diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 00000000..d6279fc1 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,22 @@ +{ + "name": "linearis", + "owner": { + "name": "linearis-oss", + "url": "https://github.com/linearis-oss/linearis" + }, + "metadata": { + "description": "Marketplace for the linearis Claude Code plugin.", + "version": "1.0.0" + }, + "plugins": [ + { + "name": "linearis", + "source": "./", + "description": "Agent skill teaching agents to use the linearis Linear.app CLI.", + "version": "1.0.0", + "author": { "name": "linearis-oss" }, + "homepage": "https://github.com/linearis-oss/linearis", + "license": "MIT" + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 00000000..6f33d71f --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "linearis", + "description": "Agent skill for the linearis Linear.app CLI: preflight, discover-then-act usage protocol, JSON output, ID resolution, discussions, files.", + "version": "1.0.0", + "author": { + "name": "linearis-oss", + "url": "https://github.com/linearis-oss/linearis" + }, + "homepage": "https://github.com/linearis-oss/linearis", + "license": "MIT" +} diff --git a/.conductor/settings.toml b/.conductor/settings.toml new file mode 100644 index 00000000..91fd5e1a --- /dev/null +++ b/.conductor/settings.toml @@ -0,0 +1,137 @@ +#:schema https://conductor.build/schemas/settings.repo.schema.json +"$schema" = "https://conductor.build/schemas/settings.repo.schema.json" + +[scripts] +# `npm install` also runs the `prepare` hook: GraphQL codegen (src/gql/) + lefthook install. +setup = "npm install" +# Stateless JSON-output CLI — no shared port/db/stack — so workspaces can run side by side. +run_mode = "concurrent" +# Remove per-workspace build output on archive; leaves source untouched. +archive = "npm run clean" + +[scripts.run.test] +command = "npm test" +default = true +icon = "test-tube" + +[scripts.run.build] +command = "npm run build" +icon = "package" + +[scripts.run.check] +command = "npm run check:ci" +icon = "wrench" + +[prompts] +# Appended to EVERY agent session. Keep lean — AGENTS.md (loaded as CLAUDE.md) is the +# single source of truth for architecture, the P0 invariants, and the verification +# checklist; do not restate them here. +general = """ +You are a senior engineer on Linearis, a JSON-only CLI for Linear.app (TypeScript strict, \ +ESM). Always respond in English, even when addressed in another language. + +AGENTS.md (loaded as CLAUDE.md) is authoritative for the 5-layer architecture, the P0 \ +invariants, and the verification checklist — follow it; do not re-derive or restate its rules. + +Be a careful git operator. Before starting substantial work, bring the branch up to date \ +with its base branch (the branch it will merge into) — fetch, then rebase onto it rather \ +than merging it in — so your changes apply cleanly and the history stays linear. + +Treat the commit history as durable context for future contributors, held to the same \ +standard as the code. Make each commit well-scoped and single-purpose, and give it an \ +extended body explaining not just what changed but why: the problem, the approach, and any \ +trade-offs or alternatives considered. Any TODO you leave must reference an issue. + +Verify with the AGENTS.md checklist, scoped to what you touched, and report exactly what you \ +ran — never claim a check passed without running it. Skip the build/test checklist for \ +docs-, comment-, or config-only changes; run the full checklist before opening a PR. + +Never touch CHANGELOG.md — it is owned by the release workflow. +""" + +# Commit history is a first-class deliverable. Conventional Commits are enforced by +# commitlint in CI over the whole PR range, so malformed messages fail the build. +create_pr = """ +In addition to Conductor's standard Create PR behavior, hold the commit history and PR to \ +these repo rules. + +Commits — Conventional Commits, enforced by commitlint over the full PR range: +- Format `type(scope): subject`; lower-case scope; imperative subject, >= 10 chars. +- Allowed types only: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test. +- Choose the type by real impact: feat/fix/perf/revert trigger a release, the rest do not — \ + do not inflate a chore into a feat. +- One logical change per commit; a blank line before any body/footer. Reference issues in the \ + body (Closes #n, Refs #n, Part of #n) when applicable. +- Give every non-trivial commit an extended body written as context for future contributors: \ + what changed and, more importantly, why — the problem, the approach, and any trade-offs or \ + alternatives considered. Structure it (short prose or bullets); do not just restate the subject. +- Do NOT add AI co-author or Co-authored-by trailers. +- Never include CHANGELOG.md in the branch history — it is release-workflow-owned. + +History hygiene — before opening the PR, rework the branch into a clean, linear history: +- Rebase onto the latest base branch (resolve conflicts; do not merge the base in). +- Reshape the commits into well-isolated, correctly-scoped Conventional Commits: split commits \ + that mix concerns, squash fixup/wip noise, and reorder so each commit stands on its own, \ + builds, and passes its own checks. +- Ensure each resulting commit carries the extended what/why body above. +- Only rewrite history that is yours and unmerged — never rewrite commits already on the base \ + branch or that teammates build on. + +PR: +- The title must itself be a valid Conventional Commit subject. +- Body: what changed and why, the risk/impact, and which AGENTS.md checks you ran to verify. \ + Keep it tight and skimmable. +- Remove design artifacts (e.g. plan files under docs/plans/) before opening the PR. +""" + +# Correctness first, then the repo's own invariants; reference AGENTS.md rather than +# transcribing the full (drift-prone) invariant list. +code_review = """ +In addition to Conductor's standard review, report only real, verifiable issues, most severe \ +first. Do not pad with style nitpicks the linter already covers. + +Prioritise: +- Correctness and reliability: edge cases, error handling, resource/lifecycle bugs, races, \ + wrong assumptions. +- Every P0 invariant in AGENTS.md — especially strict layer separation, ID resolution only in \ + resolvers, no `any`, and explicit return types on exports. +- Test coverage: happy path + primary error case per changed function; tests mock exactly one \ + layer down. +- Maintainability and readability: naming, duplication, unnecessary complexity. +- Commit history: a clean, linear series of well-scoped Conventional Commits with informative \ + what/why bodies — flag mixed-concern, wip, or fixup commits that should be split, squashed, \ + or rebased before merge. +For each finding give file:line, why it is wrong, and a concrete fix. If the change is sound, \ +say so plainly rather than inventing problems. +""" + +# Root cause, minimal fix, verification scaled to the size of the change. +fix_errors = """ +Diagnose the root cause before changing anything — do not patch symptoms or silence the type \ +checker with casts. Make the smallest correct fix, matching surrounding style. When the bug is \ +testable, add or adjust a test that fails before the fix and passes after. Verify with the \ +AGENTS.md checklist scoped to the fix: always run the type check and tests; add the full build \ +and knip when the fix touches generated code, exports, or dependencies. Report exactly what you \ +ran and its result. If chasing the fix left the branch history messy (wip/fixup commits, mixed \ +concerns), fold it back into clean, well-scoped Conventional Commits with what/why bodies before \ +finishing. +""" + +# Never silently drop a side; regenerate generated code from resolved sources, in that order. +resolve_merge_conflicts = """ +Resolve conflicts by understanding the intent of both sides, not by picking one blindly. \ +Preserve every behavioural change from both branches; if two changes are genuinely \ +incompatible, stop and surface it rather than dropping one. For conflicts in generated files \ +(src/gql/), resolve the .graphql sources first, then run `npm run generate` to regenerate \ +rather than hand-merging. Never resurrect CHANGELOG.md edits. After resolving, run \ +`npx tsc --noEmit` and `npm test` to confirm the merged tree is coherent. +""" + +# Conventional-Commit-aligned, descriptive, kebab-case; issue number first when one exists. +rename_branch = """ +Name the branch after the change: `type/short-kebab-summary`, where type is a Conventional \ +Commit type (feat, fix, chore, docs, refactor, ...). Lower-case, hyphen-separated, concise but \ +descriptive. When an issue exists, put its number first: `type/NNN-short-summary` \ +(e.g. fix/142-null-token, feat/issue-search-filters). Conductor may add its own branch prefix; \ +keep the descriptive part in this form regardless. +""" diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 00000000..e6738a78 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,47 @@ +name: Setup Node and install +description: >- + Sets up Node (version from .nvmrc), restores the npm cache, runs `npm ci`, and + optionally builds the project. Centralizes the checkout-adjacent boilerplate + shared by every CI job. The caller must run actions/checkout first so this + local action and .nvmrc are present on disk. + +inputs: + build: + description: Whether to run `npm run build` after install (runs codegen + tsc + usage). + required: false + default: "true" + node-version: + description: >- + Explicit Node version to use, overriding .nvmrc. Leave empty to use the + project default from .nvmrc. Set by matrix jobs that test multiple versions. + required: false + default: "" + registry-url: + description: Optional npm registry URL to configure for publishing (leave empty for CI-only jobs). + required: false + default: "" + +runs: + using: composite + steps: + # node-version (when set by a matrix job) takes precedence over + # node-version-file; an empty node-version falls back to .nvmrc. + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: ${{ inputs.node-version }} + node-version-file: .nvmrc + cache: npm + registry-url: ${{ inputs.registry-url }} + + # `npm ci` runs `prepare`, which is CI-guarded (scripts/prepare.mjs) so it + # does NOT hit the live Linear GraphQL schema here. Jobs needing generated + # types must run the build step below (or `npm pack`). + - name: Install dependencies + shell: bash + run: npm ci + + - name: Build project + if: ${{ inputs.build == 'true' }} + shell: bash + run: npm run build diff --git a/.github/workflows/ci-post-merge.yml b/.github/workflows/ci-post-merge.yml index 8a2a6e5d..eaa7fa25 100644 --- a/.github/workflows/ci-post-merge.yml +++ b/.github/workflows/ci-post-merge.yml @@ -15,24 +15,15 @@ concurrency: jobs: sentinel: - name: Post-merge sentinel (node v22) + name: Post-merge sentinel runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Setup node v22 - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: npm - - - name: Install deps - run: npm ci - - - name: Build project - run: npm run build + - name: Setup and build + uses: ./.github/actions/setup - name: Verify packed binaries run: npm run verify:packed-binaries diff --git a/.github/workflows/ci-validate.yml b/.github/workflows/ci-validate.yml index b5562e04..e9a07004 100644 --- a/.github/workflows/ci-validate.yml +++ b/.github/workflows/ci-validate.yml @@ -10,6 +10,10 @@ on: - synchronize - ready_for_review - reopened + # `edited` re-runs validation when the PR base branch changes, since the + # base-dependent guards below (changelog/plan-file history) compare + # against it. It also fires on title/body edits — a small amount of extra + # runs we accept to keep base-change reruns correct. - edited permissions: @@ -23,53 +27,32 @@ jobs: test: name: Run Unit Tests on Node v${{ matrix.node-version }} runs-on: ubuntu-latest - strategy: + fail-fast: false matrix: - node-version: [22] - + # 22 = engines support floor; 24 = project default (.nvmrc). + node-version: [22, 24] steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Setup node v${{ matrix.node-version }} - uses: actions/setup-node@v6 + - name: Setup and build + uses: ./.github/actions/setup with: node-version: ${{ matrix.node-version }} - cache: "npm" - - - name: Install deps - run: npm ci - - - name: Build project - run: npm run build - name: Run unit tests run: npm test lint: - strategy: - matrix: - node-version: [22] - - name: Run Code Checks on Node v${{ matrix.node-version }} + name: Run Code Checks runs-on: ubuntu-latest - steps: - name: Checkout code - uses: actions/checkout@v6 - - - name: Setup node v${{ matrix.node-version }} - uses: actions/setup-node@v6 - with: - node-version: ${{ matrix.node-version }} - cache: "npm" - - - name: Install deps - run: npm ci + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Build project - run: npm run build + - name: Setup and build + uses: ./.github/actions/setup - name: Biome check run: npm run check:ci @@ -77,24 +60,116 @@ jobs: - name: TypeScript type check run: npx tsc --noEmit + - name: TypeScript type check (tests) + run: npm run typecheck:test + + actionlint: + name: Lint Workflows + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Run actionlint + uses: docker://rhysd/actionlint@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 # v1.7.12 + with: + args: -color + env: + # SC2016 (single-quoted `${...}` don't expand) fires on the many + # intentional literal strings we echo into PR comments (markdown + + # bash examples that must stay literal). Exclude just that one + # info-level rule; all other shellcheck findings still fail the job. + SHELLCHECK_OPTS: --exclude=SC2016 + + knip: + name: Detect Dead Code + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + # Build runs codegen (prebuild) so src/gql exists and imports resolve. + - name: Setup and build + uses: ./.github/actions/setup + + - name: Run knip + id: knip + run: | + set +e + npm run --silent knip:ci > knip-report.md + echo "exit_code=$?" >> "$GITHUB_OUTPUT" + set -e + + - name: Compose PR comment + if: always() + run: | + if [ "${{ steps.knip.outputs.exit_code }}" = "0" ]; then + { + echo '## ✅ knip — no dead code' + echo + echo 'No unused files, exports, types, or dependencies detected.' + } > knip-comment.md + else + { + echo '## 🧹 knip found dead code' + echo + cat knip-report.md + echo + echo '### Fix it locally' + echo + echo '```bash' + echo 'npm run generate # ensure generated GraphQL types exist' + echo 'npm run knip # see the full report' + echo 'npx knip --fix --allow-remove-files # auto-remove unused exports/files, then review the diff' + echo '```' + echo + echo 'If a finding is a false positive (dynamically-wired code knip cannot trace), add a narrow entry to `knip.json` explaining why.' + } > knip-comment.md + fi + + - name: Save PR number + if: always() + run: echo "${{ github.event.pull_request.number }}" > pr-number.txt + + # GITHUB_TOKEN is forced read-only for pull_request events from forks, + # so this job can't post the comment itself regardless of permissions:. + # Upload the rendered comment + PR number as an artifact; a separate + # workflow_run-triggered workflow (which does get a writable token) + # downloads it and posts the comment. See .github/workflows/knip-comment.yml. + - name: Upload knip comment artifact + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: knip-comment + path: | + knip-comment.md + pr-number.txt + retention-days: 1 + + - name: Fail if dead code found + if: steps.knip.outputs.exit_code != '0' + run: | + echo "::error::knip found dead code — see the PR comment for details" + exit 1 + commitlint: name: Validate Commit Messages runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 - - name: Setup node v22 - uses: actions/setup-node@v6 + - name: Setup (no build) + uses: ./.github/actions/setup with: - node-version: 22 - cache: "npm" - - - name: Install deps - run: npm ci + build: "false" - name: Validate PR commit range run: | @@ -110,16 +185,14 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Setup Node.js - uses: actions/setup-node@v6 + # No build here: verify:packed-binaries runs `npm pack`, whose `prepack` + # builds (and generates) the package the same way a consumer install would. + - name: Setup (no build) + uses: ./.github/actions/setup with: - node-version: 24 - cache: "npm" - - - name: Install deps - run: npm ci + build: "false" - name: Verify packed binaries run: npm run verify:packed-binaries @@ -132,7 +205,7 @@ jobs: contents: read pull-requests: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 - name: Check for docs/plans/*.md in branch history @@ -147,23 +220,29 @@ jobs: if [ -n "$plan_files" ]; then echo "::error::Found plan files in branch history" - printf '%s\n' \ - '' \ - '' \ - '> [!WARNING]' \ - '> Found `docs/plans/*.md` files in this branch'"'"'s history.' \ - '>' \ - '> Plan files in `docs/plans/` are working artifacts created by AI agents during the design phase. Once the implementation they describe is complete and the PR is ready for review, these files serve no further purpose — they are not reference docs, not changelogs, and not part of the shipped project.' \ - '>' \ - '> Leaving them in the commit history would add noise and suggest unresolved or incomplete work.' \ - '>' \ - '> **Remove them by rebasing and dropping the commits that introduced them:**' \ - '> ```bash' \ - '> git rebase -i main' \ - '> # drop the commits that added docs/plans/*.md, then force-push' \ - '> git push --force-with-lease' \ - '> ```' \ - | gh pr comment ${{ github.event.pull_request.number }} --body-file - + # GITHUB_TOKEN is forced read-only for pull_request events from + # forks, so posting a comment would fail there regardless of + # permissions:. The ::error:: above still surfaces in the checks + # UI, so skip the comment rather than fail this step on forks. + if [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then + printf '%s\n' \ + '' \ + '' \ + '> [!WARNING]' \ + '> Found `docs/plans/*.md` files in this branch'"'"'s history.' \ + '>' \ + '> Plan files in `docs/plans/` are working artifacts created by AI agents during the design phase. Once the implementation they describe is complete and the PR is ready for review, these files serve no further purpose — they are not reference docs, not changelogs, and not part of the shipped project.' \ + '>' \ + '> Leaving them in the commit history would add noise and suggest unresolved or incomplete work.' \ + '>' \ + '> **Remove them by rebasing and dropping the commits that introduced them:**' \ + '> ```bash' \ + '> git rebase -i main' \ + '> # drop the commits that added docs/plans/*.md, then force-push' \ + '> git push --force-with-lease' \ + '> ```' \ + | gh pr comment ${{ github.event.pull_request.number }} --body-file - + fi exit 1 fi @@ -173,10 +252,8 @@ jobs: name: Guard CHANGELOG History in PR runs-on: ubuntu-latest if: github.event_name == 'pull_request' - permissions: - contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 diff --git a/.github/workflows/knip-comment.yml b/.github/workflows/knip-comment.yml new file mode 100644 index 00000000..bab79f04 --- /dev/null +++ b/.github/workflows/knip-comment.yml @@ -0,0 +1,54 @@ +name: Post Knip Comment + +# Runs after "Validate CI" completes, in the base repo's context (so it gets +# a writable GITHUB_TOKEN even for fork PRs) instead of running with the +# PR's own code. See the knip job in ci-validate.yml for the untrusted half +# of this split — it builds/runs the PR's code with a read-only token and +# uploads the rendered comment as an artifact for this workflow to post. +on: + workflow_run: + workflows: + - Validate CI + types: + - completed + +permissions: + contents: read + +jobs: + comment: + name: Post / Update Knip PR Comment + runs-on: ubuntu-latest + # Run for PR builds regardless of pass/fail: the knip job intentionally + # fails "Validate CI" when it finds dead code, and that's exactly when the + # comment matters most. The artifact is uploaded with `if: always()` before + # that failing step, so it exists on both success and failure. Only skip + # `cancelled` runs (e.g. superseded by cancel-in-progress concurrency), + # which may never reach the upload step and thus have no artifact. + if: >- + github.event.workflow_run.event == 'pull_request' + && github.event.workflow_run.conclusion != 'cancelled' + permissions: + # actions: read is required for download-artifact to fetch the artifact + # from a different workflow run (via run-id); pull-requests: write is for + # posting the comment. + actions: read + pull-requests: write + steps: + - name: Download knip comment artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: knip-comment + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Read PR number + id: pr + run: echo "number=$(cat pr-number.txt)" >> "$GITHUB_OUTPUT" + + - name: Post / update PR comment + uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 + with: + header: knip-dead-code + path: knip-comment.md + number: ${{ steps.pr.outputs.number }} diff --git a/.github/workflows/release-promote-next-to-main.yml b/.github/workflows/release-promote-next-to-main.yml index 3cf401cb..175daa2b 100644 --- a/.github/workflows/release-promote-next-to-main.yml +++ b/.github/workflows/release-promote-next-to-main.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: next fetch-depth: 0 @@ -48,7 +48,7 @@ jobs: - name: Generate linearis-bot app token if: ${{ steps.commits-check.outputs.has_commits == 'true' }} id: app-token - uses: actions/create-github-app-token@v3 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ secrets.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index a2fa6816..47510de9 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Guard workflow_dispatch caller permissions if: ${{ github.event_name == 'workflow_dispatch' }} - uses: actions/github-script@v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const { owner, repo } = context.repo; @@ -65,7 +65,7 @@ jobs: echo "Releasing from branch: $branch" - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 ref: ${{ steps.target.outputs.branch }} @@ -73,7 +73,7 @@ jobs: - name: Create linearis-bot app token id: app-token - uses: actions/create-github-app-token@v3 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ secrets.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} @@ -97,34 +97,22 @@ jobs: git config user.name "${{ steps.app-bot.outputs.name }}" git config user.email "${{ steps.app-bot.outputs.email }}" - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: npm - registry-url: https://registry.npmjs.org - - - name: Install dependencies - run: npm ci - - - name: Build - run: npm run build + # No registry-url: it writes _authToken into .npmrc, shadowing OIDC. + - name: Setup, install and build + uses: ./.github/actions/setup - - name: Verify npm auth - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # OIDC trusted publishing needs npm >= 11.5.1; .nvmrc pins only Node major. + - name: Ensure npm supports trusted publishing + shell: bash run: | - test -n "${NODE_AUTH_TOKEN}" || { - echo "NPM_TOKEN missing (check job environment + secret scope)" - exit 1 - } - npm whoami --registry=https://registry.npmjs.org/ + npm install -g npm@^11.5.1 + npm --version + # Publishes via OIDC (no NODE_AUTH_TOKEN); GH_TOKEN is only for the release + push. - name: Run semantic-release env: GH_TOKEN: ${{ steps.app-token.outputs.token }} GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} GITHUB_REF: refs/heads/${{ steps.target.outputs.branch }} GITHUB_REF_NAME: ${{ steps.target.outputs.branch }} run: npm run release:run diff --git a/.github/workflows/release-sync-main-back-to-next.yml b/.github/workflows/release-sync-main-back-to-next.yml index 0694fabb..569b09ce 100644 --- a/.github/workflows/release-sync-main-back-to-next.yml +++ b/.github/workflows/release-sync-main-back-to-next.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout next - uses: actions/checkout@v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: next fetch-depth: 0 @@ -27,7 +27,7 @@ jobs: - name: Create linearis-bot app token id: app-token - uses: actions/create-github-app-token@v3 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ secrets.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/.gitignore b/.gitignore index 878e3130..8760a204 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ /src/gql/ USAGE.md +# clean-publish staging dir (release publish; see .releaserc.cjs) +/.clean-pkg/ + docs/superpowers/ # pi diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..a45fd52c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/.releaserc.cjs b/.releaserc.cjs index 310ab0b5..4c961fa4 100644 --- a/.releaserc.cjs +++ b/.releaserc.cjs @@ -35,8 +35,25 @@ module.exports = { [ "@semantic-release/exec", { - publishCmd: - 'npx clean-publish --access public --tag $( [ "$GITHUB_REF_NAME" = "next" ] && echo next || echo latest ) -- --provenance', + // Two stages so a failed publish fails the release: clean-publish + // swallows exit codes, so it only stages .clean-pkg (--without-publish) + // and the real `npm publish` runs separately and can propagate failure. + publishCmd: [ + "set -e", + "npx clean-publish --without-publish --temp-dir .clean-pkg", + 'VERSION="$(node -p "require(\'./.clean-pkg/package.json\').version")"', + 'TAG="$([ "$GITHUB_REF_NAME" = next ] && echo next || echo latest)"', + 'npm publish ./.clean-pkg --provenance --access public --tag "$TAG"', + // Read-back guard: fail if the version is not visible on the + // registry (the outage was a publish that "succeeded" but published + // nothing); retry for read-after-write lag. + "for i in $(seq 1 6); do", + ' if npm view "linearis@$VERSION" version; then FOUND=1; break; fi', + ' echo "waiting for registry to reflect $VERSION ($i/6)"; sleep 5', + "done", + '[ "$FOUND" = 1 ] || { echo "publish verification failed: linearis@$VERSION not on registry"; exit 1; }', + "rm -rf .clean-pkg", + ].join("\n"), }, ], ["@semantic-release/github", { successComment: false, failComment: false }], diff --git a/AGENTS.md b/AGENTS.md index 82724d7a..4605c8b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,16 +42,16 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full commit types table and examp ``` CLI Input → Command → Resolver → Service → JSON Output │ │ │ - createContext SDK GraphQL + createContext GraphQL GraphQL (UUID) (data) ``` | Layer | Directory | Client | Responsibility | |-----------|-----------------|---------------------|--------------------------------------| | Client | `src/client/` | — | Thin API wrappers, no logic | -| Resolver | `src/resolvers/` | `LinearSdkClient` | Human ID → UUID conversion | +| Resolver | `src/resolvers/` | `GraphQLClient` | Human ID → UUID conversion | | Service | `src/services/` | `GraphQLClient` | Business logic, CRUD via GraphQL | -| Command | `src/commands/` | Both via `createContext()` | CLI orchestration only | +| Command | `src/commands/` | `GraphQLClient` via `createContext()` | CLI orchestration only | | Common | `src/common/` | — | Shared types, errors, output, auth | ### Invariants (P0 — violations fail CI/review) @@ -59,12 +59,12 @@ CLI Input → Command → Resolver → Service → JSON Output 1. **No `any` types.** Use `unknown`, codegen types, or explicit interfaces. 2. **Strict layer separation.** No cross-layer imports: - Resolvers must not import services (or vice versa). - - Commands must not import `GraphQLClient` directly. + - Commands must not construct `GraphQLClient` directly — use `createContext()`. 3. **Client-layer contract:** - - Resolvers → `LinearSdkClient` by default. - - Services → `GraphQLClient` only. - - Commands → both, via `createContext()`. - - **Narrow exceptions allowed only when SDK lacks required capability**, with explicit `ARCHITECTURAL EXCEPTION` docstring in code (current examples: milestone/project-status lookups, initiative relation/link ID lookup helpers). + - Resolvers → `GraphQLClient` (via lean filter-based lookup queries). + - Services → `GraphQLClient`. + - Commands → `GraphQLClient` via `createContext()` (`ctx.gql`). + - Resolvers should prefer the lean lookup fragments; when the Linear API exposes no lean lookup for an entity, a resolver may query it directly with an explicit `ARCHITECTURAL EXCEPTION` docstring (current examples: milestone/project-status lookups, initiative relation/link ID lookup helpers). 4. **ID resolution happens once**, in resolvers only. Services accept UUIDs. 5. **All commands** use `handleCommand()` wrapper and `outputSuccess()` for output. 6. **Explicit return types** on all exported functions. @@ -84,9 +84,9 @@ Need a new GraphQL operation? Need to resolve a human-friendly ID? → Add/edit src/resolvers/*-resolver.ts - → Prefer LinearSdkClient, return UUID string - → Pattern: UUID passthrough → SDK lookup → notFoundError() - → If SDK cannot express lookup, use GraphQL as documented ARCHITECTURAL EXCEPTION (include rationale in resolver docstring) + → Use GraphQLClient with a lean lookup query, return UUID string + → Pattern: UUID passthrough → GraphQL filter lookup → notFoundError() + → If no lean lookup fragment exists, query directly as a documented ARCHITECTURAL EXCEPTION (include rationale in resolver docstring) Need business logic / CRUD? → Add/edit src/services/*-service.ts @@ -110,7 +110,7 @@ Tests mirror `src/` structure under `tests/unit/`. Mock the dependency one layer | Test target | Mock | Example | |-------------|------|---------| -| Resolver | `LinearSdkClient` (mock `sdk.*`) | `{ sdk: { teams: vi.fn() } } as unknown as LinearSdkClient` | +| Resolver | `GraphQLClient` (mock `request`) | `{ request: vi.fn() } as unknown as GraphQLClient` | | Service | `GraphQLClient` (mock `request`) | `{ request: vi.fn() } as unknown as GraphQLClient` | | Common | No mocks (pure functions) | Direct import + assert | @@ -136,12 +136,14 @@ async function createIssue(client: GraphQLClient, teamName: string) { async function createIssue(client: GraphQLClient, input: { teamId: string }) { ... } ``` -**Wrong client in layer:** +**ID resolution in command:** ```typescript -// WRONG: resolver uses GraphQLClient -async function resolveTeamId(client: GraphQLClient) { ... } -// RIGHT: resolver uses LinearSdkClient -async function resolveTeamId(client: LinearSdkClient) { ... } +// WRONG: command builds a raw mutation instead of delegating +async function resolveTeamId(client: GraphQLClient, teamName: string) { + const teamId = /* inline lookup in the command */; +} +// RIGHT: resolvers own ID resolution, services own CRUD +async function resolveTeamId(client: GraphQLClient, teamName: string): Promise { ... } ``` **Business logic in command:** @@ -152,7 +154,7 @@ async function resolveTeamId(client: LinearSdkClient) { ... } })) // RIGHT: command delegates .action(handleCommand(async (title, opts) => { - const teamId = await resolveTeamId(ctx.sdk, opts.team); + const teamId = await resolveTeamId(ctx.gql, opts.team); const result = await createIssue(ctx.gql, { title, teamId }); outputSuccess(result); })) @@ -195,7 +197,7 @@ Registration checklist: ``` src/ main.ts # entry point, command registration - client/ # GraphQLClient, LinearSdkClient + client/ # GraphQLClient resolvers/ # ID resolution (human → UUID) services/ # business logic (GraphQL CRUD) commands/ # CLI definitions (Commander.js) @@ -218,9 +220,16 @@ npm run check:ci # biome lint + format check npx tsc --noEmit # type check npm test # unit tests npm run build # full build (includes codegen + usage generation) +npm run knip # dead-code check (unused files/exports/types/deps) ``` -All four must pass. CI runs these on every push and PR. +All five must pass. CI runs the first four on every push and PR; the `knip` +check runs on PRs only, where it is required — it posts a self-updating comment +listing any dead code, and `npx knip --fix --allow-remove-files` auto-removes +most findings. Run +`npm run generate` (or `npm run build`) first so generated GraphQL types exist. +Genuine false positives (dynamically-wired code knip cannot trace) are suppressed +in `knip.json`, not left unaddressed. ## Extended Documentation diff --git a/CHANGELOG.md b/CHANGELOG.md index 628e2d7f..06257b25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,108 @@ +## [2026.6.0-next.13](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.12...v2026.6.0-next.13) (2026-07-04) + +### Features + +* **teams:** add team create, update, and membership management ([c89208b](https://github.com/linearis-oss/linearis/commit/c89208bc7a14514da62caec25364207fa29fbb18)), closes [#142](https://github.com/linearis-oss/linearis/issues/142) + +## [2026.6.0-next.12](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.11...v2026.6.0-next.12) (2026-07-04) + +### Features + +* **issues:** add activity command with threaded discussion timeline ([38a3ea3](https://github.com/linearis-oss/linearis/commit/38a3ea3379a7c92eba728d0d5ee7537dbd2a452b)), closes [#144](https://github.com/linearis-oss/linearis/issues/144) + +## [2026.6.0-next.12](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.11...v2026.6.0-next.12) (2026-07-04) + +### Features + +* **issues:** add activity command with threaded discussion timeline ([38a3ea3](https://github.com/linearis-oss/linearis/commit/38a3ea3379a7c92eba728d0d5ee7537dbd2a452b)), closes [#144](https://github.com/linearis-oss/linearis/issues/144) + +## [2026.6.0-next.11](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.10...v2026.6.0-next.11) (2026-07-03) + +### Features + +* **skill:** add agent skill and Claude Code plugin for the CLI ([1233ad9](https://github.com/linearis-oss/linearis/commit/1233ad995e858ee61b925ac580e4b231c6dfa1c8)) + +### Bug Fixes + +* **skill:** comma-separate allowed-tools in SKILL.md frontmatter ([66db248](https://github.com/linearis-oss/linearis/commit/66db24863e1669ac2dad88aa9fc60ca0cfdd0e31)) + +## [2026.6.0-next.10](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.9...v2026.6.0-next.10) (2026-07-03) + +### Bug Fixes + +* **resolvers:** restore global cycle fallback in search without team ([c0cbc95](https://github.com/linearis-oss/linearis/commit/c0cbc95889c4b7aaafe7e3281e9c8458bb71fefe)), closes [#126](https://github.com/linearis-oss/linearis/issues/126) + +### Performance Improvements + +* **issues:** batch-resolve assignee and IDs in create/update ([819c045](https://github.com/linearis-oss/linearis/commit/819c0451c909bbe2c5d297b6b584ce4da1358391)), closes [#126](https://github.com/linearis-oss/linearis/issues/126) + +## [2026.6.0-next.9](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.8...v2026.6.0-next.9) (2026-07-03) + +### Bug Fixes + +* **retry:** retry native fetch transport failures ([6fc8e64](https://github.com/linearis-oss/linearis/commit/6fc8e64ea83b45ee316307dd14701cb3df696300)), closes [#207](https://github.com/linearis-oss/linearis/issues/207) + +## [2026.6.0-next.8](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.7...v2026.6.0-next.8) (2026-07-02) + +### Features + +* **labels:** add issue label CRUD commands ([5fca207](https://github.com/linearis-oss/linearis/commit/5fca207d96e597ef0e755bb0e1eb0135deeae47e)) +* **labels:** support label removal modes ([c6cfa62](https://github.com/linearis-oss/linearis/commit/c6cfa627972f825868b8868c50057d7c15e906a6)) + +### Bug Fixes + +* **labels:** allow clearing label description with empty string ([9a28108](https://github.com/linearis-oss/linearis/commit/9a2810813753f5f3a3e836f5a02d0ceed2eee433)) + +## [2026.6.0-next.7](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.6...v2026.6.0-next.7) (2026-07-02) + +### Features + +* **cli:** add passive update notifier and version command ([6ca3952](https://github.com/linearis-oss/linearis/commit/6ca39522a0845cb84af0ed14cdfd5ba5de23677e)) + +## [2026.6.0-next.6](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.5...v2026.6.0-next.6) (2026-07-02) + +### Features + +* **projects:** restore project workflow options ([1f35779](https://github.com/linearis-oss/linearis/commit/1f35779fb07b42beb30d13a9e96917aa2d63d03e)) + +### Bug Fixes + +* **ci:** skip PR comment posting for fork pull requests ([390dd38](https://github.com/linearis-oss/linearis/commit/390dd385cded3c3249e3ab9ad13864b1d377b810)) + +## [2026.6.0-next.5](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.4...v2026.6.0-next.5) (2026-07-02) + +### Features + +* restore document attachment compatibility ([f096add](https://github.com/linearis-oss/linearis/commit/f096adda7aa20c946a25c7c06fefec56d9684975)) + +## [2026.6.0-next.4](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.3...v2026.6.0-next.4) (2026-07-02) + +### Features + +* **output:** add --compact and --fields flags for token-efficient output ([682bde9](https://github.com/linearis-oss/linearis/commit/682bde9489176b4dffa26f3e4fd40bdb633fe77c)), closes [#220](https://github.com/linearis-oss/linearis/issues/220) + +### Bug Fixes + +* **output:** harden pickFields against prototype-chain keys ([a624313](https://github.com/linearis-oss/linearis/commit/a624313e0d1cb74ed77ddbf1f87a24c3262cdc72)), closes [#220](https://github.com/linearis-oss/linearis/issues/220) + +## [2026.6.0-next.3](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.2...v2026.6.0-next.3) (2026-07-02) + +### Bug Fixes + +* **milestones:** use $input convention for milestone mutations ([309aaab](https://github.com/linearis-oss/linearis/commit/309aaab662ab0dc79731f2c288f97d7b75d74caf)), closes [#223](https://github.com/linearis-oss/linearis/issues/223) + +## [2026.6.0-next.2](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.1...v2026.6.0-next.2) (2026-07-02) + +### Features + +* **issues:** restore relation commands ([e0f880c](https://github.com/linearis-oss/linearis/commit/e0f880c8e700b889b0565cae787675c62b3c3eb8)) + +## [2026.6.0-next.1](https://github.com/linearis-oss/linearis/compare/v2026.5.0...v2026.6.0-next.1) (2026-06-16) + +### Bug Fixes + +* **discussions:** forward parent entity id when replying to a thread ([745ca56](https://github.com/linearis-oss/linearis/commit/745ca56465e51f2981740a1153eedc01a40e0431)), closes [#226](https://github.com/linearis-oss/linearis/issues/226) + ## [2026.5.0](https://github.com/linearis-oss/linearis/compare/v2026.4.9...v2026.5.0) (2026-06-16) ### Bug Fixes diff --git a/MIGRATION_2026.4.9.md b/MIGRATION_2026.4.9.md new file mode 100644 index 00000000..d7c6b06c --- /dev/null +++ b/MIGRATION_2026.4.9.md @@ -0,0 +1,46 @@ +# Migration Guide — v2026.4.9: `comments` → discussions + +**Introduced in:** v2026.4.9 (2026-04-27) · **Status of `comments`:** deprecated compatibility facade + +## What changed + +Before v2026.4.9, Linearis exposed a flat `comments` domain: a single list of comments per issue, with replies that were hard to relate back to their parent. + +v2026.4.9 replaces that with **discussions** — threaded conversations modeled the way Linear itself models them. A discussion is a **root thread** with a body, and each root thread has an ordered list of **replies** (which may themselves be nested). Discussions are available across multiple domains (`issues`, `projects`, `initiatives`), not just issues. + +The old `comments` commands still work — they now route through the discussion service as a **deprecated compatibility facade** — so existing scripts keep running. New automation and agent prompts should use the discussion commands directly. + +## Why the change + +- **Faithful data model.** Linear's API represents conversations as threads with replies. The old flat `comments` view flattened that structure and lost the parent/child relationship. Discussions expose it directly. +- **Nested replies.** Deep reply chains are now represented correctly instead of being collapsed into one level. +- **Consistency across domains.** The same discussion model applies to issues, projects, and initiatives, so agents learn one pattern instead of an issue-only special case. +- **Reactions.** v2026.4.9 also added reaction workflows on discussions, which the flat comment model could not express cleanly. + +## Command mapping + +| Deprecated (`comments`) | Preferred (`issues`) | +|---|---| +| `linearis comments create --body ` | `linearis issues discuss --body ` | +| `linearis comments list ` | `linearis issues discussions ` | +| `linearis comments reply --body ` | `linearis issues reply --body ` | +| `linearis comments edit --body ` | `linearis issues edit-reply --body ` | +| `linearis comments delete ` | `linearis issues delete-reply ` | + +Projects and initiatives expose the equivalent discussion subcommands in their own domains — run `linearis projects usage` or `linearis initiatives usage` for the exact commands. + +## What to respect + +- **Root threads vs. replies are distinct.** `issues discussions ` lists **root** threads. Replying with `issues reply ` requires a **root discussion thread ID**, not a reply ID. Passing a reply ID where a root thread ID is expected will fail. +- **Fetch replies explicitly.** `issues discussions ` returns root threads only. Use `issues replies ` to load the replies within one thread, including nested replies. +- **Discussions are domain-scoped.** A thread belongs to the domain it was created in. Operating on a thread through the wrong domain is rejected. +- **Compatibility facade is more lenient.** The deprecated `comments edit`/`delete` commands accept both root thread IDs and reply IDs. The new discussion commands are strict about which ID they expect — do not assume the facade's leniency carries over. +- **Prefer discussions over description edits for progress.** For anything beyond simple checkbox updates, start or continue a discussion thread rather than rewriting an issue's description. + +## Timeline + +- **v2026.4.9 (2026-04-27)** — discussion commands added across `issues`, `projects`, and `initiatives`; `comments` deprecated as a compatibility facade. +- **A future release** — the `comments` facade may be removed. Migrate before then. + +> [!TIP] +> Run `linearis issues usage` for the full, always-current discussion command reference. diff --git a/README.md b/README.md index f1a2b059..dd2c1027 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,45 @@ +
+ # Linearis -CLI tool for [Linear.app](https://linear.app) optimized for AI agents. JSON output, smart ID resolution, token-efficient usage commands, and a discover-then-act workflow that keeps agent context small. Works just as well for humans who prefer structured data on the command line. +**A token-efficient [Linear.app](https://linear.app) CLI built for AI agents — and humans who like structured data.** + +[![NPM version](https://img.shields.io/npm/v/linearis.svg)](https://www.npmjs.com/package/linearis) +[![Node version](https://img.shields.io/node/v/linearis.svg)](https://nodejs.org) +[![CI](https://github.com/linearis-oss/linearis/actions/workflows/ci.yml/badge.svg)](https://github.com/linearis-oss/linearis/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.md) +[![skills.sh](https://skills.sh/b/linearis-oss/linearis)](https://skills.sh/linearis-oss/linearis) + +
+ +Linearis is a command-line interface for Linear that speaks **JSON only**. It resolves human-friendly IDs (like `ENG-42` or a team name) to UUIDs for you, and exposes a two-tier `usage` system so an agent can discover exactly the commands it needs without loading the whole API surface into context. + +```bash +npm install -g linearis +linearis auth login +linearis issues list --limit 10 +``` + +## Why Linearis? + +The official Linear MCP works well, but it costs ~13k tokens just by being connected — before an agent does anything. Linearis takes a different approach: agents discover capabilities on demand through a two-tier usage system. + +- `linearis usage` — a compact overview of every domain (~200 tokens). +- `linearis usage` — the full reference for one domain (~300–500 tokens). + +A typical agent interaction costs **~500–700 tokens** of context instead of ~13k. The agent pays only for what it uses, one domain at a time. -## Why? +> [!NOTE] +> The trade-off is coverage. Linearis focuses on the operations that matter for day-to-day work — issues, discussions, cycles, projects, documents, and files. For custom workflows, integrations, or workspace settings, the MCP is the better choice. -The official Linear MCP works fine, but it eats up ~13k tokens just by being connected -- before the agent does anything. Linearis takes a different approach: instead of exposing the full API surface upfront, agents discover what they need through a two-tier usage system. `linearis usage` gives an overview in ~200 tokens, then `linearis usage` provides the full reference for one area in ~300-500 tokens. A typical agent interaction costs ~500-700 tokens of context, not ~13k. +## Features -The trade-off is coverage. An MCP exposes the entire Linear API; Linearis covers the operations that matter for day-to-day work with issues, discussions, cycles, documents, and files. If you need to manage custom workflows, integrations, or workspace settings, the MCP is the better choice. +- **JSON-only output** — pipe into `jq`, no parsing of tables or prose. +- **Smart ID resolution** — pass `ENG-42`, a team name, or a UUID interchangeably. +- **Two-tier discovery** — self-documenting `usage` commands keep agent context small. +- **Discussion threads** — first-class root/reply modeling on issues. +- **File attachments** — upload and download with signed URLs. +- **Broad domain coverage** — issues, projects, cycles, milestones, initiatives, documents, labels, teams, users, and more. ## Installation @@ -14,58 +47,54 @@ The trade-off is coverage. An MCP exposes the entire Linear API; Linearis covers npm install -g linearis ``` -`linearis` is the canonical documented command; `linear` is a fully supported alias that runs the same CLI. - -Requires Node.js >= 22. +Requires **Node.js ≥ 22**. The `linearis` command is canonical; `linear` is a fully supported alias that runs the same CLI. ## Authentication +The interactive flow opens Linear in your browser, walks you through creating an API key, and stores it encrypted in `~/.linearis/token`: + ```bash linearis auth login ``` -This opens Linear in your browser, guides you through creating an API key, and stores the token encrypted in `~/.linearis/token`. - -Alternatively, provide a token directly: +Or provide a token directly: ```bash -# Via CLI flag -linearis --api-token issues list - -# Via environment variable -LINEAR_API_TOKEN= linearis issues list +linearis --api-token issues list # via flag +LINEAR_API_TOKEN= linearis issues list # via environment variable ``` -Token resolution order: `--api-token` flag > `LINEAR_API_TOKEN` env > `~/.linearis/token` > `~/.linear_api_token` (deprecated). +Token resolution order: `--api-token` flag → `LINEAR_API_TOKEN` env → `~/.linearis/token` → `~/.linear_api_token` (deprecated). ## Usage -All output is JSON. Pipe through `jq` or similar for formatting. +All output is JSON. Start with discovery, then act. ```bash -# Discovery -linearis usage # overview of all domains -linearis issues usage # detailed usage for one domain -``` - -### Quick Start - -```bash -# Discover available commands +# Discover what's available (~200 tokens) linearis usage -# Drill into a domain +# Drill into one domain for its full command reference linearis issues usage -# List recent issues +# List and search linearis issues list --limit 10 - -# Search for issues linearis issues search "authentication bug" # Create an issue linearis issues create "Fix login flow" --team Platform --priority 2 +# Read an issue (includes embeds with signed download URLs) +linearis issues read ENG-42 +``` + +For the complete reference of every command and flag, run `linearis usage`. + +### Discussions + +Discussions are modeled as root threads with replies, rather than a flat comment list: + +```bash # Start a discussion thread on an issue linearis issues discuss ENG-42 --body "Investigating this now" @@ -73,122 +102,91 @@ linearis issues discuss ENG-42 --body "Investigating this now" linearis issues discussions ENG-42 # List replies in one root thread -linearis issues replies 6f4f28cd-4f53-4d76-ae95-80f1b6f6b87e +linearis issues replies -# Reply to a thread (use a root discussion thread ID) -linearis issues reply 6f4f28cd-4f53-4d76-ae95-80f1b6f6b87e --body "I found the root cause" +# Reply to a thread (use a root discussion thread ID, not a reply ID) +linearis issues reply --body "I found the root cause" ``` -For the full reference of every command and flag, run: - -```bash -linearis usage -``` - -### Migration: `comments` → issue discussion commands - -The `comments` domain remains available as a **deprecated compatibility facade**. For new automation and agent prompts, migrate to issue discussion commands in the `issues` domain: +### Domains -| Deprecated | Preferred | +| Domain | What it covers | |---|---| -| `linearis comments create --body ` | `linearis issues discuss --body ` | -| `linearis comments list ` | `linearis issues discussions ` | -| `linearis comments reply --body ` | `linearis issues reply --body ` | -| `linearis comments edit --body ` | `linearis issues edit-reply --body ` | -| `linearis comments delete ` | `linearis issues delete-reply ` | +| `issues` | Work items with status, priority, assignee, labels, and discussions | +| `projects` | Groups of issues working toward a goal | +| `initiatives` | Strategic, multi-project goals | +| `cycles` | Time-boxed iterations (sprints) per team | +| `milestones` | Progress checkpoints within projects | +| `documents` | Long-form markdown docs attached to projects or issues | +| `labels` | Categorization tags for issues and projects | +| `attachments` | Linked external resources on issues (PRs, commits, URLs) | +| `files` | Upload and download file attachments | +| `teams` | Organizational units owning issues and cycles | +| `users` | Workspace members and assignees | +| `auth` | Authenticate with the Linear API | -Notes: -- `issues discussions ` lists **root** threads. -- Use `issues replies ` to fetch replies in one thread, including nested replies. -- Replying requires a **root discussion thread ID** (not a reply ID). -- Compatibility `comments edit/delete` accepts root thread IDs and reply IDs. +## AI agent integration -## AI Agent Integration +Linearis is structured around a **discover-then-act** pattern that matches how agents work: -### How agents use Linearis +1. **Discover** — `linearis usage` returns a compact overview of all domains. The agent reads it once. +2. **Drill down** — `linearis usage` gives the full reference for a single domain. The agent loads only what it needs. +3. **Execute** — every command returns structured JSON. No table or prose parsing. -The CLI is structured around a discover-then-act pattern that matches how agents work: +The agent never loads the full API surface into context — it pays for what it uses, one domain at a time. -1. **Discover** -- `linearis usage` returns a compact overview of all domains (~200 tokens). The agent reads this once to understand what's available. -2. **Drill down** -- `linearis usage` gives the full command reference for one domain (~300-500 tokens). The agent only loads what it needs. -3. **Execute** -- All commands return structured JSON. No parsing of human-readable tables or prose. - -This means the agent never loads the full API surface into context. It pays for what it uses, one domain at a time. - -### Linearis vs. MCP +### Linearis vs. Linear MCP | | Linearis | Linear MCP | |---|---|---| -| Context cost | ~500-700 tokens per interaction | ~13k tokens on connect | +| Context cost | ~500–700 tokens per interaction | ~13k tokens on connect | | Coverage | Common operations (issues, discussions, cycles, docs, files) | Full Linear API | -| Output | JSON via stdout | Tool call responses | -| Setup | `npm install -g linearis` + bash tool | MCP server connection | +| Output | JSON via stdout | Tool-call responses | +| Setup | `npm install -g linearis` + Bash tool | MCP server connection | Use Linearis when token efficiency matters and you work primarily with issues and related data. Use the MCP when you need full API coverage or tight tool-call integration. -### Example prompt - -```markdown -## Linear (project management) - -Tool: `linearis` CLI via Bash. All output is JSON. +### Agent skill -Discovery: Run `linearis usage` once to see available domains. Run `linearis usage` for full command reference of a specific domain. Do NOT guess flags or subcommands -- check usage first. +Linearis ships an agent skill (following the [agentskills.io](https://agentskills.io) standard) so your agent knows how to use it — no prompt to paste. The skill preflights the install, advisory-checks for updates, then follows the discover-then-act protocol above. -Ticket format: "ABC-123". Always reference tickets by their identifier. +**Any harness (recommended)** — Vercel's skills CLI installs into the right place for 70+ agents and lists it on [skills.sh](https://skills.sh): -Workflow rules: -- When creating a ticket, ask the user which project to assign it to if unclear. -- For subtasks, inherit the parent ticket's project by default. -- When a task in a ticket description changes status, update the description. -- For progress beyond simple checkbox changes, start or reply in a discussion thread instead of editing the description. - -File handling: `issues read` returns an `embeds` array with signed download URLs and expiration timestamps. Use `files download` to retrieve them. Use `files upload` to attach new files, then reference the returned URL in discussions or descriptions. +```bash +npx skills add linearis-oss/linearis ``` -Add this (or a version adapted to your workflow) to your `AGENTS.md` or `CLAUDE.md` so every agent session has it in context automatically. - -## Release Automation Policy +**Claude Code** — native plugin: -Linearis uses three CI/release workflows: - -- `ci.yml` for required pull request checks -- `ci-post-merge.yml` for post-merge sentinel validation on `main`/`next` pushes -- `release-check.yml` for push-driven and manual releases - -For the authoritative trigger matrix, required checks, and operational verification commands, see [`docs/ci-run-model.md`](docs/ci-run-model.md) (source of truth). - -`CHANGELOG.md` is automation-owned and must not be edited in pull requests. If a pull request branch contains `CHANGELOG.md` changes anywhere in `main...HEAD` history, CI fails and posts rebase instructions. - -## Contributing - -Want to contribute? See [CONTRIBUTING.md](CONTRIBUTING.md). - -## Creator - -Carlo Zottmann -- [c.zottmann.dev](https://c.zottmann.dev) | [github.com/czottmann](https://github.com/czottmann) - -Carlo created Linearis and drove its early development. As interest in the project grew, he handed maintenance over to [Fabian Jocks](https://github.com/iamfj) ([in/fabianjocks](https://linkedin.com/in/fabianjocks)). +``` +/plugin marketplace add linearis-oss/linearis +/plugin install linearis@linearis +``` -This project is neither affiliated with nor endorsed by Linear. +**OpenAI Codex** — `npx skills add linearis-oss/linearis` installs to `~/.agents/skills/`; invoke with `/skills` or `$`. -### Sponsoring Carlo's work +**pi** — `npx skills add linearis-oss/linearis` (or drop `skills/linearis/` into `.pi/skills/`); invoke `/skill:linearis`. -Carlo doesn't accept sponsoring in the "GitHub sponsorship" sense[^1] but [next to his own apps, he also sells "Tokens of Appreciation"](https://actions.work/store/?ref=github). Any support is appreciated! +**Google Antigravity** — `npx skills add linearis-oss/linearis` installs to `.agents/skills/`; auto-discovered from the skill list. -[^1]: Apparently, the German revenue service is still having some fits over "money for nothing??". +## Documentation -> [!TIP] -> Carlo makes Shortcuts-related macOS & iOS productivity apps like [Actions For Obsidian](https://actions.work/actions-for-obsidian), [Browser Actions](https://actions.work/browser-actions) (which adds Shortcuts support for several major browsers), and [BarCuts](https://actions.work/barcuts) (a surprisingly useful contextual Shortcuts launcher). Check them out! +- [MIGRATION_2026.4.9.md](MIGRATION_2026.4.9.md) — migrating from the deprecated `comments` domain to discussions (v2026.4.9). +- [`docs/`](docs/) — architecture, development, testing, and build-system references. +- [`docs/ci-run-model.md`](docs/ci-run-model.md) — the authoritative CI/release trigger matrix. +- [CONTRIBUTING.md](CONTRIBUTING.md) — contributor guidelines. +- [SECURITY.md](SECURITY.md) — how to report security issues. ## Contributors - + Contributors Made with [contrib.rocks](https://contrib.rocks). ## License -MIT. See [LICENSE.md](LICENSE.md). +[MIT](LICENSE.md) + +This project is neither affiliated with nor endorsed by Linear. diff --git a/biome.json b/biome.json index 83fda0d9..236ec8c7 100644 --- a/biome.json +++ b/biome.json @@ -24,6 +24,9 @@ "linter": { "rules": { "recommended": true, + "complexity": { + "useLiteralKeys": "off" + }, "style": { "noNonNullAssertion": "off" } diff --git a/codegen.config.ts b/codegen.config.ts index c7f9486e..c1111882 100644 --- a/codegen.config.ts +++ b/codegen.config.ts @@ -9,6 +9,25 @@ const config: CodegenConfig = { presetConfig: { fragmentMasking: false, }, + config: { + // Any custom scalar reachable from our operations that is not mapped + // below falls back to `unknown` (safe) instead of the codegen default + // `any`. + defaultScalarType: "unknown", + scalars: { + DateTime: { input: "string", output: "string" }, + DateTimeOrDuration: { input: "string", output: "string" }, + TimelessDate: { input: "string", output: "string" }, + TimelessDateOrDuration: { input: "string", output: "string" }, + Duration: { input: "string | number", output: "string" }, + UUID: { input: "string", output: "string" }, + JSON: { input: "unknown", output: "unknown" }, + JSONObject: { + input: "Record", + output: "Record", + }, + }, + }, }, }, }; diff --git a/docs/architecture.md b/docs/architecture.md index 918af886..d8c9477e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,13 +2,13 @@ Linearis follows a modular, five-layer architecture with clear separation of concerns. The application uses a command-based structure with Commander.js, typed GraphQL operations, standalone resolver functions, and service functions that eliminate code duplication. -The architecture emphasizes performance through GraphQL batch operations, single-query optimizations, and smart ID resolution for user convenience. All components are fully typed with TypeScript - no `any` types in the new architecture. The system uses both direct GraphQL queries (via typed client) and Linear SDK (for ID resolution). +The architecture emphasizes performance through GraphQL batch operations, single-query optimizations, and smart ID resolution for user convenience. All components are fully typed with TypeScript - no `any` types in the new architecture. Every layer talks to the Linear API through a single typed GraphQL client — both ID resolution and data operations. ## Five-Layer Architecture ### 1. Client Layer (`src/client/`) -Thin wrappers around GraphQL and Linear SDK with no business logic. +Thin wrapper around the Linear GraphQL API with no business logic. - **graphql-client.ts** - Typed GraphQL client - Takes `DocumentNode` from codegen @@ -16,11 +16,6 @@ Thin wrappers around GraphQL and Linear SDK with no business logic. - Handles error transformation - No ID resolution or business logic -- **linear-client.ts** - Linear SDK wrapper - - Simple wrapper exposing `sdk` property - - Used by resolvers for lookups - - No business logic - ### 2. Resolver Layer (`src/resolvers/`) Pure functions that convert human-friendly identifiers to UUIDs. @@ -34,10 +29,10 @@ Pure functions that convert human-friendly identifiers to UUIDs. - **issue-resolver.ts** - `resolveIssueId(client, issueIdOrIdentifier)` - Parses ABC-123 format - **status-resolver.ts** - `resolveStatusId(client, nameOrId, teamId?)` - **cycle-resolver.ts** - `resolveCycleId(client, nameOrId, teamFilter?)` - Complex disambiguation -- **milestone-resolver.ts** - `resolveMilestoneId(gqlClient, sdkClient, nameOrId, projectNameOrId?)` +- **milestone-resolver.ts** - `resolveMilestoneId(gqlClient, nameOrId, projectNameOrId?)` **Pattern:** -- Accept SDK or GraphQL client +- Accept the `GraphQLClient` - Check if input is UUID (early return) - Query Linear API for name/key match - Throw descriptive error if not found @@ -60,7 +55,7 @@ Pure, typed functions for CRUD operations. Receive pre-resolved UUIDs. - **file-service.ts** - File upload/download operations **Pattern:** -- Accept `GraphQLClient` or `LinearSdkClient` +- Accept `GraphQLClient` - Take pre-resolved UUIDs in inputs - Use codegen `DocumentNode` types - Return typed results @@ -91,8 +86,8 @@ Thin orchestration layer that composes resolvers and services. const ctx = await createContext(command.parent!.parent!.opts()); // Resolve IDs - const teamId = await resolveTeamId(ctx.sdk, options.team); - const labelIds = await resolveLabelIds(ctx.sdk, options.labels.split(',')); + const teamId = await resolveTeamId(ctx.gql, options.team); + const labelIds = await resolveLabelIds(ctx.gql, options.labels.split(',')); // Call service const result = await createIssue(ctx.gql, { @@ -111,7 +106,7 @@ Thin orchestration layer that composes resolvers and services. Shared utilities used across layers. -- **context.ts** - `createContext(options)` - Creates `{ gql, sdk }` from auth +- **context.ts** - `createContext(options)` - Creates `{ gql }` from auth - **auth.ts** - `resolveApiToken(options)` - Multi-source authentication (flag, env, encrypted storage, legacy file) - **output.ts** - `outputSuccess(data)`, `outputError(error)`, `handleCommand(fn)` - **errors.ts** - `notFoundError()`, `multipleMatchesError()`, `invalidParameterError()` @@ -140,7 +135,6 @@ Shared utilities used across layers. ### Client Layer - API Wrappers - **src/client/graphql-client.ts** - Typed GraphQL client with error handling -- **src/client/linear-client.ts** - Linear SDK wrapper ### Resolver Layer - ID Resolution @@ -170,7 +164,6 @@ Shared utilities used across layers. **Client Layer** - src/client/graphql-client.ts - GraphQLClient class with typed request method -- src/client/linear-client.ts - LinearSdkClient wrapper **Resolver Layer** @@ -202,9 +195,9 @@ Shared utilities used across layers. ### Command Execution Flow 1. **Command Parsing** - src/main.ts parses CLI arguments via Commander.js -2. **Context Creation** - src/common/context.ts creates `{ gql, sdk }` from auth options +2. **Context Creation** - src/common/context.ts creates `{ gql }` from auth options 3. **Authentication** - src/common/auth.ts resolves API token from multiple sources -4. **ID Resolution** - src/resolvers/* convert human inputs to UUIDs via SDK +4. **ID Resolution** - src/resolvers/* convert human inputs to UUIDs via GraphQL 5. **Service Operations** - src/services/* execute typed GraphQL operations 6. **Response Formatting** - src/common/output.ts outputs structured JSON diff --git a/docs/build-system.md b/docs/build-system.md index e3706ebe..ccc89b1d 100644 --- a/docs/build-system.md +++ b/docs/build-system.md @@ -135,8 +135,9 @@ npm run test:commands # Run command coverage analysis | Package | Version | Purpose | |---|---|---| -| `@linear/sdk` | ^58.1.0 | Linear API SDK for ID resolution | -| `commander` | ^14.0.0 | CLI argument parsing | +| `commander` | 14.0.3 | CLI argument parsing | +| `graphql` | 16.12.0 | GraphQL document parsing/types for the typed client | +| `node-emoji` | 2.2.0 | Emoji shortcode handling | ### Development @@ -144,8 +145,6 @@ npm run test:commands # Run command coverage analysis |---|---|---| | `@graphql-codegen/cli` | ^6.1.1 | GraphQL code generation CLI | | `@graphql-codegen/client-preset` | ^5.2.2 | Typed document node generation | -| `@graphql-codegen/introspection` | 5.0.0 | Schema introspection plugin | -| `@graphql-codegen/schema-ast` | ^5.0.0 | Schema AST generation | | `@types/node` | ^22.0.0 | Node.js type definitions | | `@vitest/coverage-v8` | ^2.1.8 | V8-based code coverage | | `@vitest/ui` | ^2.1.8 | Browser-based test UI | diff --git a/docs/development.md b/docs/development.md index 21308cec..631c6c82 100644 --- a/docs/development.md +++ b/docs/development.md @@ -33,19 +33,19 @@ The codebase is organized into five layers, each with a single responsibility: ``` CLI Input --> Command --> Resolver --> Service --> JSON Output | | - SDK client GraphQL client + GraphQL client GraphQL client (ID lookup) (data operations) ``` | Layer | Directory | Client | Responsibility | |-------|-----------|--------|----------------| -| Client | `src/client/` | -- | API client wrappers | -| Resolver | `src/resolvers/` | `LinearSdkClient` | Convert human IDs to UUIDs | +| Client | `src/client/` | -- | API client wrapper | +| Resolver | `src/resolvers/` | `GraphQLClient` | Convert human IDs to UUIDs | | Service | `src/services/` | `GraphQLClient` | Business logic and CRUD | -| Command | `src/commands/` | Both (via `createContext()`) | CLI orchestration | +| Command | `src/commands/` | `GraphQLClient` (via `createContext()`) | CLI orchestration | | Common | `src/common/` | -- | Shared utilities and types | -Two separate clients exist because the Linear SDK is convenient for ID lookups (resolvers), while direct GraphQL queries are more efficient for data operations (services). Commands get both clients through `createContext()`. +A single typed GraphQL client backs every layer: resolvers use lean filter-based lookup queries for ID resolution, while services use richer queries for data operations. Commands get the client through `createContext()` as `ctx.gql`. ## Code Style @@ -90,7 +90,7 @@ export function setupIssuesCommands(program: Command): void { .action(handleCommand(async (title, options, command) => { const ctx = await createContext(command.parent!.parent!.opts()); const teamId = options.team - ? await resolveTeamId(ctx.sdk, options.team) + ? await resolveTeamId(ctx.gql, options.team) : undefined; const result = await createIssue(ctx.gql, { title, teamId }); outputSuccess(result); @@ -109,38 +109,44 @@ setupEntityCommands(program); ### Resolver Pattern -Resolvers convert human-friendly identifiers (team keys, names, issue identifiers like `ENG-123`) into UUIDs. They use the `LinearSdkClient` and live in `src/resolvers/`. +Resolvers convert human-friendly identifiers (team keys, names, issue identifiers like `ENG-123`) into UUIDs. They use the `GraphQLClient` with lean filter-based lookup queries and live in `src/resolvers/`. ```typescript -import type { LinearSdkClient } from "../client/linear-client.js"; -import { isUuid } from "../common/identifier.js"; +import type { GraphQLClient } from "../client/graphql-client.js"; +import { notFoundError } from "../common/errors.js"; +import { asUuid, isUuid, type UUID } from "../common/identifier.js"; +import { FindTeamsDocument } from "../gql/graphql.js"; export async function resolveTeamId( - client: LinearSdkClient, + client: GraphQLClient, keyOrNameOrId: string, -): Promise { - if (isUuid(keyOrNameOrId)) return keyOrNameOrId; +): Promise { + if (isUuid(keyOrNameOrId)) return asUuid(keyOrNameOrId); - const byKey = await client.sdk.teams({ + // Try by key first + const byKey = await client.request(FindTeamsDocument, { filter: { key: { eq: keyOrNameOrId } }, first: 1, }); - if (byKey.nodes.length > 0) return byKey.nodes[0].id; + const [byKeyMatch] = byKey.teams.nodes; + if (byKeyMatch) return asUuid(byKeyMatch.id); - const byName = await client.sdk.teams({ + // Fall back to name + const byName = await client.request(FindTeamsDocument, { filter: { name: { eq: keyOrNameOrId } }, first: 1, }); - if (byName.nodes.length > 0) return byName.nodes[0].id; + const [byNameMatch] = byName.teams.nodes; + if (byNameMatch) return asUuid(byNameMatch.id); - throw new Error(`Team "${keyOrNameOrId}" not found`); + throw notFoundError("Team", keyOrNameOrId); } ``` Rules for resolvers: - Always accept a UUID passthrough as the first check. - Return a UUID string, never an object. -- Use `LinearSdkClient` only (not `GraphQLClient`). +- Use `GraphQLClient` with a lean lookup query; do not import services. - No CRUD operations or data transformations. ### Service Pattern @@ -180,7 +186,7 @@ export async function createIssue( ``` Rules for services: -- Use `GraphQLClient` only (not `LinearSdkClient`). +- Use `GraphQLClient` (the only client). - Accept UUIDs, not human-friendly identifiers. - Import `DocumentNode` constants and types from `src/gql/graphql.js`. - Always type the `client.request()` call. @@ -299,7 +305,7 @@ A typical feature addition touches four layers. Here is the sequence: 1. **GraphQL operations** -- Define queries and mutations in `graphql/queries/` or `graphql/mutations/`, then run `npm run generate`. -2. **Resolver** (if new entity types need ID resolution) -- Add a `resolve*Id()` function in `src/resolvers/`. Use `LinearSdkClient`, return a UUID string. +2. **Resolver** (if new entity types need ID resolution) -- Add a `resolve*Id()` function in `src/resolvers/`. Use `GraphQLClient` with a lean lookup query, return a UUID string. 3. **Service** -- Add functions in `src/services/`. Use `GraphQLClient`, accept UUIDs, import codegen types. @@ -319,6 +325,32 @@ A typical feature addition touches four layers. Here is the sequence: | `npm run test:coverage` | Run tests with coverage | | `npm run test:commands` | Check command coverage | | `npm run generate` | Regenerate GraphQL types | +| `npm run knip` | Detect dead code (unused files, exports, types, deps) | + +## Dead-Code Checks (knip) + +[knip](https://knip.dev) finds unused files, exports, exported types, and +dependencies. It runs as a required PR check (the `knip` job in +`ci-validate.yml`), which posts a single self-updating comment with any findings +and fails the check until they are resolved. + +```bash +npm run generate # ensure generated GraphQL types exist first +npm run knip # report dead code +npx knip --fix --allow-remove-files # auto-remove unused exports/files, then review the diff +``` + +Configuration lives in `knip.json`: + +- `src/gql/**` is ignored — it is generated by codegen and must never be + dead-code-linted (mirrors the Biome ignore in `biome.json`). +- A few `@semantic-release/*` plugins are listed under `ignoreDependencies` + because they are referenced only in `.releaserc.cjs` (knip's semantic-release + plugin cannot fully trace them, but they are genuinely used at release time). + +When a finding is a real false positive — dynamically-wired code knip cannot +trace — add a narrow entry to `knip.json` explaining why, rather than deleting +live code or leaving the check red. ## Project Structure @@ -327,7 +359,6 @@ src/ main.ts # Entry point, registers all command groups client/ graphql-client.ts # GraphQLClient - direct GraphQL execution - linear-client.ts # LinearSdkClient - SDK wrapper for resolvers resolvers/ # Human ID to UUID resolution team-resolver.ts project-resolver.ts @@ -377,7 +408,7 @@ graphql/ mutations/ # GraphQL mutation definitions tests/ unit/ - resolvers/ # Resolver tests (mock SDK) + resolvers/ # Resolver tests (mock GraphQLClient) services/ # Service tests (mock GraphQL) common/ # Pure function tests ``` @@ -385,8 +416,9 @@ tests/ ## Dependencies **Runtime:** -- `@linear/sdk` -- Linear SDK, used by resolvers for ID lookups - `commander` -- CLI framework +- `graphql` -- GraphQL document parsing/types for the typed client +- `node-emoji` -- emoji shortcode handling **Development:** - `typescript` -- Compiler diff --git a/docs/files.md b/docs/files.md index 9b0f5922..788e6edf 100644 --- a/docs/files.md +++ b/docs/files.md @@ -8,14 +8,13 @@ A reference of every file in the Linearis codebase, organized by architectural l ## Client Layer (`src/client/`) -Thin wrappers around the Linear API. No business logic. +Thin wrapper around the Linear API. No business logic. - **graphql-client.ts** -- `GraphQLClient` class with a typed `request(document: DocumentNode, variables?: Record)` method for direct GraphQL execution. -- **linear-client.ts** -- `LinearSdkClient` wrapper exposing a readonly `sdk: LinearClient` property for SDK-based lookups. ## Resolver Layer (`src/resolvers/`) -Each resolver converts a human-friendly identifier (name, key, or slug) into a UUID. Resolvers use `LinearSdkClient` exclusively. +Each resolver converts a human-friendly identifier (name, key, or slug) into a UUID. Resolvers use `GraphQLClient` with lean filter-based lookup queries. - **team-resolver.ts** -- `resolveTeamId(client, keyOrNameOrId)` - **project-resolver.ts** -- `resolveProjectId(client, nameOrId)` @@ -61,7 +60,7 @@ CLI orchestration. Each file registers a command group via a `setup*Commands(pro Shared utilities used across all layers. -- **context.ts** -- `CommandContext` interface and `createContext()` factory that produces both `GraphQLClient` and `LinearSdkClient`. +- **context.ts** -- `CommandContext` interface and `createContext()` factory that produces the `GraphQLClient` (`ctx.gql`). - **auth.ts** -- `resolveApiToken()` with multi-source lookup: `--api-token` flag, `LINEAR_API_TOKEN` env var, `~/.linearis/token` (encrypted), `~/.linear_api_token` (deprecated). - **token-storage.ts** -- `saveToken()`, `getStoredToken()`, `clearToken()` for encrypted token storage in `~/.linearis/token`. - **encryption.ts** -- AES-256-CBC encryption for token storage. @@ -103,7 +102,7 @@ Source `.graphql` files that feed into code generation. ## Tests (`tests/`) -Unit tests mirror the source structure. Resolver tests mock the SDK client; service tests mock the GraphQL client; common tests require no mocks. +Unit tests mirror the source structure. Resolver and service tests both mock the `GraphQLClient` (`request`); common tests require no mocks. ``` tests/unit/ @@ -138,7 +137,7 @@ tests/unit/ ``` CLI Input --> Command --> Resolver --> Service --> JSON Output | | | - createContext() SDK GraphQL + createContext() GraphQL GraphQL (name->UUID) (CRUD) ``` diff --git a/docs/project-overview.md b/docs/project-overview.md index 027cebf9..02f2ba56 100644 --- a/docs/project-overview.md +++ b/docs/project-overview.md @@ -10,10 +10,10 @@ The codebase follows a five-layer architecture. Each layer has a specific respon | Layer | Directory | Responsibility | Client | |-------|-----------|---------------|--------| -| Client | `src/client/` | Low-level API wrappers | -- | -| Resolver | `src/resolvers/` | Human ID to UUID conversion | LinearSdkClient | +| Client | `src/client/` | Low-level API wrapper | -- | +| Resolver | `src/resolvers/` | Human ID to UUID conversion | GraphQLClient | | Service | `src/services/` | Business logic and CRUD operations | GraphQLClient | -| Command | `src/commands/` | CLI orchestration via Commander.js | Both (via `createContext()`) | +| Command | `src/commands/` | CLI orchestration via Commander.js | GraphQLClient (via `createContext()`) | | Common | `src/common/` | Shared utilities, types, error handling | -- | Data flows in one direction: @@ -29,7 +29,7 @@ Commands receive user input, resolve any identifiers to UUIDs through the resolv - **TypeScript** with strict mode enabled and no `any` types - **Node.js** >= 22.0.0, ES modules throughout - **Commander.js** v14.0.0 for CLI structure -- **Linear SDK** v58.1.0 for the SDK client used in resolvers +- **GraphQL** for the typed client backing every layer (resolvers and services) - **GraphQL Codegen** for type-safe query and mutation documents - **Vitest** for unit testing - **tsx** for development execution diff --git a/docs/testing.md b/docs/testing.md index bb2b8b5a..0eca7b09 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -61,17 +61,16 @@ Each architectural layer uses a different mock target. The rule is simple: mock ### Resolver Tests -Resolvers depend on `LinearSdkClient`. Mock the SDK methods it calls: +Resolvers depend on `GraphQLClient`. Mock the `request` method it calls: ```typescript -import type { LinearSdkClient } from "../../src/client/linear-client.js"; +import type { GraphQLClient } from "../../src/client/graphql-client.js"; -const mockSdk = { - teams: vi.fn().mockResolvedValue({ - nodes: [{ id: "uuid-123", key: "ABC" }], +const client = { + request: vi.fn().mockResolvedValue({ + teams: { nodes: [{ id: "uuid-123", key: "ABC" }] }, }), -}; -const client = { sdk: mockSdk } as unknown as LinearSdkClient; +} as unknown as GraphQLClient; ``` ### Service Tests @@ -100,10 +99,11 @@ expect(isUuid("ABC-123")).toBe(false); ### Client Tests -Client tests mock the underlying network layer: +Client tests mock the underlying network layer by stubbing global `fetch`: ```typescript -const mockClient = { rawRequest: vi.fn() }; +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); ``` ## Writing a New Test @@ -116,34 +116,32 @@ Example resolver test: ```typescript import { describe, expect, it, vi } from "vitest"; -import type { LinearSdkClient } from "../../../src/client/linear-client.js"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { resolveTeamId } from "../../../src/resolvers/team-resolver.js"; +// Queue one `{ teams: { nodes } }` response per expected request. +function mockGqlClient(...results: Array<{ nodes: Array<{ id: string; key?: string; name?: string }> }>) { + const request = vi.fn(); + for (const teams of results) request.mockResolvedValueOnce({ teams }); + return { request } as unknown as GraphQLClient; +} + describe("resolveTeamId", () => { - it("should return UUID as-is", async () => { - const client = { sdk: {} } as unknown as LinearSdkClient; + it("should return UUID as-is without querying", async () => { + const client = mockGqlClient(); const result = await resolveTeamId(client, "550e8400-e29b-41d4-a716-446655440000"); expect(result).toBe("550e8400-e29b-41d4-a716-446655440000"); + expect(client.request).not.toHaveBeenCalled(); }); it("should resolve team by key", async () => { - const mockSdk = { - teams: vi.fn().mockResolvedValue({ - nodes: [{ id: "uuid-456", key: "ENG" }], - }), - }; - const client = { sdk: mockSdk } as unknown as LinearSdkClient; - + const client = mockGqlClient({ nodes: [{ id: "uuid-456", key: "ENG" }] }); const result = await resolveTeamId(client, "ENG"); expect(result).toBe("uuid-456"); }); it("should throw when team is not found", async () => { - const mockSdk = { - teams: vi.fn().mockResolvedValue({ nodes: [] }), - }; - const client = { sdk: mockSdk } as unknown as LinearSdkClient; - + const client = mockGqlClient({ nodes: [] }, { nodes: [] }); await expect(resolveTeamId(client, "NOPE")).rejects.toThrow(); }); }); diff --git a/graphql/mutations/documents.graphql b/graphql/mutations/documents.graphql index fdbbc492..27f6f574 100644 --- a/graphql/mutations/documents.graphql +++ b/graphql/mutations/documents.graphql @@ -2,8 +2,7 @@ # GraphQL mutations for Linear documents # # Documents are standalone entities that can be associated with projects, -# initiatives, or teams. To link a document to an issue, use the -# attachments API (see attachments.graphql). +# initiatives, issues, or teams. # ------------------------------------------------------------ # Create a new document mutation diff --git a/graphql/mutations/issue-relations.graphql b/graphql/mutations/issue-relations.graphql index 021bc34a..19a6f1f6 100644 --- a/graphql/mutations/issue-relations.graphql +++ b/graphql/mutations/issue-relations.graphql @@ -6,9 +6,16 @@ fragment IssueRelationFields on IssueRelation { id type + createdAt + issue { + id + identifier + title + } relatedIssue { id identifier + title } } @@ -16,9 +23,16 @@ fragment IssueRelationFields on IssueRelation { fragment InverseIssueRelationFields on IssueRelation { id type + createdAt issue { id identifier + title + } + relatedIssue { + id + identifier + title } } @@ -44,6 +58,8 @@ mutation DeleteIssueRelation($id: String!) { # Used by --remove-relation to locate the relation ID before deletion query GetIssueRelations($issueId: String!) { issue(id: $issueId) { + id + identifier relations { nodes { ...IssueRelationFields diff --git a/graphql/mutations/labels.graphql b/graphql/mutations/labels.graphql new file mode 100644 index 00000000..460afeed --- /dev/null +++ b/graphql/mutations/labels.graphql @@ -0,0 +1,28 @@ +# ------------------------------------------------------------ +# GraphQL mutations for Linear issue labels +# ------------------------------------------------------------ + +mutation CreateIssueLabel($input: IssueLabelCreateInput!) { + issueLabelCreate(input: $input) { + success + issueLabel { + ...LabelFields + } + } +} + +mutation UpdateIssueLabel($id: String!, $input: IssueLabelUpdateInput!) { + issueLabelUpdate(id: $id, input: $input) { + success + issueLabel { + ...LabelFields + } + } +} + +mutation DeleteIssueLabel($id: String!) { + issueLabelDelete(id: $id) { + success + entityId + } +} diff --git a/graphql/mutations/project-milestones.graphql b/graphql/mutations/project-milestones.graphql index 7889675b..49e34a91 100644 --- a/graphql/mutations/project-milestones.graphql +++ b/graphql/mutations/project-milestones.graphql @@ -7,20 +7,8 @@ # Create a new project milestone # # Creates a new project milestone and returns the created project milestone data. -mutation CreateProjectMilestone( - $projectId: String! - $name: String! - $description: String - $targetDate: TimelessDate -) { - projectMilestoneCreate( - input: { - projectId: $projectId - name: $name - description: $description - targetDate: $targetDate - } - ) { +mutation CreateProjectMilestone($input: ProjectMilestoneCreateInput!) { + projectMilestoneCreate(input: $input) { success projectMilestone { id @@ -43,20 +31,9 @@ mutation CreateProjectMilestone( # Updates an existing project milestone and returns the updated project milestone data. mutation UpdateProjectMilestone( $id: String! - $name: String - $description: String - $targetDate: TimelessDate - $sortOrder: Float + $input: ProjectMilestoneUpdateInput! ) { - projectMilestoneUpdate( - id: $id - input: { - name: $name - description: $description - targetDate: $targetDate - sortOrder: $sortOrder - } - ) { + projectMilestoneUpdate(id: $id, input: $input) { success projectMilestone { id diff --git a/graphql/mutations/projects.graphql b/graphql/mutations/projects.graphql index b4e3c134..b0ce91c0 100644 --- a/graphql/mutations/projects.graphql +++ b/graphql/mutations/projects.graphql @@ -13,7 +13,7 @@ mutation CreateProject($input: ProjectCreateInput!) { projectCreate(input: $input) { success project { - ...ProjectDetailFields + ...ProjectDetailWithDefaultConnectionsFields } } } @@ -25,7 +25,7 @@ mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) { projectUpdate(id: $id, input: $input) { success project { - ...ProjectDetailFields + ...ProjectDetailWithDefaultConnectionsFields } } } @@ -34,7 +34,7 @@ mutation ArchiveProject($id: String!) { projectArchive(id: $id) { success entity { - ...ProjectDetailFields + ...ProjectDetailWithDefaultConnectionsFields } } } @@ -43,7 +43,7 @@ mutation UnarchiveProject($id: String!) { projectUnarchive(id: $id) { success entity { - ...ProjectDetailFields + ...ProjectDetailWithDefaultConnectionsFields } } } @@ -52,7 +52,7 @@ mutation DeleteProject($id: String!) { projectDelete(id: $id) { success entity { - ...ProjectDetailFields + ...ProjectDetailWithDefaultConnectionsFields } } } diff --git a/graphql/mutations/teams.graphql b/graphql/mutations/teams.graphql new file mode 100644 index 00000000..3ade8b26 --- /dev/null +++ b/graphql/mutations/teams.graphql @@ -0,0 +1,59 @@ +# ------------------------------------------------------------ +# GraphQL mutations for Linear team operations +# +# Creates and updates teams, and manages team membership. Team +# mutations return the same TeamDetailFields fragment that `teams read` +# queries, so the entity shape matches (the read command additionally +# derives valid estimate options on top of these fields). Membership +# mutations use Linear's TeamMembership entity: a membership joins a +# user to a team and is deleted by its own id. +# ------------------------------------------------------------ + +# Create a new team +# +# Requires at minimum a name. The key is auto-derived from the name +# when omitted. Returns complete detail fields. +mutation CreateTeam($input: TeamCreateInput!) { + teamCreate(input: $input) { + success + team { + ...TeamDetailFields + } + } +} + +# Update an existing team +# +# Updates mutable team metadata and settings, returning detail fields. +mutation UpdateTeam($id: String!, $input: TeamUpdateInput!) { + teamUpdate(id: $id, input: $input) { + success + team { + ...TeamDetailFields + } + } +} + +# Add a user to a team +# +# Creates a TeamMembership joining the user to the team. Set owner to +# grant team-admin rights. +mutation AddTeamMember($input: TeamMembershipCreateInput!) { + teamMembershipCreate(input: $input) { + success + teamMembership { + ...TeamMembershipFields + } + } +} + +# Remove a user from a team +# +# Deletes a TeamMembership by its id (resolved from team + user by the +# service layer). +mutation RemoveTeamMember($id: String!) { + teamMembershipDelete(id: $id) { + success + entityId + } +} diff --git a/graphql/queries/activity.graphql b/graphql/queries/activity.graphql new file mode 100644 index 00000000..031dbb7b --- /dev/null +++ b/graphql/queries/activity.graphql @@ -0,0 +1,74 @@ +fragment IssueHistoryFields on IssueHistory { + id + createdAt + actor { + id + displayName + } + botActor { + id + name + } + fromState { + id + name + } + toState { + id + name + } + fromAssignee { + id + displayName + } + toAssignee { + id + displayName + } + fromPriority + toPriority + fromProject { + id + name + } + toProject { + id + name + } + fromCycle { + id + number + } + toCycle { + id + number + } + fromTitle + toTitle + fromEstimate + toEstimate + addedLabelIds + removedLabelIds + archived +} + +query GetIssueActivityRef($id: String!) { + issue(id: $id) { + id + identifier + } +} + +query ListIssueActivityHistory($issueId: String!, $first: Int, $after: String) { + issue(id: $issueId) { + history(first: $first, after: $after) { + nodes { + ...IssueHistoryFields + } + pageInfo { + hasNextPage + endCursor + } + } + } +} diff --git a/graphql/queries/initiatives.graphql b/graphql/queries/initiatives.graphql index e3095af8..dcd23885 100644 --- a/graphql/queries/initiatives.graphql +++ b/graphql/queries/initiatives.graphql @@ -182,6 +182,21 @@ query FindInitiativesByName($name: String!, $teamId: ID, $ownerId: ID) { } } +# Find initiatives by a dynamic filter for ID resolution +# +# The filter is supplied by the caller (name plus optional team/owner +# scope), preserving the resolver's dynamic filter construction and +# avoiding the nullable-scope behavior of FindInitiativesByName. +# Emits the InitiativeFilter input type. first: 20 matches the resolver's +# candidate limit for ambiguity reporting. +query FindInitiatives($filter: InitiativeFilter, $first: Int = 20) { + initiatives(filter: $filter, first: $first) { + nodes { + ...InitiativeNameLookupFields + } + } +} + query FindInitiativeRelationByPair( $parentId: String! $childId: String! diff --git a/graphql/queries/issues.graphql b/graphql/queries/issues.graphql index 2afbf99e..32d936d2 100644 --- a/graphql/queries/issues.graphql +++ b/graphql/queries/issues.graphql @@ -385,7 +385,7 @@ query BatchResolveForUpdate( $assigneeQuery: String $projectName: String $projectId: ID - $labelNames: [String!] + $labelFilter: IssueLabelFilter $statusName: String $cycleName: String $teamKey: String @@ -420,10 +420,7 @@ query BatchResolveForUpdate( nodes { id name - projectMilestones( - filter: { name: { eqIgnoreCase: $milestoneName } } - first: 10 - ) { + projectMilestones(filter: { name: { eq: $milestoneName } }, first: 10) { nodes { id name @@ -432,7 +429,7 @@ query BatchResolveForUpdate( } } - labels: issueLabels(filter: { name: { in: $labelNames } }) { + labels: issueLabels(filter: $labelFilter) { nodes { id name @@ -519,7 +516,7 @@ query BatchResolveForCreate( $assigneeQuery: String $projectName: String $projectId: ID - $labelNames: [String!] + $labelFilter: IssueLabelFilter $statusName: String $cycleName: String $milestoneName: String @@ -527,13 +524,22 @@ query BatchResolveForCreate( $parentIssueNumber: Float ) { teams( - filter: { or: [{ key: { eq: $teamKey } }, { name: { eq: $teamName } }] } + filter: { + or: [ + { key: { eq: $teamKey } } + { name: { eq: $teamName } } + { id: { eq: $teamId } } + ] + } first: 10 ) { nodes { id key name + issueEstimationType + issueEstimationExtended + issueEstimationAllowZero } } @@ -563,10 +569,7 @@ query BatchResolveForCreate( nodes { id name - projectMilestones( - filter: { name: { eqIgnoreCase: $milestoneName } } - first: 10 - ) { + projectMilestones(filter: { name: { eq: $milestoneName } }, first: 10) { nodes { id name @@ -575,7 +578,7 @@ query BatchResolveForCreate( } } - labels: issueLabels(filter: { name: { in: $labelNames } }) { + labels: issueLabels(filter: $labelFilter) { nodes { id name @@ -661,7 +664,7 @@ query BatchResolveForSearch( $creatorQuery: String $projectName: String $projectId: ID - $labelNames: [String!] + $labelFilter: IssueLabelFilter $cycleName: String $parentTeamKey: String $parentIssueNumber: Float @@ -733,7 +736,7 @@ query BatchResolveForSearch( } } - labels: issueLabels(filter: { name: { in: $labelNames } }) { + labels: issueLabels(filter: $labelFilter) { nodes { id name @@ -835,6 +838,38 @@ query BatchResolveWorkflowStatesPage($first: Int!, $after: String) { } } +# Find workflow states by a dynamic filter for status ID resolution +# +# The filter is supplied by the caller (name plus optional team scope) +# so the unscoped case omits the team clause entirely rather than +# filtering on a null team id. Emits the WorkflowStateFilter input type. +query FindWorkflowStates($filter: WorkflowStateFilter, $first: Int = 1) { + workflowStates(filter: $filter, first: $first) { + nodes { + id + } + } +} + +# Find issues by a dynamic filter for ID resolution +# +# The filter is supplied by the caller so the resolver can preserve both the +# UUID lookup ({ id: { eq } }) and the identifier lookup +# ({ number: { eq }, team: { key: { eq } } }). team { id key } is selected so +# the estimate-context resolver can derive the owning team without a second +# round-trip. +query FindIssues($filter: IssueFilter, $first: Int = 1) { + issues(filter: $filter, first: $first) { + nodes { + id + team { + id + key + } + } + } +} + # Complete issue fragment with attachments fragment CompleteIssueWithAttachmentsFields on Issue { ...CompleteIssueWithDefaultCommentsFields diff --git a/graphql/queries/labels.graphql b/graphql/queries/labels.graphql index 5759a84c..6c66ceca 100644 --- a/graphql/queries/labels.graphql +++ b/graphql/queries/labels.graphql @@ -29,6 +29,12 @@ fragment ProjectLabelFields on ProjectLabel { description } +query GetIssueLabel($id: String!) { + issueLabel(id: $id) { + ...LabelFields + } +} + # List labels in the workspace # # Fetches a list of issue labels with optional team filtering. @@ -38,8 +44,19 @@ fragment ProjectLabelFields on ProjectLabel { # Variables: # $first: Maximum number of labels to return (default: 50) # $filter: Optional filter (e.g., { team: { id: { eq: "team-uuid" } } }) -query GetLabels($first: Int = 50, $after: String, $filter: IssueLabelFilter) { - issueLabels(first: $first, after: $after, filter: $filter) { +# $includeArchived: Include archived labels (default: false) +query GetLabels( + $first: Int = 50 + $after: String + $filter: IssueLabelFilter + $includeArchived: Boolean = false +) { + issueLabels( + first: $first + after: $after + filter: $filter + includeArchived: $includeArchived + ) { nodes { ...LabelFields } @@ -61,3 +78,28 @@ query GetProjectLabels($first: Int = 50, $after: String) { } } } + +# Find a project label by case-insensitive name for ID resolution +# +# Returns the first match; project label names are treated as unique +# for resolution purposes (no ambiguity error). +query FindProjectLabelByName($name: String!) { + projectLabels(filter: { name: { eqIgnoreCase: $name } }, first: 1) { + nodes { + id + } + } +} + +# Find issue labels by a dynamic filter for ID resolution +# +# The filter is supplied by the caller so the resolver can preserve its +# scope-aware filter construction (workspace/team/team-scoped) rather than +# baking the scope clauses into the query. +query FindIssueLabels($filter: IssueLabelFilter, $first: Int = 1) { + issueLabels(filter: $filter, first: $first) { + nodes { + id + } + } +} diff --git a/graphql/queries/projects.graphql b/graphql/queries/projects.graphql index 9b3e8a5f..81889fac 100644 --- a/graphql/queries/projects.graphql +++ b/graphql/queries/projects.graphql @@ -75,17 +75,21 @@ fragment ProjectDetailFields on Project { name } } - projectMilestones { + initiatives { nodes { id name - targetDate } } - initiatives { +} + +fragment ProjectDetailWithDefaultConnectionsFields on Project { + ...ProjectDetailFields + projectMilestones { nodes { id name + targetDate } } } @@ -98,8 +102,9 @@ fragment ProjectDetailFields on Project { # Variables: # $first: Maximum number of projects to return (default: 50) # $after: Cursor for pagination -query GetProjects($first: Int = 50, $after: String) { - projects(first: $first, after: $after) { +# $includeArchived: Include archived projects +query GetProjects($first: Int = 50, $after: String, $includeArchived: Boolean) { + projects(first: $first, after: $after, includeArchived: $includeArchived) { nodes { ...ProjectListFields } @@ -137,9 +142,33 @@ fragment ProjectDetailFieldsWithReactions on Project { # # Variables: # $id: Project UUID -query GetProject($id: String!) { +# $milestonesFirst: Maximum number of milestones to return +# $skipMilestones: Omit projectMilestones from the response +# $issuesFirst: Maximum number of issues to return +# $skipIssues: Omit issues from the response +query GetProject( + $id: String! + $milestonesFirst: Int! + $skipMilestones: Boolean! + $issuesFirst: Int! + $skipIssues: Boolean! +) { project(id: $id) { ...ProjectDetailFields + projectMilestones(first: $milestonesFirst) @skip(if: $skipMilestones) { + nodes { + id + name + description + targetDate + sortOrder + } + } + issues(first: $issuesFirst) @skip(if: $skipIssues) { + nodes { + ...CompleteIssueFields + } + } } } @@ -166,3 +195,19 @@ query GetProjectStatuses { } } } + +# Find projects by case-insensitive name for ID resolution +# +# Returns up to two matches so the resolver can throw on ambiguity. +# includeArchived is passed through to preserve archive-aware lookups. +query FindProjectsByName($name: String!, $includeArchived: Boolean) { + projects( + filter: { name: { eqIgnoreCase: $name } } + first: 2 + includeArchived: $includeArchived + ) { + nodes { + id + } + } +} diff --git a/graphql/queries/teams.graphql b/graphql/queries/teams.graphql index f941bd33..1a1f26e8 100644 --- a/graphql/queries/teams.graphql +++ b/graphql/queries/teams.graphql @@ -78,3 +78,65 @@ query GetTeamById($id: String!) { ...TeamDetailFields } } + +# Lean fields for team ID/estimate resolution +# +# Covers both resolveTeamId (id only) and resolveTeamEstimateContext +# (id, key, name plus the estimation fields) without over-fetching the +# heavy TeamDetailFields fragment. +fragment TeamLookupFields on Team { + id + key + name + issueEstimationType + issueEstimationExtended + issueEstimationAllowZero +} + +# Find teams by an arbitrary filter for resolver lookups +# +# The filter is supplied dynamically so callers can preserve the +# key-first-then-name lookup order (two calls) and the id/key/name +# estimate lookups with a single operation. +query FindTeams($filter: TeamFilter, $first: Int = 1) { + teams(filter: $filter, first: $first) { + nodes { + ...TeamLookupFields + } + } +} + +# Team membership fields fragment +# +# A membership joins a user to a team. The membership id is required to +# delete the membership; owner marks a team admin. +fragment TeamMembershipFields on TeamMembership { + id + owner + user { + id + name + email + displayName + } +} + +# List a team's memberships +# +# Used by `teams members` and to resolve a membership id from a +# team + user pair for `teams remove-member`. Paginated by the service +# layer so all members are fetched regardless of team size. +query GetTeamMemberships($id: String!, $first: Int = 250, $after: String) { + team(id: $id) { + id + memberships(first: $first, after: $after) { + nodes { + ...TeamMembershipFields + } + pageInfo { + hasNextPage + endCursor + } + } + } +} diff --git a/graphql/queries/users.graphql b/graphql/queries/users.graphql index 253c6cac..214d5321 100644 --- a/graphql/queries/users.graphql +++ b/graphql/queries/users.graphql @@ -42,3 +42,18 @@ query GetUsers($first: Int = 50, $after: String, $filter: UserFilter) { } } } + +# Find users by a dynamic filter for ID resolution +# +# The filter is supplied by the caller so the resolver can preserve its +# display-name-first (first: 10) then email-fallback (first: 1) lookup order. +# name/email are selected so the resolver can report ambiguous matches. +query FindUsers($filter: UserFilter, $first: Int = 1) { + users(filter: $filter, first: $first) { + nodes { + id + name + email + } + } +} diff --git a/knip.json b/knip.json new file mode 100644 index 00000000..39a1645f --- /dev/null +++ b/knip.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://unpkg.com/knip@6/schema.json", + "project": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs"], + "ignore": ["src/gql/**"], + "ignoreDependencies": [ + "@semantic-release/github", + "@semantic-release/npm", + "@semantic-release/release-notes-generator" + ] +} diff --git a/package-lock.json b/package-lock.json index 5ba80a2c..c66e36c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "linearis", - "version": "2026.5.0", + "version": "2026.6.0-next.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.5.0", + "version": "2026.6.0-next.13", "license": "MIT", "dependencies": { - "@linear/sdk": "82.1.0", "commander": "14.0.3", + "graphql": "16.12.0", "node-emoji": "2.2.0" }, "bin": { @@ -19,12 +19,11 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.14", - "@commitlint/cli": "^20.4.1", - "@commitlint/config-conventional": "^20.4.1", - "@graphql-codegen/cli": "^6.1.1", - "@graphql-codegen/client-preset": "^5.2.2", - "@graphql-codegen/introspection": "5.0.2", - "@graphql-codegen/schema-ast": "^5.0.0", + "@commitlint/cli": "^21.0.0", + "@commitlint/config-conventional": "^21.0.0", + "@graphql-codegen/cli": "^7.0.0", + "@graphql-codegen/client-preset": "^6.0.0", + "@graphql-typed-document-node/core": "3.2.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/exec": "^7.1.0", @@ -35,7 +34,8 @@ "@types/node": "^24.0.0", "@vitest/coverage-v8": "^4.0.0", "@vitest/ui": "^4.0.0", - "clean-publish": "^6.0.5", + "clean-publish": "^7.0.0", + "knip": "^6.24.0", "lefthook": "^2.1.0", "semantic-release": "^25.0.1", "tsx": "^4.20.5", @@ -359,9 +359,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "dev": true, "license": "MIT", "engines": { @@ -427,9 +427,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.13.tgz", - "integrity": "sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.1.tgz", + "integrity": "sha512-IXWLCxKmae+rI7LOHS1B3EbVisQ6GRAWbhN9msa6KjNCyFWrvKZWR4oUdinaNssrV852OrSHuSPa95h1GPJc7Q==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -443,20 +443,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.4.13", - "@biomejs/cli-darwin-x64": "2.4.13", - "@biomejs/cli-linux-arm64": "2.4.13", - "@biomejs/cli-linux-arm64-musl": "2.4.13", - "@biomejs/cli-linux-x64": "2.4.13", - "@biomejs/cli-linux-x64-musl": "2.4.13", - "@biomejs/cli-win32-arm64": "2.4.13", - "@biomejs/cli-win32-x64": "2.4.13" + "@biomejs/cli-darwin-arm64": "2.5.1", + "@biomejs/cli-darwin-x64": "2.5.1", + "@biomejs/cli-linux-arm64": "2.5.1", + "@biomejs/cli-linux-arm64-musl": "2.5.1", + "@biomejs/cli-linux-x64": "2.5.1", + "@biomejs/cli-linux-x64-musl": "2.5.1", + "@biomejs/cli-win32-arm64": "2.5.1", + "@biomejs/cli-win32-x64": "2.5.1" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.13.tgz", - "integrity": "sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-npqDzvqv7vFaWRiNN1Te71siRgPaqS9MpqgYCdP/CrUbkJ7ApezaeaKjueKHRN/JH/6lRjJQAHi8acQDCAz22w==", "cpu": [ "arm64" ], @@ -471,9 +471,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.13.tgz", - "integrity": "sha512-BKrJklbaFN4p1Ts4kPBczo+PkbsHQg57kmJ+vON9u2t6uN5okYHaSr7h/MutPCWQgg2lglaWoSmm+zhYW+oOkg==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.1.tgz", + "integrity": "sha512-RgwTqPAM8g2tn1j+b5oRjF/DbSBX8a4gwojtuG9XuhfK7GgomvZ9+T+tqjXiVbjLEeGJOoL6VEk8mvRTVeSybw==", "cpu": [ "x64" ], @@ -488,9 +488,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.13.tgz", - "integrity": "sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.1.tgz", + "integrity": "sha512-yhV35CzZh38VyMvTEXi3JTjxZBs++oCKK9KG8vB6VI5+uvQvZNR3BFWEKKzuOmx9DJJj7sQpZ4LQJcmbGTs3+Q==", "cpu": [ "arm64" ], @@ -508,9 +508,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.13.tgz", - "integrity": "sha512-U5MsuBQW25dXaYtqWWSPM3P96H6Y+fHuja3TQpMNnylocHW0tEbtFTDlUj6oM+YJLntvEkQy4grBvQNUD4+RCg==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-WMcvMLgByyTqVxGlq918NBBYliq9FRR9GAQVETHb+VjGVqXCZFfHlZHC1FX4ibuYY/Hg6TJE3rHU0xVrdJXNRw==", "cpu": [ "arm64" ], @@ -528,9 +528,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.13.tgz", - "integrity": "sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.1.tgz", + "integrity": "sha512-J/7uHSX7NfoYDI7HijAkd8lnQIOrRb2W7j3X+tw4R+N5ExvXGsyXFiGdQcfcxfOmNQmZVSQOCDk757fwpzqQcg==", "cpu": [ "x64" ], @@ -548,9 +548,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.13.tgz", - "integrity": "sha512-Z601MienRgTBDza/+u2CH3RSrWoXo9rtr8NK6A4KJzqGgfxx+H3VlyLgTJ4sRo40T3pIsqpTmiOQEvYzQvBRvQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-ANTowtlLmPYm5yeMckWY8Xzb9Ix+JJP3tgHR/n6xRj1VWyIzzWtfRfih9hv9VmClwadpBvZduISZIbBsIlYG3A==", "cpu": [ "x64" ], @@ -568,9 +568,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.13.tgz", - "integrity": "sha512-Px9PS2B5/Q183bUwy/5VHqp3J2lzdOCeVGzMpphYfl8oSa7VDCqenBdqWpy6DCy/en4Rbf/Y1RieZF6dJPcc9A==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.1.tgz", + "integrity": "sha512-zgXnKNgWPC4iPF7Y1lR3STUeCUuZRpD6IiOrC7TZTlh0Lx6FiVUT05myuMQHQ9D+1cc7uyMldi4forE6lp0ivQ==", "cpu": [ "arm64" ], @@ -585,9 +585,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.13.tgz", - "integrity": "sha512-tTcMkXyBrmHi9BfrD2VNHs/5rYIUKETqsBlYOvSAABwBkJhSDVb5e7wPukftsQbO3WzQkXe6kaztC6WtUOXSoQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.1.tgz", + "integrity": "sha512-6uxpR9hvaglANkZemeSiN/FhYgkGasrEGn267eXIWvjrjJ2LhDlk251IhjVJq6MXzkV2/bcXwLwSroLyPtqRZg==", "cpu": [ "x64" ], @@ -613,251 +613,246 @@ } }, "node_modules/@commitlint/cli": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.5.0.tgz", - "integrity": "sha512-yNkyN/tuKTJS3wdVfsZ2tXDM4G4Gi7z+jW54Cki8N8tZqwKBltbIvUUrSbT4hz1bhW/h0CdR+5sCSpXD+wMKaQ==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.1.0.tgz", + "integrity": "sha512-CVwY6TxGv5naEaWxBdgNHko1xgL95Mb4WcIqp9iik33H0ctVqRv6YtekCntayhEP0T/apuiGvHu5HcCwFuVxEA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/format": "^20.5.0", - "@commitlint/lint": "^20.5.0", - "@commitlint/load": "^20.5.0", - "@commitlint/read": "^20.5.0", - "@commitlint/types": "^20.5.0", + "@commitlint/config-conventional": "^21.1.0", + "@commitlint/format": "^21.1.0", + "@commitlint/lint": "^21.1.0", + "@commitlint/load": "^21.1.0", + "@commitlint/read": "^21.1.0", + "@commitlint/types": "^21.1.0", "tinyexec": "^1.0.0", - "yargs": "^17.0.0" + "yargs": "^18.0.0" }, "bin": { "commitlint": "cli.js" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/config-conventional": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.5.0.tgz", - "integrity": "sha512-t3Ni88rFw1XMa4nZHgOKJ8fIAT9M2j5TnKyTqJzsxea7FUetlNdYFus9dz+MhIRZmc16P0PPyEfh6X2d/qw8SA==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.1.0.tgz", + "integrity": "sha512-BIFl8xM+3SLy3jrblUC3wmQLCVbLty+++6o859BDCmybVrQdXmIWO+dlkGIbv/M2bBoC55wGuh0zGiw3TPjL1g==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.5.0", + "@commitlint/types": "^21.1.0", "conventional-changelog-conventionalcommits": "^9.2.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/config-validator": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.5.0.tgz", - "integrity": "sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.1.0.tgz", + "integrity": "sha512-gHczt1xqQSwfNqBmOI3HjejtTljkiBEUneExMmTBLD0WwTC78lAqDvNMyydbySt3DhpH0F9oX7Vvuks6s5XPFw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.5.0", + "@commitlint/types": "^21.1.0", "ajv": "^8.11.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/ensure": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.5.0.tgz", - "integrity": "sha512-IpHqAUesBeW1EDDdjzJeaOxU9tnogLAyXLRBn03SHlj1SGENn2JGZqSWGkFvBJkJzfXAuCNtsoYzax+ZPS+puw==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.1.0.tgz", + "integrity": "sha512-/S8Mo3Q1NtQUYDQjDmyQVPxfIwtnxq+guzMOkuGk8OSdwlzanm1WB9wDPIuuzlbMDDnBNbiAuBEUCcCNlfjrTQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.5.0", - "lodash.camelcase": "^4.3.0", - "lodash.kebabcase": "^4.1.1", - "lodash.snakecase": "^4.1.1", - "lodash.startcase": "^4.4.0", - "lodash.upperfirst": "^4.3.1" + "@commitlint/types": "^21.1.0", + "es-toolkit": "^1.46.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/execute-rule": { - "version": "20.0.0", - "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-20.0.0.tgz", - "integrity": "sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-21.0.1.tgz", + "integrity": "sha512-RifH+FmImozKBE6mozhF4K3r2RRKP7SMi/Q/zLCmExtp5e05lhHOUYqGBlFBAGNHaZxU/WYw1XuugYK9jQzqnA==", "dev": true, "license": "MIT", "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/format": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-20.5.0.tgz", - "integrity": "sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.1.0.tgz", + "integrity": "sha512-ySymqKYBfjNrQ5N4W/l1iF2ISW1W7Eu/Oi/wRxlri31N0yjNyzUyUzQwyuZLDzTXIlMs4IZ7hIOfAZx8lO18gA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.5.0", + "@commitlint/types": "^21.1.0", "picocolors": "^1.1.1" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/is-ignored": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.5.0.tgz", - "integrity": "sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.1.0.tgz", + "integrity": "sha512-RoRh1/YI+fYH+aid5lMQ2UD0vZ3p3Vf1KeUWT1ir3H/p/7T/6SFv1OiXLgLwUT8dP72EVWeEIyOfkiSWLZYVvw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.5.0", + "@commitlint/types": "^21.1.0", "semver": "^7.6.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/lint": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.5.0.tgz", - "integrity": "sha512-jiM3hNUdu04jFBf1VgPdjtIPvbuVfDTBAc6L98AWcoLjF5sYqkulBHBzlVWll4rMF1T5zeQFB6r//a+s+BBKlA==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.1.0.tgz", + "integrity": "sha512-0DbfVVUjAWBfixW6v7CXXWVxMcj6Ukf/oB7O8NAbouP3jxmqUaC4eVQphxl3B3M0ii3cCQiR3sRAYxICwU2gAA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/is-ignored": "^20.5.0", - "@commitlint/parse": "^20.5.0", - "@commitlint/rules": "^20.5.0", - "@commitlint/types": "^20.5.0" + "@commitlint/is-ignored": "^21.1.0", + "@commitlint/parse": "^21.1.0", + "@commitlint/rules": "^21.1.0", + "@commitlint/types": "^21.1.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/load": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.5.0.tgz", - "integrity": "sha512-sLhhYTL/KxeOTZjjabKDhwidGZan84XKK1+XFkwDYL/4883kIajcz/dZFAhBJmZPtL8+nBx6bnkzA95YxPeDPw==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.1.0.tgz", + "integrity": "sha512-juiClVEcoreNB0TNVkseO2EmNcpEs/Yhnmgbnm/hQAKBFRynKwIaoNIljXkx/3yvZcMO0EE8I2XOEI7d5KZG8Q==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^20.5.0", - "@commitlint/execute-rule": "^20.0.0", - "@commitlint/resolve-extends": "^20.5.0", - "@commitlint/types": "^20.5.0", + "@commitlint/config-validator": "^21.1.0", + "@commitlint/execute-rule": "^21.0.1", + "@commitlint/resolve-extends": "^21.1.0", + "@commitlint/types": "^21.1.0", "cosmiconfig": "^9.0.1", "cosmiconfig-typescript-loader": "^6.1.0", + "es-toolkit": "^1.46.0", "is-plain-obj": "^4.1.0", - "lodash.mergewith": "^4.6.2", "picocolors": "^1.1.1" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/message": { - "version": "20.4.3", - "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-20.4.3.tgz", - "integrity": "sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==", + "version": "21.0.2", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-21.0.2.tgz", + "integrity": "sha512-5n4aqHGD/FNnom/D5L8i7cYtV+xjuXcBL832C3w9VglEsZzIsoHpJsvxzJ7cgiOsOdc/2jU4t5+7qMHh7GBX3g==", "dev": true, "license": "MIT", "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/parse": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-20.5.0.tgz", - "integrity": "sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.1.0.tgz", + "integrity": "sha512-HdAqbbjQS8eEtbR74Ysg2VNmbvAfeWLVYMkip/lHibNrtjRsC/97XAYN3/H5P0pEJtDfyTb3iLs8x6y0eu4OYA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.5.0", + "@commitlint/types": "^21.1.0", "conventional-changelog-angular": "^8.2.0", "conventional-commits-parser": "^6.3.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/read": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-20.5.0.tgz", - "integrity": "sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.1.0.tgz", + "integrity": "sha512-ID7m79aw8d0dMlxuXHD2QGxEX3Fhl/mUPA80WwEW5VgeOpUHNahhwWJefDdoBDVZcDfbHuf429NrcK0gxQsQjA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/top-level": "^20.4.3", - "@commitlint/types": "^20.5.0", + "@commitlint/top-level": "^21.0.2", + "@commitlint/types": "^21.1.0", "git-raw-commits": "^5.0.0", - "minimist": "^1.2.8", "tinyexec": "^1.0.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/resolve-extends": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.5.0.tgz", - "integrity": "sha512-3SHPWUW2v0tyspCTcfSsYml0gses92l6TlogwzvM2cbxDgmhSRc+fldDjvGkCXJrjSM87BBaWYTPWwwyASZRrg==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.1.0.tgz", + "integrity": "sha512-SANYkxJDfMl3TvnyALWHEaiF5nc6FFaOnh7VvfxjT4X2vD4i2gVHhmfMm1fsrBwDRX98/XyM1XDo5sAd/KXcyQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^20.5.0", - "@commitlint/types": "^20.5.0", - "global-directory": "^4.0.1", - "import-meta-resolve": "^4.0.0", - "lodash.mergewith": "^4.6.2", + "@commitlint/config-validator": "^21.1.0", + "@commitlint/types": "^21.1.0", + "es-toolkit": "^1.46.0", + "global-directory": "^5.0.0", "resolve-from": "^5.0.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/rules": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.5.0.tgz", - "integrity": "sha512-5NdQXQEdnDPT5pK8O39ZA7HohzPRHEsDGU23cyVCNPQy4WegAbAwrQk3nIu7p2sl3dutPk8RZd91yKTrMTnRkQ==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.1.0.tgz", + "integrity": "sha512-fOPEYSmKn1ZJptjLmCEjJfYqz0PUYr8ng6VY2ZW26sB7KtENR90CmAXHEmScBbOIZip+d/+OwqK12DFBuHTqsQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/ensure": "^20.5.0", - "@commitlint/message": "^20.4.3", - "@commitlint/to-lines": "^20.0.0", - "@commitlint/types": "^20.5.0" + "@commitlint/ensure": "^21.1.0", + "@commitlint/message": "^21.0.2", + "@commitlint/to-lines": "^21.0.1", + "@commitlint/types": "^21.1.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/to-lines": { - "version": "20.0.0", - "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-20.0.0.tgz", - "integrity": "sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-21.0.1.tgz", + "integrity": "sha512-bd1BFII7p1EQZre9Kaj+kKaMFP3cFCdt21K7DItVux9XP5WjLgJ0/Uy1pJJh9aPwVJ6SKg62PxqlZaHI8hQAXw==", "dev": true, "license": "MIT", "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/top-level": { - "version": "20.4.3", - "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-20.4.3.tgz", - "integrity": "sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==", + "version": "21.0.2", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-21.0.2.tgz", + "integrity": "sha512-s9KKM+e+mXgFeIh4n7KmOGAVT3mkJ3Fp1bBYHIK5pjeUwlEMzp/tZfb5u0Poa680AsQTXMEMRxZi1vQ9m2X5ug==", "dev": true, "license": "MIT", "dependencies": { "escalade": "^3.2.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/types": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-20.5.0.tgz", - "integrity": "sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.1.0.tgz", + "integrity": "sha512-YodnnnH1Cp+08nP8HGNJAIuB6L3/vdCTHVRTfF8Ik/wRCLOTsU9zwv3yO1cSPQRDa9CLYtE+UJ2K67r7CwMSFw==", "dev": true, "license": "MIT", "dependencies": { @@ -865,7 +860,7 @@ "picocolors": "^1.1.1" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@conventional-changelog/git-client": { @@ -896,21 +891,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -919,9 +914,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -974,9 +969,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz", - "integrity": "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -991,9 +986,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.1.tgz", - "integrity": "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -1008,9 +1003,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.1.tgz", - "integrity": "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -1025,9 +1020,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.1.tgz", - "integrity": "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -1042,9 +1037,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz", - "integrity": "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -1059,9 +1054,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.1.tgz", - "integrity": "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -1076,9 +1071,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.1.tgz", - "integrity": "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -1093,9 +1088,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.1.tgz", - "integrity": "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -1110,9 +1105,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.1.tgz", - "integrity": "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -1127,9 +1122,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.1.tgz", - "integrity": "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -1144,9 +1139,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.1.tgz", - "integrity": "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -1161,9 +1156,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.1.tgz", - "integrity": "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -1178,9 +1173,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.1.tgz", - "integrity": "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -1195,9 +1190,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.1.tgz", - "integrity": "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -1212,9 +1207,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.1.tgz", - "integrity": "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -1229,9 +1224,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.1.tgz", - "integrity": "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -1246,9 +1241,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.1.tgz", - "integrity": "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -1263,9 +1258,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.1.tgz", - "integrity": "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -1280,9 +1275,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.1.tgz", - "integrity": "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -1297,9 +1292,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.1.tgz", - "integrity": "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -1314,9 +1309,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.1.tgz", - "integrity": "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -1331,9 +1326,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.1.tgz", - "integrity": "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -1348,9 +1343,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.1.tgz", - "integrity": "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -1365,9 +1360,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.1.tgz", - "integrity": "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -1382,9 +1377,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.1.tgz", - "integrity": "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -1399,9 +1394,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz", - "integrity": "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -1423,13 +1418,13 @@ "license": "MIT" }, "node_modules/@graphql-codegen/add": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-6.0.1.tgz", - "integrity": "sha512-MSylSekjpVWbOBw2A/2ssk1fPY54sYb6Qk2C4AX5u7s2R+2pMQ9ws7DTXo8VU9qwTgWwVp6vGfdQ0AMpAn4Iug==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-7.0.1.tgz", + "integrity": "sha512-kWw6RMu9ysBw1wcgcgf9mOnswc5M3ekOApDTiaJC/UZNTEYins01srZHYTP7z3P/WlGGC844BRtjwh3U2kNd/A==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.3.0", + "@graphql-codegen/plugin-helpers": "^7.0.1", "tslib": "^2.8.0" }, "engines": { @@ -1440,18 +1435,18 @@ } }, "node_modules/@graphql-codegen/cli": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-6.3.1.tgz", - "integrity": "sha512-I5KkyX1SgQZPojMeQTRydB6fml4cysZq/mIdhNW4rmqdoOcTgdMPq1Tl+wtRp1VpBAOrBazJUJh1nAqJMMSPIQ==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-7.1.3.tgz", + "integrity": "sha512-mMYwpvpqJjjHoA/c6HBjdlbT8JqFC6W85RB80tpHACapufBnLlyNtYHYeOYAoUuU1n3cGQi1if1pKHnjLgS/eQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/generator": "^7.18.13", "@babel/template": "^7.18.10", "@babel/types": "^7.18.13", - "@graphql-codegen/client-preset": "^5.3.0", - "@graphql-codegen/core": "^5.0.2", - "@graphql-codegen/plugin-helpers": "^6.3.0", + "@graphql-codegen/client-preset": "^6.0.1", + "@graphql-codegen/core": "^6.1.0", + "@graphql-codegen/plugin-helpers": "^7.0.1", "@graphql-tools/apollo-engine-loader": "^8.0.28", "@graphql-tools/code-file-loader": "^8.1.28", "@graphql-tools/git-loader": "^8.0.32", @@ -1462,30 +1457,31 @@ "@graphql-tools/merge": "^9.0.6", "@graphql-tools/url-loader": "^9.0.6", "@graphql-tools/utils": "^11.0.0", - "@inquirer/prompts": "^7.8.2", + "@inquirer/prompts": "^8.3.2", "@whatwg-node/fetch": "^0.10.0", - "chalk": "^4.1.0", + "chalk": "^5.6.0", "cosmiconfig": "^9.0.0", - "debounce": "^2.0.0", - "detect-indent": "^6.0.0", + "debounce": "^3.0.0", + "detect-indent": "^7.0.0", "graphql-config": "^5.1.6", "is-glob": "^4.0.1", "jiti": "^2.3.0", "json-to-pretty-yaml": "^1.2.2", - "listr2": "^9.0.0", - "log-symbols": "^4.0.0", + "listr2": "^10.2.1", + "log-symbols": "^7.0.0", "micromatch": "^4.0.5", "shell-quote": "^1.7.3", "string-env-interpolation": "^1.0.1", - "ts-log": "^2.2.3", + "ts-log": "^3.0.0", "tslib": "^2.4.0", "yaml": "^2.3.1", - "yargs": "^17.0.0" + "yargs": "^18.0.0" }, "bin": { - "gql-gen": "cjs/bin.js", - "graphql-code-generator": "cjs/bin.js", - "graphql-codegen": "cjs/bin.js", + "gql-gen": "esm/bin.js", + "graphql-code-generator": "esm/bin.js", + "graphql-codegen": "esm/bin.js", + "graphql-codegen-cjs": "cjs/bin.js", "graphql-codegen-esm": "esm/bin.js" }, "engines": { @@ -1501,22 +1497,35 @@ } } }, + "node_modules/@graphql-codegen/cli/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/@graphql-codegen/client-preset": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-5.3.0.tgz", - "integrity": "sha512-K9FON+j7qyxAUDuSGqI3ofb7lWTBs16oPTYpu14lhdL4DKZQSHLyc8EMYU9e3KcyQ/13gU/d6culOppzAuexLA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-6.0.1.tgz", + "integrity": "sha512-6wh0ZHG9WzBD6bE4AVOO6VCCMXK2orxHuXxaNKj+sj1w0qZ3Y3WIjZnqZLg6JZrHCIs/e+gy3T15Dc2pH8IbHA==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.20.2", "@babel/template": "^7.20.7", - "@graphql-codegen/add": "^6.0.1", - "@graphql-codegen/gql-tag-operations": "5.2.0", - "@graphql-codegen/plugin-helpers": "^6.3.0", - "@graphql-codegen/typed-document-node": "^6.1.8", - "@graphql-codegen/typescript": "^5.0.10", - "@graphql-codegen/typescript-operations": "^5.1.0", - "@graphql-codegen/visitor-plugin-common": "^6.3.0", + "@graphql-codegen/add": "^7.0.1", + "@graphql-codegen/gql-tag-operations": "^6.0.1", + "@graphql-codegen/plugin-helpers": "^7.0.1", + "@graphql-codegen/typed-document-node": "^7.0.1", + "@graphql-codegen/typescript": "^6.0.2", + "@graphql-codegen/typescript-operations": "^6.0.3", + "@graphql-codegen/visitor-plugin-common": "^7.0.3", "@graphql-tools/documents": "^1.0.0", "@graphql-tools/utils": "^11.0.0", "@graphql-typed-document-node/core": "3.2.0", @@ -1535,36 +1544,36 @@ } } }, - "node_modules/@graphql-codegen/core": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@graphql-codegen/core/-/core-5.0.2.tgz", - "integrity": "sha512-7RX0wwjoWPlLG/tUmpaTK91ZZqHcACNWpRL0nGnnJaJrORie9pgmX8JPrcwBgYiHSC+3ERo9xY91RFPem/VrpQ==", + "node_modules/@graphql-codegen/client-preset/node_modules/@graphql-codegen/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-zyLfKsFJ7TRkQ0PyaUVuiAek9TSbtVJwwBoOuaE9RAWr45+9Y5W1LYldpiSTcyfxKVSIniE7Gj0V87qzrpdyYw==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.3.0", - "@graphql-tools/schema": "^10.0.0", - "@graphql-tools/utils": "^11.0.0", - "tslib": "^2.8.0" + "@graphql-codegen/plugin-helpers": "^7.0.1", + "@graphql-codegen/schema-ast": "^6.0.1", + "@graphql-codegen/visitor-plugin-common": "^7.0.3", + "auto-bind": "^5.0.0", + "tslib": "~2.8.0" }, "engines": { "node": ">=16" }, "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, - "node_modules/@graphql-codegen/gql-tag-operations": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/gql-tag-operations/-/gql-tag-operations-5.2.0.tgz", - "integrity": "sha512-B9gtJ4ziqpIv+7mHqwjtpYLFOuv0GmmRGpNDoWKM2VIx4OQqgI84d6OHKYCVeO7yu3mUr0QPvUgkSyuLVrdukA==", + "node_modules/@graphql-codegen/core": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/core/-/core-6.1.0.tgz", + "integrity": "sha512-jReAzuCYlrSBJHW2bfBpDl/vMRCw0yQEoTvGi9K+3OTsazDXEQGOpCVfj8p/xO2h7ynu5Yrvzo0sUylVv0CnwA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.3.0", - "@graphql-codegen/visitor-plugin-common": "^6.3.0", + "@graphql-codegen/plugin-helpers": "^7.0.1", + "@graphql-tools/schema": "^10.0.0", "@graphql-tools/utils": "^11.0.0", - "auto-bind": "~4.0.0", "tslib": "^2.8.0" }, "engines": { @@ -1574,15 +1583,17 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, - "node_modules/@graphql-codegen/introspection": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@graphql-codegen/introspection/-/introspection-5.0.2.tgz", - "integrity": "sha512-2Y1xC4A/6yudxvpyHLF6wcrZSm1BBGsaxabbZJCWebImXdYNU+yAdbiiaHfYrHMUEVgPnjo/qo4gt0m8JqeRHQ==", + "node_modules/@graphql-codegen/gql-tag-operations": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/gql-tag-operations/-/gql-tag-operations-6.0.1.tgz", + "integrity": "sha512-eHYUIchZLG6G+kafeKnUByL2Nkmb8Uj2vg33UVLFj8XJ2coC4b1iRDWxCdTXbupZrN0FaM0QRRBLs3zBEAzcJg==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.3.0", - "@graphql-codegen/visitor-plugin-common": "^6.3.0", + "@graphql-codegen/plugin-helpers": "^7.0.1", + "@graphql-codegen/visitor-plugin-common": "^7.0.3", + "@graphql-tools/utils": "^11.0.0", + "auto-bind": "^5.0.0", "tslib": "^2.8.0" }, "engines": { @@ -1593,14 +1604,14 @@ } }, "node_modules/@graphql-codegen/plugin-helpers": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-6.3.0.tgz", - "integrity": "sha512-Auc+/B7okDx9+pVgLVliZtZLYh6iltWXlnzzM+bRE+zh1T4r3hKbnr8xAmtT937ArfSgk5GHcQHr8LfPYnrRBg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-7.0.1.tgz", + "integrity": "sha512-S2X0YT3XQbP2haqhIeku8GOXo2j8QuBu7BrLsOEHz4UeMu78y3rja1Q4ri3oJ0jq4dMgaQlazoVHI/A+FAKMGw==", "dev": true, "license": "MIT", "dependencies": { "@graphql-tools/utils": "^11.0.0", - "change-case-all": "1.0.15", + "change-case-all": "^2.1.0", "common-tags": "1.8.2", "import-from": "4.0.0", "tslib": "^2.8.0" @@ -1613,13 +1624,13 @@ } }, "node_modules/@graphql-codegen/schema-ast": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@graphql-codegen/schema-ast/-/schema-ast-5.0.2.tgz", - "integrity": "sha512-jl1F/9IjRkJisEb9B0ayG4QGqYlPldLRy8ojDdmL9NE1NsdB5ROfxQnSqyC3g+wuvBhWX7kZgMRQYn3RU1I5bA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/schema-ast/-/schema-ast-6.0.1.tgz", + "integrity": "sha512-P16b6XCWXfcrA4fkuAyqoy883USAULifv8YWgEOrNKDAnr2DR+Kr85jSomknIUTY39wiuvisv4/lrdXobwK6sA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.3.0", + "@graphql-codegen/plugin-helpers": "^7.0.1", "@graphql-tools/utils": "^11.0.0", "tslib": "^2.8.0" }, @@ -1631,16 +1642,16 @@ } }, "node_modules/@graphql-codegen/typed-document-node": { - "version": "6.1.8", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typed-document-node/-/typed-document-node-6.1.8.tgz", - "integrity": "sha512-+qDdiJSQ7Ol+vpLMAH8ZJok50CvlYxA6seQ7cwEa3emXt8MmH5hh3zdc9unQlPc7bynoJHRCgoKk7E0B7hry0w==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typed-document-node/-/typed-document-node-7.0.3.tgz", + "integrity": "sha512-l/4KenYJG5D8Aj6Aa2KPeS0fdMIIi04Qx28d4SLwMWuyFU9WXspU5mR9YMNDRZzaTgBtGR8aMIl9RzyiWf5uUw==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.3.0", - "@graphql-codegen/visitor-plugin-common": "^6.3.0", - "auto-bind": "~4.0.0", - "change-case-all": "1.0.15", + "@graphql-codegen/plugin-helpers": "^7.0.1", + "@graphql-codegen/visitor-plugin-common": "^7.1.0", + "auto-bind": "^5.0.0", + "change-case-all": "^2.1.0", "tslib": "^2.8.0" }, "engines": { @@ -1650,37 +1661,17 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, - "node_modules/@graphql-codegen/typescript": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-5.0.10.tgz", - "integrity": "sha512-Pa8OFmL9TdhEYnLYJLYA9EhP8eEeivP/YDYq4Nb8LQaL7GXm4TGX8zELYaCM9Fu8M3iZb7iQGMt7qc+1lXz8XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.3.0", - "@graphql-codegen/schema-ast": "^5.0.2", - "@graphql-codegen/visitor-plugin-common": "^6.3.0", - "auto-bind": "~4.0.0", - "tslib": "^2.8.0" - }, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" - } - }, "node_modules/@graphql-codegen/typescript-operations": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-5.1.0.tgz", - "integrity": "sha512-JlmjbFl0EnsfMDIYvTE1Q0kAOrntVEZ+ZfBqWTP91g4e0F/TzuwJ/V4tiFmeDf5dx/rf9AK4VkPehIdxu7TYhw==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-6.0.5.tgz", + "integrity": "sha512-amsfjYbLIbYUIPr481hlXXjgnm5knel3v6EXJ8oThhcbOoT332XJjzjohZaTjij7eqebOewzzRXLPWi8z+mgSA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.3.0", - "@graphql-codegen/typescript": "^5.0.10", - "@graphql-codegen/visitor-plugin-common": "^6.3.0", - "auto-bind": "~4.0.0", + "@graphql-codegen/plugin-helpers": "^7.0.1", + "@graphql-codegen/schema-ast": "^6.0.1", + "@graphql-codegen/visitor-plugin-common": "^7.1.2", + "auto-bind": "^5.0.0", "tslib": "^2.8.0" }, "engines": { @@ -1697,18 +1688,18 @@ } }, "node_modules/@graphql-codegen/visitor-plugin-common": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-6.3.0.tgz", - "integrity": "sha512-vGBoE+4huzZyNhyGSAhXAkdROHlwKxxuziZm4XtP1mxe7nuI+VgyOmXebafLijbmuDsptPXQN0C/htL54O8hrg==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-7.1.2.tgz", + "integrity": "sha512-1TdaKeDBj91ZYcJO9fMHHo02HEoWCSYhlsi4B7EGto7cqQa2htraAi+sCYIxQuccxNsRmM6apQ+O5+cJj1Bs7w==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.3.0", + "@graphql-codegen/plugin-helpers": "^7.0.1", "@graphql-tools/optimize": "^2.0.0", "@graphql-tools/relay-operation-optimizer": "^7.1.1", "@graphql-tools/utils": "^11.0.0", - "auto-bind": "~4.0.0", - "change-case-all": "1.0.15", + "auto-bind": "^5.0.0", + "change-case-all": "^2.1.0", "dependency-graph": "^1.0.0", "graphql-tag": "^2.11.0", "parse-filepath": "^1.0.2", @@ -2108,14 +2099,14 @@ } }, "node_modules/@graphql-tools/relay-operation-optimizer": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-7.1.3.tgz", - "integrity": "sha512-Vzh5QORIqX0KtwxgNepl/T16a85Br7YbOxxxmnyVpS7yza9vBjkrERbvAwADcYyPH7kyShmH1Gu5+88+vCVhuA==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-7.1.5.tgz", + "integrity": "sha512-B3nscUeWT3wYucrvbJcmU8sAVlkCp+WhZ5wVlK432AfnLjDYUNLobHYzQnnU7tT5NncMXusPiS8K4YG4iUPjrw==", "dev": true, "license": "MIT", "dependencies": { "@ardatan/relay-compiler": "^13.0.1", - "@graphql-tools/utils": "^11.0.1", + "@graphql-tools/utils": "^11.1.1", "tslib": "^2.4.0" }, "engines": { @@ -2171,9 +2162,9 @@ } }, "node_modules/@graphql-tools/utils": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.0.1.tgz", - "integrity": "sha512-pNyCOb95ab/z3zkkiPwIPYxigX7IcpyFVcgD1XACDEvg/7yGnKCESx3k/XHEeneKYx/aWKGzEh/uuf6M6Q8HOw==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.1.1.tgz", + "integrity": "sha512-MuWwacINZZV6mX1ZSk6CcV4XVQLsbKBG/hLd0QPhe4GHxjqr2ATjKsnnwlB0TKI+QQvj2U8ewu8WeAzz9kC2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -2213,36 +2204,36 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "dev": true, "license": "MIT", "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "node_modules/@inquirer/ansi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", - "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } }, "node_modules/@inquirer/checkbox": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", - "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.1.tgz", + "integrity": "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2254,17 +2245,17 @@ } }, "node_modules/@inquirer/confirm": { - "version": "5.1.21", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", - "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2276,23 +2267,22 @@ } }, "node_modules/@inquirer/core": { - "version": "10.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", - "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", "cli-width": "^4.1.0", - "mute-stream": "^2.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.3" + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2303,73 +2293,19 @@ } } }, - "node_modules/@inquirer/core/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@inquirer/core/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@inquirer/core/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@inquirer/core/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@inquirer/editor": { - "version": "4.2.23", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", - "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.2.2.tgz", + "integrity": "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/external-editor": "^1.0.3", - "@inquirer/type": "^3.0.10" + "@inquirer/core": "^11.2.1", + "@inquirer/external-editor": "^3.0.3", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2381,18 +2317,17 @@ } }, "node_modules/@inquirer/expand": { - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", - "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.1.tgz", + "integrity": "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2404,17 +2339,17 @@ } }, "node_modules/@inquirer/external-editor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", - "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.3.tgz", + "integrity": "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==", "dev": true, "license": "MIT", "dependencies": { "chardet": "^2.1.1", - "iconv-lite": "^0.7.0" + "iconv-lite": "^0.7.2" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2426,27 +2361,27 @@ } }, "node_modules/@inquirer/figures": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", - "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } }, "node_modules/@inquirer/input": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", - "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.2.tgz", + "integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2458,17 +2393,17 @@ } }, "node_modules/@inquirer/number": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", - "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.1.1.tgz", + "integrity": "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2480,18 +2415,18 @@ } }, "node_modules/@inquirer/password": { - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", - "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.1.1.tgz", + "integrity": "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2503,25 +2438,25 @@ } }, "node_modules/@inquirer/prompts": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", - "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.5.2.tgz", + "integrity": "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/checkbox": "^4.3.2", - "@inquirer/confirm": "^5.1.21", - "@inquirer/editor": "^4.2.23", - "@inquirer/expand": "^4.0.23", - "@inquirer/input": "^4.3.1", - "@inquirer/number": "^3.0.23", - "@inquirer/password": "^4.0.23", - "@inquirer/rawlist": "^4.1.11", - "@inquirer/search": "^3.2.2", - "@inquirer/select": "^4.4.2" + "@inquirer/checkbox": "^5.2.1", + "@inquirer/confirm": "^6.1.1", + "@inquirer/editor": "^5.2.2", + "@inquirer/expand": "^5.1.1", + "@inquirer/input": "^5.1.2", + "@inquirer/number": "^4.1.1", + "@inquirer/password": "^5.1.1", + "@inquirer/rawlist": "^5.3.1", + "@inquirer/search": "^4.2.1", + "@inquirer/select": "^5.2.1" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2533,18 +2468,17 @@ } }, "node_modules/@inquirer/rawlist": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", - "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.1.tgz", + "integrity": "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2556,19 +2490,18 @@ } }, "node_modules/@inquirer/search": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", - "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.2.1.tgz", + "integrity": "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2580,20 +2513,19 @@ } }, "node_modules/@inquirer/select": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", - "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.1.tgz", + "integrity": "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2605,13 +2537,13 @@ } }, "node_modules/@inquirer/type": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", - "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2672,27 +2604,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@linear/sdk": { - "version": "82.1.0", - "resolved": "https://registry.npmjs.org/@linear/sdk/-/sdk-82.1.0.tgz", - "integrity": "sha512-Ok7o+LqXaenx6Um58NQqjQoQanDsCgAIe9yNgpVbqRSh5APz3Ds1kZUz2vWmSNTNATFZm1zDQtEktTMga2X7UQ==", - "license": "MIT", - "dependencies": { - "@graphql-typed-document-node/core": "^3.2.0" - }, - "engines": { - "node": ">=18.x" - } - }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -2898,79 +2818,27 @@ "@octokit/openapi-types": "^27.0.0" } }, - "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@pnpm/config.env-replace": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", - "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.22.0" - } - }, - "node_modules/@pnpm/network.ca-file": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", - "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "4.2.10" - }, - "engines": { - "node": ">=12.22.0" - } - }, - "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", - "dev": true, - "license": "ISC" - }, - "node_modules/@pnpm/npm-conf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.2.tgz", - "integrity": "sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==", + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.137.0.tgz", + "integrity": "sha512-KDs+0VPdEmasOkpuJHW9V5WCF+cvYdMQv2Jd+aJXt+cxIx12NToRQRbXaRwUEDsZw+/jMk81Ve8ZFbjUkJTOwA==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@pnpm/config.env-replace": "^1.1.0", - "@pnpm/network.ca-file": "^1.0.1", - "config-chain": "^1.1.11" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, - "license": "MIT" - }, - "node_modules/@repeaterjs/repeater": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.0.6.tgz", - "integrity": "sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.137.0.tgz", + "integrity": "sha512-WhALNzfy3x/RfC6bsqX+csavuUY0yHHE7XfgPE5M542uhoBZUUoGTPG+nkMbGoG4+gcfss5s7urMyn5QBHu0sw==", "cpu": [ "arm64" ], @@ -2984,10 +2852,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.137.0.tgz", + "integrity": "sha512-bFPr5hgmNMOMoyPTGtdsK4Ug21RovIPojRMgDDhSp1LtCnc/DkLwGONKjgRjszg677RlGnkYSviQ8hHaUPOVYA==", "cpu": [ "arm64" ], @@ -3001,10 +2869,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.137.0.tgz", + "integrity": "sha512-CL5dMm1asqXIDZHg14FLxj3Mc36w8PI7xCWh1uA4is6z8g2XrIILoTcQYOxDbwzuk34RDPX5IAGUxZr6LA9KAg==", "cpu": [ "x64" ], @@ -3018,10 +2886,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.137.0.tgz", + "integrity": "sha512-79h8rYGnSlKPGWo7mHr2ixO6ea7aW8B0CT965SZ8SLbNnCOH5aOYBTeVXUY6eMvEaiLyWr8Skuiugr5pDYgLGw==", "cpu": [ "x64" ], @@ -3035,10 +2903,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.137.0.tgz", + "integrity": "sha512-ASgmlSimhGyr0lksgVIo6hibz1obnDq4qJbiMX/AzltfgPnanRrzG1Q+23g8ljOHOjv6dsznkUuCYL3gg0sY1Q==", "cpu": [ "arm" ], @@ -3052,17 +2920,14 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.137.0.tgz", + "integrity": "sha512-AU2J9aa22Sx32wRGnDjybOU9TQXXQUud5sdUi+ZB0XxwM8aToWLweV+yA0wlQm0yIUVqljquqoHCYEq9II8gJQ==", "cpu": [ - "arm64" + "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3072,16 +2937,16 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.137.0.tgz", + "integrity": "sha512-GdEtiG89yMr7XkUGxifgodXEEm2f+xW2f9CpDjlgAnBOwhTmrpQMvhOGobLVKUyzf/qHBXW16smk5zbF3nZU6w==", "cpu": [ "arm64" ], "dev": true, "libc": [ - "musl" + "glibc" ], "license": "MIT", "optional": true, @@ -3092,16 +2957,16 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.137.0.tgz", + "integrity": "sha512-EGJ+Bs8iXx8KBH8DQ5BLoEm5lnHaYjlh4/8j8vFhrr/6z4tqONy5BZDzLpKmmNWlN6Hlc5r8YOuBVHqZ9vRFEQ==", "cpu": [ - "ppc64" + "arm64" ], "dev": true, "libc": [ - "glibc" + "musl" ], "license": "MIT", "optional": true, @@ -3112,12 +2977,12 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.137.0.tgz", + "integrity": "sha512-vzFUQENy/fnbSe5DZWovq6tIBc1uhuMztanSW6rz1e9WdQE4gHwYuD7ZII6JnrJifd1R3RSoqiZbgRFlVL2tYQ==", "cpu": [ - "s390x" + "ppc64" ], "dev": true, "libc": [ @@ -3132,10 +2997,749 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.137.0.tgz", + "integrity": "sha512-SfVI14HBQs9gtLcUD5hTt5hsNbdrqSUNg9S8muN+LhVQ5nf1WwH3hAoK6B9NKgdYgWAQSXFXGiiBedQ4r/BKuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.137.0.tgz", + "integrity": "sha512-e7Ppy4FCIFNQxT/ikSeIWFoQ0l+N9vgtRBtLcyZXeolTzApyVoPqEXsYPrcdM/9i0Bwk8knvYd37vaEMxHyi6g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.137.0.tgz", + "integrity": "sha512-Bho5qFwdhqsIFR7gipYEUlqvi3SRrY8sugxXig380MIaakBB1PyU9+7dBiBVScfImTNWhijUxdBwqrprGdq5WA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.137.0.tgz", + "integrity": "sha512-36mGWtg7PyFzjJwGDkH6/F4o2nIDEoKXLPr/X/lwqklkomQwJJt1I5GJVmGhovUEmgPK5WAeAZMqlFCehwiy9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.137.0.tgz", + "integrity": "sha512-/Jqx6+N7A44n2BdvUr7pXhVr2vFjs6WGH3unZRczwrfiH0H1zY0QwKQMG/dtRiTlKGDKGukznPT8lx84/oEsZg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.137.0.tgz", + "integrity": "sha512-9Uj0qHNNl+OgT1UTGwF7ixIXU6T1u2SbMidmgPy/h1h/fl2gRS6YpAxxY1gwHofcWjoTwkoMFd8xs5Vuj6GOFA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.137.0.tgz", + "integrity": "sha512-gW2vfkytNGgMVADiuzdvOfw0mWG9za20F/1fCJsif5aBMAvWJTSbpIXbIe0XkOe0VENk+PadpQ7cZgUy2sUJcA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.5" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.137.0.tgz", + "integrity": "sha512-x+pFANF0yL5uK/6T7lu6SlR5qid6sp//eZXKLq5iNsIE+EQg6EaS8/wsW7E91nXXjpnPhSoMOHXShSVhGRdn8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.137.0.tgz", + "integrity": "sha512-sQUqym80PFi6McRsIqfJrSu2JrSClEZIXXD+/FjAFoULEKzOPsldIdFBG96xdX8aVMzCNQ9792FPx3MfkEIrFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.137.0.tgz", + "integrity": "sha512-2AsevxlvNN4WKxpEn3RtqD5zbqMaXF+T7JXblsP4gVuY+vC9dXS4ED/PwfRCliFqoeisYS3Iro4DHzxr0TEvVA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.21.3.tgz", + "integrity": "sha512-eNU11A2WNizh04v3uyaJCootrHIaS0B9aHYXvAvVnPNk4xYSjMUjHnhQ6dewPN2MRYDskV85d1N0Aw0WNWhcyg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.21.3.tgz", + "integrity": "sha512-8Q+ZjTLvn2dIcWsrmhdrEihm7q+ag/k+mkry7Z+t0QbbHaVxXQfvH9AewyVMh/WrpEKhQ3DDgx9fYbqeCpeOEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.21.3.tgz", + "integrity": "sha512-wkh0qKZGHXVUDxFw3oA1TXnU2BDYY/r775oJflGeIr8uDPPoN2pk8gijQIzYRT6hoql/lg3+Tx/SaTn9e2/aGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.21.3.tgz", + "integrity": "sha512-HbNc23FAQYbuyDV2vBWMez4u4mrsm5RAkniGZAWqr6lYZ3N4beeqIb776jzwRl8qL2zRhHVXpUj97X0QgogVzg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.21.3.tgz", + "integrity": "sha512-K6xNsTUPEUdfrn0+kbMq5nOUB5w1C5pavPQngt4TM2FpN91lP0PBe2srSpamb4d69O7h86oAi/qWX/kZNRSjkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.21.3.tgz", + "integrity": "sha512-VcFmOpcpWX1zoEy8M58tR2M9YxM+Z9RuQhqAx5q0CTmrruaP7Gveejg75hzd/5sg5nk9G3aLALEa3hE2FsmmTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.21.3.tgz", + "integrity": "sha512-quVoxFLBy43hWaQbbDtQNRwAX5vX76mv7n64icAtQcJ3eNgVeblqmkupF/hAneNthdqSlnd1sTjb3aQSaDPaCQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.21.3.tgz", + "integrity": "sha512-X0AqNZgcD07Q4V3RDK18/vYOj/HQT/FnmEFGYS2jTWqY7JO13ryE3TEs3eAIgUJhBnNkpEaiXqz3VK8M7qQhWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.21.3.tgz", + "integrity": "sha512-YkaQnaKYdbuaXvRt5Qd0GpbihzVnyfR6z1SpYfIUC6RTu4NF7lDKPjVkYb+jRI2gedVO2rVpN35Y6akG6ud4Lw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.21.3.tgz", + "integrity": "sha512-gB9HwhrPiFqUzDeEq+y/CgAijz1YdI6BnXz5GaH2Pa9cWdutchlkGFAiAuGb/PjVQpiK6NFKzFuztxrweoit7A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.21.3.tgz", + "integrity": "sha512-zjDWBlYk8QGv0H8dsPUWqkfjYIIjG2TvspGkzXL0eImbgxtZorA/klKeHyolevoT3Kvbi+1iMr9Lhrh7jf54Og==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.21.3.tgz", + "integrity": "sha512-4UfsQvacV388y1zpXL7C1x1FNYaV52JtuNRiuzrfQA2z1z6ElVrsidkGsrvQ5EgeSq1Pj7kaKqrgGkvFuxJ/tw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.21.3.tgz", + "integrity": "sha512-b5uH+HKH0MP5mNBYaK75SKsJbw52URqrx2LavYdq6wb0l3ExAG5niYRP9DWUNHdKilpaBVM2bXk9HNWrH3ew7Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.21.3.tgz", + "integrity": "sha512-PjYlmilBpNRh2ntXNYAK3Am5w/nPfEpnU/96iNx7CI8EzAn12J4JRiec63wHJTH31nLoCNxBg/829pN+3CfG3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.21.3.tgz", + "integrity": "sha512-QTBAb7JuHlZ7JUEyM8UiQi2f7m/L4swBhP2TNpYIDc9Wp/wRw1G/8sl6i13aIzQAXH7LKIm294LeOHd0lQR8zA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-openharmony-arm64": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.21.3.tgz", + "integrity": "sha512-4j1DFwjwv36ec9kds0jU/ucQ5Ha4ERO/H95BxR5JFf0kqUUAJ1kwII7XhTc1vZrkdJkvLGC9Q2MbpObpum8RBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.21.3.tgz", + "integrity": "sha512-i8oluoel5kru/j1WNrjmQSiA3GQ7wvIYVR1IwIoZtKogAhya2iub+ZKIeSIkcJOrnzQ18Tzl/F+kL3fYOxZLvA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.0", + "@emnapi/runtime": "1.11.0", + "@napi-rs/wasm-runtime": "^1.1.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz", + "integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz", + "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.21.3.tgz", + "integrity": "sha512-M/8dw8dD6aOs+NlPJax401CZB9I7Aut84isQLgALGGwke4Afvw+/7yYhZb94yXf6t2sPLhQLmSmtSV+2FhsOWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.21.3.tgz", + "integrity": "sha512-H7BCt/VnS9hnmMp42eGhZ99izSCRvlnWwy/N71K1/J8QoExwY4262Z8QiEkMDtduRJrztayDxETTckmUuAVL9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@pnpm/config.env-replace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", + "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", + "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "4.2.10" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "dev": true, + "license": "ISC" + }, + "node_modules/@pnpm/npm-conf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.2.tgz", + "integrity": "sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/config.env-replace": "^1.1.0", + "@pnpm/network.ca-file": "^1.0.1", + "config-chain": "^1.1.11" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@repeaterjs/repeater": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.0.6.tgz", + "integrity": "sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", "cpu": [ "x64" ], @@ -3153,9 +3757,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", "cpu": [ "x64" ], @@ -3173,9 +3777,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", "cpu": [ "arm64" ], @@ -3190,9 +3794,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", "cpu": [ "wasm32" ], @@ -3200,18 +3804,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", "cpu": [ "arm64" ], @@ -3226,9 +3830,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", "cpu": [ "x64" ], @@ -3243,9 +3847,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -3504,9 +4108,9 @@ } }, "node_modules/@semantic-release/github": { - "version": "12.0.6", - "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.6.tgz", - "integrity": "sha512-aYYFkwHW3c6YtHwQF0t0+lAjlU+87NFOZuH2CvWFD0Ylivc7MwhZMiHOJ0FMpIgPpCVib/VUAcOwvrW0KnxQtA==", + "version": "12.0.8", + "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.8.tgz", + "integrity": "sha512-tej5AAgK5X9wHRoDmYhecMXEHEkFeGOY1XsEblKxu8pIQwahzf1STYyr7iPU6Lpbg6C5I3N2w/ocXrBo+L7jhw==", "dev": true, "license": "MIT", "dependencies": { @@ -3518,8 +4122,8 @@ "aggregate-error": "^5.0.0", "debug": "^4.3.4", "dir-glob": "^3.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", + "http-proxy-agent": "^9.0.0", + "https-proxy-agent": "^9.0.0", "issue-parser": "^7.0.0", "lodash-es": "^4.17.21", "mime": "^4.0.0", @@ -3906,9 +4510,9 @@ } }, "node_modules/@semantic-release/release-notes-generator": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.0.tgz", - "integrity": "sha512-CcyDRk7xq+ON/20YNR+1I/jP7BYKICr1uKd1HHpROSnnTdGqOTburi4jcRiTYz0cpfhxSloQO3cGhnoot7IEkA==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.1.tgz", + "integrity": "sha512-Pbd2e2XRMUD0OxehHpgd5/YghsE76cddkRHSoDvKLK+OCy4Ewxn49rWR631MEUU01lgwF/uyVXvbnVuu6+Z6VA==", "dev": true, "license": "MIT", "dependencies": { @@ -3917,9 +4521,7 @@ "conventional-commits-filter": "^5.0.0", "conventional-commits-parser": "^6.0.0", "debug": "^4.0.0", - "get-stream": "^7.0.0", "import-from-esm": "^2.0.0", - "into-stream": "^7.0.0", "lodash-es": "^4.17.21", "read-package-up": "^11.0.0" }, @@ -3992,9 +4594,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -4028,13 +4630,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", - "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/normalize-package-data": { @@ -4055,14 +4657,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", - "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.9", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -4076,8 +4678,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.5", - "vitest": "4.1.5" + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -4086,16 +4688,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -4104,13 +4706,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.5", + "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4131,9 +4733,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { @@ -4144,13 +4746,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.9", "pathe": "^2.0.3" }, "funding": { @@ -4158,14 +4760,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -4174,9 +4776,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, "license": "MIT", "funding": { @@ -4184,13 +4786,13 @@ } }, "node_modules/@vitest/ui": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.5.tgz", - "integrity": "sha512-3Z9HNFiV0IF1fk0JPiK+7kE1GcaIPefQQIBYur6PM5yFIq6agys3uqP/0t966e1wXfmjbRCHDe7qW236Xjwnag==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.9.tgz", + "integrity": "sha512-U/cRvtqfEPj27FI1n9cyUvi4vXXdcLhjJiI+InYKdk8hP4VrS6RXOjGL7rfFaeBc37iRKANsR6eEzIoC7lmgBQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.9", "fflate": "^0.8.2", "flatted": "^3.4.2", "pathe": "^2.0.3", @@ -4202,17 +4804,17 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.5" + "vitest": "4.1.9" } }, "node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", + "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4278,13 +4880,13 @@ } }, "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 14" + "node": ">= 20" } }, "node_modules/aggregate-error": { @@ -4302,9 +4904,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { @@ -4428,13 +5030,13 @@ "license": "MIT" }, "node_modules/auto-bind": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-4.0.0.tgz", - "integrity": "sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -4521,17 +5123,6 @@ "node": ">=6" } }, - "node_modules/camel-case": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", - "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "pascal-case": "^3.1.2", - "tslib": "^2.0.3" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001767", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz", @@ -4553,18 +5144,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/capital-case": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/capital-case/-/capital-case-1.0.4.tgz", - "integrity": "sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3", - "upper-case-first": "^2.0.2" - } - }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -4609,43 +5188,23 @@ } }, "node_modules/change-case": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/change-case/-/change-case-4.1.2.tgz", - "integrity": "sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "camel-case": "^4.1.2", - "capital-case": "^1.0.4", - "constant-case": "^3.0.4", - "dot-case": "^3.0.4", - "header-case": "^2.0.4", - "no-case": "^3.0.4", - "param-case": "^3.0.4", - "pascal-case": "^3.1.2", - "path-case": "^3.0.4", - "sentence-case": "^3.0.4", - "snake-case": "^3.0.4", - "tslib": "^2.0.3" - } + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" }, "node_modules/change-case-all": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/change-case-all/-/change-case-all-1.0.15.tgz", - "integrity": "sha512-3+GIFhk3sNuvFAJKU46o26OdzudQlPNBCu1ZQi3cMeMHhty1bhDxu2WrEilVNYaGvqUtR1VSigFcJOiS13dRhQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/change-case-all/-/change-case-all-2.1.0.tgz", + "integrity": "sha512-v6b0WWWkZUMHVuYk82l+WROgkUm4qEN2w5hKRNWtEOYwWqUGoi8C6xH0l1RLF1EoWqDFK6MFclmN3od6ws3/uw==", "dev": true, "license": "MIT", "dependencies": { - "change-case": "^4.1.2", - "is-lower-case": "^2.0.2", - "is-upper-case": "^2.0.2", - "lower-case": "^2.0.2", - "lower-case-first": "^2.0.2", - "sponge-case": "^1.0.1", - "swap-case": "^2.0.2", - "title-case": "^3.0.3", - "upper-case": "^2.0.2", - "upper-case-first": "^2.0.2" + "change-case": "^5.2.0", + "sponge-case": "^2.0.2", + "swap-case": "^3.0.2", + "title-case": "^3.0.3" } }, "node_modules/char-regex": { @@ -4658,30 +5217,30 @@ } }, "node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", "dev": true, "license": "MIT" }, "node_modules/clean-publish": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/clean-publish/-/clean-publish-6.0.5.tgz", - "integrity": "sha512-Iqm/EDPQFLY0I8kktg61Nt8V/5fiXYNkNR5UsHcLKmj4vp7a0a7EGZmNEbN2Hg77frQlHNljT/MruK5Wr/Rtog==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/clean-publish/-/clean-publish-7.1.0.tgz", + "integrity": "sha512-fnsFHW6fIq7Sr5Pf9YUsxB1up5pEA13DHg4Q/bZdphWssmIUuKV7G5Kaq2Q0X6UsCFm//uFbONOWTi7m4DvMTg==", "dev": true, "license": "MIT", "dependencies": { "lilconfig": "^3.1.3", "picomatch": "^4.0.4", - "tinyexec": "^1.0.4", - "tinyglobby": "^0.2.15" + "tinyexec": "^1.1.2", + "tinyglobby": "^0.2.16" }, "bin": { "clean-publish": "clean-publish.js", "clear-package-json": "clear-package-json.js" }, "engines": { - "node": ">= 20.0.0" + "node": ">= 22.0.0" } }, "node_modules/clean-stack": { @@ -4813,14 +5372,14 @@ } }, "node_modules/cli-truncate": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", - "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", "dev": true, "license": "MIT", "dependencies": { - "slice-ansi": "^7.1.0", - "string-width": "^8.0.0" + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" }, "engines": { "node": ">=20" @@ -4829,15 +5388,48 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/cli-truncate/node_modules/string-width": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.1.tgz", - "integrity": "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { "node": ">=20" @@ -4857,41 +5449,61 @@ } }, "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "dev": true, "license": "ISC", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=12" + "node": ">=20" } }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/cliui/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/color-convert": { @@ -4914,13 +5526,6 @@ "dev": true, "license": "MIT" }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", @@ -4969,18 +5574,6 @@ "dev": true, "license": "ISC" }, - "node_modules/constant-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/constant-case/-/constant-case-3.0.4.tgz", - "integrity": "sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3", - "upper-case": "^2.0.2" - } - }, "node_modules/conventional-changelog-angular": { "version": "8.3.1", "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz", @@ -5201,13 +5794,13 @@ "license": "MIT" }, "node_modules/debounce": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/debounce/-/debounce-2.2.0.tgz", - "integrity": "sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-3.0.0.tgz", + "integrity": "sha512-64byRbF0/AirwbuHqB3/ZpMG9/nckDa6ZA0yd6UnaQNwbbemCOwvz2sL5sjXLHhZHADyiwLm0M5qMhltUUx+TA==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -5252,13 +5845,16 @@ } }, "node_modules/detect-indent": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", - "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-7.0.2.tgz", + "integrity": "sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/detect-libc": { @@ -5284,17 +5880,6 @@ "node": ">=8" } }, - "node_modules/dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, "node_modules/dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -5510,10 +6095,21 @@ "dev": true, "license": "MIT" }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "dev": true, + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz", - "integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -5524,32 +6120,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.1", - "@esbuild/android-arm": "0.27.1", - "@esbuild/android-arm64": "0.27.1", - "@esbuild/android-x64": "0.27.1", - "@esbuild/darwin-arm64": "0.27.1", - "@esbuild/darwin-x64": "0.27.1", - "@esbuild/freebsd-arm64": "0.27.1", - "@esbuild/freebsd-x64": "0.27.1", - "@esbuild/linux-arm": "0.27.1", - "@esbuild/linux-arm64": "0.27.1", - "@esbuild/linux-ia32": "0.27.1", - "@esbuild/linux-loong64": "0.27.1", - "@esbuild/linux-mips64el": "0.27.1", - "@esbuild/linux-ppc64": "0.27.1", - "@esbuild/linux-riscv64": "0.27.1", - "@esbuild/linux-s390x": "0.27.1", - "@esbuild/linux-x64": "0.27.1", - "@esbuild/netbsd-arm64": "0.27.1", - "@esbuild/netbsd-x64": "0.27.1", - "@esbuild/openbsd-arm64": "0.27.1", - "@esbuild/openbsd-x64": "0.27.1", - "@esbuild/openharmony-arm64": "0.27.1", - "@esbuild/sunos-x64": "0.27.1", - "@esbuild/win32-arm64": "0.27.1", - "@esbuild/win32-ia32": "0.27.1", - "@esbuild/win32-x64": "0.27.1" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { @@ -5602,31 +6198,18 @@ "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "license": "MIT", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, "engines": { "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, "node_modules/execa/node_modules/mimic-fn": { @@ -5713,10 +6296,27 @@ "node": ">=8.6.0" } }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", "dev": true, "funding": [ { @@ -5730,6 +6330,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -5740,6 +6350,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fd-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", + "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "walk-up-path": "^4.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -5805,19 +6425,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/figures/node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5881,6 +6488,22 @@ "dev": true, "license": "ISC" }, + "node_modules/formatly": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", + "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fd-package-json": "^2.0.0" + }, + "bin": { + "formatly": "bin/index.mjs" + }, + "engines": { + "node": ">=18.3.0" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -5894,17 +6517,6 @@ "node": ">=12.20.0" } }, - "node_modules/from2": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", - "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "readable-stream": "^2.0.0" - } - }, "node_modules/fs-extra": { "version": "11.3.4", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", @@ -5969,9 +6581,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "dev": true, "license": "MIT", "engines": { @@ -5982,22 +6594,22 @@ } }, "node_modules/get-stream": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-7.0.1.tgz", - "integrity": "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", "dev": true, "license": "MIT", "engines": { - "node": ">=16" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/get-tsconfig": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", - "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", "dev": true, "license": "MIT", "dependencies": { @@ -6053,16 +6665,16 @@ } }, "node_modules/global-directory": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", - "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-5.0.0.tgz", + "integrity": "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==", "dev": true, "license": "MIT", "dependencies": { - "ini": "4.1.1" + "ini": "6.0.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -6101,7 +6713,6 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", "license": "MIT", - "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -6205,9 +6816,9 @@ } }, "node_modules/graphql-tag": { - "version": "2.12.6", - "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", - "integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==", + "version": "2.12.7", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.7.tgz", + "integrity": "sha512-xnE/NFzy+0eIesvAsREJZ284zTl/wYuBAvpsFSDhRGRdRHdnE90M21Q3xAWyYInb0J756c6x0pIQ62+vtvOs1Q==", "dev": true, "license": "MIT", "dependencies": { @@ -6217,7 +6828,7 @@ "node": ">=10" }, "peerDependencies": { - "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "node_modules/graphql-ws": { @@ -6279,17 +6890,6 @@ "node": ">=8" } }, - "node_modules/header-case": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.4.tgz", - "integrity": "sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "capital-case": "^1.0.4", - "tslib": "^2.0.3" - } - }, "node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -6334,31 +6934,33 @@ "license": "MIT" }, "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" }, "engines": { - "node": ">= 14" + "node": ">= 20" } }, "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" }, "engines": { - "node": ">= 14" + "node": ">= 20" } }, "node_modules/human-signals": { @@ -6399,9 +7001,9 @@ } }, "node_modules/immutable": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", - "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.8.tgz", + "integrity": "sha512-TM5YqrGeTsVIPPpILzeqZ8D2Zc2TvNgSDi88zPF2a4cyqQdWV/wVWBDRDbNzzrLeRWScrFcOX9lW2iX6GOtUDw==", "dev": true, "license": "MIT" }, @@ -6501,30 +7103,13 @@ "license": "ISC" }, "node_modules/ini": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", - "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", "dev": true, "license": "ISC", "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/into-stream": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-7.0.0.tgz", - "integrity": "sha512-2dYz766i9HprMBasCMvHMuazJ7u4WzhJwo5kb3iPSiW/iRYV6uPari3zHoqZlnuaR7V1bEiNMxikhp37rdBXbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "from2": "^2.3.0", - "p-is-promise": "^3.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/invariant": { @@ -6591,16 +7176,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-lower-case/-/is-lower-case-2.0.2.tgz", - "integrity": "sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -6674,28 +7249,18 @@ } }, "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-upper-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-upper-case/-/is-upper-case-2.0.2.tgz", - "integrity": "sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, "node_modules/is-windows": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", @@ -6813,9 +7378,9 @@ } }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { @@ -6923,10 +7488,62 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/knip": { + "version": "6.24.0", + "resolved": "https://registry.npmjs.org/knip/-/knip-6.24.0.tgz", + "integrity": "sha512-PokLlgeEjLh1rAsB7ts+52wZ37HBr1nDhE6NNONwEaXdeZGCJOkP7ZlIAI2Gtu8xohquzTWy75bc/1diI9shQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/webpro" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/knip" + } + ], + "license": "ISC", + "dependencies": { + "fdir": "^6.5.0", + "formatly": "^0.3.0", + "get-tsconfig": "4.14.0", + "jiti": "^2.7.0", + "oxc-parser": "^0.137.0", + "oxc-resolver": "11.21.3", + "picomatch": "^4.0.4", + "smol-toml": "^1.6.1", + "strip-json-comments": "5.0.3", + "tinyglobby": "^0.2.17", + "unbash": "^4.0.1", + "yaml": "^2.9.0", + "zod": "^4.1.11" + }, + "bin": { + "knip": "bin/knip.js", + "knip-bun": "bin/knip-bun.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/knip/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lefthook": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.1.6.tgz", - "integrity": "sha512-w9sBoR0mdN+kJc3SB85VzpiAAl451/rxdCRcZlwW71QLjkeH3EBQFgc4VMj5apePychYDHAlqEWTB8J8JK/j1Q==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.1.9.tgz", + "integrity": "sha512-bwDaIOViTktE8kJLf9jP0p+H2/RDTlFFlc43Am2YgUsX22hI6Sq4RbzsrecwzY5y+MHTipOH7WsmWSEniePHWQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -6934,22 +7551,22 @@ "lefthook": "bin/index.js" }, "optionalDependencies": { - "lefthook-darwin-arm64": "2.1.6", - "lefthook-darwin-x64": "2.1.6", - "lefthook-freebsd-arm64": "2.1.6", - "lefthook-freebsd-x64": "2.1.6", - "lefthook-linux-arm64": "2.1.6", - "lefthook-linux-x64": "2.1.6", - "lefthook-openbsd-arm64": "2.1.6", - "lefthook-openbsd-x64": "2.1.6", - "lefthook-windows-arm64": "2.1.6", - "lefthook-windows-x64": "2.1.6" + "lefthook-darwin-arm64": "2.1.9", + "lefthook-darwin-x64": "2.1.9", + "lefthook-freebsd-arm64": "2.1.9", + "lefthook-freebsd-x64": "2.1.9", + "lefthook-linux-arm64": "2.1.9", + "lefthook-linux-x64": "2.1.9", + "lefthook-openbsd-arm64": "2.1.9", + "lefthook-openbsd-x64": "2.1.9", + "lefthook-windows-arm64": "2.1.9", + "lefthook-windows-x64": "2.1.9" } }, "node_modules/lefthook-darwin-arm64": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-2.1.6.tgz", - "integrity": "sha512-hyB7eeiX78BS66f70byTJacDLC/xV1vgMv9n+idFUsrM7J3Udd/ag9Ag5NP3t0eN0EqQqAtrNnt35EH01lxnRQ==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-2.1.9.tgz", + "integrity": "sha512-119HryNcvr4nqn0wUIrNPgpMEPn9yMQzEcW/lezRsnb56PCJriJB92+MCySPVcWDxJnZef7o0T3jdnPNiSH7Qg==", "cpu": [ "arm64" ], @@ -6961,9 +7578,9 @@ ] }, "node_modules/lefthook-darwin-x64": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/lefthook-darwin-x64/-/lefthook-darwin-x64-2.1.6.tgz", - "integrity": "sha512-5Ka6cFxiH83krt+OMRQtmS6zqoZR5SLXSudLjTbZA1c3ZqF0+dqkeb4XcB6plx6WR0GFizabuc6Bi3iXPIe1eQ==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/lefthook-darwin-x64/-/lefthook-darwin-x64-2.1.9.tgz", + "integrity": "sha512-dwo5Tke2XcQCM56DGHgFKBfRbJIL6xs2wZ0zG1TUVZgl4t4mQUt6LiZ4V/ZQfYHTZF9qywvXoIlR5N35qOaiVQ==", "cpu": [ "x64" ], @@ -6975,9 +7592,9 @@ ] }, "node_modules/lefthook-freebsd-arm64": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/lefthook-freebsd-arm64/-/lefthook-freebsd-arm64-2.1.6.tgz", - "integrity": "sha512-VswyOg5CVN3rMaOJ2HtnkltiMKgFHW/wouWxXsV8RxSa4tgWOKxM0EmSXi8qc2jX+LRga6B0uOY6toXS01zWxA==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/lefthook-freebsd-arm64/-/lefthook-freebsd-arm64-2.1.9.tgz", + "integrity": "sha512-+09PVap6nl6xsaHch5JLtq7WvIR++U1Q2MzA2ai0M4uB/VP3AqrvKqHw6+9hjyKnIH+HHL83uqi77EAY+LaxLA==", "cpu": [ "arm64" ], @@ -6989,9 +7606,9 @@ ] }, "node_modules/lefthook-freebsd-x64": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/lefthook-freebsd-x64/-/lefthook-freebsd-x64-2.1.6.tgz", - "integrity": "sha512-vXsCUFYuVwrVWwcypB7Zt2Hf+5pl1V1la7ZfvGYZaTRURu0zF/XUnMF/nOz/PebGv0f4x/iOWXWwP7E42xRWsg==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/lefthook-freebsd-x64/-/lefthook-freebsd-x64-2.1.9.tgz", + "integrity": "sha512-8XresjKIYpkE9ARgCtBEZgJZxAU3T4MIqzj4zNy15XRT59I1Us+QdqXTNm+pkZ41Yd2X/nxs2Pkvbq3NWWlIGw==", "cpu": [ "x64" ], @@ -7003,9 +7620,9 @@ ] }, "node_modules/lefthook-linux-arm64": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/lefthook-linux-arm64/-/lefthook-linux-arm64-2.1.6.tgz", - "integrity": "sha512-WDJiQhJdZOvKORZd+kF/ms2l6NSsXzdA9ahflyr65V90AC4jES223W8VtEMbGPUtHuGWMEZ/v/XvwlWv0Ioz9g==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/lefthook-linux-arm64/-/lefthook-linux-arm64-2.1.9.tgz", + "integrity": "sha512-1oNIQfwrPe6rgU2KcDM3aF6+hpZDCKx1TmawQKpXUY5gVsbZ7MqX0Sk/1lnnWxqPm+kQQ5f6J2dpFWd+4xH8jg==", "cpu": [ "arm64" ], @@ -7017,9 +7634,9 @@ ] }, "node_modules/lefthook-linux-x64": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/lefthook-linux-x64/-/lefthook-linux-x64-2.1.6.tgz", - "integrity": "sha512-C18nCd7nTX1AVL4TcvwMmLAO1VI1OuGluIOTjiPkBQ746Ls1HhL5rl//jMPACmT28YmxIQJ2ZcLPNmhvEVBZvw==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/lefthook-linux-x64/-/lefthook-linux-x64-2.1.9.tgz", + "integrity": "sha512-fT+7Q+BJyGp+CslFQkNXmdFRgyVXsPHPi9NAsDX0a6QOyNnoORByAsvx6zeAKuF5rL3BBgNfho1/v2RuGxGy9w==", "cpu": [ "x64" ], @@ -7031,9 +7648,9 @@ ] }, "node_modules/lefthook-openbsd-arm64": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/lefthook-openbsd-arm64/-/lefthook-openbsd-arm64-2.1.6.tgz", - "integrity": "sha512-mZOMxM8HiPxVFXDO3PtCUbH4GB8rkveXhsgXF27oAZTYVzQ3gO9vT6r/pxit6msqRXz3fvcwimLVJgb8eRsa8A==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/lefthook-openbsd-arm64/-/lefthook-openbsd-arm64-2.1.9.tgz", + "integrity": "sha512-4bVuafBk3dddVNo0+3hMbjcJs4mqYAstxpPMmX2ufkudSTYFNIhWoqwuGVQV/SS/xdcOKJAldW4qayAzed2ysw==", "cpu": [ "arm64" ], @@ -7045,9 +7662,9 @@ ] }, "node_modules/lefthook-openbsd-x64": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-2.1.6.tgz", - "integrity": "sha512-sG9ALLZSnnMOfXu+B7SmxFhJhuoAh4bqi5En5aaHJET48TqrLOcWWZuH+7ArFM6gr/U5KfSUvdmHFmY8WqCcIg==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-2.1.9.tgz", + "integrity": "sha512-PmPoMmLP/wQQWcQ9u2YH86bTZ3UCfBsxuEmVTEyPU2U8R1qSTp5r/Gs3G8cN5Mxo91XB9oBERtF1n+xD3W6aVA==", "cpu": [ "x64" ], @@ -7059,9 +7676,9 @@ ] }, "node_modules/lefthook-windows-arm64": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/lefthook-windows-arm64/-/lefthook-windows-arm64-2.1.6.tgz", - "integrity": "sha512-lD8yFWY4Csuljd0Rqs7EQaySC0VvDf7V3rN1FhRMUISTRDHutebIom1Loc8ckQPvKYGC6mftT9k0GvipsS+Brw==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/lefthook-windows-arm64/-/lefthook-windows-arm64-2.1.9.tgz", + "integrity": "sha512-KphfkBKmwBnmolyrdhIl3lrBaOyTcCgXBT2AB/9OHnEXhOLvv5uTCUkrD4YRAxXPtFKq6UvnapIeoL3GZq0bdA==", "cpu": [ "arm64" ], @@ -7073,9 +7690,9 @@ ] }, "node_modules/lefthook-windows-x64": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/lefthook-windows-x64/-/lefthook-windows-x64-2.1.6.tgz", - "integrity": "sha512-q4z2n3xucLscoWiyMwFViEj3N8MDSkPulMwcJYuCYFHoPhP1h+icqNu7QRLGYj6AnVrCQweiUJY3Tb2X+GbD/A==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/lefthook-windows-x64/-/lefthook-windows-x64-2.1.9.tgz", + "integrity": "sha512-2qlUtkJHZ3MyUxgV5XTEmcrIoNZA07iwaquoswAcqv/1MeBFXlD+O+koFRfrzWng2O5WYEbpJnd8tvaYnV8fTA==", "cpu": [ "x64" ], @@ -7380,61 +7997,52 @@ "license": "MIT" }, "node_modules/listr2": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", - "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.2.tgz", + "integrity": "sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw==", "dev": true, "license": "MIT", "dependencies": { - "cli-truncate": "^5.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", + "cli-truncate": "^5.2.0", + "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" + "wrap-ansi": "^10.0.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.13.0" } }, - "node_modules/listr2/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, "node_modules/listr2/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/listr2/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", + "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -7498,13 +8106,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.capitalize": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/lodash.capitalize/-/lodash.capitalize-4.2.1.tgz", @@ -7533,27 +8134,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.kebabcase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", - "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.mergewith": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.snakecase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -7561,13 +8141,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.startcase": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", - "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.uniqby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", @@ -7575,25 +8148,18 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.upperfirst": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", - "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", - "dev": true, - "license": "MIT" - }, "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -7675,26 +8241,6 @@ "loose-envify": "cli.js" } }, - "node_modules/lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/lower-case-first": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case-first/-/lower-case-first-2.0.2.tgz", - "integrity": "sha512-EVm/rR94FJTZi3zefZ82fLWab+GX14LJN4HrWBcuo6Evmsl9hEfnqxgcHCKb9q+mNf6EVdsjx/qucYFIIB84pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -7961,13 +8507,13 @@ "license": "MIT" }, "node_modules/mute-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", - "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", "dev": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/mz": { @@ -7983,9 +8529,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, "funding": [ { @@ -8015,17 +8561,6 @@ "dev": true, "license": "MIT" }, - "node_modules/no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" - } - }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -10067,6 +10602,75 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/oxc-parser": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.137.0.tgz", + "integrity": "sha512-yFImD+WLElJpLKy8llG1qe4DCmMsL18peRp8XP1JKfig/gISbJkglnpDtX2aTmAn10kZF7164HbN2H8QPsXxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.137.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.137.0", + "@oxc-parser/binding-android-arm64": "0.137.0", + "@oxc-parser/binding-darwin-arm64": "0.137.0", + "@oxc-parser/binding-darwin-x64": "0.137.0", + "@oxc-parser/binding-freebsd-x64": "0.137.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.137.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.137.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.137.0", + "@oxc-parser/binding-linux-arm64-musl": "0.137.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.137.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.137.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.137.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.137.0", + "@oxc-parser/binding-linux-x64-gnu": "0.137.0", + "@oxc-parser/binding-linux-x64-musl": "0.137.0", + "@oxc-parser/binding-openharmony-arm64": "0.137.0", + "@oxc-parser/binding-wasm32-wasi": "0.137.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.137.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.137.0", + "@oxc-parser/binding-win32-x64-msvc": "0.137.0" + } + }, + "node_modules/oxc-resolver": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.21.3.tgz", + "integrity": "sha512-2Mx3fKQz7+xgrBONjsxOgCGtMHOn38/HxMzW1I5efwXB5a4lRN0Vp40gYUJFBWJslcrvwoofTrqoTnLbwTd3pA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.21.3", + "@oxc-resolver/binding-android-arm64": "11.21.3", + "@oxc-resolver/binding-darwin-arm64": "11.21.3", + "@oxc-resolver/binding-darwin-x64": "11.21.3", + "@oxc-resolver/binding-freebsd-x64": "11.21.3", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.21.3", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.21.3", + "@oxc-resolver/binding-linux-arm64-gnu": "11.21.3", + "@oxc-resolver/binding-linux-arm64-musl": "11.21.3", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.21.3", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.21.3", + "@oxc-resolver/binding-linux-riscv64-musl": "11.21.3", + "@oxc-resolver/binding-linux-s390x-gnu": "11.21.3", + "@oxc-resolver/binding-linux-x64-gnu": "11.21.3", + "@oxc-resolver/binding-linux-x64-musl": "11.21.3", + "@oxc-resolver/binding-openharmony-arm64": "11.21.3", + "@oxc-resolver/binding-wasm32-wasi": "11.21.3", + "@oxc-resolver/binding-win32-arm64-msvc": "11.21.3", + "@oxc-resolver/binding-win32-x64-msvc": "11.21.3" + } + }, "node_modules/p-each-series": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-3.0.0.tgz", @@ -10112,16 +10716,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-is-promise": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", - "integrity": "sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -10210,17 +10804,6 @@ "node": ">=4" } }, - "node_modules/param-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", - "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", - "dev": true, - "license": "MIT", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -10305,28 +10888,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pascal-case": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", - "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/path-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/path-case/-/path-case-3.0.4.tgz", - "integrity": "sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, "node_modules/path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", @@ -10432,9 +10993,9 @@ } }, "node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -10452,7 +11013,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -10490,6 +11051,24 @@ "dev": true, "license": "ISC" }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -10719,14 +11298,14 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -10735,21 +11314,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" } }, "node_modules/run-parallel": { @@ -10791,9 +11370,9 @@ "license": "MIT" }, "node_modules/semantic-release": { - "version": "25.0.3", - "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.3.tgz", - "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", + "version": "25.0.5", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.5.tgz", + "integrity": "sha512-mn61SUJwtM8ThrWn2WmgLVpwVJeG/hPSupua1psdMoufmwRIPyvRLkRkL0JDXkP67OntlLWUYnBnfVc8EDO3/g==", "dev": true, "license": "MIT", "dependencies": { @@ -10876,28 +11455,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/semantic-release/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, "node_modules/semantic-release/node_modules/execa": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", @@ -10942,23 +11499,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/semantic-release/node_modules/hosted-git-info": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", - "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", "dev": true, "license": "ISC", "dependencies": { @@ -11005,9 +11549,9 @@ } }, "node_modules/semantic-release/node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -11154,24 +11698,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/semantic-release/node_modules/strip-final-newline": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", @@ -11186,9 +11712,9 @@ } }, "node_modules/semantic-release/node_modules/type-fest": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", - "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", "dev": true, "license": "(MIT OR CC0-1.0)", "dependencies": { @@ -11214,52 +11740,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/semantic-release/node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/semantic-release/node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -11286,18 +11766,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/sentence-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/sentence-case/-/sentence-case-3.0.4.tgz", - "integrity": "sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3", - "upper-case-first": "^2.0.2" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -11530,15 +11998,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/snake-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", - "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "node_modules/smol-toml": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz", + "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==", "dev": true, - "license": "MIT", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" } }, "node_modules/source-map": { @@ -11615,14 +12085,11 @@ } }, "node_modules/sponge-case": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/sponge-case/-/sponge-case-1.0.1.tgz", - "integrity": "sha512-dblb9Et4DAtiZ5YSUZHLl4XhH4uK80GhAZrVXdN4O2P4gQ40Wa5UIOPUHlA/nFd2PLblBZWUioLMMAVrgpoYcA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/sponge-case/-/sponge-case-2.0.3.tgz", + "integrity": "sha512-i4h9ZGRfxV6Xw3mpZSFOfbXjf0cQcYmssGWutgNIfFZ2VM+YIWfD71N/kjjwK6X/AAHzBr+rciEcn/L34S8TGw==", "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } + "license": "MIT" }, "node_modules/stackback": { "version": "0.0.2", @@ -11799,14 +12266,11 @@ } }, "node_modules/swap-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/swap-case/-/swap-case-2.0.2.tgz", - "integrity": "sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/swap-case/-/swap-case-3.0.3.tgz", + "integrity": "sha512-6p4op8wE9CQv7uDFzulI6YXUw4lD9n4oQierdbFThEKVWVQcbQcUjdP27W8XE7V4QnWmnq9jueSHceyyQnqQVA==", "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } + "license": "MIT" }, "node_modules/sync-fetch": { "version": "0.6.0", @@ -11978,9 +12442,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", - "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, "license": "MIT", "engines": { @@ -11988,9 +12452,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -12061,11 +12525,15 @@ } }, "node_modules/ts-log": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/ts-log/-/ts-log-2.2.7.tgz", - "integrity": "sha512-320x5Ggei84AxzlXp91QkIGSw5wgaLT6GeAH0KsqDmRZdVWW2OiSeVvElVoatk3f7nicwXlElXsoFkARiGE2yg==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/ts-log/-/ts-log-3.0.2.tgz", + "integrity": "sha512-esq6hx2lM66sQV1YcFkIYTqrWWabmqBqobKHyn1CswdI5FgfQhkmiKiRWVGBNlIbdjBxEIkNvMIwLKKPgRYZLQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20", + "npm": ">=10" + } }, "node_modules/tslib": { "version": "2.8.1", @@ -12075,14 +12543,13 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" @@ -12145,6 +12612,16 @@ "node": ">=0.8.0" } }, + "node_modules/unbash": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/unbash/-/unbash-4.0.2.tgz", + "integrity": "sha512-8gwNZ29+0/3zmXw7ToIHZtg6wK37xnniRUdBt7B27xZxaxfgR5tGMaGHT0t0dLtBV9fXE7zurh0s6Z1DHVjfWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, "node_modules/unc-path-regex": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", @@ -12166,9 +12643,9 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, @@ -12271,26 +12748,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/upper-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-2.0.2.tgz", - "integrity": "sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/upper-case-first": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/upper-case-first/-/upper-case-first-2.0.2.tgz", - "integrity": "sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, "node_modules/url-join": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", @@ -12327,17 +12784,17 @@ } }, "node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", - "tinyglobby": "^0.2.16" + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -12353,7 +12810,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -12405,19 +12862,19 @@ } }, "node_modules/vitest": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.5", - "@vitest/mocker": "4.1.5", - "@vitest/pretty-format": "4.1.5", - "@vitest/runner": "4.1.5", - "@vitest/snapshot": "4.1.5", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -12445,12 +12902,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.5", - "@vitest/browser-preview": "4.1.5", - "@vitest/browser-webdriverio": "4.1.5", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/ui": "4.1.5", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -12494,6 +12951,16 @@ } } }, + "node_modules/walk-up-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -12668,9 +13135,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "dev": true, "license": "ISC", "bin": { @@ -12684,32 +13151,56 @@ } }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", "dev": true, "license": "MIT", "dependencies": { - "cliui": "^8.0.1", + "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", + "string-width": "^7.2.0", "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "yargs-parser": "^22.0.0" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "dev": true, "license": "ISC", "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/yocto-queue": { @@ -12738,17 +13229,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", - "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" - }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/colinhacks" } } } diff --git a/package.json b/package.json index b37ffdf5..6acddfab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.5.0", + "version": "2026.6.0-next.13", "description": "CLI tool for Linear.app with JSON output, smart ID resolution, and optimized GraphQL queries. Designed for LLM agents and humans who prefer structured data.", "main": "dist/main.js", "type": "module", @@ -24,6 +24,7 @@ "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", "test:commands": "tsx tests/command-coverage.ts", + "typecheck:test": "tsc --noEmit -p tsconfig.test.json", "generate": "graphql-codegen --config codegen.config.ts", "generate:usage": "tsx src/main.ts usage --all > USAGE.md", "format": "biome format --write .", @@ -32,12 +33,14 @@ "lint:check": "biome lint .", "check": "biome check --write .", "check:ci": "biome check .", + "knip": "knip --no-config-hints", + "knip:ci": "knip --no-config-hints --reporter markdown", "verify:packed-binaries": "node scripts/verify-packed-binaries.mjs", - "release": "npm test && npm run build && npm run verify:packed-binaries && npx clean-publish --access public", + "release": "npm test && npm run build && npm run verify:packed-binaries && rm -rf .clean-pkg && npx clean-publish --without-publish --temp-dir .clean-pkg && npm publish ./.clean-pkg --access public && rm -rf .clean-pkg", "prestart": "npm run generate", "predev": "npm run generate", "prebuild": "npm run generate && npm run generate:usage", - "prepare": "npm run generate && lefthook install", + "prepare": "node scripts/prepare.mjs", "prepack": "npm run build", "prepublishOnly": "npm test", "release:dry-run": "semantic-release --dry-run --no-ci", @@ -67,26 +70,17 @@ }, "homepage": "https://github.com/linearis-oss/linearis#readme", "dependencies": { - "@linear/sdk": "82.1.0", "commander": "14.0.3", + "graphql": "16.12.0", "node-emoji": "2.2.0" }, "devDependencies": { "@biomejs/biome": "^2.3.14", - "@commitlint/cli": "^20.4.1", - "@commitlint/config-conventional": "^20.4.1", - "@graphql-codegen/cli": "^6.1.1", - "@graphql-codegen/client-preset": "^5.2.2", - "@graphql-codegen/introspection": "5.0.2", - "@graphql-codegen/schema-ast": "^5.0.0", - "@types/node": "^24.0.0", - "@vitest/coverage-v8": "^4.0.0", - "@vitest/ui": "^4.0.0", - "clean-publish": "^6.0.5", - "lefthook": "^2.1.0", - "tsx": "^4.20.5", - "typescript": "^6.0.0", - "vitest": "^4.0.0", + "@commitlint/cli": "^21.0.0", + "@commitlint/config-conventional": "^21.0.0", + "@graphql-codegen/cli": "^7.0.0", + "@graphql-codegen/client-preset": "^6.0.0", + "@graphql-typed-document-node/core": "3.2.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/exec": "^7.1.0", @@ -94,7 +88,16 @@ "@semantic-release/github": "^12.0.0", "@semantic-release/npm": "^13.0.0", "@semantic-release/release-notes-generator": "^14.1.0", - "semantic-release": "^25.0.1" + "@types/node": "^24.0.0", + "@vitest/coverage-v8": "^4.0.0", + "@vitest/ui": "^4.0.0", + "clean-publish": "^7.0.0", + "knip": "^6.24.0", + "lefthook": "^2.1.0", + "semantic-release": "^25.0.1", + "tsx": "^4.20.5", + "typescript": "^6.0.0", + "vitest": "^4.0.0" }, "graphql": { "schema": "https://api.linear.app/graphql", diff --git a/renovate.json b/renovate.json index c2fd4a6d..f2f81624 100644 --- a/renovate.json +++ b/renovate.json @@ -23,16 +23,9 @@ "groupName": "dev dependencies (non-major)" }, { - "description": "Group GitHub Actions updates", + "description": "Group GitHub Actions updates (SHA-pinned actions + docker digests)", "matchManagers": ["github-actions"], "groupName": "github actions" - }, - { - "description": "Pin release-publish workflow to minimum supported Node version", - "matchManagers": ["github-actions"], - "matchPackageNames": ["node"], - "matchFileNames": [".github/workflows/release-publish.yml"], - "enabled": false } ] } diff --git a/scripts/prepare.mjs b/scripts/prepare.mjs new file mode 100644 index 00000000..68dca72a --- /dev/null +++ b/scripts/prepare.mjs @@ -0,0 +1,29 @@ +import { execFileSync } from "node:child_process"; + +// WHY THIS EXISTS — do not remove the CI guard. +// +// npm runs the `prepare` lifecycle script on every `npm ci` / `npm install`. +// Our `generate` step (graphql-codegen) introspects the LIVE Linear schema at +// https://api.linear.app/graphql (see codegen.config.ts). If `prepare` ran in +// CI, every job's `npm ci` — roughly eight installs per pipeline run — would +// make a network call to Linear's API, coupling build/test/release reliability +// to Linear's uptime and adding latency plus flakiness. `lefthook install` +// (local git hooks) is also pointless inside CI. +// +// So: skip both when running in CI. Every CI job that actually needs the +// generated types runs `npm run build` explicitly (its `prebuild` runs +// `generate`) or `npm pack` (whose `prepack` builds) — codegen still happens +// where it's needed, just not on install. Locally (no CI env var) `prepare` +// behaves normally: generate types + install git hooks. GitHub Actions sets +// CI=true automatically, as do most other CI providers. +if (process.env.CI) { + console.log( + "CI detected — skipping generate + lefthook install (see scripts/prepare.mjs)", + ); + process.exit(0); +} + +// `shell: true` so npm/npx resolve on Windows, where they are `.cmd` shims +// that cannot be launched without a shell. +execFileSync("npm", ["run", "generate"], { stdio: "inherit", shell: true }); +execFileSync("npx", ["lefthook", "install"], { stdio: "inherit", shell: true }); diff --git a/skills/linearis/SKILL.md b/skills/linearis/SKILL.md new file mode 100644 index 00000000..f9ef4010 --- /dev/null +++ b/skills/linearis/SKILL.md @@ -0,0 +1,48 @@ +--- +name: linearis +description: >- + Manage Linear.app work from the command line with the linearis CLI (bins + linearis / linear), which outputs JSON: issues/tickets, projects, cycles + (sprints), milestones, initiatives (roadmap), documents, labels, teams, + users, and issue discussions/comments. Use when the user mentions Linear, a + ticket identifier like ENG-42 or ABC-123, sprints, triage, or the roadmap, or + asks to create, read, search, update, assign, comment on, or otherwise manage + Linear issues and projects. +license: MIT +compatibility: Requires the linearis CLI (npm i -g linearis), Node >=22, and a Linear API token. +allowed-tools: Bash(linearis:*), Bash(linear:*), Bash(jq:*) +metadata: + author: linearis-oss + version: "1.0.0" +--- + +# linearis + +Drive [Linear.app](https://linear.app) from the shell via the `linearis` CLI (JSON-only output; `linear` is an alias). Do not guess the command surface — the CLI documents itself, and this skill teaches the protocol, not the flags. + +## Preflight (reactive — branch on the CLI's own output; don't pre-run checks every turn) + +- **Not installed** — if the shell reports command-not-found, tell the user linearis isn't installed and offer `npm install -g linearis`. As a no-install fallback, prefix commands with `npx linearis@latest` (adds cold-start latency and needs network per call — fallback, not default). Never silently `npm install -g`. +- **Auth required** — any command may fail with this envelope on stderr and exit code 42: + `{ "error": "AUTHENTICATION_REQUIRED", "action": "USER_ACTION_REQUIRED", "instruction": "Run 'linearis auth' …", "exit_code": 42 }`. + Detect it by `exit_code === 42` / `error === "AUTHENTICATION_REQUIRED"` (not paraphrased text) and surface the CLI's own `instruction`. `linearis auth` is an interactive browser flow you cannot complete — hand it to the user. +- **Updates (advisory, never blocking)** — optionally run `linearis version check` once → `{ current, latest, channel, updateAvailable }`. If `updateAvailable` is true, mention it and ask the user before `npm install -g linearis@latest`, honoring `channel` (don't move a `next` user to `latest`). npm can hang or rate-limit; on any timeout/error just proceed with the installed version. Read the plain installed version with `linearis version` (JSON), not `--version`. + +## Discover, then act + +1. Run `linearis usage` once for the list of domains (issues, projects, cycles, …). +2. Run `linearis usage` for a domain's full command and flag reference **before** acting. +3. Never invent flags or subcommands — `usage` is authoritative and always current. + +## Output + +Every command prints JSON on stdout. Shape it at the source with the global `--fields identifier,title,state.name` and `--compact` — no external binary, works on Windows and fresh containers. Reach for `jq` only for complex reshaping, and fall back to raw JSON if `jq` is absent. + +## Invariants worth knowing (everything else lives in `usage`) + +- IDs are forgiving: pass a UUID, team key (`ENG`), issue identifier (`ABC-123`), or name interchangeably. Reference tickets by identifier. +- `issues create` requires `--team`; some filters need a scope flag — confirm in `usage` rather than memorizing. +- Threaded discussion lives under `issues discuss` / `discussions` / `replies` / `reply`. The top-level `comments` domain is a deprecated facade (still works) — prefer the `issues` discussion commands. Record non-trivial progress in a discussion thread and keep the description in sync on status changes. +- `files download ` only fetches Linear storage URLs (`uploads.linear.app`); `files upload` returns an `assetUrl` you can embed; `issues read --with-attachments` lists linked resources (PRs, docs, URLs) — references, not necessarily downloadable files. + +For anything not covered here, `linearis usage` is the reference. diff --git a/src/client/graphql-client.ts b/src/client/graphql-client.ts index db75c621..dcc65503 100644 --- a/src/client/graphql-client.ts +++ b/src/client/graphql-client.ts @@ -1,18 +1,61 @@ -import { LinearClient } from "@linear/sdk"; -import { type DocumentNode, print } from "graphql"; +import type { TypedDocumentNode } from "@graphql-typed-document-node/core"; +import { print } from "graphql"; import { AuthenticationError, isAuthError } from "../common/errors.js"; import { withRetry } from "../common/retry.js"; +/** Linear's GraphQL API endpoint. */ +const LINEAR_GRAPHQL_ENDPOINT = "https://api.linear.app/graphql"; + /** Default timeout for GraphQL API requests (30 seconds) */ const REQUEST_TIMEOUT_MS = 30_000; +/** + * Variable-less operations generate `Exact<{ [key: string]: never }>` for their + * variables type. Make the variables argument optional in exactly that case and + * required otherwise, so callers infer both types from the document alone. + */ +type RequestVariables = + TVariables extends Record + ? [variables?: TVariables] + : [variables: TVariables]; + +interface GraphQLError { + message: string; +} + +interface GraphQLResponseBody { + data?: unknown; + errors?: GraphQLError[]; +} + interface GraphQLErrorResponse { response?: { - errors?: Array<{ message: string }>; + errors?: GraphQLError[]; }; message?: string; } +/** + * Transport-level error carrying an HTTP status and any GraphQL errors. The + * `response` shape mirrors what `withRetry`/`isRetryable` and the `request()` + * catch block expect, so retry and error-mapping behavior stay unchanged after + * dropping the SDK's `rawRequest`. + */ +interface TransportErrorResponse { + status: number; + errors?: GraphQLError[] | undefined; +} + +class GraphQLTransportError extends Error { + readonly response: TransportErrorResponse; + + constructor(message: string, response: TransportErrorResponse) { + super(message); + this.name = "GraphQLTransportError"; + this.response = response; + } +} + export class GraphQLClient { private readonly apiToken: string; @@ -20,40 +63,90 @@ export class GraphQLClient { this.apiToken = apiToken; } - private createRawClient( - signal?: AbortSignal, - ): InstanceType["client"] { - const linearClient = new LinearClient({ - apiKey: this.apiToken, + /** + * Perform a single GraphQL request over native `fetch`. Returns the raw + * `data` payload (typed `unknown`, validated by the caller) or throws a + * `GraphQLTransportError` for HTTP failures and GraphQL-level errors. + */ + private async execute( + document: TypedDocumentNode>, + variables: Record | undefined, + signal: AbortSignal, + ): Promise { + const response = await fetch(LINEAR_GRAPHQL_ENDPOINT, { + method: "POST", signal, headers: { + "Content-Type": "application/json", + // Personal API keys are sent as the raw Authorization value (matching + // the SDK, which forwards `apiKey` verbatim). + Authorization: this.apiToken, // Request 1-hour signed URLs for file downloads (see file-service.ts) "public-file-urls-expire-in": "3600", }, + body: JSON.stringify({ query: print(document), variables }), }); - return linearClient.client; + + // Parse defensively: a non-JSON body (e.g. an HTML error page) should not + // mask the underlying HTTP status. + let body: GraphQLResponseBody | undefined; + try { + body = (await response.json()) as GraphQLResponseBody; + } catch { + body = undefined; + } + + const errors = body?.errors; + + if (!response.ok) { + // Surface HTTP failures with their status so `isRetryable` can retry 429 + // and 5xx responses. + const message = + errors?.[0]?.message ?? `Request failed with status ${response.status}`; + throw new GraphQLTransportError(message, { + status: response.status, + errors, + }); + } + + if (errors && errors.length > 0) { + // GraphQL errors are returned with HTTP 200; propagate them the same way. + throw new GraphQLTransportError(errors[0]?.message ?? "", { + status: response.status, + errors, + }); + } + + return body?.data; } - async request( - document: DocumentNode, - variables?: Record, + async request>( + document: TypedDocumentNode, + // `NoInfer` pins `TVariables` to the document, so the variables argument is + // type-checked against it rather than widening the inferred type itself. + ...[variables]: RequestVariables> ): Promise { try { - const response = await withRetry(async () => { + const data = await withRetry(async () => { const timeoutController = new AbortController(); const timeoutHandle = setTimeout(() => { timeoutController.abort(); }, REQUEST_TIMEOUT_MS); try { - return await this.createRawClient( + // `TVariables extends Record` lets `variables` widen + // to the `execute` bound directly. `data` stays untyped (`unknown`) + // and is checked and cast to `TResult` below. + return await this.execute( + document as TypedDocumentNode>, + variables, timeoutController.signal, - ).rawRequest(print(document), variables); + ); } catch (error: unknown) { if ( timeoutController.signal.aborted && error instanceof Error && - error.message.toLowerCase().includes("aborted") + error.message.toLowerCase().includes("abort") ) { throw new Error("Request timed out"); } @@ -62,7 +155,12 @@ export class GraphQLClient { clearTimeout(timeoutHandle); } }); - return response.data as TResult; + // A successful response with no `data` (and no errors) is unexpected; + // guard it instead of returning a `TResult`-typed `undefined`. + if (data == null) { + throw new Error("GraphQL response contained no data"); + } + return data as TResult; } catch (error: unknown) { const gqlError = error as GraphQLErrorResponse; const errorMessage = gqlError.response?.errors?.[0]?.message ?? ""; diff --git a/src/client/linear-client.ts b/src/client/linear-client.ts deleted file mode 100644 index 96b936f6..00000000 --- a/src/client/linear-client.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { LinearClient } from "@linear/sdk"; - -export class LinearSdkClient { - readonly sdk: LinearClient; - - constructor(apiToken: string) { - this.sdk = new LinearClient({ apiKey: apiToken }); - } -} diff --git a/src/commands/attachments.ts b/src/commands/attachments.ts index f8f94342..90003f6d 100644 --- a/src/commands/attachments.ts +++ b/src/commands/attachments.ts @@ -1,10 +1,13 @@ import type { Command } from "commander"; import { createContext, getRootOpts } from "../common/context.js"; +import { invalidParameterError } from "../common/errors.js"; +import { asUuid } from "../common/identifier.js"; import { handleCommand, outputSuccess } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; -import type { AttachmentFilter } from "../gql/graphql.js"; import { resolveIssueId } from "../resolvers/issue-resolver.js"; import { + buildAttachmentFilter, + type CreateAttachmentInput, createAttachment, deleteAttachment, listAttachments, @@ -28,6 +31,7 @@ export const ATTACHMENTS_META: DomainMeta = { }; interface ListOptions { + issue?: string; sourceType?: string; title?: string; createdAfter?: string; @@ -35,32 +39,31 @@ interface ListOptions { } interface CreateOptions { + issue?: string; title: string; url: string; subtitle?: string; + comment?: string; + iconUrl?: string; } -function buildAttachmentFilter( - options: ListOptions, -): AttachmentFilter | undefined { - const filters: AttachmentFilter[] = []; - - if (options.sourceType) { - filters.push({ sourceType: { eq: options.sourceType } }); - } - if (options.title) { - filters.push({ title: { eqIgnoreCase: options.title } }); - } - if (options.createdAfter) { - filters.push({ createdAt: { gte: options.createdAfter } }); +function resolveIssueArgument( + positionalIssue: string | undefined, + optionIssue: string | undefined, +): string { + if (positionalIssue && optionIssue) { + throw invalidParameterError( + "--issue", + "cannot be combined with positional issue", + ); } - if (options.createdBefore) { - filters.push({ createdAt: { lt: options.createdBefore } }); + + const issue = positionalIssue ?? optionIssue; + if (!issue) { + throw invalidParameterError("issue", "is required"); } - if (filters.length === 0) return undefined; - if (filters.length === 1) return filters[0]; - return { and: filters }; + return issue; } export function setupAttachmentsCommands(program: Command): void { @@ -71,8 +74,9 @@ export function setupAttachmentsCommands(program: Command): void { attachments.action(() => attachments.help()); attachments - .command("list ") + .command("list [issue]") .description("list attachments on an issue") + .option("--issue ", "issue identifier (alias for positional issue)") .option( "--source-type ", "filter by source type (e.g. github, slack)", @@ -83,12 +87,13 @@ export function setupAttachmentsCommands(program: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [issue, options, command] = args as [ - string, + string | undefined, ListOptions, Command, ]; + const issueIdentifier = resolveIssueArgument(issue, options.issue); const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); + const issueId = await resolveIssueId(ctx.gql, issueIdentifier); const filter = buildAttachmentFilter(options); const result = await listAttachments(ctx.gql, issueId, filter); outputSuccess(result); @@ -96,26 +101,33 @@ export function setupAttachmentsCommands(program: Command): void { ); attachments - .command("create ") + .command("create [issue]") .description("create an attachment on an issue") + .option("--issue ", "issue identifier (alias for positional issue)") .requiredOption("--title ", "attachment title") .requiredOption("--url <url>", "attachment URL") .option("--subtitle <text>", "attachment subtitle") + .option("--comment <text>", "comment body to create with the attachment") + .option("--icon-url <url>", "attachment icon URL") .action( handleCommand(async (...args: unknown[]) => { const [issue, options, command] = args as [ - string, + string | undefined, CreateOptions, Command, ]; + const issueIdentifier = resolveIssueArgument(issue, options.issue); const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); - const result = await createAttachment(ctx.gql, { + const issueId = await resolveIssueId(ctx.gql, issueIdentifier); + const input: CreateAttachmentInput = { issueId, title: options.title, url: options.url, ...(options.subtitle && { subtitle: options.subtitle }), - }); + ...(options.comment && { commentBody: options.comment }), + ...(options.iconUrl && { iconUrl: options.iconUrl }), + }; + const result = await createAttachment(ctx.gql, input); outputSuccess(result); }), ); @@ -127,7 +139,7 @@ export function setupAttachmentsCommands(program: Command): void { handleCommand(async (...args: unknown[]) => { const [id, , command] = args as [string, unknown, Command]; const ctx = createContext(getRootOpts(command)); - const result = await deleteAttachment(ctx.gql, id); + const result = await deleteAttachment(ctx.gql, asUuid(id)); outputSuccess(result); }), ); diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 7e0b9087..8f5c6a90 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -9,9 +9,8 @@ import { import { createGraphQLClient, getRootOpts } from "../common/context.js"; import { handleCommand, outputSuccess } from "../common/output.js"; import { clearToken, saveToken } from "../common/token-storage.js"; -import type { Viewer } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; -import { validateToken } from "../services/auth-service.js"; +import { type Viewer, validateToken } from "../services/auth-service.js"; const LINEAR_API_KEY_URL = "https://linear.app/settings/account/security/api-keys/new"; diff --git a/src/commands/comments.ts b/src/commands/comments.ts index 094e926c..3a233279 100644 --- a/src/commands/comments.ts +++ b/src/commands/comments.ts @@ -6,7 +6,9 @@ import { } from "../common/context.js"; import { resolveReactionEmojiInput } from "../common/emoji.js"; import { invalidParameterError } from "../common/errors.js"; +import { asUuid } from "../common/identifier.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; +import { buildPaginationOptions } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { resolveIssueId } from "../resolvers/issue-resolver.js"; import { @@ -96,11 +98,12 @@ export function setupCommentsCommands(program: Command): void { const ctx = createContext(getRootOpts(command)); const limit = parseLimit(options.limit || "25"); - const resolvedIssueId = await resolveIssueId(ctx.sdk, issue); - const result = await listDiscussionsForIssue(ctx.gql, resolvedIssueId, { - limit, - after: options.after, - }); + const resolvedIssueId = await resolveIssueId(ctx.gql, issue); + const result = await listDiscussionsForIssue( + ctx.gql, + resolvedIssueId, + buildPaginationOptions(limit, options.after), + ); outputSuccess(result); }), @@ -130,7 +133,7 @@ export function setupCommentsCommands(program: Command): void { throw invalidParameterError("--body", "is required"); } - const resolvedIssueId = await resolveIssueId(ctx.sdk, issue); + const resolvedIssueId = await resolveIssueId(ctx.gql, issue); const result = await startIssueDiscussion(ctx.gql, { issueId: resolvedIssueId, body: options.body, @@ -169,7 +172,7 @@ export function setupCommentsCommands(program: Command): void { } const result = await replyToDiscussion(ctx.gql, { - threadId: thread, + threadId: asUuid(thread), body: options.body, entityKind: "issue", }); @@ -198,7 +201,7 @@ export function setupCommentsCommands(program: Command): void { throw invalidParameterError("--body", "is required"); } - const result = await editDiscussionComment(ctx.gql, comment, { + const result = await editDiscussionComment(ctx.gql, asUuid(comment), { body: options.body, }); @@ -217,7 +220,7 @@ export function setupCommentsCommands(program: Command): void { const [comment, , command] = args as [string, unknown, Command]; const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionComment(ctx.gql, comment); + const result = await deleteDiscussionComment(ctx.gql, asUuid(comment)); outputSuccess(result); }), @@ -244,7 +247,7 @@ export function setupCommentsCommands(program: Command): void { const ctx = createContext(getRootOpts(command)); const result = await createIssueDiscussionCommentReaction(ctx.gql, { - commentId: comment, + commentId: asUuid(comment), emoji: resolveReactionEmojiInput(emoji, options.shortcode), }); @@ -275,7 +278,7 @@ export function setupCommentsCommands(program: Command): void { const result = await deleteIssueDiscussionCommentReactionByEmoji( ctx.gql, { - commentId: comment, + commentId: asUuid(comment), emoji: resolveReactionEmojiInput(emoji, options.shortcode), }, ); @@ -304,8 +307,8 @@ export function setupCommentsCommands(program: Command): void { const ctx = createContext(getRootOpts(command)); const result = await deleteIssueDiscussionCommentReactionById(ctx.gql, { - commentId: comment, - reactionId, + commentId: asUuid(comment), + reactionId: asUuid(reactionId), }); outputSuccess(result); diff --git a/src/commands/cycles.ts b/src/commands/cycles.ts index c13a9d46..7efbd0f7 100644 --- a/src/commands/cycles.ts +++ b/src/commands/cycles.ts @@ -10,6 +10,7 @@ import { requiresParameterError, } from "../common/errors.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; +import { buildPaginationOptions } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { resolveCycleId } from "../resolvers/cycle-resolver.js"; import { resolveTeamId } from "../resolvers/team-resolver.js"; @@ -71,7 +72,7 @@ export function setupCyclesCommands(program: Command): void { // Resolve team filter if provided const teamId = options.team - ? await resolveTeamId(ctx.sdk, options.team) + ? await resolveTeamId(ctx.gql, options.team) : undefined; // Fetch cycles @@ -79,7 +80,7 @@ export function setupCyclesCommands(program: Command): void { ctx.gql, teamId, options.active || false, - { limit: parseLimit(options.limit), after: options.after }, + buildPaginationOptions(parseLimit(options.limit), options.after), ); if (options.window) { @@ -129,7 +130,7 @@ export function setupCyclesCommands(program: Command): void { ]; const ctx = createContext(getRootOpts(command)); - const cycleId = await resolveCycleId(ctx.sdk, cycle, options.team); + const cycleId = await resolveCycleId(ctx.gql, cycle, options.team); const cycleResult = await getCycle( ctx.gql, diff --git a/src/commands/documents.ts b/src/commands/documents.ts index 8cc75a12..e9392eba 100644 --- a/src/commands/documents.ts +++ b/src/commands/documents.ts @@ -1,21 +1,22 @@ import type { Command } from "commander"; import { createContext, getRootOpts } from "../common/context.js"; +import { invalidParameterError } from "../common/errors.js"; +import { asUuid, type UUID } from "../common/identifier.js"; +import { omitUndefined } from "../common/object.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; -import type { DocumentUpdateInput } from "../gql/graphql.js"; import { resolveIssueId } from "../resolvers/issue-resolver.js"; import { resolveProjectId } from "../resolvers/project-resolver.js"; import { resolveTeamId } from "../resolvers/team-resolver.js"; +import { listAttachments } from "../services/attachment-service.js"; import { - createAttachment, - listAttachments, -} from "../services/attachment-service.js"; -import { + buildIssueDocumentFilter, + buildProjectDocumentFilter, createDocument, deleteDocument, getDocument, listDocuments, - listDocumentsBySlugIds, + type UpdateDocumentInput, updateDocument, } from "../services/document-service.js"; @@ -27,6 +28,7 @@ interface DocumentCreateOptions { icon?: string; color?: string; issue?: string; + attachTo?: string; } interface DocumentUpdateOptions { @@ -45,7 +47,7 @@ interface DocumentListOptions { } /** Extracts slug ID from a Linear document URL (e.g. /workspace/document/title-slug-abc123 -> abc123). */ -export function extractDocumentIdFromUrl(url: string): string | null { +function extractDocumentIdFromUrl(url: string): string | null { try { const parsed = new URL(url); if (!parsed.hostname.includes("linear.app")) { @@ -59,6 +61,9 @@ export function extractDocumentIdFromUrl(url: string): string | null { } const docSlug = pathParts[docIndex + 1]; + if (docSlug === undefined) { + return null; + } const lastHyphenIndex = docSlug.lastIndexOf("-"); if (lastHyphenIndex === -1) { return docSlug || null; @@ -66,7 +71,7 @@ export function extractDocumentIdFromUrl(url: string): string | null { return docSlug.substring(lastHyphenIndex + 1) || null; } catch { - // URL constructor throws on malformed input — treat as unresolvable + // URL constructor throws on malformed input — treat as unresolvable. return null; } } @@ -115,49 +120,39 @@ export function setupDocumentsCommands(program: Command): void { const limit = parseLimit(options.limit || "50"); + let projectId: UUID | undefined; + if (options.project) { + projectId = await resolveProjectId(ctx.gql, options.project); + } + + let issueId: UUID | undefined; if (options.issue) { - const issueId = await resolveIssueId(ctx.sdk, options.issue); - const attachments = await listAttachments(ctx.gql, issueId); + issueId = await resolveIssueId(ctx.gql, options.issue); + } - const documentSlugIds = [ + let filter: ReturnType<typeof buildIssueDocumentFilter> | undefined; + if (projectId) { + filter = buildProjectDocumentFilter(projectId); + } else if (issueId) { + const attachments = await listAttachments(ctx.gql, issueId); + const legacyDocumentSlugIds = [ ...new Set( attachments .map((att) => extractDocumentIdFromUrl(att.url)) .filter((id): id is string => id !== null), ), ]; - - if (documentSlugIds.length === 0) { - outputSuccess({ - nodes: [], - pageInfo: { hasNextPage: false, endCursor: null }, - }); - return; - } - - const documents = await listDocumentsBySlugIds( - ctx.gql, - documentSlugIds, - ); - outputSuccess({ - nodes: documents, - pageInfo: { hasNextPage: false, endCursor: null }, - }); - return; + filter = buildIssueDocumentFilter(issueId, legacyDocumentSlugIds); } - let projectId: string | undefined; - if (options.project) { - projectId = await resolveProjectId(ctx.sdk, options.project); - } - - const documents = await listDocuments(ctx.gql, { - limit, - after: options.after, - filter: projectId - ? { project: { id: { eq: projectId } } } - : undefined, - }); + const documents = await listDocuments( + ctx.gql, + omitUndefined({ + limit, + after: options.after, + filter, + }), + ); outputSuccess(documents); }), @@ -172,7 +167,7 @@ export function setupDocumentsCommands(program: Command): void { const rootOpts = getRootOpts(command); const ctx = createContext(rootOpts); - const documentResult = await getDocument(ctx.gql, document); + const documentResult = await getDocument(ctx.gql, asUuid(document)); outputSuccess(documentResult); }), ); @@ -187,17 +182,29 @@ export function setupDocumentsCommands(program: Command): void { .option("--icon <icon>", "document icon") .option("--color <color>", "icon color") .option("--issue <issue>", "also attach document to issue (e.g., ABC-123)") + .option("--attach-to <issue>", "alias for --issue") .action( handleCommand(async (...args: unknown[]) => { const [options, command] = args as [DocumentCreateOptions, Command]; + if (options.issue && options.attachTo) { + throw invalidParameterError( + "--attach-to", + "cannot be combined with --issue", + ); + } + + const issueIdentifier = options.issue ?? options.attachTo; const rootOpts = getRootOpts(command); const ctx = createContext(rootOpts); const projectId = options.project - ? await resolveProjectId(ctx.sdk, options.project) + ? await resolveProjectId(ctx.gql, options.project) : undefined; const teamId = options.team - ? await resolveTeamId(ctx.sdk, options.team) + ? await resolveTeamId(ctx.gql, options.team) + : undefined; + const issueId = issueIdentifier + ? await resolveIssueId(ctx.gql, issueIdentifier) : undefined; const document = await createDocument(ctx.gql, { @@ -205,30 +212,11 @@ export function setupDocumentsCommands(program: Command): void { content: options.content, projectId, teamId, + issueId, icon: options.icon, color: options.color, }); - if (options.issue) { - const issueId = await resolveIssueId(ctx.sdk, options.issue); - - try { - await createAttachment(ctx.gql, { - issueId, - url: document.url, - title: document.title, - }); - } catch (attachError) { - const errorMessage = - attachError instanceof Error - ? attachError.message - : String(attachError); - throw new Error( - `Document created (${document.id}) but failed to attach to issue "${options.issue}": ${errorMessage}.`, - ); - } - } - outputSuccess(document); }), ); @@ -251,16 +239,20 @@ export function setupDocumentsCommands(program: Command): void { const rootOpts = getRootOpts(command); const ctx = createContext(rootOpts); - const input: DocumentUpdateInput = {}; + const input: UpdateDocumentInput = {}; if (options.title) input.title = options.title; if (options.content) input.content = options.content; if (options.project) { - input.projectId = await resolveProjectId(ctx.sdk, options.project); + input.projectId = await resolveProjectId(ctx.gql, options.project); } if (options.icon) input.icon = options.icon; if (options.color) input.color = options.color; - const updatedDocument = await updateDocument(ctx.gql, document, input); + const updatedDocument = await updateDocument( + ctx.gql, + asUuid(document), + input, + ); outputSuccess(updatedDocument); }), ); @@ -274,7 +266,7 @@ export function setupDocumentsCommands(program: Command): void { const rootOpts = getRootOpts(command); const ctx = createContext(rootOpts); - const result = await deleteDocument(ctx.gql, document); + const result = await deleteDocument(ctx.gql, asUuid(document)); outputSuccess(result); }), ); diff --git a/src/commands/files.ts b/src/commands/files.ts index 4230f9d5..261f0bfa 100644 --- a/src/commands/files.ts +++ b/src/commands/files.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { type CommandOptions, getApiToken } from "../common/auth.js"; import { getRootOpts } from "../common/context.js"; +import { omitUndefined } from "../common/object.js"; import { handleCommand, outputSuccess } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { FileService } from "../services/file-service.js"; @@ -40,10 +41,13 @@ export function setupFilesCommands(program: Command): void { ]; const apiToken = getApiToken(getRootOpts(command)); const fileService = new FileService(apiToken); - const result = await fileService.downloadFile(url, { - output: options.output, - overwrite: options.overwrite, - }); + const result = await fileService.downloadFile( + url, + omitUndefined({ + output: options.output, + overwrite: options.overwrite, + }), + ); if (!result.success) { throw new Error(result.error || "Download failed"); diff --git a/src/commands/initiatives/entity.ts b/src/commands/initiatives/entity.ts index 261bd13c..8466ef57 100644 --- a/src/commands/initiatives/entity.ts +++ b/src/commands/initiatives/entity.ts @@ -1,23 +1,16 @@ import type { Command } from "commander"; -import type { LinearSdkClient } from "../../client/linear-client.js"; +import type { GraphQLClient } from "../../client/graphql-client.js"; import { createContext, getRootOpts } from "../../common/context.js"; import { resolveReactionEmojiInput } from "../../common/emoji.js"; import { invalidParameterError } from "../../common/errors.js"; +import { asUuid } from "../../common/identifier.js"; +import { omitUndefined } from "../../common/object.js"; import { - handleCommand, + commandAction, outputSuccess, parseLimit, } from "../../common/output.js"; -import { - type InitiativeCreateInput, - type InitiativeSortInput, - InitiativeStatus, - type InitiativeUpdateInput, - type ListInitiativesQueryVariables, - PaginationNulls, - PaginationOrderBy, - PaginationSortOrder, -} from "../../gql/graphql.js"; +import { buildPaginationOptions } from "../../common/types.js"; import { resolveInitiativeId } from "../../resolvers/initiative-resolver.js"; import { resolveTeamId } from "../../resolvers/team-resolver.js"; import { resolveUserId } from "../../resolvers/user-resolver.js"; @@ -40,10 +33,18 @@ import { } from "../../services/discussion-service.js"; import { archiveInitiative, + buildInitiativeFilter, + type CreateInitiativeInput, createInitiative, deleteInitiative, getInitiative, + type InitiativeFilterInput, + type InitiativeSortBy, listInitiatives, + mapSortByToInitiativeSort, + mapSortByToPaginationOrderBy, + parseInitiativeStatus, + type UpdateInitiativeInput, unarchiveInitiative, updateInitiative, } from "../../services/initiative-service.js"; @@ -116,22 +117,18 @@ function addCommentReactionCommands( .description(`add a reaction to a discussion ${noun}`) .option("--shortcode <name>", "emoji shortcode (e.g. thumbs_up)") .action( - handleCommand(async (...args: unknown[]) => { - const [commentId, emoji, options, command] = args as [ - string, - string | undefined, - ReactionOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const result = await createDiscussionCommentReaction(ctx.gql, { - commentId, - target: noun, - expectedEntityKind: "initiative", - emoji: resolveReactionEmojiInput(emoji, options.shortcode), - }); - outputSuccess(result); - }), + commandAction<[string, string | undefined, ReactionOptions, Command]>( + async (commentId, emoji, options, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await createDiscussionCommentReaction(ctx.gql, { + commentId: asUuid(commentId), + target: noun, + expectedEntityKind: "initiative", + emoji: resolveReactionEmojiInput(emoji, options.shortcode), + }); + outputSuccess(result); + }, + ), ); parent @@ -139,22 +136,18 @@ function addCommentReactionCommands( .description(`remove your reaction from a discussion ${noun} by emoji`) .option("--shortcode <name>", "emoji shortcode (e.g. thumbs_up)") .action( - handleCommand(async (...args: unknown[]) => { - const [commentId, emoji, options, command] = args as [ - string, - string | undefined, - ReactionOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { - commentId, - target: noun, - expectedEntityKind: "initiative", - emoji: resolveReactionEmojiInput(emoji, options.shortcode), - }); - outputSuccess(result); - }), + commandAction<[string, string | undefined, ReactionOptions, Command]>( + async (commentId, emoji, options, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { + commentId: asUuid(commentId), + target: noun, + expectedEntityKind: "initiative", + emoji: resolveReactionEmojiInput(emoji, options.shortcode), + }); + outputSuccess(result); + }, + ), ); parent @@ -163,22 +156,18 @@ function addCommentReactionCommands( `remove your reaction from a discussion ${noun} by reaction ID`, ) .action( - handleCommand(async (...args: unknown[]) => { - const [commentId, reactionId, , command] = args as [ - string, - string, - unknown, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionCommentReactionById(ctx.gql, { - commentId, - target: noun, - expectedEntityKind: "initiative", - reactionId, - }); - outputSuccess(result); - }), + commandAction<[string, string, unknown, Command]>( + async (commentId, reactionId, _unused2, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await deleteDiscussionCommentReactionById(ctx.gql, { + commentId: asUuid(commentId), + target: noun, + expectedEntityKind: "initiative", + reactionId: asUuid(reactionId), + }); + outputSuccess(result); + }, + ), ); } @@ -201,16 +190,6 @@ interface InitiativeUpdateOptions { sortOrder?: string; } -type InitiativeSortBy = - | "name" - | "createdAt" - | "updatedAt" - | "targetDate" - | "health" - | "healthUpdatedAt" - | "manual" - | "owner"; - function parseSortOrder(value?: string): "asc" | "desc" | undefined { if (!value) return undefined; const normalized = value.toLowerCase(); @@ -240,64 +219,6 @@ function parseSortBy(value?: string): InitiativeSortBy | undefined { ); } -function mapSortByToPaginationOrderBy( - sortBy?: InitiativeSortBy, -): PaginationOrderBy | undefined { - if (sortBy === "createdAt") return PaginationOrderBy.CreatedAt; - if (sortBy === "updatedAt") return PaginationOrderBy.UpdatedAt; - return undefined; -} - -function mapSortByToInitiativeSort( - sortBy?: InitiativeSortBy, - sortOrder?: "asc" | "desc", -): ListInitiativesQueryVariables["sort"] | undefined { - if (!sortBy) return undefined; - - const order = - sortOrder === "desc" - ? PaginationSortOrder.Descending - : PaginationSortOrder.Ascending; - - const withNulls = { - order, - nulls: PaginationNulls.Last, - }; - - const sortEntry: InitiativeSortInput = - sortBy === "manual" - ? { manual: withNulls } - : sortBy === "name" - ? { name: withNulls } - : sortBy === "createdAt" - ? { createdAt: withNulls } - : sortBy === "updatedAt" - ? { updatedAt: withNulls } - : sortBy === "targetDate" - ? { targetDate: withNulls } - : sortBy === "health" - ? { health: withNulls } - : sortBy === "healthUpdatedAt" - ? { healthUpdatedAt: withNulls } - : { owner: withNulls }; - - return [sortEntry]; -} - -function parseInitiativeStatus(value?: string): InitiativeStatus | undefined { - if (!value) return undefined; - - const normalized = value.toLowerCase(); - if (normalized === "planned") return InitiativeStatus.Planned; - if (normalized === "active") return InitiativeStatus.Active; - if (normalized === "completed") return InitiativeStatus.Completed; - - throw invalidParameterError( - "--status", - 'must be one of: "Planned", "Active", "Completed"', - ); -} - function parseSortOrderNumber(value?: string): number | undefined { if (value === undefined) return undefined; const parsed = Number.parseFloat(value); @@ -310,19 +231,6 @@ function parseSortOrderNumber(value?: string): number | undefined { return parsed; } -function applyNullableDateRange( - target: { gte?: string; lte?: string }, - after?: string, - before?: string, -): void { - if (after !== undefined) { - target.gte = after; - } - if (before !== undefined) { - target.lte = before; - } -} - function getExpandFlags(options: InitiativeExpandOptions): string[] { const map: Array<[boolean | undefined, string]> = [ [options.withProjects, "--with-projects"], @@ -337,110 +245,53 @@ function getExpandFlags(options: InitiativeExpandOptions): string[] { return map.filter(([enabled]) => enabled).map(([, flag]) => flag); } -async function buildInitiativeFilter( - sdk: LinearSdkClient, +async function resolveInitiativeFilterInput( + gql: GraphQLClient, options: InitiativeListOptions, -): Promise<ListInitiativesQueryVariables["filter"] | undefined> { - const filter: NonNullable<ListInitiativesQueryVariables["filter"]> = {}; - - if (options.id) { - filter.id = { eq: options.id }; - } - - if (options.slug) { - filter.slugId = { eqIgnoreCase: options.slug }; - } - - if (options.name) { - filter.name = { eqIgnoreCase: options.name }; - } - - const status = parseInitiativeStatus(options.status); - if (status) { - filter.status = { eq: status }; - } - - if (options.health) { - filter.health = { eq: options.health }; +): Promise<InitiativeFilterInput> { + if (options.parent) { + throw invalidParameterError( + "--parent", + "is not supported by current Linear initiatives filter API", + ); } - if (options.healthWithAge) { - filter.healthWithAge = { eq: options.healthWithAge }; - } + const input: InitiativeFilterInput = omitUndefined({ + id: options.id, + slug: options.slug, + name: options.name, + status: parseInitiativeStatus(options.status), + health: options.health, + healthWithAge: options.healthWithAge, + targetAfter: options.targetAfter, + targetBefore: options.targetBefore, + startedAfter: options.startedAfter, + startedBefore: options.startedBefore, + completedAfter: options.completedAfter, + completedBefore: options.completedBefore, + createdAfter: options.createdAfter, + createdBefore: options.createdBefore, + updatedAfter: options.updatedAfter, + updatedBefore: options.updatedBefore, + }); if (options.owner) { - const ownerId = await resolveUserId(sdk, options.owner); - filter.owner = { id: { eq: ownerId } }; + input.ownerId = await resolveUserId(gql, options.owner); } if (options.creator) { - const creatorId = await resolveUserId(sdk, options.creator); - filter.creator = { id: { eq: creatorId } }; + input.creatorId = await resolveUserId(gql, options.creator); } if (options.team) { - const teamId = await resolveTeamId(sdk, options.team); - filter.teams = { some: { id: { eq: teamId } } }; - } - - if (options.targetAfter || options.targetBefore) { - filter.targetDate = {}; - applyNullableDateRange( - filter.targetDate, - options.targetAfter, - options.targetBefore, - ); - } - - if (options.startedAfter || options.startedBefore) { - filter.startedAt = {}; - applyNullableDateRange( - filter.startedAt, - options.startedAfter, - options.startedBefore, - ); - } - - if (options.completedAfter || options.completedBefore) { - filter.completedAt = {}; - applyNullableDateRange( - filter.completedAt, - options.completedAfter, - options.completedBefore, - ); - } - - if (options.createdAfter || options.createdBefore) { - filter.createdAt = {}; - applyNullableDateRange( - filter.createdAt, - options.createdAfter, - options.createdBefore, - ); - } - - if (options.updatedAfter || options.updatedBefore) { - filter.updatedAt = {}; - applyNullableDateRange( - filter.updatedAt, - options.updatedAfter, - options.updatedBefore, - ); + input.teamId = await resolveTeamId(gql, options.team); } if (options.ancestor) { - const ancestorId = await resolveInitiativeId(sdk, options.ancestor); - filter.ancestors = { some: { id: { eq: ancestorId } } }; - } - - if (options.parent) { - throw invalidParameterError( - "--parent", - "is not supported by current Linear initiatives filter API", - ); + input.ancestorId = await resolveInitiativeId(gql, options.ancestor); } - return Object.keys(filter).length > 0 ? filter : undefined; + return input; } export function setupInitiativeEntityCommands(initiatives: Command): void { @@ -490,44 +341,52 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .option("--with-history", "include history in list output") .option("--with-documents", "include documents in list output") .action( - handleCommand(async (...args: unknown[]) => { - const [options, command] = args as [InitiativeListOptions, Command]; - const ctx = createContext(getRootOpts(command)); - - const sortOrder = parseSortOrder(options.sortOrder); - const sortBy = parseSortBy(options.sortBy); - - const expandFlags = getExpandFlags(options); - if (expandFlags.length > 0) { - throw invalidParameterError( - "expand flags", - `${expandFlags.join(", ")} are not supported for initiatives list yet`, - ); - } - - if (sortOrder && !sortBy) { - throw invalidParameterError( - "--sort-order", - "requires --sort-by to be specified", - ); - } + commandAction<[InitiativeListOptions, Command]>( + async (options, command) => { + const ctx = createContext(getRootOpts(command)); + + const sortOrder = parseSortOrder(options.sortOrder); + const sortBy = parseSortBy(options.sortBy); + + const expandFlags = getExpandFlags(options); + if (expandFlags.length > 0) { + throw invalidParameterError( + "expand flags", + `${expandFlags.join(", ")} are not supported for initiatives list yet`, + ); + } - const orderBy = mapSortByToPaginationOrderBy(sortBy); - const sort = mapSortByToInitiativeSort(sortBy, sortOrder); + if (sortOrder && !sortBy) { + throw invalidParameterError( + "--sort-order", + "requires --sort-by to be specified", + ); + } - const filter = await buildInitiativeFilter(ctx.sdk, options); + const orderBy = mapSortByToPaginationOrderBy(sortBy); + const sort = mapSortByToInitiativeSort(sortBy, sortOrder); - const result = await listInitiatives(ctx.gql, { - limit: parseLimit(options.limit), - after: options.after, - includeArchived: options.includeArchived ?? false, - filter, - orderBy, - sort, - }); + const filterInput = await resolveInitiativeFilterInput( + ctx.gql, + options, + ); + const filter = buildInitiativeFilter(filterInput); + + const result = await listInitiatives( + ctx.gql, + omitUndefined({ + limit: parseLimit(options.limit), + after: options.after, + includeArchived: options.includeArchived ?? false, + filter, + orderBy, + sort, + }), + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); initiatives @@ -547,22 +406,19 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .option("--with-history", "include history in read output") .option("--with-documents", "include documents in read output") .action( - handleCommand(async (...args: unknown[]) => { - const [initiative, options, command] = args as [ - string, - InitiativeReadOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); - - // Read query already returns expanded fields. Keep flags accepted for - // CLI contract compatibility until conditional field selection is added. - void getExpandFlags(options); - - const result = await getInitiative(ctx.gql, initiativeId); - outputSuccess(result); - }), + commandAction<[string, InitiativeReadOptions, Command]>( + async (initiative, options, command) => { + const ctx = createContext(getRootOpts(command)); + const initiativeId = await resolveInitiativeId(ctx.gql, initiative); + + // Read query already returns expanded fields. Keep flags accepted for + // CLI contract compatibility until conditional field selection is added. + void getExpandFlags(options); + + const result = await getInitiative(ctx.gql, initiativeId); + outputSuccess(result); + }, + ), ); initiatives @@ -570,26 +426,23 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .description("start a discussion thread on an initiative") .option("--body <text>", "discussion body (required, markdown supported)") .action( - handleCommand(async (...args: unknown[]) => { - const [initiative, options, command] = args as [ - string, - DiscussionBodyOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } - - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); - const result = await startInitiativeDiscussion(ctx.gql, { - initiativeId, - body: options.body, - }); - - outputSuccess(result); - }), + commandAction<[string, DiscussionBodyOptions, Command]>( + async (initiative, options, command) => { + const ctx = createContext(getRootOpts(command)); + + if (!options.body) { + throw invalidParameterError("--body", "is required"); + } + + const initiativeId = await resolveInitiativeId(ctx.gql, initiative); + const result = await startInitiativeDiscussion(ctx.gql, { + initiativeId, + body: options.body, + }); + + outputSuccess(result); + }, + ), ); initiatives @@ -599,33 +452,30 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .option("--after <cursor>", "cursor for next page") .option("--with-reactions", "include normalized discussion reactions") .action( - handleCommand(async (...args: unknown[]) => { - const [initiative, options, command] = args as [ - string, - DiscussionsOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); - const paginationOptions = { - limit: parseLimit(options.limit || "25"), - after: options.after, - }; - const result = options.withReactions - ? await listDiscussionsForInitiativeWithReactions( - ctx.gql, - initiativeId, - paginationOptions, - ) - : await listDiscussionsForInitiative( - ctx.gql, - initiativeId, - paginationOptions, - ); - - outputSuccess(result); - }), + commandAction<[string, DiscussionsOptions, Command]>( + async (initiative, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const initiativeId = await resolveInitiativeId(ctx.gql, initiative); + const paginationOptions = buildPaginationOptions( + parseLimit(options.limit || "25"), + options.after, + ); + const result = options.withReactions + ? await listDiscussionsForInitiativeWithReactions( + ctx.gql, + initiativeId, + paginationOptions, + ) + : await listDiscussionsForInitiative( + ctx.gql, + initiativeId, + paginationOptions, + ); + + outputSuccess(result); + }, + ), ); const initiativeThreads = initiatives @@ -640,34 +490,31 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .option("--after <cursor>", "cursor for next page") .option("--with-reactions", "include normalized discussion reactions") .action( - handleCommand(async (...args: unknown[]) => { - const [thread, options, command] = args as [ - string, - DiscussionsOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - const paginationOptions = { - limit: parseLimit(options.limit || "50"), - after: options.after, - }; - const result = options.withReactions - ? await listDiscussionRepliesWithReactions( - ctx.gql, - thread, - paginationOptions, - "initiative", - ) - : await listDiscussionReplies( - ctx.gql, - thread, - paginationOptions, - "initiative", - ); + commandAction<[string, DiscussionsOptions, Command]>( + async (thread, options, command) => { + const ctx = createContext(getRootOpts(command)); - outputSuccess(result); - }), + const paginationOptions = buildPaginationOptions( + parseLimit(options.limit || "50"), + options.after, + ); + const result = options.withReactions + ? await listDiscussionRepliesWithReactions( + ctx.gql, + asUuid(thread), + paginationOptions, + "initiative", + ) + : await listDiscussionReplies( + ctx.gql, + asUuid(thread), + paginationOptions, + "initiative", + ); + + outputSuccess(result); + }, + ), ); addCommentReactionCommands(initiativeReplies, "reply"); @@ -680,26 +527,23 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { ) .option("--body <text>", "reply body (required, markdown supported)") .action( - handleCommand(async (...args: unknown[]) => { - const [thread, options, command] = args as [ - string, - DiscussionBodyOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } - - const result = await replyToDiscussion(ctx.gql, { - threadId: thread, - body: options.body, - entityKind: "initiative", - }); - - outputSuccess(result); - }), + commandAction<[string, DiscussionBodyOptions, Command]>( + async (thread, options, command) => { + const ctx = createContext(getRootOpts(command)); + + if (!options.body) { + throw invalidParameterError("--body", "is required"); + } + + const result = await replyToDiscussion(ctx.gql, { + threadId: asUuid(thread), + body: options.body, + entityKind: "initiative", + }); + + outputSuccess(result); + }, + ), ); initiatives @@ -707,29 +551,26 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .description("edit a root discussion or reply comment") .option("--body <text>", "new comment body (required, markdown supported)") .action( - handleCommand(async (...args: unknown[]) => { - const [comment, options, command] = args as [ - string, - DiscussionBodyOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } - - const result = await editDiscussionComment( - ctx.gql, - comment, - { - body: options.body, - }, - "initiative", - ); + commandAction<[string, DiscussionBodyOptions, Command]>( + async (comment, options, command) => { + const ctx = createContext(getRootOpts(command)); + + if (!options.body) { + throw invalidParameterError("--body", "is required"); + } + + const result = await editDiscussionComment( + ctx.gql, + asUuid(comment), + { + body: options.body, + }, + "initiative", + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); initiatives @@ -737,65 +578,64 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .description("edit a discussion reply") .option("--body <text>", "new reply body (required, markdown supported)") .action( - handleCommand(async (...args: unknown[]) => { - const [reply, options, command] = args as [ - string, - DiscussionBodyOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } - - const result = await editDiscussionReply( - ctx.gql, - reply, - { - body: options.body, - }, - "initiative", - ); + commandAction<[string, DiscussionBodyOptions, Command]>( + async (reply, options, command) => { + const ctx = createContext(getRootOpts(command)); + + if (!options.body) { + throw invalidParameterError("--body", "is required"); + } + + const result = await editDiscussionReply( + ctx.gql, + asUuid(reply), + { + body: options.body, + }, + "initiative", + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); initiatives .command("delete-comment <comment>") .description("delete a root discussion or reply comment") .action( - handleCommand(async (...args: unknown[]) => { - const [comment, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - - const result = await deleteDiscussionComment( - ctx.gql, - comment, - "initiative", - ); - - outputSuccess(result); - }), + commandAction<[string, unknown, Command]>( + async (comment, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + + const result = await deleteDiscussionComment( + ctx.gql, + asUuid(comment), + "initiative", + ); + + outputSuccess(result); + }, + ), ); initiatives .command("delete-reply <reply>") .description("delete a discussion reply") .action( - handleCommand(async (...args: unknown[]) => { - const [reply, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - - const result = await deleteDiscussionReply( - ctx.gql, - reply, - "initiative", - ); - - outputSuccess(result); - }), + commandAction<[string, unknown, Command]>( + async (reply, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + + const result = await deleteDiscussionReply( + ctx.gql, + asUuid(reply), + "initiative", + ); + + outputSuccess(result); + }, + ), ); initiatives @@ -803,36 +643,40 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .description("resolve a discussion thread") .option("--with-comment <comment>", "comment to mark as resolving comment") .action( - handleCommand(async (...args: unknown[]) => { - const [thread, options, command] = args as [ - string, - ResolveDiscussionOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - const result = await resolveDiscussion(ctx.gql, { - threadId: thread, - resolvingCommentId: options.withComment, - entityKind: "initiative", - }); - - outputSuccess(result); - }), + commandAction<[string, ResolveDiscussionOptions, Command]>( + async (thread, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const result = await resolveDiscussion(ctx.gql, { + threadId: asUuid(thread), + ...(options.withComment !== undefined + ? { resolvingCommentId: asUuid(options.withComment) } + : {}), + entityKind: "initiative", + }); + + outputSuccess(result); + }, + ), ); initiatives .command("unresolve <thread>") .description("unresolve a discussion thread") .action( - handleCommand(async (...args: unknown[]) => { - const [thread, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - - const result = await unresolveDiscussion(ctx.gql, thread, "initiative"); + commandAction<[string, unknown, Command]>( + async (thread, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + + const result = await unresolveDiscussion( + ctx.gql, + asUuid(thread), + "initiative", + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); initiatives @@ -845,45 +689,42 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .option("--target-date <date>", "target date (YYYY-MM-DD)") .option("--sort-order <n>", "display sort order") .action( - handleCommand(async (...args: unknown[]) => { - const [name, options, command] = args as [ - string, - InitiativeCreateOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - const input: InitiativeCreateInput = { name }; - - if (options.description !== undefined) { - input.description = options.description; - } - - if (options.content !== undefined) { - input.content = options.content; - } - - if (options.owner) { - input.ownerId = await resolveUserId(ctx.sdk, options.owner); - } - - const status = parseInitiativeStatus(options.status); - if (status) { - input.status = status; - } - - if (options.targetDate !== undefined) { - input.targetDate = options.targetDate; - } - - const sortOrder = parseSortOrderNumber(options.sortOrder); - if (sortOrder !== undefined) { - input.sortOrder = sortOrder; - } - - const result = await createInitiative(ctx.gql, input); - outputSuccess(result); - }), + commandAction<[string, InitiativeCreateOptions, Command]>( + async (name, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const input: CreateInitiativeInput = { name }; + + if (options.description !== undefined) { + input.description = options.description; + } + + if (options.content !== undefined) { + input.content = options.content; + } + + if (options.owner) { + input.ownerId = await resolveUserId(ctx.gql, options.owner); + } + + const status = parseInitiativeStatus(options.status); + if (status) { + input.status = status; + } + + if (options.targetDate !== undefined) { + input.targetDate = options.targetDate; + } + + const sortOrder = parseSortOrderNumber(options.sortOrder); + if (sortOrder !== undefined) { + input.sortOrder = sortOrder; + } + + const result = await createInitiative(ctx.gql, input); + outputSuccess(result); + }, + ), ); initiatives @@ -897,95 +738,95 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .option("--target-date <date>", "new target date (YYYY-MM-DD)") .option("--sort-order <n>", "new display sort order") .action( - handleCommand(async (...args: unknown[]) => { - const [initiative, options, command] = args as [ - string, - InitiativeUpdateOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); - - const input: InitiativeUpdateInput = {}; - - if (options.name !== undefined) { - input.name = options.name; - } - - if (options.description !== undefined) { - input.description = options.description; - } - - if (options.content !== undefined) { - input.content = options.content; - } - - if (options.owner) { - input.ownerId = await resolveUserId(ctx.sdk, options.owner); - } - - const status = parseInitiativeStatus(options.status); - if (status) { - input.status = status; - } - - if (options.targetDate !== undefined) { - input.targetDate = options.targetDate; - } - - const sortOrder = parseSortOrderNumber(options.sortOrder); - if (sortOrder !== undefined) { - input.sortOrder = sortOrder; - } - - if (Object.keys(input).length === 0) { - throw invalidParameterError( - "update options", - "at least one option must be provided", - ); - } + commandAction<[string, InitiativeUpdateOptions, Command]>( + async (initiative, options, command) => { + const ctx = createContext(getRootOpts(command)); + const initiativeId = await resolveInitiativeId(ctx.gql, initiative); + + const input: UpdateInitiativeInput = {}; + + if (options.name !== undefined) { + input.name = options.name; + } + + if (options.description !== undefined) { + input.description = options.description; + } + + if (options.content !== undefined) { + input.content = options.content; + } + + if (options.owner) { + input.ownerId = await resolveUserId(ctx.gql, options.owner); + } + + const status = parseInitiativeStatus(options.status); + if (status) { + input.status = status; + } + + if (options.targetDate !== undefined) { + input.targetDate = options.targetDate; + } + + const sortOrder = parseSortOrderNumber(options.sortOrder); + if (sortOrder !== undefined) { + input.sortOrder = sortOrder; + } + + if (Object.keys(input).length === 0) { + throw invalidParameterError( + "update options", + "at least one option must be provided", + ); + } - const result = await updateInitiative(ctx.gql, initiativeId, input); - outputSuccess(result); - }), + const result = await updateInitiative(ctx.gql, initiativeId, input); + outputSuccess(result); + }, + ), ); initiatives .command("archive <initiative>") .description("archive an initiative") .action( - handleCommand(async (...args: unknown[]) => { - const [initiative, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); - const result = await archiveInitiative(ctx.gql, initiativeId); - outputSuccess(result); - }), + commandAction<[string, unknown, Command]>( + async (initiative, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const initiativeId = await resolveInitiativeId(ctx.gql, initiative); + const result = await archiveInitiative(ctx.gql, initiativeId); + outputSuccess(result); + }, + ), ); initiatives .command("unarchive <initiative>") .description("unarchive an initiative") .action( - handleCommand(async (...args: unknown[]) => { - const [initiative, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); - const result = await unarchiveInitiative(ctx.gql, initiativeId); - outputSuccess(result); - }), + commandAction<[string, unknown, Command]>( + async (initiative, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const initiativeId = await resolveInitiativeId(ctx.gql, initiative); + const result = await unarchiveInitiative(ctx.gql, initiativeId); + outputSuccess(result); + }, + ), ); initiatives .command("delete <initiative>") .description("delete an initiative") .action( - handleCommand(async (...args: unknown[]) => { - const [initiative, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); - const result = await deleteInitiative(ctx.gql, initiativeId); - outputSuccess(result); - }), + commandAction<[string, unknown, Command]>( + async (initiative, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const initiativeId = await resolveInitiativeId(ctx.gql, initiative); + const result = await deleteInitiative(ctx.gql, initiativeId); + outputSuccess(result); + }, + ), ); } diff --git a/src/commands/initiatives/projects.ts b/src/commands/initiatives/projects.ts index 6b1d17e0..13b35fc8 100644 --- a/src/commands/initiatives/projects.ts +++ b/src/commands/initiatives/projects.ts @@ -25,8 +25,8 @@ export function setupInitiativeProjectCommands(initiatives: Command): void { ]; const ctx = createContext(getRootOpts(command)); - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); - const projectId = await resolveProjectId(ctx.sdk, project); + const initiativeId = await resolveInitiativeId(ctx.gql, initiative); + const projectId = await resolveProjectId(ctx.gql, project); const result = await createInitiativeProjectLink(ctx.gql, { initiativeId, @@ -50,8 +50,8 @@ export function setupInitiativeProjectCommands(initiatives: Command): void { ]; const ctx = createContext(getRootOpts(command)); - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); - const projectId = await resolveProjectId(ctx.sdk, project); + const initiativeId = await resolveInitiativeId(ctx.gql, initiative); + const projectId = await resolveProjectId(ctx.gql, project); const linkId = await resolveInitiativeProjectLinkId( ctx.gql, diff --git a/src/commands/initiatives/relations.ts b/src/commands/initiatives/relations.ts index 58520a37..9ecaf746 100644 --- a/src/commands/initiatives/relations.ts +++ b/src/commands/initiatives/relations.ts @@ -24,8 +24,8 @@ export function setupInitiativeRelationCommands(initiatives: Command): void { ]; const ctx = createContext(getRootOpts(command)); - const parentId = await resolveInitiativeId(ctx.sdk, parent); - const childId = await resolveInitiativeId(ctx.sdk, child); + const parentId = await resolveInitiativeId(ctx.gql, parent); + const childId = await resolveInitiativeId(ctx.gql, child); const result = await createInitiativeRelation(ctx.gql, { parentId, @@ -49,8 +49,8 @@ export function setupInitiativeRelationCommands(initiatives: Command): void { ]; const ctx = createContext(getRootOpts(command)); - const parentId = await resolveInitiativeId(ctx.sdk, parent); - const childId = await resolveInitiativeId(ctx.sdk, child); + const parentId = await resolveInitiativeId(ctx.gql, parent); + const childId = await resolveInitiativeId(ctx.gql, child); const relationId = await resolveInitiativeRelationId( ctx.gql, diff --git a/src/commands/initiatives/updates.ts b/src/commands/initiatives/updates.ts index 60629653..ddc4159c 100644 --- a/src/commands/initiatives/updates.ts +++ b/src/commands/initiatives/updates.ts @@ -1,22 +1,22 @@ import type { Command } from "commander"; import { createContext, getRootOpts } from "../../common/context.js"; import { invalidParameterError } from "../../common/errors.js"; +import { asUuid } from "../../common/identifier.js"; import { handleCommand, outputSuccess, parseLimit, } from "../../common/output.js"; -import { - type InitiativeUpdateCreateInput, - InitiativeUpdateHealthType, - type InitiativeUpdateUpdateInput, -} from "../../gql/graphql.js"; +import { buildPaginationOptions } from "../../common/types.js"; import { resolveInitiativeId } from "../../resolvers/initiative-resolver.js"; import { archiveInitiativeUpdate, + type CreateInitiativeUpdateInput, createInitiativeUpdate, getInitiativeUpdate, listInitiativeUpdates, + parseHealth, + type UpdateInitiativeUpdateInput, unarchiveInitiativeUpdate, updateInitiativeUpdate, } from "../../services/initiative-update-service.js"; @@ -39,20 +39,6 @@ interface InitiativeUpdatesUpdateOptions { health?: string; } -function parseHealth(value?: string): InitiativeUpdateHealthType | undefined { - if (!value) return undefined; - - const normalized = value.trim().toLowerCase(); - if (normalized === "ontrack") return InitiativeUpdateHealthType.OnTrack; - if (normalized === "atrisk") return InitiativeUpdateHealthType.AtRisk; - if (normalized === "offtrack") return InitiativeUpdateHealthType.OffTrack; - - throw invalidParameterError( - "--health", - 'must be one of: "onTrack", "atRisk", "offTrack"', - ); -} - export function setupInitiativeUpdateCommands(initiatives: Command): void { const updates = initiatives .command("updates") @@ -76,14 +62,13 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { const ctx = createContext(getRootOpts(command)); const initiativeId = await resolveInitiativeId( - ctx.sdk, + ctx.gql, options.initiative, ); const result = await listInitiativeUpdates(ctx.gql, { initiativeId, - limit: parseLimit(options.limit), - after: options.after, + ...buildPaginationOptions(parseLimit(options.limit), options.after), includeArchived: options.includeArchived ?? false, }); @@ -98,7 +83,7 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { handleCommand(async (...args: unknown[]) => { const [updateId, , command] = args as [string, unknown, Command]; const ctx = createContext(getRootOpts(command)); - const result = await getInitiativeUpdate(ctx.gql, updateId); + const result = await getInitiativeUpdate(ctx.gql, asUuid(updateId)); outputSuccess(result); }), ); @@ -118,11 +103,11 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { const ctx = createContext(getRootOpts(command)); const initiativeId = await resolveInitiativeId( - ctx.sdk, + ctx.gql, options.initiative, ); - const input: InitiativeUpdateCreateInput = { initiativeId }; + const input: CreateInitiativeUpdateInput = { initiativeId }; if (options.body !== undefined) { input.body = options.body; @@ -152,7 +137,7 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { ]; const ctx = createContext(getRootOpts(command)); - const input: InitiativeUpdateUpdateInput = {}; + const input: UpdateInitiativeUpdateInput = {}; if (options.body !== undefined) { input.body = options.body; @@ -170,7 +155,11 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { ); } - const result = await updateInitiativeUpdate(ctx.gql, updateId, input); + const result = await updateInitiativeUpdate( + ctx.gql, + asUuid(updateId), + input, + ); outputSuccess(result); }), ); @@ -182,7 +171,7 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { handleCommand(async (...args: unknown[]) => { const [updateId, , command] = args as [string, unknown, Command]; const ctx = createContext(getRootOpts(command)); - const result = await archiveInitiativeUpdate(ctx.gql, updateId); + const result = await archiveInitiativeUpdate(ctx.gql, asUuid(updateId)); outputSuccess(result); }), ); @@ -194,7 +183,10 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { handleCommand(async (...args: unknown[]) => { const [updateId, , command] = args as [string, unknown, Command]; const ctx = createContext(getRootOpts(command)); - const result = await unarchiveInitiativeUpdate(ctx.gql, updateId); + const result = await unarchiveInitiativeUpdate( + ctx.gql, + asUuid(updateId), + ); outputSuccess(result); }), ); diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 8d8080e9..9e37b872 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -1,41 +1,41 @@ import type { Command } from "commander"; +import { firstOrThrow } from "../common/array.js"; import type { CommandContext } from "../common/context.js"; import { createContext, getRootOpts } from "../common/context.js"; +import { parseLabelMode } from "../common/domain-values.js"; import { resolveReactionEmojiInput } from "../common/emoji.js"; import { invalidParameterError } from "../common/errors.js"; import { validateEstimateAgainstTeamConfig } from "../common/estimate-validation.js"; import { + asUuid, isUuid, parseDueDate, parseIssueIdentifier, + type UUID, } from "../common/identifier.js"; import type { RawFilterFlags } from "../common/issue-filter.js"; import { parseEstimateOption, parsePriorityOption, } from "../common/number-options.js"; -import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; +import { commandAction, outputSuccess, parseLimit } from "../common/output.js"; import { resolveFilterOptions } from "../common/resolve-filters.js"; +import { buildPaginationOptions } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; +import type { IssueRelationType } from "../gql/graphql.js"; import { - type IssueCreateInput, - IssueRelationType, - type IssueUpdateInput, -} from "../gql/graphql.js"; -import { resolveCycleId } from "../resolvers/cycle-resolver.js"; + type ResolveCreateIssueIdsInput, + type ResolvedUpdateIssueIds, + type ResolveUpdateIssueIdsInput, + resolveCreateIssueIds, + resolveUpdateIssueIds, + type UpdateIssueContext, +} from "../resolvers/issue-mutation-resolver.js"; import { resolveIssueEstimateContext, resolveIssueId, } from "../resolvers/issue-resolver.js"; -import { resolveLabelIds } from "../resolvers/label-resolver.js"; -import { resolveMilestoneId } from "../resolvers/milestone-resolver.js"; -import { resolveProjectId } from "../resolvers/project-resolver.js"; -import { resolveStatusId } from "../resolvers/status-resolver.js"; -import { - resolveTeamEstimateContext, - resolveTeamId, -} from "../resolvers/team-resolver.js"; -import { resolveUserId } from "../resolvers/user-resolver.js"; +import { getIssueActivity } from "../services/activity-service.js"; import { createDiscussionCommentReaction, deleteDiscussionComment, @@ -58,9 +58,11 @@ import { createIssueRelation, deleteIssueRelation, findIssueRelation, + listIssueRelations, } from "../services/issue-relation-service.js"; import { archiveIssue, + type CreateIssueInput, createIssue, deleteIssue, getIssue, @@ -75,6 +77,7 @@ import { getIssueWithReactions, listIssues, searchIssues, + type UpdateIssueInput, unarchiveIssue, updateIssue, } from "../services/issue-service.js"; @@ -107,6 +110,7 @@ interface CreateOptions { blockedBy?: string; relatesTo?: string; duplicateOf?: string; + similarTo?: string; } interface UpdateOptions { @@ -133,6 +137,7 @@ interface UpdateOptions { blockedBy?: string; relatesTo?: string; duplicateOf?: string; + similarTo?: string; removeRelation?: string; } @@ -167,6 +172,13 @@ interface DiscussionsOptions { withReactions?: boolean; } +interface ActivityOptions { + limit: string; + after?: string; + commentsOnly?: boolean; + withReactions?: boolean; +} + interface DiscussionBodyOptions { body?: string; } @@ -184,23 +196,19 @@ function addCommentReactionCommands( .description(`add a reaction to a discussion ${noun}`) .option("--shortcode <name>", "emoji shortcode (e.g. thumbs_up)") .action( - handleCommand(async (...args: unknown[]) => { - const [commentId, emoji, options, command] = args as [ - string, - string | undefined, - ReactionOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const result = await createDiscussionCommentReaction(ctx.gql, { - commentId, - target: noun, - expectedEntityKind: "issue", - emoji: resolveReactionEmojiInput(emoji, options.shortcode), - }); + commandAction<[string, string | undefined, ReactionOptions, Command]>( + async (commentId, emoji, options, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await createDiscussionCommentReaction(ctx.gql, { + commentId: asUuid(commentId), + target: noun, + expectedEntityKind: "issue", + emoji: resolveReactionEmojiInput(emoji, options.shortcode), + }); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); parent @@ -208,23 +216,19 @@ function addCommentReactionCommands( .description(`remove your reaction from a discussion ${noun} by emoji`) .option("--shortcode <name>", "emoji shortcode (e.g. thumbs_up)") .action( - handleCommand(async (...args: unknown[]) => { - const [commentId, emoji, options, command] = args as [ - string, - string | undefined, - ReactionOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { - commentId, - target: noun, - expectedEntityKind: "issue", - emoji: resolveReactionEmojiInput(emoji, options.shortcode), - }); + commandAction<[string, string | undefined, ReactionOptions, Command]>( + async (commentId, emoji, options, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { + commentId: asUuid(commentId), + target: noun, + expectedEntityKind: "issue", + emoji: resolveReactionEmojiInput(emoji, options.shortcode), + }); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); parent @@ -233,23 +237,19 @@ function addCommentReactionCommands( `remove your reaction from a discussion ${noun} by reaction ID`, ) .action( - handleCommand(async (...args: unknown[]) => { - const [commentId, reactionId, , command] = args as [ - string, - string, - unknown, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionCommentReactionById(ctx.gql, { - commentId, - target: noun, - expectedEntityKind: "issue", - reactionId, - }); + commandAction<[string, string, unknown, Command]>( + async (commentId, reactionId, _unused2, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await deleteDiscussionCommentReactionById(ctx.gql, { + commentId: asUuid(commentId), + target: noun, + expectedEntityKind: "issue", + reactionId: asUuid(reactionId), + }); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); } @@ -273,6 +273,7 @@ export const ISSUES_META: DomainMeta = { query: "full-text search term", }, seeAlso: [ + "issues activity <issue>", "comments create <issue>", "documents list --issue <issue>", "attachments list <issue>", @@ -284,15 +285,29 @@ export const ISSUES_META: DomainMeta = { }; interface RelationAction { - type: "blocks" | "blockedBy" | "relatesTo" | "duplicateOf" | "remove"; + type: + | "blocks" + | "blockedBy" + | "relatesTo" + | "duplicateOf" + | "similarTo" + | "remove"; targets: string[]; } +interface RelationAddOptions { + blocks?: string; + related?: string; + duplicate?: string; + similar?: string; +} + function parseRelationFlags(flags: { blocks?: string; blockedBy?: string; relatesTo?: string; duplicateOf?: string; + similarTo?: string; removeRelation?: string; }): RelationAction[] { const entries: Array<{ @@ -303,6 +318,7 @@ function parseRelationFlags(flags: { { type: "blockedBy", raw: flags.blockedBy }, { type: "relatesTo", raw: flags.relatesTo }, { type: "duplicateOf", raw: flags.duplicateOf }, + { type: "similarTo", raw: flags.similarTo }, { type: "remove", raw: flags.removeRelation }, ]; @@ -327,7 +343,7 @@ function parseRelationFlags(flags: { ]; if (targets.length === 0) { throw new Error( - `Relation flag --${type === "remove" ? "remove-relation" : type} must not be empty`, + `Relation flag ${relationFlagName(type)} must not be empty`, ); } actions.push({ type, targets }); @@ -340,30 +356,83 @@ function parseRelationFlags(flags: { const prev = seen.get(target); if (prev) { throw new Error( - `${target} appears in multiple relation flags (${prev} and --${action.type === "remove" ? "remove-relation" : action.type})`, + `${target} appears in multiple relation flags (${prev} and ${relationFlagName(action.type)})`, ); } - seen.set( - target, - `--${action.type === "remove" ? "remove-relation" : action.type}`, - ); + seen.set(target, relationFlagName(action.type)); } } return actions; } +function relationFlagName(type: RelationAction["type"]): string { + switch (type) { + case "blocks": + return "--blocks"; + case "blockedBy": + return "--blocked-by"; + case "relatesTo": + return "--relates-to"; + case "duplicateOf": + return "--duplicate-of"; + case "similarTo": + return "--similar-to"; + case "remove": + return "--remove-relation"; + } +} + +function parseRelationAddOptions(options: RelationAddOptions): { + type: IssueRelationType; + targets: string[]; +} { + const typeFlags = [ + options.blocks ? "blocks" : null, + options.related ? "related" : null, + options.duplicate ? "duplicate" : null, + options.similar ? "similar" : null, + ].filter((type): type is keyof RelationAddOptions => type !== null); + + if (typeFlags.length > 1) { + throw new Error("Cannot specify multiple relation types"); + } + + const type = firstOrThrow( + typeFlags, + "Must specify one of --blocks, --related, --duplicate, or --similar", + ); + const rawTargets = options[type] ?? ""; + const targets = [ + ...new Set( + rawTargets + .split(",") + .map((target) => target.trim()) + .filter(Boolean), + ), + ]; + + if (targets.length === 0) { + throw new Error("At least one related issue ID must be provided"); + } + + return { + type, + targets, + }; +} + async function resolveAndApplyRelations( ctx: CommandContext, - issueId: string, + issueId: UUID, actions: RelationAction[], ): Promise<void> { // Resolve all unique targets to UUIDs const uniqueTargets = new Set(actions.flatMap((a) => a.targets)); - const resolved = new Map<string, string>(); + const resolved = new Map<string, UUID>(); await Promise.all( [...uniqueTargets].map(async (target) => { - resolved.set(target, await resolveIssueId(ctx.sdk, target)); + resolved.set(target, await resolveIssueId(ctx.gql, target)); }), ); @@ -376,28 +445,35 @@ async function resolveAndApplyRelations( await createIssueRelation(ctx.gql, { issueId, relatedIssueId: targetId, - type: IssueRelationType.Blocks, + type: "blocks", }); break; case "blockedBy": await createIssueRelation(ctx.gql, { issueId: targetId, relatedIssueId: issueId, - type: IssueRelationType.Blocks, + type: "blocks", }); break; case "relatesTo": await createIssueRelation(ctx.gql, { issueId, relatedIssueId: targetId, - type: IssueRelationType.Related, + type: "related", }); break; case "duplicateOf": await createIssueRelation(ctx.gql, { issueId, relatedIssueId: targetId, - type: IssueRelationType.Duplicate, + type: "duplicate", + }); + break; + case "similarTo": + await createIssueRelation(ctx.gql, { + issueId, + relatedIssueId: targetId, + type: "similar", }); break; case "remove": { @@ -450,6 +526,76 @@ export function setupIssuesCommands(program: Command): void { issues.action(() => issues.help()); + const relations = issues + .command("relations") + .description("Issue relation operations"); + + relations.action(() => relations.help()); + + relations + .command("list <issue>") + .description("list relations for an issue") + .action( + commandAction<[string, unknown, Command]>( + async (issue, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const issueId = await resolveIssueId(ctx.gql, issue); + const result = await listIssueRelations(ctx.gql, issueId); + + outputSuccess(result); + }, + ), + ); + + relations + .command("add <issue>") + .description("add relation(s) to an issue") + .option("--blocks <issues>", "issues this issue blocks (comma-separated)") + .option("--related <issues>", "related issues (comma-separated)") + .option( + "--duplicate <issues>", + "issues this is a duplicate of (comma-separated)", + ) + .option("--similar <issues>", "similar issues (comma-separated)") + .action( + commandAction<[string, RelationAddOptions, Command]>( + async (issue, options, command) => { + const relation = parseRelationAddOptions(options); + const ctx = createContext(getRootOpts(command)); + const sourceIssueId = await resolveIssueId(ctx.gql, issue); + const targetIds = await Promise.all( + relation.targets.map((target) => resolveIssueId(ctx.gql, target)), + ); + + const created = await Promise.all( + targetIds.map((targetId) => + createIssueRelation(ctx.gql, { + issueId: sourceIssueId, + relatedIssueId: targetId, + type: relation.type, + }), + ), + ); + + outputSuccess(created); + }, + ), + ); + + relations + .command("remove <relation>") + .description("remove a relation by UUID") + .action( + commandAction<[string, unknown, Command]>( + async (relation, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await deleteIssueRelation(ctx.gql, asUuid(relation)); + + outputSuccess(result); + }, + ), + ); + addFilterOptions( issues .command("list") @@ -458,14 +604,13 @@ export function setupIssuesCommands(program: Command): void { .option("-l, --limit <n>", "max results", "50") .option("--after <cursor>", "cursor for next page"), ).action( - handleCommand(async (...args: unknown[]) => { - const [options, command] = args as [FilterOptions, Command]; + commandAction<[FilterOptions, Command]>(async (options, command) => { const ctx = createContext(getRootOpts(command)); - const paginationOptions = { - limit: parseLimit(options.limit), - after: options.after, - }; + const paginationOptions = buildPaginationOptions( + parseLimit(options.limit), + options.after, + ); const filterOptions = await resolveFilterOptions(ctx, options); const filter = buildIssueFilter(filterOptions); @@ -493,29 +638,26 @@ export function setupIssuesCommands(program: Command): void { .option("-l, --limit <n>", "max results", "50") .option("--after <cursor>", "cursor for next page"), ).action( - handleCommand(async (...args: unknown[]) => { - const [query, options, command] = args as [ - string, - FilterOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, FilterOptions, Command]>( + async (query, options, command) => { + const ctx = createContext(getRootOpts(command)); - const paginationOptions = { - limit: parseLimit(options.limit), - after: options.after, - }; + const paginationOptions = buildPaginationOptions( + parseLimit(options.limit), + options.after, + ); - const filterOptions = await resolveFilterOptions(ctx, options); - const filter = buildIssueFilter(filterOptions); - const result = await searchIssues( - ctx.gql, - query, - paginationOptions, - filter, - ); - outputSuccess(result); - }), + const filterOptions = await resolveFilterOptions(ctx, options); + const filter = buildIssueFilter(filterOptions); + const result = await searchIssues( + ctx.gql, + query, + paginationOptions, + filter, + ); + outputSuccess(result); + }, + ), ); issues @@ -533,92 +675,89 @@ export function setupIssuesCommands(program: Command): void { `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, ) .action( - handleCommand(async (...args: unknown[]) => { - const [issue, options, command] = args as [ - string, - ReadOptions, - Command, - ]; - validateReadOptions(options); - const ctx = createContext(getRootOpts(command)); + commandAction<[string, ReadOptions, Command]>( + async (issue, options, command) => { + validateReadOptions(options); + const ctx = createContext(getRootOpts(command)); + + if (options.withAttachments) { + if (isUuid(issue)) { + const result = await getIssueWithAttachments(ctx.gql, issue); + outputSuccess(result); + } else { + const { teamKey, issueNumber } = parseIssueIdentifier(issue); + const result = await getIssueByIdentifierWithAttachments( + ctx.gql, + teamKey, + issueNumber, + ); + outputSuccess(result); + } + return; + } - if (options.withAttachments) { - if (isUuid(issue)) { - const result = await getIssueWithAttachments(ctx.gql, issue); - outputSuccess(result); - } else { - const { teamKey, issueNumber } = parseIssueIdentifier(issue); - const result = await getIssueByIdentifierWithAttachments( - ctx.gql, - teamKey, - issueNumber, - ); - outputSuccess(result); + if (options.withCommentThreads) { + if (isUuid(issue)) { + const result = await getIssueWithCommentThreads(ctx.gql, issue); + outputSuccess(result); + } else { + const { teamKey, issueNumber } = parseIssueIdentifier(issue); + const result = await getIssueByIdentifierWithCommentThreads( + ctx.gql, + teamKey, + issueNumber, + ); + outputSuccess(result); + } + return; } - return; - } - if (options.withCommentThreads) { - if (isUuid(issue)) { - const result = await getIssueWithCommentThreads(ctx.gql, issue); - outputSuccess(result); - } else { - const { teamKey, issueNumber } = parseIssueIdentifier(issue); - const result = await getIssueByIdentifierWithCommentThreads( - ctx.gql, - teamKey, - issueNumber, - ); - outputSuccess(result); + if (options.withComments) { + if (isUuid(issue)) { + const result = await getIssueWithComments(ctx.gql, issue); + outputSuccess(result); + } else { + const { teamKey, issueNumber } = parseIssueIdentifier(issue); + const result = await getIssueByIdentifierWithComments( + ctx.gql, + teamKey, + issueNumber, + ); + outputSuccess(result); + } + return; } - return; - } - if (options.withComments) { - if (isUuid(issue)) { - const result = await getIssueWithComments(ctx.gql, issue); - outputSuccess(result); - } else { - const { teamKey, issueNumber } = parseIssueIdentifier(issue); - const result = await getIssueByIdentifierWithComments( - ctx.gql, - teamKey, - issueNumber, - ); - outputSuccess(result); + if (options.withReactions) { + if (isUuid(issue)) { + const result = await getIssueWithReactions(ctx.gql, issue); + outputSuccess(result); + } else { + const { teamKey, issueNumber } = parseIssueIdentifier(issue); + const result = await getIssueByIdentifierWithReactions( + ctx.gql, + teamKey, + issueNumber, + ); + outputSuccess(result); + } + return; } - return; - } - if (options.withReactions) { if (isUuid(issue)) { - const result = await getIssueWithReactions(ctx.gql, issue); + const result = await getIssue(ctx.gql, issue); outputSuccess(result); } else { const { teamKey, issueNumber } = parseIssueIdentifier(issue); - const result = await getIssueByIdentifierWithReactions( + const result = await getIssueByIdentifier( ctx.gql, teamKey, issueNumber, ); outputSuccess(result); } - return; - } - - if (isUuid(issue)) { - const result = await getIssue(ctx.gql, issue); - outputSuccess(result); - } else { - const { teamKey, issueNumber } = parseIssueIdentifier(issue); - const result = await getIssueByIdentifier( - ctx.gql, - teamKey, - issueNumber, - ); - outputSuccess(result); - } - }), + }, + ), ); issues @@ -630,22 +769,18 @@ export function setupIssuesCommands(program: Command): void { `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, ) .action( - handleCommand(async (...args: unknown[]) => { - const [issue, emoji, options, command] = args as [ - string, - string | undefined, - ReactionOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); - const result = await createReactionForIssue(ctx.gql, { - issueId, - emoji: resolveReactionEmojiInput(emoji, options.shortcode), - }); + commandAction<[string, string | undefined, ReactionOptions, Command]>( + async (issue, emoji, options, command) => { + const ctx = createContext(getRootOpts(command)); + const issueId = await resolveIssueId(ctx.gql, issue); + const result = await createReactionForIssue(ctx.gql, { + issueId, + emoji: resolveReactionEmojiInput(emoji, options.shortcode), + }); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues @@ -657,23 +792,19 @@ export function setupIssuesCommands(program: Command): void { `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, ) .action( - handleCommand(async (...args: unknown[]) => { - const [issue, emoji, options, command] = args as [ - string, - string | undefined, - ReactionOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); - const result = await deleteOwnReactionByEmoji(ctx.gql, { - kind: "issue", - id: issueId, - emoji: resolveReactionEmojiInput(emoji, options.shortcode), - }); + commandAction<[string, string | undefined, ReactionOptions, Command]>( + async (issue, emoji, options, command) => { + const ctx = createContext(getRootOpts(command)); + const issueId = await resolveIssueId(ctx.gql, issue); + const result = await deleteOwnReactionByEmoji(ctx.gql, { + kind: "issue", + id: issueId, + emoji: resolveReactionEmojiInput(emoji, options.shortcode), + }); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues @@ -684,23 +815,19 @@ export function setupIssuesCommands(program: Command): void { `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, ) .action( - handleCommand(async (...args: unknown[]) => { - const [issue, reactionId, , command] = args as [ - string, - string, - unknown, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); - const result = await deleteOwnReactionById(ctx.gql, { - kind: "issue", - id: issueId, - reactionId, - }); + commandAction<[string, string, unknown, Command]>( + async (issue, reactionId, _unused2, command) => { + const ctx = createContext(getRootOpts(command)); + const issueId = await resolveIssueId(ctx.gql, issue); + const result = await deleteOwnReactionById(ctx.gql, { + kind: "issue", + id: issueId, + reactionId: asUuid(reactionId), + }); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues @@ -712,26 +839,57 @@ export function setupIssuesCommands(program: Command): void { ) .option("--body <text>", "discussion body (required, markdown supported)") .action( - handleCommand(async (...args: unknown[]) => { - const [issue, options, command] = args as [ - string, - DiscussionBodyOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, DiscussionBodyOptions, Command]>( + async (issue, options, command) => { + const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + if (!options.body) { + throw invalidParameterError("--body", "is required"); + } - const issueId = await resolveIssueId(ctx.sdk, issue); - const result = await startIssueDiscussion(ctx.gql, { - issueId, - body: options.body, - }); + const issueId = await resolveIssueId(ctx.gql, issue); + const result = await startIssueDiscussion(ctx.gql, { + issueId, + body: options.body, + }); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), + ); + + issues + .command("activity <issue>") + .description( + "chronological activity timeline: comment threads plus history events", + ) + .addHelpText( + "after", + `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, + ) + .option("-l, --limit <n>", "max timeline items", "50") + .option("--after <cursor>", "cursor for next page") + .option("--comments-only", "exclude non-comment history events") + .option("--with-reactions", "include normalized comment reactions") + .action( + commandAction<[string, ActivityOptions, Command]>( + async (issue, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const issueId = await resolveIssueId(ctx.gql, issue); + const paginationOptions = buildPaginationOptions( + parseLimit(options.limit), + options.after, + ); + const result = await getIssueActivity(ctx.gql, issueId, { + ...paginationOptions, + commentsOnly: Boolean(options.commentsOnly), + withReactions: Boolean(options.withReactions), + }); + + outputSuccess(result); + }, + ), ); issues @@ -745,29 +903,30 @@ export function setupIssuesCommands(program: Command): void { .option("--after <cursor>", "cursor for next page") .option("--with-reactions", "include normalized discussion reactions") .action( - handleCommand(async (...args: unknown[]) => { - const [issue, options, command] = args as [ - string, - DiscussionsOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - const issueId = await resolveIssueId(ctx.sdk, issue); - const paginationOptions = { - limit: parseLimit(options.limit || "25"), - after: options.after, - }; - const result = options.withReactions - ? await listDiscussionsForIssueWithReactions( - ctx.gql, - issueId, - paginationOptions, - ) - : await listDiscussionsForIssue(ctx.gql, issueId, paginationOptions); + commandAction<[string, DiscussionsOptions, Command]>( + async (issue, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const issueId = await resolveIssueId(ctx.gql, issue); + const paginationOptions = buildPaginationOptions( + parseLimit(options.limit || "25"), + options.after, + ); + const result = options.withReactions + ? await listDiscussionsForIssueWithReactions( + ctx.gql, + issueId, + paginationOptions, + ) + : await listDiscussionsForIssue( + ctx.gql, + issueId, + paginationOptions, + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); const issueThreads = issues @@ -782,34 +941,31 @@ export function setupIssuesCommands(program: Command): void { .option("--after <cursor>", "cursor for next page") .option("--with-reactions", "include normalized discussion reactions") .action( - handleCommand(async (...args: unknown[]) => { - const [thread, options, command] = args as [ - string, - DiscussionsOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, DiscussionsOptions, Command]>( + async (thread, options, command) => { + const ctx = createContext(getRootOpts(command)); - const paginationOptions = { - limit: parseLimit(options.limit || "50"), - after: options.after, - }; - const result = options.withReactions - ? await listDiscussionRepliesWithReactions( - ctx.gql, - thread, - paginationOptions, - "issue", - ) - : await listDiscussionReplies( - ctx.gql, - thread, - paginationOptions, - "issue", - ); + const paginationOptions = buildPaginationOptions( + parseLimit(options.limit || "50"), + options.after, + ); + const result = options.withReactions + ? await listDiscussionRepliesWithReactions( + ctx.gql, + asUuid(thread), + paginationOptions, + "issue", + ) + : await listDiscussionReplies( + ctx.gql, + asUuid(thread), + paginationOptions, + "issue", + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); addCommentReactionCommands(issueReplies, "reply"); @@ -822,26 +978,23 @@ export function setupIssuesCommands(program: Command): void { ) .option("--body <text>", "reply body (required, markdown supported)") .action( - handleCommand(async (...args: unknown[]) => { - const [thread, options, command] = args as [ - string, - DiscussionBodyOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, DiscussionBodyOptions, Command]>( + async (thread, options, command) => { + const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + if (!options.body) { + throw invalidParameterError("--body", "is required"); + } - const result = await replyToDiscussion(ctx.gql, { - threadId: thread, - body: options.body, - entityKind: "issue", - }); + const result = await replyToDiscussion(ctx.gql, { + threadId: asUuid(thread), + body: options.body, + entityKind: "issue", + }); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues @@ -849,29 +1002,26 @@ export function setupIssuesCommands(program: Command): void { .description("edit a root discussion or reply comment") .option("--body <text>", "new comment body (required, markdown supported)") .action( - handleCommand(async (...args: unknown[]) => { - const [comment, options, command] = args as [ - string, - DiscussionBodyOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, DiscussionBodyOptions, Command]>( + async (comment, options, command) => { + const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + if (!options.body) { + throw invalidParameterError("--body", "is required"); + } - const result = await editDiscussionComment( - ctx.gql, - comment, - { - body: options.body, - }, - "issue", - ); + const result = await editDiscussionComment( + ctx.gql, + asUuid(comment), + { + body: options.body, + }, + "issue", + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues @@ -879,57 +1029,64 @@ export function setupIssuesCommands(program: Command): void { .description("edit a discussion reply") .option("--body <text>", "new reply body (required, markdown supported)") .action( - handleCommand(async (...args: unknown[]) => { - const [reply, options, command] = args as [ - string, - DiscussionBodyOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, DiscussionBodyOptions, Command]>( + async (reply, options, command) => { + const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + if (!options.body) { + throw invalidParameterError("--body", "is required"); + } - const result = await editDiscussionReply( - ctx.gql, - reply, - { - body: options.body, - }, - "issue", - ); + const result = await editDiscussionReply( + ctx.gql, + asUuid(reply), + { + body: options.body, + }, + "issue", + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues .command("delete-comment <comment>") .description("delete a root discussion or reply comment") .action( - handleCommand(async (...args: unknown[]) => { - const [comment, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, unknown, Command]>( + async (comment, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionComment(ctx.gql, comment, "issue"); + const result = await deleteDiscussionComment( + ctx.gql, + asUuid(comment), + "issue", + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues .command("delete-reply <reply>") .description("delete a discussion reply") .action( - handleCommand(async (...args: unknown[]) => { - const [reply, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, unknown, Command]>( + async (reply, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionReply(ctx.gql, reply, "issue"); + const result = await deleteDiscussionReply( + ctx.gql, + asUuid(reply), + "issue", + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues @@ -937,36 +1094,40 @@ export function setupIssuesCommands(program: Command): void { .description("resolve a discussion thread") .option("--with-comment <comment>", "comment to mark as resolving comment") .action( - handleCommand(async (...args: unknown[]) => { - const [thread, options, command] = args as [ - string, - ResolveDiscussionOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - const result = await resolveDiscussion(ctx.gql, { - threadId: thread, - resolvingCommentId: options.withComment, - entityKind: "issue", - }); + commandAction<[string, ResolveDiscussionOptions, Command]>( + async (thread, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const result = await resolveDiscussion(ctx.gql, { + threadId: asUuid(thread), + ...(options.withComment !== undefined + ? { resolvingCommentId: asUuid(options.withComment) } + : {}), + entityKind: "issue", + }); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues .command("unresolve <thread>") .description("unresolve a discussion thread") .action( - handleCommand(async (...args: unknown[]) => { - const [thread, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, unknown, Command]>( + async (thread, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); - const result = await unresolveDiscussion(ctx.gql, thread, "issue"); + const result = await unresolveDiscussion( + ctx.gql, + asUuid(thread), + "issue", + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues @@ -988,126 +1149,125 @@ export function setupIssuesCommands(program: Command): void { .option("--blocked-by <issue>", "this issue is blocked by <issue>") .option("--relates-to <issue>", "this issue relates to <issue>") .option("--duplicate-of <issue>", "this issue duplicates <issue>") + .option("--similar-to <issue>", "this issue is similar to <issue>") .action( - handleCommand(async (...args: unknown[]) => { - const [title, options, command] = args as [ - string, - CreateOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, CreateOptions, Command]>( + async (title, options, command) => { + const ctx = createContext(getRootOpts(command)); - const relationActions = parseRelationFlags(options); + const relationActions = parseRelationFlags(options); - const parsedPriority = - options.priority !== undefined - ? parsePriorityOption(options.priority) - : undefined; - const parsedEstimate = - options.estimate !== undefined - ? parseEstimateOption(options.estimate) - : undefined; + const parsedPriority = + options.priority !== undefined + ? parsePriorityOption(options.priority) + : undefined; + const parsedEstimate = + options.estimate !== undefined + ? parseEstimateOption(options.estimate) + : undefined; - if (!options.team) { - throw new Error("--team is required"); - } + if (!options.team) { + throw new Error("--team is required"); + } - const teamEstimateContext = - parsedEstimate !== undefined - ? await resolveTeamEstimateContext(ctx.sdk, options.team) - : undefined; + if (options.projectMilestone && !options.project) { + throw new Error( + "--project-milestone requires --project to be specified", + ); + } - const teamId = teamEstimateContext - ? teamEstimateContext.teamId - : await resolveTeamId(ctx.sdk, options.team); - - if (parsedEstimate !== undefined && teamEstimateContext) { - validateEstimateAgainstTeamConfig(parsedEstimate, { - teamKey: teamEstimateContext.teamKey, - issueEstimationType: teamEstimateContext.issueEstimationType, - issueEstimationExtended: - teamEstimateContext.issueEstimationExtended, - issueEstimationAllowZero: - teamEstimateContext.issueEstimationAllowZero, - }); - } + const idsInput: ResolveCreateIssueIdsInput = { + team: options.team, + withEstimateContext: parsedEstimate !== undefined, + }; + if (options.assignee) idsInput.assignee = options.assignee; + if (options.project) idsInput.project = options.project; + if (options.labels) { + idsInput.labels = options.labels.split(",").map((l) => l.trim()); + } + if (options.projectMilestone) { + idsInput.projectMilestone = options.projectMilestone; + } + if (options.cycle) idsInput.cycle = options.cycle; + if (options.status) idsInput.status = options.status; + if (options.parentTicket) + idsInput.parentTicket = options.parentTicket; + + const ids = await resolveCreateIssueIds(ctx.gql, idsInput); + + if (parsedEstimate !== undefined && ids.estimateContext) { + validateEstimateAgainstTeamConfig(parsedEstimate, { + teamKey: ids.estimateContext.teamKey, + issueEstimationType: ids.estimateContext.issueEstimationType, + issueEstimationExtended: + ids.estimateContext.issueEstimationExtended, + issueEstimationAllowZero: + ids.estimateContext.issueEstimationAllowZero, + }); + } - const input: IssueCreateInput = { - title, - teamId, - }; + const input: CreateIssueInput = { + title, + teamId: ids.teamId, + }; - if (options.description) { - input.description = options.description; - } + if (options.description) { + input.description = options.description; + } - if (options.assignee) { - input.assigneeId = await resolveUserId(ctx.sdk, options.assignee); - } + if (ids.assigneeId) { + input.assigneeId = ids.assigneeId; + } - if (parsedPriority !== undefined) { - input.priority = parsedPriority; - } + if (parsedPriority !== undefined) { + input.priority = parsedPriority; + } - if (parsedEstimate !== undefined) { - input.estimate = parsedEstimate; - } + if (parsedEstimate !== undefined) { + input.estimate = parsedEstimate; + } - if (options.project) { - input.projectId = await resolveProjectId(ctx.sdk, options.project); - } + if (ids.projectId) { + input.projectId = ids.projectId; + } - if (options.labels) { - const labelNames = options.labels.split(",").map((l) => l.trim()); - input.labelIds = await resolveLabelIds(ctx.sdk, labelNames); - } + if (ids.labelIds) { + input.labelIds = ids.labelIds; + } - if (options.projectMilestone) { - if (!options.project) { - throw new Error( - "--project-milestone requires --project to be specified", - ); + if (ids.projectMilestoneId) { + input.projectMilestoneId = ids.projectMilestoneId; } - input.projectMilestoneId = await resolveMilestoneId( - ctx.gql, - ctx.sdk, - options.projectMilestone, - options.project, - ); - } - if (options.cycle) { - input.cycleId = await resolveCycleId( - ctx.sdk, - options.cycle, - options.team, - ); - } + if (ids.cycleId) { + input.cycleId = ids.cycleId; + } - if (options.status) { - input.stateId = await resolveStatusId( - ctx.sdk, - options.status, - teamId, - ); - } + if (ids.stateId) { + input.stateId = ids.stateId; + } - if (options.parentTicket) { - input.parentId = await resolveIssueId(ctx.sdk, options.parentTicket); - } + if (ids.parentId) { + input.parentId = ids.parentId; + } - if (options.dueDate) { - input.dueDate = parseDueDate(options.dueDate); - } + if (options.dueDate) { + input.dueDate = parseDueDate(options.dueDate); + } - const result = await createIssue(ctx.gql, input); + const result = await createIssue(ctx.gql, input); - if (relationActions.length > 0) { - await resolveAndApplyRelations(ctx, result.id, relationActions); - } + if (relationActions.length > 0) { + await resolveAndApplyRelations( + ctx, + asUuid(result.id), + relationActions, + ); + } - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues @@ -1124,7 +1284,7 @@ export function setupIssuesCommands(program: Command): void { .option("--assignee <user>", "new assignee") .option("--project <project>", "new project") .option("--labels <labels>", "labels to apply (comma-separated)") - .option("--label-mode <mode>", "add | overwrite") + .option("--label-mode <mode>", "add | remove | overwrite") .option("--clear-labels", "remove all labels") .option("--parent-ticket <issue>", "set parent issue") .option("--clear-parent-ticket", "clear parent") @@ -1140,248 +1300,274 @@ export function setupIssuesCommands(program: Command): void { .option("--blocked-by <issue>", "add blocked-by relation") .option("--relates-to <issue>", "add relates-to relation") .option("--duplicate-of <issue>", "add duplicate relation") + .option("--similar-to <issue>", "add similar relation") .option("--remove-relation <issue>", "remove relation with <issue>") .action( - handleCommand(async (...args: unknown[]) => { - const [issue, options, command] = args as [ - string, - UpdateOptions, - Command, - ]; - if (options.parentTicket && options.clearParentTicket) { - throw new Error( - "Cannot use --parent-ticket and --clear-parent-ticket together", - ); - } + commandAction<[string, UpdateOptions, Command]>( + async (issue, options, command) => { + if (options.parentTicket && options.clearParentTicket) { + throw new Error( + "Cannot use --parent-ticket and --clear-parent-ticket together", + ); + } - if (options.projectMilestone && options.clearProjectMilestone) { - throw new Error( - "Cannot use --project-milestone and --clear-project-milestone together", - ); - } + if (options.projectMilestone && options.clearProjectMilestone) { + throw new Error( + "Cannot use --project-milestone and --clear-project-milestone together", + ); + } - if (options.estimate !== undefined && options.clearEstimate) { - throw new Error( - "Cannot use --estimate and --clear-estimate together", - ); - } + if (options.estimate !== undefined && options.clearEstimate) { + throw new Error( + "Cannot use --estimate and --clear-estimate together", + ); + } - if (options.cycle && options.clearCycle) { - throw new Error("Cannot use --cycle and --clear-cycle together"); - } + if (options.cycle && options.clearCycle) { + throw new Error("Cannot use --cycle and --clear-cycle together"); + } - if (options.dueDate && options.clearDueDate) { - throw new Error( - "Cannot use --due-date and --clear-due-date together", - ); - } + if (options.dueDate && options.clearDueDate) { + throw new Error( + "Cannot use --due-date and --clear-due-date together", + ); + } - if (options.labelMode && !options.labels) { - throw new Error("--label-mode requires --labels to be specified"); - } + if (options.labelMode && !options.labels) { + throw new Error("--label-mode requires --labels to be specified"); + } - if (options.clearLabels && options.labels) { - throw new Error("--clear-labels cannot be used with --labels"); - } + if (options.clearLabels && options.labels) { + throw new Error("--clear-labels cannot be used with --labels"); + } - if (options.clearLabels && options.labelMode) { - throw new Error("--clear-labels cannot be used with --label-mode"); - } + if (options.clearLabels && options.labelMode) { + throw new Error("--clear-labels cannot be used with --label-mode"); + } - if ( - options.labelMode && - !["add", "overwrite"].includes(options.labelMode) - ) { - throw new Error("--label-mode must be either 'add' or 'overwrite'"); - } + const labelMode = parseLabelMode(options.labelMode); - const parsedPriority = - options.priority !== undefined - ? parsePriorityOption(options.priority) - : undefined; - const parsedEstimate = - options.estimate !== undefined - ? parseEstimateOption(options.estimate) - : undefined; + const parsedPriority = + options.priority !== undefined + ? parsePriorityOption(options.priority) + : undefined; + const parsedEstimate = + options.estimate !== undefined + ? parseEstimateOption(options.estimate) + : undefined; - const relationActions = parseRelationFlags(options); + const relationActions = parseRelationFlags(options); - const ctx = createContext(getRootOpts(command)); + const ctx = createContext(getRootOpts(command)); + + const issueEstimateContext = + parsedEstimate !== undefined + ? await resolveIssueEstimateContext(ctx.gql, issue) + : undefined; - const issueEstimateContext = - parsedEstimate !== undefined - ? await resolveIssueEstimateContext(ctx.sdk, issue) + const resolvedIssueId = issueEstimateContext + ? issueEstimateContext.issueId + : await resolveIssueId(ctx.gql, issue); + + if (parsedEstimate !== undefined && issueEstimateContext) { + validateEstimateAgainstTeamConfig(parsedEstimate, { + teamKey: issueEstimateContext.team.teamKey, + issueEstimationType: + issueEstimateContext.team.issueEstimationType, + issueEstimationExtended: + issueEstimateContext.team.issueEstimationExtended, + issueEstimationAllowZero: + issueEstimateContext.team.issueEstimationAllowZero, + }); + } + + const needsContext = + options.status || + options.projectMilestone || + options.cycle || + (options.labels && (labelMode === "add" || labelMode === "remove")); + const issueContext = needsContext + ? await getIssue(ctx.gql, resolvedIssueId) : undefined; - const resolvedIssueId = issueEstimateContext - ? issueEstimateContext.issueId - : await resolveIssueId(ctx.sdk, issue); - - if (parsedEstimate !== undefined && issueEstimateContext) { - validateEstimateAgainstTeamConfig(parsedEstimate, { - teamKey: issueEstimateContext.team.teamKey, - issueEstimationType: issueEstimateContext.team.issueEstimationType, - issueEstimationExtended: - issueEstimateContext.team.issueEstimationExtended, - issueEstimationAllowZero: - issueEstimateContext.team.issueEstimationAllowZero, - }); - } + const updContext: UpdateIssueContext = {}; + if (issueContext && "team" in issueContext && issueContext.team) { + updContext.teamId = asUuid(issueContext.team.id); + if (issueContext.team.key) { + updContext.teamKey = issueContext.team.key; + } + } + if ( + issueContext && + "project" in issueContext && + issueContext.project?.name + ) { + updContext.projectName = issueContext.project.name; + } - const needsContext = - options.status || - options.projectMilestone || - options.cycle || - (options.labels && options.labelMode === "add"); - const issueContext = needsContext - ? await getIssue(ctx.gql, resolvedIssueId) - : undefined; + const updIdsInput: ResolveUpdateIssueIdsInput = {}; + if (options.assignee) updIdsInput.assignee = options.assignee; + if (options.project) updIdsInput.project = options.project; + if (!options.clearLabels && options.labels) { + updIdsInput.labels = options.labels.split(",").map((l) => l.trim()); + } + if (!options.clearProjectMilestone && options.projectMilestone) { + updIdsInput.projectMilestone = options.projectMilestone; + } + if (!options.clearCycle && options.cycle) { + updIdsInput.cycle = options.cycle; + } + if (options.status) updIdsInput.status = options.status; + if (!options.clearParentTicket && options.parentTicket) { + updIdsInput.parentTicket = options.parentTicket; + } - const input: IssueUpdateInput = {}; + const needsResolution = + updIdsInput.assignee !== undefined || + updIdsInput.project !== undefined || + updIdsInput.labels !== undefined || + updIdsInput.projectMilestone !== undefined || + updIdsInput.cycle !== undefined || + updIdsInput.status !== undefined || + updIdsInput.parentTicket !== undefined; - if (options.title) { - input.title = options.title; - } + const ids: ResolvedUpdateIssueIds = needsResolution + ? await resolveUpdateIssueIds(ctx.gql, updIdsInput, updContext) + : {}; - if (options.description) { - input.description = options.description; - } + const input: UpdateIssueInput = {}; - if (options.status) { - const teamId = - issueContext && "team" in issueContext && issueContext.team - ? issueContext.team.id - : undefined; - input.stateId = await resolveStatusId( - ctx.sdk, - options.status, - teamId, - ); - } + if (options.title) { + input.title = options.title; + } - if (parsedPriority !== undefined) { - input.priority = parsedPriority; - } + if (options.description) { + input.description = options.description; + } - if (options.clearEstimate) { - input.estimate = null; - } else if (parsedEstimate !== undefined) { - input.estimate = parsedEstimate; - } + if (ids.stateId) { + input.stateId = ids.stateId; + } - if (options.assignee) { - input.assigneeId = await resolveUserId(ctx.sdk, options.assignee); - } + if (parsedPriority !== undefined) { + input.priority = parsedPriority; + } - if (options.project) { - input.projectId = await resolveProjectId(ctx.sdk, options.project); - } + if (options.clearEstimate) { + input.estimate = null; + } else if (parsedEstimate !== undefined) { + input.estimate = parsedEstimate; + } - if (options.clearLabels) { - input.labelIds = []; - } else if (options.labels) { - const labelNames = options.labels.split(",").map((l) => l.trim()); - const labelIds = await resolveLabelIds(ctx.sdk, labelNames); + if (ids.assigneeId) { + input.assigneeId = ids.assigneeId; + } + + if (ids.projectId) { + input.projectId = ids.projectId; + } - if (options.labelMode === "add") { + if (options.clearLabels) { + input.labelIds = []; + } else if (options.labels && ids.labelIds) { + const labelIds = ids.labelIds; const currentLabels = issueContext && "labels" in issueContext && issueContext.labels?.nodes - ? issueContext.labels.nodes.map((l) => l.id) + ? issueContext.labels.nodes.map((l) => asUuid(l.id)) : []; - input.labelIds = [...new Set([...currentLabels, ...labelIds])]; - } else { - input.labelIds = labelIds; + + if (labelMode === "add") { + input.labelIds = [...new Set([...currentLabels, ...labelIds])]; + } else if (labelMode === "remove") { + input.labelIds = currentLabels.filter( + (id) => !labelIds.includes(id), + ); + } else { + input.labelIds = labelIds; + } } - } - if (options.clearParentTicket) { - input.parentId = null; - } else if (options.parentTicket) { - input.parentId = await resolveIssueId(ctx.sdk, options.parentTicket); - } + if (options.clearParentTicket) { + input.parentId = null; + } else if (ids.parentId) { + input.parentId = ids.parentId; + } - if (options.clearProjectMilestone) { - input.projectMilestoneId = null; - } else if (options.projectMilestone) { - const projectName = - issueContext && - "project" in issueContext && - issueContext.project?.name - ? issueContext.project.name - : undefined; - input.projectMilestoneId = await resolveMilestoneId( - ctx.gql, - ctx.sdk, - options.projectMilestone, - projectName, - ); - } + if (options.clearProjectMilestone) { + input.projectMilestoneId = null; + } else if (ids.projectMilestoneId) { + input.projectMilestoneId = ids.projectMilestoneId; + } - if (options.clearCycle) { - input.cycleId = null; - } else if (options.cycle) { - const teamKey = - issueContext && "team" in issueContext && issueContext.team?.key - ? issueContext.team.key - : undefined; - input.cycleId = await resolveCycleId(ctx.sdk, options.cycle, teamKey); - } + if (options.clearCycle) { + input.cycleId = null; + } else if (ids.cycleId) { + input.cycleId = ids.cycleId; + } - if (options.clearDueDate) { - input.dueDate = null; - } else if (options.dueDate) { - input.dueDate = parseDueDate(options.dueDate); - } + if (options.clearDueDate) { + input.dueDate = null; + } else if (options.dueDate) { + input.dueDate = parseDueDate(options.dueDate); + } - const result = await updateIssue(ctx.gql, resolvedIssueId, input); + const result = await updateIssue(ctx.gql, resolvedIssueId, input); - if (relationActions.length > 0) { - await resolveAndApplyRelations(ctx, resolvedIssueId, relationActions); - } + if (relationActions.length > 0) { + await resolveAndApplyRelations( + ctx, + resolvedIssueId, + relationActions, + ); + } - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues .command("archive <issue>") .description("archive an issue") .action( - handleCommand(async (...args: unknown[]) => { - const [issue, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); - const result = await archiveIssue(ctx.gql, issueId); - outputSuccess(result); - }), + commandAction<[string, unknown, Command]>( + async (issue, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const issueId = await resolveIssueId(ctx.gql, issue); + const result = await archiveIssue(ctx.gql, issueId); + outputSuccess(result); + }, + ), ); issues .command("unarchive <issue>") .description("unarchive an issue") .action( - handleCommand(async (...args: unknown[]) => { - const [issue, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); - const result = await unarchiveIssue(ctx.gql, issueId); - outputSuccess(result); - }), + commandAction<[string, unknown, Command]>( + async (issue, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const issueId = await resolveIssueId(ctx.gql, issue); + const result = await unarchiveIssue(ctx.gql, issueId); + outputSuccess(result); + }, + ), ); issues .command("delete <issue>") .description("delete an issue") .action( - handleCommand(async (...args: unknown[]) => { - const [issue, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); - const result = await deleteIssue(ctx.gql, issueId); - outputSuccess(result); - }), + commandAction<[string, unknown, Command]>( + async (issue, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const issueId = await resolveIssueId(ctx.gql, issue); + const result = await deleteIssue(ctx.gql, issueId); + outputSuccess(result); + }, + ), ); issues diff --git a/src/commands/labels.ts b/src/commands/labels.ts index 330d0a09..62f8b24d 100644 --- a/src/commands/labels.ts +++ b/src/commands/labels.ts @@ -5,14 +5,26 @@ import { getRootOpts, } from "../common/context.js"; import { invalidParameterError } from "../common/errors.js"; +import type { UUID } from "../common/identifier.js"; +import { omitUndefined } from "../common/object.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; +import { + type LabelResolverScope, + resolveLabelId, +} from "../resolvers/label-resolver.js"; import { resolveTeamId } from "../resolvers/team-resolver.js"; import { + type CreateLabelInput, + createLabel, + deleteLabel, + getLabel, type LabelScope, type LabelType, listLabels, listProjectLabels, + type UpdateLabelInput, + updateLabel, } from "../services/label-service.js"; interface ListLabelsOptions extends CommandOptions { @@ -23,6 +35,23 @@ interface ListLabelsOptions extends CommandOptions { after?: string; } +interface LabelLookupOptions extends CommandOptions { + team?: string; + scope?: string; +} + +interface CreateLabelOptions extends CommandOptions { + team?: string; + color?: string; + description?: string; +} + +interface UpdateLabelOptions extends LabelLookupOptions { + name?: string; + color?: string; + description?: string; +} + function parseLabelType(value?: string): LabelType { if (value === undefined || value === "issue" || value === "project") { return value ?? "issue"; @@ -42,16 +71,94 @@ function parseLabelScope(value?: string): LabelScope | undefined { ); } +function parseLabelColor(value?: string): string | undefined { + if (value === undefined) { + return undefined; + } + + if (!/^#[0-9a-fA-F]{6}$/.test(value)) { + throw invalidParameterError("--color", "must be a hex color like #B45309"); + } + + return value; +} + +async function resolveIssueLabelLookup( + command: Command, + label: string, + options: LabelLookupOptions, +): Promise<{ ctx: ReturnType<typeof createContext>; labelId: UUID }> { + const ctx = createContext(getRootOpts(command)); + const scope = parseLabelScope(options.scope); + + if (scope === "team" && !options.team) { + throw invalidParameterError("--scope", "team scope requires --team"); + } + + if (scope === "workspace" && options.team) { + throw invalidParameterError( + "--team", + "cannot be used with --scope workspace", + ); + } + + const teamId = options.team + ? await resolveTeamId(ctx.gql, options.team) + : undefined; + const labelId = await resolveLabelId( + ctx.gql, + label, + omitUndefined({ + teamId, + scope: scope as LabelResolverScope | undefined, + }), + ); + + return { ctx, labelId }; +} + +function buildUpdateInput(options: UpdateLabelOptions): UpdateLabelInput { + const input: UpdateLabelInput = {}; + const color = parseLabelColor(options.color); + + if (options.name) { + input.name = options.name; + } + + if (color) { + input.color = color; + } + + if (options.description !== undefined) { + input.description = options.description; + } + + if (Object.keys(input).length === 0) { + throw invalidParameterError( + "label update", + "at least one option must be provided", + ); + } + + return input; +} + export const LABELS_META: DomainMeta = { name: "labels", summary: "categorization tags for issues and projects", context: [ "issue labels can exist at workspace level or be scoped to a specific", - "team. project labels are workspace-level only. use with issues", - "create/update --labels and projects create/update --labels.", + "team. project labels are workspace-level only. use labels list to", + "inspect existing labels, labels create/read/update/delete for issue", + "labels, and issues/projects create/update --labels plus update", + "--label-mode remove or --clear-labels to apply or remove them.", ].join("\n"), - arguments: {}, + arguments: { name: "label name or UUID" }, seeAlso: [ + "labels create <name>", + "labels read <label>", + "labels update <label>", + "labels delete <label>", "issues create --labels", "issues update --labels", "projects create --labels", @@ -78,11 +185,11 @@ export function setupLabelsCommands(program: Command): void { const ctx = createContext(getRootOpts(command)); const type = parseLabelType(options.type); const scope = parseLabelScope(options.scope); - const pagination = { + const pagination = omitUndefined({ limit: parseLimit(options.limit), after: options.after, scope, - }; + }); if (type === "project") { if (options.team) { @@ -115,13 +222,126 @@ export function setupLabelsCommands(program: Command): void { } const teamId = options.team - ? await resolveTeamId(ctx.sdk, options.team) + ? await resolveTeamId(ctx.gql, options.team) : undefined; outputSuccess(await listLabels(ctx.gql, teamId, pagination)); }), ); + labels + .command("create <name>") + .description("create an issue label") + .option("--team <team>", "create a team-scoped label (key, name, or UUID)") + .option("--color <hex>", "label color as a hex code (for example #B45309)") + .option("--description <text>", "label description") + .action( + handleCommand(async (...args: unknown[]) => { + const [name, options, command] = args as [ + string, + CreateLabelOptions, + Command, + ]; + const ctx = createContext(getRootOpts(command)); + + const input: CreateLabelInput = { name }; + const color = parseLabelColor(options.color); + + if (options.team) { + input.teamId = await resolveTeamId(ctx.gql, options.team); + } + + if (color) { + input.color = color; + } + + if (options.description) { + input.description = options.description; + } + + outputSuccess(await createLabel(ctx.gql, input)); + }), + ); + + labels + .command("read <label>") + .description("read an issue label") + .option( + "--team <team>", + "resolve a team-scoped label by team (key, name, or UUID)", + ) + .option("--scope <scope>", "resolve within workspace or team scope") + .action( + handleCommand(async (...args: unknown[]) => { + const [label, options, command] = args as [ + string, + LabelLookupOptions, + Command, + ]; + const { ctx, labelId } = await resolveIssueLabelLookup( + command, + label, + options, + ); + + outputSuccess(await getLabel(ctx.gql, labelId)); + }), + ); + + labels + .command("update <label>") + .description("update an issue label") + .option( + "--team <team>", + "resolve a team-scoped label by team (key, name, or UUID)", + ) + .option("--scope <scope>", "resolve within workspace or team scope") + .option("--name <name>", "new label name") + .option("--color <hex>", "new label color as a hex code") + .option("--description <text>", "new label description") + .action( + handleCommand(async (...args: unknown[]) => { + const [label, options, command] = args as [ + string, + UpdateLabelOptions, + Command, + ]; + const input = buildUpdateInput(options); + const { ctx, labelId } = await resolveIssueLabelLookup( + command, + label, + options, + ); + + outputSuccess(await updateLabel(ctx.gql, labelId, input)); + }), + ); + + labels + .command("delete <label>") + .description("delete an issue label") + .option( + "--team <team>", + "resolve a team-scoped label by team (key, name, or UUID)", + ) + .option("--scope <scope>", "resolve within workspace or team scope") + .action( + handleCommand(async (...args: unknown[]) => { + const [label, options, command] = args as [ + string, + LabelLookupOptions, + Command, + ]; + const { ctx, labelId } = await resolveIssueLabelLookup( + command, + label, + options, + ); + + outputSuccess(await deleteLabel(ctx.gql, labelId)); + }), + ); + labels .command("usage") .description("show detailed usage for labels") diff --git a/src/commands/milestones.ts b/src/commands/milestones.ts index 03715188..029b1f61 100644 --- a/src/commands/milestones.ts +++ b/src/commands/milestones.ts @@ -1,14 +1,15 @@ import type { Command } from "commander"; import { createContext, getRootOpts } from "../common/context.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; +import { buildPaginationOptions } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; -import type { ProjectMilestoneUpdateInput } from "../gql/graphql.js"; import { resolveMilestoneId } from "../resolvers/milestone-resolver.js"; import { resolveProjectId } from "../resolvers/project-resolver.js"; import { createMilestone, getMilestone, listMilestones, + type UpdateMilestoneInput, updateMilestone, } from "../services/milestone-service.js"; @@ -75,12 +76,16 @@ export function setupMilestonesCommands(program: Command): void { const ctx = createContext(getRootOpts(command)); // Resolve project ID - const projectId = await resolveProjectId(ctx.sdk, options.project); + const projectId = await resolveProjectId(ctx.gql, options.project); - const milestones = await listMilestones(ctx.gql, projectId, { - limit: parseLimit(options.limit || "50"), - after: options.after, - }); + const milestones = await listMilestones( + ctx.gql, + projectId, + buildPaginationOptions( + parseLimit(options.limit || "50"), + options.after, + ), + ); outputSuccess(milestones); }), @@ -103,7 +108,6 @@ export function setupMilestonesCommands(program: Command): void { const milestoneId = await resolveMilestoneId( ctx.gql, - ctx.sdk, milestone, options.project, ); @@ -135,7 +139,7 @@ export function setupMilestonesCommands(program: Command): void { const ctx = createContext(getRootOpts(command)); // Resolve project ID - const projectId = await resolveProjectId(ctx.sdk, options.project); + const projectId = await resolveProjectId(ctx.gql, options.project); const milestone = await createMilestone(ctx.gql, { projectId, @@ -171,13 +175,12 @@ export function setupMilestonesCommands(program: Command): void { const milestoneId = await resolveMilestoneId( ctx.gql, - ctx.sdk, milestone, options.project, ); // Build update input (only include provided fields) - const updateInput: ProjectMilestoneUpdateInput = {}; + const updateInput: UpdateMilestoneInput = {}; if (options.name !== undefined) updateInput.name = options.name; if (options.description !== undefined) { updateInput.description = options.description; diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 3bcddcc8..e9e9fc20 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -1,10 +1,12 @@ import type { Command } from "commander"; import { createContext, getRootOpts } from "../common/context.js"; +import { type Priority, parseLabelMode } from "../common/domain-values.js"; import { resolveReactionEmojiInput } from "../common/emoji.js"; import { invalidParameterError } from "../common/errors.js"; -import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; +import { asUuid } from "../common/identifier.js"; +import { commandAction, outputSuccess, parseLimit } from "../common/output.js"; +import { buildPaginationOptions } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; -import type { ProjectCreateInput, ProjectUpdateInput } from "../gql/graphql.js"; import { resolveProjectId, resolveProjectLabelIds, @@ -31,10 +33,12 @@ import { } from "../services/discussion-service.js"; import { archiveProject, + type CreateProjectInput, createProject, deleteProject, getProject, listProjects, + type UpdateProjectInput, unarchiveProject, updateProject, } from "../services/project-service.js"; @@ -42,6 +46,12 @@ import { interface ListOptions { limit: string; after?: string; + includeArchived?: boolean; +} + +interface ReadOptions { + milestonesFirst: string; + issuesFirst: string; } interface DiscussionsOptions { @@ -71,22 +81,18 @@ function addCommentReactionCommands( .description(`add a reaction to a discussion ${noun}`) .option("--shortcode <name>", "emoji shortcode (e.g. thumbs_up)") .action( - handleCommand(async (...args: unknown[]) => { - const [commentId, emoji, options, command] = args as [ - string, - string | undefined, - ReactionOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const result = await createDiscussionCommentReaction(ctx.gql, { - commentId, - target: noun, - expectedEntityKind: "project", - emoji: resolveReactionEmojiInput(emoji, options.shortcode), - }); - outputSuccess(result); - }), + commandAction<[string, string | undefined, ReactionOptions, Command]>( + async (commentId, emoji, options, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await createDiscussionCommentReaction(ctx.gql, { + commentId: asUuid(commentId), + target: noun, + expectedEntityKind: "project", + emoji: resolveReactionEmojiInput(emoji, options.shortcode), + }); + outputSuccess(result); + }, + ), ); parent @@ -94,22 +100,18 @@ function addCommentReactionCommands( .description(`remove your reaction from a discussion ${noun} by emoji`) .option("--shortcode <name>", "emoji shortcode (e.g. thumbs_up)") .action( - handleCommand(async (...args: unknown[]) => { - const [commentId, emoji, options, command] = args as [ - string, - string | undefined, - ReactionOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { - commentId, - target: noun, - expectedEntityKind: "project", - emoji: resolveReactionEmojiInput(emoji, options.shortcode), - }); - outputSuccess(result); - }), + commandAction<[string, string | undefined, ReactionOptions, Command]>( + async (commentId, emoji, options, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { + commentId: asUuid(commentId), + target: noun, + expectedEntityKind: "project", + emoji: resolveReactionEmojiInput(emoji, options.shortcode), + }); + outputSuccess(result); + }, + ), ); parent @@ -118,29 +120,28 @@ function addCommentReactionCommands( `remove your reaction from a discussion ${noun} by reaction ID`, ) .action( - handleCommand(async (...args: unknown[]) => { - const [commentId, reactionId, , command] = args as [ - string, - string, - unknown, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionCommentReactionById(ctx.gql, { - commentId, - target: noun, - expectedEntityKind: "project", - reactionId, - }); - outputSuccess(result); - }), + commandAction<[string, string, unknown, Command]>( + async (commentId, reactionId, _unused2, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await deleteDiscussionCommentReactionById(ctx.gql, { + commentId: asUuid(commentId), + target: noun, + expectedEntityKind: "project", + reactionId: asUuid(reactionId), + }); + outputSuccess(result); + }, + ), ); } interface CreateOptions { - teams: string; + teams?: string; + team?: string; description?: string; content?: string; + icon?: string; + color?: string; lead?: string; members?: string; priority?: string; @@ -154,14 +155,22 @@ interface UpdateOptions { name?: string; description?: string; content?: string; + icon?: string; + color?: string; lead?: string; + clearLead?: boolean; members?: string; priority?: string; status?: string; startDate?: string; + clearStartDate?: boolean; targetDate?: string; + clearTargetDate?: boolean; teams?: string; + team?: string; labels?: string; + labelMode?: string; + clearLabels?: boolean; } export const PROJECTS_META: DomainMeta = { @@ -185,12 +194,58 @@ export const PROJECTS_META: DomainMeta = { ], }; -function parsePriority(value: string): number { +function parsePriority(value: string): Priority { const priority = Number.parseInt(value, 10); if (Number.isNaN(priority) || priority < 0 || priority > 4) { throw invalidParameterError("priority", `must be 0-4, got "${value}"`); } - return priority; + return priority as Priority; +} + +function parseNonNegativeIntegerOption(name: string, value: string): number { + if (!/^\d+$/.test(value)) { + throw invalidParameterError(name, `must be a non-negative integer`); + } + return Number.parseInt(value, 10); +} + +function parseCommaSeparatedOption(name: string, value: string): string[] { + const values = value + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + + if (values.length === 0) { + throw invalidParameterError(name, "must include at least one value"); + } + + return values; +} + +function getCreateTeamNames(options: CreateOptions): string[] { + if (options.team && options.teams) { + throw invalidParameterError("--team", "cannot be combined with --teams"); + } + + const teams = options.teams ?? options.team; + if (!teams) { + throw invalidParameterError("--teams", "is required"); + } + + return parseCommaSeparatedOption(options.teams ? "--teams" : "--team", teams); +} + +function getUpdateTeamNames(options: UpdateOptions): string[] | undefined { + if (options.team && options.teams) { + throw invalidParameterError("--team", "cannot be combined with --teams"); + } + + const teams = options.teams ?? options.team; + if (!teams) { + return undefined; + } + + return parseCommaSeparatedOption(options.teams ? "--teams" : "--team", teams); } export function setupProjectsCommands(program: Command): void { @@ -205,13 +260,15 @@ export function setupProjectsCommands(program: Command): void { .description("list projects") .option("-l, --limit <n>", "max results", "100") .option("--after <cursor>", "cursor for next page") + .option("--include-archived", "include archived projects") .action( - handleCommand(async (...args: unknown[]) => { - const [options, command] = args as [ListOptions, Command]; + commandAction<[ListOptions, Command]>(async (options, command) => { const ctx = createContext(getRootOpts(command)); const result = await listProjects(ctx.gql, { - limit: parseLimit(options.limit), - after: options.after, + ...buildPaginationOptions(parseLimit(options.limit), options.after), + ...(options.includeArchived !== undefined + ? { includeArchived: options.includeArchived } + : {}), }); outputSuccess(result); }), @@ -220,14 +277,34 @@ export function setupProjectsCommands(program: Command): void { projects .command("read <project>") .description("get full project details") + .option( + "--milestones-first <n>", + "how many milestones to fetch; 0 omits milestones", + "25", + ) + .option( + "--issues-first <n>", + "how many issues to fetch; 0 omits issues", + "50", + ) .action( - handleCommand(async (...args: unknown[]) => { - const [project, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - const projectId = await resolveProjectId(ctx.sdk, project); - const result = await getProject(ctx.gql, projectId); - outputSuccess(result); - }), + commandAction<[string, ReadOptions, Command]>( + async (project, options, command) => { + const ctx = createContext(getRootOpts(command)); + const projectId = await resolveProjectId(ctx.gql, project); + const result = await getProject(ctx.gql, projectId, { + milestonesFirst: parseNonNegativeIntegerOption( + "--milestones-first", + options.milestonesFirst, + ), + issuesFirst: parseNonNegativeIntegerOption( + "--issues-first", + options.issuesFirst, + ), + }); + outputSuccess(result); + }, + ), ); projects @@ -235,26 +312,23 @@ export function setupProjectsCommands(program: Command): void { .description("start a discussion thread on a project") .option("--body <text>", "discussion body (required, markdown supported)") .action( - handleCommand(async (...args: unknown[]) => { - const [project, options, command] = args as [ - string, - DiscussionBodyOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, DiscussionBodyOptions, Command]>( + async (project, options, command) => { + const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + if (!options.body) { + throw invalidParameterError("--body", "is required"); + } - const projectId = await resolveProjectId(ctx.sdk, project); - const result = await startProjectDiscussion(ctx.gql, { - projectId, - body: options.body, - }); + const projectId = await resolveProjectId(ctx.gql, project); + const result = await startProjectDiscussion(ctx.gql, { + projectId, + body: options.body, + }); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); projects @@ -264,33 +338,30 @@ export function setupProjectsCommands(program: Command): void { .option("--after <cursor>", "cursor for next page") .option("--with-reactions", "include normalized discussion reactions") .action( - handleCommand(async (...args: unknown[]) => { - const [project, options, command] = args as [ - string, - DiscussionsOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - const projectId = await resolveProjectId(ctx.sdk, project); - const paginationOptions = { - limit: parseLimit(options.limit || "25"), - after: options.after, - }; - const result = options.withReactions - ? await listDiscussionsForProjectWithReactions( - ctx.gql, - projectId, - paginationOptions, - ) - : await listDiscussionsForProject( - ctx.gql, - projectId, - paginationOptions, - ); - - outputSuccess(result); - }), + commandAction<[string, DiscussionsOptions, Command]>( + async (project, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const projectId = await resolveProjectId(ctx.gql, project); + const paginationOptions = buildPaginationOptions( + parseLimit(options.limit || "25"), + options.after, + ); + const result = options.withReactions + ? await listDiscussionsForProjectWithReactions( + ctx.gql, + projectId, + paginationOptions, + ) + : await listDiscussionsForProject( + ctx.gql, + projectId, + paginationOptions, + ); + + outputSuccess(result); + }, + ), ); const projectThreads = projects @@ -305,34 +376,31 @@ export function setupProjectsCommands(program: Command): void { .option("--after <cursor>", "cursor for next page") .option("--with-reactions", "include normalized discussion reactions") .action( - handleCommand(async (...args: unknown[]) => { - const [thread, options, command] = args as [ - string, - DiscussionsOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - const paginationOptions = { - limit: parseLimit(options.limit || "50"), - after: options.after, - }; - const result = options.withReactions - ? await listDiscussionRepliesWithReactions( - ctx.gql, - thread, - paginationOptions, - "project", - ) - : await listDiscussionReplies( - ctx.gql, - thread, - paginationOptions, - "project", - ); + commandAction<[string, DiscussionsOptions, Command]>( + async (thread, options, command) => { + const ctx = createContext(getRootOpts(command)); - outputSuccess(result); - }), + const paginationOptions = buildPaginationOptions( + parseLimit(options.limit || "50"), + options.after, + ); + const result = options.withReactions + ? await listDiscussionRepliesWithReactions( + ctx.gql, + asUuid(thread), + paginationOptions, + "project", + ) + : await listDiscussionReplies( + ctx.gql, + asUuid(thread), + paginationOptions, + "project", + ); + + outputSuccess(result); + }, + ), ); addCommentReactionCommands(projectReplies, "reply"); @@ -345,26 +413,23 @@ export function setupProjectsCommands(program: Command): void { ) .option("--body <text>", "reply body (required, markdown supported)") .action( - handleCommand(async (...args: unknown[]) => { - const [thread, options, command] = args as [ - string, - DiscussionBodyOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, DiscussionBodyOptions, Command]>( + async (thread, options, command) => { + const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + if (!options.body) { + throw invalidParameterError("--body", "is required"); + } - const result = await replyToDiscussion(ctx.gql, { - threadId: thread, - body: options.body, - entityKind: "project", - }); + const result = await replyToDiscussion(ctx.gql, { + threadId: asUuid(thread), + body: options.body, + entityKind: "project", + }); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); projects @@ -372,29 +437,26 @@ export function setupProjectsCommands(program: Command): void { .description("edit a root discussion or reply comment") .option("--body <text>", "new comment body (required, markdown supported)") .action( - handleCommand(async (...args: unknown[]) => { - const [comment, options, command] = args as [ - string, - DiscussionBodyOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, DiscussionBodyOptions, Command]>( + async (comment, options, command) => { + const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + if (!options.body) { + throw invalidParameterError("--body", "is required"); + } - const result = await editDiscussionComment( - ctx.gql, - comment, - { - body: options.body, - }, - "project", - ); + const result = await editDiscussionComment( + ctx.gql, + asUuid(comment), + { + body: options.body, + }, + "project", + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); projects @@ -402,61 +464,64 @@ export function setupProjectsCommands(program: Command): void { .description("edit a discussion reply") .option("--body <text>", "new reply body (required, markdown supported)") .action( - handleCommand(async (...args: unknown[]) => { - const [reply, options, command] = args as [ - string, - DiscussionBodyOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, DiscussionBodyOptions, Command]>( + async (reply, options, command) => { + const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + if (!options.body) { + throw invalidParameterError("--body", "is required"); + } - const result = await editDiscussionReply( - ctx.gql, - reply, - { - body: options.body, - }, - "project", - ); + const result = await editDiscussionReply( + ctx.gql, + asUuid(reply), + { + body: options.body, + }, + "project", + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); projects .command("delete-comment <comment>") .description("delete a root discussion or reply comment") .action( - handleCommand(async (...args: unknown[]) => { - const [comment, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, unknown, Command]>( + async (comment, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionComment( - ctx.gql, - comment, - "project", - ); + const result = await deleteDiscussionComment( + ctx.gql, + asUuid(comment), + "project", + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); projects .command("delete-reply <reply>") .description("delete a discussion reply") .action( - handleCommand(async (...args: unknown[]) => { - const [reply, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, unknown, Command]>( + async (reply, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionReply(ctx.gql, reply, "project"); + const result = await deleteDiscussionReply( + ctx.gql, + asUuid(reply), + "project", + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); projects @@ -464,44 +529,51 @@ export function setupProjectsCommands(program: Command): void { .description("resolve a discussion thread") .option("--with-comment <comment>", "comment to mark as resolving comment") .action( - handleCommand(async (...args: unknown[]) => { - const [thread, options, command] = args as [ - string, - ResolveDiscussionOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - const result = await resolveDiscussion(ctx.gql, { - threadId: thread, - resolvingCommentId: options.withComment, - entityKind: "project", - }); - - outputSuccess(result); - }), + commandAction<[string, ResolveDiscussionOptions, Command]>( + async (thread, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const result = await resolveDiscussion(ctx.gql, { + threadId: asUuid(thread), + ...(options.withComment !== undefined + ? { resolvingCommentId: asUuid(options.withComment) } + : {}), + entityKind: "project", + }); + + outputSuccess(result); + }, + ), ); projects .command("unresolve <thread>") .description("unresolve a discussion thread") .action( - handleCommand(async (...args: unknown[]) => { - const [thread, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, unknown, Command]>( + async (thread, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); - const result = await unresolveDiscussion(ctx.gql, thread, "project"); + const result = await unresolveDiscussion( + ctx.gql, + asUuid(thread), + "project", + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); projects .command("create <name>") .description("create a new project") - .requiredOption("--teams <teams>", "comma-separated team names or UUIDs") + .option("--teams <teams>", "comma-separated team names or UUIDs") + .option("--team <team>", "team name or UUID (alias for --teams)") .option("--description <text>", "project description") .option("--content <text>", "project content (markdown)") + .option("--icon <icon>", "project icon") + .option("--color <color>", "project color") .option("--lead <user>", "project lead (name, email, or UUID)") .option("--members <users>", "comma-separated member names or UUIDs") .option("--priority <0-4>", "0=none 1=urgent 2=high 3=medium 4=low") @@ -510,79 +582,81 @@ export function setupProjectsCommands(program: Command): void { .option("--target-date <date>", "target date (YYYY-MM-DD)") .option("--labels <labels>", "comma-separated label names or UUIDs") .action( - handleCommand(async (...args: unknown[]) => { - const [name, options, command] = args as [ - string, - CreateOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, CreateOptions, Command]>( + async (name, options, command) => { + const ctx = createContext(getRootOpts(command)); - const teamNames = options.teams - .split(",") - .map((t) => t.trim()) - .filter(Boolean); - const teamIds = await Promise.all( - teamNames.map((t) => resolveTeamId(ctx.sdk, t)), - ); - - const input: ProjectCreateInput = { - name, - teamIds, - }; - - if (options.description) { - input.description = options.description; - } - - if (options.content) { - input.content = options.content; - } - - if (options.lead) { - input.leadId = await resolveUserId(ctx.sdk, options.lead); - } - - if (options.members) { - const memberNames = options.members - .split(",") - .map((m) => m.trim()) - .filter(Boolean); - input.memberIds = await Promise.all( - memberNames.map((m) => resolveUserId(ctx.sdk, m)), + const teamNames = getCreateTeamNames(options); + const teamIds = await Promise.all( + teamNames.map((t) => resolveTeamId(ctx.gql, t)), ); - } - if (options.priority) { - input.priority = parsePriority(options.priority); - } - - if (options.status) { - input.statusId = await resolveProjectStatusId( - ctx.gql, - options.status, - ); - } - - if (options.startDate) { - input.startDate = options.startDate; - } - - if (options.targetDate) { - input.targetDate = options.targetDate; - } + const input: CreateProjectInput = { + name, + teamIds, + }; + + if (options.description) { + input.description = options.description; + } + + if (options.content) { + input.content = options.content; + } + + if (options.icon !== undefined) { + input.icon = options.icon; + } + + if (options.color !== undefined) { + input.color = options.color; + } + + if (options.lead) { + input.leadId = await resolveUserId(ctx.gql, options.lead); + } + + if (options.members) { + const memberNames = options.members + .split(",") + .map((m) => m.trim()) + .filter(Boolean); + input.memberIds = await Promise.all( + memberNames.map((m) => resolveUserId(ctx.gql, m)), + ); + } - if (options.labels) { - const labelNames = options.labels - .split(",") - .map((l) => l.trim()) - .filter(Boolean); - input.labelIds = await resolveProjectLabelIds(ctx.sdk, labelNames); - } + if (options.priority) { + input.priority = parsePriority(options.priority); + } - const result = await createProject(ctx.gql, input); - outputSuccess(result); - }), + if (options.status) { + input.statusId = await resolveProjectStatusId( + ctx.gql, + options.status, + ); + } + + if (options.startDate) { + input.startDate = options.startDate; + } + + if (options.targetDate) { + input.targetDate = options.targetDate; + } + + if (options.labels) { + const labelNames = options.labels + .split(",") + .map((l) => l.trim()) + .filter(Boolean); + input.labelIds = await resolveProjectLabelIds(ctx.gql, labelNames); + } + + const result = await createProject(ctx.gql, input); + outputSuccess(result); + }, + ), ); projects @@ -591,143 +665,229 @@ export function setupProjectsCommands(program: Command): void { .option("--name <name>", "new name") .option("--description <text>", "new description") .option("--content <text>", "new content (markdown)") + .option("--icon <icon>", "new icon") + .option("--color <color>", "new color") .option("--lead <user>", "new lead (name, email, or UUID)") + .option("--clear-lead", "remove project lead") .option("--members <users>", "comma-separated member names or UUIDs") .option("--priority <0-4>", "new priority") .option("--status <status>", "new status name or UUID") .option("--start-date <date>", "new start date (YYYY-MM-DD)") + .option("--clear-start-date", "remove start date") .option("--target-date <date>", "new target date (YYYY-MM-DD)") + .option("--clear-target-date", "remove target date") .option("--teams <teams>", "comma-separated team names or UUIDs") + .option("--team <team>", "team name or UUID (alias for --teams)") .option("--labels <labels>", "comma-separated label names or UUIDs") + .option("--label-mode <mode>", "add | remove | overwrite") + .option("--clear-labels", "remove all labels") .action( - handleCommand(async (...args: unknown[]) => { - const [project, options, command] = args as [ - string, - UpdateOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - const projectId = await resolveProjectId(ctx.sdk, project); - - const input: ProjectUpdateInput = {}; + commandAction<[string, UpdateOptions, Command]>( + async (project, options, command) => { + const ctx = createContext(getRootOpts(command)); + + if (options.lead && options.clearLead) { + throw invalidParameterError( + "--lead", + "cannot be combined with --clear-lead", + ); + } - if (options.name) { - input.name = options.name; - } + if (options.startDate && options.clearStartDate) { + throw invalidParameterError( + "--start-date", + "cannot be combined with --clear-start-date", + ); + } - if (options.description) { - input.description = options.description; - } + if (options.targetDate && options.clearTargetDate) { + throw invalidParameterError( + "--target-date", + "cannot be combined with --clear-target-date", + ); + } - if (options.content) { - input.content = options.content; - } + if (options.labelMode && !options.labels) { + throw invalidParameterError( + "--label-mode", + "requires --labels to be specified", + ); + } - if (options.lead) { - input.leadId = await resolveUserId(ctx.sdk, options.lead); - } + if (options.clearLabels && options.labels) { + throw invalidParameterError( + "--clear-labels", + "cannot be used with --labels", + ); + } - if (options.members) { - const memberNames = options.members - .split(",") - .map((m) => m.trim()) - .filter(Boolean); - input.memberIds = await Promise.all( - memberNames.map((m) => resolveUserId(ctx.sdk, m)), - ); - } + if (options.clearLabels && options.labelMode) { + throw invalidParameterError( + "--clear-labels", + "cannot be used with --label-mode", + ); + } + + const labelMode = parseLabelMode(options.labelMode); + + const projectId = await resolveProjectId(ctx.gql, project); + const needsLabelContext = + options.labels && (labelMode === "add" || labelMode === "remove"); + const projectContext = needsLabelContext + ? await getProject(ctx.gql, projectId) + : undefined; + + const input: UpdateProjectInput = {}; + + if (options.name) { + input.name = options.name; + } + + if (options.description) { + input.description = options.description; + } + + if (options.content) { + input.content = options.content; + } + + if (options.icon !== undefined) { + input.icon = options.icon; + } + + if (options.color !== undefined) { + input.color = options.color; + } + + if (options.clearLead) { + input.leadId = null; + } else if (options.lead) { + input.leadId = await resolveUserId(ctx.gql, options.lead); + } + + if (options.members) { + const memberNames = options.members + .split(",") + .map((m) => m.trim()) + .filter(Boolean); + input.memberIds = await Promise.all( + memberNames.map((m) => resolveUserId(ctx.gql, m)), + ); + } - if (options.priority) { - input.priority = parsePriority(options.priority); - } + if (options.priority) { + input.priority = parsePriority(options.priority); + } - if (options.status) { - input.statusId = await resolveProjectStatusId( - ctx.gql, - options.status, - ); - } - - if (options.startDate) { - input.startDate = options.startDate; - } - - if (options.targetDate) { - input.targetDate = options.targetDate; - } - - if (options.teams) { - const teamNames = options.teams - .split(",") - .map((t) => t.trim()) - .filter(Boolean); - input.teamIds = await Promise.all( - teamNames.map((t) => resolveTeamId(ctx.sdk, t)), - ); - } - - if (options.labels) { - const labelNames = options.labels - .split(",") - .map((l) => l.trim()) - .filter(Boolean); - input.labelIds = await resolveProjectLabelIds(ctx.sdk, labelNames); - } - - if (Object.keys(input).length === 0) { - throw invalidParameterError( - "update options", - "at least one option must be provided", - ); - } + if (options.status) { + input.statusId = await resolveProjectStatusId( + ctx.gql, + options.status, + ); + } + + if (options.clearStartDate) { + input.startDate = null; + } else if (options.startDate) { + input.startDate = options.startDate; + } + + if (options.clearTargetDate) { + input.targetDate = null; + } else if (options.targetDate) { + input.targetDate = options.targetDate; + } + + const teamNames = getUpdateTeamNames(options); + if (teamNames) { + input.teamIds = await Promise.all( + teamNames.map((t) => resolveTeamId(ctx.gql, t)), + ); + } + + if (options.clearLabels) { + input.labelIds = []; + } else if (options.labels) { + const labelNames = options.labels + .split(",") + .map((l) => l.trim()) + .filter(Boolean); + const labelIds = await resolveProjectLabelIds(ctx.gql, labelNames); + + if (labelMode === "add") { + const currentLabels = projectContext?.labels?.nodes + ? projectContext.labels.nodes.map((l) => asUuid(l.id)) + : []; + input.labelIds = [...new Set([...currentLabels, ...labelIds])]; + } else if (labelMode === "remove") { + const currentLabels = projectContext?.labels?.nodes + ? projectContext.labels.nodes.map((l) => asUuid(l.id)) + : []; + input.labelIds = currentLabels.filter( + (id) => !labelIds.includes(id), + ); + } else { + input.labelIds = labelIds; + } + } + + if (Object.keys(input).length === 0) { + throw invalidParameterError( + "update options", + "at least one option must be provided", + ); + } - const result = await updateProject(ctx.gql, projectId, input); - outputSuccess(result); - }), + const result = await updateProject(ctx.gql, projectId, input); + outputSuccess(result); + }, + ), ); projects .command("archive <project>") .description("archive a project") .action( - handleCommand(async (...args: unknown[]) => { - const [project, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - const projectId = await resolveProjectId(ctx.sdk, project); - const result = await archiveProject(ctx.gql, projectId); - outputSuccess(result); - }), + commandAction<[string, unknown, Command]>( + async (project, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const projectId = await resolveProjectId(ctx.gql, project); + const result = await archiveProject(ctx.gql, projectId); + outputSuccess(result); + }, + ), ); projects .command("unarchive <project>") .description("unarchive a project") .action( - handleCommand(async (...args: unknown[]) => { - const [project, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - const projectId = await resolveProjectId(ctx.sdk, project, { - includeArchived: true, - }); - const result = await unarchiveProject(ctx.gql, projectId); - outputSuccess(result); - }), + commandAction<[string, unknown, Command]>( + async (project, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const projectId = await resolveProjectId(ctx.gql, project, { + includeArchived: true, + }); + const result = await unarchiveProject(ctx.gql, projectId); + outputSuccess(result); + }, + ), ); projects .command("delete <project>") .description("delete a project") .action( - handleCommand(async (...args: unknown[]) => { - const [project, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - const projectId = await resolveProjectId(ctx.sdk, project, { - includeArchived: true, - }); - const result = await deleteProject(ctx.gql, projectId); - outputSuccess(result); - }), + commandAction<[string, unknown, Command]>( + async (project, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const projectId = await resolveProjectId(ctx.gql, project, { + includeArchived: true, + }); + const result = await deleteProject(ctx.gql, projectId); + outputSuccess(result); + }, + ), ); projects diff --git a/src/commands/teams.ts b/src/commands/teams.ts index 09b471ef..f88fdaec 100644 --- a/src/commands/teams.ts +++ b/src/commands/teams.ts @@ -1,9 +1,26 @@ import type { Command } from "commander"; -import { createContext, getRootOpts } from "../common/context.js"; +import { + type CommandContext, + createContext, + getRootOpts, +} from "../common/context.js"; +import { invalidParameterError } from "../common/errors.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; +import { buildPaginationOptions } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { resolveTeamId } from "../resolvers/team-resolver.js"; -import { getTeam, listTeams } from "../services/team-service.js"; +import { resolveUserId } from "../resolvers/user-resolver.js"; +import { + addTeamMember, + type CreateTeamInput, + createTeam, + getTeam, + listTeamMembers, + listTeams, + removeTeamMember, + type UpdateTeamInput, + updateTeam, +} from "../services/team-service.js"; export const TEAMS_META: DomainMeta = { name: "teams", @@ -11,11 +28,233 @@ export const TEAMS_META: DomainMeta = { context: [ "a team is a group of users that owns issues, cycles, statuses, and", "labels. teams are identified by a short key (e.g. ENG), name, or UUID.", + "teams can be created and updated, and their membership managed with", + "add-member/remove-member. boolean settings take an explicit true|false", + "value so scripts can set or unset them unambiguously.", ].join("\n"), - arguments: {}, - seeAlso: [], + arguments: { + team: "team identifier (key, name, or UUID)", + name: "team display name", + user: "user identifier (display name, email, or UUID)", + }, + seeAlso: ["users list", "issues create --team", "cycles list --team"], }; +const ESTIMATION_TYPES = [ + "notUsed", + "exponential", + "fibonacci", + "linear", + "tShirt", +] as const; + +function parseBooleanOption(flag: string, value: string): boolean { + const normalized = value.trim().toLowerCase(); + if (normalized === "true") return true; + if (normalized === "false") return false; + throw invalidParameterError(flag, `expected true or false, got "${value}"`); +} + +function parseIntegerOption(flag: string, value: string): number { + // Number("") and Number(" ") coerce to 0, so reject blank input first. + const parsed = value.trim() === "" ? Number.NaN : Number(value); + if (!Number.isInteger(parsed)) { + throw invalidParameterError(flag, `expected an integer, got "${value}"`); + } + return parsed; +} + +// Linear types cycle/auto-close durations and cycleStartDay as Float, so +// fractional values are valid (e.g. a cycleStartDay with a time-of-day +// component). Only finite numbers are accepted. +function parseNumberOption(flag: string, value: string): number { + // Number("") and Number(" ") coerce to 0, so reject blank input first. + const parsed = value.trim() === "" ? Number.NaN : Number(value); + if (!Number.isFinite(parsed)) { + throw invalidParameterError(flag, `expected a number, got "${value}"`); + } + return parsed; +} + +function parseEstimationType(value: string): string { + if (!(ESTIMATION_TYPES as readonly string[]).includes(value)) { + throw invalidParameterError( + "--estimation-type", + `expected one of ${ESTIMATION_TYPES.join(", ")}, got "${value}"`, + ); + } + return value; +} + +// Shared mutable fields accepted by both `create` and `update`. `name` is +// handled by the caller (positional for create, --name for update). +interface TeamFieldOptions { + key?: string; + description?: string; + private?: string; + icon?: string; + color?: string; + timezone?: string; + parent?: string; + estimationType?: string; + estimationExtended?: string; + estimationAllowZero?: string; + defaultEstimate?: string; + inheritEstimation?: string; + cyclesEnabled?: string; + cycleDuration?: string; + cycleCooldown?: string; + cycleStartDay?: string; + triageEnabled?: string; + requirePriorityToLeaveTriage?: string; + autoClosePeriod?: string; + autoArchivePeriod?: string; +} + +// Build the shared mutable field set once, resolving the parent team to a +// UUID. Only fields the user provided are included, so `update` never +// overwrites untouched settings. +async function buildTeamFields( + ctx: CommandContext, + options: TeamFieldOptions, +): Promise<UpdateTeamInput> { + const input: UpdateTeamInput = {}; + + if (options.key !== undefined) input.key = options.key; + if (options.description !== undefined) + input.description = options.description; + if (options.icon !== undefined) input.icon = options.icon; + if (options.color !== undefined) input.color = options.color; + if (options.timezone !== undefined) input.timezone = options.timezone; + + if (options.private !== undefined) { + input.private = parseBooleanOption("--private", options.private); + } + + if (options.parent !== undefined) { + input.parentId = await resolveTeamId(ctx.gql, options.parent); + } + + if (options.estimationType !== undefined) { + input.issueEstimationType = parseEstimationType(options.estimationType); + } + if (options.estimationExtended !== undefined) { + input.issueEstimationExtended = parseBooleanOption( + "--estimation-extended", + options.estimationExtended, + ); + } + if (options.estimationAllowZero !== undefined) { + input.issueEstimationAllowZero = parseBooleanOption( + "--estimation-allow-zero", + options.estimationAllowZero, + ); + } + if (options.defaultEstimate !== undefined) { + input.defaultIssueEstimate = parseIntegerOption( + "--default-estimate", + options.defaultEstimate, + ); + } + if (options.inheritEstimation !== undefined) { + input.inheritIssueEstimation = parseBooleanOption( + "--inherit-estimation", + options.inheritEstimation, + ); + } + + if (options.cyclesEnabled !== undefined) { + input.cyclesEnabled = parseBooleanOption( + "--cycles-enabled", + options.cyclesEnabled, + ); + } + if (options.cycleDuration !== undefined) { + input.cycleDuration = parseNumberOption( + "--cycle-duration", + options.cycleDuration, + ); + } + if (options.cycleCooldown !== undefined) { + input.cycleCooldownTime = parseNumberOption( + "--cycle-cooldown", + options.cycleCooldown, + ); + } + if (options.cycleStartDay !== undefined) { + input.cycleStartDay = parseNumberOption( + "--cycle-start-day", + options.cycleStartDay, + ); + } + + if (options.triageEnabled !== undefined) { + input.triageEnabled = parseBooleanOption( + "--triage-enabled", + options.triageEnabled, + ); + } + if (options.requirePriorityToLeaveTriage !== undefined) { + input.requirePriorityToLeaveTriage = parseBooleanOption( + "--require-priority-to-leave-triage", + options.requirePriorityToLeaveTriage, + ); + } + if (options.autoClosePeriod !== undefined) { + input.autoClosePeriod = parseNumberOption( + "--auto-close-period", + options.autoClosePeriod, + ); + } + if (options.autoArchivePeriod !== undefined) { + input.autoArchivePeriod = parseNumberOption( + "--auto-archive-period", + options.autoArchivePeriod, + ); + } + + return input; +} + +// Register the estimation/cycle/triage flags shared by create and update. +function addTeamSettingFlags(command: Command): Command { + return command + .option("--description <text>", "team description") + .option("--private <true|false>", "whether the team is private") + .option("--icon <icon>", "team icon") + .option("--color <color>", "team color (hex)") + .option("--timezone <tz>", "team timezone (e.g. America/New_York)") + .option("--parent <team>", "parent team (key, name, or UUID)") + .option( + "--estimation-type <type>", + `estimation scale (${ESTIMATION_TYPES.join(" | ")})`, + ) + .option( + "--estimation-extended <true|false>", + "add extended estimate points", + ) + .option( + "--estimation-allow-zero <true|false>", + "allow zero-point estimates", + ) + .option("--default-estimate <n>", "default estimate for unestimated issues") + .option( + "--inherit-estimation <true|false>", + "inherit estimation from parent (sub-teams only)", + ) + .option("--cycles-enabled <true|false>", "whether the team uses cycles") + .option("--cycle-duration <weeks>", "cycle length in weeks") + .option("--cycle-cooldown <n>", "cooldown between cycles in weeks") + .option("--cycle-start-day <n>", "day of week a new cycle starts") + .option("--triage-enabled <true|false>", "whether triage mode is enabled") + .option( + "--require-priority-to-leave-triage <true|false>", + "require a priority before leaving triage", + ) + .option("--auto-close-period <months>", "auto-close period in months") + .option("--auto-archive-period <months>", "auto-archive period in months"); +} + export function setupTeamsCommands(program: Command): void { const teams = program.command("teams").description("Team operations"); @@ -33,10 +272,10 @@ export function setupTeamsCommands(program: Command): void { Command, ]; const ctx = createContext(getRootOpts(command)); - const result = await listTeams(ctx.gql, { - limit: parseLimit(options.limit), - after: options.after, - }); + const result = await listTeams( + ctx.gql, + buildPaginationOptions(parseLimit(options.limit), options.after), + ); outputSuccess(result); }), ); @@ -49,12 +288,128 @@ export function setupTeamsCommands(program: Command): void { const team = args[0] as string; const command = args.at(-1) as Command; const ctx = createContext(getRootOpts(command)); - const teamId = await resolveTeamId(ctx.sdk, team); + const teamId = await resolveTeamId(ctx.gql, team); const result = await getTeam(ctx.gql, { id: teamId }); outputSuccess(result); }), ); + addTeamSettingFlags( + teams + .command("create <name>") + .description("create a new team") + .option( + "--key <key>", + "unique team key (auto-derived from name if omitted)", + ), + ).action( + handleCommand(async (...args: unknown[]) => { + const [name, options, command] = args as [ + string, + TeamFieldOptions, + Command, + ]; + const ctx = createContext(getRootOpts(command)); + const fields = await buildTeamFields(ctx, options); + const input: CreateTeamInput = { ...fields, name }; + const result = await createTeam(ctx.gql, input); + outputSuccess(result); + }), + ); + + addTeamSettingFlags( + teams + .command("update <team>") + .description("update an existing team") + .option("--name <name>", "new team name") + .option("--key <key>", "new team key"), + ).action( + handleCommand(async (...args: unknown[]) => { + const [team, options, command] = args as [ + string, + TeamFieldOptions & { name?: string }, + Command, + ]; + const ctx = createContext(getRootOpts(command)); + const input = await buildTeamFields(ctx, options); + if (options.name !== undefined) input.name = options.name; + + if (Object.keys(input).length === 0) { + throw invalidParameterError( + "update options", + "at least one field must be provided", + ); + } + + const teamId = await resolveTeamId(ctx.gql, team); + const result = await updateTeam(ctx.gql, teamId, input); + outputSuccess(result); + }), + ); + + teams + .command("members <team>") + .description("list a team's members") + .action( + handleCommand(async (...args: unknown[]) => { + const team = args[0] as string; + const command = args.at(-1) as Command; + const ctx = createContext(getRootOpts(command)); + const teamId = await resolveTeamId(ctx.gql, team); + const result = await listTeamMembers(ctx.gql, { id: teamId }); + outputSuccess(result); + }), + ); + + teams + .command("add-member <team>") + .description("add a user to a team") + .requiredOption("--user <user>", "user display name, email, or UUID") + .option("--owner <true|false>", "grant team-admin (owner) rights") + .action( + handleCommand(async (...args: unknown[]) => { + const [team, options, command] = args as [ + string, + { user: string; owner?: string }, + Command, + ]; + const ctx = createContext(getRootOpts(command)); + const [teamId, userId] = await Promise.all([ + resolveTeamId(ctx.gql, team), + resolveUserId(ctx.gql, options.user), + ]); + const result = await addTeamMember(ctx.gql, { + teamId, + userId, + ...(options.owner === undefined + ? {} + : { owner: parseBooleanOption("--owner", options.owner) }), + }); + outputSuccess(result); + }), + ); + + teams + .command("remove-member <team>") + .description("remove a user from a team") + .requiredOption("--user <user>", "user display name, email, or UUID") + .action( + handleCommand(async (...args: unknown[]) => { + const [team, options, command] = args as [ + string, + { user: string }, + Command, + ]; + const ctx = createContext(getRootOpts(command)); + const [teamId, userId] = await Promise.all([ + resolveTeamId(ctx.gql, team), + resolveUserId(ctx.gql, options.user), + ]); + const result = await removeTeamMember(ctx.gql, { teamId, userId }); + outputSuccess(result); + }), + ); + teams .command("usage") .description("show detailed usage for teams") diff --git a/src/commands/users.ts b/src/commands/users.ts index bb68a54e..15cac95c 100644 --- a/src/commands/users.ts +++ b/src/commands/users.ts @@ -5,6 +5,7 @@ import { getRootOpts, } from "../common/context.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; +import { buildPaginationOptions } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { listUsers } from "../services/user-service.js"; @@ -40,10 +41,11 @@ export function setupUsersCommands(program: Command): void { handleCommand(async (...args: unknown[]) => { const [options, command] = args as [ListUsersOptions, Command]; const ctx = createContext(getRootOpts(command)); - const result = await listUsers(ctx.gql, options.active || false, { - limit: parseLimit(options.limit), - after: options.after, - }); + const result = await listUsers( + ctx.gql, + options.active || false, + buildPaginationOptions(parseLimit(options.limit), options.after), + ); outputSuccess(result); }), ); diff --git a/src/commands/version.ts b/src/commands/version.ts new file mode 100644 index 00000000..3c6e8168 --- /dev/null +++ b/src/commands/version.ts @@ -0,0 +1,65 @@ +import type { Command } from "commander"; +import pkg from "../../package.json" with { type: "json" }; +import { handleCommand, outputSuccess } from "../common/output.js"; +import { + channelFor, + fetchLatestVersion, + isNewer, + writeCache, +} from "../common/update-notifier.js"; +import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; + +export const VERSION_META: DomainMeta = { + name: "version", + summary: "show the installed version and check for updates", + context: [ + "reports the installed linearis version and its release channel (latest or", + "next). `version check` queries the npm registry for the newest version on", + "that channel and reports whether an update is available. interactive runs", + "also print a one-line hint to stderr; set NO_UPDATE_NOTIFIER=1 to silence.", + ].join("\n"), + arguments: {}, + seeAlso: [], +}; + +export function setupVersionCommands(program: Command): void { + const version = program + .command("version") + .description("show the installed version"); + + version.action( + handleCommand(async () => { + outputSuccess({ + version: pkg.version, + channel: channelFor(pkg.version), + }); + }), + ); + + version + .command("check") + .description("check the npm registry for a newer version") + .action( + handleCommand(async () => { + const channel = channelFor(pkg.version); + const latest = await fetchLatestVersion(channel); + const updateAvailable = latest ? isNewer(latest, pkg.version) : false; + if (latest) { + writeCache({ channel, latest, checkedAt: Date.now() }); + } + outputSuccess({ + current: pkg.version, + latest, + channel, + updateAvailable, + }); + }), + ); + + version + .command("usage") + .description("show detailed usage for version") + .action(() => { + console.log(formatDomainUsage(version, VERSION_META)); + }); +} diff --git a/src/common/array.ts b/src/common/array.ts new file mode 100644 index 00000000..f9777997 --- /dev/null +++ b/src/common/array.ts @@ -0,0 +1,22 @@ +/** + * Return the first element of `items`, or throw when the array is empty. + * + * Preferred over a non-null assertion (`items[0]!`) at call sites where the + * array is expected to be non-empty: it keeps the narrowing explicit and yields + * a meaningful error instead of a downstream `undefined` access under + * `noUncheckedIndexedAccess`. Pass a string for an ad-hoc message, a ready-made + * `Error` (e.g. `notFoundError(...)`) to preserve domain-specific messaging, or + * a factory returning either — the factory form defers constructing the error + * (and capturing its stack) to the empty path, avoiding wasted work on the + * common non-empty case. + */ +export function firstOrThrow<T>( + items: readonly T[], + error: string | Error | (() => string | Error), +): T { + if (items.length === 0) { + const resolved = typeof error === "function" ? error() : error; + throw typeof resolved === "string" ? new Error(resolved) : resolved; + } + return items[0] as T; +} diff --git a/src/common/auth.ts b/src/common/auth.ts index 86c94c0d..17cfb2b1 100644 --- a/src/common/auth.ts +++ b/src/common/auth.ts @@ -5,6 +5,8 @@ import { getStoredToken } from "./token-storage.js"; export interface CommandOptions { apiToken?: string; + compact?: boolean; + fields?: string[]; } export type TokenSource = "flag" | "env" | "stored" | "legacy"; @@ -22,8 +24,8 @@ export function resolveApiToken(options: CommandOptions): ResolvedToken { } // 2. Environment variable - if (process.env.LINEAR_API_TOKEN) { - return { token: process.env.LINEAR_API_TOKEN, source: "env" }; + if (process.env["LINEAR_API_TOKEN"]) { + return { token: process.env["LINEAR_API_TOKEN"], source: "env" }; } // 3. Encrypted stored token (~/.linearis/token) diff --git a/src/common/context.ts b/src/common/context.ts index 4baa1d62..082dc52a 100644 --- a/src/common/context.ts +++ b/src/common/context.ts @@ -1,20 +1,17 @@ import type { Command } from "commander"; import { GraphQLClient } from "../client/graphql-client.js"; -import { LinearSdkClient } from "../client/linear-client.js"; import { type CommandOptions, getApiToken } from "./auth.js"; export type { CommandOptions }; export interface CommandContext { gql: GraphQLClient; - sdk: LinearSdkClient; } export function createContext(options: CommandOptions): CommandContext { const token = getApiToken(options); return { gql: new GraphQLClient(token), - sdk: new LinearSdkClient(token), }; } diff --git a/src/common/domain-values.ts b/src/common/domain-values.ts new file mode 100644 index 00000000..0bd28bc9 --- /dev/null +++ b/src/common/domain-values.ts @@ -0,0 +1,19 @@ +import { invalidParameterError } from "./errors.js"; + +/** Linear priority scale: 0=none, 1=urgent, 2=high, 3=medium, 4=low. */ +export type Priority = 0 | 1 | 2 | 3 | 4; + +/** How `issues update --labels` combines with existing labels. */ +export type LabelMode = "add" | "remove" | "overwrite"; + +export function parseLabelMode( + value: string | undefined, +): LabelMode | undefined { + if (value === undefined) return undefined; + if (value === "add" || value === "remove" || value === "overwrite") + return value; + throw invalidParameterError( + "--label-mode", + "must be one of 'add', 'remove', or 'overwrite'", + ); +} diff --git a/src/common/embed-parser.ts b/src/common/embed-parser.ts index 594b6137..2a16a0e0 100644 --- a/src/common/embed-parser.ts +++ b/src/common/embed-parser.ts @@ -1,56 +1,3 @@ -export interface EmbedInfo { - label: string; - url: string; - /** ISO timestamp when the signed URL expires (1 hour from generation) */ - expiresAt: string; -} - -/** Removes code blocks and inline code to avoid extracting URLs from code examples. */ -function stripCodeContexts(content: string): string { - // Remove escaped backticks - let cleaned = content.replace(/\\`/g, ""); - - // Remove fenced code blocks (```...```) - greedy match with dotall behavior - cleaned = cleaned.replace(/```[\s\S]*?```/g, ""); - - // Remove inline code (`...`) - cleaned = cleaned.replace(/`[^`]+`/g, ""); - - return cleaned; -} - -/** Extracts Linear upload URLs from markdown image and link syntax. */ -export function extractEmbeds(content: string): EmbedInfo[] { - if (!content) { - return []; - } - - // Strip code contexts to avoid extracting URLs from code examples - const cleanedContent = stripCodeContexts(content); - - const embeds: EmbedInfo[] = []; - const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString(); - - // Match both image ![label](url) and link [label](url) syntax - const patterns = [ - /!\[([^\]]*)\]\(([^)]+)\)/g, // images - /(?<!!)\[([^\]]+)\]\(([^)]+)\)/g, // links - ]; - - for (const regex of patterns) { - for (const match of cleanedContent.matchAll(regex)) { - const label = match[1] || "file"; - const url = match[2]; - - if (isLinearUploadUrl(url)) { - embeds.push({ label, url, expiresAt }); - } - } - } - - return embeds; -} - export function isLinearUploadUrl(url: string): boolean { if (!url) { return false; diff --git a/src/common/identifier.ts b/src/common/identifier.ts index 03478048..56d50881 100644 --- a/src/common/identifier.ts +++ b/src/common/identifier.ts @@ -1,10 +1,47 @@ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -export function isUuid(value: string): boolean { +type Brand<T, TBrand extends string> = T & { readonly __brand: TBrand }; + +export function isUuid(value: string): value is UUID { return UUID_REGEX.test(value); } +/** + * A resolved Linear entity UUID. + * + * Branded so the compiler enforces the architecture invariant that services + * receive already-resolved IDs: a plain `string` (e.g. a human identifier like + * `ENG-123`) is not assignable to `UUID`, but a `UUID` flows into `string` + * slots (such as codegen GraphQL inputs) untouched. The brand is erased at + * runtime, so a `UUID` behaves exactly like the underlying string. + */ +export type UUID = Brand<string, "UUID">; + +/** + * Brand a string as a resolved UUID at a trust boundary — the output of a + * resolver, or a UUID the user supplied directly on the CLI. Performs no + * runtime validation. + */ +export function asUuid(value: string): UUID { + return value as UUID; +} + +/** Replace a `string`/`string[]` core with `UUID`/`UUID[]`, preserving null/undefined. */ +type ReplaceStringWithUuid<V> = V extends string + ? UUID + : V extends string[] + ? UUID[] + : V; + +/** + * Brand selected keys of an input type as UUID, preserving each field's + * optional and readonly modifiers (homomorphic over `keyof T`). + */ +export type BrandUuidFields<T, K extends keyof T> = { + [P in keyof T]: P extends K ? ReplaceStringWithUuid<T[P]> : T[P]; +}; + export interface IssueIdentifier { teamKey: string; issueNumber: number; @@ -12,16 +49,19 @@ export interface IssueIdentifier { /** @throws Error if identifier format is invalid */ export function parseIssueIdentifier(identifier: string): IssueIdentifier { - const parts = identifier.split("-"); + const [teamKey, issueNumberRaw, ...rest] = identifier.split("-"); - if (parts.length !== 2) { + if ( + teamKey === undefined || + issueNumberRaw === undefined || + rest.length > 0 + ) { throw new Error( `Invalid issue identifier format: "${identifier}". Expected format: TEAM-123`, ); } - const teamKey = parts[0]; - const issueNumber = parseInt(parts[1], 10); + const issueNumber = parseInt(issueNumberRaw, 10); if (Number.isNaN(issueNumber)) { throw new Error(`Invalid issue number in identifier: "${identifier}"`); @@ -41,17 +81,22 @@ export function tryParseIssueIdentifier( } } -const DUE_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; +const DUE_DATE_REGEX = /^(\d{4})-(\d{2})-(\d{2})$/; /** @throws Error if date format is invalid or date doesn't exist */ export function parseDueDate(value: string): string { - if (!DUE_DATE_REGEX.test(value)) { + const match = DUE_DATE_REGEX.exec(value); + if (!match) { throw new Error( `Invalid due date format: "${value}". Expected format: YYYY-MM-DD`, ); } - const [year, month, day] = value.split("-").map(Number); + // The three capture groups are guaranteed present when the regex matches. + const [, yearStr, monthStr, dayStr] = match; + const year = Number(yearStr); + const month = Number(monthStr); + const day = Number(dayStr); const date = new Date(year, month - 1, day); if ( diff --git a/src/common/json.ts b/src/common/json.ts new file mode 100644 index 00000000..4b75380c --- /dev/null +++ b/src/common/json.ts @@ -0,0 +1,55 @@ +/** + * JSON value types encoding Linearis' "JSON-only output" contract at the type + * level. Anything reaching {@link ./output.outputSuccess} must serialize to + * JSON without data loss or a runtime error. + */ + +/** A JSON scalar: the leaves of any JSON document. */ +type JsonPrimitive = string | number | boolean | null; + +/** + * A value that serializes to JSON losslessly — no functions, `undefined`, + * symbols, `bigint`, or class instances with behaviour. This is the strict + * contract the output boundary ultimately targets. It is not consumed directly + * yet: functions and other non-JSON values vacuously satisfy its index + * signature, so {@link JsonSerializable} does the real enforcement. Kept as the + * documented target for issue #202's path to strict compile-time enforcement. + * + * @public exported as the project-level JSON contract type; not consumed + * internally yet (see above), so it is tagged to document intent. + */ +export type JsonValue = + | JsonPrimitive + | { readonly [key: string]: JsonValue } + | readonly JsonValue[]; + +/** + * Transitional constraint for the output boundary. It is meaningfully stricter + * than `unknown` — it rejects the values that break `JSON.stringify` or lose + * data (functions, `symbol`, `bigint`) at every position — while tolerating the + * shapes today's generated GraphQL result types legitimately produce: + * + * - optional (`field?:`) properties that widen to `undefined` (dropped by + * `JSON.stringify`); + * - opaque `Record<string, unknown>` / `unknown` JSON blobs (e.g. attachment + * `metadata`) that TypeScript cannot prove are pure JSON but which the + * Linear API only ever populates with parsed JSON; + * - named `interface`/type shapes that lack an implicit index signature and so + * are not structurally assignable to {@link JsonValue}. + * + * Each object is validated member-by-member, so nominal result types are + * accepted without per-call-site casts. See issue #202 for the path to + * requiring {@link JsonValue} directly once the generated types are narrowed. + */ +export type JsonSerializable<T> = T extends (...args: never[]) => unknown + ? never + : T extends bigint | symbol + ? never + : T extends JsonPrimitive | undefined + ? T + : T extends readonly (infer U)[] + ? readonly JsonSerializable<U>[] + : T extends object + ? { [K in keyof T]: JsonSerializable<T[K]> } + : // `unknown`/`any` opaque values fall through, tolerated transitionally + T; diff --git a/src/common/mutation-payload.ts b/src/common/mutation-payload.ts new file mode 100644 index 00000000..593156cc --- /dev/null +++ b/src/common/mutation-payload.ts @@ -0,0 +1,28 @@ +/** + * Assert a Linear mutation payload succeeded and return its entity field. + * + * The entity field name varies per mutation (`issue`, `project`, `entity`, + * `comment`, …), so the caller supplies the key. Typing stays exact via + * `keyof` + `NonNullable`, so no `any` is needed and the returned value is + * narrowed to the non-null entity type. + */ +export function requireMutationEntity< + P extends { success: boolean }, + K extends keyof P, +>(payload: P, key: K, message: string): NonNullable<P[K]> { + const entity = payload[key]; + if (!payload.success || entity == null) { + throw new Error(message); + } + return entity as NonNullable<P[K]>; +} + +/** Assert a mutation payload succeeded when there is no entity to return. */ +export function requireMutationSuccess( + payload: { success: boolean }, + message: string, +): void { + if (!payload.success) { + throw new Error(message); + } +} diff --git a/src/common/number-options.ts b/src/common/number-options.ts index 0d72b9d4..176e7012 100644 --- a/src/common/number-options.ts +++ b/src/common/number-options.ts @@ -1,3 +1,4 @@ +import type { Priority } from "./domain-values.js"; import { invalidParameterError } from "./errors.js"; function parseStrictNonNegativeInteger(raw: string): number | null { @@ -8,7 +9,7 @@ function parseStrictNonNegativeInteger(raw: string): number | null { return Number.parseInt(raw, 10); } -export function parsePriorityOption(raw: string): number { +export function parsePriorityOption(raw: string): Priority { const value = parseStrictNonNegativeInteger(raw); if (value === null || value < 1 || value > 4) { throw invalidParameterError( @@ -17,7 +18,7 @@ export function parsePriorityOption(raw: string): number { ); } - return value; + return value as Priority; } export function parseEstimateOption(raw: string): number { diff --git a/src/common/object.ts b/src/common/object.ts new file mode 100644 index 00000000..751a0f21 --- /dev/null +++ b/src/common/object.ts @@ -0,0 +1,16 @@ +/** + * Return a shallow copy of `obj` with every `undefined`-valued key removed. + * + * The result type marks each key optional and strips `undefined` from its value + * type, so the object satisfies interfaces declared under + * `exactOptionalPropertyTypes` (where an explicit `key: undefined` is not + * assignable to `key?: T`). Preferred over a wall of conditional spreads when + * building filter/option objects whose fields are all individually optional. + */ +export function omitUndefined<T extends object>( + obj: T, +): { [K in keyof T]?: Exclude<T[K], undefined> } { + return Object.fromEntries( + Object.entries(obj).filter(([, value]) => value !== undefined), + ) as { [K in keyof T]?: Exclude<T[K], undefined> }; +} diff --git a/src/common/output.ts b/src/common/output.ts index 3bb42ea3..40790b28 100644 --- a/src/common/output.ts +++ b/src/common/output.ts @@ -1,11 +1,80 @@ +import type { CommandOptions } from "./auth.js"; import { AUTH_ERROR_CODE, AuthenticationError, invalidParameterError, } from "./errors.js"; +import type { JsonSerializable } from "./json.js"; -export function outputSuccess(data: unknown): void { - console.log(JSON.stringify(data, null, 2)); +// Derived from CommandOptions so the two can never drift; `fields` holds raw +// dot-paths, e.g. ["identifier", "state.name"]. +type OutputOptions = Pick<CommandOptions, "compact" | "fields">; + +let currentOutputOptions: OutputOptions = {}; + +/** + * Set once per process by the preAction hook in main.ts. Also used to reset + * state between unit tests. + */ +export function setOutputOptions(opts: OutputOptions): void { + currentOutputOptions = opts; +} + +/** Commander option parser for `--fields`: "a, b ,, c" -> ["a","b","c"]. */ +export function parseFieldsList(value: string): string[] { + return value + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +/** + * Recursively project `value` down to the given dot-path segments, preserving + * nested object shape and traversing arrays mid-path. Only own properties are + * matched (inherited members like `toString`/`constructor` are never picked), + * and results are written with `Object.defineProperty` so a user-supplied + * `--fields __proto__` cannot invoke the prototype setter. Missing keys are + * skipped silently; a path that stops at a subtree keeps that whole subtree. + */ +export function pickFields(value: unknown, paths: string[][]): unknown { + if (Array.isArray(value)) { + return value.map((item) => pickFields(item, paths)); + } + if (value === null || typeof value !== "object") { + return value; // path descends past a scalar; nothing to pick + } + const src = value as Record<string, unknown>; + const byHead = new Map<string, string[][]>(); + for (const [head, ...tail] of paths) { + if (head === undefined) continue; + const tails = byHead.get(head) ?? []; + if (tail.length > 0) tails.push(tail); + byHead.set(head, tails); + } + const out: Record<string, unknown> = {}; + for (const [head, tails] of byHead) { + if (!Object.hasOwn(src, head)) continue; + const picked = tails.length > 0 ? pickFields(src[head], tails) : src[head]; + Object.defineProperty(out, head, { + value: picked, + enumerable: true, + writable: true, + configurable: true, + }); + } + return out; +} + +export function outputSuccess<T>(data: JsonSerializable<T>): void { + const { compact, fields } = currentOutputOptions; + const shaped = + fields && fields.length > 0 + ? pickFields( + data, + fields.map((p) => p.split(".")), + ) + : data; + console.log(JSON.stringify(shaped, null, compact ? undefined : 2)); } export function outputError(error: Error): void { @@ -55,3 +124,35 @@ export function handleCommand( } }; } + +/** + * Typed wrapper around {@link handleCommand} for Commander action handlers. + * + * Commander invokes an action with the positional arguments first, followed by + * the parsed options object and the `Command` instance. That boundary is + * inherently `unknown[]`, so command bodies used to open with a hand-written + * tuple cast (`const [issue, options, command] = args as [...]`). Those casts + * are invisible to the compiler: if a command signature changes, TypeScript + * cannot flag the now-wrong destructuring. + * + * `commandAction` centralizes the cast in one place. Declare the expected + * argument tuple once via the generic parameter and the handler receives fully + * typed arguments, while `handleCommand` remains the single error wrapper. + * + * @example + * .action( + * commandAction<[string, ReadOptions, Command]>( + * async (issue, options, command) => { + * const ctx = createContext(getRootOpts(command)); + * // ... + * }, + * ), + * ) + */ +export function commandAction<TArgs extends readonly unknown[]>( + fn: (...args: TArgs) => Promise<void>, +): (...args: unknown[]) => Promise<void> { + return handleCommand(async (...args: unknown[]) => { + await fn(...(args as unknown as TArgs)); + }); +} diff --git a/src/common/resolve-filters.ts b/src/common/resolve-filters.ts index ccd3fcac..02bf5b40 100644 --- a/src/common/resolve-filters.ts +++ b/src/common/resolve-filters.ts @@ -12,6 +12,7 @@ import { validateFilterDependencies, validatePriority, } from "./issue-filter.js"; +import { omitUndefined } from "./object.js"; /** * Resolves raw CLI filter flags into validated IssueFilterOptions with UUIDs. @@ -19,7 +20,7 @@ import { * Validation order: format → dependency → date ranges → ID resolution. * Fails fast before making any API calls when input is invalid. * - * @param ctx - Command context with SDK and GraphQL clients + * @param ctx - Command context with the GraphQL client * @param opts - Raw filter flags from CLI options * @returns Resolved filter options with UUIDs ready for buildIssueFilter() */ @@ -106,23 +107,26 @@ export async function resolveFilterOptions( opts.parent !== undefined; const batchResolved = hasResolvableFilters - ? await resolveSearchFilterIds(ctx.sdk, { - team: opts.team, - assignee: opts.assignee, - creator: opts.creator, - project: opts.project, - statusNames: parsedStatusNames, - labelNames: parsedLabelNames, - cycle: opts.cycle, - parent: opts.parent, - }) + ? await resolveSearchFilterIds( + ctx.gql, + omitUndefined({ + team: opts.team, + assignee: opts.assignee, + creator: opts.creator, + project: opts.project, + statusNames: parsedStatusNames, + labelNames: parsedLabelNames, + cycle: opts.cycle, + parent: opts.parent, + }), + ) : {}; const milestoneId = opts.milestone - ? await resolveMilestoneId(ctx.gql, ctx.sdk, opts.milestone, opts.project) + ? await resolveMilestoneId(ctx.gql, opts.milestone, opts.project) : undefined; - const resolved: IssueFilterOptions = { + const resolved: IssueFilterOptions = omitUndefined({ ...batchResolved, milestoneId, priority: parsedPriority, @@ -137,7 +141,7 @@ export async function resolveFilterOptions( updatedBefore: opts.updatedBefore, hasBlockers: opts.hasBlockers, isBlocking: opts.isBlocking, - }; + }); return resolved; } diff --git a/src/common/retry.ts b/src/common/retry.ts index 6c535023..456f046a 100644 --- a/src/common/retry.ts +++ b/src/common/retry.ts @@ -9,22 +9,40 @@ interface RetryableError { }; } +/** + * Collect the lowercased messages of an error and its `cause` chain. Native + * `fetch` (undici) rejects transport failures as `TypeError: fetch failed` and + * carries the real error (e.g. `ECONNRESET`) on `cause`, so the top-level + * message alone is not enough to classify the failure. + */ +function collectErrorMessages(error: unknown): string { + const messages: string[] = []; + const seen = new Set<unknown>(); + let current: unknown = error; + while (current instanceof Error && !seen.has(current)) { + seen.add(current); + messages.push(current.message); + current = current.cause; + } + return messages.join(" ").toLowerCase(); +} + export function isRetryable(error: unknown): boolean { const err = error as RetryableError; const status = err?.response?.status; if (typeof status === "number") { return status === 429 || (status >= 500 && status < 600); } - // network-level errors (ECONNRESET, ETIMEDOUT, etc.) - if (error instanceof Error) { - const msg = error.message.toLowerCase(); - return ( - msg.includes("timed out") || - msg.includes("econnreset") || - msg.includes("network") - ); - } - return false; + // network-level errors (ECONNRESET, ETIMEDOUT, etc.). `fetch failed` is + // undici's generic wrapper for transport failures with no HTTP status. + const msg = collectErrorMessages(error); + return ( + msg.includes("timed out") || + msg.includes("etimedout") || + msg.includes("econnreset") || + msg.includes("network") || + msg.includes("fetch failed") + ); } export async function withRetry<T>( diff --git a/src/common/token-storage.ts b/src/common/token-storage.ts index ae2117a3..2b966f45 100644 --- a/src/common/token-storage.ts +++ b/src/common/token-storage.ts @@ -9,7 +9,7 @@ const TOKEN_FILE = "token"; export function getTokenDir(): string { if (process.platform === "linux") { - const xdgConfig = process.env.XDG_CONFIG_HOME; + const xdgConfig = process.env["XDG_CONFIG_HOME"]; if (xdgConfig && path.isAbsolute(xdgConfig)) { return path.join(xdgConfig, DIR_NAME); } diff --git a/src/common/types.ts b/src/common/types.ts index 1759ba1e..0ed84247 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -1,57 +1,7 @@ -import type { - ArchiveInitiativeMutation, - ArchiveInitiativeUpdateMutation, - ArchiveProjectMutation, - AttachmentCreateMutation, - CreateCommentMutation, - CreateInitiativeMutation, - CreateInitiativeRelationMutation, - CreateInitiativeToProjectMutation, - CreateInitiativeUpdateMutation, - CreateIssueMutation, - CreateIssueRelationMutation, - CreateProjectMilestoneMutation, - CreateProjectMutation, - DeleteInitiativeMutation, - DeleteInitiativeRelationMutation, - DeleteInitiativeToProjectMutation, - DocumentCreateMutation, - DocumentUpdateMutation, - GetDocumentQuery, - GetInitiativeQuery, - GetInitiativeUpdateQuery, - GetIssueByIdentifierQuery, - GetIssueByIdentifierWithAttachmentsQuery, - GetIssueByIdentifierWithCommentsQuery, - GetIssueByIdQuery, - GetIssueByIdWithAttachmentsQuery, - GetIssueByIdWithCommentsQuery, - GetIssuesQuery, - GetProjectMilestoneByIdQuery, - GetProjectQuery, - GetProjectsQuery, - GetTeamByIdQuery, - GetViewerQuery, - ListAttachmentsQuery, - ListCommentsQuery, - ListDocumentsQuery, - ListInitiativesQuery, - ListInitiativeUpdatesQuery, - ListProjectMilestonesQuery, - SearchIssuesQuery, - UnarchiveInitiativeMutation, - UnarchiveInitiativeUpdateMutation, - UnarchiveProjectMutation, - UpdateCommentMutation, - UpdateInitiativeMutation, - UpdateInitiativeUpdateMutation, - UpdateIssueMutation, - UpdateProjectMilestoneMutation, - UpdateProjectMutation, -} from "../gql/graphql.js"; +import type { GetIssuesQuery } from "../gql/graphql.js"; // Pagination types -export type PageInfo = GetIssuesQuery["issues"]["pageInfo"]; +type PageInfo = GetIssuesQuery["issues"]["pageInfo"]; export interface PaginatedResult<T> { nodes: T[]; @@ -63,181 +13,49 @@ export interface PaginationOptions { after?: string; } -// Team types -export type TeamEstimateOption = { - value: number; - label: string; -}; - -export type TeamEstimationSource = "self" | "parent" | "self_fallback"; - -export type TeamDetail = NonNullable<GetTeamByIdQuery["team"]> & { - validEstimates: TeamEstimateOption[]; - estimationSource: TeamEstimationSource; -}; - -// Issue types -export type Issue = GetIssuesQuery["issues"]["nodes"][0]; -export type IssueDetail = NonNullable<GetIssueByIdQuery["issue"]>; -export type IssueByIdentifier = GetIssueByIdentifierQuery["issues"]["nodes"][0]; -export type IssueDetailWithComments = NonNullable< - GetIssueByIdWithCommentsQuery["issue"] ->; -export type IssueByIdentifierWithComments = - GetIssueByIdentifierWithCommentsQuery["issues"]["nodes"][0]; -export type IssueComment = NonNullable< - NonNullable<IssueDetailWithComments["comments"]>["nodes"][0] ->; -export type IssueCommentThread = IssueComment & { - replies: IssueCommentThread[]; -}; -export type IssueDetailWithCommentThreads = Omit< - IssueDetailWithComments, - "comments" -> & { - comments: { nodes: IssueCommentThread[] }; -}; -export type IssueByIdentifierWithCommentThreads = Omit< - IssueByIdentifierWithComments, - "comments" -> & { - comments: { nodes: IssueCommentThread[] }; -}; -export type IssueDetailWithAttachments = NonNullable< - GetIssueByIdWithAttachmentsQuery["issue"] ->; -export type IssueByIdentifierWithAttachments = - GetIssueByIdentifierWithAttachmentsQuery["issues"]["nodes"][0]; -export type IssueSearchResult = SearchIssuesQuery["searchIssues"]["nodes"][0]; -export type CreatedIssue = NonNullable< - CreateIssueMutation["issueCreate"]["issue"] ->; -export type UpdatedIssue = NonNullable< - UpdateIssueMutation["issueUpdate"]["issue"] ->; - -// Issue relation types -export type CreatedIssueRelation = - CreateIssueRelationMutation["issueRelationCreate"]["issueRelation"]; - -// Document types -export type Document = NonNullable<GetDocumentQuery["document"]>; -export type DocumentListItem = ListDocumentsQuery["documents"]["nodes"][0]; -export type CreatedDocument = - DocumentCreateMutation["documentCreate"]["document"]; -export type UpdatedDocument = - DocumentUpdateMutation["documentUpdate"]["document"]; - -// Attachment types -export type Attachment = - ListAttachmentsQuery["issue"]["attachments"]["nodes"][0]; -export type CreatedAttachment = - AttachmentCreateMutation["attachmentCreate"]["attachment"]; - -// Project types -export type ProjectListItem = GetProjectsQuery["projects"]["nodes"][0]; -export type ProjectDetail = NonNullable<GetProjectQuery["project"]>; -export type CreatedProject = NonNullable< - CreateProjectMutation["projectCreate"]["project"] ->; -export type UpdatedProject = NonNullable< - UpdateProjectMutation["projectUpdate"]["project"] ->; -export type ArchivedProject = NonNullable< - ArchiveProjectMutation["projectArchive"]["entity"] ->; -export type UnarchivedProject = NonNullable< - UnarchiveProjectMutation["projectUnarchive"]["entity"] ->; -export type DeletedProject = { - id: string; - success: true; -}; - -// Milestone types -export type MilestoneDetail = NonNullable< - GetProjectMilestoneByIdQuery["projectMilestone"] ->; -export type MilestoneListItem = - ListProjectMilestonesQuery["project"]["projectMilestones"]["nodes"][0]; -export type CreatedMilestone = NonNullable< - CreateProjectMilestoneMutation["projectMilestoneCreate"]["projectMilestone"] ->; -export type UpdatedMilestone = NonNullable< - UpdateProjectMilestoneMutation["projectMilestoneUpdate"]["projectMilestone"] ->; - -// Initiative types -export type InitiativeListItem = - ListInitiativesQuery["initiatives"]["nodes"][0]; -export type InitiativeDetail = NonNullable<GetInitiativeQuery["initiative"]>; -export type CreatedInitiative = NonNullable< - CreateInitiativeMutation["initiativeCreate"]["initiative"] ->; -export type UpdatedInitiative = NonNullable< - UpdateInitiativeMutation["initiativeUpdate"]["initiative"] ->; -export type ArchivedInitiative = NonNullable< - ArchiveInitiativeMutation["initiativeArchive"]["entity"] ->; -export type UnarchivedInitiative = NonNullable< - UnarchiveInitiativeMutation["initiativeUnarchive"]["entity"] ->; - -export type InitiativeRelation = NonNullable< - CreateInitiativeRelationMutation["initiativeRelationCreate"]["initiativeRelation"] ->; - -export type InitiativeProjectLink = NonNullable< - CreateInitiativeToProjectMutation["initiativeToProjectCreate"]["initiativeToProject"] ->; - -export type DeletedInitiative = { - id: NonNullable<DeleteInitiativeMutation["initiativeDelete"]["entityId"]>; - success: true; -}; - -export type DeletedInitiativeRelation = { - id: NonNullable< - DeleteInitiativeRelationMutation["initiativeRelationDelete"]["entityId"] - >; - success: true; -}; - -export type DeletedInitiativeProjectLink = { - id: NonNullable< - DeleteInitiativeToProjectMutation["initiativeToProjectDelete"]["entityId"] - >; - success: true; -}; - -export type InitiativeUpdateListItem = - ListInitiativeUpdatesQuery["initiativeUpdates"]["nodes"][0]; -export type InitiativeUpdateDetail = NonNullable< - GetInitiativeUpdateQuery["initiativeUpdate"] ->; -export type CreatedInitiativeUpdate = NonNullable< - CreateInitiativeUpdateMutation["initiativeUpdateCreate"]["initiativeUpdate"] ->; -export type UpdatedInitiativeUpdate = NonNullable< - UpdateInitiativeUpdateMutation["initiativeUpdateUpdate"]["initiativeUpdate"] ->; -export type ArchivedInitiativeUpdate = NonNullable< - ArchiveInitiativeUpdateMutation["initiativeUpdateArchive"]["entity"] ->; -export type UnarchivedInitiativeUpdate = NonNullable< - UnarchiveInitiativeUpdateMutation["initiativeUpdateUnarchive"]["entity"] ->; +/** + * Build a {@link PaginationOptions} object from raw CLI values, omitting `after` + * when it is undefined. Keeping the key absent (rather than set to `undefined`) + * is required under `exactOptionalPropertyTypes` and avoids repeating the + * conditional spread at every list command. + */ +export function buildPaginationOptions( + limit: number, + after: string | undefined, +): PaginationOptions { + return after === undefined ? { limit } : { limit, after }; +} -// Comment types -export type CreatedComment = NonNullable< - CreateCommentMutation["commentCreate"]["comment"] ->; -export type UpdatedComment = NonNullable< - UpdateCommentMutation["commentUpdate"]["comment"] ->; -export type CommentListItem = - ListCommentsQuery["issue"]["comments"]["nodes"][0]; +/** A Relay-style GraphQL connection page. */ +interface Connection<T> { + nodes: readonly T[]; + pageInfo: { hasNextPage: boolean; endCursor?: string | null }; +} -// Viewer types -export type Viewer = GetViewerQuery["viewer"]; +/** + * Exhaust a cursor-paginated GraphQL connection, returning every node. The + * caller's `fetchPage` requests a single page for the given `after` cursor (and + * performs any per-page guards, e.g. asserting the parent entity exists); + * iteration stops once the server reports no further pages. Centralizes the + * fetch-until-empty loop shared by services that must materialize a whole + * connection before processing it. + */ +export async function collectConnection<TNode>( + fetchPage: (after: string | undefined) => Promise<Connection<TNode>>, +): Promise<TNode[]> { + const nodes: TNode[] = []; + let after: string | undefined; + + while (true) { + const connection = await fetchPage(after); + nodes.push(...connection.nodes); + + if (!connection.pageInfo.hasNextPage || !connection.pageInfo.endCursor) { + break; + } + + after = connection.pageInfo.endCursor; + } + + return nodes; +} diff --git a/src/common/update-notifier.ts b/src/common/update-notifier.ts new file mode 100644 index 00000000..ac89cff1 --- /dev/null +++ b/src/common/update-notifier.ts @@ -0,0 +1,197 @@ +import fs from "node:fs"; +import path from "node:path"; +import { ensureTokenDir, getTokenDir } from "./token-storage.js"; + +/** + * Passive "update available" notifier, run inline before every command. + * + * Design constraints (Linearis emits JSON on stdout for agents): + * - The hint is written to **stderr only**, never stdout, so it can never + * corrupt the JSON contract that agents parse. + * - It is shown only on interactive runs (`process.stdout.isTTY`). Agents and + * scripts pipe stdout, so they are never nagged and make no network calls. + * - The registry lookup is cached on disk; only a stale cache (older than + * CHECK_INTERVAL_MS) triggers a network call, so the common path is instant. + * - Every operation fails silently; a version check must never affect a command. + */ + +const CACHE_FILE = "update-check.json"; +const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h +const NPM_DIST_TAGS_URL = + "https://registry.npmjs.org/-/package/linearis/dist-tags"; +const FETCH_TIMEOUT_MS = 3000; + +export type Channel = "latest" | "next"; + +export interface UpdateCacheData { + channel: Channel; + latest: string; + checkedAt: number; +} + +/** "next" when the installed version carries a `-next` prerelease, else "latest". */ +export function channelFor(version: string): Channel { + return /-next\b/.test(version) ? "next" : "latest"; +} + +function cachePath(): string { + return path.join(getTokenDir(), CACHE_FILE); +} + +/** Read the last cached registry lookup, or null if missing/corrupt. */ +export function readCache(): UpdateCacheData | null { + try { + const data = JSON.parse(fs.readFileSync(cachePath(), "utf8")) as + | UpdateCacheData + | undefined; + if ( + data && + typeof data.latest === "string" && + typeof data.checkedAt === "number" && + (data.channel === "latest" || data.channel === "next") + ) { + return data; + } + } catch { + // missing or corrupt cache — treat as absent + } + return null; +} + +/** Persist a registry lookup for the next invocation to read. */ +export function writeCache(data: UpdateCacheData): void { + ensureTokenDir(); + fs.writeFileSync(cachePath(), JSON.stringify(data), "utf8"); +} + +/** + * Compare two versions of the form `YYYY.M.P` with an optional `-tag.N` + * prerelease suffix. Returns >0 if `a` > `b`, <0 if `a` < `b`, 0 if equal. + * A release (no prerelease) outranks a prerelease sharing the same core. + */ +export function compareVersions(a: string, b: string): number { + const [coreA = "", preA = ""] = a.split("-"); + const [coreB = "", preB = ""] = b.split("-"); + const numsA = coreA.split(".").map((n) => Number.parseInt(n, 10) || 0); + const numsB = coreB.split(".").map((n) => Number.parseInt(n, 10) || 0); + const len = Math.max(numsA.length, numsB.length); + for (let i = 0; i < len; i++) { + const diff = (numsA[i] ?? 0) - (numsB[i] ?? 0); + if (diff !== 0) return Math.sign(diff); + } + if (preA === preB) return 0; + if (preA === "") return 1; // a is a release, b a prerelease of same core + if (preB === "") return -1; + return comparePrerelease(preA, preB); +} + +function comparePrerelease(a: string, b: string): number { + const as = a.split("."); + const bs = b.split("."); + const len = Math.max(as.length, bs.length); + for (let i = 0; i < len; i++) { + const x = as[i]; + const y = bs[i]; + if (x === undefined) return -1; // shorter prerelease sorts lower + if (y === undefined) return 1; + const nx = Number.parseInt(x, 10); + const ny = Number.parseInt(y, 10); + if (!Number.isNaN(nx) && !Number.isNaN(ny)) { + if (nx !== ny) return Math.sign(nx - ny); + } else if (x !== y) { + return x < y ? -1 : 1; + } + } + return 0; +} + +/** True when `candidate` is a strictly newer version than `current`. */ +export function isNewer(candidate: string, current: string): boolean { + return compareVersions(candidate, current) > 0; +} + +/** Respect the de-facto `NO_UPDATE_NOTIFIER`, a project escape hatch, and CI. */ +export function updateChecksDisabled( + env: NodeJS.ProcessEnv = process.env, +): boolean { + return Boolean( + env["NO_UPDATE_NOTIFIER"] || env["LINEARIS_NO_UPDATE_CHECK"] || env["CI"], + ); +} + +/** The one-line stderr hint shown when an update is available. */ +export function formatUpdateNotice( + current: string, + latest: string, + channel: Channel, +): string { + const tag = channel === "next" ? "@next" : "@latest"; + return [ + `▲ linearis update available: ${current} → ${latest}`, + ` run: npm install -g linearis${tag}`, + " silence: set NO_UPDATE_NOTIFIER=1", + ].join("\n"); +} + +/** Query the npm registry for the newest version on a dist-tag channel. */ +export async function fetchLatestVersion( + channel: Channel, +): Promise<string | null> { + try { + const res = await fetch(NPM_DIST_TAGS_URL, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!res.ok) return null; + const tags = (await res.json()) as Record<string, string>; + const version = tags[channel]; + return typeof version === "string" ? version : null; + } catch { + return null; + } +} + +/** + * On interactive runs, print a one-line hint to stderr when a newer version is + * available. Reads from the on-disk cache; refreshes it inline only when stale. + * Never blocks agents/scripts, never throws. + */ +export async function maybeNotifyUpdate(currentVersion: string): Promise<void> { + try { + // Agents/scripts consume stdout non-interactively — never nag them, and + // never make a network call on their behalf. + if (!process.stdout.isTTY) return; + if (updateChecksDisabled()) return; + + const channel = channelFor(currentVersion); + let cache = readCache(); + const stale = + !cache || + cache.channel !== channel || + Date.now() - cache.checkedAt > CHECK_INTERVAL_MS; + if (stale) { + const latest = await fetchLatestVersion(channel); + // Advance checkedAt even when the lookup fails so a failed check backs + // off for CHECK_INTERVAL_MS instead of re-fetching on every command. + // Carry the prior latest when the registry is unreachable, falling back + // to the current version (which never triggers a notice) when there is + // no prior cache to reuse. + const resolvedLatest = + latest ?? (cache?.channel === channel ? cache.latest : currentVersion); + cache = { channel, latest: resolvedLatest, checkedAt: Date.now() }; + writeCache(cache); + } + + if ( + cache && + cache.channel === channel && + isNewer(cache.latest, currentVersion) + ) { + process.stderr.write( + `${formatUpdateNotice(currentVersion, cache.latest, channel)}\n`, + ); + } + } catch { + // Update checks must never affect the command outcome. + } +} diff --git a/src/common/usage.ts b/src/common/usage.ts index 7a446152..eba67bc5 100644 --- a/src/common/usage.ts +++ b/src/common/usage.ts @@ -61,12 +61,13 @@ export function formatDomainUsage(command: Command, meta: DomainMeta): string { const subcommands = command.commands.filter((c) => c.name() !== "usage"); lines.push("commands:"); - const signatures = subcommands.map((c) => formatCommandSignature(c)); - const maxSigLen = Math.max(...signatures.map((s) => s.length)); + const subcommandEntries = subcommands.map((c) => ({ + sig: formatCommandSignature(c), + desc: c.description(), + })); + const maxSigLen = Math.max(...subcommandEntries.map((e) => e.sig.length)); - for (let i = 0; i < subcommands.length; i++) { - const sig = signatures[i]; - const desc = subcommands[i].description(); + for (const { sig, desc } of subcommandEntries) { lines.push(` ${sig.padEnd(maxSigLen + 2)}${desc}`); } @@ -89,15 +90,17 @@ export function formatDomainUsage(command: Command, meta: DomainMeta): string { lines.push(""); lines.push(`${cmd.name()} options:`); - const flags = opts.map((o) => extractLongFlag(o.flags)); - const maxFlagLen = Math.max(...flags.map((f) => f.length)); - - for (let j = 0; j < opts.length; j++) { - const flag = flags[j]; - let desc = opts[j].description; - const defaultVal = opts[j].defaultValue; - if (defaultVal !== undefined && defaultVal !== false) { - desc += ` (default: ${defaultVal})`; + const optionEntries = opts.map((o) => ({ + flag: extractLongFlag(o.flags), + description: o.description, + defaultValue: o.defaultValue, + })); + const maxFlagLen = Math.max(...optionEntries.map((e) => e.flag.length)); + + for (const { flag, description, defaultValue } of optionEntries) { + let desc = description; + if (defaultValue !== undefined && defaultValue !== false) { + desc += ` (default: ${defaultValue})`; } lines.push(` ${flag.padEnd(maxFlagLen + 2)}${desc}`); } diff --git a/src/main.ts b/src/main.ts index fa1360a1..8cd5305c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,6 +27,10 @@ import { import { PROJECTS_META, setupProjectsCommands } from "./commands/projects.js"; import { setupTeamsCommands, TEAMS_META } from "./commands/teams.js"; import { setupUsersCommands, USERS_META } from "./commands/users.js"; +import { setupVersionCommands, VERSION_META } from "./commands/version.js"; +import { getRootOpts } from "./common/context.js"; +import { parseFieldsList, setOutputOptions } from "./common/output.js"; +import { maybeNotifyUpdate } from "./common/update-notifier.js"; import { type DomainMeta, formatDomainUsage, @@ -37,7 +41,18 @@ program .name("linearis") .description("CLI for Linear.app with JSON output") .version(pkg.version) - .option("--api-token <token>", "Linear API token"); + .option("--api-token <token>", "Linear API token") + .option("--compact", "emit single-line JSON (no indentation)") + .option( + "--fields <list>", + "comma-separated dot-paths to include (e.g. identifier,title,state.name)", + parseFieldsList, + ); + +program.hook("preAction", async (_thisCommand, actionCommand) => { + setOutputOptions(getRootOpts(actionCommand)); + await maybeNotifyUpdate(pkg.version); +}); const allMetas: DomainMeta[] = [ AUTH_META, @@ -53,6 +68,7 @@ const allMetas: DomainMeta[] = [ TEAMS_META, USERS_META, INITIATIVES_META, + VERSION_META, ]; program.action(() => console.log(formatOverview(pkg.version, allMetas))); @@ -70,6 +86,7 @@ setupTeamsCommands(program); setupUsersCommands(program); setupInitiativesCommands(program); setupDocumentsCommands(program); +setupVersionCommands(program); program .command("usage") @@ -92,4 +109,4 @@ program } }); -program.parse(); +program.parseAsync(); diff --git a/src/resolvers/batch-resolve-mappers.ts b/src/resolvers/batch-resolve-mappers.ts new file mode 100644 index 00000000..e3e6c757 --- /dev/null +++ b/src/resolvers/batch-resolve-mappers.ts @@ -0,0 +1,173 @@ +import { multipleMatchesError, notFoundError } from "../common/errors.js"; +import { asUuid, isUuid, type UUID } from "../common/identifier.js"; +import type { + BatchResolveForCreateQuery, + IssueLabelFilter, +} from "../gql/graphql.js"; + +/** + * Pure mappers shared by the batch resolvers (issue create/update and issue + * search). Each turns a `BatchResolve*` response collection into resolved + * UUIDs, reproducing the exact disambiguation / not-found / UUID-passthrough + * semantics of the corresponding single-entity resolver. + * + * The three batch queries select identical node shapes, so the aliases below + * (derived from `BatchResolveForCreate`) apply to all of them structurally. + */ + +export type TeamNode = BatchResolveForCreateQuery["teams"]["nodes"][number]; +export type UserNode = BatchResolveForCreateQuery["assignees"]["nodes"][number]; +export type ProjectNode = + BatchResolveForCreateQuery["projects"]["nodes"][number]; +export type MilestoneNode = ProjectNode["projectMilestones"]["nodes"][number]; +export type LabelNode = BatchResolveForCreateQuery["labels"]["nodes"][number]; +export type StatusNode = + BatchResolveForCreateQuery["statuses"]["nodes"][number]; +export type CycleNode = BatchResolveForCreateQuery["cycles"]["nodes"][number]; +export type ParentNode = + BatchResolveForCreateQuery["parentIssues"]["nodes"][number]; + +/** + * Builds an `IssueLabelFilter` matching each name case-insensitively (mirroring + * `resolveLabelId`'s `eqIgnoreCase`). An empty list yields a filter that matches + * nothing, so the batch query's `labels` field stays cheap when no labels are + * requested. + */ +export function buildLabelFilter(names: string[]): IssueLabelFilter { + if (names.length === 0) return { name: { in: [] } }; + return { or: names.map((name) => ({ name: { eqIgnoreCase: name } })) }; +} + +/** + * Two-phase user precedence, replicated purely from the `or:[displayName,email]` + * result: an exact (case-insensitive) display-name match wins; multiple name + * matches are ambiguous; otherwise the first email match is used. Matches + * `resolveUserId`. + */ +export function mapUser(nodes: UserNode[], query: string): UUID { + const lower = query.toLowerCase(); + + const byName = nodes.filter((n) => n.displayName.toLowerCase() === lower); + if (byName.length === 1) return asUuid((byName[0] as UserNode).id); + if (byName.length > 1) { + throw multipleMatchesError( + "User", + query, + byName.map((u) => `${u.name} <${u.email}>`), + "Use email or UUID to disambiguate", + ); + } + + const byEmail = nodes.find((n) => n.email.toLowerCase() === lower); + if (byEmail) return asUuid(byEmail.id); + + throw notFoundError("User", query); +} + +/** Matches `resolveProjectId`: exactly one match, else not-found / ambiguous. */ +export function mapProjectNode( + nodes: ProjectNode[], + query: string, +): ProjectNode { + if (nodes.length === 0) throw notFoundError("Project", query); + if (nodes.length > 1) { + throw multipleMatchesError( + "Project", + query, + nodes.map((project) => project.id), + "provide project UUID", + ); + } + return nodes[0] as ProjectNode; +} + +/** Convenience wrapper: {@link mapProjectNode} returning just the UUID. */ +export function mapProjectId(nodes: ProjectNode[], query: string): UUID { + return asUuid(mapProjectNode(nodes, query).id); +} + +/** Matches `resolveLabelIds`: UUID passthrough, else case-insensitive name → id. */ +export function mapLabels(requested: string[], nodes: LabelNode[]): UUID[] { + return requested.map((label) => { + if (isUuid(label)) return asUuid(label); + const match = nodes.find( + (n) => n.name.toLowerCase() === label.toLowerCase(), + ); + if (!match) throw notFoundError("Label", label); + return asUuid(match.id); + }); +} + +/** Matches `resolveStatusId`: first match, scoped not-found context. */ +export function mapStatus( + nodes: StatusNode[], + query: string, + teamContext: string | undefined, +): UUID { + const first = nodes[0]; + if (!first) throw notFoundError("Status", query, teamContext); + return asUuid(first.id); +} + +/** Matches `resolveCycleId`: prefer active > next > previous, else ambiguous. */ +export function mapCycle( + nodes: CycleNode[], + query: string, + teamLabel: string | undefined, +): UUID { + if (nodes.length === 0) { + throw notFoundError( + "Cycle", + query, + teamLabel ? `for team ${teamLabel}` : undefined, + ); + } + + let chosen = + nodes.find((n) => n.isActive) ?? + nodes.find((n) => n.isNext) ?? + nodes.find((n) => n.isPrevious); + if (!chosen && nodes.length === 1) chosen = nodes[0]; + + if (!chosen) { + const matches = nodes.map( + (n) => + `${n.id} (${n.team?.key || "?"} / #${n.number} / ${ + n.startsAt ? new Date(n.startsAt).toISOString() : undefined + })`, + ); + throw multipleMatchesError( + "cycle", + query, + matches, + "use an ID or scope with --team", + ); + } + + return asUuid(chosen.id); +} + +/** Matches `resolveMilestoneId` scoped to a single project's milestones. */ +export function mapMilestone( + nodes: MilestoneNode[], + query: string, + projectName: string | undefined, +): UUID { + if (nodes.length === 0) throw notFoundError("Milestone", query); + if (nodes.length > 1) { + throw multipleMatchesError( + "milestone", + query, + nodes.map((m) => `"${m.name}" in project "${projectName ?? "?"}"`), + "specify --project or use the milestone ID", + ); + } + return asUuid((nodes[0] as MilestoneNode).id); +} + +/** Matches `resolveIssueId`: first match or not-found (UUID handled by caller). */ +export function mapParent(nodes: ParentNode[], query: string): UUID { + const first = nodes[0]; + if (!first) throw notFoundError("Issue", query); + return asUuid(first.id); +} diff --git a/src/resolvers/cycle-resolver.ts b/src/resolvers/cycle-resolver.ts index 9f8048cb..7a51a254 100644 --- a/src/resolvers/cycle-resolver.ts +++ b/src/resolvers/cycle-resolver.ts @@ -1,7 +1,10 @@ -import type { LinearDocument } from "@linear/sdk"; -import type { LinearSdkClient } from "../client/linear-client.js"; +import type { GraphQLClient } from "../client/graphql-client.js"; import { multipleMatchesError, notFoundError } from "../common/errors.js"; -import { isUuid } from "../common/identifier.js"; +import { asUuid, isUuid, type UUID } from "../common/identifier.js"; +import { + FindCycleGlobalDocument, + FindCycleScopedDocument, +} from "../gql/graphql.js"; import { resolveTeamId } from "./team-resolver.js"; /** @@ -10,59 +13,40 @@ import { resolveTeamId } from "./team-resolver.js"; * Accepts UUID or cycle name. When multiple cycles match a name, * prefers active > next > previous. Use teamFilter to disambiguate. * - * @param client - Linear SDK client + * @param client - GraphQL client * @param nameOrId - Cycle name or UUID * @param teamFilter - Optional team key/name/ID to scope search * @returns Cycle UUID * @throws Error if not found or multiple matches without clear preference */ export async function resolveCycleId( - client: LinearSdkClient, + client: GraphQLClient, nameOrId: string, teamFilter?: string, -): Promise<string> { - if (isUuid(nameOrId)) return nameOrId; +): Promise<UUID> { + if (isUuid(nameOrId)) return asUuid(nameOrId); - const filter: LinearDocument.CycleFilter = { - name: { eq: nameOrId }, - }; + const matched = teamFilter + ? ( + await client.request(FindCycleScopedDocument, { + name: nameOrId, + teamId: await resolveTeamId(client, teamFilter), + }) + ).cycles.nodes + : (await client.request(FindCycleGlobalDocument, { name: nameOrId })).cycles + .nodes; - if (teamFilter) { - const teamId = await resolveTeamId(client, teamFilter); - filter.team = { id: { eq: teamId } }; - } - - const cyclesConnection = await client.sdk.cycles({ - filter, - first: 10, - }); - - const nodes: Array<{ - id: string; - name: string; - number: number; - startsAt?: string; - isActive: boolean; - isNext: boolean; - isPrevious: boolean; - team?: { id: string; key: string; name: string }; - }> = []; - - for (const cycle of cyclesConnection.nodes) { - const team = await cycle.team; - nodes.push({ - id: cycle.id, - name: cycle.name ?? "", - number: cycle.number, - startsAt: cycle.startsAt - ? new Date(cycle.startsAt).toISOString() - : undefined, - isActive: cycle.isActive, - isNext: cycle.isNext, - isPrevious: cycle.isPrevious, - team: team ? { id: team.id, key: team.key, name: team.name } : undefined, - }); - } + const nodes = matched.map((cycle) => ({ + id: cycle.id, + number: cycle.number, + isActive: cycle.isActive, + isNext: cycle.isNext, + isPrevious: cycle.isPrevious, + ...(cycle.startsAt + ? { startsAt: new Date(cycle.startsAt).toISOString() } + : {}), + ...(cycle.team ? { team: { key: cycle.team.key } } : {}), + })); if (nodes.length === 0) { throw notFoundError( @@ -90,5 +74,5 @@ export async function resolveCycleId( ); } - return chosen.id; + return asUuid(chosen.id); } diff --git a/src/resolvers/initiative-resolver.ts b/src/resolvers/initiative-resolver.ts index ed1d79f7..f0c4a241 100644 --- a/src/resolvers/initiative-resolver.ts +++ b/src/resolvers/initiative-resolver.ts @@ -1,56 +1,66 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { LinearSdkClient } from "../client/linear-client.js"; +import { firstOrThrow } from "../common/array.js"; import { multipleMatchesError, notFoundError } from "../common/errors.js"; -import { isUuid } from "../common/identifier.js"; +import { asUuid, isUuid, type UUID } from "../common/identifier.js"; import { FindInitiativeProjectLinkByPairDocument, - type FindInitiativeProjectLinkByPairQuery, FindInitiativeRelationByPairDocument, - type FindInitiativeRelationByPairQuery, + FindInitiativesDocument, + type InitiativeFilter, } from "../gql/graphql.js"; export interface InitiativeResolveScope { - teamId?: string; - ownerId?: string; + teamId?: UUID; + ownerId?: UUID; } export async function resolveInitiativeId( - client: LinearSdkClient, + client: GraphQLClient, nameOrId: string, scope: InitiativeResolveScope = {}, -): Promise<string> { +): Promise<UUID> { if (isUuid(nameOrId)) { - return nameOrId; + return asUuid(nameOrId); } - const clauses: Array<Record<string, unknown>> = [ - { name: { eqIgnoreCase: nameOrId } }, - ]; + const nameClause: InitiativeFilter = { + name: { eqIgnoreCase: nameOrId }, + }; + const scopeClauses: InitiativeFilter[] = []; if (scope.teamId) { - clauses.push({ teams: { some: { id: { eq: scope.teamId } } } }); + scopeClauses.push({ teams: { some: { id: { eq: scope.teamId } } } }); } if (scope.ownerId) { - clauses.push({ owner: { id: { eq: scope.ownerId } } }); + scopeClauses.push({ owner: { id: { eq: scope.ownerId } } }); } - const filter = clauses.length === 1 ? clauses[0] : { and: clauses }; + const filter: InitiativeFilter = + scopeClauses.length === 0 + ? nameClause + : { and: [nameClause, ...scopeClauses] }; - const result = await client.sdk.initiatives({ + const { initiatives } = await client.request(FindInitiativesDocument, { filter, first: 20, }); - if (result.nodes.length === 0) { + if (initiatives.nodes.length === 0) { throw notFoundError("Initiative", nameOrId); } - if (result.nodes.length === 1) { - return result.nodes[0].id; + if (initiatives.nodes.length === 1) { + return asUuid( + firstOrThrow(initiatives.nodes, () => + notFoundError("Initiative", nameOrId), + ).id, + ); } - const candidates = result.nodes.map((node) => `${node.name} (${node.id})`); + const candidates = initiatives.nodes.map( + (node) => `${node.name} (${node.id})`, + ); throw multipleMatchesError( "initiative", @@ -64,16 +74,17 @@ export async function resolveInitiativeId( export async function resolveInitiativeRelationId( client: GraphQLClient, - parentId: string, - childId: string, -): Promise<string> { + parentId: UUID, + childId: UUID, +): Promise<UUID> { let after: string | undefined; while (true) { - const result = await client.request<FindInitiativeRelationByPairQuery>( - FindInitiativeRelationByPairDocument, - { parentId, childId, after }, - ); + const result = await client.request(FindInitiativeRelationByPairDocument, { + parentId, + childId, + after, + }); const relation = result.initiativeRelations.nodes.find( (node) => @@ -82,7 +93,7 @@ export async function resolveInitiativeRelationId( ); if (relation) { - return relation.id; + return asUuid(relation.id); } if (!result.initiativeRelations.pageInfo.hasNextPage) { @@ -100,13 +111,13 @@ export async function resolveInitiativeRelationId( export async function resolveInitiativeProjectLinkId( client: GraphQLClient, - initiativeId: string, - projectId: string, -): Promise<string> { + initiativeId: UUID, + projectId: UUID, +): Promise<UUID> { let after: string | undefined; while (true) { - const result = await client.request<FindInitiativeProjectLinkByPairQuery>( + const result = await client.request( FindInitiativeProjectLinkByPairDocument, { initiativeId, projectId, after }, ); @@ -117,7 +128,7 @@ export async function resolveInitiativeProjectLinkId( ); if (link) { - return link.id; + return asUuid(link.id); } if (!result.initiativeToProjects.pageInfo.hasNextPage) { diff --git a/src/resolvers/issue-filter-resolver.ts b/src/resolvers/issue-filter-resolver.ts index 8fdf3164..df7f1e7c 100644 --- a/src/resolvers/issue-filter-resolver.ts +++ b/src/resolvers/issue-filter-resolver.ts @@ -1,11 +1,22 @@ -import type { LinearSdkClient } from "../client/linear-client.js"; +import type { GraphQLClient } from "../client/graphql-client.js"; +import { notFoundError } from "../common/errors.js"; +import { + asUuid, + isUuid, + parseIssueIdentifier, + type UUID, +} from "../common/identifier.js"; +import { BatchResolveForSearchDocument } from "../gql/graphql.js"; +import { + buildLabelFilter, + mapCycle, + mapLabels, + mapParent, + mapProjectId, + mapUser, +} from "./batch-resolve-mappers.js"; import { resolveCycleId } from "./cycle-resolver.js"; -import { resolveIssueId } from "./issue-resolver.js"; -import { resolveLabelIds } from "./label-resolver.js"; -import { resolveProjectId } from "./project-resolver.js"; import { resolveStatusId } from "./status-resolver.js"; -import { resolveTeamId } from "./team-resolver.js"; -import { resolveUserId } from "./user-resolver.js"; export interface SearchFilterResolutionInput { team?: string; @@ -19,61 +30,146 @@ export interface SearchFilterResolutionInput { } export interface SearchFilterResolution { - teamId?: string; - assigneeId?: string; - creatorId?: string; - projectId?: string; - stateIds?: string[]; - labelIds?: string[]; - cycleId?: string; - parentId?: string; + teamId?: UUID; + assigneeId?: UUID; + creatorId?: UUID; + projectId?: UUID; + stateIds?: UUID[]; + labelIds?: UUID[]; + cycleId?: UUID; + parentId?: UUID; } +/** + * Resolves every human identifier in a search filter in a single + * `BatchResolveForSearch` request, preserving the exact semantics of the + * individual resolvers via the shared batch mappers. + * + * Statuses and cycles are the exception: the batch query scopes both to the + * team (matched client-side by name). When no team is given — or the name is + * not among the returned nodes — resolution falls back to `resolveStatusId` / + * `resolveCycleId`, which match globally, so filtering by status or cycle name + * without a team keeps working. + */ export async function resolveSearchFilterIds( - sdkClient: LinearSdkClient, + gqlClient: GraphQLClient, input: SearchFilterResolutionInput, ): Promise<SearchFilterResolution> { + const team = input.team; + const teamIsUuid = team ? isUuid(team) : false; + + const assigneeQuery = + input.assignee && !isUuid(input.assignee) ? input.assignee : null; + const creatorQuery = + input.creator && !isUuid(input.creator) ? input.creator : null; + const projectName = + input.project && !isUuid(input.project) ? input.project : null; + const projectIdVar = + input.project && isUuid(input.project) ? input.project : null; + const cycleName = input.cycle && !isUuid(input.cycle) ? input.cycle : null; + const labelNames = (input.labelNames ?? []).filter((l) => !isUuid(l)); + + const parent = + input.parent && !isUuid(input.parent) + ? parseIssueIdentifier(input.parent) + : null; + + const response = await gqlClient.request(BatchResolveForSearchDocument, { + teamKey: team && !teamIsUuid ? team : null, + teamName: team && !teamIsUuid ? team : null, + teamId: team && teamIsUuid ? team : null, + assigneeQuery, + creatorQuery, + projectName, + projectId: projectIdVar, + labelFilter: buildLabelFilter(labelNames), + cycleName, + parentTeamKey: parent?.teamKey ?? null, + parentIssueNumber: parent?.issueNumber ?? null, + milestoneName: null, + }); + const resolved: SearchFilterResolution = {}; - if (input.team) { - resolved.teamId = await resolveTeamId(sdkClient, input.team); + if (team) { + resolved.teamId = teamIsUuid + ? asUuid(team) + : mapSearchTeamId(response.teams.nodes, team); } if (input.assignee) { - resolved.assigneeId = await resolveUserId(sdkClient, input.assignee); + resolved.assigneeId = isUuid(input.assignee) + ? asUuid(input.assignee) + : mapUser(response.assignees.nodes, input.assignee); } if (input.creator) { - resolved.creatorId = await resolveUserId(sdkClient, input.creator); + resolved.creatorId = isUuid(input.creator) + ? asUuid(input.creator) + : mapUser(response.creators.nodes, input.creator); } if (input.project) { - resolved.projectId = await resolveProjectId(sdkClient, input.project); + resolved.projectId = isUuid(input.project) + ? asUuid(input.project) + : mapProjectId(response.projects.nodes, input.project); } if (input.statusNames && input.statusNames.length > 0) { resolved.stateIds = await Promise.all( - input.statusNames.map((status) => - resolveStatusId(sdkClient, status, resolved.teamId), - ), + input.statusNames.map((name) => { + if (isUuid(name)) return asUuid(name); + const match = response.statuses.nodes.find( + (n) => n.name.toLowerCase() === name.toLowerCase(), + ); + if (match) return asUuid(match.id); + // Not among the scoped team's states (or no team given) — resolve + // individually to match globally, as resolveStatusId did before. + return resolveStatusId(gqlClient, name, resolved.teamId); + }), ); } if (input.labelNames && input.labelNames.length > 0) { - resolved.labelIds = await resolveLabelIds(sdkClient, input.labelNames); + resolved.labelIds = mapLabels(input.labelNames, response.labels.nodes); } if (input.cycle) { - resolved.cycleId = await resolveCycleId( - sdkClient, - input.cycle, - resolved.teamId ?? input.team, - ); + if (isUuid(input.cycle)) { + resolved.cycleId = asUuid(input.cycle); + } else if (response.cycles.nodes.length > 0) { + resolved.cycleId = mapCycle( + response.cycles.nodes, + input.cycle, + resolved.teamId ?? input.team, + ); + } else { + // No cycles in the batch response (the query scopes them to a team, so + // this is the no-team case) — resolve individually to match globally, as + // resolveCycleId did before. + resolved.cycleId = await resolveCycleId( + gqlClient, + input.cycle, + resolved.teamId ?? input.team, + ); + } } if (input.parent) { - resolved.parentId = await resolveIssueId(sdkClient, input.parent); + resolved.parentId = isUuid(input.parent) + ? asUuid(input.parent) + : mapParent(response.parentIssues.nodes, input.parent); } return resolved; } + +type SearchTeamNode = { id: string; key: string; name: string }; + +/** Mirrors resolveTeamId: prefer key match, then name; else not-found. */ +function mapSearchTeamId(nodes: SearchTeamNode[], raw: string): UUID { + const match = + nodes.find((n) => n.key === raw) ?? nodes.find((n) => n.name === raw); + if (!match) throw notFoundError("Team", raw); + return asUuid(match.id); +} diff --git a/src/resolvers/issue-mutation-resolver.ts b/src/resolvers/issue-mutation-resolver.ts new file mode 100644 index 00000000..d1daaa10 --- /dev/null +++ b/src/resolvers/issue-mutation-resolver.ts @@ -0,0 +1,370 @@ +import type { GraphQLClient } from "../client/graphql-client.js"; +import { notFoundError } from "../common/errors.js"; +import { + asUuid, + isUuid, + parseIssueIdentifier, + type UUID, +} from "../common/identifier.js"; +import { + BatchResolveForCreateDocument, + BatchResolveForUpdateDocument, +} from "../gql/graphql.js"; +import { + buildLabelFilter, + mapCycle, + mapLabels, + mapMilestone, + mapParent, + mapProjectNode, + mapStatus, + mapUser, + type ProjectNode, + type TeamNode, +} from "./batch-resolve-mappers.js"; +import type { TeamEstimateContext } from "./team-resolver.js"; + +/** + * Batch resolver for issue create / update. + * + * Replaces the per-field sequential resolver calls (`resolveTeamId`, + * `resolveUserId`, `resolveProjectId`, …) with a single `BatchResolve*` + * GraphQL request, then maps the response back to UUIDs while preserving the + * exact disambiguation, not-found and UUID-passthrough semantics of each + * individual resolver. + * + * Create resolves everything in one request. Update inherently needs two + * sequential requests — the target issue must be fetched first (its team / + * project scope the status / cycle / milestone lookups) — the caller supplies + * that context via {@link UpdateIssueContext}. + */ + +const TEAM_ESTIMATION_TYPES = [ + "notUsed", + "exponential", + "fibonacci", + "linear", + "tShirt", +] as const; + +type TeamEstimationType = (typeof TEAM_ESTIMATION_TYPES)[number]; + +function narrowEstimationType( + value: string, + teamLabel: string, +): TeamEstimationType { + if ((TEAM_ESTIMATION_TYPES as readonly string[]).includes(value)) { + return value as TeamEstimationType; + } + throw new Error(`Team "${teamLabel}" is missing required estimation context`); +} + +// --- Create ----------------------------------------------------------------- + +export interface ResolveCreateIssueIdsInput { + /** Team key, name or UUID. Required for create. */ + team: string; + assignee?: string; + project?: string; + labels?: string[]; + /** Milestone name or UUID; requires {@link ResolveCreateIssueIdsInput.project}. */ + projectMilestone?: string; + cycle?: string; + status?: string; + parentTicket?: string; + /** When true, resolve the team's estimation config for `--estimate` validation. */ + withEstimateContext?: boolean; +} + +export interface ResolvedCreateIssueIds { + teamId: UUID; + estimateContext?: TeamEstimateContext; + assigneeId?: UUID; + projectId?: UUID; + labelIds?: UUID[]; + projectMilestoneId?: UUID; + cycleId?: UUID; + stateId?: UUID; + parentId?: UUID; +} + +/** + * Resolves every human identifier needed to create an issue in a single + * `BatchResolveForCreate` request. + */ +export async function resolveCreateIssueIds( + client: GraphQLClient, + input: ResolveCreateIssueIdsInput, +): Promise<ResolvedCreateIssueIds> { + const teamIsUuid = isUuid(input.team); + const assigneeQuery = + input.assignee && !isUuid(input.assignee) ? input.assignee : null; + const projectName = + input.project && !isUuid(input.project) ? input.project : null; + const projectIdVar = + input.project && isUuid(input.project) ? input.project : null; + const milestoneName = + input.projectMilestone && !isUuid(input.projectMilestone) + ? input.projectMilestone + : null; + const statusName = + input.status && !isUuid(input.status) ? input.status : null; + const cycleName = input.cycle && !isUuid(input.cycle) ? input.cycle : null; + const labelNames = (input.labels ?? []).filter((l) => !isUuid(l)); + + const parent = + input.parentTicket && !isUuid(input.parentTicket) + ? parseIssueIdentifier(input.parentTicket) + : null; + + const response = await client.request(BatchResolveForCreateDocument, { + teamKey: teamIsUuid ? null : input.team, + teamName: teamIsUuid ? null : input.team, + teamId: teamIsUuid ? input.team : null, + assigneeQuery, + projectName, + projectId: projectIdVar, + labelFilter: buildLabelFilter(labelNames), + statusName, + cycleName, + milestoneName, + parentTeamKey: parent?.teamKey ?? null, + parentIssueNumber: parent?.issueNumber ?? null, + }); + + // Team (required). Prefer key match, then name, then id — mirrors resolveTeamId. + const teamNode = teamIsUuid + ? response.teams.nodes.find((n) => n.id === input.team) + : findTeamNode(response.teams.nodes, input.team); + const teamId: UUID = + teamIsUuid && !teamNode + ? asUuid(input.team) + : asUuid(requireTeam(teamNode, input.team).id); + + const resolved: ResolvedCreateIssueIds = { teamId }; + + if (input.withEstimateContext) { + const node = requireTeam(teamNode, input.team); + resolved.estimateContext = { + teamId: asUuid(node.id), + teamKey: node.key, + teamName: node.name, + issueEstimationType: narrowEstimationType( + node.issueEstimationType, + input.team, + ), + issueEstimationExtended: node.issueEstimationExtended, + issueEstimationAllowZero: node.issueEstimationAllowZero, + }; + } + + if (input.assignee) { + resolved.assigneeId = isUuid(input.assignee) + ? asUuid(input.assignee) + : mapUser(response.assignees.nodes, input.assignee); + } + + let matchedProject: ProjectNode | undefined; + if (input.project) { + if (isUuid(input.project)) { + resolved.projectId = asUuid(input.project); + matchedProject = response.projects.nodes.find( + (n) => n.id === input.project, + ); + } else { + matchedProject = mapProjectNode(response.projects.nodes, input.project); + resolved.projectId = asUuid(matchedProject.id); + } + } + + if (input.labels && input.labels.length > 0) { + resolved.labelIds = mapLabels(input.labels, response.labels.nodes); + } + + if (input.projectMilestone) { + resolved.projectMilestoneId = isUuid(input.projectMilestone) + ? asUuid(input.projectMilestone) + : mapMilestone( + matchedProject?.projectMilestones.nodes ?? [], + input.projectMilestone, + matchedProject?.name, + ); + } + + if (input.cycle) { + resolved.cycleId = isUuid(input.cycle) + ? asUuid(input.cycle) + : mapCycle(response.cycles.nodes, input.cycle, input.team); + } + + if (input.status) { + resolved.stateId = isUuid(input.status) + ? asUuid(input.status) + : mapStatus(response.statuses.nodes, input.status, `for team ${teamId}`); + } + + if (input.parentTicket) { + resolved.parentId = isUuid(input.parentTicket) + ? asUuid(input.parentTicket) + : mapParent(response.parentIssues.nodes, input.parentTicket); + } + + return resolved; +} + +function findTeamNode(nodes: TeamNode[], raw: string): TeamNode | undefined { + return nodes.find((n) => n.key === raw) ?? nodes.find((n) => n.name === raw); +} + +function requireTeam(node: TeamNode | undefined, raw: string): TeamNode { + if (!node) throw notFoundError("Team", raw); + return node; +} + +// --- Update ----------------------------------------------------------------- + +/** Context derived from the target issue (already fetched) that scopes lookups. */ +export interface UpdateIssueContext { + /** The issue's team UUID — scopes status / cycle resolution. */ + teamId?: UUID; + /** The issue's team key — used in cycle not-found messages. */ + teamKey?: string; + /** The issue's current project name — scopes milestone resolution. */ + projectName?: string; +} + +export interface ResolveUpdateIssueIdsInput { + assignee?: string; + project?: string; + labels?: string[]; + projectMilestone?: string; + cycle?: string; + status?: string; + parentTicket?: string; +} + +export interface ResolvedUpdateIssueIds { + assigneeId?: UUID; + projectId?: UUID; + /** Resolved label UUIDs (add/remove/overwrite set math stays in the command). */ + labelIds?: UUID[]; + projectMilestoneId?: UUID; + cycleId?: UUID; + stateId?: UUID; + parentId?: UUID; +} + +/** + * Resolves the new values for an issue update in a single + * `BatchResolveForUpdate` request. Status / cycle / milestone are scoped by the + * target issue's own team / project supplied in {@link UpdateIssueContext}. + * + * When both `--project` and `--project-milestone` are given, the milestone is + * resolved within the *new* project (an intentional improvement over the prior + * behavior, which scoped it to the issue's old project). + */ +export async function resolveUpdateIssueIds( + client: GraphQLClient, + input: ResolveUpdateIssueIdsInput, + context: UpdateIssueContext, +): Promise<ResolvedUpdateIssueIds> { + const assigneeQuery = + input.assignee && !isUuid(input.assignee) ? input.assignee : null; + + // projectName scopes both --project resolution and milestone lookup: the new + // project when --project is a name, else the issue's current project. + const projectNameVar = + input.project && !isUuid(input.project) + ? input.project + : (context.projectName ?? null); + const projectIdVar = + input.project && isUuid(input.project) ? input.project : null; + + const milestoneName = + input.projectMilestone && !isUuid(input.projectMilestone) + ? input.projectMilestone + : null; + const statusName = + input.status && !isUuid(input.status) ? input.status : null; + const cycleName = input.cycle && !isUuid(input.cycle) ? input.cycle : null; + const labelNames = (input.labels ?? []).filter((l) => !isUuid(l)); + + const parent = + input.parentTicket && !isUuid(input.parentTicket) + ? parseIssueIdentifier(input.parentTicket) + : null; + + const response = await client.request(BatchResolveForUpdateDocument, { + assigneeQuery, + projectName: projectNameVar, + projectId: projectIdVar, + labelFilter: buildLabelFilter(labelNames), + statusName, + cycleName, + teamKey: context.teamKey ?? null, + teamId: context.teamId ?? null, + milestoneName, + parentTeamKey: parent?.teamKey ?? null, + parentIssueNumber: parent?.issueNumber ?? null, + }); + + const resolved: ResolvedUpdateIssueIds = {}; + + if (input.assignee) { + resolved.assigneeId = isUuid(input.assignee) + ? asUuid(input.assignee) + : mapUser(response.assignees.nodes, input.assignee); + } + + // The projects field matches by projectNameVar/projectIdVar; the matched node + // is reused for milestone scoping. + const matchedProject: ProjectNode | undefined = isUuid(input.project ?? "") + ? response.projects.nodes.find((n) => n.id === input.project) + : response.projects.nodes.find( + (n) => n.name.toLowerCase() === (projectNameVar ?? "").toLowerCase(), + ); + + if (input.project) { + resolved.projectId = isUuid(input.project) + ? asUuid(input.project) + : asUuid(mapProjectNode(response.projects.nodes, input.project).id); + } + + if (input.labels && input.labels.length > 0) { + resolved.labelIds = mapLabels(input.labels, response.labels.nodes); + } + + if (input.projectMilestone) { + resolved.projectMilestoneId = isUuid(input.projectMilestone) + ? asUuid(input.projectMilestone) + : mapMilestone( + matchedProject?.projectMilestones.nodes ?? [], + input.projectMilestone, + matchedProject?.name ?? projectNameVar ?? undefined, + ); + } + + if (input.cycle) { + resolved.cycleId = isUuid(input.cycle) + ? asUuid(input.cycle) + : mapCycle(response.cycles.nodes, input.cycle, context.teamKey); + } + + if (input.status) { + resolved.stateId = isUuid(input.status) + ? asUuid(input.status) + : mapStatus( + response.statuses.nodes, + input.status, + context.teamId ? `for team ${context.teamId}` : undefined, + ); + } + + if (input.parentTicket) { + resolved.parentId = isUuid(input.parentTicket) + ? asUuid(input.parentTicket) + : mapParent(response.parentIssues.nodes, input.parentTicket); + } + + return resolved; +} diff --git a/src/resolvers/issue-resolver.ts b/src/resolvers/issue-resolver.ts index 07d6e8c8..aceacb1b 100644 --- a/src/resolvers/issue-resolver.ts +++ b/src/resolvers/issue-resolver.ts @@ -1,46 +1,33 @@ -import type { LinearSdkClient } from "../client/linear-client.js"; +import type { GraphQLClient } from "../client/graphql-client.js"; +import { firstOrThrow } from "../common/array.js"; import { notFoundError } from "../common/errors.js"; -import { isUuid, parseIssueIdentifier } from "../common/identifier.js"; +import { + asUuid, + isUuid, + parseIssueIdentifier, + type UUID, +} from "../common/identifier.js"; +import { FindIssuesDocument, type IssueFilter } from "../gql/graphql.js"; import { resolveTeamEstimateContext, type TeamEstimateContext, } from "./team-resolver.js"; -function isRecord(value: unknown): value is Record<string, unknown> { - return typeof value === "object" && value !== null; -} - -function isPromiseLike(value: unknown): value is PromiseLike<unknown> { - if ((typeof value !== "object" && typeof value !== "function") || !value) { - return false; +/** Builds the FindIssues filter for a UUID or "TEAM-123" identifier. */ +function issueLookupFilter(issueIdOrIdentifier: string): IssueFilter { + if (isUuid(issueIdOrIdentifier)) { + return { id: { eq: issueIdOrIdentifier } }; } - return typeof (value as { then?: unknown }).then === "function"; -} - -async function resolveRelationValue(value: unknown): Promise<unknown> { - return isPromiseLike(value) ? await value : value; -} - -function getTeamLookupFromRelation(team: unknown): string | undefined { - if (!isRecord(team)) return undefined; - - if (typeof team.id === "string") return team.id; - if (typeof team.key === "string") return team.key; - - return undefined; -} - -async function getIssueTeamLookup( - node: Record<string, unknown>, -): Promise<string | undefined> { - if (typeof node.teamId === "string") return node.teamId; - - return getTeamLookupFromRelation(await resolveRelationValue(node.team)); + const { teamKey, issueNumber } = parseIssueIdentifier(issueIdOrIdentifier); + return { + number: { eq: issueNumber }, + team: { key: { eq: teamKey } }, + }; } export interface IssueEstimateContext { - issueId: string; + issueId: UUID; team: TeamEstimateContext; } @@ -49,77 +36,44 @@ export interface IssueEstimateContext { * * Accepts UUID or issue identifier (e.g., "ENG-123"). * - * @param client - Linear SDK client + * @param client - GraphQL client * @param issueIdOrIdentifier - Issue UUID or identifier * @returns Issue UUID * @throws Error if issue not found */ export async function resolveIssueId( - client: LinearSdkClient, + client: GraphQLClient, issueIdOrIdentifier: string, -): Promise<string> { - if (isUuid(issueIdOrIdentifier)) return issueIdOrIdentifier; - - const { teamKey, issueNumber } = parseIssueIdentifier(issueIdOrIdentifier); +): Promise<UUID> { + if (isUuid(issueIdOrIdentifier)) return asUuid(issueIdOrIdentifier); - const issues = await client.sdk.issues({ - filter: { - number: { eq: issueNumber }, - team: { key: { eq: teamKey } }, - }, + const { issues } = await client.request(FindIssuesDocument, { + filter: issueLookupFilter(issueIdOrIdentifier), first: 1, }); - if (issues.nodes.length === 0) { - throw notFoundError("Issue", issueIdOrIdentifier); - } - - return issues.nodes[0].id; + return asUuid( + firstOrThrow(issues.nodes, () => + notFoundError("Issue", issueIdOrIdentifier), + ).id, + ); } export async function resolveIssueEstimateContext( - client: LinearSdkClient, + client: GraphQLClient, issueIdOrIdentifier: string, ): Promise<IssueEstimateContext> { - const issueIsUuid = isUuid(issueIdOrIdentifier); - const issues = await (issueIsUuid - ? client.sdk.issues({ - filter: { id: { eq: issueIdOrIdentifier } }, - first: 1, - }) - : (() => { - const { teamKey, issueNumber } = - parseIssueIdentifier(issueIdOrIdentifier); - - return client.sdk.issues({ - filter: { - number: { eq: issueNumber }, - team: { key: { eq: teamKey } }, - }, - first: 1, - }); - })()); - - if (issues.nodes.length === 0) { - throw notFoundError("Issue", issueIdOrIdentifier); - } - - const issueNode = issues.nodes[0]; - if (!isRecord(issueNode) || typeof issueNode.id !== "string") { - throw new Error( - `Issue "${issueIdOrIdentifier}" is missing required team context`, - ); - } + const { issues } = await client.request(FindIssuesDocument, { + filter: issueLookupFilter(issueIdOrIdentifier), + first: 1, + }); - const teamLookup = await getIssueTeamLookup(issueNode); - if (!teamLookup) { - throw new Error( - `Issue "${issueIdOrIdentifier}" is missing required team context`, - ); - } + const node = firstOrThrow(issues.nodes, () => + notFoundError("Issue", issueIdOrIdentifier), + ); return { - issueId: issueNode.id, - team: await resolveTeamEstimateContext(client, teamLookup), + issueId: asUuid(node.id), + team: await resolveTeamEstimateContext(client, node.team.id), }; } diff --git a/src/resolvers/label-resolver.ts b/src/resolvers/label-resolver.ts index 5fd3b609..33cd9b1e 100644 --- a/src/resolvers/label-resolver.ts +++ b/src/resolvers/label-resolver.ts @@ -1,28 +1,67 @@ -import type { LinearSdkClient } from "../client/linear-client.js"; +import type { GraphQLClient } from "../client/graphql-client.js"; +import { firstOrThrow } from "../common/array.js"; import { notFoundError } from "../common/errors.js"; -import { isUuid } from "../common/identifier.js"; +import { asUuid, isUuid, type UUID } from "../common/identifier.js"; +import { + FindIssueLabelsDocument, + type IssueLabelFilter, +} from "../gql/graphql.js"; + +export type LabelResolverScope = "workspace" | "team"; + +export interface ResolveLabelOptions { + teamId?: string; + scope?: LabelResolverScope; +} + +function buildLabelFilter( + nameOrId: string, + options: ResolveLabelOptions, +): IssueLabelFilter { + if (options.scope === "workspace") { + return { + name: { eqIgnoreCase: nameOrId }, + team: { null: true }, + }; + } + + if (options.scope === "team" && options.teamId) { + return { + name: { eqIgnoreCase: nameOrId }, + team: { id: { eq: options.teamId }, null: false }, + }; + } + + if (options.teamId) { + return { + name: { eqIgnoreCase: nameOrId }, + team: { id: { eq: options.teamId } }, + }; + } + + return { name: { eqIgnoreCase: nameOrId } }; +} export async function resolveLabelId( - client: LinearSdkClient, + client: GraphQLClient, nameOrId: string, -): Promise<string> { - if (isUuid(nameOrId)) return nameOrId; + options: ResolveLabelOptions = {}, +): Promise<UUID> { + if (isUuid(nameOrId)) return asUuid(nameOrId); - const result = await client.sdk.issueLabels({ - filter: { name: { eqIgnoreCase: nameOrId } }, + const { issueLabels } = await client.request(FindIssueLabelsDocument, { + filter: buildLabelFilter(nameOrId, options), first: 1, }); - if (result.nodes.length === 0) { - throw notFoundError("Label", nameOrId); - } - - return result.nodes[0].id; + return asUuid( + firstOrThrow(issueLabels.nodes, () => notFoundError("Label", nameOrId)).id, + ); } export async function resolveLabelIds( - client: LinearSdkClient, + client: GraphQLClient, namesOrIds: string[], -): Promise<string[]> { +): Promise<UUID[]> { return Promise.all(namesOrIds.map((id) => resolveLabelId(client, id))); } diff --git a/src/resolvers/milestone-resolver.ts b/src/resolvers/milestone-resolver.ts index e9d9367e..686d98c9 100644 --- a/src/resolvers/milestone-resolver.ts +++ b/src/resolvers/milestone-resolver.ts @@ -1,12 +1,10 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { LinearSdkClient } from "../client/linear-client.js"; +import { firstOrThrow } from "../common/array.js"; import { multipleMatchesError, notFoundError } from "../common/errors.js"; -import { isUuid } from "../common/identifier.js"; +import { asUuid, isUuid, type UUID } from "../common/identifier.js"; import { FindProjectMilestoneGlobalDocument, - type FindProjectMilestoneGlobalQuery, FindProjectMilestoneScopedDocument, - type FindProjectMilestoneScopedQuery, } from "../gql/graphql.js"; import { resolveProjectId } from "./project-resolver.js"; @@ -16,14 +14,12 @@ import { resolveProjectId } from "./project-resolver.js"; * Accepts UUID or milestone name. When multiple milestones match a name, * use projectNameOrId to scope the search to a specific project. * - * ARCHITECTURAL EXCEPTION: This resolver uses GraphQLClient in addition to - * LinearSdkClient because the Linear SDK does not expose milestone lookup - * by name. The GraphQL client is needed for the FindProjectMilestoneScoped - * and FindProjectMilestoneGlobal queries. This is a documented deviation - * from the standard resolver contract (resolvers normally use SDK only). + * ARCHITECTURAL EXCEPTION: This resolver queries milestones directly via + * GraphQL (FindProjectMilestoneScoped / FindProjectMilestoneGlobal) because + * the Linear API exposes no lean lookup fragment for milestones by name. All + * lookups go through the single GraphQL client. * - * @param gqlClient - GraphQL client for querying milestones - * @param sdkClient - SDK client for project resolution + * @param gqlClient - GraphQL client for querying milestones and projects * @param nameOrId - Milestone name or UUID * @param projectNameOrId - Optional project name/ID to scope search * @returns Milestone UUID @@ -31,11 +27,10 @@ import { resolveProjectId } from "./project-resolver.js"; */ export async function resolveMilestoneId( gqlClient: GraphQLClient, - sdkClient: LinearSdkClient, nameOrId: string, projectNameOrId?: string, -): Promise<string> { - if (isUuid(nameOrId)) return nameOrId; +): Promise<UUID> { + if (isUuid(nameOrId)) return asUuid(nameOrId); type MilestoneNode = { id: string; @@ -45,21 +40,20 @@ export async function resolveMilestoneId( let nodes: MilestoneNode[] = []; if (projectNameOrId) { - const projectId = await resolveProjectId(sdkClient, projectNameOrId); - const result = await gqlClient.request<FindProjectMilestoneScopedQuery>( - FindProjectMilestoneScopedDocument, - { name: nameOrId, projectId }, - ); + const projectId = await resolveProjectId(gqlClient, projectNameOrId); + const result = await gqlClient.request(FindProjectMilestoneScopedDocument, { + name: nameOrId, + projectId, + }); nodes = (result.project?.projectMilestones?.nodes as MilestoneNode[]) || []; } // Fall back to global search if no project scope or not found if (nodes.length === 0) { - const globalResult = - await gqlClient.request<FindProjectMilestoneGlobalQuery>( - FindProjectMilestoneGlobalDocument, - { name: nameOrId }, - ); + const globalResult = await gqlClient.request( + FindProjectMilestoneGlobalDocument, + { name: nameOrId }, + ); nodes = (globalResult.projectMilestones?.nodes as MilestoneNode[]) || []; } @@ -79,5 +73,7 @@ export async function resolveMilestoneId( ); } - return nodes[0].id; + return asUuid( + firstOrThrow(nodes, () => notFoundError("Milestone", nameOrId)).id, + ); } diff --git a/src/resolvers/project-resolver.ts b/src/resolvers/project-resolver.ts index c88caa76..5e67d096 100644 --- a/src/resolvers/project-resolver.ts +++ b/src/resolvers/project-resolver.ts @@ -1,62 +1,68 @@ -import type { LinearSdkClient } from "../client/linear-client.js"; +import type { GraphQLClient } from "../client/graphql-client.js"; +import { firstOrThrow } from "../common/array.js"; import { multipleMatchesError, notFoundError } from "../common/errors.js"; -import { isUuid } from "../common/identifier.js"; +import { asUuid, isUuid, type UUID } from "../common/identifier.js"; +import { + FindProjectLabelByNameDocument, + FindProjectsByNameDocument, +} from "../gql/graphql.js"; export interface ResolveProjectIdOptions { includeArchived?: boolean; } export async function resolveProjectId( - client: LinearSdkClient, + client: GraphQLClient, nameOrId: string, options: ResolveProjectIdOptions = {}, -): Promise<string> { - if (isUuid(nameOrId)) return nameOrId; +): Promise<UUID> { + if (isUuid(nameOrId)) return asUuid(nameOrId); - const result = await client.sdk.projects({ - filter: { name: { eqIgnoreCase: nameOrId } }, - first: 2, + const { projects } = await client.request(FindProjectsByNameDocument, { + name: nameOrId, includeArchived: options.includeArchived, }); - if (result.nodes.length === 0) { + if (projects.nodes.length === 0) { throw notFoundError("Project", nameOrId); } - if (result.nodes.length > 1) { + if (projects.nodes.length > 1) { throw multipleMatchesError( "Project", nameOrId, - result.nodes.map((project) => project.id), + projects.nodes.map((project) => project.id), "provide project UUID", ); } - return result.nodes[0].id; + return asUuid( + firstOrThrow(projects.nodes, () => notFoundError("Project", nameOrId)).id, + ); } export async function resolveProjectLabelId( - client: LinearSdkClient, + client: GraphQLClient, nameOrId: string, -): Promise<string> { - if (isUuid(nameOrId)) return nameOrId; - - const result = await client.sdk.projectLabels({ - filter: { name: { eqIgnoreCase: nameOrId } }, - first: 1, - }); +): Promise<UUID> { + if (isUuid(nameOrId)) return asUuid(nameOrId); - if (result.nodes.length === 0) { - throw notFoundError("Project label", nameOrId); - } + const { projectLabels } = await client.request( + FindProjectLabelByNameDocument, + { name: nameOrId }, + ); - return result.nodes[0].id; + return asUuid( + firstOrThrow(projectLabels.nodes, () => + notFoundError("Project label", nameOrId), + ).id, + ); } export async function resolveProjectLabelIds( - client: LinearSdkClient, + client: GraphQLClient, namesOrIds: string[], -): Promise<string[]> { +): Promise<UUID[]> { return Promise.all( namesOrIds.map((nameOrId) => resolveProjectLabelId(client, nameOrId)), ); diff --git a/src/resolvers/project-status-resolver.ts b/src/resolvers/project-status-resolver.ts index 030b73ca..a998280b 100644 --- a/src/resolvers/project-status-resolver.ts +++ b/src/resolvers/project-status-resolver.ts @@ -1,22 +1,15 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import { notFoundError } from "../common/errors.js"; -import { isUuid } from "../common/identifier.js"; -import { - GetProjectStatusesDocument, - type GetProjectStatusesQuery, -} from "../gql/graphql.js"; +import { asUuid, isUuid, type UUID } from "../common/identifier.js"; +import { GetProjectStatusesDocument } from "../gql/graphql.js"; /** * Resolves project status name to UUID. * * Accepts UUID (returned as-is) or a status name (case-insensitive match). * - * ARCHITECTURAL EXCEPTION: This resolver uses GraphQLClient instead of - * LinearSdkClient because the Linear SDK's projectStatuses() method does - * not support server-side filtering. A GraphQL query fetches all statuses - * (a small fixed set) and filters client-side. This is a documented - * deviation from the standard resolver contract (resolvers normally use - * SDK only). + * projectStatuses has no server-side name filter, so this fetches the full + * (small, fixed) set and matches client-side. * * @param client - GraphQL client for querying project statuses * @param nameOrId - Status name or UUID @@ -26,12 +19,10 @@ import { export async function resolveProjectStatusId( client: GraphQLClient, nameOrId: string, -): Promise<string> { - if (isUuid(nameOrId)) return nameOrId; +): Promise<UUID> { + if (isUuid(nameOrId)) return asUuid(nameOrId); - const result = await client.request<GetProjectStatusesQuery>( - GetProjectStatusesDocument, - ); + const result = await client.request(GetProjectStatusesDocument); const match = result.projectStatuses.nodes.find( (s) => s.name.toLowerCase() === nameOrId.toLowerCase(), ); @@ -40,5 +31,5 @@ export async function resolveProjectStatusId( throw notFoundError("Project status", nameOrId); } - return match.id; + return asUuid(match.id); } diff --git a/src/resolvers/status-resolver.ts b/src/resolvers/status-resolver.ts index 9dc92545..f4f6b14c 100644 --- a/src/resolvers/status-resolver.ts +++ b/src/resolvers/status-resolver.ts @@ -1,16 +1,20 @@ -import type { LinearDocument } from "@linear/sdk"; -import type { LinearSdkClient } from "../client/linear-client.js"; +import type { GraphQLClient } from "../client/graphql-client.js"; +import { firstOrThrow } from "../common/array.js"; import { notFoundError } from "../common/errors.js"; -import { isUuid } from "../common/identifier.js"; +import { asUuid, isUuid, type UUID } from "../common/identifier.js"; +import { + FindWorkflowStatesDocument, + type WorkflowStateFilter, +} from "../gql/graphql.js"; export async function resolveStatusId( - client: LinearSdkClient, + client: GraphQLClient, nameOrId: string, - teamId?: string, -): Promise<string> { - if (isUuid(nameOrId)) return nameOrId; + teamId?: UUID, +): Promise<UUID> { + if (isUuid(nameOrId)) return asUuid(nameOrId); - const filter: LinearDocument.WorkflowStateFilter = { + const filter: WorkflowStateFilter = { name: { eqIgnoreCase: nameOrId }, }; @@ -18,15 +22,18 @@ export async function resolveStatusId( filter.team = { id: { eq: teamId } }; } - const result = await client.sdk.workflowStates({ + const { workflowStates } = await client.request(FindWorkflowStatesDocument, { filter, first: 1, }); - if (result.nodes.length === 0) { - const context = teamId ? `for team ${teamId}` : undefined; - throw notFoundError("Status", nameOrId, context); - } - - return result.nodes[0].id; + return asUuid( + firstOrThrow(workflowStates.nodes, () => + notFoundError( + "Status", + nameOrId, + teamId ? `for team ${teamId}` : undefined, + ), + ).id, + ); } diff --git a/src/resolvers/team-resolver.ts b/src/resolvers/team-resolver.ts index f24adc87..b410b22e 100644 --- a/src/resolvers/team-resolver.ts +++ b/src/resolvers/team-resolver.ts @@ -1,6 +1,7 @@ -import type { LinearSdkClient } from "../client/linear-client.js"; +import type { GraphQLClient } from "../client/graphql-client.js"; import { notFoundError } from "../common/errors.js"; -import { isUuid } from "../common/identifier.js"; +import { asUuid, isUuid, type UUID } from "../common/identifier.js"; +import { FindTeamsDocument } from "../gql/graphql.js"; type TeamEstimationType = | "notUsed" @@ -10,7 +11,7 @@ type TeamEstimationType = | "tShirt"; export interface TeamEstimateContext { - teamId: string; + teamId: UUID; teamKey: string; teamName: string; issueEstimationType: TeamEstimationType; @@ -51,12 +52,12 @@ function toTeamEstimateNode( ); } - const id = node.id; - const key = node.key; - const name = node.name; - const issueEstimationType = node.issueEstimationType; - const issueEstimationExtended = node.issueEstimationExtended; - const issueEstimationAllowZero = node.issueEstimationAllowZero; + const id = node["id"]; + const key = node["key"]; + const name = node["name"]; + const issueEstimationType = node["issueEstimationType"]; + const issueEstimationExtended = node["issueEstimationExtended"]; + const issueEstimationAllowZero = node["issueEstimationAllowZero"]; if ( typeof id !== "string" || @@ -85,7 +86,7 @@ function mapTeamNodeToEstimateContext( node: TeamEstimateNode, ): TeamEstimateContext { return { - teamId: node.id, + teamId: asUuid(node.id), teamKey: node.key, teamName: node.name, issueEstimationType: node.issueEstimationType, @@ -95,39 +96,39 @@ function mapTeamNodeToEstimateContext( } export async function resolveTeamEstimateContext( - client: LinearSdkClient, + client: GraphQLClient, keyOrNameOrId: string, ): Promise<TeamEstimateContext> { if (isUuid(keyOrNameOrId)) { - const byId = await client.sdk.teams({ + const { teams } = await client.request(FindTeamsDocument, { filter: { id: { eq: keyOrNameOrId } }, first: 1, }); - if (byId.nodes.length > 0) { + if (teams.nodes.length > 0) { return mapTeamNodeToEstimateContext( - toTeamEstimateNode(byId.nodes[0], keyOrNameOrId), + toTeamEstimateNode(teams.nodes[0], keyOrNameOrId), ); } throw notFoundError("Team", keyOrNameOrId); } - const byKey = await client.sdk.teams({ + const byKey = await client.request(FindTeamsDocument, { filter: { key: { eq: keyOrNameOrId } }, first: 1, }); - if (byKey.nodes.length > 0) { + if (byKey.teams.nodes.length > 0) { return mapTeamNodeToEstimateContext( - toTeamEstimateNode(byKey.nodes[0], keyOrNameOrId), + toTeamEstimateNode(byKey.teams.nodes[0], keyOrNameOrId), ); } - const byName = await client.sdk.teams({ + const byName = await client.request(FindTeamsDocument, { filter: { name: { eq: keyOrNameOrId } }, first: 1, }); - if (byName.nodes.length > 0) { + if (byName.teams.nodes.length > 0) { return mapTeamNodeToEstimateContext( - toTeamEstimateNode(byName.nodes[0], keyOrNameOrId), + toTeamEstimateNode(byName.teams.nodes[0], keyOrNameOrId), ); } @@ -135,24 +136,26 @@ export async function resolveTeamEstimateContext( } export async function resolveTeamId( - client: LinearSdkClient, + client: GraphQLClient, keyOrNameOrId: string, -): Promise<string> { - if (isUuid(keyOrNameOrId)) return keyOrNameOrId; +): Promise<UUID> { + if (isUuid(keyOrNameOrId)) return asUuid(keyOrNameOrId); // Try by key first - const byKey = await client.sdk.teams({ + const byKey = await client.request(FindTeamsDocument, { filter: { key: { eq: keyOrNameOrId } }, first: 1, }); - if (byKey.nodes.length > 0) return byKey.nodes[0].id; + const [byKeyMatch] = byKey.teams.nodes; + if (byKeyMatch) return asUuid(byKeyMatch.id); // Fall back to name - const byName = await client.sdk.teams({ + const byName = await client.request(FindTeamsDocument, { filter: { name: { eq: keyOrNameOrId } }, first: 1, }); - if (byName.nodes.length > 0) return byName.nodes[0].id; + const [byNameMatch] = byName.teams.nodes; + if (byNameMatch) return asUuid(byNameMatch.id); throw notFoundError("Team", keyOrNameOrId); } diff --git a/src/resolvers/user-resolver.ts b/src/resolvers/user-resolver.ts index 98b847a9..d051099d 100644 --- a/src/resolvers/user-resolver.ts +++ b/src/resolvers/user-resolver.ts @@ -1,20 +1,22 @@ -import type { LinearSdkClient } from "../client/linear-client.js"; +import type { GraphQLClient } from "../client/graphql-client.js"; import { multipleMatchesError, notFoundError } from "../common/errors.js"; -import { isUuid } from "../common/identifier.js"; +import { asUuid, isUuid, type UUID } from "../common/identifier.js"; +import { FindUsersDocument } from "../gql/graphql.js"; export async function resolveUserId( - client: LinearSdkClient, + client: GraphQLClient, nameOrEmailOrId: string, -): Promise<string> { - if (isUuid(nameOrEmailOrId)) return nameOrEmailOrId; +): Promise<UUID> { + if (isUuid(nameOrEmailOrId)) return asUuid(nameOrEmailOrId); // Try by display name first (case-insensitive) - const byName = await client.sdk.users({ + const { users: byName } = await client.request(FindUsersDocument, { filter: { displayName: { eqIgnoreCase: nameOrEmailOrId } }, first: 10, }); - if (byName.nodes.length === 1) return byName.nodes[0].id; + const [byNameMatch] = byName.nodes; + if (byName.nodes.length === 1 && byNameMatch) return asUuid(byNameMatch.id); if (byName.nodes.length > 1) { throw multipleMatchesError( @@ -26,12 +28,13 @@ export async function resolveUserId( } // Fall back to email (case-insensitive) - const byEmail = await client.sdk.users({ + const { users: byEmail } = await client.request(FindUsersDocument, { filter: { email: { eqIgnoreCase: nameOrEmailOrId } }, first: 1, }); - if (byEmail.nodes.length > 0) return byEmail.nodes[0].id; + const [byEmailMatch] = byEmail.nodes; + if (byEmailMatch) return asUuid(byEmailMatch.id); throw notFoundError("User", nameOrEmailOrId); } diff --git a/src/services/activity-service.ts b/src/services/activity-service.ts new file mode 100644 index 00000000..f8787733 --- /dev/null +++ b/src/services/activity-service.ts @@ -0,0 +1,427 @@ +import type { GraphQLClient } from "../client/graphql-client.js"; +import { asUuid, type UUID } from "../common/identifier.js"; +import { collectConnection } from "../common/types.js"; +import { + GetIssueActivityRefDocument, + GetLabelsDocument, + type IssueHistoryFieldsFragment, + ListIssueActivityHistoryDocument, + ListIssueDiscussionRootsDocument, + ListIssueDiscussionRootsWithReactionsDocument, +} from "../gql/graphql.js"; +import { + buildThreadRepliesIndex, + collectThreadReplies, + type DiscussionThread, + type DiscussionThreadWithReactions, + fetchAllIssueDiscussionReplyCandidates, + fetchAllIssueDiscussionReplyCandidatesWithReactions, + normalizeDiscussionCommentsReactions, +} from "./discussion-service.js"; + +/** Page size used when exhausting a connection to build the merged timeline. */ +const TIMELINE_FETCH_LIMIT = 250; +/** Default number of top-level timeline items returned per page. */ +const DEFAULT_ACTIVITY_LIMIT = 50; + +type NamedRef = { id: string; name: string }; +type UserRef = { id: string; displayName: string }; +type CycleRef = { id: string; number: number }; +/** A label reference; `name` is null when the label no longer exists. */ +type LabelRef = { id: string; name: string | null }; + +/** A single normalized change captured by an issue history event. */ +type ActivityChange = + | { field: "state"; from: NamedRef | null; to: NamedRef | null } + | { field: "assignee"; from: UserRef | null; to: UserRef | null } + | { field: "priority"; from: number | null; to: number | null } + | { field: "project"; from: NamedRef | null; to: NamedRef | null } + | { field: "cycle"; from: CycleRef | null; to: CycleRef | null } + | { field: "title"; from: string | null; to: string | null } + | { field: "estimate"; from: number | null; to: number | null } + | { field: "labels"; added: LabelRef[]; removed: LabelRef[] } + | { field: "archived"; to: boolean }; + +interface ActivityHistoryItem { + type: "history"; + id: string; + createdAt: string; + actor: UserRef | null; + botActor: { id: string | null; name: string | null } | null; + changes: ActivityChange[]; +} + +interface ActivityCommentThreadItem< + TComment extends DiscussionThread | DiscussionThreadWithReactions, +> { + type: "commentThread"; + root: TComment; + replies: TComment[]; +} + +type ActivityItem = + | ActivityHistoryItem + | ActivityCommentThreadItem<DiscussionThread> + | ActivityCommentThreadItem<DiscussionThreadWithReactions>; + +export interface IssueActivityResult { + issue: { id: string; identifier: string }; + activity: ActivityItem[]; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; +} + +export interface IssueActivityOptions { + limit?: number; + after?: string; + commentsOnly?: boolean; + withReactions?: boolean; +} + +function refsDiffer( + from: { id: string } | null, + to: { id: string } | null, +): boolean { + return (from?.id ?? null) !== (to?.id ?? null); +} + +/** Translate a raw issue history node into its list of meaningful changes. */ +function buildHistoryChanges( + node: IssueHistoryFieldsFragment, + labelNames: ReadonlyMap<string, string>, +): ActivityChange[] { + const changes: ActivityChange[] = []; + + const toLabelRef = (id: string): LabelRef => ({ + id, + name: labelNames.get(id) ?? null, + }); + + if (refsDiffer(node.fromState, node.toState)) { + changes.push({ field: "state", from: node.fromState, to: node.toState }); + } + + if (refsDiffer(node.fromAssignee, node.toAssignee)) { + changes.push({ + field: "assignee", + from: node.fromAssignee, + to: node.toAssignee, + }); + } + + if (node.fromPriority !== node.toPriority) { + changes.push({ + field: "priority", + from: node.fromPriority, + to: node.toPriority, + }); + } + + if (refsDiffer(node.fromProject, node.toProject)) { + changes.push({ + field: "project", + from: node.fromProject, + to: node.toProject, + }); + } + + if (refsDiffer(node.fromCycle, node.toCycle)) { + changes.push({ field: "cycle", from: node.fromCycle, to: node.toCycle }); + } + + if (node.fromTitle !== node.toTitle) { + changes.push({ field: "title", from: node.fromTitle, to: node.toTitle }); + } + + if (node.fromEstimate !== node.toEstimate) { + changes.push({ + field: "estimate", + from: node.fromEstimate, + to: node.toEstimate, + }); + } + + const added = node.addedLabelIds ?? []; + const removed = node.removedLabelIds ?? []; + if (added.length > 0 || removed.length > 0) { + changes.push({ + field: "labels", + added: added.map(toLabelRef), + removed: removed.map(toLabelRef), + }); + } + + if (node.archived !== null) { + changes.push({ field: "archived", to: node.archived }); + } + + return changes; +} + +async function fetchAllIssueDiscussionRoots( + client: GraphQLClient, + issueId: UUID, +): Promise<DiscussionThread[]> { + return collectConnection(async (after) => { + const result = await client.request(ListIssueDiscussionRootsDocument, { + issueId, + first: TIMELINE_FETCH_LIMIT, + after, + }); + + if (!result.issue) { + throw new Error(`Issue with ID "${issueId}" not found`); + } + + return result.issue.comments; + }); +} + +async function fetchAllIssueDiscussionRootsWithReactions( + client: GraphQLClient, + issueId: UUID, +): Promise<DiscussionThreadWithReactions[]> { + const nodes = await collectConnection(async (after) => { + const result = await client.request( + ListIssueDiscussionRootsWithReactionsDocument, + { issueId, first: TIMELINE_FETCH_LIMIT, after }, + ); + + if (!result.issue) { + throw new Error(`Issue with ID "${issueId}" not found`); + } + + return result.issue.comments; + }); + + return normalizeDiscussionCommentsReactions(nodes); +} + +async function fetchAllIssueHistory( + client: GraphQLClient, + issueId: UUID, +): Promise<IssueHistoryFieldsFragment[]> { + return collectConnection(async (after) => { + const result = await client.request(ListIssueActivityHistoryDocument, { + issueId, + first: TIMELINE_FETCH_LIMIT, + after, + }); + + if (!result.issue) { + throw new Error(`Issue with ID "${issueId}" not found`); + } + + return result.issue.history; + }); +} + +/** Collect the distinct label IDs referenced by any history node's label changes. */ +function collectLabelIds( + nodes: readonly IssueHistoryFieldsFragment[], +): string[] { + const ids = new Set<string>(); + + for (const node of nodes) { + for (const id of node.addedLabelIds ?? []) { + ids.add(id); + } + for (const id of node.removedLabelIds ?? []) { + ids.add(id); + } + } + + return [...ids]; +} + +/** + * Resolve label IDs referenced by history events to their names so the timeline + * exposes human-readable labels (matching state/assignee/project). Archived + * labels are included so historic label changes still resolve; labels that no + * longer exist are simply absent from the map, yielding a null name. + */ +async function resolveLabelNames( + client: GraphQLClient, + ids: readonly string[], +): Promise<Map<string, string>> { + const names = new Map<string, string>(); + + if (ids.length === 0) { + return names; + } + + const labels = await collectConnection(async (after) => { + const result = await client.request(GetLabelsDocument, { + first: TIMELINE_FETCH_LIMIT, + after, + filter: { id: { in: [...ids] } }, + includeArchived: true, + }); + + return result.issueLabels; + }); + + for (const label of labels) { + names.set(label.id, label.name); + } + + return names; +} + +/** Assemble comment-thread timeline items from root threads and reply candidates. */ +function buildCommentThreadItems< + TComment extends DiscussionThread | DiscussionThreadWithReactions, +>( + roots: readonly TComment[], + candidates: readonly TComment[], +): ActivityCommentThreadItem<TComment>[] { + const childrenByParentId = buildThreadRepliesIndex(candidates); + return roots.map((root) => ({ + type: "commentThread" as const, + root, + replies: collectThreadReplies(childrenByParentId, asUuid(root.id)), + })); +} + +/** + * Fetch an issue's comment threads. Roots and reply candidates are independent + * connections, so they are exhausted concurrently before being assembled. + */ +async function fetchCommentThreadItems( + client: GraphQLClient, + issueId: UUID, + withReactions: boolean, +): Promise<ActivityItem[]> { + if (withReactions) { + const [roots, candidates] = await Promise.all([ + fetchAllIssueDiscussionRootsWithReactions(client, issueId), + fetchAllIssueDiscussionReplyCandidatesWithReactions(client, issueId), + ]); + return buildCommentThreadItems(roots, candidates); + } + + const [roots, candidates] = await Promise.all([ + fetchAllIssueDiscussionRoots(client, issueId), + fetchAllIssueDiscussionReplyCandidates(client, issueId), + ]); + return buildCommentThreadItems(roots, candidates); +} + +interface TimelineEntry { + createdAt: string; + id: string; + item: ActivityItem; +} + +function toTimelineEntry(item: ActivityItem): TimelineEntry { + return item.type === "history" + ? { createdAt: item.createdAt, id: item.id, item } + : { createdAt: item.root.createdAt, id: item.root.id, item }; +} + +function compareTimelineEntries(a: TimelineEntry, b: TimelineEntry): number { + const byCreatedAt = a.createdAt.localeCompare(b.createdAt); + return byCreatedAt !== 0 ? byCreatedAt : a.id.localeCompare(b.id); +} + +interface TimelinePage { + nodes: ActivityItem[]; + hasNextPage: boolean; + endCursor: string | null; +} + +/** Slice a sorted timeline using an opaque id cursor (mirrors reply pagination). */ +function paginateTimeline( + entries: readonly TimelineEntry[], + limit: number, + after?: string, +): TimelinePage { + const startIndex = + after === undefined + ? 0 + : entries.findIndex((entry) => entry.id === after) + 1; + + if (after !== undefined && startIndex === 0) { + throw new Error(`Activity cursor "${after}" not found`); + } + + const page = entries.slice(startIndex, startIndex + limit); + + return { + nodes: page.map((entry) => entry.item), + hasNextPage: startIndex + limit < entries.length, + endCursor: page.at(-1)?.id ?? null, + }; +} + +/** + * Build a chronological activity timeline for an issue: comment threads (root + + * nested replies) merged with issue history events, sorted ascending by + * creation time and paginated with an opaque id cursor. + * + * The timeline is materialized in full on every call before it is sliced: the + * comment and history connections are independent (Linear exposes no unified + * activity connection) and reply nesting needs all reply candidates regardless + * of the requested page. As a stateless CLI there is no cross-invocation cache, + * so each `--after` page re-fetches everything — the same materialize-then-slice + * tradeoff as {@link paginateTimeline}'s sibling in discussion reply pagination. + * This is cheap for typical issues; only pathologically large histories pay for it. + */ +export async function getIssueActivity( + client: GraphQLClient, + issueId: UUID, + options: IssueActivityOptions = {}, +): Promise<IssueActivityResult> { + const { + limit = DEFAULT_ACTIVITY_LIMIT, + after, + commentsOnly = false, + withReactions = false, + } = options; + + const ref = await client.request(GetIssueActivityRefDocument, { + id: issueId, + }); + + if (!ref.issue) { + throw new Error(`Issue with ID "${issueId}" not found`); + } + + // Comment threads and issue history are independent connections; fetch both + // concurrently. Label names depend on the history nodes, so they resolve after. + const [threadItems, historyNodes] = await Promise.all([ + fetchCommentThreadItems(client, issueId, withReactions), + commentsOnly + ? Promise.resolve<IssueHistoryFieldsFragment[]>([]) + : fetchAllIssueHistory(client, issueId), + ]); + + const labelNames = await resolveLabelNames( + client, + collectLabelIds(historyNodes), + ); + + const historyItems: ActivityItem[] = historyNodes + .map((node) => ({ + type: "history" as const, + id: node.id, + createdAt: node.createdAt, + actor: node.actor, + botActor: node.botActor, + changes: buildHistoryChanges(node, labelNames), + })) + // Drop events whose only changes fall outside the captured fragment fields; + // they carry no information and would otherwise consume pagination slots. + .filter((item) => item.changes.length > 0); + + const entries = [...threadItems, ...historyItems] + .map(toTimelineEntry) + .sort(compareTimelineEntries); + + const page = paginateTimeline(entries, limit, after); + + return { + issue: { id: ref.issue.id, identifier: ref.issue.identifier }, + activity: page.nodes, + pageInfo: { hasNextPage: page.hasNextPage, endCursor: page.endCursor }, + }; +} diff --git a/src/services/attachment-service.ts b/src/services/attachment-service.ts index cd47e368..435f03cb 100644 --- a/src/services/attachment-service.ts +++ b/src/services/attachment-service.ts @@ -1,57 +1,103 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { Attachment, CreatedAttachment } from "../common/types.js"; +import type { BrandUuidFields, UUID } from "../common/identifier.js"; +import { + requireMutationEntity, + requireMutationSuccess, +} from "../common/mutation-payload.js"; import { AttachmentCreateDocument, type AttachmentCreateInput, type AttachmentCreateMutation, AttachmentDeleteDocument, - type AttachmentDeleteMutation, type AttachmentFilter, ListAttachmentsDocument, type ListAttachmentsQuery, } from "../gql/graphql.js"; +// Attachment projection types +export type AttachmentListItem = + ListAttachmentsQuery["issue"]["attachments"]["nodes"][0]; +export type CreatedAttachment = + AttachmentCreateMutation["attachmentCreate"]["attachment"]; + +// Service-owned input type (UUIDs pre-resolved by the command). +export type CreateAttachmentInput = BrandUuidFields< + Pick< + AttachmentCreateInput, + "issueId" | "title" | "url" | "subtitle" | "commentBody" | "iconUrl" + >, + "issueId" +>; + +export interface AttachmentFilterOptions { + sourceType?: string; + title?: string; + createdAfter?: string; + createdBefore?: string; +} + +export function buildAttachmentFilter( + options: AttachmentFilterOptions, +): AttachmentFilter | undefined { + const filters: AttachmentFilter[] = []; + + if (options.sourceType) { + filters.push({ sourceType: { eq: options.sourceType } }); + } + if (options.title) { + filters.push({ title: { eqIgnoreCase: options.title } }); + } + if (options.createdAfter) { + filters.push({ createdAt: { gte: options.createdAfter } }); + } + if (options.createdBefore) { + filters.push({ createdAt: { lt: options.createdBefore } }); + } + + if (filters.length === 0) return undefined; + if (filters.length === 1) return filters[0]; + return { and: filters }; +} + export async function createAttachment( client: GraphQLClient, - input: AttachmentCreateInput, + input: CreateAttachmentInput, ): Promise<CreatedAttachment> { - const result = await client.request<AttachmentCreateMutation>( - AttachmentCreateDocument, - { input }, - ); - - if (!result.attachmentCreate.success || !result.attachmentCreate.attachment) { - throw new Error("Failed to create attachment"); - } + const gqlInput: AttachmentCreateInput = input; + const result = await client.request(AttachmentCreateDocument, { + input: gqlInput, + }); - return result.attachmentCreate.attachment; + return requireMutationEntity( + result.attachmentCreate, + "attachment", + "Failed to create attachment", + ); } export async function deleteAttachment( client: GraphQLClient, - id: string, + id: UUID, ): Promise<{ id: string; success: boolean }> { - const result = await client.request<AttachmentDeleteMutation>( - AttachmentDeleteDocument, - { id }, - ); + const result = await client.request(AttachmentDeleteDocument, { id }); - if (!result.attachmentDelete.success) { - throw new Error("Failed to delete attachment"); - } + requireMutationSuccess( + result.attachmentDelete, + "Failed to delete attachment", + ); return { id: result.attachmentDelete.entityId, success: true }; } export async function listAttachments( client: GraphQLClient, - issueId: string, + issueId: UUID, filter?: AttachmentFilter, -): Promise<Attachment[]> { - const result = await client.request<ListAttachmentsQuery>( - ListAttachmentsDocument, - { issueId, ...(filter && { filter }) }, - ); +): Promise<AttachmentListItem[]> { + const result = await client.request(ListAttachmentsDocument, { + issueId, + ...(filter && { filter }), + }); if (!result.issue) { throw new Error(`Issue with ID "${issueId}" not found`); diff --git a/src/services/auth-service.ts b/src/services/auth-service.ts index 1807974e..89e3b58a 100644 --- a/src/services/auth-service.ts +++ b/src/services/auth-service.ts @@ -1,8 +1,10 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { Viewer } from "../common/types.js"; import { GetViewerDocument, type GetViewerQuery } from "../gql/graphql.js"; +// Viewer projection types +export type Viewer = GetViewerQuery["viewer"]; + export async function validateToken(client: GraphQLClient): Promise<Viewer> { - const result = await client.request<GetViewerQuery>(GetViewerDocument); + const result = await client.request(GetViewerDocument); return result.viewer; } diff --git a/src/services/comment-service.ts b/src/services/comment-service.ts index 2ced7f27..05aba485 100644 --- a/src/services/comment-service.ts +++ b/src/services/comment-service.ts @@ -1,65 +1,67 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { - CommentListItem, - CreatedComment, - PaginatedResult, - PaginationOptions, - UpdatedComment, -} from "../common/types.js"; +import type { BrandUuidFields, UUID } from "../common/identifier.js"; +import { + requireMutationEntity, + requireMutationSuccess, +} from "../common/mutation-payload.js"; +import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { type CommentCreateInput, type CommentUpdateInput, CreateCommentDocument, type CreateCommentMutation, DeleteCommentDocument, - type DeleteCommentMutation, ListCommentsDocument, type ListCommentsQuery, UpdateCommentDocument, type UpdateCommentMutation, } from "../gql/graphql.js"; +// Comment projection types +export type CreatedComment = NonNullable< + CreateCommentMutation["commentCreate"]["comment"] +>; +export type UpdatedComment = NonNullable< + UpdateCommentMutation["commentUpdate"]["comment"] +>; +export type CommentListItem = + ListCommentsQuery["issue"]["comments"]["nodes"][0]; + export async function createComment( client: GraphQLClient, - input: CommentCreateInput, + input: BrandUuidFields<CommentCreateInput, "issueId" | "parentId">, ): Promise<CreatedComment> { - const result = await client.request<CreateCommentMutation>( - CreateCommentDocument, - { input }, - ); - - if (!result.commentCreate.success || !result.commentCreate.comment) { - throw new Error("Failed to create comment"); - } + const result = await client.request(CreateCommentDocument, { input }); - return result.commentCreate.comment; + return requireMutationEntity( + result.commentCreate, + "comment", + "Failed to create comment", + ); } export async function updateComment( client: GraphQLClient, - id: string, + id: UUID, input: CommentUpdateInput, ): Promise<UpdatedComment> { - const result = await client.request<UpdateCommentMutation>( - UpdateCommentDocument, - { id, input }, - ); - - if (!result.commentUpdate.success || !result.commentUpdate.comment) { - throw new Error("Failed to update comment"); - } + const result = await client.request(UpdateCommentDocument, { id, input }); - return result.commentUpdate.comment; + return requireMutationEntity( + result.commentUpdate, + "comment", + "Failed to update comment", + ); } export async function listComments( client: GraphQLClient, - issueId: string, + issueId: UUID, options: PaginationOptions = {}, ): Promise<PaginatedResult<CommentListItem>> { const { limit = 25, after } = options; - const result = await client.request<ListCommentsQuery>(ListCommentsDocument, { + const result = await client.request(ListCommentsDocument, { issueId, first: limit, after, @@ -80,32 +82,26 @@ export async function listComments( export async function replyToComment( client: GraphQLClient, - input: { parentId: string; body: string }, + input: { parentId: UUID; body: string }, ): Promise<CreatedComment> { - const result = await client.request<CreateCommentMutation>( - CreateCommentDocument, - { input: { parentId: input.parentId, body: input.body } }, - ); - - if (!result.commentCreate.success || !result.commentCreate.comment) { - throw new Error("Failed to create reply"); - } + const result = await client.request(CreateCommentDocument, { + input: { parentId: input.parentId, body: input.body }, + }); - return result.commentCreate.comment; + return requireMutationEntity( + result.commentCreate, + "comment", + "Failed to create reply", + ); } export async function deleteComment( client: GraphQLClient, - id: string, + id: UUID, ): Promise<{ id: string; success: boolean }> { - const result = await client.request<DeleteCommentMutation>( - DeleteCommentDocument, - { id }, - ); + const result = await client.request(DeleteCommentDocument, { id }); - if (!result.commentDelete.success) { - throw new Error("Failed to delete comment"); - } + requireMutationSuccess(result.commentDelete, "Failed to delete comment"); return { id: result.commentDelete.entityId, diff --git a/src/services/cycle-service.ts b/src/services/cycle-service.ts index 90cdd4f8..7b787b02 100644 --- a/src/services/cycle-service.ts +++ b/src/services/cycle-service.ts @@ -1,11 +1,10 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import type { UUID } from "../common/identifier.js"; import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { type CycleFilter, GetCycleByIdDocument, - type GetCycleByIdQuery, GetCyclesDocument, - type GetCyclesQuery, } from "../gql/graphql.js"; export interface Cycle { @@ -30,7 +29,7 @@ export interface CycleDetail extends Cycle { export async function listCycles( client: GraphQLClient, - teamId?: string, + teamId?: UUID, activeOnly: boolean = false, options: PaginationOptions = {}, ): Promise<PaginatedResult<Cycle>> { @@ -45,7 +44,7 @@ export async function listCycles( filter.isActive = { eq: true }; } - const result = await client.request<GetCyclesQuery>(GetCyclesDocument, { + const result = await client.request(GetCyclesDocument, { first: limit, after, filter, @@ -68,10 +67,10 @@ export async function listCycles( export async function getCycle( client: GraphQLClient, - cycleId: string, + cycleId: UUID, issuesLimit: number = 50, ): Promise<CycleDetail> { - const result = await client.request<GetCycleByIdQuery>(GetCycleByIdDocument, { + const result = await client.request(GetCycleByIdDocument, { id: cycleId, first: issuesLimit, }); diff --git a/src/services/discussion-service.ts b/src/services/discussion-service.ts index 6571ed9e..a04a87b1 100644 --- a/src/services/discussion-service.ts +++ b/src/services/discussion-service.ts @@ -1,10 +1,18 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { PaginatedResult, PaginationOptions } from "../common/types.js"; +import type { UUID } from "../common/identifier.js"; +import { + requireMutationEntity, + requireMutationSuccess, +} from "../common/mutation-payload.js"; +import { + collectConnection, + type PaginatedResult, + type PaginationOptions, +} from "../common/types.js"; import { type CommentCreateInput, type CommentUpdateInput, DeleteDiscussionReplyDocument, - type DeleteDiscussionReplyMutation, type DiscussionCommentFieldsFragment, type DiscussionCommentFieldsWithReactionsFragment, EditDiscussionReplyDocument, @@ -16,25 +24,19 @@ import { ListInitiativeDiscussionReplyCandidatesWithReactionsDocument, type ListInitiativeDiscussionReplyCandidatesWithReactionsQuery, ListInitiativeDiscussionRootsDocument, - type ListInitiativeDiscussionRootsQuery, ListInitiativeDiscussionRootsWithReactionsDocument, - type ListInitiativeDiscussionRootsWithReactionsQuery, ListIssueDiscussionReplyCandidatesDocument, type ListIssueDiscussionReplyCandidatesQuery, ListIssueDiscussionReplyCandidatesWithReactionsDocument, type ListIssueDiscussionReplyCandidatesWithReactionsQuery, ListIssueDiscussionRootsDocument, - type ListIssueDiscussionRootsQuery, ListIssueDiscussionRootsWithReactionsDocument, - type ListIssueDiscussionRootsWithReactionsQuery, ListProjectDiscussionReplyCandidatesDocument, type ListProjectDiscussionReplyCandidatesQuery, ListProjectDiscussionReplyCandidatesWithReactionsDocument, type ListProjectDiscussionReplyCandidatesWithReactionsQuery, ListProjectDiscussionRootsDocument, - type ListProjectDiscussionRootsQuery, ListProjectDiscussionRootsWithReactionsDocument, - type ListProjectDiscussionRootsWithReactionsQuery, ResolveDiscussionDocument, type ResolveDiscussionMutation, StartDiscussionDocument, @@ -85,7 +87,7 @@ type DeleteDiscussionReactionResult = Awaited< >; interface DiscussionReactionTargetInput { - commentId: string; + commentId: UUID; target: DiscussionReactionTarget; expectedEntityKind?: DiscussionEntityKind; } @@ -101,7 +103,7 @@ interface DeleteDiscussionReactionByEmojiInput interface DeleteDiscussionReactionByIdInput extends DiscussionReactionTargetInput { - reactionId: string; + reactionId: UUID; } function normalizeDiscussionCommentReactions< @@ -117,7 +119,7 @@ function normalizeDiscussionCommentReactions< }; } -function normalizeDiscussionCommentsReactions< +export function normalizeDiscussionCommentsReactions< T extends { reactions: Parameters<typeof normalizeReactions>[0] }, >( comments: readonly T[], @@ -168,14 +170,13 @@ function assertExpectedDiscussionEntityKind( async function assertDiscussionCommentExists( client: GraphQLClient, - id: string, + id: UUID, expectedEntityKind?: DiscussionEntityKind, label: "comment" | "reply" = "comment", ): Promise<DiscussionCommentContext> { - const result = await client.request<GetDiscussionCommentContextQuery>( - GetDiscussionCommentContextDocument, - { id }, - ); + const result = await client.request(GetDiscussionCommentContextDocument, { + id, + }); if (!result.comment) { throw new Error(`Discussion comment ID "${id}" not found`); @@ -188,13 +189,12 @@ async function assertDiscussionCommentExists( async function assertRootDiscussionThread( client: GraphQLClient, - threadId: string, + threadId: UUID, expectedEntityKind?: DiscussionEntityKind, ): Promise<DiscussionThreadContext> { - const result = await client.request<GetDiscussionCommentContextQuery>( - GetDiscussionCommentContextDocument, - { id: threadId }, - ); + const result = await client.request(GetDiscussionCommentContextDocument, { + id: threadId, + }); if (!result.comment) { throw new Error(`Discussion thread ID "${threadId}" not found`); @@ -217,7 +217,7 @@ async function assertRootDiscussionThread( async function assertReplyComment( client: GraphQLClient, - commentId: string, + commentId: UUID, expectedEntityKind?: DiscussionEntityKind, ): Promise<DiscussionCommentContext> { const comment = await assertDiscussionCommentExists( @@ -306,7 +306,7 @@ async function listDiscussionReplyCandidates( let result: DiscussionReplyCandidateQuery; if (entity.kind === "issue") { - result = await client.request<ListIssueDiscussionReplyCandidatesQuery>( + result = await client.request( ListIssueDiscussionReplyCandidatesDocument, { issueId: entity.id, @@ -315,7 +315,7 @@ async function listDiscussionReplyCandidates( }, ); } else if (entity.kind === "project") { - result = await client.request<ListProjectDiscussionReplyCandidatesQuery>( + result = await client.request( ListProjectDiscussionReplyCandidatesDocument, { projectId: entity.id, @@ -324,15 +324,14 @@ async function listDiscussionReplyCandidates( }, ); } else { - result = - await client.request<ListInitiativeDiscussionReplyCandidatesQuery>( - ListInitiativeDiscussionReplyCandidatesDocument, - { - initiativeId: entity.id, - first: DISCUSSION_REPLY_FETCH_LIMIT, - after, - }, - ); + result = await client.request( + ListInitiativeDiscussionReplyCandidatesDocument, + { + initiativeId: entity.id, + first: DISCUSSION_REPLY_FETCH_LIMIT, + after, + }, + ); } nodes.push(...result.comments.nodes); @@ -362,35 +361,32 @@ async function listDiscussionReplyCandidatesWithReactions( let result: DiscussionReplyCandidateWithReactionsQuery; if (entity.kind === "issue") { - result = - await client.request<ListIssueDiscussionReplyCandidatesWithReactionsQuery>( - ListIssueDiscussionReplyCandidatesWithReactionsDocument, - { - issueId: entity.id, - first: DISCUSSION_REPLY_FETCH_LIMIT, - after, - }, - ); + result = await client.request( + ListIssueDiscussionReplyCandidatesWithReactionsDocument, + { + issueId: entity.id, + first: DISCUSSION_REPLY_FETCH_LIMIT, + after, + }, + ); } else if (entity.kind === "project") { - result = - await client.request<ListProjectDiscussionReplyCandidatesWithReactionsQuery>( - ListProjectDiscussionReplyCandidatesWithReactionsDocument, - { - projectId: entity.id, - first: DISCUSSION_REPLY_FETCH_LIMIT, - after, - }, - ); + result = await client.request( + ListProjectDiscussionReplyCandidatesWithReactionsDocument, + { + projectId: entity.id, + first: DISCUSSION_REPLY_FETCH_LIMIT, + after, + }, + ); } else { - result = - await client.request<ListInitiativeDiscussionReplyCandidatesWithReactionsQuery>( - ListInitiativeDiscussionReplyCandidatesWithReactionsDocument, - { - initiativeId: entity.id, - first: DISCUSSION_REPLY_FETCH_LIMIT, - after, - }, - ); + result = await client.request( + ListInitiativeDiscussionReplyCandidatesWithReactionsDocument, + { + initiativeId: entity.id, + first: DISCUSSION_REPLY_FETCH_LIMIT, + after, + }, + ); } nodes.push(...result.comments.nodes); @@ -410,10 +406,53 @@ async function listDiscussionReplyCandidatesWithReactions( ); } -function filterThreadReplies<T extends DiscussionCommentFieldsFragment>( - comments: readonly T[], - threadId: string, -): T[] { +/** + * Fetch every reply candidate (comments with a parent) for an issue directly by + * issue UUID, looping until the connection is exhausted. Used by the activity + * timeline, which resolves the issue up front and does not have a thread context. + */ +export async function fetchAllIssueDiscussionReplyCandidates( + client: GraphQLClient, + issueId: UUID, +): Promise<DiscussionCommentFieldsFragment[]> { + const nodes = await collectConnection(async (after) => { + const result = await client.request( + ListIssueDiscussionReplyCandidatesDocument, + { issueId, first: DISCUSSION_REPLY_FETCH_LIMIT, after }, + ); + + return result.comments; + }); + + return nodes.sort(compareDiscussionCommentsChronologically); +} + +export async function fetchAllIssueDiscussionReplyCandidatesWithReactions( + client: GraphQLClient, + issueId: UUID, +): Promise<DiscussionThreadWithReactions[]> { + const nodes = await collectConnection(async (after) => { + const result = await client.request( + ListIssueDiscussionReplyCandidatesWithReactionsDocument, + { issueId, first: DISCUSSION_REPLY_FETCH_LIMIT, after }, + ); + + return result.comments; + }); + + return normalizeDiscussionCommentsReactions( + nodes.sort(compareDiscussionCommentsChronologically), + ); +} + +/** + * Index reply candidates by their `parentId`, with each sibling list sorted + * chronologically. Building this once lets callers extract many threads' + * replies without rescanning the full candidate list per thread. + */ +export function buildThreadRepliesIndex< + T extends DiscussionCommentFieldsFragment, +>(comments: readonly T[]): Map<string, T[]> { const childrenByParentId = new Map<string, T[]>(); for (const comment of comments) { @@ -427,6 +466,14 @@ function filterThreadReplies<T extends DiscussionCommentFieldsFragment>( childrenByParentId.set(comment.parentId, siblings); } + return childrenByParentId; +} + +/** Walk a pre-built reply index depth-first to collect a thread's replies. */ +export function collectThreadReplies<T extends DiscussionCommentFieldsFragment>( + childrenByParentId: ReadonlyMap<string, T[]>, + threadId: UUID, +): T[] { const replies: T[] = []; const stack = [...(childrenByParentId.get(threadId) ?? [])].reverse(); @@ -446,13 +493,23 @@ function filterThreadReplies<T extends DiscussionCommentFieldsFragment>( } for (let i = children.length - 1; i >= 0; i -= 1) { - stack.push(children[i]); + const child = children[i]; + if (child !== undefined) { + stack.push(child); + } } } return replies; } +function filterThreadReplies<T extends DiscussionCommentFieldsFragment>( + comments: readonly T[], + threadId: UUID, +): T[] { + return collectThreadReplies(buildThreadRepliesIndex(comments), threadId); +} + function paginateDiscussionReplies<T extends DiscussionCommentFieldsFragment>( replies: readonly T[], limit: number, @@ -482,16 +539,13 @@ async function startDiscussion( client: GraphQLClient, input: CommentCreateInput, ): Promise<StartDiscussionMutation["commentCreate"]["comment"]> { - const result = await client.request<StartDiscussionMutation>( - StartDiscussionDocument, - { input }, - ); + const result = await client.request(StartDiscussionDocument, { input }); - if (!result.commentCreate.success || !result.commentCreate.comment) { - throw new Error("Failed to start discussion"); - } - - return result.commentCreate.comment; + return requireMutationEntity( + result.commentCreate, + "comment", + "Failed to start discussion", + ); } export async function createDiscussionCommentReaction( @@ -508,7 +562,7 @@ export async function createDiscussionCommentReaction( export async function createIssueDiscussionCommentReaction( client: GraphQLClient, - input: { commentId: string; emoji: string }, + input: { commentId: UUID; emoji: string }, ): Promise<CreateDiscussionReactionResult> { await assertDiscussionCommentExists(client, input.commentId, "issue"); @@ -533,7 +587,7 @@ export async function deleteDiscussionCommentReactionByEmoji( export async function deleteIssueDiscussionCommentReactionByEmoji( client: GraphQLClient, - input: { commentId: string; emoji: string }, + input: { commentId: UUID; emoji: string }, ): Promise<DeleteDiscussionReactionResult> { await assertDiscussionCommentExists(client, input.commentId, "issue"); @@ -559,7 +613,7 @@ export async function deleteDiscussionCommentReactionById( export async function deleteIssueDiscussionCommentReactionById( client: GraphQLClient, - input: { commentId: string; reactionId: string }, + input: { commentId: UUID; reactionId: UUID }, ): Promise<DeleteDiscussionReactionResult> { await assertDiscussionCommentExists(client, input.commentId, "issue"); @@ -572,18 +626,15 @@ export async function deleteIssueDiscussionCommentReactionById( export async function listDiscussionsForIssue( client: GraphQLClient, - issueId: string, + issueId: UUID, options: PaginationOptions = {}, ): Promise<PaginatedResult<DiscussionThread>> { const { limit = DEFAULT_ROOT_LIMIT, after } = options; - const result = await client.request<ListIssueDiscussionRootsQuery>( - ListIssueDiscussionRootsDocument, - { - issueId, - first: limit, - after, - }, - ); + const result = await client.request(ListIssueDiscussionRootsDocument, { + issueId, + first: limit, + after, + }); if (!result.issue) { throw new Error(`Issue with ID "${issueId}" not found`); @@ -597,19 +648,18 @@ export async function listDiscussionsForIssue( export async function listDiscussionsForIssueWithReactions( client: GraphQLClient, - issueId: string, + issueId: UUID, options: PaginationOptions = {}, ): Promise<PaginatedResult<DiscussionThreadWithReactions>> { const { limit = DEFAULT_ROOT_LIMIT, after } = options; - const result = - await client.request<ListIssueDiscussionRootsWithReactionsQuery>( - ListIssueDiscussionRootsWithReactionsDocument, - { - issueId, - first: limit, - after, - }, - ); + const result = await client.request( + ListIssueDiscussionRootsWithReactionsDocument, + { + issueId, + first: limit, + after, + }, + ); if (!result.issue) { throw new Error(`Issue with ID "${issueId}" not found`); @@ -623,18 +673,15 @@ export async function listDiscussionsForIssueWithReactions( export async function listDiscussionsForProject( client: GraphQLClient, - projectId: string, + projectId: UUID, options: PaginationOptions = {}, ): Promise<PaginatedResult<DiscussionThread>> { const { limit = DEFAULT_ROOT_LIMIT, after } = options; - const result = await client.request<ListProjectDiscussionRootsQuery>( - ListProjectDiscussionRootsDocument, - { - projectId, - first: limit, - after, - }, - ); + const result = await client.request(ListProjectDiscussionRootsDocument, { + projectId, + first: limit, + after, + }); if (!result.project) { throw new Error(`Project with ID "${projectId}" not found`); @@ -648,19 +695,18 @@ export async function listDiscussionsForProject( export async function listDiscussionsForProjectWithReactions( client: GraphQLClient, - projectId: string, + projectId: UUID, options: PaginationOptions = {}, ): Promise<PaginatedResult<DiscussionThreadWithReactions>> { const { limit = DEFAULT_ROOT_LIMIT, after } = options; - const result = - await client.request<ListProjectDiscussionRootsWithReactionsQuery>( - ListProjectDiscussionRootsWithReactionsDocument, - { - projectId, - first: limit, - after, - }, - ); + const result = await client.request( + ListProjectDiscussionRootsWithReactionsDocument, + { + projectId, + first: limit, + after, + }, + ); if (!result.project) { throw new Error(`Project with ID "${projectId}" not found`); @@ -674,19 +720,16 @@ export async function listDiscussionsForProjectWithReactions( export async function listDiscussionsForInitiative( client: GraphQLClient, - initiativeId: string, + initiativeId: UUID, options: PaginationOptions = {}, ): Promise<PaginatedResult<DiscussionThread>> { const { limit = DEFAULT_ROOT_LIMIT, after } = options; - const result = await client.request<ListInitiativeDiscussionRootsQuery>( - ListInitiativeDiscussionRootsDocument, - { - initiativeId, - initiativeLookupId: initiativeId, - first: limit, - after, - }, - ); + const result = await client.request(ListInitiativeDiscussionRootsDocument, { + initiativeId, + initiativeLookupId: initiativeId, + first: limit, + after, + }); if (!result.initiative) { throw new Error(`Initiative with ID "${initiativeId}" not found`); @@ -700,20 +743,19 @@ export async function listDiscussionsForInitiative( export async function listDiscussionsForInitiativeWithReactions( client: GraphQLClient, - initiativeId: string, + initiativeId: UUID, options: PaginationOptions = {}, ): Promise<PaginatedResult<DiscussionThreadWithReactions>> { const { limit = DEFAULT_ROOT_LIMIT, after } = options; - const result = - await client.request<ListInitiativeDiscussionRootsWithReactionsQuery>( - ListInitiativeDiscussionRootsWithReactionsDocument, - { - initiativeId, - initiativeLookupId: initiativeId, - first: limit, - after, - }, - ); + const result = await client.request( + ListInitiativeDiscussionRootsWithReactionsDocument, + { + initiativeId, + initiativeLookupId: initiativeId, + first: limit, + after, + }, + ); if (!result.initiative) { throw new Error(`Initiative with ID "${initiativeId}" not found`); @@ -727,7 +769,7 @@ export async function listDiscussionsForInitiativeWithReactions( export async function listDiscussionReplies( client: GraphQLClient, - threadId: string, + threadId: UUID, options: PaginationOptions = {}, expectedEntityKind?: DiscussionEntityKind, ): Promise<PaginatedResult<DiscussionCommentFieldsFragment>> { @@ -745,7 +787,7 @@ export async function listDiscussionReplies( export async function listDiscussionRepliesWithReactions( client: GraphQLClient, - threadId: string, + threadId: UUID, options: PaginationOptions = {}, expectedEntityKind?: DiscussionEntityKind, ): Promise<PaginatedResult<DiscussionThreadWithReactions>> { @@ -766,14 +808,14 @@ export async function listDiscussionRepliesWithReactions( export async function startIssueDiscussion( client: GraphQLClient, - input: { issueId: string; body: string }, + input: { issueId: UUID; body: string }, ): Promise<StartDiscussionMutation["commentCreate"]["comment"]> { return startDiscussion(client, { issueId: input.issueId, body: input.body }); } export async function startProjectDiscussion( client: GraphQLClient, - input: { projectId: string; body: string }, + input: { projectId: UUID; body: string }, ): Promise<StartDiscussionMutation["commentCreate"]["comment"]> { return startDiscussion(client, { projectId: input.projectId, @@ -783,7 +825,7 @@ export async function startProjectDiscussion( export async function startInitiativeDiscussion( client: GraphQLClient, - input: { initiativeId: string; body: string }, + input: { initiativeId: UUID; body: string }, ): Promise<StartDiscussionMutation["commentCreate"]["comment"]> { return startDiscussion(client, { initiativeId: input.initiativeId, @@ -793,62 +835,69 @@ export async function startInitiativeDiscussion( export async function replyToDiscussion( client: GraphQLClient, - input: { threadId: string; body: string; entityKind?: DiscussionEntityKind }, + input: { threadId: UUID; body: string; entityKind?: DiscussionEntityKind }, ): Promise<StartDiscussionMutation["commentCreate"]["comment"]> { - await assertRootDiscussionThread(client, input.threadId, input.entityKind); - - const result = await client.request<StartDiscussionMutation>( - StartDiscussionDocument, - { - input: { - parentId: input.threadId, - body: input.body, - }, - }, + const thread = await assertRootDiscussionThread( + client, + input.threadId, + input.entityKind, ); + const entity = getDiscussionThreadEntity(thread); + const entityField = + entity.kind === "issue" + ? { issueId: entity.id } + : entity.kind === "project" + ? { projectId: entity.id } + : { initiativeId: entity.id }; + + const result = await client.request(StartDiscussionDocument, { + input: { + parentId: input.threadId, + ...entityField, + body: input.body, + }, + }); - if (!result.commentCreate.success || !result.commentCreate.comment) { - throw new Error("Failed to create discussion reply"); - } - - return result.commentCreate.comment; + return requireMutationEntity( + result.commentCreate, + "comment", + "Failed to create discussion reply", + ); } export async function editDiscussionReply( client: GraphQLClient, - id: string, + id: UUID, input: CommentUpdateInput, expectedEntityKind?: DiscussionEntityKind, ): Promise<EditDiscussionReplyMutation["commentUpdate"]["comment"]> { await assertReplyComment(client, id, expectedEntityKind); - const result = await client.request<EditDiscussionReplyMutation>( - EditDiscussionReplyDocument, - { id, input }, - ); - - if (!result.commentUpdate.success || !result.commentUpdate.comment) { - throw new Error("Failed to edit discussion reply"); - } + const result = await client.request(EditDiscussionReplyDocument, { + id, + input, + }); - return result.commentUpdate.comment; + return requireMutationEntity( + result.commentUpdate, + "comment", + "Failed to edit discussion reply", + ); } export async function deleteDiscussionReply( client: GraphQLClient, - id: string, + id: UUID, expectedEntityKind?: DiscussionEntityKind, ): Promise<{ id: string; success: true }> { await assertReplyComment(client, id, expectedEntityKind); - const result = await client.request<DeleteDiscussionReplyMutation>( - DeleteDiscussionReplyDocument, - { id }, - ); + const result = await client.request(DeleteDiscussionReplyDocument, { id }); - if (!result.commentDelete.success) { - throw new Error("Failed to delete discussion reply"); - } + requireMutationSuccess( + result.commentDelete, + "Failed to delete discussion reply", + ); return { id: result.commentDelete.entityId, @@ -858,39 +907,37 @@ export async function deleteDiscussionReply( export async function editDiscussionComment( client: GraphQLClient, - id: string, + id: UUID, input: CommentUpdateInput, expectedEntityKind?: DiscussionEntityKind, ): Promise<EditDiscussionReplyMutation["commentUpdate"]["comment"]> { await assertDiscussionCommentExists(client, id, expectedEntityKind); - const result = await client.request<EditDiscussionReplyMutation>( - EditDiscussionReplyDocument, - { id, input }, - ); - - if (!result.commentUpdate.success || !result.commentUpdate.comment) { - throw new Error("Failed to edit discussion comment"); - } + const result = await client.request(EditDiscussionReplyDocument, { + id, + input, + }); - return result.commentUpdate.comment; + return requireMutationEntity( + result.commentUpdate, + "comment", + "Failed to edit discussion comment", + ); } export async function deleteDiscussionComment( client: GraphQLClient, - id: string, + id: UUID, expectedEntityKind?: DiscussionEntityKind, ): Promise<{ id: string; success: true }> { await assertDiscussionCommentExists(client, id, expectedEntityKind); - const result = await client.request<DeleteDiscussionReplyMutation>( - DeleteDiscussionReplyDocument, - { id }, - ); + const result = await client.request(DeleteDiscussionReplyDocument, { id }); - if (!result.commentDelete.success) { - throw new Error("Failed to delete discussion comment"); - } + requireMutationSuccess( + result.commentDelete, + "Failed to delete discussion comment", + ); return { id: result.commentDelete.entityId, @@ -901,43 +948,39 @@ export async function deleteDiscussionComment( export async function resolveDiscussion( client: GraphQLClient, input: { - threadId: string; - resolvingCommentId?: string; + threadId: UUID; + resolvingCommentId?: UUID; entityKind?: DiscussionEntityKind; }, ): Promise<ResolveDiscussionMutation["commentResolve"]["comment"]> { await assertRootDiscussionThread(client, input.threadId, input.entityKind); - const result = await client.request<ResolveDiscussionMutation>( - ResolveDiscussionDocument, - { - id: input.threadId, - resolvingCommentId: input.resolvingCommentId, - }, - ); - - if (!result.commentResolve.success || !result.commentResolve.comment) { - throw new Error("Failed to resolve discussion"); - } + const result = await client.request(ResolveDiscussionDocument, { + id: input.threadId, + resolvingCommentId: input.resolvingCommentId, + }); - return result.commentResolve.comment; + return requireMutationEntity( + result.commentResolve, + "comment", + "Failed to resolve discussion", + ); } export async function unresolveDiscussion( client: GraphQLClient, - threadId: string, + threadId: UUID, expectedEntityKind?: DiscussionEntityKind, ): Promise<UnresolveDiscussionMutation["commentUnresolve"]["comment"]> { await assertRootDiscussionThread(client, threadId, expectedEntityKind); - const result = await client.request<UnresolveDiscussionMutation>( - UnresolveDiscussionDocument, - { id: threadId }, - ); - - if (!result.commentUnresolve.success || !result.commentUnresolve.comment) { - throw new Error("Failed to unresolve discussion"); - } + const result = await client.request(UnresolveDiscussionDocument, { + id: threadId, + }); - return result.commentUnresolve.comment; + return requireMutationEntity( + result.commentUnresolve, + "comment", + "Failed to unresolve discussion", + ); } diff --git a/src/services/document-service.ts b/src/services/document-service.ts index 98c5d6ec..95119e19 100644 --- a/src/services/document-service.ts +++ b/src/services/document-service.ts @@ -1,17 +1,15 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { - CreatedDocument, - Document, - DocumentListItem, - PaginatedResult, - UpdatedDocument, -} from "../common/types.js"; +import type { BrandUuidFields, UUID } from "../common/identifier.js"; +import { + requireMutationEntity, + requireMutationSuccess, +} from "../common/mutation-payload.js"; +import type { PaginatedResult } from "../common/types.js"; import { DocumentCreateDocument, type DocumentCreateInput, type DocumentCreateMutation, DocumentDeleteDocument, - type DocumentDeleteMutation, type DocumentFilter, DocumentUpdateDocument, type DocumentUpdateInput, @@ -22,11 +20,58 @@ import { type ListDocumentsQuery, } from "../gql/graphql.js"; +// Document projection types +export type DocumentDetail = NonNullable<GetDocumentQuery["document"]>; +export type DocumentListItem = ListDocumentsQuery["documents"]["nodes"][0]; +export type CreatedDocument = + DocumentCreateMutation["documentCreate"]["document"]; +export type UpdatedDocument = + DocumentUpdateMutation["documentUpdate"]["document"]; + +// Service-owned input types (UUIDs pre-resolved by the command). +export type CreateDocumentInput = BrandUuidFields< + Pick< + DocumentCreateInput, + "title" | "content" | "projectId" | "teamId" | "issueId" | "icon" | "color" + >, + "projectId" | "teamId" | "issueId" +>; +export type UpdateDocumentInput = BrandUuidFields< + Pick< + DocumentUpdateInput, + "title" | "content" | "projectId" | "icon" | "color" + >, + "projectId" +>; + +export function buildProjectDocumentFilter(projectId: UUID): DocumentFilter { + return { project: { id: { eq: projectId } } }; +} + +export function buildIssueDocumentFilter( + issueId: UUID, + legacyDocumentSlugIds: string[], +): DocumentFilter { + const issueFilter: DocumentFilter = { issue: { id: { eq: issueId } } }; + if (legacyDocumentSlugIds.length === 0) { + return issueFilter; + } + + return { + or: [ + issueFilter, + ...legacyDocumentSlugIds.map((slugId) => ({ + slugId: { eq: slugId }, + })), + ], + }; +} + export async function getDocument( client: GraphQLClient, - id: string, -): Promise<Document> { - const result = await client.request<GetDocumentQuery>(GetDocumentDocument, { + id: UUID, +): Promise<DocumentDetail> { + const result = await client.request(GetDocumentDocument, { id, }); @@ -39,35 +84,36 @@ export async function getDocument( export async function createDocument( client: GraphQLClient, - input: DocumentCreateInput, + input: CreateDocumentInput, ): Promise<CreatedDocument> { - const result = await client.request<DocumentCreateMutation>( - DocumentCreateDocument, - { input }, - ); - - if (!result.documentCreate.success || !result.documentCreate.document) { - throw new Error("Failed to create document"); - } + const gqlInput: DocumentCreateInput = input; + const result = await client.request(DocumentCreateDocument, { + input: gqlInput, + }); - return result.documentCreate.document; + return requireMutationEntity( + result.documentCreate, + "document", + "Failed to create document", + ); } export async function updateDocument( client: GraphQLClient, - id: string, - input: DocumentUpdateInput, + id: UUID, + input: UpdateDocumentInput, ): Promise<UpdatedDocument> { - const result = await client.request<DocumentUpdateMutation>( - DocumentUpdateDocument, - { id, input }, - ); - - if (!result.documentUpdate.success || !result.documentUpdate.document) { - throw new Error("Failed to update document"); - } + const gqlInput: DocumentUpdateInput = input; + const result = await client.request(DocumentUpdateDocument, { + id, + input: gqlInput, + }); - return result.documentUpdate.document; + return requireMutationEntity( + result.documentUpdate, + "document", + "Failed to update document", + ); } export async function listDocuments( @@ -78,14 +124,11 @@ export async function listDocuments( filter?: DocumentFilter; }, ): Promise<PaginatedResult<DocumentListItem>> { - const result = await client.request<ListDocumentsQuery>( - ListDocumentsDocument, - { - first: options?.limit ?? 25, - after: options?.after, - filter: options?.filter, - }, - ); + const result = await client.request(ListDocumentsDocument, { + first: options?.limit ?? 25, + after: options?.after, + filter: options?.filter, + }); return { nodes: result.documents?.nodes ?? [], @@ -96,39 +139,13 @@ export async function listDocuments( }; } -export async function listDocumentsBySlugIds( - client: GraphQLClient, - slugIds: string[], -): Promise<DocumentListItem[]> { - if (slugIds.length === 0) { - return []; - } - - const result = await client.request<ListDocumentsQuery>( - ListDocumentsDocument, - { - first: slugIds.length, - filter: { - slugId: { in: slugIds }, - }, - }, - ); - - return result.documents?.nodes ?? []; -} - export async function deleteDocument( client: GraphQLClient, - id: string, + id: UUID, ): Promise<{ id: string; success: boolean }> { - const result = await client.request<DocumentDeleteMutation>( - DocumentDeleteDocument, - { id }, - ); + const result = await client.request(DocumentDeleteDocument, { id }); - if (!result.documentDelete.success) { - throw new Error("Failed to delete document"); - } + requireMutationSuccess(result.documentDelete, "Failed to delete document"); return { id: result.documentDelete.entity?.id ?? id, success: true }; } diff --git a/src/services/file-service.ts b/src/services/file-service.ts index c23e3d8a..b246cb9a 100644 --- a/src/services/file-service.ts +++ b/src/services/file-service.ts @@ -195,7 +195,7 @@ export class FileService { // Make HTTP request (with Bearer token only if not a signed URL) const headers: Record<string, string> = {}; if (!isSignedUrl) { - headers.Authorization = `Bearer ${this.apiToken}`; + headers["Authorization"] = `Bearer ${this.apiToken}`; } const response = await fetch(url, { diff --git a/src/services/initiative-project-service.ts b/src/services/initiative-project-service.ts index e44e0029..d4d01095 100644 --- a/src/services/initiative-project-service.ts +++ b/src/services/initiative-project-service.ts @@ -1,8 +1,6 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { - DeletedInitiativeProjectLink, - InitiativeProjectLink, -} from "../common/types.js"; +import type { UUID } from "../common/identifier.js"; +import { requireMutationEntity } from "../common/mutation-payload.js"; import { CreateInitiativeToProjectDocument, type CreateInitiativeToProjectMutation, @@ -10,47 +8,48 @@ import { type DeleteInitiativeToProjectMutation, } from "../gql/graphql.js"; +// Initiative-project link projection types +export type InitiativeProjectLink = NonNullable< + CreateInitiativeToProjectMutation["initiativeToProjectCreate"]["initiativeToProject"] +>; +export type DeletedInitiativeProjectLink = { + id: NonNullable< + DeleteInitiativeToProjectMutation["initiativeToProjectDelete"]["entityId"] + >; + success: true; +}; + export async function createInitiativeProjectLink( client: GraphQLClient, - input: { initiativeId: string; projectId: string }, + input: { initiativeId: UUID; projectId: UUID }, ): Promise<InitiativeProjectLink> { - const result = await client.request<CreateInitiativeToProjectMutation>( - CreateInitiativeToProjectDocument, - { - input, - }, - ); - - if ( - !result.initiativeToProjectCreate.success || - !result.initiativeToProjectCreate.initiativeToProject - ) { - throw new Error( - `Failed to create initiative-project link for initiative "${input.initiativeId}" and project "${input.projectId}"`, - ); - } + const result = await client.request(CreateInitiativeToProjectDocument, { + input, + }); - return result.initiativeToProjectCreate.initiativeToProject; + return requireMutationEntity( + result.initiativeToProjectCreate, + "initiativeToProject", + `Failed to create initiative-project link for initiative "${input.initiativeId}" and project "${input.projectId}"`, + ); } export async function deleteInitiativeProjectLink( client: GraphQLClient, - id: string, + id: UUID, ): Promise<DeletedInitiativeProjectLink> { - const result = await client.request<DeleteInitiativeToProjectMutation>( - DeleteInitiativeToProjectDocument, - { id }, - ); + const result = await client.request(DeleteInitiativeToProjectDocument, { + id, + }); - if ( - !result.initiativeToProjectDelete.success || - !result.initiativeToProjectDelete.entityId - ) { - throw new Error(`Failed to delete initiative-project link "${id}"`); - } + const entityId = requireMutationEntity( + result.initiativeToProjectDelete, + "entityId", + `Failed to delete initiative-project link "${id}"`, + ); return { - id: result.initiativeToProjectDelete.entityId, + id: entityId, success: true, }; } diff --git a/src/services/initiative-relation-service.ts b/src/services/initiative-relation-service.ts index 21b587e2..254ab5b3 100644 --- a/src/services/initiative-relation-service.ts +++ b/src/services/initiative-relation-service.ts @@ -1,8 +1,6 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { - DeletedInitiativeRelation, - InitiativeRelation, -} from "../common/types.js"; +import type { UUID } from "../common/identifier.js"; +import { requireMutationEntity } from "../common/mutation-payload.js"; import { CreateInitiativeRelationDocument, type CreateInitiativeRelationMutation, @@ -10,50 +8,49 @@ import { type DeleteInitiativeRelationMutation, } from "../gql/graphql.js"; +// Initiative relation projection types +export type InitiativeRelation = NonNullable< + CreateInitiativeRelationMutation["initiativeRelationCreate"]["initiativeRelation"] +>; +export type DeletedInitiativeRelation = { + id: NonNullable< + DeleteInitiativeRelationMutation["initiativeRelationDelete"]["entityId"] + >; + success: true; +}; + export async function createInitiativeRelation( client: GraphQLClient, - input: { parentId: string; childId: string }, + input: { parentId: UUID; childId: UUID }, ): Promise<InitiativeRelation> { - const result = await client.request<CreateInitiativeRelationMutation>( - CreateInitiativeRelationDocument, - { - input: { - initiativeId: input.parentId, - relatedInitiativeId: input.childId, - }, + const result = await client.request(CreateInitiativeRelationDocument, { + input: { + initiativeId: input.parentId, + relatedInitiativeId: input.childId, }, - ); - - if ( - !result.initiativeRelationCreate.success || - !result.initiativeRelationCreate.initiativeRelation - ) { - throw new Error( - `Failed to create initiative relation from "${input.parentId}" to "${input.childId}"`, - ); - } + }); - return result.initiativeRelationCreate.initiativeRelation; + return requireMutationEntity( + result.initiativeRelationCreate, + "initiativeRelation", + `Failed to create initiative relation from "${input.parentId}" to "${input.childId}"`, + ); } export async function deleteInitiativeRelation( client: GraphQLClient, - id: string, + id: UUID, ): Promise<DeletedInitiativeRelation> { - const result = await client.request<DeleteInitiativeRelationMutation>( - DeleteInitiativeRelationDocument, - { id }, - ); + const result = await client.request(DeleteInitiativeRelationDocument, { id }); - if ( - !result.initiativeRelationDelete.success || - !result.initiativeRelationDelete.entityId - ) { - throw new Error(`Failed to delete initiative relation "${id}"`); - } + const entityId = requireMutationEntity( + result.initiativeRelationDelete, + "entityId", + `Failed to delete initiative relation "${id}"`, + ); return { - id: result.initiativeRelationDelete.entityId, + id: entityId, success: true, }; } diff --git a/src/services/initiative-service.ts b/src/services/initiative-service.ts index 073f5fe9..43afef61 100644 --- a/src/services/initiative-service.ts +++ b/src/services/initiative-service.ts @@ -1,15 +1,8 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import { invalidParameterError } from "../common/errors.js"; -import type { - ArchivedInitiative, - CreatedInitiative, - DeletedInitiative, - InitiativeDetail, - InitiativeListItem, - PaginatedResult, - UnarchivedInitiative, - UpdatedInitiative, -} from "../common/types.js"; +import type { BrandUuidFields, UUID } from "../common/identifier.js"; +import { requireMutationEntity } from "../common/mutation-payload.js"; +import type { PaginatedResult } from "../common/types.js"; import { ArchiveInitiativeDocument, type ArchiveInitiativeMutation, @@ -20,16 +13,270 @@ import { GetInitiativeDocument, type GetInitiativeQuery, type InitiativeCreateInput, + type InitiativeSortInput, + type InitiativeStatus, type InitiativeUpdateInput, ListInitiativesDocument, type ListInitiativesQuery, type ListInitiativesQueryVariables, + type PaginationOrderBy, UnarchiveInitiativeDocument, type UnarchiveInitiativeMutation, UpdateInitiativeDocument, type UpdateInitiativeMutation, } from "../gql/graphql.js"; +// Initiative projection types +export type InitiativeListItem = + ListInitiativesQuery["initiatives"]["nodes"][0]; +export type InitiativeDetail = NonNullable<GetInitiativeQuery["initiative"]>; +export type CreatedInitiative = NonNullable< + CreateInitiativeMutation["initiativeCreate"]["initiative"] +>; +export type UpdatedInitiative = NonNullable< + UpdateInitiativeMutation["initiativeUpdate"]["initiative"] +>; +export type ArchivedInitiative = NonNullable< + ArchiveInitiativeMutation["initiativeArchive"]["entity"] +>; +export type UnarchivedInitiative = NonNullable< + UnarchiveInitiativeMutation["initiativeUnarchive"]["entity"] +>; +export type DeletedInitiative = { + id: NonNullable<DeleteInitiativeMutation["initiativeDelete"]["entityId"]>; + success: true; +}; + +// Service-owned input types (UUIDs pre-resolved by the command). +export type CreateInitiativeInput = BrandUuidFields< + Pick< + InitiativeCreateInput, + | "name" + | "description" + | "content" + | "ownerId" + | "status" + | "targetDate" + | "sortOrder" + >, + "ownerId" +>; +export type UpdateInitiativeInput = BrandUuidFields< + Pick< + InitiativeUpdateInput, + | "name" + | "description" + | "content" + | "ownerId" + | "status" + | "targetDate" + | "sortOrder" + >, + "ownerId" +>; + +export type InitiativeSortBy = + | "name" + | "createdAt" + | "updatedAt" + | "targetDate" + | "health" + | "healthUpdatedAt" + | "manual" + | "owner"; + +const INITIATIVE_STATUS_VALUES = ["Planned", "Active", "Completed"] as const; + +export function parseInitiativeStatus( + value?: string, +): InitiativeStatus | undefined { + if (!value) return undefined; + + const normalized = value.toLowerCase(); + const match = INITIATIVE_STATUS_VALUES.find( + (status) => status.toLowerCase() === normalized, + ); + if (match) return match; + + throw invalidParameterError( + "--status", + 'must be one of: "Planned", "Active", "Completed"', + ); +} + +export function mapSortByToPaginationOrderBy( + sortBy?: InitiativeSortBy, +): PaginationOrderBy | undefined { + return sortBy === "createdAt" || sortBy === "updatedAt" ? sortBy : undefined; +} + +export function mapSortByToInitiativeSort( + sortBy?: InitiativeSortBy, + sortOrder?: "asc" | "desc", +): ListInitiativesQueryVariables["sort"] | undefined { + if (!sortBy) return undefined; + + const withNulls = { + order: sortOrder === "desc" ? "Descending" : "Ascending", + nulls: "last", + } as const; + + const sortEntry: InitiativeSortInput = + sortBy === "manual" + ? { manual: withNulls } + : sortBy === "name" + ? { name: withNulls } + : sortBy === "createdAt" + ? { createdAt: withNulls } + : sortBy === "updatedAt" + ? { updatedAt: withNulls } + : sortBy === "targetDate" + ? { targetDate: withNulls } + : sortBy === "health" + ? { health: withNulls } + : sortBy === "healthUpdatedAt" + ? { healthUpdatedAt: withNulls } + : { owner: withNulls }; + + return [sortEntry]; +} + +// Filter input carrying pre-resolved UUIDs and a parsed status; the command +// resolves human-friendly IDs before calling buildInitiativeFilter. +export interface InitiativeFilterInput { + id?: string; + slug?: string; + name?: string; + status?: InitiativeStatus; + health?: string; + healthWithAge?: string; + ownerId?: UUID; + creatorId?: UUID; + teamId?: UUID; + targetAfter?: string; + targetBefore?: string; + startedAfter?: string; + startedBefore?: string; + completedAfter?: string; + completedBefore?: string; + createdAfter?: string; + createdBefore?: string; + updatedAfter?: string; + updatedBefore?: string; + ancestorId?: UUID; +} + +function applyNullableDateRange( + // Accepts a GraphQL date comparator whose optional fields are `InputMaybe` + // (i.e. include `undefined`); the function only ever writes to gte/lte. + target: { + gte?: string | null | undefined; + lte?: string | null | undefined; + }, + after?: string, + before?: string, +): void { + if (after !== undefined) { + target.gte = after; + } + if (before !== undefined) { + target.lte = before; + } +} + +export function buildInitiativeFilter( + input: InitiativeFilterInput, +): ListInitiativesQueryVariables["filter"] | undefined { + const filter: NonNullable<ListInitiativesQueryVariables["filter"]> = {}; + + if (input.id) { + filter.id = { eq: input.id }; + } + + if (input.slug) { + filter.slugId = { eqIgnoreCase: input.slug }; + } + + if (input.name) { + filter.name = { eqIgnoreCase: input.name }; + } + + if (input.status) { + filter.status = { eq: input.status }; + } + + if (input.health) { + filter.health = { eq: input.health }; + } + + if (input.healthWithAge) { + filter.healthWithAge = { eq: input.healthWithAge }; + } + + if (input.ownerId) { + filter.owner = { id: { eq: input.ownerId } }; + } + + if (input.creatorId) { + filter.creator = { id: { eq: input.creatorId } }; + } + + if (input.teamId) { + filter.teams = { some: { id: { eq: input.teamId } } }; + } + + if (input.targetAfter || input.targetBefore) { + filter.targetDate = {}; + applyNullableDateRange( + filter.targetDate, + input.targetAfter, + input.targetBefore, + ); + } + + if (input.startedAfter || input.startedBefore) { + filter.startedAt = {}; + applyNullableDateRange( + filter.startedAt, + input.startedAfter, + input.startedBefore, + ); + } + + if (input.completedAfter || input.completedBefore) { + filter.completedAt = {}; + applyNullableDateRange( + filter.completedAt, + input.completedAfter, + input.completedBefore, + ); + } + + if (input.createdAfter || input.createdBefore) { + filter.createdAt = {}; + applyNullableDateRange( + filter.createdAt, + input.createdAfter, + input.createdBefore, + ); + } + + if (input.updatedAfter || input.updatedBefore) { + filter.updatedAt = {}; + applyNullableDateRange( + filter.updatedAt, + input.updatedAfter, + input.updatedBefore, + ); + } + + if (input.ancestorId) { + filter.ancestors = { some: { id: { eq: input.ancestorId } } }; + } + + return Object.keys(filter).length > 0 ? filter : undefined; +} + export interface InitiativeListOptions { limit?: number; after?: string; @@ -52,17 +299,14 @@ export async function listInitiatives( sort, } = options; - const result = await client.request<ListInitiativesQuery>( - ListInitiativesDocument, - { - first: limit, - after, - includeArchived, - filter, - orderBy, - sort, - }, - ); + const result = await client.request(ListInitiativesDocument, { + first: limit, + after, + includeArchived, + filter, + orderBy, + sort, + }); return { nodes: result.initiatives.nodes, @@ -72,14 +316,11 @@ export async function listInitiatives( export async function getInitiative( client: GraphQLClient, - id: string, + id: UUID, ): Promise<InitiativeDetail> { - const result = await client.request<GetInitiativeQuery>( - GetInitiativeDocument, - { - id, - }, - ); + const result = await client.request(GetInitiativeDocument, { + id, + }); if (!result.initiative) { throw new Error(`Initiative with ID "${id}" not found`); @@ -90,26 +331,24 @@ export async function getInitiative( export async function createInitiative( client: GraphQLClient, - input: InitiativeCreateInput, + input: CreateInitiativeInput, ): Promise<CreatedInitiative> { - const result = await client.request<CreateInitiativeMutation>( - CreateInitiativeDocument, - { - input, - }, - ); + const gqlInput: InitiativeCreateInput = input; + const result = await client.request(CreateInitiativeDocument, { + input: gqlInput, + }); - if (!result.initiativeCreate.success || !result.initiativeCreate.initiative) { - throw new Error(`Failed to create initiative "${input.name}"`); - } - - return result.initiativeCreate.initiative; + return requireMutationEntity( + result.initiativeCreate, + "initiative", + `Failed to create initiative "${input.name}"`, + ); } export async function updateInitiative( client: GraphQLClient, - id: string, - input: InitiativeUpdateInput, + id: UUID, + input: UpdateInitiativeInput, ): Promise<UpdatedInitiative> { const hasAtLeastOneField = Object.values(input).some( (value) => value !== undefined, @@ -122,71 +361,59 @@ export async function updateInitiative( ); } - const result = await client.request<UpdateInitiativeMutation>( - UpdateInitiativeDocument, - { - id, - input, - }, - ); - - if (!result.initiativeUpdate.success || !result.initiativeUpdate.initiative) { - throw new Error(`Failed to update initiative "${id}"`); - } + const gqlInput: InitiativeUpdateInput = input; + const result = await client.request(UpdateInitiativeDocument, { + id, + input: gqlInput, + }); - return result.initiativeUpdate.initiative; + return requireMutationEntity( + result.initiativeUpdate, + "initiative", + `Failed to update initiative "${id}"`, + ); } export async function archiveInitiative( client: GraphQLClient, - id: string, + id: UUID, ): Promise<ArchivedInitiative> { - const result = await client.request<ArchiveInitiativeMutation>( - ArchiveInitiativeDocument, - { id }, - ); + const result = await client.request(ArchiveInitiativeDocument, { id }); - if (!result.initiativeArchive.success || !result.initiativeArchive.entity) { - throw new Error(`Failed to archive initiative "${id}"`); - } - - return result.initiativeArchive.entity; + return requireMutationEntity( + result.initiativeArchive, + "entity", + `Failed to archive initiative "${id}"`, + ); } export async function unarchiveInitiative( client: GraphQLClient, - id: string, + id: UUID, ): Promise<UnarchivedInitiative> { - const result = await client.request<UnarchiveInitiativeMutation>( - UnarchiveInitiativeDocument, - { id }, - ); - - if ( - !result.initiativeUnarchive.success || - !result.initiativeUnarchive.entity - ) { - throw new Error(`Failed to unarchive initiative "${id}"`); - } + const result = await client.request(UnarchiveInitiativeDocument, { id }); - return result.initiativeUnarchive.entity; + return requireMutationEntity( + result.initiativeUnarchive, + "entity", + `Failed to unarchive initiative "${id}"`, + ); } export async function deleteInitiative( client: GraphQLClient, - id: string, + id: UUID, ): Promise<DeletedInitiative> { - const result = await client.request<DeleteInitiativeMutation>( - DeleteInitiativeDocument, - { id }, - ); + const result = await client.request(DeleteInitiativeDocument, { id }); - if (!result.initiativeDelete.success || !result.initiativeDelete.entityId) { - throw new Error(`Failed to delete initiative "${id}"`); - } + const entityId = requireMutationEntity( + result.initiativeDelete, + "entityId", + `Failed to delete initiative "${id}"`, + ); return { - id: result.initiativeDelete.entityId, + id: entityId, success: true, }; } diff --git a/src/services/initiative-update-service.ts b/src/services/initiative-update-service.ts index af552bc7..fd2aa4f4 100644 --- a/src/services/initiative-update-service.ts +++ b/src/services/initiative-update-service.ts @@ -1,14 +1,8 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import { invalidParameterError } from "../common/errors.js"; -import type { - ArchivedInitiativeUpdate, - CreatedInitiativeUpdate, - InitiativeUpdateDetail, - InitiativeUpdateListItem, - PaginatedResult, - UnarchivedInitiativeUpdate, - UpdatedInitiativeUpdate, -} from "../common/types.js"; +import type { BrandUuidFields, UUID } from "../common/identifier.js"; +import { requireMutationEntity } from "../common/mutation-payload.js"; +import type { PaginatedResult } from "../common/types.js"; import { ArchiveInitiativeUpdateDocument, type ArchiveInitiativeUpdateMutation, @@ -17,6 +11,7 @@ import { GetInitiativeUpdateDocument, type GetInitiativeUpdateQuery, type InitiativeUpdateCreateInput, + type InitiativeUpdateHealthType, type InitiativeUpdateUpdateInput, ListInitiativeUpdatesDocument, type ListInitiativeUpdatesQuery, @@ -26,28 +21,70 @@ import { type UpdateInitiativeUpdateMutation, } from "../gql/graphql.js"; +// Initiative update projection types +export type InitiativeUpdateListItem = + ListInitiativeUpdatesQuery["initiativeUpdates"]["nodes"][0]; +export type InitiativeUpdateDetail = NonNullable< + GetInitiativeUpdateQuery["initiativeUpdate"] +>; +export type CreatedInitiativeUpdate = NonNullable< + CreateInitiativeUpdateMutation["initiativeUpdateCreate"]["initiativeUpdate"] +>; +export type UpdatedInitiativeUpdate = NonNullable< + UpdateInitiativeUpdateMutation["initiativeUpdateUpdate"]["initiativeUpdate"] +>; +export type ArchivedInitiativeUpdate = NonNullable< + ArchiveInitiativeUpdateMutation["initiativeUpdateArchive"]["entity"] +>; +export type UnarchivedInitiativeUpdate = NonNullable< + UnarchiveInitiativeUpdateMutation["initiativeUpdateUnarchive"]["entity"] +>; + export interface InitiativeUpdateListOptions { - initiativeId: string; + initiativeId: UUID; limit?: number; after?: string; includeArchived?: boolean; } +// Service-owned input types (UUIDs pre-resolved by the command). +export type CreateInitiativeUpdateInput = BrandUuidFields< + Pick<InitiativeUpdateCreateInput, "initiativeId" | "body" | "health">, + "initiativeId" +>; +export type UpdateInitiativeUpdateInput = Pick< + InitiativeUpdateUpdateInput, + "body" | "health" +>; + +export function parseHealth( + value?: string, +): InitiativeUpdateHealthType | undefined { + if (!value) return undefined; + + const normalized = value.trim().toLowerCase(); + if (normalized === "ontrack") return "onTrack"; + if (normalized === "atrisk") return "atRisk"; + if (normalized === "offtrack") return "offTrack"; + + throw invalidParameterError( + "--health", + 'must be one of: "onTrack", "atRisk", "offTrack"', + ); +} + export async function listInitiativeUpdates( client: GraphQLClient, options: InitiativeUpdateListOptions, ): Promise<PaginatedResult<InitiativeUpdateListItem>> { const { initiativeId, limit = 50, after, includeArchived = false } = options; - const result = await client.request<ListInitiativeUpdatesQuery>( - ListInitiativeUpdatesDocument, - { - initiativeId, - first: limit, - after, - includeArchived, - }, - ); + const result = await client.request(ListInitiativeUpdatesDocument, { + initiativeId, + first: limit, + after, + includeArchived, + }); return { nodes: result.initiativeUpdates.nodes, @@ -57,12 +94,9 @@ export async function listInitiativeUpdates( export async function getInitiativeUpdate( client: GraphQLClient, - id: string, + id: UUID, ): Promise<InitiativeUpdateDetail> { - const result = await client.request<GetInitiativeUpdateQuery>( - GetInitiativeUpdateDocument, - { id }, - ); + const result = await client.request(GetInitiativeUpdateDocument, { id }); if (!result.initiativeUpdate) { throw new Error(`Initiative update with ID "${id}" not found`); @@ -73,27 +107,24 @@ export async function getInitiativeUpdate( export async function createInitiativeUpdate( client: GraphQLClient, - input: InitiativeUpdateCreateInput, + input: CreateInitiativeUpdateInput, ): Promise<CreatedInitiativeUpdate> { - const result = await client.request<CreateInitiativeUpdateMutation>( - CreateInitiativeUpdateDocument, - { input }, + const gqlInput: InitiativeUpdateCreateInput = input; + const result = await client.request(CreateInitiativeUpdateDocument, { + input: gqlInput, + }); + + return requireMutationEntity( + result.initiativeUpdateCreate, + "initiativeUpdate", + "Failed to create initiative update", ); - - if ( - !result.initiativeUpdateCreate.success || - !result.initiativeUpdateCreate.initiativeUpdate - ) { - throw new Error("Failed to create initiative update"); - } - - return result.initiativeUpdateCreate.initiativeUpdate; } export async function updateInitiativeUpdate( client: GraphQLClient, - id: string, - input: InitiativeUpdateUpdateInput, + id: UUID, + input: UpdateInitiativeUpdateInput, ): Promise<UpdatedInitiativeUpdate> { const hasAtLeastOneField = Object.values(input).some( (value) => value !== undefined, @@ -106,55 +137,43 @@ export async function updateInitiativeUpdate( ); } - const result = await client.request<UpdateInitiativeUpdateMutation>( - UpdateInitiativeUpdateDocument, - { id, input }, - ); - - if ( - !result.initiativeUpdateUpdate.success || - !result.initiativeUpdateUpdate.initiativeUpdate - ) { - throw new Error(`Failed to update initiative update "${id}"`); - } + const gqlInput: InitiativeUpdateUpdateInput = input; + const result = await client.request(UpdateInitiativeUpdateDocument, { + id, + input: gqlInput, + }); - return result.initiativeUpdateUpdate.initiativeUpdate; + return requireMutationEntity( + result.initiativeUpdateUpdate, + "initiativeUpdate", + `Failed to update initiative update "${id}"`, + ); } export async function archiveInitiativeUpdate( client: GraphQLClient, - id: string, + id: UUID, ): Promise<ArchivedInitiativeUpdate> { - const result = await client.request<ArchiveInitiativeUpdateMutation>( - ArchiveInitiativeUpdateDocument, - { id }, - ); - - if ( - !result.initiativeUpdateArchive.success || - !result.initiativeUpdateArchive.entity - ) { - throw new Error(`Failed to archive initiative update "${id}"`); - } + const result = await client.request(ArchiveInitiativeUpdateDocument, { id }); - return result.initiativeUpdateArchive.entity; + return requireMutationEntity( + result.initiativeUpdateArchive, + "entity", + `Failed to archive initiative update "${id}"`, + ); } export async function unarchiveInitiativeUpdate( client: GraphQLClient, - id: string, + id: UUID, ): Promise<UnarchivedInitiativeUpdate> { - const result = await client.request<UnarchiveInitiativeUpdateMutation>( - UnarchiveInitiativeUpdateDocument, - { id }, + const result = await client.request(UnarchiveInitiativeUpdateDocument, { + id, + }); + + return requireMutationEntity( + result.initiativeUpdateUnarchive, + "entity", + `Failed to unarchive initiative update "${id}"`, ); - - if ( - !result.initiativeUpdateUnarchive.success || - !result.initiativeUpdateUnarchive.entity - ) { - throw new Error(`Failed to unarchive initiative update "${id}"`); - } - - return result.initiativeUpdateUnarchive.entity; } diff --git a/src/services/issue-relation-service.ts b/src/services/issue-relation-service.ts index 995035fa..62fedfef 100644 --- a/src/services/issue-relation-service.ts +++ b/src/services/issue-relation-service.ts @@ -1,43 +1,71 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import { notFoundError } from "../common/errors.js"; -import type { CreatedIssueRelation } from "../common/types.js"; +import { asUuid, type UUID } from "../common/identifier.js"; +import { requireMutationSuccess } from "../common/mutation-payload.js"; import { CreateIssueRelationDocument, type CreateIssueRelationMutation, DeleteIssueRelationDocument, - type DeleteIssueRelationMutation, GetIssueRelationsDocument, type GetIssueRelationsQuery, type IssueRelationType, } from "../gql/graphql.js"; +// Issue relation projection types +export type CreatedIssueRelation = + CreateIssueRelationMutation["issueRelationCreate"]["issueRelation"]; + +type IssueRelationsIssue = NonNullable<GetIssueRelationsQuery["issue"]>; + export async function createIssueRelation( client: GraphQLClient, input: { - issueId: string; - relatedIssueId: string; + issueId: UUID; + relatedIssueId: UUID; type: IssueRelationType; }, ): Promise<CreatedIssueRelation> { - const result = await client.request<CreateIssueRelationMutation>( - CreateIssueRelationDocument, - { input }, + const result = await client.request(CreateIssueRelationDocument, { input }); + requireMutationSuccess( + result.issueRelationCreate, + "Failed to create issue relation", ); - if (!result.issueRelationCreate.success) { - throw new Error("Failed to create issue relation"); - } return result.issueRelationCreate.issueRelation; } +export async function listIssueRelations( + client: GraphQLClient, + issueId: UUID, +): Promise<{ + issueId: string; + identifier: string; + relations: Array< + | IssueRelationsIssue["relations"]["nodes"][0] + | IssueRelationsIssue["inverseRelations"]["nodes"][0] + >; +}> { + const result = await client.request(GetIssueRelationsDocument, { issueId }); + + if (!result.issue) { + throw notFoundError("Issue", issueId); + } + + return { + issueId: result.issue.id, + identifier: result.issue.identifier, + relations: [ + ...result.issue.relations.nodes, + ...result.issue.inverseRelations.nodes, + ], + }; +} + export async function findIssueRelation( client: GraphQLClient, - issueId: string, - relatedIssueId: string, -): Promise<string> { - const result = await client.request<GetIssueRelationsQuery>( - GetIssueRelationsDocument, - { issueId }, - ); + issueId: UUID, + relatedIssueId: UUID, +): Promise<UUID> { + const result = await client.request(GetIssueRelationsDocument, { issueId }); if (!result.issue) { throw notFoundError("Issue", issueId); @@ -47,27 +75,27 @@ export async function findIssueRelation( const forwardMatch = result.issue.relations.nodes.find( (r) => r.relatedIssue.id === relatedIssueId, ); - if (forwardMatch) return forwardMatch.id; + if (forwardMatch) return asUuid(forwardMatch.id); // Check inverse relations const inverseMatch = result.issue.inverseRelations.nodes.find( (r) => r.issue.id === relatedIssueId, ); - if (inverseMatch) return inverseMatch.id; + if (inverseMatch) return asUuid(inverseMatch.id); throw notFoundError("Relation", `between ${issueId} and ${relatedIssueId}`); } export async function deleteIssueRelation( client: GraphQLClient, - relationId: string, + relationId: UUID, ): Promise<{ id: string; success: boolean }> { - const result = await client.request<DeleteIssueRelationMutation>( - DeleteIssueRelationDocument, - { id: relationId }, + const result = await client.request(DeleteIssueRelationDocument, { + id: relationId, + }); + requireMutationSuccess( + result.issueRelationDelete, + "Failed to delete issue relation", ); - if (!result.issueRelationDelete.success) { - throw new Error("Failed to delete issue relation"); - } return { id: result.issueRelationDelete.entityId, success: true }; } diff --git a/src/services/issue-service.ts b/src/services/issue-service.ts index 7f4e0adf..11dca0f4 100644 --- a/src/services/issue-service.ts +++ b/src/services/issue-service.ts @@ -1,31 +1,14 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { - CreatedIssue, - Issue, - IssueByIdentifier, - IssueByIdentifierWithAttachments, - IssueByIdentifierWithComments, - IssueByIdentifierWithCommentThreads, - IssueComment, - IssueCommentThread, - IssueDetail, - IssueDetailWithAttachments, - IssueDetailWithComments, - IssueDetailWithCommentThreads, - IssueSearchResult, - PaginatedResult, - PaginationOptions, - UpdatedIssue, -} from "../common/types.js"; +import { firstOrThrow } from "../common/array.js"; +import type { BrandUuidFields, UUID } from "../common/identifier.js"; +import { requireMutationEntity } from "../common/mutation-payload.js"; +import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { ArchiveIssueDocument, - type ArchiveIssueMutation, CreateIssueDocument, type CreateIssueMutation, DeleteIssueDocument, - type DeleteIssueMutation, FilteredSearchIssuesDocument, - type FilteredSearchIssuesQuery, GetIssueByIdDocument, GetIssueByIdentifierDocument, type GetIssueByIdentifierQuery, @@ -47,17 +30,107 @@ import { type IssueCreateInput, type IssueFilter, type IssueUpdateInput, - PaginationOrderBy, SearchIssuesDocument, type SearchIssuesQuery, type SearchIssuesQueryVariables, UnarchiveIssueDocument, - type UnarchiveIssueMutation, UpdateIssueDocument, type UpdateIssueMutation, } from "../gql/graphql.js"; import { normalizeReactions } from "./reaction-service.js"; +// Issue projection types +export type IssueListItem = GetIssuesQuery["issues"]["nodes"][0]; +export type IssueDetail = NonNullable<GetIssueByIdQuery["issue"]>; +export type IssueByIdentifier = GetIssueByIdentifierQuery["issues"]["nodes"][0]; +export type IssueDetailWithComments = NonNullable< + GetIssueByIdWithCommentsQuery["issue"] +>; +export type IssueByIdentifierWithComments = + GetIssueByIdentifierWithCommentsQuery["issues"]["nodes"][0]; +type IssueComment = NonNullable< + NonNullable<IssueDetailWithComments["comments"]>["nodes"][0] +>; +type IssueCommentThread = IssueComment & { + replies: IssueCommentThread[]; +}; +export type IssueDetailWithCommentThreads = Omit< + IssueDetailWithComments, + "comments" +> & { + comments: { nodes: IssueCommentThread[] }; +}; +export type IssueByIdentifierWithCommentThreads = Omit< + IssueByIdentifierWithComments, + "comments" +> & { + comments: { nodes: IssueCommentThread[] }; +}; +export type IssueDetailWithAttachments = NonNullable< + GetIssueByIdWithAttachmentsQuery["issue"] +>; +export type IssueByIdentifierWithAttachments = + GetIssueByIdentifierWithAttachmentsQuery["issues"]["nodes"][0]; +export type IssueSearchResult = SearchIssuesQuery["searchIssues"]["nodes"][0]; +export type CreatedIssue = NonNullable< + CreateIssueMutation["issueCreate"]["issue"] +>; +export type UpdatedIssue = NonNullable< + UpdateIssueMutation["issueUpdate"]["issue"] +>; + +// Service-owned input types (UUIDs pre-resolved by the command). +export type CreateIssueInput = BrandUuidFields< + Pick< + IssueCreateInput, + | "title" + | "teamId" + | "description" + | "assigneeId" + | "priority" + | "estimate" + | "projectId" + | "labelIds" + | "projectMilestoneId" + | "cycleId" + | "stateId" + | "parentId" + | "dueDate" + >, + | "teamId" + | "assigneeId" + | "projectId" + | "labelIds" + | "projectMilestoneId" + | "cycleId" + | "stateId" + | "parentId" +>; +export type UpdateIssueInput = BrandUuidFields< + Pick< + IssueUpdateInput, + | "title" + | "description" + | "stateId" + | "priority" + | "estimate" + | "assigneeId" + | "projectId" + | "labelIds" + | "parentId" + | "projectMilestoneId" + | "cycleId" + | "dueDate" + >, + | "stateId" + | "assigneeId" + | "projectId" + | "labelIds" + | "parentId" + | "projectMilestoneId" + | "cycleId" +>; + const NON_COMPLETED_ISSUES_FILTER: IssueFilter = { state: { type: { neq: "completed" } }, }; @@ -197,29 +270,26 @@ export async function listIssues( client: GraphQLClient, options: PaginationOptions = {}, filter?: IssueFilter, -): Promise<PaginatedResult<Issue>> { +): Promise<PaginatedResult<IssueListItem>> { const { limit = 25, after } = options; if (filter) { - const result = await client.request<FilteredSearchIssuesQuery>( - FilteredSearchIssuesDocument, - { - first: limit, - after, - filter: buildListIssuesFilter(filter), - orderBy: PaginationOrderBy.UpdatedAt, - }, - ); + const result = await client.request(FilteredSearchIssuesDocument, { + first: limit, + after, + filter: buildListIssuesFilter(filter), + orderBy: "updatedAt", + }); return { nodes: result.issues?.nodes ?? [], pageInfo: result.issues.pageInfo, }; } - const result = await client.request<GetIssuesQuery>(GetIssuesDocument, { + const result = await client.request(GetIssuesDocument, { first: limit, after, - orderBy: PaginationOrderBy.UpdatedAt, + orderBy: "updatedAt", }); return { nodes: result.issues?.nodes ?? [], @@ -229,9 +299,9 @@ export async function listIssues( export async function getIssue( client: GraphQLClient, - id: string, + id: UUID, ): Promise<IssueDetail> { - const result = await client.request<GetIssueByIdQuery>(GetIssueByIdDocument, { + const result = await client.request(GetIssueByIdDocument, { id, }); if (!result.issue) { @@ -242,12 +312,9 @@ export async function getIssue( export async function getIssueWithComments( client: GraphQLClient, - id: string, + id: UUID, ): Promise<IssueDetailWithComments> { - const result = await client.request<GetIssueByIdWithCommentsQuery>( - GetIssueByIdWithCommentsDocument, - { id }, - ); + const result = await client.request(GetIssueByIdWithCommentsDocument, { id }); if (!result.issue) { throw new Error(`Issue with ID "${id}" not found`); } @@ -256,7 +323,7 @@ export async function getIssueWithComments( export async function getIssueWithCommentThreads( client: GraphQLClient, - id: string, + id: UUID, ): Promise<IssueDetailWithCommentThreads> { const issue = await getIssueWithComments(client, id); return threadIssueComments(issue); @@ -267,16 +334,14 @@ export async function getIssueByIdentifier( teamKey: string, issueNumber: number, ): Promise<IssueByIdentifier> { - const result = await client.request<GetIssueByIdentifierQuery>( - GetIssueByIdentifierDocument, - { teamKey, number: issueNumber }, + const result = await client.request(GetIssueByIdentifierDocument, { + teamKey, + number: issueNumber, + }); + return firstOrThrow( + result.issues.nodes, + `Issue with identifier "${teamKey}-${issueNumber}" not found`, ); - if (!result.issues.nodes.length) { - throw new Error( - `Issue with identifier "${teamKey}-${issueNumber}" not found`, - ); - } - return result.issues.nodes[0]; } export async function getIssueByIdentifierWithComments( @@ -284,16 +349,14 @@ export async function getIssueByIdentifierWithComments( teamKey: string, issueNumber: number, ): Promise<IssueByIdentifierWithComments> { - const result = await client.request<GetIssueByIdentifierWithCommentsQuery>( + const result = await client.request( GetIssueByIdentifierWithCommentsDocument, { teamKey, number: issueNumber }, ); - if (!result.issues.nodes.length) { - throw new Error( - `Issue with identifier "${teamKey}-${issueNumber}" not found`, - ); - } - return result.issues.nodes[0]; + return firstOrThrow( + result.issues.nodes, + `Issue with identifier "${teamKey}-${issueNumber}" not found`, + ); } export async function getIssueByIdentifierWithCommentThreads( @@ -311,12 +374,11 @@ export async function getIssueByIdentifierWithCommentThreads( export async function getIssueWithReactions( client: GraphQLClient, - id: string, + id: UUID, ): Promise<IssueDetailWithReactions> { - const result = await client.request<GetIssueByIdWithReactionsQuery>( - GetIssueByIdWithReactionsDocument, - { id }, - ); + const result = await client.request(GetIssueByIdWithReactionsDocument, { + id, + }); if (!result.issue) { throw new Error(`Issue with ID "${id}" not found`); } @@ -328,26 +390,25 @@ export async function getIssueByIdentifierWithReactions( teamKey: string, issueNumber: number, ): Promise<IssueByIdentifierWithReactions> { - const result = await client.request<GetIssueByIdentifierWithReactionsQuery>( + const result = await client.request( GetIssueByIdentifierWithReactionsDocument, { teamKey, number: issueNumber }, ); - if (!result.issues.nodes.length) { - throw new Error( + return normalizeIssueReactions( + firstOrThrow( + result.issues.nodes, `Issue with identifier "${teamKey}-${issueNumber}" not found`, - ); - } - return normalizeIssueReactions(result.issues.nodes[0]); + ), + ); } export async function getIssueWithAttachments( client: GraphQLClient, - id: string, + id: UUID, ): Promise<IssueDetailWithAttachments> { - const result = await client.request<GetIssueByIdWithAttachmentsQuery>( - GetIssueByIdWithAttachmentsDocument, - { id }, - ); + const result = await client.request(GetIssueByIdWithAttachmentsDocument, { + id, + }); if (!result.issue) { throw new Error(`Issue with ID "${id}" not found`); } @@ -359,16 +420,14 @@ export async function getIssueByIdentifierWithAttachments( teamKey: string, issueNumber: number, ): Promise<IssueByIdentifierWithAttachments> { - const result = await client.request<GetIssueByIdentifierWithAttachmentsQuery>( + const result = await client.request( GetIssueByIdentifierWithAttachmentsDocument, { teamKey, number: issueNumber }, ); - if (!result.issues.nodes.length) { - throw new Error( - `Issue with identifier "${teamKey}-${issueNumber}" not found`, - ); - } - return result.issues.nodes[0]; + return firstOrThrow( + result.issues.nodes, + `Issue with identifier "${teamKey}-${issueNumber}" not found`, + ); } export async function searchIssues( @@ -384,10 +443,7 @@ export async function searchIssues( after, ...(filter && { filter }), }; - const result = await client.request<SearchIssuesQuery>( - SearchIssuesDocument, - variables, - ); + const result = await client.request(SearchIssuesDocument, variables); return { nodes: result.searchIssues?.nodes ?? [], pageInfo: result.searchIssues.pageInfo, @@ -396,75 +452,67 @@ export async function searchIssues( export async function createIssue( client: GraphQLClient, - input: IssueCreateInput, + input: CreateIssueInput, ): Promise<CreatedIssue> { - const result = await client.request<CreateIssueMutation>( - CreateIssueDocument, - { input }, + const gqlInput: IssueCreateInput = input; + const result = await client.request(CreateIssueDocument, { input: gqlInput }); + return requireMutationEntity( + result.issueCreate, + "issue", + "Failed to create issue", ); - if (!result.issueCreate.success || !result.issueCreate.issue) { - throw new Error("Failed to create issue"); - } - return result.issueCreate.issue; } export async function updateIssue( client: GraphQLClient, - id: string, - input: IssueUpdateInput, + id: UUID, + input: UpdateIssueInput, ): Promise<UpdatedIssue> { - const result = await client.request<UpdateIssueMutation>( - UpdateIssueDocument, - { id, input }, + const gqlInput: IssueUpdateInput = input; + const result = await client.request(UpdateIssueDocument, { + id, + input: gqlInput, + }); + return requireMutationEntity( + result.issueUpdate, + "issue", + "Failed to update issue", ); - if (!result.issueUpdate.success || !result.issueUpdate.issue) { - throw new Error("Failed to update issue"); - } - return result.issueUpdate.issue; } export async function archiveIssue( client: GraphQLClient, - id: string, + id: UUID, ): Promise<IssueDetail> { - const result = await client.request<ArchiveIssueMutation>( - ArchiveIssueDocument, - { id }, - ); + const result = await client.request(ArchiveIssueDocument, { id }); - if (!result.issueArchive.success || !result.issueArchive.entity) { - throw new Error(`Failed to archive issue "${id}"`); - } - - return result.issueArchive.entity; + return requireMutationEntity( + result.issueArchive, + "entity", + `Failed to archive issue "${id}"`, + ); } export async function unarchiveIssue( client: GraphQLClient, - id: string, + id: UUID, ): Promise<IssueDetail> { - const result = await client.request<UnarchiveIssueMutation>( - UnarchiveIssueDocument, - { id }, - ); - - if (!result.issueUnarchive.success || !result.issueUnarchive.entity) { - throw new Error(`Failed to unarchive issue "${id}"`); - } + const result = await client.request(UnarchiveIssueDocument, { id }); - return result.issueUnarchive.entity; + return requireMutationEntity( + result.issueUnarchive, + "entity", + `Failed to unarchive issue "${id}"`, + ); } export async function deleteIssue( client: GraphQLClient, - id: string, + id: UUID, ): Promise<{ id: string; success: true }> { - const result = await client.request<DeleteIssueMutation>( - DeleteIssueDocument, - { - id, - }, - ); + const result = await client.request(DeleteIssueDocument, { + id, + }); if (!result.issueDelete.success || !result.issueDelete.entity?.id) { throw new Error(`Failed to delete issue "${id}"`); diff --git a/src/services/label-service.ts b/src/services/label-service.ts index 9d8f485a..b1a5c251 100644 --- a/src/services/label-service.ts +++ b/src/services/label-service.ts @@ -1,11 +1,17 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import type { BrandUuidFields, UUID } from "../common/identifier.js"; +import { requireMutationSuccess } from "../common/mutation-payload.js"; import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { + CreateIssueLabelDocument, + DeleteIssueLabelDocument, + GetIssueLabelDocument, GetLabelsDocument, - type GetLabelsQuery, GetProjectLabelsDocument, - type GetProjectLabelsQuery, + type IssueLabelCreateInput, type IssueLabelFilter, + type IssueLabelUpdateInput, + UpdateIssueLabelDocument, } from "../gql/graphql.js"; export type LabelType = "issue" | "project"; @@ -19,12 +25,110 @@ export interface Label { type: LabelType; } +export interface DeleteLabelResult { + id: string; + success: true; +} + export interface ListLabelOptions extends PaginationOptions { scope?: LabelScope; } +// Service-owned input types (UUIDs pre-resolved by the command). +export type CreateLabelInput = BrandUuidFields< + Pick<IssueLabelCreateInput, "name" | "teamId" | "color" | "description">, + "teamId" +>; +export type UpdateLabelInput = Pick< + IssueLabelUpdateInput, + "name" | "color" | "description" +>; + +function mapIssueLabel(label: { + id: string; + name: string; + color: string; + description?: string | null; +}): Label { + return { + id: label.id, + name: label.name, + color: label.color, + type: "issue", + ...(label.description != null ? { description: label.description } : {}), + }; +} + +export async function getLabel( + client: GraphQLClient, + id: UUID, +): Promise<Label> { + const result = await client.request(GetIssueLabelDocument, { + id, + }); + + if (!result.issueLabel) { + throw new Error(`Label with ID "${id}" not found`); + } + + return mapIssueLabel(result.issueLabel); +} + +export async function createLabel( + client: GraphQLClient, + input: CreateLabelInput, +): Promise<Label> { + const gqlInput: IssueLabelCreateInput = input; + const result = await client.request(CreateIssueLabelDocument, { + input: gqlInput, + }); + + requireMutationSuccess( + result.issueLabelCreate, + `Failed to create label "${input.name}"`, + ); + + return mapIssueLabel(result.issueLabelCreate.issueLabel); +} + +export async function updateLabel( + client: GraphQLClient, + id: UUID, + input: UpdateLabelInput, +): Promise<Label> { + const gqlInput: IssueLabelUpdateInput = input; + const result = await client.request(UpdateIssueLabelDocument, { + id, + input: gqlInput, + }); + + requireMutationSuccess( + result.issueLabelUpdate, + `Failed to update label "${id}"`, + ); + + return mapIssueLabel(result.issueLabelUpdate.issueLabel); +} + +export async function deleteLabel( + client: GraphQLClient, + id: UUID, +): Promise<DeleteLabelResult> { + const result = await client.request(DeleteIssueLabelDocument, { id }); + + requireMutationSuccess( + result.issueLabelDelete, + `Failed to delete label "${id}"`, + ); + + return { + id: result.issueLabelDelete.entityId, + success: true, + }; +} + function buildIssueLabelFilter( - teamId?: string, + teamId?: UUID, scope?: LabelScope, ): IssueLabelFilter | undefined { if (scope === "workspace") { @@ -44,26 +148,20 @@ function buildIssueLabelFilter( export async function listLabels( client: GraphQLClient, - teamId?: string, + teamId?: UUID, options: ListLabelOptions = {}, ): Promise<PaginatedResult<Label>> { const { limit = 50, after, scope } = options; const filter = buildIssueLabelFilter(teamId, scope); - const result = await client.request<GetLabelsQuery>(GetLabelsDocument, { + const result = await client.request(GetLabelsDocument, { first: limit, after, filter, }); return { - nodes: result.issueLabels.nodes.map((label) => ({ - id: label.id, - name: label.name, - color: label.color, - description: label.description ?? undefined, - type: "issue", - })), + nodes: result.issueLabels.nodes.map((label) => mapIssueLabel(label)), pageInfo: result.issueLabels.pageInfo, }; } @@ -74,22 +172,23 @@ export async function listProjectLabels( ): Promise<PaginatedResult<Label>> { const { limit = 50, after } = options; - const result = await client.request<GetProjectLabelsQuery>( - GetProjectLabelsDocument, - { - first: limit, - after, - }, - ); + const result = await client.request(GetProjectLabelsDocument, { + first: limit, + after, + }); return { - nodes: result.projectLabels.nodes.map((label) => ({ - id: label.id, - name: label.name, - color: label.color, - description: label.description ?? undefined, - type: "project", - })), + nodes: result.projectLabels.nodes.map( + (label): Label => ({ + id: label.id, + name: label.name, + color: label.color, + type: "project", + ...(label.description != null + ? { description: label.description } + : {}), + }), + ), pageInfo: result.projectLabels.pageInfo, }; } diff --git a/src/services/milestone-service.ts b/src/services/milestone-service.ts index eb7bf43d..dadc6629 100644 --- a/src/services/milestone-service.ts +++ b/src/services/milestone-service.ts @@ -1,12 +1,7 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { - CreatedMilestone, - MilestoneDetail, - MilestoneListItem, - PaginatedResult, - PaginationOptions, - UpdatedMilestone, -} from "../common/types.js"; +import type { BrandUuidFields, UUID } from "../common/identifier.js"; +import { requireMutationEntity } from "../common/mutation-payload.js"; +import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { CreateProjectMilestoneDocument, type CreateProjectMilestoneMutation, @@ -20,16 +15,43 @@ import { type UpdateProjectMilestoneMutation, } from "../gql/graphql.js"; +// Milestone projection types +export type MilestoneDetail = NonNullable< + GetProjectMilestoneByIdQuery["projectMilestone"] +>; +export type MilestoneListItem = + ListProjectMilestonesQuery["project"]["projectMilestones"]["nodes"][0]; +export type CreatedMilestone = NonNullable< + CreateProjectMilestoneMutation["projectMilestoneCreate"]["projectMilestone"] +>; +export type UpdatedMilestone = NonNullable< + UpdateProjectMilestoneMutation["projectMilestoneUpdate"]["projectMilestone"] +>; + +// Service-owned input types (UUIDs pre-resolved by the command). +export type CreateMilestoneInput = BrandUuidFields< + Pick< + ProjectMilestoneCreateInput, + "projectId" | "name" | "description" | "targetDate" + >, + "projectId" +>; +export type UpdateMilestoneInput = Pick< + ProjectMilestoneUpdateInput, + "name" | "description" | "targetDate" | "sortOrder" +>; + export async function listMilestones( client: GraphQLClient, - projectId: string, + projectId: UUID, options: PaginationOptions = {}, ): Promise<PaginatedResult<MilestoneListItem>> { const { limit = 50, after } = options; - const result = await client.request<ListProjectMilestonesQuery>( - ListProjectMilestonesDocument, - { projectId, first: limit, after }, - ); + const result = await client.request(ListProjectMilestonesDocument, { + projectId, + first: limit, + after, + }); return { nodes: result.project?.projectMilestones?.nodes ?? [], @@ -42,13 +64,13 @@ export async function listMilestones( export async function getMilestone( client: GraphQLClient, - id: string, + id: UUID, issuesLimit?: number, ): Promise<MilestoneDetail> { - const result = await client.request<GetProjectMilestoneByIdQuery>( - GetProjectMilestoneByIdDocument, - { id, issuesFirst: issuesLimit }, - ); + const result = await client.request(GetProjectMilestoneByIdDocument, { + id, + issuesFirst: issuesLimit, + }); if (!result.projectMilestone) { throw new Error(`Milestone with ID "${id}" not found`); @@ -59,39 +81,34 @@ export async function getMilestone( export async function createMilestone( client: GraphQLClient, - input: ProjectMilestoneCreateInput, + input: CreateMilestoneInput, ): Promise<CreatedMilestone> { - const result = await client.request<CreateProjectMilestoneMutation>( - CreateProjectMilestoneDocument, - input, - ); - - if ( - !result.projectMilestoneCreate.success || - !result.projectMilestoneCreate.projectMilestone - ) { - throw new Error("Failed to create milestone"); - } + const gqlInput: ProjectMilestoneCreateInput = input; + const result = await client.request(CreateProjectMilestoneDocument, { + input: gqlInput, + }); - return result.projectMilestoneCreate.projectMilestone; + return requireMutationEntity( + result.projectMilestoneCreate, + "projectMilestone", + "Failed to create milestone", + ); } export async function updateMilestone( client: GraphQLClient, - id: string, - input: ProjectMilestoneUpdateInput, + id: UUID, + input: UpdateMilestoneInput, ): Promise<UpdatedMilestone> { - const result = await client.request<UpdateProjectMilestoneMutation>( - UpdateProjectMilestoneDocument, - { id, ...input }, - ); + const gqlInput: ProjectMilestoneUpdateInput = input; + const result = await client.request(UpdateProjectMilestoneDocument, { + id, + input: gqlInput, + }); - if ( - !result.projectMilestoneUpdate.success || - !result.projectMilestoneUpdate.projectMilestone - ) { - throw new Error("Failed to update milestone"); - } - - return result.projectMilestoneUpdate.projectMilestone; + return requireMutationEntity( + result.projectMilestoneUpdate, + "projectMilestone", + "Failed to update milestone", + ); } diff --git a/src/services/project-service.ts b/src/services/project-service.ts index 38eb27c8..6c8c8297 100644 --- a/src/services/project-service.ts +++ b/src/services/project-service.ts @@ -1,22 +1,16 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { - ArchivedProject, - CreatedProject, - DeletedProject, - PaginatedResult, - PaginationOptions, - ProjectDetail, - ProjectListItem, - UnarchivedProject, - UpdatedProject, -} from "../common/types.js"; +import type { BrandUuidFields, UUID } from "../common/identifier.js"; +import { + requireMutationEntity, + requireMutationSuccess, +} from "../common/mutation-payload.js"; +import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { ArchiveProjectDocument, type ArchiveProjectMutation, CreateProjectDocument, type CreateProjectMutation, DeleteProjectDocument, - type DeleteProjectMutation, GetProjectDocument, type GetProjectQuery, GetProjectsDocument, @@ -29,14 +23,91 @@ import { type UpdateProjectMutation, } from "../gql/graphql.js"; +// Project projection types +export type ProjectListItem = GetProjectsQuery["projects"]["nodes"][0]; +export type ProjectDetail = NonNullable<GetProjectQuery["project"]>; +export type CreatedProject = NonNullable< + CreateProjectMutation["projectCreate"]["project"] +>; +export type UpdatedProject = NonNullable< + UpdateProjectMutation["projectUpdate"]["project"] +>; +export type ArchivedProject = NonNullable< + ArchiveProjectMutation["projectArchive"]["entity"] +>; +export type UnarchivedProject = NonNullable< + UnarchiveProjectMutation["projectUnarchive"]["entity"] +>; +export type DeletedProject = { + id: string; + success: true; +}; + +// Service-owned input types (UUIDs pre-resolved by the command). +export type CreateProjectInput = BrandUuidFields< + Pick< + ProjectCreateInput, + | "name" + | "teamIds" + | "description" + | "content" + | "icon" + | "color" + | "leadId" + | "memberIds" + | "priority" + | "statusId" + | "startDate" + | "targetDate" + | "labelIds" + >, + "teamIds" | "leadId" | "memberIds" | "statusId" | "labelIds" +>; +export type UpdateProjectInput = BrandUuidFields< + Pick< + ProjectUpdateInput, + | "name" + | "description" + | "content" + | "icon" + | "color" + | "leadId" + | "memberIds" + | "priority" + | "statusId" + | "startDate" + | "targetDate" + | "teamIds" + | "labelIds" + >, + "teamIds" | "leadId" | "memberIds" | "statusId" | "labelIds" +>; + +export interface ProjectListOptions extends PaginationOptions { + includeArchived?: boolean; +} + +export interface ProjectDetailOptions { + milestonesFirst?: number; + issuesFirst?: number; +} + +const DEFAULT_PROJECT_MILESTONES_FIRST = 25; +const DEFAULT_PROJECT_ISSUES_FIRST = 50; + +function connectionFirstOrOneWhenSkipped(value: number): number { + return value === 0 ? 1 : value; +} + export async function listProjects( client: GraphQLClient, - options: PaginationOptions = {}, + options: ProjectListOptions = {}, ): Promise<PaginatedResult<ProjectListItem>> { - const { limit = 50, after } = options; - const result = await client.request<GetProjectsQuery>(GetProjectsDocument, { + const { limit = 50, after, includeArchived } = options; + const result = await client.request(GetProjectsDocument, { first: limit, after, + includeArchived, }); return { @@ -47,10 +118,19 @@ export async function listProjects( export async function getProject( client: GraphQLClient, - id: string, + id: UUID, + options: ProjectDetailOptions = {}, ): Promise<ProjectDetail> { - const result = await client.request<GetProjectQuery>(GetProjectDocument, { + const milestonesFirst = + options.milestonesFirst ?? DEFAULT_PROJECT_MILESTONES_FIRST; + const issuesFirst = options.issuesFirst ?? DEFAULT_PROJECT_ISSUES_FIRST; + + const result = await client.request(GetProjectDocument, { id, + milestonesFirst: connectionFirstOrOneWhenSkipped(milestonesFirst), + skipMilestones: milestonesFirst === 0, + issuesFirst: connectionFirstOrOneWhenSkipped(issuesFirst), + skipIssues: issuesFirst === 0, }); if (!result.project) { @@ -62,81 +142,74 @@ export async function getProject( export async function createProject( client: GraphQLClient, - input: ProjectCreateInput, + input: CreateProjectInput, ): Promise<CreatedProject> { - const result = await client.request<CreateProjectMutation>( - CreateProjectDocument, - { input }, - ); - - if (!result.projectCreate.success || !result.projectCreate.project) { - throw new Error(`Failed to create project "${input.name}"`); - } + const gqlInput: ProjectCreateInput = input; + const result = await client.request(CreateProjectDocument, { + input: gqlInput, + }); - return result.projectCreate.project; + return requireMutationEntity( + result.projectCreate, + "project", + `Failed to create project "${input.name}"`, + ); } export async function updateProject( client: GraphQLClient, - id: string, - input: ProjectUpdateInput, + id: UUID, + input: UpdateProjectInput, ): Promise<UpdatedProject> { - const result = await client.request<UpdateProjectMutation>( - UpdateProjectDocument, - { id, input }, - ); - - if (!result.projectUpdate.success || !result.projectUpdate.project) { - throw new Error(`Failed to update project "${id}"`); - } + const gqlInput: ProjectUpdateInput = input; + const result = await client.request(UpdateProjectDocument, { + id, + input: gqlInput, + }); - return result.projectUpdate.project; + return requireMutationEntity( + result.projectUpdate, + "project", + `Failed to update project "${id}"`, + ); } export async function archiveProject( client: GraphQLClient, - id: string, + id: UUID, ): Promise<ArchivedProject> { - const result = await client.request<ArchiveProjectMutation>( - ArchiveProjectDocument, - { id }, - ); - - if (!result.projectArchive.success || !result.projectArchive.entity) { - throw new Error(`Failed to archive project "${id}"`); - } + const result = await client.request(ArchiveProjectDocument, { id }); - return result.projectArchive.entity; + return requireMutationEntity( + result.projectArchive, + "entity", + `Failed to archive project "${id}"`, + ); } export async function unarchiveProject( client: GraphQLClient, - id: string, + id: UUID, ): Promise<UnarchivedProject> { - const result = await client.request<UnarchiveProjectMutation>( - UnarchiveProjectDocument, - { id }, - ); - - if (!result.projectUnarchive.success || !result.projectUnarchive.entity) { - throw new Error(`Failed to unarchive project "${id}"`); - } + const result = await client.request(UnarchiveProjectDocument, { id }); - return result.projectUnarchive.entity; + return requireMutationEntity( + result.projectUnarchive, + "entity", + `Failed to unarchive project "${id}"`, + ); } export async function deleteProject( client: GraphQLClient, - id: string, + id: UUID, ): Promise<DeletedProject> { - const result = await client.request<DeleteProjectMutation>( - DeleteProjectDocument, - { id }, - ); + const result = await client.request(DeleteProjectDocument, { id }); - if (!result.projectDelete.success) { - throw new Error(`Failed to delete project "${id}"`); - } + requireMutationSuccess( + result.projectDelete, + `Failed to delete project "${id}"`, + ); return { id: result.projectDelete.entity?.id ?? id, diff --git a/src/services/reaction-service.ts b/src/services/reaction-service.ts index c8fe73c1..e3313d7a 100644 --- a/src/services/reaction-service.ts +++ b/src/services/reaction-service.ts @@ -1,16 +1,15 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import { firstOrThrow } from "../common/array.js"; import { normalizeReactionEmojiInput } from "../common/emoji.js"; +import type { UUID } from "../common/identifier.js"; +import { requireMutationSuccess } from "../common/mutation-payload.js"; import { CreateReactionDocument, type CreateReactionMutation, DeleteReactionDocument, - type DeleteReactionMutation, GetCommentReactionsDocument, - type GetCommentReactionsQuery, GetIssueReactionsDocument, - type GetIssueReactionsQuery, GetViewerDocument, - type GetViewerQuery, type ReactionCreateInput, type ReactionReadFieldsFragment, } from "../gql/graphql.js"; @@ -32,7 +31,7 @@ interface NormalizedReactionGroup { interface ReactionLookupInput { kind: "issue" | "comment"; - id: string; + id: UUID; } interface DeleteOwnReactionByEmojiInput extends ReactionLookupInput { @@ -40,7 +39,7 @@ interface DeleteOwnReactionByEmojiInput extends ReactionLookupInput { } interface DeleteOwnReactionByIdInput extends ReactionLookupInput { - reactionId: string; + reactionId: UUID; } function compareNormalizedUsers( @@ -85,7 +84,7 @@ function normalizeReactionUser( } async function getViewerId(client: GraphQLClient): Promise<string> { - const result = await client.request<GetViewerQuery>(GetViewerDocument); + const result = await client.request(GetViewerDocument); return result.viewer.id; } @@ -94,10 +93,9 @@ async function getTargetReactions( input: ReactionLookupInput, ): Promise<ReactionNode[]> { if (input.kind === "issue") { - const result = await client.request<GetIssueReactionsQuery>( - GetIssueReactionsDocument, - { id: input.id }, - ); + const result = await client.request(GetIssueReactionsDocument, { + id: input.id, + }); if (!result.issue) { throw new Error(`Issue with ID "${input.id}" not found`); @@ -106,10 +104,9 @@ async function getTargetReactions( return result.issue.reactions; } - const result = await client.request<GetCommentReactionsQuery>( - GetCommentReactionsDocument, - { id: input.id }, - ); + const result = await client.request(GetCommentReactionsDocument, { + id: input.id, + }); if (!result.comment) { throw new Error(`Discussion comment ID "${input.id}" not found`); @@ -137,14 +134,11 @@ async function createReaction( throw new Error(`Already reacted with emoji ${normalizedEmoji}`); } - const result = await client.request<CreateReactionMutation>( - CreateReactionDocument, - { input: normalizedInput }, - ); + const result = await client.request(CreateReactionDocument, { + input: normalizedInput, + }); - if (!result.reactionCreate.success) { - throw new Error("Failed to create reaction"); - } + requireMutationSuccess(result.reactionCreate, "Failed to create reaction"); return result.reactionCreate.reaction; } @@ -153,14 +147,11 @@ async function deleteReaction( client: GraphQLClient, reactionId: string, ): Promise<{ id: string; success: boolean }> { - const result = await client.request<DeleteReactionMutation>( - DeleteReactionDocument, - { id: reactionId }, - ); + const result = await client.request(DeleteReactionDocument, { + id: reactionId, + }); - if (!result.reactionDelete.success) { - throw new Error("Failed to delete reaction"); - } + requireMutationSuccess(result.reactionDelete, "Failed to delete reaction"); return { id: result.reactionDelete.entityId, success: true }; } @@ -215,7 +206,7 @@ export function normalizeReactions( export async function createReactionForIssue( client: GraphQLClient, input: { - issueId: string; + issueId: UUID; emoji: string; }, ): Promise<CreateReactionMutation["reactionCreate"]["reaction"]> { @@ -229,7 +220,7 @@ export async function createReactionForIssue( export async function createReactionForComment( client: GraphQLClient, input: { - commentId: string; + commentId: UUID; emoji: string; }, ): Promise<CreateReactionMutation["reactionCreate"]["reaction"]> { @@ -263,7 +254,13 @@ export async function deleteOwnReactionByEmoji( ); } - return deleteReaction(client, matchingReactions[0].id); + return deleteReaction( + client, + firstOrThrow( + matchingReactions, + `No own reaction found with emoji ${normalizedEmoji}`, + ).id, + ); } export async function deleteOwnReactionById( diff --git a/src/services/team-service.ts b/src/services/team-service.ts index 2a77bdb3..da5da686 100644 --- a/src/services/team-service.ts +++ b/src/services/team-service.ts @@ -1,26 +1,108 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { - PaginatedResult, - PaginationOptions, - TeamDetail, - TeamEstimateOption, - TeamEstimationSource, -} from "../common/types.js"; +import { notFoundError } from "../common/errors.js"; +import type { BrandUuidFields, UUID } from "../common/identifier.js"; +import { + requireMutationEntity, + requireMutationSuccess, +} from "../common/mutation-payload.js"; +import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { + AddTeamMemberDocument, + type AddTeamMemberMutation, + CreateTeamDocument, + type CreateTeamMutation, GetTeamByIdDocument, type GetTeamByIdQuery, + GetTeamMembershipsDocument, + type GetTeamMembershipsQuery, GetTeamsDocument, - type GetTeamsQuery, + RemoveTeamMemberDocument, + type TeamCreateInput, + type TeamUpdateInput, + UpdateTeamDocument, + type UpdateTeamMutation, } from "../gql/graphql.js"; +// Team projection types +export type TeamEstimateOption = { + value: number; + label: string; +}; + +export type TeamEstimationSource = "self" | "parent" | "self_fallback"; + +export type TeamDetail = NonNullable<GetTeamByIdQuery["team"]> & { + validEstimates: TeamEstimateOption[]; + estimationSource: TeamEstimationSource; +}; + export interface Team { id: string; key: string; name: string; } -interface GetTeamInput { +export type CreatedTeam = NonNullable<CreateTeamMutation["teamCreate"]["team"]>; +export type UpdatedTeam = NonNullable<UpdateTeamMutation["teamUpdate"]["team"]>; +export type TeamMembership = NonNullable< + AddTeamMemberMutation["teamMembershipCreate"]["teamMembership"] +>; + +// Mutable team fields the command layer may set. UUIDs (parentId) are +// pre-resolved by the command before reaching the service. +type TeamMutableFields = + | "name" + | "key" + | "description" + | "private" + | "icon" + | "color" + | "timezone" + | "parentId" + | "issueEstimationType" + | "issueEstimationExtended" + | "issueEstimationAllowZero" + | "defaultIssueEstimate" + | "inheritIssueEstimation" + | "cyclesEnabled" + | "cycleDuration" + | "cycleCooldownTime" + | "cycleStartDay" + | "triageEnabled" + | "requirePriorityToLeaveTriage" + | "autoClosePeriod" + | "autoArchivePeriod"; + +export type CreateTeamInput = BrandUuidFields< + Pick<TeamCreateInput, TeamMutableFields>, + "parentId" +>; +export type UpdateTeamInput = BrandUuidFields< + Pick<TeamUpdateInput, TeamMutableFields>, + "parentId" +>; + +export interface AddTeamMemberInput { + teamId: UUID; + userId: UUID; + owner?: boolean; +} + +export interface RemoveTeamMemberInput { + teamId: UUID; + userId: UUID; +} + +export type DeletedTeamMembership = { id: string; + success: true; +}; + +type TeamMembershipNode = + GetTeamMembershipsQuery["team"]["memberships"]["nodes"][number]; + +interface GetTeamInput { + id: UUID; } type TeamConfigSource = Pick< @@ -95,12 +177,9 @@ async function resolveEffectiveEstimationConfig( } try { - const parentResult = await client.request<GetTeamByIdQuery>( - GetTeamByIdDocument, - { - id: team.parent.id, - }, - ); + const parentResult = await client.request(GetTeamByIdDocument, { + id: team.parent.id, + }); if (!parentResult.team) { return { config: team, source: "self_fallback" }; @@ -117,7 +196,7 @@ export async function listTeams( options: PaginationOptions = {}, ): Promise<PaginatedResult<Team>> { const { limit = 50, after } = options; - const result = await client.request<GetTeamsQuery>(GetTeamsDocument, { + const result = await client.request(GetTeamsDocument, { first: limit, after, }); @@ -131,7 +210,7 @@ export async function getTeam( client: GraphQLClient, input: GetTeamInput, ): Promise<TeamDetail> { - const result = await client.request<GetTeamByIdQuery>(GetTeamByIdDocument, { + const result = await client.request(GetTeamByIdDocument, { id: input.id, }); @@ -150,3 +229,118 @@ export async function getTeam( estimationSource: source, }; } + +export async function createTeam( + client: GraphQLClient, + input: CreateTeamInput, +): Promise<CreatedTeam> { + const gqlInput: TeamCreateInput = input; + const result = await client.request(CreateTeamDocument, { input: gqlInput }); + + return requireMutationEntity( + result.teamCreate, + "team", + `Failed to create team "${input.name}"`, + ); +} + +export async function updateTeam( + client: GraphQLClient, + id: UUID, + input: UpdateTeamInput, +): Promise<UpdatedTeam> { + const gqlInput: TeamUpdateInput = input; + const result = await client.request(UpdateTeamDocument, { + id, + input: gqlInput, + }); + + return requireMutationEntity( + result.teamUpdate, + "team", + `Failed to update team "${id}"`, + ); +} + +async function fetchTeamMemberships( + client: GraphQLClient, + teamId: UUID, +): Promise<TeamMembershipNode[]> { + const nodes: TeamMembershipNode[] = []; + let after: string | undefined; + + while (true) { + const result = await client.request(GetTeamMembershipsDocument, { + id: teamId, + after, + }); + + if (!result.team) { + throw notFoundError("Team", teamId); + } + + const { memberships } = result.team; + nodes.push(...memberships.nodes); + + if (!memberships.pageInfo.hasNextPage || !memberships.pageInfo.endCursor) { + break; + } + + after = memberships.pageInfo.endCursor; + } + + return nodes; +} + +export async function listTeamMembers( + client: GraphQLClient, + input: GetTeamInput, +): Promise<{ nodes: TeamMembershipNode[] }> { + return { nodes: await fetchTeamMemberships(client, input.id) }; +} + +export async function addTeamMember( + client: GraphQLClient, + input: AddTeamMemberInput, +): Promise<TeamMembership> { + const result = await client.request(AddTeamMemberDocument, { + input: { + teamId: input.teamId, + userId: input.userId, + ...(input.owner === undefined ? {} : { owner: input.owner }), + }, + }); + + return requireMutationEntity( + result.teamMembershipCreate, + "teamMembership", + `Failed to add user "${input.userId}" to team "${input.teamId}"`, + ); +} + +export async function removeTeamMember( + client: GraphQLClient, + input: RemoveTeamMemberInput, +): Promise<DeletedTeamMembership> { + const memberships = await fetchTeamMemberships(client, input.teamId); + const membership = memberships.find((m) => m.user?.id === input.userId); + + if (!membership) { + throw notFoundError( + "Team member", + input.userId, + `on team "${input.teamId}"`, + ); + } + + const result = await client.request(RemoveTeamMemberDocument, { + id: membership.id, + }); + + requireMutationSuccess( + result.teamMembershipDelete, + `Failed to remove user "${input.userId}" from team "${input.teamId}"`, + ); + + return { id: result.teamMembershipDelete.entityId, success: true }; +} diff --git a/src/services/user-service.ts b/src/services/user-service.ts index bb2a5e4f..5c8d478f 100644 --- a/src/services/user-service.ts +++ b/src/services/user-service.ts @@ -1,6 +1,6 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import type { PaginatedResult, PaginationOptions } from "../common/types.js"; -import { GetUsersDocument, type GetUsersQuery } from "../gql/graphql.js"; +import { GetUsersDocument } from "../gql/graphql.js"; export interface User { id: string; @@ -16,7 +16,7 @@ export async function listUsers( ): Promise<PaginatedResult<User>> { const { limit = 50, after } = options; const filter = activeOnly ? { active: { eq: true } } : undefined; - const result = await client.request<GetUsersQuery>(GetUsersDocument, { + const result = await client.request(GetUsersDocument, { first: limit, after, filter, diff --git a/tests/command-coverage.ts b/tests/command-coverage.ts index 84c0109c..45c8558d 100644 --- a/tests/command-coverage.ts +++ b/tests/command-coverage.ts @@ -32,6 +32,7 @@ function extractCommands(commandsDir: string): Command[] { if (!mainCommandMatch) continue; const commandName = mainCommandMatch[1]; + if (commandName === undefined) continue; const subcommands: string[] = []; // Extract subcommands @@ -41,10 +42,12 @@ function extractCommands(commandsDir: string): Command[] { for (const match of subcommandMatches) { const sub = match[1]; // Skip the main command name - if (sub !== commandName) { + if (sub !== undefined && sub !== commandName) { // Extract just the command word, remove parameters like <id> const subName = sub.split(" ")[0]; - subcommands.push(subName); + if (subName !== undefined) { + subcommands.push(subName); + } } } diff --git a/tests/integration/cycles-cli.test.ts b/tests/integration/cycles-cli.test.ts index d3cde547..3d252512 100644 --- a/tests/integration/cycles-cli.test.ts +++ b/tests/integration/cycles-cli.test.ts @@ -18,7 +18,7 @@ const execAsync = promisify(exec); */ const CLI_PATH = "./dist/main.js"; -const hasApiToken = !!process.env.LINEAR_API_TOKEN; +const hasApiToken = !!process.env["LINEAR_API_TOKEN"]; describe("Cycles CLI Commands", () => { beforeAll(async () => { diff --git a/tests/integration/documents-cli.test.ts b/tests/integration/documents-cli.test.ts index c9d32cd2..0ea80155 100644 --- a/tests/integration/documents-cli.test.ts +++ b/tests/integration/documents-cli.test.ts @@ -14,7 +14,7 @@ const execAsync = promisify(exec); */ const CLI_PATH = "./dist/main.js"; -const hasApiToken = !!process.env.LINEAR_API_TOKEN; +const hasApiToken = !!process.env["LINEAR_API_TOKEN"]; describe("Documents CLI Commands", () => { beforeAll(async () => { diff --git a/tests/integration/issues-cli.test.ts b/tests/integration/issues-cli.test.ts index a67d290e..16ef67c2 100644 --- a/tests/integration/issues-cli.test.ts +++ b/tests/integration/issues-cli.test.ts @@ -4,7 +4,7 @@ import { beforeAll, describe, expect, it } from "vitest"; const execAsync = promisify(exec); const CLI_PATH = "./dist/main.js"; -const hasApiToken = !!process.env.LINEAR_API_TOKEN; +const hasApiToken = !!process.env["LINEAR_API_TOKEN"]; interface CliResult { stdout: string; @@ -47,7 +47,7 @@ describe("Issues CLI lifecycle", () => { ); expect(teams.nodes.length).toBeGreaterThan(0); - const teamKey = teams.nodes[0].key; + const teamKey = teams.nodes[0]?.key; const title = `issue-lifecycle-e2e-${Date.now()}`; const createdResult = await runCli( diff --git a/tests/integration/milestones-cli.test.ts b/tests/integration/milestones-cli.test.ts index 813e5e71..1bfae021 100644 --- a/tests/integration/milestones-cli.test.ts +++ b/tests/integration/milestones-cli.test.ts @@ -20,7 +20,7 @@ const execAsync = promisify(exec); */ const CLI_PATH = "./dist/main.js"; -const hasApiToken = !!process.env.LINEAR_API_TOKEN; +const hasApiToken = !!process.env["LINEAR_API_TOKEN"]; describe("Milestones CLI Commands", () => { beforeAll(async () => { diff --git a/tests/integration/teams-cli.test.ts b/tests/integration/teams-cli.test.ts index 240ad404..ca672ada 100644 --- a/tests/integration/teams-cli.test.ts +++ b/tests/integration/teams-cli.test.ts @@ -14,7 +14,7 @@ const execAsync = promisify(exec); */ const CLI_PATH = "./dist/main.js"; -const hasApiToken = !!process.env.LINEAR_API_TOKEN; +const hasApiToken = !!process.env["LINEAR_API_TOKEN"]; describe("Teams CLI Commands", () => { beforeAll(async () => { @@ -34,6 +34,38 @@ describe("Teams CLI Commands", () => { expect(stdout).toContain("Team operations"); expect(stdout).toContain("list"); }); + + it("should list the management subcommands", async () => { + const { stdout } = await execAsync(`node ${CLI_PATH} teams --help`); + + expect(stdout).toContain("create"); + expect(stdout).toContain("update"); + expect(stdout).toContain("members"); + expect(stdout).toContain("add-member"); + expect(stdout).toContain("remove-member"); + }); + }); + + describe("teams create --help", () => { + it("should document create flags", async () => { + const { stdout } = await execAsync( + `node ${CLI_PATH} teams create --help`, + ); + + expect(stdout).toContain("--key"); + expect(stdout).toContain("--estimation-type"); + expect(stdout).toContain("--parent"); + }); + }); + + describe("teams add-member --help", () => { + it("should document the --user flag", async () => { + const { stdout } = await execAsync( + `node ${CLI_PATH} teams add-member --help`, + ); + + expect(stdout).toContain("--user"); + }); }); describe("teams list", () => { diff --git a/tests/integration/users-cli.test.ts b/tests/integration/users-cli.test.ts index 13da6a79..cf9f7d73 100644 --- a/tests/integration/users-cli.test.ts +++ b/tests/integration/users-cli.test.ts @@ -14,7 +14,7 @@ const execAsync = promisify(exec); */ const CLI_PATH = "./dist/main.js"; -const hasApiToken = !!process.env.LINEAR_API_TOKEN; +const hasApiToken = !!process.env["LINEAR_API_TOKEN"]; describe("Users CLI Commands", () => { beforeAll(async () => { diff --git a/tests/unit/client/graphql-client.test.ts b/tests/unit/client/graphql-client.test.ts index a1fcdd5f..58f23820 100644 --- a/tests/unit/client/graphql-client.test.ts +++ b/tests/unit/client/graphql-client.test.ts @@ -1,24 +1,33 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { TypedDocumentNode } from "@graphql-typed-document-node/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GraphQLClient } from "../../../src/client/graphql-client.js"; import { AuthenticationError } from "../../../src/common/errors.js"; -// We test the error handling logic by mocking the underlying rawRequest -// The constructor creates a real LinearClient, so we mock at module level -vi.mock("@linear/sdk", () => { - const mockRawRequest = vi.fn(); - const mockConstructorCalls: Array<{ signal?: AbortSignal }> = []; +// A stand-in document for the error-path tests. Typing its variables as +// `Record<string, never>` makes `request`'s variables argument optional, so +// these calls can invoke it without variables. +function fakeDocument<TResult = unknown>(): TypedDocumentNode< + TResult, + Record<string, never> +> { return { - // biome-ignore lint/complexity/useArrowFunction: vitest v4 requires regular function for constructor mocks - LinearClient: vi.fn().mockImplementation(function (options?: { - signal?: AbortSignal; - }) { - mockConstructorCalls.push(options ?? {}); - return { client: { rawRequest: mockRawRequest } }; - }), - __mockRawRequest: mockRawRequest, - __mockConstructorCalls: mockConstructorCalls, - }; -}); + kind: "Document", + definitions: [], + } as unknown as TypedDocumentNode<TResult, Record<string, never>>; +} + +// Build a minimal `fetch` Response stand-in. Only the fields the transport +// touches (`ok`, `status`, `json`) are populated. +function fakeResponse( + init: { ok: boolean; status: number }, + body: unknown, +): Response { + return { + ok: init.ok, + status: init.status, + json: async () => body, + } as unknown as Response; +} describe("GraphQLClient", () => { it("can be constructed with an API token", () => { @@ -27,31 +36,51 @@ describe("GraphQLClient", () => { }); describe("request", () => { - let mockRawRequest: ReturnType<typeof vi.fn>; - let mockConstructorCalls: Array<{ signal?: AbortSignal }>; - - beforeEach(async () => { - const sdk = (await import("@linear/sdk")) as unknown as { - __mockRawRequest: ReturnType<typeof vi.fn>; - __mockConstructorCalls: Array<{ signal?: AbortSignal }>; - }; - mockRawRequest = sdk.__mockRawRequest; - mockConstructorCalls = sdk.__mockConstructorCalls; - mockRawRequest.mockReset(); - mockConstructorCalls.length = 0; + let mockFetch: ReturnType<typeof vi.fn>; + + beforeEach(() => { + mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("sends the expected request to the Linear GraphQL endpoint", async () => { + mockFetch.mockResolvedValueOnce( + fakeResponse({ ok: true, status: 200 }, { data: { ok: true } }), + ); + + const client = new GraphQLClient("test-token"); + const fakeDoc = fakeDocument<{ ok: boolean }>(); + + await client.request(fakeDoc); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0] as [ + string, + RequestInit & { headers: Record<string, string> }, + ]; + expect(url).toBe("https://api.linear.app/graphql"); + expect(options.method).toBe("POST"); + expect(options.headers["Authorization"]).toBe("test-token"); + expect(options.headers["Content-Type"]).toBe("application/json"); + expect(options.headers["public-file-urls-expire-in"]).toBe("3600"); + const body = JSON.parse(options.body as string); + expect(body).toHaveProperty("query"); }); it("throws AuthenticationError on 'Authentication required' error", async () => { - mockRawRequest.mockRejectedValueOnce({ - response: { - errors: [{ message: "Authentication required" }], - }, - }); + mockFetch.mockResolvedValueOnce( + fakeResponse( + { ok: false, status: 400 }, + { errors: [{ message: "Authentication required" }] }, + ), + ); const client = new GraphQLClient("bad-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; + const fakeDoc = fakeDocument(); await expect(client.request(fakeDoc)).rejects.toThrow( AuthenticationError, @@ -59,16 +88,15 @@ describe("GraphQLClient", () => { }); it("throws AuthenticationError on 'Unauthorized' error message", async () => { - mockRawRequest.mockRejectedValueOnce({ - response: { - errors: [{ message: "Unauthorized" }], - }, - }); + mockFetch.mockResolvedValueOnce( + fakeResponse( + { ok: false, status: 401 }, + { errors: [{ message: "Unauthorized" }] }, + ), + ); const client = new GraphQLClient("bad-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; + const fakeDoc = fakeDocument(); await expect(client.request(fakeDoc)).rejects.toThrow( AuthenticationError, @@ -76,16 +104,15 @@ describe("GraphQLClient", () => { }); it("throws regular Error on non-auth errors", async () => { - mockRawRequest.mockRejectedValueOnce({ - response: { - errors: [{ message: "Entity not found" }], - }, - }); + mockFetch.mockResolvedValueOnce( + fakeResponse( + { ok: false, status: 400 }, + { errors: [{ message: "Entity not found" }] }, + ), + ); const client = new GraphQLClient("good-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; + const fakeDoc = fakeDocument(); try { await client.request(fakeDoc); @@ -97,17 +124,44 @@ describe("GraphQLClient", () => { } }); + it("throws regular Error on GraphQL errors returned with HTTP 200", async () => { + mockFetch.mockResolvedValueOnce( + fakeResponse( + { ok: true, status: 200 }, + { errors: [{ message: "Entity not found" }] }, + ), + ); + + const client = new GraphQLClient("good-token"); + const fakeDoc = fakeDocument(); + + await expect(client.request(fakeDoc)).rejects.toThrow("Entity not found"); + }); + + it("throws when the response contains no data", async () => { + mockFetch.mockResolvedValueOnce( + fakeResponse({ ok: true, status: 200 }, { data: undefined }), + ); + + const client = new GraphQLClient("good-token"); + const fakeDoc = fakeDocument(); + + await expect(client.request(fakeDoc)).rejects.toThrow( + "GraphQL response contained no data", + ); + }); + it("clears timeout timer when request succeeds before timeout", async () => { vi.useFakeTimers(); try { - mockRawRequest.mockResolvedValueOnce({ data: { ok: true } }); + mockFetch.mockResolvedValueOnce( + fakeResponse({ ok: true, status: 200 }, { data: { ok: true } }), + ); const client = new GraphQLClient("good-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; + const fakeDoc = fakeDocument<{ ok: boolean }>(); - const result = await client.request<{ ok: boolean }>(fakeDoc); + const result = await client.request(fakeDoc); expect(result).toEqual({ ok: true }); expect(vi.getTimerCount()).toBe(0); @@ -119,16 +173,15 @@ describe("GraphQLClient", () => { it("clears timeout timer on non-retryable GraphQL error", async () => { vi.useFakeTimers(); try { - mockRawRequest.mockRejectedValueOnce({ - response: { - errors: [{ message: "Entity not found" }], - }, - }); + mockFetch.mockResolvedValueOnce( + fakeResponse( + { ok: false, status: 400 }, + { errors: [{ message: "Entity not found" }] }, + ), + ); const client = new GraphQLClient("good-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; + const fakeDoc = fakeDocument(); await expect(client.request(fakeDoc)).rejects.toThrow( "Entity not found", @@ -142,26 +195,27 @@ describe("GraphQLClient", () => { it("aborts in-flight request when timeout elapses", async () => { vi.useFakeTimers(); try { - mockRawRequest.mockImplementation(() => { - const call = mockConstructorCalls.at(-1); - return new Promise((_, reject) => { - call?.signal?.addEventListener("abort", () => { - reject(new Error("aborted-by-signal")); + let capturedSignal: AbortSignal | undefined; + mockFetch.mockImplementation( + (_url: string, options: { signal?: AbortSignal }) => { + capturedSignal = options.signal; + return new Promise((_, reject) => { + options.signal?.addEventListener("abort", () => { + reject(new Error("This operation was aborted")); + }); }); - }); - }); + }, + ); const client = new GraphQLClient("good-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; + const fakeDoc = fakeDocument(); const promise = client.request(fakeDoc); const rejection = expect(promise).rejects.toThrow("Request timed out"); await vi.runAllTimersAsync(); await rejection; - expect(mockConstructorCalls.at(-1)?.signal?.aborted).toBe(true); + expect(capturedSignal?.aborted).toBe(true); expect(vi.getTimerCount()).toBe(0); } finally { vi.useRealTimers(); @@ -169,15 +223,14 @@ describe("GraphQLClient", () => { }); it("retries on 429 and succeeds on next attempt", async () => { - const rateLimitError = { response: { status: 429 } }; - mockRawRequest - .mockRejectedValueOnce(rateLimitError) - .mockResolvedValueOnce({ data: { foo: "bar" } }); + mockFetch + .mockResolvedValueOnce(fakeResponse({ ok: false, status: 429 }, {})) + .mockResolvedValueOnce( + fakeResponse({ ok: true, status: 200 }, { data: { foo: "bar" } }), + ); const client = new GraphQLClient("good-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; + const fakeDoc = fakeDocument(); vi.useFakeTimers(); try { @@ -186,7 +239,7 @@ describe("GraphQLClient", () => { const result = await promise; expect(result).toEqual({ foo: "bar" }); - expect(mockRawRequest).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledTimes(2); expect(vi.getTimerCount()).toBe(0); } finally { vi.useRealTimers(); @@ -196,15 +249,14 @@ describe("GraphQLClient", () => { it("clears timeout timers across retry attempts", async () => { vi.useFakeTimers(); try { - const rateLimitError = { response: { status: 429 } }; - mockRawRequest - .mockRejectedValueOnce(rateLimitError) - .mockResolvedValueOnce({ data: { foo: "bar" } }); + mockFetch + .mockResolvedValueOnce(fakeResponse({ ok: false, status: 429 }, {})) + .mockResolvedValueOnce( + fakeResponse({ ok: true, status: 200 }, { data: { foo: "bar" } }), + ); const client = new GraphQLClient("good-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; + const fakeDoc = fakeDocument(); const promise = client.request(fakeDoc); @@ -212,7 +264,7 @@ describe("GraphQLClient", () => { await vi.advanceTimersByTimeAsync(500); await expect(promise).resolves.toEqual({ foo: "bar" }); - expect(mockRawRequest).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledTimes(2); expect(vi.getTimerCount()).toBe(0); } finally { vi.useRealTimers(); diff --git a/tests/unit/commands/attachments.test.ts b/tests/unit/commands/attachments.test.ts index 89cc47f0..83fd1ccb 100644 --- a/tests/unit/commands/attachments.test.ts +++ b/tests/unit/commands/attachments.test.ts @@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../../../src/common/context.js", () => ({ createContext: vi.fn(() => ({ gql: { request: vi.fn() }, - sdk: {}, })), getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), })); @@ -22,22 +21,32 @@ vi.mock("../../../src/resolvers/issue-resolver.js", () => ({ resolveIssueId: vi.fn().mockResolvedValue("resolved-issue-uuid"), })); -vi.mock("../../../src/services/attachment-service.js", () => ({ - createAttachment: vi.fn().mockResolvedValue({ - id: "att-1", - title: "Test", - url: "https://example.com", - }), - deleteAttachment: vi.fn().mockResolvedValue({ - id: "att-1", - success: true, - }), - listAttachments: vi - .fn() - .mockResolvedValue([ - { id: "att-1", title: "PR #42", sourceType: "github" }, - ]), -})); +vi.mock( + "../../../src/services/attachment-service.js", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/services/attachment-service.js") + >(); + return { + ...actual, + createAttachment: vi.fn().mockResolvedValue({ + id: "att-1", + title: "Test", + url: "https://example.com", + }), + deleteAttachment: vi.fn().mockResolvedValue({ + id: "att-1", + success: true, + }), + listAttachments: vi + .fn() + .mockResolvedValue([ + { id: "att-1", title: "PR #42", sourceType: "github" }, + ]), + }; + }, +); import { setupAttachmentsCommands } from "../../../src/commands/attachments.js"; import { resolveIssueId } from "../../../src/resolvers/issue-resolver.js"; @@ -72,6 +81,53 @@ describe("attachments list", () => { ); }); + it("accepts --issue as an alias for the issue argument", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "attachments", + "list", + "--issue", + "ENG-42", + ]); + + expect(resolveIssueId).toHaveBeenCalledWith(expect.anything(), "ENG-42"); + expect(listAttachments).toHaveBeenCalledWith( + expect.anything(), + "resolved-issue-uuid", + undefined, + ); + }); + + it("rejects combining positional issue and --issue", async () => { + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); + vi.spyOn(console, "error").mockImplementation(() => {}); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "attachments", + "list", + "ENG-42", + "--issue", + "ENG-43", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining( + "Invalid --issue: cannot be combined with positional issue", + ), + ); + expect(listAttachments).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + + exitSpy.mockRestore(); + }); + it("passes source-type filter", async () => { const program = createProgram(); await program.parseAsync([ @@ -178,6 +234,59 @@ describe("attachments create", () => { }), ); }); + + it("accepts --issue as an alias for the issue argument", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "attachments", + "create", + "--issue", + "ENG-42", + "--title", + "My PR", + "--url", + "https://github.com/org/repo/pull/1", + ]); + + expect(resolveIssueId).toHaveBeenCalledWith(expect.anything(), "ENG-42"); + expect(createAttachment).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + issueId: "resolved-issue-uuid", + title: "My PR", + url: "https://github.com/org/repo/pull/1", + }), + ); + }); + + it("passes optional comment and icon URL", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "attachments", + "create", + "ENG-42", + "--title", + "Build", + "--url", + "https://ci.example.com/build/1", + "--comment", + "Build is green", + "--icon-url", + "https://ci.example.com/icon.png", + ]); + + expect(createAttachment).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + commentBody: "Build is green", + iconUrl: "https://ci.example.com/icon.png", + }), + ); + }); }); describe("attachments delete", () => { diff --git a/tests/unit/commands/comments.test.ts b/tests/unit/commands/comments.test.ts index f16ee3c6..58c34ac7 100644 --- a/tests/unit/commands/comments.test.ts +++ b/tests/unit/commands/comments.test.ts @@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../../../src/common/context.js", () => ({ createContext: vi.fn(() => ({ gql: { request: vi.fn() }, - sdk: { sdk: {} }, })), getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), })); diff --git a/tests/unit/commands/documents.test.ts b/tests/unit/commands/documents.test.ts new file mode 100644 index 00000000..06508f32 --- /dev/null +++ b/tests/unit/commands/documents.test.ts @@ -0,0 +1,226 @@ +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../../src/common/context.js", () => ({ + createContext: vi.fn(() => ({ + gql: { request: vi.fn() }, + })), + getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), +})); + +vi.mock("../../../src/common/output.js", async (importOriginal) => { + const actual = + await importOriginal<typeof import("../../../src/common/output.js")>(); + return { + ...actual, + outputSuccess: vi.fn(), + }; +}); + +vi.mock("../../../src/resolvers/issue-resolver.js", () => ({ + resolveIssueId: vi.fn().mockResolvedValue("resolved-issue-uuid"), +})); + +vi.mock("../../../src/resolvers/project-resolver.js", () => ({ + resolveProjectId: vi.fn().mockResolvedValue("resolved-project-uuid"), +})); + +vi.mock("../../../src/resolvers/team-resolver.js", () => ({ + resolveTeamId: vi.fn().mockResolvedValue("resolved-team-uuid"), +})); + +vi.mock("../../../src/services/attachment-service.js", () => ({ + listAttachments: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../../../src/services/document-service.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/services/document-service.js") + >(); + return { + ...actual, + createDocument: vi.fn().mockResolvedValue({ + id: "doc-1", + title: "Runbook", + url: "https://linear.app/example/document/runbook-abc123", + }), + deleteDocument: vi.fn().mockResolvedValue({ id: "doc-1", success: true }), + getDocument: vi.fn().mockResolvedValue({ id: "doc-1", title: "Runbook" }), + listDocuments: vi.fn().mockResolvedValue({ + nodes: [{ id: "doc-1", title: "Runbook" }], + pageInfo: { hasNextPage: false, endCursor: null }, + }), + updateDocument: vi + .fn() + .mockResolvedValue({ id: "doc-1", title: "Runbook" }), + }; +}); + +import { setupDocumentsCommands } from "../../../src/commands/documents.js"; +import { resolveIssueId } from "../../../src/resolvers/issue-resolver.js"; +import { resolveTeamId } from "../../../src/resolvers/team-resolver.js"; +import { listAttachments } from "../../../src/services/attachment-service.js"; +import { + createDocument, + listDocuments, +} from "../../../src/services/document-service.js"; + +function createProgram(): Command { + const program = new Command(); + program.option("--api-token <token>"); + setupDocumentsCommands(program); + return program; +} + +describe("documents list", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + it("uses the document issue filter for --issue", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "documents", + "list", + "--issue", + "ENG-42", + ]); + + expect(resolveIssueId).toHaveBeenCalledWith(expect.anything(), "ENG-42"); + expect(listDocuments).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + filter: { issue: { id: { eq: "resolved-issue-uuid" } } }, + }), + ); + }); + + it("includes legacy document URL attachments in the issue filter", async () => { + vi.mocked(listAttachments).mockResolvedValueOnce([ + { + id: "att-1", + title: "Runbook", + subtitle: null, + url: "https://linear.app/example/document/runbook-abc123", + sourceType: null, + metadata: {}, + source: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ]); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "documents", + "list", + "--issue", + "ENG-42", + ]); + + expect(listDocuments).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + filter: { + or: [ + { issue: { id: { eq: "resolved-issue-uuid" } } }, + { slugId: { eq: "abc123" } }, + ], + }, + }), + ); + }); +}); + +describe("documents create", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + it("passes issueId directly when --issue is provided", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "documents", + "create", + "--title", + "Runbook", + "--issue", + "ENG-42", + ]); + + expect(resolveIssueId).toHaveBeenCalledWith(expect.anything(), "ENG-42"); + expect(createDocument).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + title: "Runbook", + issueId: "resolved-issue-uuid", + }), + ); + }); + + it("accepts --attach-to as an alias for --issue", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "documents", + "create", + "--title", + "Runbook", + "--team", + "ENG", + "--attach-to", + "ENG-42", + ]); + + expect(resolveTeamId).toHaveBeenCalledWith(expect.anything(), "ENG"); + expect(resolveIssueId).toHaveBeenCalledWith(expect.anything(), "ENG-42"); + expect(createDocument).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + teamId: "resolved-team-uuid", + issueId: "resolved-issue-uuid", + }), + ); + }); + + it("rejects combining --issue and --attach-to before creating", async () => { + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); + vi.spyOn(console, "error").mockImplementation(() => {}); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "documents", + "create", + "--title", + "Runbook", + "--issue", + "ENG-42", + "--attach-to", + "ENG-43", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining( + "Invalid --attach-to: cannot be combined with --issue", + ), + ); + expect(createDocument).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + + exitSpy.mockRestore(); + }); +}); diff --git a/tests/unit/commands/initiatives.test.ts b/tests/unit/commands/initiatives.test.ts index 36ce0afb..bd0b7d6e 100644 --- a/tests/unit/commands/initiatives.test.ts +++ b/tests/unit/commands/initiatives.test.ts @@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../../../src/common/context.js", () => ({ createContext: vi.fn(() => ({ gql: { request: vi.fn() }, - sdk: { sdk: {} }, })), getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), })); @@ -40,28 +39,40 @@ vi.mock("../../../src/resolvers/user-resolver.js", () => ({ resolveUserId: vi.fn().mockResolvedValue("resolved-user-uuid"), })); -vi.mock("../../../src/services/initiative-service.js", () => ({ - listInitiatives: vi.fn().mockResolvedValue({ - nodes: [], - pageInfo: { hasNextPage: false, endCursor: null }, - }), - getInitiative: vi.fn().mockResolvedValue({ id: "resolved-initiative-uuid" }), - createInitiative: vi - .fn() - .mockResolvedValue({ id: "resolved-initiative-uuid" }), - updateInitiative: vi - .fn() - .mockResolvedValue({ id: "resolved-initiative-uuid" }), - archiveInitiative: vi - .fn() - .mockResolvedValue({ id: "resolved-initiative-uuid" }), - unarchiveInitiative: vi - .fn() - .mockResolvedValue({ id: "resolved-initiative-uuid" }), - deleteInitiative: vi - .fn() - .mockResolvedValue({ id: "resolved-initiative-uuid", success: true }), -})); +vi.mock( + "../../../src/services/initiative-service.js", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/services/initiative-service.js") + >(); + return { + ...actual, + listInitiatives: vi.fn().mockResolvedValue({ + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }), + getInitiative: vi + .fn() + .mockResolvedValue({ id: "resolved-initiative-uuid" }), + createInitiative: vi + .fn() + .mockResolvedValue({ id: "resolved-initiative-uuid" }), + updateInitiative: vi + .fn() + .mockResolvedValue({ id: "resolved-initiative-uuid" }), + archiveInitiative: vi + .fn() + .mockResolvedValue({ id: "resolved-initiative-uuid" }), + unarchiveInitiative: vi + .fn() + .mockResolvedValue({ id: "resolved-initiative-uuid" }), + deleteInitiative: vi + .fn() + .mockResolvedValue({ id: "resolved-initiative-uuid", success: true }), + }; + }, +); vi.mock("../../../src/services/initiative-relation-service.js", () => ({ createInitiativeRelation: vi @@ -81,27 +92,37 @@ vi.mock("../../../src/services/initiative-project-service.js", () => ({ .mockResolvedValue({ id: "resolved-link-uuid", success: true }), })); -vi.mock("../../../src/services/initiative-update-service.js", () => ({ - listInitiativeUpdates: vi.fn().mockResolvedValue({ - nodes: [], - pageInfo: { hasNextPage: false, endCursor: null }, - }), - getInitiativeUpdate: vi - .fn() - .mockResolvedValue({ id: "resolved-update-uuid" }), - createInitiativeUpdate: vi - .fn() - .mockResolvedValue({ id: "resolved-update-uuid" }), - updateInitiativeUpdate: vi - .fn() - .mockResolvedValue({ id: "resolved-update-uuid" }), - archiveInitiativeUpdate: vi - .fn() - .mockResolvedValue({ id: "resolved-update-uuid" }), - unarchiveInitiativeUpdate: vi - .fn() - .mockResolvedValue({ id: "resolved-update-uuid" }), -})); +vi.mock( + "../../../src/services/initiative-update-service.js", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/services/initiative-update-service.js") + >(); + return { + ...actual, + listInitiativeUpdates: vi.fn().mockResolvedValue({ + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }), + getInitiativeUpdate: vi + .fn() + .mockResolvedValue({ id: "resolved-update-uuid" }), + createInitiativeUpdate: vi + .fn() + .mockResolvedValue({ id: "resolved-update-uuid" }), + updateInitiativeUpdate: vi + .fn() + .mockResolvedValue({ id: "resolved-update-uuid" }), + archiveInitiativeUpdate: vi + .fn() + .mockResolvedValue({ id: "resolved-update-uuid" }), + unarchiveInitiativeUpdate: vi + .fn() + .mockResolvedValue({ id: "resolved-update-uuid" }), + }; + }, +); vi.mock("../../../src/services/discussion-service.js", () => ({ startInitiativeDiscussion: vi diff --git a/tests/unit/commands/issues.test.ts b/tests/unit/commands/issues.test.ts index 389e790f..f5683794 100644 --- a/tests/unit/commands/issues.test.ts +++ b/tests/unit/commands/issues.test.ts @@ -1,12 +1,13 @@ // tests/unit/commands/issues.test.ts + import { Command } from "commander"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { asUuid } from "../../../src/common/identifier.js"; // Mock all external dependencies before importing the module under test vi.mock("../../../src/common/context.js", () => ({ createContext: vi.fn(() => ({ gql: { request: vi.fn() }, - sdk: { sdk: {} }, })), getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), })); @@ -20,20 +21,13 @@ vi.mock("../../../src/common/output.js", async (importOriginal) => { }; }); -vi.mock("../../../src/resolvers/user-resolver.js", () => ({ - resolveUserId: vi.fn().mockResolvedValue("resolved-user-uuid"), +vi.mock("../../../src/resolvers/issue-mutation-resolver.js", () => ({ + resolveCreateIssueIds: vi.fn(), + resolveUpdateIssueIds: vi.fn(), })); -vi.mock("../../../src/resolvers/team-resolver.js", () => ({ - resolveTeamId: vi.fn().mockResolvedValue("resolved-team-uuid"), - resolveTeamEstimateContext: vi.fn().mockResolvedValue({ - teamId: "resolved-team-uuid", - teamKey: "ENG", - teamName: "Engineering", - issueEstimationType: "fibonacci", - issueEstimationExtended: false, - issueEstimationAllowZero: false, - }), +vi.mock("../../../src/resolvers/issue-filter-resolver.js", () => ({ + resolveSearchFilterIds: vi.fn(), })); vi.mock("../../../src/resolvers/issue-resolver.js", () => ({ @@ -51,26 +45,6 @@ vi.mock("../../../src/resolvers/issue-resolver.js", () => ({ }), })); -vi.mock("../../../src/resolvers/project-resolver.js", () => ({ - resolveProjectId: vi.fn().mockResolvedValue("resolved-project-uuid"), -})); - -vi.mock("../../../src/resolvers/label-resolver.js", () => ({ - resolveLabelIds: vi.fn().mockResolvedValue(["resolved-label-uuid"]), -})); - -vi.mock("../../../src/resolvers/milestone-resolver.js", () => ({ - resolveMilestoneId: vi.fn().mockResolvedValue("resolved-milestone-uuid"), -})); - -vi.mock("../../../src/resolvers/cycle-resolver.js", () => ({ - resolveCycleId: vi.fn().mockResolvedValue("resolved-cycle-uuid"), -})); - -vi.mock("../../../src/resolvers/status-resolver.js", () => ({ - resolveStatusId: vi.fn().mockResolvedValue("resolved-status-uuid"), -})); - vi.mock("../../../src/services/issue-service.js", () => ({ archiveIssue: vi.fn().mockResolvedValue({ id: "resolved-issue-uuid" }), createIssue: vi.fn().mockResolvedValue({ id: "new-issue-id" }), @@ -116,9 +90,17 @@ vi.mock("../../../src/services/issue-service.js", () => ({ })); vi.mock("../../../src/services/issue-relation-service.js", () => ({ - createIssueRelation: vi.fn(), - deleteIssueRelation: vi.fn(), - findIssueRelation: vi.fn(), + createIssueRelation: vi.fn().mockResolvedValue({ id: "relation-uuid" }), + deleteIssueRelation: vi.fn().mockResolvedValue({ + id: "relation-uuid", + success: true, + }), + findIssueRelation: vi.fn().mockResolvedValue("relation-uuid"), + listIssueRelations: vi.fn().mockResolvedValue({ + issueId: "resolved-issue-uuid", + identifier: "ENG-42", + relations: [], + }), })); vi.mock("../../../src/services/reaction-service.js", () => ({ @@ -197,15 +179,15 @@ vi.mock("../../../src/services/discussion-service.js", () => ({ })); import { setupIssuesCommands } from "../../../src/commands/issues.js"; +import { resolveSearchFilterIds } from "../../../src/resolvers/issue-filter-resolver.js"; +import { + resolveCreateIssueIds, + resolveUpdateIssueIds, +} from "../../../src/resolvers/issue-mutation-resolver.js"; import { resolveIssueEstimateContext, resolveIssueId, } from "../../../src/resolvers/issue-resolver.js"; -import { - resolveTeamEstimateContext, - resolveTeamId, -} from "../../../src/resolvers/team-resolver.js"; -import { resolveUserId } from "../../../src/resolvers/user-resolver.js"; import { createDiscussionCommentReaction, deleteDiscussionComment, @@ -247,6 +229,69 @@ import { deleteOwnReactionById, } from "../../../src/services/reaction-service.js"; +// Default echo implementations for the batch resolvers: each provided human +// input is "resolved" to a deterministic UUID. Set once at module scope; +// beforeEach uses clearAllMocks (call history only), so implementations persist. +// Individual tests override with mock*Once for estimate-context / error cases. +vi.mocked(resolveCreateIssueIds).mockImplementation(async (_gql, input) => { + const out: Awaited<ReturnType<typeof resolveCreateIssueIds>> = { + teamId: asUuid("resolved-team-uuid"), + }; + if (input.assignee) out.assigneeId = asUuid("resolved-user-uuid"); + if (input.project) out.projectId = asUuid("resolved-project-uuid"); + if (input.labels) out.labelIds = [asUuid("resolved-label-uuid")]; + if (input.projectMilestone) { + out.projectMilestoneId = asUuid("resolved-milestone-uuid"); + } + if (input.cycle) out.cycleId = asUuid("resolved-cycle-uuid"); + if (input.status) out.stateId = asUuid("resolved-status-uuid"); + if (input.parentTicket) out.parentId = asUuid("resolved-parent-uuid"); + if (input.withEstimateContext) { + out.estimateContext = { + teamId: asUuid("resolved-team-uuid"), + teamKey: "ENG", + teamName: "Engineering", + issueEstimationType: "fibonacci", + issueEstimationExtended: false, + issueEstimationAllowZero: false, + }; + } + return out; +}); + +vi.mocked(resolveUpdateIssueIds).mockImplementation(async (_gql, input) => { + const out: Awaited<ReturnType<typeof resolveUpdateIssueIds>> = {}; + if (input.assignee) out.assigneeId = asUuid("resolved-user-uuid"); + if (input.project) out.projectId = asUuid("resolved-project-uuid"); + if (input.labels) { + out.labelIds = input.labels.map(() => asUuid("resolved-label-uuid")); + } + if (input.projectMilestone) { + out.projectMilestoneId = asUuid("resolved-milestone-uuid"); + } + if (input.cycle) out.cycleId = asUuid("resolved-cycle-uuid"); + if (input.status) out.stateId = asUuid("resolved-status-uuid"); + if (input.parentTicket) out.parentId = asUuid("resolved-parent-uuid"); + return out; +}); + +vi.mocked(resolveSearchFilterIds).mockImplementation(async (_gql, input) => { + const out: Awaited<ReturnType<typeof resolveSearchFilterIds>> = {}; + if (input.team) out.teamId = asUuid("resolved-team-uuid"); + if (input.assignee) out.assigneeId = asUuid("resolved-user-uuid"); + if (input.creator) out.creatorId = asUuid("resolved-creator-uuid"); + if (input.project) out.projectId = asUuid("resolved-project-uuid"); + if (input.statusNames && input.statusNames.length > 0) { + out.stateIds = [asUuid("resolved-status-uuid")]; + } + if (input.labelNames && input.labelNames.length > 0) { + out.labelIds = [asUuid("resolved-label-uuid")]; + } + if (input.cycle) out.cycleId = asUuid("resolved-cycle-uuid"); + if (input.parent) out.parentId = asUuid("resolved-parent-uuid"); + return out; +}); + function createProgram(): Command { const program = new Command(); program.option("--api-token <token>"); @@ -276,7 +321,10 @@ describe("issues create --assignee", () => { "John Doe", ]); - expect(resolveUserId).toHaveBeenCalledWith(expect.anything(), "John Doe"); + expect(resolveCreateIssueIds).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ assignee: "John Doe" }), + ); expect(createIssue).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ assigneeId: "resolved-user-uuid" }), @@ -297,9 +345,9 @@ describe("issues create --assignee", () => { "john@example.com", ]); - expect(resolveUserId).toHaveBeenCalledWith( + expect(resolveCreateIssueIds).toHaveBeenCalledWith( expect.anything(), - "john@example.com", + expect.objectContaining({ assignee: "john@example.com" }), ); expect(createIssue).toHaveBeenCalledWith( expect.anything(), @@ -307,7 +355,7 @@ describe("issues create --assignee", () => { ); }); - it("does not call resolveUserId when --assignee is omitted", async () => { + it("does not resolve an assignee when --assignee is omitted", async () => { const program = createProgram(); await program.parseAsync([ "node", @@ -319,7 +367,10 @@ describe("issues create --assignee", () => { "ENG", ]); - expect(resolveUserId).not.toHaveBeenCalled(); + expect(resolveCreateIssueIds).toHaveBeenCalledWith( + expect.anything(), + expect.not.objectContaining({ assignee: expect.anything() }), + ); expect(createIssue).toHaveBeenCalledWith( expect.anything(), expect.not.objectContaining({ assigneeId: expect.anything() }), @@ -356,13 +407,16 @@ describe("issues create --estimate", () => { }); it("passes estimate 0 through to createIssue when team allows zero", async () => { - vi.mocked(resolveTeamEstimateContext).mockResolvedValueOnce({ - teamId: "resolved-team-uuid", - teamKey: "ENG", - teamName: "Engineering", - issueEstimationType: "fibonacci", - issueEstimationExtended: false, - issueEstimationAllowZero: true, + vi.mocked(resolveCreateIssueIds).mockResolvedValueOnce({ + teamId: asUuid("resolved-team-uuid"), + estimateContext: { + teamId: asUuid("resolved-team-uuid"), + teamKey: "ENG", + teamName: "Engineering", + issueEstimationType: "fibonacci", + issueEstimationExtended: false, + issueEstimationAllowZero: true, + }, }); const program = createProgram(); @@ -418,7 +472,7 @@ describe("issues create --estimate", () => { ]); const outOfScaleCreateError = JSON.parse( - vi.mocked(console.error).mock.calls[0][0] as string, + vi.mocked(console.error).mock.calls[0]?.[0] as string, ) as { error: string }; expect(outOfScaleCreateError.error).toBe( 'Invalid --estimate: must be one of [1, 2, 3, 5, 8] for team "ENG" (fibonacci)', @@ -427,13 +481,16 @@ describe("issues create --estimate", () => { }); it("rejects create estimate when team estimation disabled", async () => { - vi.mocked(resolveTeamEstimateContext).mockResolvedValueOnce({ - teamId: "resolved-team-uuid", - teamKey: "ENG", - teamName: "Engineering", - issueEstimationType: "notUsed", - issueEstimationExtended: false, - issueEstimationAllowZero: false, + vi.mocked(resolveCreateIssueIds).mockResolvedValueOnce({ + teamId: asUuid("resolved-team-uuid"), + estimateContext: { + teamId: asUuid("resolved-team-uuid"), + teamKey: "ENG", + teamName: "Engineering", + issueEstimationType: "notUsed", + issueEstimationExtended: false, + issueEstimationAllowZero: false, + }, }); const program = createProgram(); @@ -450,7 +507,7 @@ describe("issues create --estimate", () => { ]); const disabledEstimationCreateError = JSON.parse( - vi.mocked(console.error).mock.calls[0][0] as string, + vi.mocked(console.error).mock.calls[0]?.[0] as string, ) as { error: string }; expect(disabledEstimationCreateError.error).toBe( 'Invalid --estimate: team "ENG" has estimates disabled (issueEstimationType=notUsed)', @@ -486,7 +543,7 @@ describe("issues create numeric option validation", () => { "Invalid --priority: must be an integer between 1 and 4", ), ); - expect(resolveTeamId).not.toHaveBeenCalled(); + expect(resolveCreateIssueIds).not.toHaveBeenCalled(); expect(createIssue).not.toHaveBeenCalled(); }); @@ -509,7 +566,7 @@ describe("issues create numeric option validation", () => { "Invalid --estimate: must be a non-negative integer", ), ); - expect(resolveTeamId).not.toHaveBeenCalled(); + expect(resolveCreateIssueIds).not.toHaveBeenCalled(); expect(createIssue).not.toHaveBeenCalled(); }); @@ -660,7 +717,7 @@ describe("issues update --estimate", () => { ]); const outOfScaleUpdateError = JSON.parse( - vi.mocked(console.error).mock.calls[0][0] as string, + vi.mocked(console.error).mock.calls[0]?.[0] as string, ) as { error: string }; expect(outOfScaleUpdateError.error).toBe( 'Invalid --estimate: must be one of [1, 2, 3, 4, 5] for team "ENG" (linear)', @@ -1013,7 +1070,11 @@ describe("issues update --assignee", () => { "Jane Smith", ]); - expect(resolveUserId).toHaveBeenCalledWith(expect.anything(), "Jane Smith"); + expect(resolveUpdateIssueIds).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ assignee: "Jane Smith" }), + expect.anything(), + ); expect(updateIssue).toHaveBeenCalledWith( expect.anything(), "resolved-issue-uuid", @@ -1021,7 +1082,7 @@ describe("issues update --assignee", () => { ); }); - it("does not call resolveUserId when --assignee is omitted", async () => { + it("does not resolve IDs when only non-reference fields change", async () => { const program = createProgram(); await program.parseAsync([ "node", @@ -1033,7 +1094,7 @@ describe("issues update --assignee", () => { "New title", ]); - expect(resolveUserId).not.toHaveBeenCalled(); + expect(resolveUpdateIssueIds).not.toHaveBeenCalled(); }); }); @@ -1915,6 +1976,91 @@ describe("issues create relations", () => { ); expect(createIssueRelation).toHaveBeenCalledTimes(1); }); + + it("creates similar relation", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "create", + "Title", + "--team", + "ENG", + "--similar-to", + "DAT-103", + ]); + const { createIssueRelation } = await import( + "../../../src/services/issue-relation-service.js" + ); + expect(createIssueRelation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ type: "similar" }), + ); + }); +}); + +describe("issues update --labels", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + }); + + it("removes selected labels without clearing all labels", async () => { + vi.mocked(getIssue).mockResolvedValueOnce({ + id: "resolved-issue-uuid", + team: { id: "team-uuid", key: "ENG" }, + labels: { + nodes: [{ id: "keep-label-uuid" }, { id: "resolved-label-uuid" }], + }, + } as Awaited<ReturnType<typeof getIssue>>); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "update", + "ENG-123", + "--labels", + "bug", + "--label-mode", + "remove", + ]); + + expect(resolveUpdateIssueIds).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ labels: ["bug"] }), + expect.anything(), + ); + expect(updateIssue).toHaveBeenCalledWith( + expect.anything(), + "resolved-issue-uuid", + expect.objectContaining({ labelIds: ["keep-label-uuid"] }), + ); + }); + + it("rejects invalid issue label mode", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "update", + "ENG-123", + "--labels", + "bug", + "--label-mode", + "append", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("must be one of 'add', 'remove', or 'overwrite'"), + ); + expect(updateIssue).not.toHaveBeenCalled(); + }); }); describe("issues update relations", () => { @@ -1998,4 +2144,115 @@ describe("issues update relations", () => { ); expect(process.exit).toHaveBeenCalledWith(1); }); + + it("adds similar relation", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "update", + "ENG-42", + "--similar-to", + "DAT-103", + ]); + const { createIssueRelation } = await import( + "../../../src/services/issue-relation-service.js" + ); + expect(createIssueRelation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ type: "similar" }), + ); + }); +}); + +describe("issues relations subcommands", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + }); + + it("lists relations for an issue", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "relations", + "list", + "ENG-42", + ]); + + const { listIssueRelations } = await import( + "../../../src/services/issue-relation-service.js" + ); + expect(listIssueRelations).toHaveBeenCalledWith( + expect.anything(), + "resolved-issue-uuid", + ); + }); + + it("adds comma-separated relations", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "relations", + "add", + "ENG-42", + "--similar", + "DAT-103,DAT-104", + ]); + + const { createIssueRelation } = await import( + "../../../src/services/issue-relation-service.js" + ); + expect(createIssueRelation).toHaveBeenCalledTimes(2); + expect(createIssueRelation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ type: "similar" }), + ); + }); + + it("rejects add without relation type", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "relations", + "add", + "ENG-42", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining( + "Must specify one of --blocks, --related, --duplicate, or --similar", + ), + ); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("removes relation by UUID", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "relations", + "remove", + "relation-uuid", + ]); + + const { deleteIssueRelation } = await import( + "../../../src/services/issue-relation-service.js" + ); + expect(deleteIssueRelation).toHaveBeenCalledWith( + expect.anything(), + "relation-uuid", + ); + }); }); diff --git a/tests/unit/commands/labels.test.ts b/tests/unit/commands/labels.test.ts index 4388da0c..d383fcdc 100644 --- a/tests/unit/commands/labels.test.ts +++ b/tests/unit/commands/labels.test.ts @@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../../../src/common/context.js", () => ({ createContext: vi.fn(() => ({ gql: { request: vi.fn() }, - sdk: { sdk: {} }, })), getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), })); @@ -22,7 +21,33 @@ vi.mock("../../../src/resolvers/team-resolver.js", () => ({ resolveTeamId: vi.fn().mockResolvedValue("resolved-team-uuid"), })); +vi.mock("../../../src/resolvers/label-resolver.js", () => ({ + resolveLabelId: vi.fn().mockResolvedValue("resolved-label-uuid"), +})); + vi.mock("../../../src/services/label-service.js", () => ({ + createLabel: vi.fn().mockResolvedValue({ + id: "lbl-new", + name: "branch:unmerged", + color: "#B45309", + type: "issue", + }), + getLabel: vi.fn().mockResolvedValue({ + id: "resolved-label-uuid", + name: "branch:unmerged", + color: "#B45309", + type: "issue", + }), + updateLabel: vi.fn().mockResolvedValue({ + id: "resolved-label-uuid", + name: "branch:merged", + color: "#1D4ED8", + type: "issue", + }), + deleteLabel: vi.fn().mockResolvedValue({ + id: "resolved-label-uuid", + success: true, + }), listLabels: vi.fn().mockResolvedValue({ nodes: [{ id: "lbl-1", name: "Bug", color: "#ff0000", type: "issue" }], pageInfo: { hasNextPage: false, endCursor: null }, @@ -42,10 +67,15 @@ vi.mock("../../../src/services/label-service.js", () => ({ import { setupLabelsCommands } from "../../../src/commands/labels.js"; import { outputSuccess } from "../../../src/common/output.js"; +import { resolveLabelId } from "../../../src/resolvers/label-resolver.js"; import { resolveTeamId } from "../../../src/resolvers/team-resolver.js"; import { + createLabel, + deleteLabel, + getLabel, listLabels, listProjectLabels, + updateLabel, } from "../../../src/services/label-service.js"; function createProgram(): Command { @@ -216,7 +246,184 @@ describe("labels list", () => { }); }); -describe("labels list validation", () => { +describe("labels create", () => { + it("creates a workspace issue label by default", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "create", + "branch:unmerged", + ]); + + expect(resolveTeamId).not.toHaveBeenCalled(); + expect(createLabel).toHaveBeenCalledWith(expect.anything(), { + name: "branch:unmerged", + }); + expect(outputSuccess).toHaveBeenCalledWith({ + id: "lbl-new", + name: "branch:unmerged", + color: "#B45309", + type: "issue", + }); + }); + + it("creates a team-scoped issue label with optional fields", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "create", + "branch:unmerged", + "--team", + "DBL", + "--color", + "#B45309", + "--description", + "Created from DBL branch workflow", + ]); + + expect(resolveTeamId).toHaveBeenCalledWith(expect.anything(), "DBL"); + expect(createLabel).toHaveBeenCalledWith(expect.anything(), { + name: "branch:unmerged", + teamId: "resolved-team-uuid", + color: "#B45309", + description: "Created from DBL branch workflow", + }); + }); +}); + +describe("labels read", () => { + it("reads a label by resolved id", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "read", + "branch:unmerged", + "--team", + "DBL", + ]); + + expect(resolveTeamId).toHaveBeenCalledWith(expect.anything(), "DBL"); + expect(resolveLabelId).toHaveBeenCalledWith( + expect.anything(), + "branch:unmerged", + { + teamId: "resolved-team-uuid", + scope: undefined, + }, + ); + expect(getLabel).toHaveBeenCalledWith( + expect.anything(), + "resolved-label-uuid", + ); + }); +}); + +describe("labels update", () => { + it("updates a resolved label", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "update", + "branch:unmerged", + "--team", + "DBL", + "--name", + "branch:merged", + "--color", + "#1D4ED8", + "--description", + "Updated from DBL branch workflow", + ]); + + expect(resolveTeamId).toHaveBeenCalledWith(expect.anything(), "DBL"); + expect(resolveLabelId).toHaveBeenCalledWith( + expect.anything(), + "branch:unmerged", + { + teamId: "resolved-team-uuid", + scope: undefined, + }, + ); + expect(updateLabel).toHaveBeenCalledWith( + expect.anything(), + "resolved-label-uuid", + { + name: "branch:merged", + color: "#1D4ED8", + description: "Updated from DBL branch workflow", + }, + ); + }); + + it("clears the description when passed an empty string", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "update", + "branch:merged", + "--description", + "", + ]); + + expect(updateLabel).toHaveBeenCalledWith( + expect.anything(), + "resolved-label-uuid", + { + description: "", + }, + ); + }); +}); + +describe("labels delete", () => { + it("deletes a resolved label", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "delete", + "branch:unmerged", + "--scope", + "workspace", + ]); + + expect(resolveLabelId).toHaveBeenCalledWith( + expect.anything(), + "branch:unmerged", + { + teamId: undefined, + scope: "workspace", + }, + ); + expect(deleteLabel).toHaveBeenCalledWith( + expect.anything(), + "resolved-label-uuid", + ); + expect(outputSuccess).toHaveBeenCalledWith({ + id: "resolved-label-uuid", + success: true, + }); + }); +}); + +describe("labels validation", () => { it("rejects unsupported label types", async () => { const program = createProgram(); @@ -230,7 +437,7 @@ describe("labels list validation", () => { ]); const errorOutput = JSON.parse( - vi.mocked(console.error).mock.calls[0][0] as string, + vi.mocked(console.error).mock.calls[0]?.[0] as string, ) as { error: string }; expect(errorOutput.error).toBe( @@ -254,7 +461,7 @@ describe("labels list validation", () => { ]); const errorOutput = JSON.parse( - vi.mocked(console.error).mock.calls[0][0] as string, + vi.mocked(console.error).mock.calls[0]?.[0] as string, ) as { error: string }; expect(errorOutput.error).toBe( @@ -278,7 +485,7 @@ describe("labels list validation", () => { ]); const errorOutput = JSON.parse( - vi.mocked(console.error).mock.calls[0][0] as string, + vi.mocked(console.error).mock.calls[0]?.[0] as string, ) as { error: string }; expect(errorOutput.error).toBe( @@ -304,7 +511,7 @@ describe("labels list validation", () => { ]); const errorOutput = JSON.parse( - vi.mocked(console.error).mock.calls[0][0] as string, + vi.mocked(console.error).mock.calls[0]?.[0] as string, ) as { error: string }; expect(errorOutput.error).toBe( @@ -330,7 +537,7 @@ describe("labels list validation", () => { ]); const errorOutput = JSON.parse( - vi.mocked(console.error).mock.calls[0][0] as string, + vi.mocked(console.error).mock.calls[0]?.[0] as string, ) as { error: string }; expect(errorOutput.error).toBe( @@ -356,7 +563,7 @@ describe("labels list validation", () => { ]); const errorOutput = JSON.parse( - vi.mocked(console.error).mock.calls[0][0] as string, + vi.mocked(console.error).mock.calls[0]?.[0] as string, ) as { error: string }; expect(errorOutput.error).toBe( @@ -366,4 +573,94 @@ describe("labels list validation", () => { expect(listProjectLabels).not.toHaveBeenCalled(); expect(resolveTeamId).not.toHaveBeenCalled(); }); + + it("rejects invalid label colors on create", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "create", + "branch:unmerged", + "--color", + "B45309", + ]); + + const errorOutput = JSON.parse( + vi.mocked(console.error).mock.calls[0]?.[0] as string, + ) as { error: string }; + + expect(errorOutput.error).toBe( + "Invalid --color: must be a hex color like #B45309", + ); + expect(createLabel).not.toHaveBeenCalled(); + }); + + it("rejects invalid label colors on update", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "update", + "branch:unmerged", + "--color", + "B45309", + ]); + + const errorOutput = JSON.parse( + vi.mocked(console.error).mock.calls[0]?.[0] as string, + ) as { error: string }; + + expect(errorOutput.error).toBe( + "Invalid --color: must be a hex color like #B45309", + ); + expect(updateLabel).not.toHaveBeenCalled(); + }); + + it("rejects update with no fields", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "update", + "branch:unmerged", + ]); + + const errorOutput = JSON.parse( + vi.mocked(console.error).mock.calls[0]?.[0] as string, + ) as { error: string }; + + expect(errorOutput.error).toBe( + "Invalid label update: at least one option must be provided", + ); + expect(updateLabel).not.toHaveBeenCalled(); + }); + + it("rejects team scope without a team filter for read", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "read", + "branch:unmerged", + "--scope", + "team", + ]); + + const errorOutput = JSON.parse( + vi.mocked(console.error).mock.calls[0]?.[0] as string, + ) as { error: string }; + + expect(errorOutput.error).toBe( + "Invalid --scope: team scope requires --team", + ); + expect(resolveLabelId).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/commands/projects.test.ts b/tests/unit/commands/projects.test.ts index 9f142991..7bb9a6f4 100644 --- a/tests/unit/commands/projects.test.ts +++ b/tests/unit/commands/projects.test.ts @@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../../../src/common/context.js", () => ({ createContext: vi.fn(() => ({ gql: { request: vi.fn() }, - sdk: { sdk: {} }, })), getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), })); @@ -113,7 +112,11 @@ vi.mock("../../../src/services/discussion-service.js", () => ({ import { setupProjectsCommands } from "../../../src/commands/projects.js"; import { outputSuccess } from "../../../src/common/output.js"; -import { resolveProjectId } from "../../../src/resolvers/project-resolver.js"; +import { + resolveProjectId, + resolveProjectLabelIds, +} from "../../../src/resolvers/project-resolver.js"; +import { resolveTeamId } from "../../../src/resolvers/team-resolver.js"; import { createDiscussionCommentReaction, deleteDiscussionComment, @@ -136,6 +139,7 @@ import { createProject, deleteProject, getProject, + listProjects, unarchiveProject, updateProject, } from "../../../src/services/project-service.js"; @@ -147,6 +151,34 @@ function createProgram(): Command { return program; } +describe("projects list", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + }); + + it("passes includeArchived to project listing", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "list", + "--include-archived", + "--limit", + "25", + ]); + + expect(listProjects).toHaveBeenCalledWith(expect.anything(), { + limit: 25, + after: undefined, + includeArchived: true, + }); + }); +}); + describe("projects read", () => { beforeEach(() => { vi.clearAllMocks(); @@ -172,9 +204,49 @@ describe("projects read", () => { expect(getProject).toHaveBeenCalledWith( expect.anything(), "resolved-project-uuid", + { milestonesFirst: 25, issuesFirst: 50 }, ); expect(outputSuccess).toHaveBeenCalledWith({ id: "proj-1" }); }); + + it("passes project detail expansion limits including zero", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "read", + "My Project", + "--milestones-first", + "0", + "--issues-first", + "10", + ]); + + expect(getProject).toHaveBeenCalledWith( + expect.anything(), + "resolved-project-uuid", + { milestonesFirst: 0, issuesFirst: 10 }, + ); + }); + + it("rejects negative project detail expansion limits", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "read", + "My Project", + "--issues-first", + "-1", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Invalid --issues-first"), + ); + expect(getProject).not.toHaveBeenCalled(); + }); }); describe("projects lifecycle", () => { @@ -349,6 +421,141 @@ describe("projects create --priority", () => { }); }); +describe("projects create compatibility options", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + }); + + it("accepts singular --team and forwards icon and color", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "create", + "My Project", + "--team", + "ENG", + "--icon", + "rocket", + "--color", + "#ff0000", + ]); + + expect(resolveTeamId).toHaveBeenCalledWith(expect.anything(), "ENG"); + expect(createProject).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + teamIds: ["resolved-team-uuid"], + icon: "rocket", + color: "#ff0000", + }), + ); + }); + + it("rejects combining --team and --teams", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "create", + "My Project", + "--team", + "ENG", + "--teams", + "DES", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("cannot be combined with --teams"), + ); + expect(createProject).not.toHaveBeenCalled(); + }); +}); + +describe("projects update compatibility options", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + }); + + it("clears lead and lifecycle dates", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "update", + "My Project", + "--clear-lead", + "--clear-start-date", + "--clear-target-date", + ]); + + expect(updateProject).toHaveBeenCalledWith( + expect.anything(), + "resolved-project-uuid", + expect.objectContaining({ + leadId: null, + startDate: null, + targetDate: null, + }), + ); + }); + + it("updates icon, color, and singular team alias", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "update", + "My Project", + "--icon", + "target", + "--color", + "#00ff00", + "--team", + "ENG", + ]); + + expect(updateProject).toHaveBeenCalledWith( + expect.anything(), + "resolved-project-uuid", + expect.objectContaining({ + icon: "target", + color: "#00ff00", + teamIds: ["resolved-team-uuid"], + }), + ); + }); + + it("rejects clear flags combined with replacement values", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "update", + "My Project", + "--lead", + "Ada", + "--clear-lead", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("cannot be combined with --clear-lead"), + ); + expect(updateProject).not.toHaveBeenCalled(); + }); +}); + describe("projects discussion commands", () => { beforeEach(() => { vi.clearAllMocks(); @@ -847,4 +1054,105 @@ describe("projects update", () => { expect.objectContaining({ name: "New Name" }), ); }); + + it("adds labels without dropping existing project labels", async () => { + vi.mocked(getProject).mockResolvedValueOnce({ + id: "proj-1", + labels: { nodes: [{ id: "existing-label-uuid" }] }, + } as Awaited<ReturnType<typeof getProject>>); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "update", + "My Project", + "--labels", + "Q3", + "--label-mode", + "add", + ]); + + expect(getProject).toHaveBeenCalledWith( + expect.anything(), + "resolved-project-uuid", + ); + expect(resolveProjectLabelIds).toHaveBeenCalledWith(expect.anything(), [ + "Q3", + ]); + expect(updateProject).toHaveBeenCalledWith( + expect.anything(), + "resolved-project-uuid", + expect.objectContaining({ + labelIds: ["existing-label-uuid", "resolved-label-uuid"], + }), + ); + }); + + it("removes selected project labels without clearing all labels", async () => { + vi.mocked(getProject).mockResolvedValueOnce({ + id: "proj-1", + labels: { + nodes: [{ id: "keep-label-uuid" }, { id: "resolved-label-uuid" }], + }, + } as Awaited<ReturnType<typeof getProject>>); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "update", + "My Project", + "--labels", + "Q3", + "--label-mode", + "remove", + ]); + + expect(updateProject).toHaveBeenCalledWith( + expect.anything(), + "resolved-project-uuid", + expect.objectContaining({ labelIds: ["keep-label-uuid"] }), + ); + }); + + it("clears all project labels", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "update", + "My Project", + "--clear-labels", + ]); + + expect(updateProject).toHaveBeenCalledWith( + expect.anything(), + "resolved-project-uuid", + expect.objectContaining({ labelIds: [] }), + ); + }); + + it("rejects invalid project label mode", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "update", + "My Project", + "--labels", + "Q3", + "--label-mode", + "append", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("must be one of 'add', 'remove', or 'overwrite'"), + ); + expect(updateProject).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/commands/teams.test.ts b/tests/unit/commands/teams.test.ts index febd052b..a23af4f7 100644 --- a/tests/unit/commands/teams.test.ts +++ b/tests/unit/commands/teams.test.ts @@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../../../src/common/context.js", () => ({ createContext: vi.fn(() => ({ gql: { request: vi.fn() }, - sdk: { sdk: {} }, })), getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), })); @@ -22,6 +21,10 @@ vi.mock("../../../src/resolvers/team-resolver.js", () => ({ resolveTeamId: vi.fn().mockResolvedValue("resolved-team-uuid"), })); +vi.mock("../../../src/resolvers/user-resolver.js", () => ({ + resolveUserId: vi.fn().mockResolvedValue("resolved-user-uuid"), +})); + vi.mock("../../../src/services/team-service.js", () => ({ listTeams: vi.fn().mockResolvedValue({ nodes: [{ id: "team-1", key: "ENG", name: "Engineering" }], @@ -39,12 +42,36 @@ vi.mock("../../../src/services/team-service.js", () => ({ ], estimationSource: "self", }), + createTeam: vi + .fn() + .mockResolvedValue({ id: "team-new", key: "NEW", name: "New Team" }), + updateTeam: vi + .fn() + .mockResolvedValue({ id: "team-1", key: "ENG", name: "Renamed" }), + listTeamMembers: vi.fn().mockResolvedValue({ + nodes: [{ id: "m1", owner: true, user: { id: "user-1", name: "Alice" } }], + }), + addTeamMember: vi.fn().mockResolvedValue({ + id: "m1", + owner: false, + user: { id: "user-1", name: "Alice" }, + }), + removeTeamMember: vi.fn().mockResolvedValue({ id: "m1", success: true }), })); import { setupTeamsCommands } from "../../../src/commands/teams.js"; import { outputSuccess } from "../../../src/common/output.js"; import { resolveTeamId } from "../../../src/resolvers/team-resolver.js"; -import { getTeam, listTeams } from "../../../src/services/team-service.js"; +import { resolveUserId } from "../../../src/resolvers/user-resolver.js"; +import { + addTeamMember, + createTeam, + getTeam, + listTeamMembers, + listTeams, + removeTeamMember, + updateTeam, +} from "../../../src/services/team-service.js"; function createProgram(): Command { const program = new Command(); @@ -102,3 +129,155 @@ describe("teams list", () => { }); }); }); + +describe("teams create", () => { + it("builds input from flags and outputs the created team", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "teams", + "create", + "New Team", + "--key", + "NEW", + "--private", + "true", + "--cycles-enabled", + "false", + "--cycle-duration", + "2", + ]); + + expect(createTeam).toHaveBeenCalledWith(expect.anything(), { + name: "New Team", + key: "NEW", + private: true, + cyclesEnabled: false, + cycleDuration: 2, + }); + expect(outputSuccess).toHaveBeenCalledWith({ + id: "team-new", + key: "NEW", + name: "New Team", + }); + }); + + it("rejects an invalid estimation type", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "teams", + "create", + "New Team", + "--estimation-type", + "bogus", + ]); + + expect(createTeam).not.toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); +}); + +describe("teams update", () => { + it("resolves the team and passes only provided fields", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "teams", + "update", + "ENG", + "--name", + "Renamed", + "--triage-enabled", + "true", + ]); + + expect(resolveTeamId).toHaveBeenCalledWith(expect.anything(), "ENG"); + expect(updateTeam).toHaveBeenCalledWith( + expect.anything(), + "resolved-team-uuid", + { name: "Renamed", triageEnabled: true }, + ); + }); + + it("errors when no fields are provided", async () => { + const program = createProgram(); + + await program.parseAsync(["node", "test", "teams", "update", "ENG"]); + + expect(updateTeam).not.toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); +}); + +describe("teams members", () => { + it("lists members for the resolved team", async () => { + const program = createProgram(); + + await program.parseAsync(["node", "test", "teams", "members", "ENG"]); + + expect(listTeamMembers).toHaveBeenCalledWith(expect.anything(), { + id: "resolved-team-uuid", + }); + expect(outputSuccess).toHaveBeenCalledWith({ + nodes: [{ id: "m1", owner: true, user: { id: "user-1", name: "Alice" } }], + }); + }); +}); + +describe("teams add-member", () => { + it("resolves team and user then adds the member", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "teams", + "add-member", + "ENG", + "--user", + "alice@example.com", + "--owner", + "true", + ]); + + expect(resolveTeamId).toHaveBeenCalledWith(expect.anything(), "ENG"); + expect(resolveUserId).toHaveBeenCalledWith( + expect.anything(), + "alice@example.com", + ); + expect(addTeamMember).toHaveBeenCalledWith(expect.anything(), { + teamId: "resolved-team-uuid", + userId: "resolved-user-uuid", + owner: true, + }); + }); +}); + +describe("teams remove-member", () => { + it("resolves team and user then removes the member", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "teams", + "remove-member", + "ENG", + "--user", + "alice@example.com", + ]); + + expect(removeTeamMember).toHaveBeenCalledWith(expect.anything(), { + teamId: "resolved-team-uuid", + userId: "resolved-user-uuid", + }); + expect(outputSuccess).toHaveBeenCalledWith({ id: "m1", success: true }); + }); +}); diff --git a/tests/unit/common/array.test.ts b/tests/unit/common/array.test.ts new file mode 100644 index 00000000..5f45bce0 --- /dev/null +++ b/tests/unit/common/array.test.ts @@ -0,0 +1,39 @@ +// tests/unit/common/array.test.ts +import { describe, expect, it, vi } from "vitest"; +import { firstOrThrow } from "../../../src/common/array.js"; + +describe("firstOrThrow", () => { + it("returns the first element of a non-empty array", () => { + expect(firstOrThrow([1, 2, 3], "empty")).toBe(1); + expect(firstOrThrow(["a"], "empty")).toBe("a"); + }); + + it("throws with the given message when the array is empty", () => { + expect(() => firstOrThrow([], "no items found")).toThrow("no items found"); + }); + + it("returns an undefined first element without throwing", () => { + expect(firstOrThrow([undefined, 2], "empty")).toBeUndefined(); + }); + + it("throws the provided Error instance when the array is empty", () => { + const err = new Error("custom"); + expect(() => firstOrThrow([], err)).toThrow(err); + }); + + it("invokes the error factory only when the array is empty", () => { + const factory = vi.fn(() => new Error("lazy")); + + expect(firstOrThrow([1], factory)).toBe(1); + expect(factory).not.toHaveBeenCalled(); + + expect(() => firstOrThrow([], factory)).toThrow("lazy"); + expect(factory).toHaveBeenCalledTimes(1); + }); + + it("wraps a string returned by the factory in an Error", () => { + expect(() => firstOrThrow([], () => "lazy message")).toThrow( + "lazy message", + ); + }); +}); diff --git a/tests/unit/common/auth.test.ts b/tests/unit/common/auth.test.ts index aa73dd9f..07f96272 100644 --- a/tests/unit/common/auth.test.ts +++ b/tests/unit/common/auth.test.ts @@ -14,19 +14,19 @@ import { getApiToken } from "../../../src/common/auth.js"; import { getStoredToken } from "../../../src/common/token-storage.js"; describe("getApiToken", () => { - const originalEnv = process.env.LINEAR_API_TOKEN; + const originalEnv = process.env["LINEAR_API_TOKEN"]; beforeEach(() => { vi.clearAllMocks(); - delete process.env.LINEAR_API_TOKEN; + delete process.env["LINEAR_API_TOKEN"]; vi.mocked(os.homedir).mockReturnValue("/home/testuser"); }); afterEach(() => { if (originalEnv !== undefined) { - process.env.LINEAR_API_TOKEN = originalEnv; + process.env["LINEAR_API_TOKEN"] = originalEnv; } else { - delete process.env.LINEAR_API_TOKEN; + delete process.env["LINEAR_API_TOKEN"]; } }); @@ -36,7 +36,7 @@ describe("getApiToken", () => { }); it("returns LINEAR_API_TOKEN env var as second priority", () => { - process.env.LINEAR_API_TOKEN = "env-token"; + process.env["LINEAR_API_TOKEN"] = "env-token"; const token = getApiToken({}); expect(token).toBe("env-token"); }); diff --git a/tests/unit/common/domain-values.test.ts b/tests/unit/common/domain-values.test.ts new file mode 100644 index 00000000..c802505f --- /dev/null +++ b/tests/unit/common/domain-values.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { parseLabelMode } from "../../../src/common/domain-values.js"; + +describe("parseLabelMode", () => { + it("returns undefined when value is undefined", () => { + expect(parseLabelMode(undefined)).toBeUndefined(); + }); + + it("returns the narrowed mode for valid values", () => { + expect(parseLabelMode("add")).toBe("add"); + expect(parseLabelMode("remove")).toBe("remove"); + expect(parseLabelMode("overwrite")).toBe("overwrite"); + }); + + it("throws for invalid values", () => { + expect(() => parseLabelMode("replace")).toThrow( + "Invalid --label-mode: must be one of 'add', 'remove', or 'overwrite'", + ); + expect(() => parseLabelMode("")).toThrow( + "Invalid --label-mode: must be one of 'add', 'remove', or 'overwrite'", + ); + }); +}); diff --git a/tests/unit/common/mutation-payload.test.ts b/tests/unit/common/mutation-payload.test.ts new file mode 100644 index 00000000..51acd4bd --- /dev/null +++ b/tests/unit/common/mutation-payload.test.ts @@ -0,0 +1,62 @@ +// tests/unit/common/mutation-payload.test.ts +import { describe, expect, it } from "vitest"; +import { + requireMutationEntity, + requireMutationSuccess, +} from "../../../src/common/mutation-payload.js"; + +describe("requireMutationEntity", () => { + it("returns the entity on success", () => { + const payload = { success: true, issue: { id: "iss-1" } }; + expect(requireMutationEntity(payload, "issue", "boom")).toEqual({ + id: "iss-1", + }); + }); + + it("supports arbitrary entity field names", () => { + const payload = { success: true, entity: { id: "ent-1" } }; + expect(requireMutationEntity(payload, "entity", "boom")).toEqual({ + id: "ent-1", + }); + }); + + it("returns a string entity field (e.g. entityId)", () => { + const payload = { success: true, entityId: "del-1" }; + expect(requireMutationEntity(payload, "entityId", "boom")).toBe("del-1"); + }); + + it("throws the given message when success is false", () => { + const payload = { success: false, issue: { id: "iss-1" } }; + expect(() => requireMutationEntity(payload, "issue", "boom")).toThrow( + "boom", + ); + }); + + it("throws when the entity field is null", () => { + const payload = { success: true, issue: null }; + expect(() => requireMutationEntity(payload, "issue", "boom")).toThrow( + "boom", + ); + }); + + it("throws when the entity field is undefined", () => { + const payload = { success: true, issue: undefined }; + expect(() => requireMutationEntity(payload, "issue", "boom")).toThrow( + "boom", + ); + }); +}); + +describe("requireMutationSuccess", () => { + it("does not throw on success", () => { + expect(() => + requireMutationSuccess({ success: true }, "boom"), + ).not.toThrow(); + }); + + it("throws the given message on failure", () => { + expect(() => requireMutationSuccess({ success: false }, "boom")).toThrow( + "boom", + ); + }); +}); diff --git a/tests/unit/common/object.test.ts b/tests/unit/common/object.test.ts new file mode 100644 index 00000000..f7771cd7 --- /dev/null +++ b/tests/unit/common/object.test.ts @@ -0,0 +1,25 @@ +// tests/unit/common/object.test.ts +import { describe, expect, it } from "vitest"; +import { omitUndefined } from "../../../src/common/object.js"; + +describe("omitUndefined", () => { + it("removes keys whose value is undefined", () => { + expect(omitUndefined({ a: 1, b: undefined, c: "x" })).toEqual({ + a: 1, + c: "x", + }); + }); + + it("keeps falsy values that are not undefined", () => { + expect(omitUndefined({ a: 0, b: "", c: false, d: null })).toEqual({ + a: 0, + b: "", + c: false, + d: null, + }); + }); + + it("returns an empty object when every value is undefined", () => { + expect(omitUndefined({ a: undefined, b: undefined })).toEqual({}); + }); +}); diff --git a/tests/unit/common/output.test.ts b/tests/unit/common/output.test.ts index e82d1e47..17c38a00 100644 --- a/tests/unit/common/output.test.ts +++ b/tests/unit/common/output.test.ts @@ -1,16 +1,21 @@ // tests/unit/common/output.test.ts -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { AuthenticationError } from "../../../src/common/errors.js"; import { handleCommand, outputAuthError, outputError, outputSuccess, + parseFieldsList, parseLimit, + pickFields, + setOutputOptions, } from "../../../src/common/output.js"; describe("outputSuccess", () => { - it("writes JSON to stdout", () => { + beforeEach(() => setOutputOptions({})); + + it("writes indented JSON to stdout by default", () => { const spy = vi.spyOn(console, "log").mockImplementation(() => {}); outputSuccess({ id: "123", title: "Test" }); expect(spy).toHaveBeenCalledWith( @@ -18,6 +23,166 @@ describe("outputSuccess", () => { ); spy.mockRestore(); }); + + it("emits single-line JSON when compact is set", () => { + setOutputOptions({ compact: true }); + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + outputSuccess({ id: "123", title: "Test" }); + expect(spy).toHaveBeenCalledWith('{"id":"123","title":"Test"}'); + spy.mockRestore(); + }); + + it("filters shape when fields are set", () => { + setOutputOptions({ fields: ["identifier", "state.name"] }); + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + outputSuccess({ + identifier: "ENG-1", + title: "Fix login bug", + state: { id: "s1", name: "In Progress", type: "started" }, + }); + expect(spy).toHaveBeenCalledWith( + JSON.stringify( + { identifier: "ENG-1", state: { name: "In Progress" } }, + null, + 2, + ), + ); + spy.mockRestore(); + }); + + it("drops undefined properties from optional fields", () => { + // Generated GraphQL results widen optional (`field?:`) props to + // `undefined`; JSON.stringify drops them, so the output stays valid JSON. + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + outputSuccess({ id: "123", editedAt: undefined }); + expect(spy).toHaveBeenCalledWith(JSON.stringify({ id: "123" }, null, 2)); + spy.mockRestore(); + }); + + it("serializes opaque Record<string, unknown> metadata as JSON", () => { + // Attachment metadata is an opaque JSON blob the type layer tolerates; it + // must still round-trip through the output boundary unchanged. + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + const metadata: Record<string, unknown> = { size: 42, nested: { a: [1] } }; + outputSuccess({ id: "123", metadata }); + expect(spy).toHaveBeenCalledWith( + JSON.stringify({ id: "123", metadata }, null, 2), + ); + spy.mockRestore(); + }); + + it("combines compact and fields (issue example)", () => { + setOutputOptions({ + compact: true, + fields: ["identifier", "title", "state.name"], + }); + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + outputSuccess([ + { + identifier: "ENG-1", + title: "Fix login bug", + state: { id: "s1", name: "In Progress", type: "started" }, + assignee: { id: "u1" }, + }, + ]); + expect(spy).toHaveBeenCalledWith( + '[{"identifier":"ENG-1","title":"Fix login bug","state":{"name":"In Progress"}}]', + ); + spy.mockRestore(); + }); +}); + +describe("parseFieldsList", () => { + it("splits on comma", () => { + expect(parseFieldsList("identifier,title,state.name")).toEqual([ + "identifier", + "title", + "state.name", + ]); + }); + + it("trims whitespace and drops empty entries", () => { + expect(parseFieldsList(" a , b ,, c ")).toEqual(["a", "b", "c"]); + }); +}); + +describe("pickFields", () => { + it("picks a single top-level field", () => { + expect(pickFields({ a: 1, b: 2 }, [["a"]])).toEqual({ a: 1 }); + }); + + it("picks a nested field", () => { + expect( + pickFields({ state: { name: "Todo", type: "unstarted" } }, [ + ["state", "name"], + ]), + ).toEqual({ state: { name: "Todo" } }); + }); + + it("merges sibling paths under one head", () => { + expect( + pickFields({ state: { id: "s1", name: "Todo", type: "unstarted" } }, [ + ["state", "name"], + ["state", "type"], + ]), + ).toEqual({ state: { name: "Todo", type: "unstarted" } }); + }); + + it("traverses arrays mid-path", () => { + expect( + pickFields( + { labels: { nodes: [{ name: "bug", id: "1" }, { name: "ux" }] } }, + [["labels", "nodes", "name"]], + ), + ).toEqual({ labels: { nodes: [{ name: "bug" }, { name: "ux" }] } }); + }); + + it("projects each element of a top-level array", () => { + expect( + pickFields( + [ + { id: "1", x: 1 }, + { id: "2", x: 2 }, + ], + [["id"]], + ), + ).toEqual([{ id: "1" }, { id: "2" }]); + }); + + it("keeps the whole subtree when a path stops at an object", () => { + const state = { id: "s1", name: "Todo" }; + expect(pickFields({ state, title: "t" }, [["state"]])).toEqual({ state }); + }); + + it("skips missing keys silently", () => { + expect(pickFields({ a: 1 }, [["a"], ["missing"]])).toEqual({ a: 1 }); + }); + + it("returns scalars unchanged when a path over-descends", () => { + expect(pickFields({ a: 5 }, [["a", "deep"]])).toEqual({ a: 5 }); + expect(pickFields({ a: null }, [["a", "deep"]])).toEqual({ a: null }); + }); + + it("never matches inherited (non-own) properties", () => { + const result = pickFields({ a: 1 }, [["toString"], ["constructor"]]); + expect(result).toEqual({}); + expect(Object.hasOwn(result as object, "toString")).toBe(false); + }); + + it("does not invoke the prototype setter for a __proto__ path", () => { + const result = pickFields({ a: 1 }, [["__proto__", "x"]]); + expect(result).toEqual({}); + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + }); + + it("preserves a legitimate own __proto__ key without polluting the result", () => { + const input = JSON.parse('{"__proto__":{"x":9},"a":1}') as unknown; + const result = pickFields(input, [["__proto__", "x"], ["a"]]); + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + expect((result as { a: number }).a).toBe(1); + const ownProto = Object.getOwnPropertyDescriptor(result, "__proto__"); + expect((ownProto?.value as { x: number }).x).toBe(9); + }); }); describe("outputError", () => { @@ -79,7 +244,7 @@ describe("handleCommand with AuthenticationError", () => { await handler(); - const output = JSON.parse(consoleSpy.mock.calls[0][0] as string); + const output = JSON.parse(consoleSpy.mock.calls[0]?.[0] as string); expect(output.error).toBe("AUTHENTICATION_REQUIRED"); expect(exitSpy).toHaveBeenCalledWith(42); @@ -120,7 +285,7 @@ describe("outputAuthError", () => { const err = new AuthenticationError("Token expired"); outputAuthError(err); - const output = JSON.parse(consoleSpy.mock.calls[0][0] as string); + const output = JSON.parse(consoleSpy.mock.calls[0]?.[0] as string); expect(output.error).toBe("AUTHENTICATION_REQUIRED"); expect(output.message).toBe("Linear API authentication failed."); expect(output.details).toBe("Token expired"); diff --git a/tests/unit/common/resolve-filters.test.ts b/tests/unit/common/resolve-filters.test.ts index 827ab20d..cfa372e1 100644 --- a/tests/unit/common/resolve-filters.test.ts +++ b/tests/unit/common/resolve-filters.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; -import type { LinearSdkClient } from "../../../src/client/linear-client.js"; import type { CommandContext } from "../../../src/common/context.js"; import { resolveFilterOptions } from "../../../src/common/resolve-filters.js"; import { resolveSearchFilterIds } from "../../../src/resolvers/issue-filter-resolver.js"; @@ -26,7 +25,6 @@ vi.mock("../../../src/resolvers/milestone-resolver.js", () => ({ function mockContext(): CommandContext { return { gql: {} as unknown as GraphQLClient, - sdk: {} as unknown as LinearSdkClient, }; } @@ -83,7 +81,6 @@ describe("resolveFilterOptions", () => { parent: "ENG-123", }); expect(resolveMilestoneId).toHaveBeenCalledWith( - expect.anything(), expect.anything(), "v1.0", "Backend", @@ -187,7 +184,6 @@ describe("resolveFilterOptions", () => { expect(resolveSearchFilterIds).not.toHaveBeenCalled(); expect(resolveMilestoneId).toHaveBeenCalledWith( - expect.anything(), expect.anything(), "550e8400-e29b-41d4-a716-446655440002", undefined, diff --git a/tests/unit/common/retry.test.ts b/tests/unit/common/retry.test.ts index 88fd5722..e578b8ce 100644 --- a/tests/unit/common/retry.test.ts +++ b/tests/unit/common/retry.test.ts @@ -33,6 +33,15 @@ describe("isRetryable", () => { it("returns false for generic errors", () => { expect(isRetryable(new Error("Entity not found"))).toBe(false); }); + + it("returns true for native fetch transport failures", () => { + expect(isRetryable(new TypeError("fetch failed"))).toBe(true); + }); + + it("returns true when a retryable code is only on the cause chain", () => { + const cause = new Error("read ECONNRESET"); + expect(isRetryable(new TypeError("fetch failed", { cause }))).toBe(true); + }); }); describe("withRetry", () => { diff --git a/tests/unit/common/token-storage.test.ts b/tests/unit/common/token-storage.test.ts index fa46a06b..2c73d67b 100644 --- a/tests/unit/common/token-storage.test.ts +++ b/tests/unit/common/token-storage.test.ts @@ -27,7 +27,7 @@ const originalPlatform = process.platform; beforeEach(() => { vi.clearAllMocks(); - delete process.env.XDG_CONFIG_HOME; + delete process.env["XDG_CONFIG_HOME"]; vi.mocked(os.homedir).mockReturnValue(HOME); }); @@ -62,13 +62,13 @@ describe("getTokenDir", () => { it("uses XDG_CONFIG_HOME on Linux when set", () => { setPlatform("linux"); - process.env.XDG_CONFIG_HOME = "/custom/config"; + process.env["XDG_CONFIG_HOME"] = "/custom/config"; expect(getTokenDir()).toBe(path.join("/custom/config", "linearis")); }); it("ignores relative XDG_CONFIG_HOME", () => { setPlatform("linux"); - process.env.XDG_CONFIG_HOME = "relative/path"; + process.env["XDG_CONFIG_HOME"] = "relative/path"; expect(getTokenDir()).toBe(xdgDir); }); }); diff --git a/tests/unit/common/update-notifier.test.ts b/tests/unit/common/update-notifier.test.ts new file mode 100644 index 00000000..abfab1c6 --- /dev/null +++ b/tests/unit/common/update-notifier.test.ts @@ -0,0 +1,205 @@ +import fs from "node:fs"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:fs"); + +vi.mock("../../../src/common/token-storage.js", () => ({ + getTokenDir: vi.fn(() => "/tmp/linearis-test"), + ensureTokenDir: vi.fn(), +})); + +import { + channelFor, + compareVersions, + formatUpdateNotice, + isNewer, + maybeNotifyUpdate, + readCache, + type UpdateCacheData, + updateChecksDisabled, +} from "../../../src/common/update-notifier.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("channelFor", () => { + it("returns 'next' for prerelease versions", () => { + expect(channelFor("2026.6.0-next.5")).toBe("next"); + }); + + it("returns 'latest' for stable versions", () => { + expect(channelFor("2026.6.0")).toBe("latest"); + }); +}); + +describe("compareVersions", () => { + it("orders by calver core", () => { + expect(compareVersions("2026.7.0", "2026.6.0")).toBeGreaterThan(0); + expect(compareVersions("2026.6.1", "2026.6.0")).toBeGreaterThan(0); + expect(compareVersions("2027.1.0", "2026.12.0")).toBeGreaterThan(0); + expect(compareVersions("2026.6.0", "2026.6.0")).toBe(0); + }); + + it("ranks a release above a prerelease of the same core", () => { + expect(compareVersions("2026.6.0", "2026.6.0-next.5")).toBeGreaterThan(0); + expect(compareVersions("2026.6.0-next.5", "2026.6.0")).toBeLessThan(0); + }); + + it("orders prerelease counters numerically", () => { + expect( + compareVersions("2026.6.0-next.10", "2026.6.0-next.9"), + ).toBeGreaterThan(0); + expect(compareVersions("2026.6.0-next.2", "2026.6.0-next.2")).toBe(0); + }); +}); + +describe("isNewer", () => { + it("is true only for strictly newer candidates", () => { + expect(isNewer("2026.6.0-next.6", "2026.6.0-next.5")).toBe(true); + expect(isNewer("2026.6.0-next.5", "2026.6.0-next.5")).toBe(false); + expect(isNewer("2026.6.0-next.4", "2026.6.0-next.5")).toBe(false); + }); +}); + +describe("updateChecksDisabled", () => { + it("respects the standard and project-specific opt-out env vars", () => { + expect(updateChecksDisabled({ NO_UPDATE_NOTIFIER: "1" })).toBe(true); + expect(updateChecksDisabled({ LINEARIS_NO_UPDATE_CHECK: "1" })).toBe(true); + expect(updateChecksDisabled({ CI: "true" })).toBe(true); + expect(updateChecksDisabled({})).toBe(false); + }); +}); + +describe("formatUpdateNotice", () => { + it("uses the channel-specific install tag", () => { + const next = formatUpdateNotice( + "2026.6.0-next.5", + "2026.6.0-next.6", + "next", + ); + expect(next).toContain("2026.6.0-next.5 → 2026.6.0-next.6"); + expect(next).toContain("npm install -g linearis@next"); + expect(next).toContain("NO_UPDATE_NOTIFIER=1"); + + const latest = formatUpdateNotice("2026.6.0", "2026.7.0", "latest"); + expect(latest).toContain("npm install -g linearis@latest"); + }); +}); + +describe("readCache", () => { + it("returns parsed cache data when valid", () => { + const cache: UpdateCacheData = { + channel: "next", + latest: "2026.6.0-next.6", + checkedAt: 123, + }; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(cache)); + expect(readCache()).toEqual(cache); + }); + + it("returns null on a missing file", () => { + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error("ENOENT"); + }); + expect(readCache()).toBeNull(); + }); + + it("returns null on corrupt or malformed cache", () => { + vi.mocked(fs.readFileSync).mockReturnValue("not json"); + expect(readCache()).toBeNull(); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ channel: "bogus", latest: 1 }), + ); + expect(readCache()).toBeNull(); + }); +}); + +describe("maybeNotifyUpdate", () => { + const originalIsTTY = process.stdout.isTTY; + let stderrSpy: ReturnType<typeof vi.spyOn>; + + function setStdoutTTY(value: boolean): void { + Object.defineProperty(process.stdout, "isTTY", { + value, + configurable: true, + }); + } + + beforeEach(() => { + stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true); + }); + + afterEach(() => { + Object.defineProperty(process.stdout, "isTTY", { + value: originalIsTTY, + configurable: true, + }); + stderrSpy.mockRestore(); + }); + + it("stays silent when stdout is not a TTY (agent/piped use)", async () => { + setStdoutTTY(false); + await maybeNotifyUpdate("2026.6.0-next.5"); + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + it("stays silent when update checks are disabled", async () => { + setStdoutTTY(true); + vi.stubEnv("NO_UPDATE_NOTIFIER", "1"); + await maybeNotifyUpdate("2026.6.0-next.5"); + expect(stderrSpy).not.toHaveBeenCalled(); + vi.unstubAllEnvs(); + }); + + it("prints a hint from a fresh cache without hitting the network", async () => { + setStdoutTTY(true); + vi.stubEnv("NO_UPDATE_NOTIFIER", ""); + vi.stubEnv("LINEARIS_NO_UPDATE_CHECK", ""); + vi.stubEnv("CI", ""); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const cache: UpdateCacheData = { + channel: "next", + latest: "2026.6.0-next.9", + checkedAt: Date.now(), + }; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(cache)); + await maybeNotifyUpdate("2026.6.0-next.5"); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(stderrSpy).toHaveBeenCalledOnce(); + expect(String(stderrSpy.mock.calls[0]?.[0])).toContain("update available"); + fetchSpy.mockRestore(); + vi.unstubAllEnvs(); + }); + + it("backs off by refreshing the cache when a stale lookup fails", async () => { + setStdoutTTY(true); + vi.stubEnv("NO_UPDATE_NOTIFIER", ""); + vi.stubEnv("LINEARIS_NO_UPDATE_CHECK", ""); + vi.stubEnv("CI", ""); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockRejectedValue(new Error("offline")); + const staleCache: UpdateCacheData = { + channel: "next", + latest: "2026.6.0-next.9", + checkedAt: 0, // older than CHECK_INTERVAL_MS → stale + }; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(staleCache)); + const before = Date.now(); + + await maybeNotifyUpdate("2026.6.0-next.5"); + + // checkedAt is advanced (so the next command backs off) while the prior + // latest is carried, so the notice still shows from cached data. + expect(fs.writeFileSync).toHaveBeenCalledOnce(); + const written = JSON.parse( + String(vi.mocked(fs.writeFileSync).mock.calls[0]?.[1]), + ) as UpdateCacheData; + expect(written.latest).toBe("2026.6.0-next.9"); + expect(written.checkedAt).toBeGreaterThanOrEqual(before); + expect(stderrSpy).toHaveBeenCalledOnce(); + fetchSpy.mockRestore(); + vi.unstubAllEnvs(); + }); +}); diff --git a/tests/unit/helpers/assert-variables.ts b/tests/unit/helpers/assert-variables.ts new file mode 100644 index 00000000..9f0d2691 --- /dev/null +++ b/tests/unit/helpers/assert-variables.ts @@ -0,0 +1,40 @@ +import { type DocumentNode, Kind } from "graphql"; +import { expect } from "vitest"; + +/** + * Collect the names of every variable declared by the operation(s) in a + * GraphQL document (e.g. `$projectId`, `$name` → "projectId", "name"). + */ +function declaredVariableNames(doc: DocumentNode): Set<string> { + const names = new Set<string>(); + for (const definition of doc.definitions) { + if (definition.kind !== Kind.OPERATION_DEFINITION) { + continue; + } + for (const variable of definition.variableDefinitions ?? []) { + names.add(variable.variable.name.value); + } + } + return names; +} + +/** + * Assert that every top-level key of the variables object passed to + * `client.request` corresponds to a variable actually declared by the + * document. This catches the input-shape-vs-declared-variable class of bug + * (see issues #223 / #228): passing variables whose keys do not match the + * mutation's declared variables (e.g. flat `$projectId`/`$name`/... against a + * document declaring `$input`, or vice versa) would surface an undeclared key + * here. + */ +export function assertVariablesMatchDocument( + doc: DocumentNode, + variables: Record<string, unknown>, +): void { + const declared = declaredVariableNames(doc); + const undeclared = Object.keys(variables).filter((key) => !declared.has(key)); + expect( + undeclared, + `Variables ${JSON.stringify(undeclared)} are not declared by the document (declared: ${JSON.stringify([...declared])})`, + ).toEqual([]); +} diff --git a/tests/unit/resolvers/cycle-resolver.test.ts b/tests/unit/resolvers/cycle-resolver.test.ts index 1efb9751..000a21a5 100644 --- a/tests/unit/resolvers/cycle-resolver.test.ts +++ b/tests/unit/resolvers/cycle-resolver.test.ts @@ -1,53 +1,97 @@ // tests/unit/resolvers/cycle-resolver.test.ts import { describe, expect, it, vi } from "vitest"; -import type { LinearSdkClient } from "../../../src/client/linear-client.js"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { resolveCycleId } from "../../../src/resolvers/cycle-resolver.js"; -function mockSdkClient( - cycleNodes: Array<{ - id: string; - name?: string; - isActive?: boolean; - isNext?: boolean; - isPrevious?: boolean; - number?: number; - startsAt?: string; - }>, -) { - const teams = vi.fn().mockResolvedValue({ nodes: [{ id: "team-uuid" }] }); - const cycles = vi.fn().mockResolvedValue({ nodes: cycleNodes }); - // Mock cycle.team as a resolved property - cycleNodes.forEach((node) => { - Object.defineProperty(node, "team", { - value: Promise.resolve({ - id: "team-uuid", - key: "ENG", - name: "Engineering", - }), - enumerable: false, - }); - }); - return { sdk: { teams, cycles } } as unknown as LinearSdkClient; +type CycleNode = { + id: string; + name?: string; + number?: number; + startsAt?: string; + isActive?: boolean; + isNext?: boolean; + isPrevious?: boolean; + team?: { id: string; key: string }; +}; + +function cycle(node: CycleNode): CycleNode { + return { + name: "Sprint", + number: 1, + isActive: false, + isNext: false, + isPrevious: false, + team: { id: "team-uuid", key: "ENG" }, + ...node, + }; +} + +// Unscoped lookups issue a single FindCycleGlobal request. +function mockGlobalClient(cycleNodes: CycleNode[]) { + return { + request: vi.fn().mockResolvedValue({ cycles: { nodes: cycleNodes } }), + } as unknown as GraphQLClient; +} + +// Team-scoped lookups first resolve the team (FindTeams), then FindCycleScoped. +function mockScopedClient(cycleNodes: CycleNode[]) { + const request = vi + .fn() + .mockResolvedValueOnce({ teams: { nodes: [{ id: "team-uuid" }] } }) + .mockResolvedValueOnce({ cycles: { nodes: cycleNodes } }); + return { request } as unknown as GraphQLClient; } describe("resolveCycleId", () => { it("returns UUID as-is", async () => { - const client = mockSdkClient([]); + const client = mockGlobalClient([]); const result = await resolveCycleId( client, "550e8400-e29b-41d4-a716-446655440000", ); expect(result).toBe("550e8400-e29b-41d4-a716-446655440000"); + expect(client.request).not.toHaveBeenCalled(); }); it("resolves single matching cycle by name", async () => { - const client = mockSdkClient([{ id: "cycle-uuid", name: "Sprint 1" }]); + const client = mockGlobalClient([cycle({ id: "cycle-uuid" })]); const result = await resolveCycleId(client, "Sprint 1"); expect(result).toBe("cycle-uuid"); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + name: "Sprint 1", + }); + }); + + it("prefers the active cycle when multiple match", async () => { + const client = mockGlobalClient([ + cycle({ id: "prev", isPrevious: true }), + cycle({ id: "active", isActive: true }), + cycle({ id: "next", isNext: true }), + ]); + const result = await resolveCycleId(client, "Sprint"); + expect(result).toBe("active"); + }); + + it("scopes to a team by resolving the team first", async () => { + const client = mockScopedClient([cycle({ id: "cycle-uuid" })]); + const result = await resolveCycleId(client, "Sprint 1", "ENG"); + expect(result).toBe("cycle-uuid"); + expect(client.request).toHaveBeenNthCalledWith(2, expect.anything(), { + name: "Sprint 1", + teamId: "team-uuid", + }); }); it("throws when cycle not found", async () => { - const client = mockSdkClient([]); + const client = mockGlobalClient([]); await expect(resolveCycleId(client, "Nonexistent")).rejects.toThrow(); }); + + it("throws when multiple cycles match without a clear preference", async () => { + const client = mockGlobalClient([ + cycle({ id: "a", team: { id: "t1", key: "ENG" } }), + cycle({ id: "b", team: { id: "t2", key: "OPS" } }), + ]); + await expect(resolveCycleId(client, "Sprint")).rejects.toThrow(/Multiple/i); + }); }); diff --git a/tests/unit/resolvers/initiative-resolver.test.ts b/tests/unit/resolvers/initiative-resolver.test.ts index fe28ed27..bd790d66 100644 --- a/tests/unit/resolvers/initiative-resolver.test.ts +++ b/tests/unit/resolvers/initiative-resolver.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; -import type { LinearSdkClient } from "../../../src/client/linear-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { resolveInitiativeId, resolveInitiativeProjectLinkId, @@ -12,12 +12,10 @@ type InitiativeLookupNode = { name: string; }; -function mockSdkClient(nodes: InitiativeLookupNode[]) { +function mockInitiativesClient(nodes: InitiativeLookupNode[]) { return { - sdk: { - initiatives: vi.fn().mockResolvedValue({ nodes }), - }, - } as unknown as LinearSdkClient; + request: vi.fn().mockResolvedValue({ initiatives: { nodes } }), + } as unknown as GraphQLClient; } function mockGqlClient(response: Record<string, unknown>) { @@ -40,59 +38,59 @@ function mockPagedGqlClient(responses: Array<Record<string, unknown>>) { describe("resolveInitiativeId", () => { it("returns UUID as-is", async () => { - const sdk = mockSdkClient([]); + const client = mockInitiativesClient([]); const result = await resolveInitiativeId( - sdk, + client, "550e8400-e29b-41d4-a716-446655440000", ); expect(result).toBe("550e8400-e29b-41d4-a716-446655440000"); - expect(sdk.sdk.initiatives).not.toHaveBeenCalled(); + expect(client.request).not.toHaveBeenCalled(); }); it("resolves initiative name", async () => { - const sdk = mockSdkClient([{ id: "init-1", name: "Growth" }]); + const client = mockInitiativesClient([{ id: "init-1", name: "Growth" }]); - await expect(resolveInitiativeId(sdk, "growth")).resolves.toBe("init-1"); - expect(sdk.sdk.initiatives).toHaveBeenCalledWith({ + await expect(resolveInitiativeId(client, "growth")).resolves.toBe("init-1"); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { filter: { name: { eqIgnoreCase: "growth" } }, first: 20, }); }); it("throws not found", async () => { - const sdk = mockSdkClient([]); + const client = mockInitiativesClient([]); - await expect(resolveInitiativeId(sdk, "Missing")).rejects.toThrow( + await expect(resolveInitiativeId(client, "Missing")).rejects.toThrow( 'Initiative "Missing" not found', ); }); it("throws ambiguity without scope", async () => { - const sdk = mockSdkClient([ + const client = mockInitiativesClient([ { id: "init-1", name: "Growth" }, { id: "init-2", name: "Growth" }, ]); - await expect(resolveInitiativeId(sdk, "Growth")).rejects.toThrow( + await expect(resolveInitiativeId(client, "Growth")).rejects.toThrow( "Multiple initiatives found matching", ); - await expect(resolveInitiativeId(sdk, "Growth")).rejects.toThrow( + await expect(resolveInitiativeId(client, "Growth")).rejects.toThrow( "provide --team or --owner, or use UUID", ); }); it("resolves scoped disambiguation", async () => { - const sdk = mockSdkClient([{ id: "init-2", name: "Growth" }]); + const client = mockInitiativesClient([{ id: "init-2", name: "Growth" }]); await expect( - resolveInitiativeId(sdk, "Growth", { - teamId: "team-1", - ownerId: "user-1", + resolveInitiativeId(client, "Growth", { + teamId: asUuid("team-1"), + ownerId: asUuid("user-1"), }), ).resolves.toBe("init-2"); - expect(sdk.sdk.initiatives).toHaveBeenCalledWith({ + expect(client.request).toHaveBeenCalledWith(expect.anything(), { filter: { and: [ { name: { eqIgnoreCase: "Growth" } }, @@ -128,7 +126,7 @@ describe("resolveInitiativeRelationId", () => { }); await expect( - resolveInitiativeRelationId(gql, "parent-id", "child-id"), + resolveInitiativeRelationId(gql, asUuid("parent-id"), asUuid("child-id")), ).resolves.toBe("rel-1"); }); @@ -143,7 +141,7 @@ describe("resolveInitiativeRelationId", () => { }); await expect( - resolveInitiativeRelationId(gql, "parent-id", "child-id"), + resolveInitiativeRelationId(gql, asUuid("parent-id"), asUuid("child-id")), ).rejects.toThrow( 'Initiative relation "between parent-id and child-id" not found', ); @@ -182,7 +180,7 @@ describe("resolveInitiativeRelationId", () => { ]); await expect( - resolveInitiativeRelationId(gql, "parent-id", "child-id"), + resolveInitiativeRelationId(gql, asUuid("parent-id"), asUuid("child-id")), ).resolves.toBe("rel-2"); expect(gql.request).toHaveBeenNthCalledWith(1, expect.anything(), { @@ -218,7 +216,7 @@ describe("resolveInitiativeRelationId", () => { ]); await expect( - resolveInitiativeRelationId(gql, "parent-id", "child-id"), + resolveInitiativeRelationId(gql, asUuid("parent-id"), asUuid("child-id")), ).rejects.toThrow( 'Initiative relation "between parent-id and child-id" not found', ); @@ -248,7 +246,11 @@ describe("resolveInitiativeProjectLinkId", () => { }); await expect( - resolveInitiativeProjectLinkId(gql, "init-id", "project-id"), + resolveInitiativeProjectLinkId( + gql, + asUuid("init-id"), + asUuid("project-id"), + ), ).resolves.toBe("link-1"); }); @@ -263,7 +265,11 @@ describe("resolveInitiativeProjectLinkId", () => { }); await expect( - resolveInitiativeProjectLinkId(gql, "init-id", "project-id"), + resolveInitiativeProjectLinkId( + gql, + asUuid("init-id"), + asUuid("project-id"), + ), ).rejects.toThrow( 'Initiative project link "between init-id and project-id" not found', ); @@ -302,7 +308,11 @@ describe("resolveInitiativeProjectLinkId", () => { ]); await expect( - resolveInitiativeProjectLinkId(gql, "init-id", "project-id"), + resolveInitiativeProjectLinkId( + gql, + asUuid("init-id"), + asUuid("project-id"), + ), ).resolves.toBe("link-2"); expect(gql.request).toHaveBeenNthCalledWith(1, expect.anything(), { @@ -338,7 +348,11 @@ describe("resolveInitiativeProjectLinkId", () => { ]); await expect( - resolveInitiativeProjectLinkId(gql, "init-id", "project-id"), + resolveInitiativeProjectLinkId( + gql, + asUuid("init-id"), + asUuid("project-id"), + ), ).rejects.toThrow( 'Initiative project link "between init-id and project-id" not found', ); diff --git a/tests/unit/resolvers/issue-filter-resolver.test.ts b/tests/unit/resolvers/issue-filter-resolver.test.ts index ef1b6a13..a64fad32 100644 --- a/tests/unit/resolvers/issue-filter-resolver.test.ts +++ b/tests/unit/resolvers/issue-filter-resolver.test.ts @@ -1,100 +1,192 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { LinearSdkClient } from "../../../src/client/linear-client.js"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { resolveSearchFilterIds } from "../../../src/resolvers/issue-filter-resolver.js"; -const { - resolveTeamIdMock, - resolveUserIdMock, - resolveProjectIdMock, - resolveStatusIdMock, - resolveLabelIdsMock, - resolveCycleIdMock, - resolveIssueIdMock, -} = vi.hoisted(() => ({ - resolveTeamIdMock: vi.fn(), - resolveUserIdMock: vi.fn(), - resolveProjectIdMock: vi.fn(), +const { resolveStatusIdMock, resolveCycleIdMock } = vi.hoisted(() => ({ resolveStatusIdMock: vi.fn(), - resolveLabelIdsMock: vi.fn(), resolveCycleIdMock: vi.fn(), - resolveIssueIdMock: vi.fn(), -})); - -vi.mock("../../../src/resolvers/team-resolver.js", () => ({ - resolveTeamId: resolveTeamIdMock, -})); - -vi.mock("../../../src/resolvers/user-resolver.js", () => ({ - resolveUserId: resolveUserIdMock, -})); - -vi.mock("../../../src/resolvers/project-resolver.js", () => ({ - resolveProjectId: resolveProjectIdMock, })); vi.mock("../../../src/resolvers/status-resolver.js", () => ({ resolveStatusId: resolveStatusIdMock, })); -vi.mock("../../../src/resolvers/label-resolver.js", () => ({ - resolveLabelIds: resolveLabelIdsMock, -})); - vi.mock("../../../src/resolvers/cycle-resolver.js", () => ({ resolveCycleId: resolveCycleIdMock, })); -vi.mock("../../../src/resolvers/issue-resolver.js", () => ({ - resolveIssueId: resolveIssueIdMock, -})); +type BatchNodes = { + teams?: Array<{ id: string; key: string; name: string }>; + assignees?: Array<{ + id: string; + name: string; + email: string; + displayName: string; + }>; + creators?: Array<{ + id: string; + name: string; + email: string; + displayName: string; + }>; + projects?: Array<{ + id: string; + name: string; + projectMilestones?: Array<{ id: string; name: string }>; + }>; + labels?: Array<{ id: string; name: string }>; + statuses?: Array<{ + id: string; + name: string; + team: { id: string; key: string }; + }>; + cycles?: Array<{ + id: string; + name: string | null; + isActive: boolean; + isNext: boolean; + isPrevious: boolean; + number: number; + startsAt: string; + team: { id: string; key: string }; + }>; + parentIssues?: Array<{ id: string; identifier: string }>; +}; + +function mockGql(nodes: BatchNodes) { + const request = vi.fn().mockResolvedValue({ + teams: { nodes: nodes.teams ?? [] }, + assignees: { nodes: nodes.assignees ?? [] }, + creators: { nodes: nodes.creators ?? [] }, + projects: { + nodes: (nodes.projects ?? []).map((p) => ({ + ...p, + projectMilestones: { nodes: p.projectMilestones ?? [] }, + })), + }, + labels: { nodes: nodes.labels ?? [] }, + statuses: { nodes: nodes.statuses ?? [] }, + cycles: { nodes: nodes.cycles ?? [] }, + parentIssues: { nodes: nodes.parentIssues ?? [] }, + }); + return { client: { request } as unknown as GraphQLClient, request }; +} describe("resolveSearchFilterIds", () => { beforeEach(() => { vi.resetAllMocks(); }); - it("passes resolved team UUID to status/cycle lookups", async () => { - const sdk = {} as unknown as LinearSdkClient; - - resolveTeamIdMock.mockResolvedValue("team-uuid"); - resolveStatusIdMock.mockResolvedValue("state-uuid"); - resolveCycleIdMock.mockResolvedValue("cycle-uuid"); + it("resolves all filters in a single batch request", async () => { + const { client, request } = mockGql({ + teams: [{ id: "team-uuid", key: "ENG", name: "Engineering" }], + assignees: [ + { + id: "user-uuid", + name: "John", + email: "john@example.com", + displayName: "John Doe", + }, + ], + projects: [{ id: "project-uuid", name: "Q1" }], + labels: [{ id: "label-uuid", name: "Bug" }], + statuses: [ + { + id: "state-uuid", + name: "Todo", + team: { id: "team-uuid", key: "ENG" }, + }, + ], + cycles: [ + { + id: "cycle-uuid", + name: "Sprint 1", + isActive: true, + isNext: false, + isPrevious: false, + number: 1, + startsAt: "2026-01-01T00:00:00.000Z", + team: { id: "team-uuid", key: "ENG" }, + }, + ], + parentIssues: [{ id: "parent-uuid", identifier: "ENG-1" }], + }); - const result = await resolveSearchFilterIds(sdk, { + const result = await resolveSearchFilterIds(client, { team: "ENG", + assignee: "John Doe", + project: "Q1", + // "bug" lower-case must still match "Bug" (case-insensitive). + labelNames: ["bug"], statusNames: ["Todo"], cycle: "Sprint 1", + parent: "ENG-1", }); - expect(resolveTeamIdMock).toHaveBeenCalledWith(sdk, "ENG"); - expect(resolveStatusIdMock).toHaveBeenCalledWith(sdk, "Todo", "team-uuid"); - expect(resolveCycleIdMock).toHaveBeenCalledWith( - sdk, - "Sprint 1", - "team-uuid", - ); + expect(request).toHaveBeenCalledTimes(1); expect(result).toEqual({ teamId: "team-uuid", + assigneeId: "user-uuid", + projectId: "project-uuid", + labelIds: ["label-uuid"], stateIds: ["state-uuid"], cycleId: "cycle-uuid", + parentId: "parent-uuid", }); + // Status matched from the batch response — no per-status fallback call. + expect(resolveStatusIdMock).not.toHaveBeenCalled(); }); - it("falls back to raw team input for cycle lookup when team not pre-resolved", async () => { - const sdk = {} as unknown as LinearSdkClient; + it("falls back to global status resolution when a name is not team-scoped", async () => { + const { client } = mockGql({}); // no statuses returned (e.g. no team) + resolveStatusIdMock.mockResolvedValue("global-state-uuid"); + + const result = await resolveSearchFilterIds(client, { + statusNames: ["In Progress"], + }); + + expect(resolveStatusIdMock).toHaveBeenCalledWith( + client, + "In Progress", + undefined, + ); + expect(result).toEqual({ stateIds: ["global-state-uuid"] }); + }); - resolveCycleIdMock.mockResolvedValue("cycle-uuid"); + it("falls back to global cycle resolution when no cycles are team-scoped", async () => { + const { client } = mockGql({}); // no cycles returned (e.g. no team) + resolveCycleIdMock.mockResolvedValue("global-cycle-uuid"); - const result = await resolveSearchFilterIds(sdk, { - cycle: "Sprint 2", - team: "Engineering", + const result = await resolveSearchFilterIds(client, { + cycle: "Sprint 1", }); expect(resolveCycleIdMock).toHaveBeenCalledWith( - sdk, - "Sprint 2", - "Engineering", + client, + "Sprint 1", + undefined, ); - expect(result).toEqual({ cycleId: "cycle-uuid" }); + expect(result).toEqual({ cycleId: "global-cycle-uuid" }); + }); + + it("throws when the team cannot be resolved", async () => { + const { client } = mockGql({ teams: [] }); + + await expect( + resolveSearchFilterIds(client, { team: "Nope" }), + ).rejects.toThrow('Team "Nope" not found'); + }); + + it("passes UUID inputs through without matching against the response", async () => { + const { client, request } = mockGql({}); + + const result = await resolveSearchFilterIds(client, { + assignee: "550e8400-e29b-41d4-a716-446655440000", + }); + + expect(request).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + assigneeId: "550e8400-e29b-41d4-a716-446655440000", + }); }); }); diff --git a/tests/unit/resolvers/issue-mutation-resolver.test.ts b/tests/unit/resolvers/issue-mutation-resolver.test.ts new file mode 100644 index 00000000..8598511b --- /dev/null +++ b/tests/unit/resolvers/issue-mutation-resolver.test.ts @@ -0,0 +1,414 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { + resolveCreateIssueIds, + resolveUpdateIssueIds, +} from "../../../src/resolvers/issue-mutation-resolver.js"; + +const UUID = "550e8400-e29b-41d4-a716-446655440000"; + +type Nodes = { + teams?: Array<{ + id: string; + key: string; + name: string; + issueEstimationType?: string; + issueEstimationExtended?: boolean; + issueEstimationAllowZero?: boolean; + }>; + assignees?: Array<{ + id: string; + name: string; + email: string; + displayName: string; + }>; + projects?: Array<{ + id: string; + name: string; + projectMilestones?: Array<{ id: string; name: string }>; + }>; + labels?: Array<{ id: string; name: string }>; + statuses?: Array<{ + id: string; + name: string; + team: { id: string; key: string }; + }>; + cycles?: Array<{ + id: string; + name: string | null; + isActive: boolean; + isNext: boolean; + isPrevious: boolean; + number: number; + startsAt: string; + team: { id: string; key: string }; + }>; + parentIssues?: Array<{ id: string; identifier: string }>; +}; + +function buildResponse(nodes: Nodes) { + return { + teams: { + nodes: (nodes.teams ?? []).map((t) => ({ + issueEstimationType: "fibonacci", + issueEstimationExtended: false, + issueEstimationAllowZero: false, + ...t, + })), + }, + assignees: { nodes: nodes.assignees ?? [] }, + projects: { + nodes: (nodes.projects ?? []).map((p) => ({ + id: p.id, + name: p.name, + projectMilestones: { nodes: p.projectMilestones ?? [] }, + })), + }, + labels: { nodes: nodes.labels ?? [] }, + statuses: { nodes: nodes.statuses ?? [] }, + cycles: { nodes: nodes.cycles ?? [] }, + parentIssues: { nodes: nodes.parentIssues ?? [] }, + }; +} + +function mockGql(nodes: Nodes) { + const request = vi.fn().mockResolvedValue(buildResponse(nodes)); + return { client: { request } as unknown as GraphQLClient, request }; +} + +describe("resolveCreateIssueIds", () => { + it("resolves every reference in a single batch request", async () => { + const { client, request } = mockGql({ + teams: [{ id: "team-uuid", key: "ENG", name: "Engineering" }], + assignees: [ + { + id: "user-uuid", + name: "John", + email: "john@example.com", + displayName: "John Doe", + }, + ], + projects: [ + { + id: "project-uuid", + name: "Q1", + projectMilestones: [{ id: "ms-uuid", name: "M1" }], + }, + ], + labels: [{ id: "label-uuid", name: "Bug" }], + statuses: [ + { + id: "state-uuid", + name: "Todo", + team: { id: "team-uuid", key: "ENG" }, + }, + ], + cycles: [ + { + id: "cycle-uuid", + name: "Sprint 1", + isActive: true, + isNext: false, + isPrevious: false, + number: 1, + startsAt: "2026-01-01T00:00:00.000Z", + team: { id: "team-uuid", key: "ENG" }, + }, + ], + parentIssues: [{ id: "parent-uuid", identifier: "ENG-1" }], + }); + + const result = await resolveCreateIssueIds(client, { + team: "ENG", + assignee: "John Doe", + project: "Q1", + labels: ["bug"], // lower-case must match "Bug" + projectMilestone: "M1", + cycle: "Sprint 1", + status: "Todo", + parentTicket: "ENG-1", + }); + + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + teamKey: "ENG", + teamName: "ENG", + assigneeQuery: "John Doe", + labelFilter: { or: [{ name: { eqIgnoreCase: "bug" } }] }, + statusName: "Todo", + cycleName: "Sprint 1", + milestoneName: "M1", + parentTeamKey: "ENG", + parentIssueNumber: 1, + }), + ); + expect(result).toEqual({ + teamId: "team-uuid", + assigneeId: "user-uuid", + projectId: "project-uuid", + labelIds: ["label-uuid"], + projectMilestoneId: "ms-uuid", + cycleId: "cycle-uuid", + stateId: "state-uuid", + parentId: "parent-uuid", + }); + }); + + it("passes UUID inputs through without name lookups", async () => { + const { client, request } = mockGql({}); + + const result = await resolveCreateIssueIds(client, { + team: UUID, + assignee: UUID, + project: UUID, + status: UUID, + parentTicket: UUID, + }); + + expect(request).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + teamKey: null, + teamName: null, + teamId: UUID, + assigneeQuery: null, + projectName: null, + projectId: UUID, + statusName: null, + parentTeamKey: null, + parentIssueNumber: null, + }), + ); + expect(result).toEqual({ + teamId: UUID, + assigneeId: UUID, + projectId: UUID, + stateId: UUID, + parentId: UUID, + }); + }); + + it("prefers a display-name match over an email match", async () => { + const { client } = mockGql({ + teams: [{ id: "team-uuid", key: "ENG", name: "Engineering" }], + assignees: [ + { + id: "by-name", + name: "John", + email: "someone@else.com", + displayName: "John Doe", + }, + { + id: "by-email", + name: "Other", + email: "john doe", + displayName: "Other Person", + }, + ], + }); + + const result = await resolveCreateIssueIds(client, { + team: "ENG", + assignee: "John Doe", + }); + + expect(result.assigneeId).toBe("by-name"); + }); + + it("throws when multiple users match by display name", async () => { + const { client } = mockGql({ + teams: [{ id: "team-uuid", key: "ENG", name: "Engineering" }], + assignees: [ + { + id: "u1", + name: "Alex A", + email: "a1@example.com", + displayName: "Alex", + }, + { + id: "u2", + name: "Alex B", + email: "a2@example.com", + displayName: "Alex", + }, + ], + }); + + await expect( + resolveCreateIssueIds(client, { team: "ENG", assignee: "Alex" }), + ).rejects.toThrow('Multiple Users found matching "Alex"'); + }); + + it("falls back to email when no display name matches", async () => { + const { client } = mockGql({ + teams: [{ id: "team-uuid", key: "ENG", name: "Engineering" }], + assignees: [ + { + id: "u2", + name: "Jane", + email: "jane@example.com", + displayName: "Jane Roe", + }, + ], + }); + + const result = await resolveCreateIssueIds(client, { + team: "ENG", + assignee: "jane@example.com", + }); + + expect(result.assigneeId).toBe("u2"); + }); + + it("throws when an assignee cannot be found", async () => { + const { client } = mockGql({ + teams: [{ id: "team-uuid", key: "ENG", name: "Engineering" }], + }); + + await expect( + resolveCreateIssueIds(client, { team: "ENG", assignee: "ghost" }), + ).rejects.toThrow('User "ghost" not found'); + }); + + it("throws when the team cannot be resolved", async () => { + const { client } = mockGql({ teams: [] }); + + await expect( + resolveCreateIssueIds(client, { team: "NOPE" }), + ).rejects.toThrow('Team "NOPE" not found'); + }); + + it("returns estimate context from the same request when requested", async () => { + const { client, request } = mockGql({ + teams: [ + { + id: "team-uuid", + key: "ENG", + name: "Engineering", + issueEstimationType: "fibonacci", + issueEstimationExtended: true, + issueEstimationAllowZero: true, + }, + ], + }); + + const result = await resolveCreateIssueIds(client, { + team: "ENG", + withEstimateContext: true, + }); + + expect(request).toHaveBeenCalledTimes(1); + expect(result.estimateContext).toEqual({ + teamId: "team-uuid", + teamKey: "ENG", + teamName: "Engineering", + issueEstimationType: "fibonacci", + issueEstimationExtended: true, + issueEstimationAllowZero: true, + }); + }); + + it("throws when a project is not found", async () => { + const { client } = mockGql({ + teams: [{ id: "team-uuid", key: "ENG", name: "Engineering" }], + projects: [], + }); + + await expect( + resolveCreateIssueIds(client, { team: "ENG", project: "Ghost" }), + ).rejects.toThrow('Project "Ghost" not found'); + }); + + it("scopes milestone resolution to the matched project", async () => { + const { client } = mockGql({ + teams: [{ id: "team-uuid", key: "ENG", name: "Engineering" }], + projects: [{ id: "project-uuid", name: "Q1", projectMilestones: [] }], + }); + + await expect( + resolveCreateIssueIds(client, { + team: "ENG", + project: "Q1", + projectMilestone: "Ghost", + }), + ).rejects.toThrow('Milestone "Ghost" not found'); + }); +}); + +describe("resolveUpdateIssueIds", () => { + it("resolves references in a single request scoped by issue context", async () => { + const { client, request } = mockGql({ + assignees: [ + { + id: "user-uuid", + name: "Jane", + email: "jane@example.com", + displayName: "Jane Roe", + }, + ], + statuses: [ + { + id: "state-uuid", + name: "Done", + team: { id: "team-uuid", key: "ENG" }, + }, + ], + }); + + const result = await resolveUpdateIssueIds( + client, + { assignee: "Jane Roe", status: "Done" }, + { teamId: "team-uuid" as never, teamKey: "ENG" }, + ); + + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + assigneeQuery: "Jane Roe", + statusName: "Done", + teamKey: "ENG", + teamId: "team-uuid", + }), + ); + expect(result).toEqual({ + assigneeId: "user-uuid", + stateId: "state-uuid", + }); + }); + + it("parses a parent identifier into filter variables", async () => { + const { client, request } = mockGql({ + parentIssues: [{ id: "parent-uuid", identifier: "ENG-7" }], + }); + + const result = await resolveUpdateIssueIds( + client, + { parentTicket: "ENG-7" }, + {}, + ); + + expect(request).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + parentTeamKey: "ENG", + parentIssueNumber: 7, + }), + ); + expect(result.parentId).toBe("parent-uuid"); + }); + + it("passes UUID inputs through", async () => { + const { client } = mockGql({}); + + const result = await resolveUpdateIssueIds( + client, + { assignee: UUID, project: UUID }, + {}, + ); + + expect(result).toEqual({ assigneeId: UUID, projectId: UUID }); + }); +}); diff --git a/tests/unit/resolvers/issue-resolver.test.ts b/tests/unit/resolvers/issue-resolver.test.ts index a7342fd1..eab3159b 100644 --- a/tests/unit/resolvers/issue-resolver.test.ts +++ b/tests/unit/resolvers/issue-resolver.test.ts @@ -1,6 +1,6 @@ // tests/unit/resolvers/issue-resolver.test.ts import { describe, expect, it, vi } from "vitest"; -import type { LinearSdkClient } from "../../../src/client/linear-client.js"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { resolveIssueEstimateContext, resolveIssueId, @@ -8,16 +8,7 @@ import { type IssueNode = { id: string; - teamId?: string; - team?: - | { - id?: string; - key?: string; - } - | Promise<{ - id?: string; - key?: string; - }>; + team: { id: string; key: string }; }; type TeamNode = { @@ -34,13 +25,14 @@ type TeamNode = { issueEstimationAllowZero: boolean; }; -function mockSdkClient(issueNodes: IssueNode[], teamNodes: TeamNode[] = []) { - return { - sdk: { - issues: vi.fn().mockResolvedValue({ nodes: issueNodes }), - teams: vi.fn().mockResolvedValue({ nodes: teamNodes }), - }, - } as unknown as LinearSdkClient; +// The estimate-context resolver issues two requests: FindIssues, then FindTeams +// (via resolveTeamEstimateContext). resolveIssueId only issues the first. +function mockGqlClient(issueNodes: IssueNode[], teamNodes: TeamNode[] = []) { + const request = vi + .fn() + .mockResolvedValueOnce({ issues: { nodes: issueNodes } }) + .mockResolvedValueOnce({ teams: { nodes: teamNodes } }); + return { request } as unknown as GraphQLClient; } const teamId = "550e8400-e29b-41d4-a716-446655440001"; @@ -54,24 +46,34 @@ const exponentialTeam: TeamNode = { issueEstimationAllowZero: false, }; +const engIssue: IssueNode = { + id: "issue-uuid", + team: { id: teamId, key: "ENG" }, +}; + describe("resolveIssueId", () => { it("returns UUID as-is", async () => { - const client = mockSdkClient([]); + const client = mockGqlClient([]); const result = await resolveIssueId( client, "550e8400-e29b-41d4-a716-446655440000", ); expect(result).toBe("550e8400-e29b-41d4-a716-446655440000"); + expect(client.request).not.toHaveBeenCalled(); }); it("resolves ABC-123 identifier", async () => { - const client = mockSdkClient([{ id: "issue-uuid" }]); + const client = mockGqlClient([engIssue]); const result = await resolveIssueId(client, "ENG-42"); expect(result).toBe("issue-uuid"); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + filter: { number: { eq: 42 }, team: { key: { eq: "ENG" } } }, + first: 1, + }); }); it("throws when issue not found", async () => { - const client = mockSdkClient([]); + const client = mockGqlClient([]); await expect(resolveIssueId(client, "ENG-999")).rejects.toThrow( 'Issue "ENG-999" not found', ); @@ -79,11 +81,8 @@ describe("resolveIssueId", () => { }); describe("resolveIssueEstimateContext", () => { - it("resolves identifier, extracts teamId, delegates to team estimate resolver, and returns issueId plus team context", async () => { - const client = mockSdkClient( - [{ id: "issue-uuid", teamId }], - [exponentialTeam], - ); + it("resolves identifier, derives team from the issue, and returns issueId plus team context", async () => { + const client = mockGqlClient([engIssue], [exponentialTeam]); await expect( resolveIssueEstimateContext(client, "ENG-42"), @@ -99,137 +98,35 @@ describe("resolveIssueEstimateContext", () => { }, }); - expect(client.sdk.issues).toHaveBeenCalledWith({ - filter: { - number: { eq: 42 }, - team: { key: { eq: "ENG" } }, - }, + expect(client.request).toHaveBeenNthCalledWith(1, expect.anything(), { + filter: { number: { eq: 42 }, team: { key: { eq: "ENG" } } }, first: 1, }); - expect(client.sdk.teams).toHaveBeenCalledWith({ + expect(client.request).toHaveBeenNthCalledWith(2, expect.anything(), { filter: { id: { eq: teamId } }, first: 1, }); }); - it("resolves by UUID and uses sdk issues filter id eq", async () => { - const client = mockSdkClient( - [{ id: "issue-uuid", teamId }], - [exponentialTeam], - ); + it("resolves by UUID using an id eq filter", async () => { + const client = mockGqlClient([engIssue], [exponentialTeam]); await resolveIssueEstimateContext( client, "550e8400-e29b-41d4-a716-446655440000", ); - expect(client.sdk.issues).toHaveBeenCalledWith({ + expect(client.request).toHaveBeenNthCalledWith(1, expect.anything(), { filter: { id: { eq: "550e8400-e29b-41d4-a716-446655440000" } }, first: 1, }); }); - it("resolves identifier and uses sdk issues filter number plus team key", async () => { - const client = mockSdkClient( - [{ id: "issue-uuid", teamId }], - [exponentialTeam], - ); - - await resolveIssueEstimateContext(client, "ENG-42"); - - expect(client.sdk.issues).toHaveBeenCalledWith({ - filter: { - number: { eq: 42 }, - team: { key: { eq: "ENG" } }, - }, - first: 1, - }); - }); - - it("succeeds when issue node has no nested team estimation fields", async () => { - const client = mockSdkClient( - [ - { - id: "issue-uuid", - team: { - id: teamId, - key: "ENG", - }, - }, - ], - [exponentialTeam], - ); - - await expect( - resolveIssueEstimateContext(client, "ENG-42"), - ).resolves.toMatchObject({ - issueId: "issue-uuid", - team: { - teamId, - teamKey: "ENG", - }, - }); - }); - - it("falls back to async team relation id when teamId is absent", async () => { - const client = mockSdkClient( - [ - { - id: "issue-uuid", - team: Promise.resolve({ id: teamId, key: "ENG" }), - }, - ], - [exponentialTeam], - ); - - await expect( - resolveIssueEstimateContext(client, "ENG-42"), - ).resolves.toMatchObject({ - issueId: "issue-uuid", - team: { - teamId, - teamKey: "ENG", - }, - }); - - expect(client.sdk.teams).toHaveBeenCalledWith({ - filter: { id: { eq: teamId } }, - first: 1, - }); - }); - - it("falls back to async team relation key when relation id is absent", async () => { - const client = mockSdkClient( - [ - { - id: "issue-uuid", - team: Promise.resolve({ key: "ENG" }), - }, - ], - [exponentialTeam], - ); - - await resolveIssueEstimateContext(client, "ENG-42"); - - expect(client.sdk.teams).toHaveBeenCalledWith({ - filter: { key: { eq: "ENG" } }, - first: 1, - }); - }); - it("throws Issue not found", async () => { - const client = mockSdkClient([]); + const client = mockGqlClient([]); await expect( resolveIssueEstimateContext(client, "ENG-999"), ).rejects.toThrow('Issue "ENG-999" not found'); }); - - it("throws when issue team context is missing", async () => { - const client = mockSdkClient([{ id: "issue-uuid" }]); - - await expect(resolveIssueEstimateContext(client, "ENG-42")).rejects.toThrow( - 'Issue "ENG-42" is missing required team context', - ); - }); }); diff --git a/tests/unit/resolvers/label-resolver.test.ts b/tests/unit/resolvers/label-resolver.test.ts index a199004e..50720bfa 100644 --- a/tests/unit/resolvers/label-resolver.test.ts +++ b/tests/unit/resolvers/label-resolver.test.ts @@ -1,22 +1,20 @@ // tests/unit/resolvers/label-resolver.test.ts import { describe, expect, it, vi } from "vitest"; -import type { LinearSdkClient } from "../../../src/client/linear-client.js"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { resolveLabelId, resolveLabelIds, } from "../../../src/resolvers/label-resolver.js"; -function mockSdkClient(nodes: Array<{ id: string; name?: string }>) { +function mockGqlClient(nodes: Array<{ id: string; name?: string }>) { return { - sdk: { - issueLabels: vi.fn().mockResolvedValue({ nodes }), - }, - } as unknown as LinearSdkClient; + request: vi.fn().mockResolvedValue({ issueLabels: { nodes } }), + } as unknown as GraphQLClient; } describe("resolveLabelId", () => { it("returns UUID as-is", async () => { - const client = mockSdkClient([]); + const client = mockGqlClient([]); const result = await resolveLabelId( client, "550e8400-e29b-41d4-a716-446655440000", @@ -25,13 +23,62 @@ describe("resolveLabelId", () => { }); it("resolves label by name", async () => { - const client = mockSdkClient([{ id: "label-uuid" }]); + const client = mockGqlClient([{ id: "label-uuid" }]); const result = await resolveLabelId(client, "Bug"); expect(result).toBe("label-uuid"); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + filter: { name: { eqIgnoreCase: "Bug" } }, + first: 1, + }); + }); + + it("resolves workspace label by name", async () => { + const client = mockGqlClient([{ id: "label-uuid" }]); + + await resolveLabelId(client, "Bug", { scope: "workspace" }); + + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + filter: { + name: { eqIgnoreCase: "Bug" }, + team: { null: true }, + }, + first: 1, + }); + }); + + it("resolves team-scoped label by name", async () => { + const client = mockGqlClient([{ id: "label-uuid" }]); + + await resolveLabelId(client, "Bug", { + teamId: "team-uuid", + scope: "team", + }); + + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + filter: { + name: { eqIgnoreCase: "Bug" }, + team: { id: { eq: "team-uuid" }, null: false }, + }, + first: 1, + }); + }); + + it("filters by team when teamId is provided without explicit scope", async () => { + const client = mockGqlClient([{ id: "label-uuid" }]); + + await resolveLabelId(client, "Bug", { teamId: "team-uuid" }); + + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + filter: { + name: { eqIgnoreCase: "Bug" }, + team: { id: { eq: "team-uuid" } }, + }, + first: 1, + }); }); it("throws when label not found", async () => { - const client = mockSdkClient([]); + const client = mockGqlClient([]); await expect(resolveLabelId(client, "Nonexistent")).rejects.toThrow( 'Label "Nonexistent" not found', ); @@ -40,7 +87,7 @@ describe("resolveLabelId", () => { describe("resolveLabelIds", () => { it("resolves mixed UUIDs and names", async () => { - const client = mockSdkClient([{ id: "label-uuid" }]); + const client = mockGqlClient([{ id: "label-uuid" }]); const result = await resolveLabelIds(client, [ "550e8400-e29b-41d4-a716-446655440000", "Bug", diff --git a/tests/unit/resolvers/milestone-resolver.test.ts b/tests/unit/resolvers/milestone-resolver.test.ts index b1485c14..12a1d394 100644 --- a/tests/unit/resolvers/milestone-resolver.test.ts +++ b/tests/unit/resolvers/milestone-resolver.test.ts @@ -1,7 +1,6 @@ // tests/unit/resolvers/milestone-resolver.test.ts import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; -import type { LinearSdkClient } from "../../../src/client/linear-client.js"; import { resolveMilestoneId } from "../../../src/resolvers/milestone-resolver.js"; function mockGqlClient(...responses: Array<Record<string, unknown>>) { @@ -12,34 +11,38 @@ function mockGqlClient(...responses: Array<Record<string, unknown>>) { return { request } as unknown as GraphQLClient; } -function mockSdkClient() { - return { - sdk: { - projects: vi.fn().mockResolvedValue({ nodes: [{ id: "proj-uuid" }] }), - }, - } as unknown as LinearSdkClient; -} - describe("resolveMilestoneId", () => { it("returns UUID as-is", async () => { const gql = mockGqlClient(); - const sdk = mockSdkClient(); const result = await resolveMilestoneId( gql, - sdk, "550e8400-e29b-41d4-a716-446655440000", ); expect(result).toBe("550e8400-e29b-41d4-a716-446655440000"); + expect(gql.request).not.toHaveBeenCalled(); + }); + + it("resolves a project-scoped milestone by name", async () => { + const gql = mockGqlClient( + { projects: { nodes: [{ id: "proj-uuid" }] } }, + { + project: { + projectMilestones: { nodes: [{ id: "ms-uuid", name: "M1" }] }, + }, + }, + ); + const result = await resolveMilestoneId(gql, "M1", "My Project"); + expect(result).toBe("ms-uuid"); }); it("throws when milestone not found", async () => { const gql = mockGqlClient( + { projects: { nodes: [{ id: "proj-uuid" }] } }, { project: { projectMilestones: { nodes: [] } } }, { projectMilestones: { nodes: [] } }, ); - const sdk = mockSdkClient(); await expect( - resolveMilestoneId(gql, sdk, "Nonexistent", "My Project"), + resolveMilestoneId(gql, "Nonexistent", "My Project"), ).rejects.toThrow('Milestone "Nonexistent" not found'); }); }); diff --git a/tests/unit/resolvers/project-resolver.test.ts b/tests/unit/resolvers/project-resolver.test.ts index 0c0785cc..185a7d61 100644 --- a/tests/unit/resolvers/project-resolver.test.ts +++ b/tests/unit/resolvers/project-resolver.test.ts @@ -1,69 +1,64 @@ // tests/unit/resolvers/project-resolver.test.ts import { describe, expect, it, vi } from "vitest"; -import type { LinearSdkClient } from "../../../src/client/linear-client.js"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { resolveProjectId, resolveProjectLabelId, resolveProjectLabelIds, } from "../../../src/resolvers/project-resolver.js"; -function mockSdkClient(nodes: Array<{ id: string }>) { +function mockGqlClient(nodes: Array<{ id: string }>) { return { - sdk: { - projects: vi.fn().mockResolvedValue({ nodes }), - }, - } as unknown as LinearSdkClient; + request: vi.fn().mockResolvedValue({ projects: { nodes } }), + } as unknown as GraphQLClient; } -function mockSdkClientWithLabels(nodes: Array<{ id: string }>) { +function mockGqlClientWithLabels(nodes: Array<{ id: string }>) { return { - sdk: { - projectLabels: vi.fn().mockResolvedValue({ nodes }), - }, - } as unknown as LinearSdkClient; + request: vi.fn().mockResolvedValue({ projectLabels: { nodes } }), + } as unknown as GraphQLClient; } describe("resolveProjectId", () => { it("returns UUID as-is", async () => { - const client = mockSdkClient([]); + const client = mockGqlClient([]); const result = await resolveProjectId( client, "550e8400-e29b-41d4-a716-446655440000", ); expect(result).toBe("550e8400-e29b-41d4-a716-446655440000"); - expect(client.sdk.projects).not.toHaveBeenCalled(); + expect(client.request).not.toHaveBeenCalled(); }); it("resolves project by name", async () => { - const client = mockSdkClient([{ id: "proj-uuid" }]); + const client = mockGqlClient([{ id: "proj-uuid" }]); const result = await resolveProjectId(client, "Mobile App"); expect(result).toBe("proj-uuid"); }); it("includes archived projects when requested", async () => { - const client = mockSdkClient([{ id: "archived-proj-uuid" }]); + const client = mockGqlClient([{ id: "archived-proj-uuid" }]); const result = await resolveProjectId(client, "Archived Project", { includeArchived: true, }); expect(result).toBe("archived-proj-uuid"); - expect(client.sdk.projects).toHaveBeenCalledWith({ - filter: { name: { eqIgnoreCase: "Archived Project" } }, - first: 2, + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + name: "Archived Project", includeArchived: true, }); }); it("throws when project not found", async () => { - const client = mockSdkClient([]); + const client = mockGqlClient([]); await expect(resolveProjectId(client, "Nonexistent")).rejects.toThrow( 'Project "Nonexistent" not found', ); }); it("throws when multiple projects match same name", async () => { - const client = mockSdkClient([ + const client = mockGqlClient([ { id: "proj-uuid-1" }, { id: "proj-uuid-2" }, ]); @@ -78,23 +73,23 @@ describe("resolveProjectId", () => { describe("resolveProjectLabelId", () => { it("returns UUID as-is", async () => { - const client = mockSdkClientWithLabels([]); + const client = mockGqlClientWithLabels([]); const result = await resolveProjectLabelId( client, "550e8400-e29b-41d4-a716-446655440000", ); expect(result).toBe("550e8400-e29b-41d4-a716-446655440000"); - expect(client.sdk.projectLabels).not.toHaveBeenCalled(); + expect(client.request).not.toHaveBeenCalled(); }); it("resolves label by name", async () => { - const client = mockSdkClientWithLabels([{ id: "label-uuid" }]); + const client = mockGqlClientWithLabels([{ id: "label-uuid" }]); const result = await resolveProjectLabelId(client, "Q1-2025"); expect(result).toBe("label-uuid"); }); it("throws when label not found", async () => { - const client = mockSdkClientWithLabels([]); + const client = mockGqlClientWithLabels([]); await expect(resolveProjectLabelId(client, "Nonexistent")).rejects.toThrow( 'Project label "Nonexistent" not found', ); @@ -103,7 +98,7 @@ describe("resolveProjectLabelId", () => { describe("resolveProjectLabelIds", () => { it("resolves mixed UUIDs and names", async () => { - const client = mockSdkClientWithLabels([{ id: "label-uuid" }]); + const client = mockGqlClientWithLabels([{ id: "label-uuid" }]); const result = await resolveProjectLabelIds(client, [ "550e8400-e29b-41d4-a716-446655440000", "Q1-2025", diff --git a/tests/unit/resolvers/status-resolver.test.ts b/tests/unit/resolvers/status-resolver.test.ts index 0d3f193f..18b96038 100644 --- a/tests/unit/resolvers/status-resolver.test.ts +++ b/tests/unit/resolvers/status-resolver.test.ts @@ -1,19 +1,19 @@ // tests/unit/resolvers/status-resolver.test.ts + import { describe, expect, it, vi } from "vitest"; -import type { LinearSdkClient } from "../../../src/client/linear-client.js"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { resolveStatusId } from "../../../src/resolvers/status-resolver.js"; -function mockSdkClient(nodes: Array<{ id: string }>) { +function mockGqlClient(nodes: Array<{ id: string }>) { return { - sdk: { - workflowStates: vi.fn().mockResolvedValue({ nodes }), - }, - } as unknown as LinearSdkClient; + request: vi.fn().mockResolvedValue({ workflowStates: { nodes } }), + } as unknown as GraphQLClient; } describe("resolveStatusId", () => { it("returns UUID as-is", async () => { - const client = mockSdkClient([]); + const client = mockGqlClient([]); const result = await resolveStatusId( client, "550e8400-e29b-41d4-a716-446655440000", @@ -22,15 +22,15 @@ describe("resolveStatusId", () => { }); it("resolves status by name", async () => { - const client = mockSdkClient([{ id: "status-uuid" }]); + const client = mockGqlClient([{ id: "status-uuid" }]); const result = await resolveStatusId(client, "In Progress"); expect(result).toBe("status-uuid"); }); it("resolves status by name with team context", async () => { - const client = mockSdkClient([{ id: "status-uuid" }]); - await resolveStatusId(client, "In Progress", "team-uuid"); - expect(client.sdk.workflowStates).toHaveBeenCalledWith({ + const client = mockGqlClient([{ id: "status-uuid" }]); + await resolveStatusId(client, "In Progress", asUuid("team-uuid")); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { filter: { name: { eqIgnoreCase: "In Progress" }, team: { id: { eq: "team-uuid" } }, @@ -40,7 +40,7 @@ describe("resolveStatusId", () => { }); it("throws when status not found", async () => { - const client = mockSdkClient([]); + const client = mockGqlClient([]); await expect(resolveStatusId(client, "Nonexistent")).rejects.toThrow( 'Status "Nonexistent" not found', ); diff --git a/tests/unit/resolvers/team-resolver.test.ts b/tests/unit/resolvers/team-resolver.test.ts index f5ef4d6b..714d37a0 100644 --- a/tests/unit/resolvers/team-resolver.test.ts +++ b/tests/unit/resolvers/team-resolver.test.ts @@ -1,63 +1,69 @@ // tests/unit/resolvers/team-resolver.test.ts import { describe, expect, it, vi } from "vitest"; -import type { LinearSdkClient } from "../../../src/client/linear-client.js"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { resolveTeamEstimateContext, resolveTeamId, } from "../../../src/resolvers/team-resolver.js"; -function mockSdkClient( - ...callResults: Array<{ - nodes: Array<{ - id: string; - key?: string; - name?: string; - issueEstimationType?: - | "notUsed" - | "exponential" - | "fibonacci" - | "linear" - | "tShirt"; - issueEstimationExtended?: boolean; - issueEstimationAllowZero?: boolean; - }>; - }> -) { - const teams = vi.fn(); +type TeamLookupNode = { + id: string; + key?: string; + name?: string; + issueEstimationType?: + | "notUsed" + | "exponential" + | "fibonacci" + | "linear" + | "tShirt"; + issueEstimationExtended?: boolean; + issueEstimationAllowZero?: boolean; +}; + +function mockGqlClient(...callResults: Array<{ nodes: TeamLookupNode[] }>) { + const request = vi.fn(); for (const result of callResults) { - teams.mockResolvedValueOnce(result); + request.mockResolvedValueOnce({ teams: result }); } - return { sdk: { teams } } as unknown as LinearSdkClient; + return { request } as unknown as GraphQLClient; } describe("resolveTeamId", () => { - it("returns UUID as-is without calling SDK", async () => { - const client = mockSdkClient(); + it("returns UUID as-is without querying", async () => { + const client = mockGqlClient(); const result = await resolveTeamId( client, "550e8400-e29b-41d4-a716-446655440000", ); expect(result).toBe("550e8400-e29b-41d4-a716-446655440000"); - expect(client.sdk.teams).not.toHaveBeenCalled(); + expect(client.request).not.toHaveBeenCalled(); }); it("resolves team by key", async () => { - const client = mockSdkClient({ nodes: [{ id: "uuid-1", key: "ENG" }] }); + const client = mockGqlClient({ nodes: [{ id: "uuid-1", key: "ENG" }] }); const result = await resolveTeamId(client, "ENG"); expect(result).toBe("uuid-1"); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + filter: { key: { eq: "ENG" } }, + first: 1, + }); }); it("falls back to name when key not found", async () => { - const client = mockSdkClient( + const client = mockGqlClient( { nodes: [] }, { nodes: [{ id: "uuid-2", name: "Engineering" }] }, ); const result = await resolveTeamId(client, "Engineering"); expect(result).toBe("uuid-2"); + expect(client.request).toHaveBeenNthCalledWith(2, expect.anything(), { + filter: { name: { eq: "Engineering" } }, + first: 1, + }); }); it("throws when team not found by key or name", async () => { - const client = mockSdkClient({ nodes: [] }, { nodes: [] }); + const client = mockGqlClient({ nodes: [] }, { nodes: [] }); await expect(resolveTeamId(client, "NOPE")).rejects.toThrow( 'Team "NOPE" not found', ); @@ -66,7 +72,7 @@ describe("resolveTeamId", () => { describe("resolveTeamEstimateContext", () => { it("resolves by key with full context fields", async () => { - const client = mockSdkClient({ + const client = mockGqlClient({ nodes: [ { id: "uuid-1", @@ -89,8 +95,8 @@ describe("resolveTeamEstimateContext", () => { }); }); - it("resolves by UUID and queries sdk with id eq filter", async () => { - const client = mockSdkClient({ + it("resolves by UUID and queries with id eq filter", async () => { + const client = mockGqlClient({ nodes: [ { id: "team-uuid", @@ -109,14 +115,14 @@ describe("resolveTeamEstimateContext", () => { ); expect(result.teamId).toBe("team-uuid"); - expect(client.sdk.teams).toHaveBeenCalledWith({ + expect(client.request).toHaveBeenCalledWith(expect.anything(), { filter: { id: { eq: "550e8400-e29b-41d4-a716-446655440000" } }, first: 1, }); }); it("falls back to name when key lookup misses", async () => { - const client = mockSdkClient( + const client = mockGqlClient( { nodes: [] }, { nodes: [ @@ -143,11 +149,11 @@ describe("resolveTeamEstimateContext", () => { issueEstimationAllowZero: true, }); - expect(client.sdk.teams).toHaveBeenNthCalledWith(1, { + expect(client.request).toHaveBeenNthCalledWith(1, expect.anything(), { filter: { key: { eq: "Engineering" } }, first: 1, }); - expect(client.sdk.teams).toHaveBeenNthCalledWith(2, { + expect(client.request).toHaveBeenNthCalledWith(2, expect.anything(), { filter: { name: { eq: "Engineering" } }, first: 1, }); @@ -155,21 +161,21 @@ describe("resolveTeamEstimateContext", () => { it("throws not found for UUID when id lookup has no nodes and does not fallback", async () => { const teamId = "550e8400-e29b-41d4-a716-446655440000"; - const client = mockSdkClient({ nodes: [] }); + const client = mockGqlClient({ nodes: [] }); await expect(resolveTeamEstimateContext(client, teamId)).rejects.toThrow( `Team "${teamId}" not found`, ); - expect(client.sdk.teams).toHaveBeenCalledTimes(1); - expect(client.sdk.teams).toHaveBeenCalledWith({ + expect(client.request).toHaveBeenCalledTimes(1); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { filter: { id: { eq: teamId } }, first: 1, }); }); it("throws not found when no nodes", async () => { - const client = mockSdkClient({ nodes: [] }, { nodes: [] }); + const client = mockGqlClient({ nodes: [] }, { nodes: [] }); await expect(resolveTeamEstimateContext(client, "NOPE")).rejects.toThrow( 'Team "NOPE" not found', ); diff --git a/tests/unit/resolvers/user-resolver.test.ts b/tests/unit/resolvers/user-resolver.test.ts index e66ab16a..d794ec7c 100644 --- a/tests/unit/resolvers/user-resolver.test.ts +++ b/tests/unit/resolvers/user-resolver.test.ts @@ -1,6 +1,6 @@ // tests/unit/resolvers/user-resolver.test.ts import { describe, expect, it, vi } from "vitest"; -import type { LinearSdkClient } from "../../../src/client/linear-client.js"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { resolveUserId } from "../../../src/resolvers/user-resolver.js"; interface MockUser { @@ -9,41 +9,41 @@ interface MockUser { email?: string; } -function mockSdkClient(...callResults: Array<{ nodes: MockUser[] }>) { - const users = vi.fn(); +function mockGqlClient(...callResults: Array<{ nodes: MockUser[] }>) { + const request = vi.fn(); for (const result of callResults) { - users.mockResolvedValueOnce(result); + request.mockResolvedValueOnce({ users: result }); } - return { sdk: { users } } as unknown as LinearSdkClient; + return { request } as unknown as GraphQLClient; } describe("resolveUserId", () => { - it("returns UUID as-is without calling SDK", async () => { - const client = mockSdkClient(); + it("returns UUID as-is without querying", async () => { + const client = mockGqlClient(); const result = await resolveUserId( client, "550e8400-e29b-41d4-a716-446655440000", ); expect(result).toBe("550e8400-e29b-41d4-a716-446655440000"); - expect(client.sdk.users).not.toHaveBeenCalled(); + expect(client.request).not.toHaveBeenCalled(); }); it("resolves user by display name", async () => { - const client = mockSdkClient({ + const client = mockGqlClient({ nodes: [ { id: "user-uuid-1", name: "John Doe", email: "john@example.com" }, ], }); const result = await resolveUserId(client, "John Doe"); expect(result).toBe("user-uuid-1"); - expect(client.sdk.users).toHaveBeenCalledWith({ + expect(client.request).toHaveBeenCalledWith(expect.anything(), { filter: { displayName: { eqIgnoreCase: "John Doe" } }, first: 10, }); }); it("falls back to email when name not found", async () => { - const client = mockSdkClient( + const client = mockGqlClient( { nodes: [] }, { nodes: [{ id: "user-uuid-2", name: "Jane", email: "jane@example.com" }], @@ -51,22 +51,22 @@ describe("resolveUserId", () => { ); const result = await resolveUserId(client, "jane@example.com"); expect(result).toBe("user-uuid-2"); - expect(client.sdk.users).toHaveBeenCalledTimes(2); - expect(client.sdk.users).toHaveBeenNthCalledWith(2, { + expect(client.request).toHaveBeenCalledTimes(2); + expect(client.request).toHaveBeenNthCalledWith(2, expect.anything(), { filter: { email: { eqIgnoreCase: "jane@example.com" } }, first: 1, }); }); it("throws when user not found by name or email", async () => { - const client = mockSdkClient({ nodes: [] }, { nodes: [] }); + const client = mockGqlClient({ nodes: [] }, { nodes: [] }); await expect(resolveUserId(client, "Nobody")).rejects.toThrow( 'User "Nobody" not found', ); }); it("throws when multiple users match by name", async () => { - const client = mockSdkClient({ + const client = mockGqlClient({ nodes: [ { id: "user-1", name: "Alex Smith", email: "alex1@example.com" }, { id: "user-2", name: "Alex Smith", email: "alex2@example.com" }, diff --git a/tests/unit/services/activity-service.test.ts b/tests/unit/services/activity-service.test.ts new file mode 100644 index 00000000..ad3dddee --- /dev/null +++ b/tests/unit/services/activity-service.test.ts @@ -0,0 +1,366 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; +import { + GetIssueActivityRefDocument, + GetLabelsDocument, + ListIssueActivityHistoryDocument, + ListIssueDiscussionReplyCandidatesDocument, + ListIssueDiscussionReplyCandidatesWithReactionsDocument, + ListIssueDiscussionRootsDocument, + ListIssueDiscussionRootsWithReactionsDocument, +} from "../../../src/gql/graphql.js"; +import { getIssueActivity } from "../../../src/services/activity-service.js"; +import type { DiscussionThreadWithReactions } from "../../../src/services/discussion-service.js"; + +const ISSUE_ID = asUuid("11111111-1111-1111-1111-111111111111"); +const USER = { id: "user-1", displayName: "Ada" }; + +function createClientMock(): GraphQLClient { + return { request: vi.fn() } as unknown as GraphQLClient; +} + +function comment( + id: string, + createdAt: string, + parentId: string | null = null, +) { + return { + id, + body: `comment-${id}`, + createdAt, + editedAt: null, + parentId, + resolvedAt: null, + resolvingComment: null, + resolvingUser: null, + user: USER, + }; +} + +function commentWithReactions( + id: string, + createdAt: string, + parentId: string | null = null, +) { + return { + ...comment(id, createdAt, parentId), + reactions: [{ id: "r-1", emoji: "👍", user: USER, externalUser: null }], + }; +} + +function historyNode( + id: string, + createdAt: string, + overrides: Record<string, unknown> = {}, +) { + return { + id, + createdAt, + fromPriority: null, + toPriority: null, + fromTitle: null, + toTitle: null, + fromEstimate: null, + toEstimate: null, + addedLabelIds: null, + removedLabelIds: null, + archived: null, + actor: USER, + botActor: null, + fromState: null, + toState: null, + fromAssignee: null, + toAssignee: null, + fromProject: null, + toProject: null, + fromCycle: null, + toCycle: null, + ...overrides, + }; +} + +const EMPTY_PAGE = { hasNextPage: false, endCursor: null }; + +interface MockData { + ref?: { id: string; identifier: string } | null; + roots?: unknown[]; + rootsWithReactions?: unknown[]; + replyCandidates?: unknown[]; + replyCandidatesWithReactions?: unknown[]; + history?: unknown[]; + labels?: { id: string; name: string }[]; +} + +function mockClient(client: GraphQLClient, data: MockData): void { + vi.mocked(client.request).mockImplementation( + async (document: unknown): Promise<unknown> => { + if (document === GetIssueActivityRefDocument) { + return { + issue: + data.ref === undefined + ? { id: ISSUE_ID, identifier: "ENG-1" } + : data.ref, + }; + } + if (document === ListIssueDiscussionRootsDocument) { + return { + issue: { + comments: { nodes: data.roots ?? [], pageInfo: EMPTY_PAGE }, + }, + }; + } + if (document === ListIssueDiscussionRootsWithReactionsDocument) { + return { + issue: { + comments: { + nodes: data.rootsWithReactions ?? [], + pageInfo: EMPTY_PAGE, + }, + }, + }; + } + if (document === ListIssueDiscussionReplyCandidatesDocument) { + return { + comments: { nodes: data.replyCandidates ?? [], pageInfo: EMPTY_PAGE }, + }; + } + if ( + document === ListIssueDiscussionReplyCandidatesWithReactionsDocument + ) { + return { + comments: { + nodes: data.replyCandidatesWithReactions ?? [], + pageInfo: EMPTY_PAGE, + }, + }; + } + if (document === ListIssueActivityHistoryDocument) { + return { + issue: { + history: { nodes: data.history ?? [], pageInfo: EMPTY_PAGE }, + }, + }; + } + if (document === GetLabelsDocument) { + return { + issueLabels: { nodes: data.labels ?? [], pageInfo: EMPTY_PAGE }, + }; + } + throw new Error("Unexpected document in mock"); + }, + ); +} + +describe("getIssueActivity", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("merges comment threads and history into one chronological timeline", async () => { + const client = createClientMock(); + mockClient(client, { + roots: [ + comment("root-1", "2026-04-21T10:00:00.000Z"), + comment("root-2", "2026-04-21T12:00:00.000Z"), + ], + replyCandidates: [ + comment("reply-1", "2026-04-21T11:00:00.000Z", "root-1"), + ], + history: [ + historyNode("hist-1", "2026-04-21T09:00:00.000Z", { + fromState: { id: "s1", name: "Todo" }, + toState: { id: "s2", name: "In Progress" }, + }), + historyNode("hist-2", "2026-04-21T13:00:00.000Z", { + toAssignee: { id: "user-2", displayName: "Alan" }, + }), + ], + }); + + const result = await getIssueActivity(client, ISSUE_ID); + + expect(result.issue).toEqual({ id: ISSUE_ID, identifier: "ENG-1" }); + expect(result.activity.map((item) => item.type)).toEqual([ + "history", + "commentThread", + "commentThread", + "history", + ]); + + const [firstHistory, firstThread] = result.activity; + expect(firstHistory).toMatchObject({ + type: "history", + id: "hist-1", + changes: [ + { + field: "state", + from: { id: "s1", name: "Todo" }, + to: { id: "s2", name: "In Progress" }, + }, + ], + }); + expect(firstThread).toMatchObject({ type: "commentThread" }); + if (firstThread?.type === "commentThread") { + expect(firstThread.root.id).toBe("root-1"); + expect(firstThread.replies.map((reply) => reply.id)).toEqual(["reply-1"]); + } + }); + + it("drops history events whose changes are all outside the captured fields", async () => { + const client = createClientMock(); + mockClient(client, { + history: [ + // No from/to captured field differs -> empty changes, should be dropped. + historyNode("hist-empty", "2026-04-21T09:00:00.000Z"), + historyNode("hist-state", "2026-04-21T10:00:00.000Z", { + fromState: { id: "s1", name: "Todo" }, + toState: { id: "s2", name: "Done" }, + }), + ], + }); + + const result = await getIssueActivity(client, ISSUE_ID); + + expect( + result.activity.map((item) => + item.type === "history" ? item.id : item.root.id, + ), + ).toEqual(["hist-state"]); + }); + + it("resolves label ids on history events to names", async () => { + const client = createClientMock(); + mockClient(client, { + history: [ + historyNode("hist-labels", "2026-04-21T10:00:00.000Z", { + addedLabelIds: ["label-1"], + removedLabelIds: ["label-2"], + }), + ], + labels: [{ id: "label-1", name: "bug" }], + }); + + const result = await getIssueActivity(client, ISSUE_ID); + + const [item] = result.activity; + expect(item).toMatchObject({ + type: "history", + changes: [ + { + field: "labels", + added: [{ id: "label-1", name: "bug" }], + // Unknown/deleted label resolves to a null name. + removed: [{ id: "label-2", name: null }], + }, + ], + }); + expect(client.request).toHaveBeenCalledWith( + GetLabelsDocument, + expect.objectContaining({ + filter: { id: { in: ["label-1", "label-2"] } }, + }), + ); + }); + + it("excludes history events when commentsOnly is set", async () => { + const client = createClientMock(); + mockClient(client, { + roots: [comment("root-1", "2026-04-21T10:00:00.000Z")], + }); + + const result = await getIssueActivity(client, ISSUE_ID, { + commentsOnly: true, + }); + + expect(result.activity.every((item) => item.type === "commentThread")).toBe( + true, + ); + expect(client.request).not.toHaveBeenCalledWith( + ListIssueActivityHistoryDocument, + expect.anything(), + ); + }); + + it("includes normalized reactions on root and replies with withReactions", async () => { + const client = createClientMock(); + mockClient(client, { + rootsWithReactions: [ + commentWithReactions("root-1", "2026-04-21T10:00:00.000Z"), + ], + replyCandidatesWithReactions: [ + commentWithReactions("reply-1", "2026-04-21T11:00:00.000Z", "root-1"), + ], + }); + + const result = await getIssueActivity(client, ISSUE_ID, { + withReactions: true, + commentsOnly: true, + }); + + const [thread] = result.activity; + expect(thread?.type).toBe("commentThread"); + if (thread?.type === "commentThread") { + const root = thread.root as DiscussionThreadWithReactions; + const replies = thread.replies as DiscussionThreadWithReactions[]; + expect(Array.isArray(root.reactions)).toBe(true); + expect(root.reactions[0]).toMatchObject({ emoji: "👍" }); + expect(replies[0]?.reactions[0]).toMatchObject({ emoji: "👍" }); + } + expect(client.request).toHaveBeenCalledWith( + ListIssueDiscussionRootsWithReactionsDocument, + expect.anything(), + ); + }); + + it("paginates the merged timeline with an id cursor", async () => { + const client = createClientMock(); + mockClient(client, { + roots: [ + comment("root-1", "2026-04-21T10:00:00.000Z"), + comment("root-2", "2026-04-21T11:00:00.000Z"), + comment("root-3", "2026-04-21T12:00:00.000Z"), + ], + }); + + const firstPage = await getIssueActivity(client, ISSUE_ID, { + limit: 2, + commentsOnly: true, + }); + expect(firstPage.activity).toHaveLength(2); + expect(firstPage.pageInfo.hasNextPage).toBe(true); + expect(firstPage.pageInfo.endCursor).toBe("root-2"); + + const secondPage = await getIssueActivity(client, ISSUE_ID, { + limit: 2, + after: "root-2", + commentsOnly: true, + }); + expect(secondPage.activity).toHaveLength(1); + expect(secondPage.pageInfo.hasNextPage).toBe(false); + expect(secondPage.pageInfo.endCursor).toBe("root-3"); + }); + + it("throws when the after cursor is unknown", async () => { + const client = createClientMock(); + mockClient(client, { + roots: [comment("root-1", "2026-04-21T10:00:00.000Z")], + }); + + await expect( + getIssueActivity(client, ISSUE_ID, { + after: "does-not-exist", + commentsOnly: true, + }), + ).rejects.toThrow(/cursor "does-not-exist" not found/); + }); + + it("throws when the issue does not exist", async () => { + const client = createClientMock(); + mockClient(client, { ref: null }); + + await expect(getIssueActivity(client, ISSUE_ID)).rejects.toThrow( + /not found/, + ); + }); +}); diff --git a/tests/unit/services/attachment-service.test.ts b/tests/unit/services/attachment-service.test.ts index 3956ff96..386af314 100644 --- a/tests/unit/services/attachment-service.test.ts +++ b/tests/unit/services/attachment-service.test.ts @@ -1,6 +1,8 @@ // tests/unit/services/attachment-service.test.ts + import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { createAttachment, deleteAttachment, @@ -26,7 +28,7 @@ describe("createAttachment", () => { }, }); const result = await createAttachment(client, { - issueId: "issue-1", + issueId: asUuid("issue-1"), title: "Test.pdf", url: "https://example.com/test.pdf", }); @@ -39,7 +41,7 @@ describe("createAttachment", () => { }); await expect( createAttachment(client, { - issueId: "issue-1", + issueId: asUuid("issue-1"), title: "Test.pdf", url: "https://example.com/test.pdf", }), @@ -52,13 +54,13 @@ describe("deleteAttachment", () => { const client = mockGqlClient({ attachmentDelete: { success: true, entityId: "att-1" }, }); - const result = await deleteAttachment(client, "att-1"); + const result = await deleteAttachment(client, asUuid("att-1")); expect(result).toEqual({ id: "att-1", success: true }); }); it("throws when delete fails", async () => { const client = mockGqlClient({ attachmentDelete: { success: false } }); - await expect(deleteAttachment(client, "att-1")).rejects.toThrow( + await expect(deleteAttachment(client, asUuid("att-1"))).rejects.toThrow( "Failed to delete attachment", ); }); @@ -76,7 +78,7 @@ describe("listAttachments", () => { }, }, }); - const result = await listAttachments(client, "issue-1"); + const result = await listAttachments(client, asUuid("issue-1")); expect(result).toHaveLength(2); }); @@ -84,13 +86,13 @@ describe("listAttachments", () => { const client = mockGqlClient({ issue: { attachments: { nodes: [] } }, }); - const result = await listAttachments(client, "issue-1"); + const result = await listAttachments(client, asUuid("issue-1")); expect(result).toEqual([]); }); it("throws when issue not found", async () => { const client = mockGqlClient({ issue: null }); - await expect(listAttachments(client, "missing")).rejects.toThrow( + await expect(listAttachments(client, asUuid("missing"))).rejects.toThrow( "not found", ); }); @@ -104,7 +106,7 @@ describe("listAttachments", () => { }, }); const filter = { sourceType: { eq: "github" } }; - const result = await listAttachments(client, "issue-1", filter); + const result = await listAttachments(client, asUuid("issue-1"), filter); expect(result).toHaveLength(1); expect(client.request).toHaveBeenCalledWith( expect.anything(), diff --git a/tests/unit/services/comment-service.test.ts b/tests/unit/services/comment-service.test.ts index 7f1fae0d..6c09515e 100644 --- a/tests/unit/services/comment-service.test.ts +++ b/tests/unit/services/comment-service.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { createComment, deleteComment, @@ -33,7 +34,7 @@ describe("createComment", () => { }); const result = await createComment(client, { - issueId: "issue-1", + issueId: asUuid("issue-1"), body: "This is a comment", }); @@ -59,7 +60,7 @@ describe("createComment", () => { }); await expect( - createComment(client, { issueId: "issue-1", body: "test" }), + createComment(client, { issueId: asUuid("issue-1"), body: "test" }), ).rejects.toThrow("Failed to create comment"); }); @@ -72,7 +73,7 @@ describe("createComment", () => { }); await expect( - createComment(client, { issueId: "issue-1", body: "test" }), + createComment(client, { issueId: asUuid("issue-1"), body: "test" }), ).rejects.toThrow("Failed to create comment"); }); }); @@ -108,7 +109,7 @@ describe("listComments", () => { }, }); - const result = await listComments(client, "issue-1"); + const result = await listComments(client, asUuid("issue-1")); expect(result.nodes).toHaveLength(2); expect(result.nodes[0]).toEqual({ @@ -119,7 +120,7 @@ describe("listComments", () => { parentId: null, user: MOCK_USER, }); - expect(result.nodes[1].parentId).toBe("comment-1"); + expect(result.nodes[1]?.parentId).toBe("comment-1"); expect(result.pageInfo).toEqual({ hasNextPage: true, endCursor: "cursor-abc", @@ -141,7 +142,7 @@ describe("listComments", () => { }, }); - const result = await listComments(client, "issue-1"); + const result = await listComments(client, asUuid("issue-1")); expect(result.nodes).toEqual([]); expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: null }); @@ -150,9 +151,9 @@ describe("listComments", () => { it("throws when issue does not exist", async () => { const client = mockGqlClient({ issue: null }); - await expect(listComments(client, "nonexistent-id")).rejects.toThrow( - 'Issue with ID "nonexistent-id" not found', - ); + await expect( + listComments(client, asUuid("nonexistent-id")), + ).rejects.toThrow('Issue with ID "nonexistent-id" not found'); }); it("passes pagination options to request", async () => { @@ -165,7 +166,10 @@ describe("listComments", () => { }, }); - await listComments(client, "issue-1", { limit: 10, after: "cursor-xyz" }); + await listComments(client, asUuid("issue-1"), { + limit: 10, + after: "cursor-xyz", + }); expect(client.request).toHaveBeenCalledWith(expect.anything(), { issueId: "issue-1", @@ -192,7 +196,7 @@ describe("replyToComment", () => { }); const result = await replyToComment(client, { - parentId: "comment-1", + parentId: asUuid("comment-1"), body: "This is a reply", }); @@ -218,7 +222,7 @@ describe("replyToComment", () => { }); await expect( - replyToComment(client, { parentId: "comment-1", body: "reply" }), + replyToComment(client, { parentId: asUuid("comment-1"), body: "reply" }), ).rejects.toThrow("Failed to create reply"); }); }); @@ -239,7 +243,7 @@ describe("updateComment", () => { }, }); - const result = await updateComment(client, "comment-1", { + const result = await updateComment(client, asUuid("comment-1"), { body: "Updated body", }); @@ -266,7 +270,7 @@ describe("updateComment", () => { }); await expect( - updateComment(client, "comment-1", { body: "new" }), + updateComment(client, asUuid("comment-1"), { body: "new" }), ).rejects.toThrow("Failed to update comment"); }); }); @@ -280,7 +284,7 @@ describe("deleteComment", () => { }, }); - const result = await deleteComment(client, "comment-1"); + const result = await deleteComment(client, asUuid("comment-1")); expect(result).toEqual({ id: "comment-1", success: true }); expect(client.request).toHaveBeenCalledWith(expect.anything(), { @@ -296,7 +300,7 @@ describe("deleteComment", () => { }, }); - await expect(deleteComment(client, "comment-1")).rejects.toThrow( + await expect(deleteComment(client, asUuid("comment-1"))).rejects.toThrow( "Failed to delete comment", ); }); diff --git a/tests/unit/services/cycle-service.test.ts b/tests/unit/services/cycle-service.test.ts index 3f0f83b3..4896b173 100644 --- a/tests/unit/services/cycle-service.test.ts +++ b/tests/unit/services/cycle-service.test.ts @@ -1,6 +1,8 @@ // tests/unit/services/cycle-service.test.ts + import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { getCycle, listCycles } from "../../../src/services/cycle-service.js"; function mockGqlClient(response: Record<string, unknown>): GraphQLClient { @@ -30,12 +32,12 @@ describe("listCycles", () => { }); const result = await listCycles(client); expect(result.nodes).toHaveLength(1); - expect(result.nodes[0].id).toBe("cyc-1"); - expect(result.nodes[0].number).toBe(1); - expect(result.nodes[0].name).toBe("Sprint 1"); - expect(result.nodes[0].startsAt).toBe("2025-01-01"); - expect(result.nodes[0].endsAt).toBe("2025-01-14"); - expect(result.nodes[0].isActive).toBe(true); + expect(result.nodes[0]?.id).toBe("cyc-1"); + expect(result.nodes[0]?.number).toBe(1); + expect(result.nodes[0]?.name).toBe("Sprint 1"); + expect(result.nodes[0]?.startsAt).toBe("2025-01-01"); + expect(result.nodes[0]?.endsAt).toBe("2025-01-14"); + expect(result.nodes[0]?.isActive).toBe(true); expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: "c1" }); }); @@ -88,7 +90,7 @@ describe("listCycles", () => { pageInfo: { hasNextPage: false, endCursor: null }, }, }); - await listCycles(client, "team-1"); + await listCycles(client, asUuid("team-1")); expect(client.request).toHaveBeenCalledWith(expect.anything(), { first: 50, after: undefined, @@ -130,7 +132,7 @@ describe("listCycles", () => { }, }); const result = await listCycles(client); - expect(result.nodes[0].name).toBe("Cycle 3"); + expect(result.nodes[0]?.name).toBe("Cycle 3"); }); }); @@ -158,16 +160,18 @@ describe("getCycle", () => { }, }, }); - const result = await getCycle(client, "cyc-1"); + const result = await getCycle(client, asUuid("cyc-1")); expect(result.id).toBe("cyc-1"); expect(result.name).toBe("Sprint 1"); expect(result.issues).toHaveLength(1); - expect(result.issues[0].identifier).toBe("ENG-1"); - expect(result.issues[0].state.name).toBe("In Progress"); + expect(result.issues[0]?.identifier).toBe("ENG-1"); + expect(result.issues[0]?.state.name).toBe("In Progress"); }); it("throws when cycle not found", async () => { const client = mockGqlClient({ cycle: null }); - await expect(getCycle(client, "missing-id")).rejects.toThrow("not found"); + await expect(getCycle(client, asUuid("missing-id"))).rejects.toThrow( + "not found", + ); }); }); diff --git a/tests/unit/services/discussion-service.test.ts b/tests/unit/services/discussion-service.test.ts index 056994df..8c55301d 100644 --- a/tests/unit/services/discussion-service.test.ts +++ b/tests/unit/services/discussion-service.test.ts @@ -1,8 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { GetDiscussionCommentContextDocument, type ListIssueDiscussionRootsQuery, + StartDiscussionDocument, } from "../../../src/gql/graphql.js"; vi.mock("../../../src/services/reaction-service.js", async (importOriginal) => { @@ -109,7 +111,7 @@ describe("discussion comment reactions", () => { await expect( createDiscussionCommentReaction(client, { - commentId: "thread-1", + commentId: asUuid("thread-1"), target: "thread", expectedEntityKind: "issue", emoji: "👍", @@ -139,7 +141,7 @@ describe("discussion comment reactions", () => { await expect( createDiscussionCommentReaction(client, { - commentId: "reply-1", + commentId: asUuid("reply-1"), target: "thread", expectedEntityKind: "issue", emoji: "👍", @@ -164,7 +166,7 @@ describe("discussion comment reactions", () => { await expect( deleteDiscussionCommentReactionByEmoji(client, { - commentId: "reply-1", + commentId: asUuid("reply-1"), target: "reply", expectedEntityKind: "issue", emoji: "👍", @@ -189,10 +191,10 @@ describe("discussion comment reactions", () => { await expect( deleteDiscussionCommentReactionById(client, { - commentId: "reply-1", + commentId: asUuid("reply-1"), target: "reply", expectedEntityKind: "initiative", - reactionId: "reaction-1", + reactionId: asUuid("reaction-1"), }), ).resolves.toEqual({ id: "reaction-1", success: true }); @@ -216,7 +218,7 @@ describe("discussion comment reactions", () => { await expect( createIssueDiscussionCommentReaction(client, { - commentId: "comment-1", + commentId: asUuid("comment-1"), emoji: "👍", }), ).resolves.toEqual({ id: "reaction-1" }); @@ -240,7 +242,7 @@ describe("discussion comment reactions", () => { await expect( createIssueDiscussionCommentReaction(client, { - commentId: "comment-1", + commentId: asUuid("comment-1"), emoji: "👍", }), ).rejects.toThrow( @@ -256,7 +258,7 @@ describe("discussion comment reactions", () => { await expect( deleteIssueDiscussionCommentReactionByEmoji(client, { - commentId: "comment-1", + commentId: asUuid("comment-1"), emoji: "👍", }), ).rejects.toThrow('Discussion comment ID "comment-1" not found'); @@ -277,8 +279,8 @@ describe("discussion comment reactions", () => { await expect( deleteIssueDiscussionCommentReactionById(client, { - commentId: "comment-1", - reactionId: "reaction-1", + commentId: asUuid("comment-1"), + reactionId: asUuid("reaction-1"), }), ).resolves.toEqual({ id: "reaction-1", success: true }); @@ -302,7 +304,7 @@ describe("listDiscussionsForIssue", () => { }, } satisfies ListIssueDiscussionRootsQuery); - const result = await listDiscussionsForIssue(client, "issue-1", { + const result = await listDiscussionsForIssue(client, asUuid("issue-1"), { limit: 2, after: "root-cursor-0", }); @@ -324,7 +326,7 @@ describe("listDiscussionsForIssue", () => { vi.mocked(client.request).mockResolvedValue({ issue: null }); await expect( - listDiscussionsForIssue(client, "issue-missing"), + listDiscussionsForIssue(client, asUuid("issue-missing")), ).rejects.toThrow('Issue with ID "issue-missing" not found'); }); @@ -341,11 +343,11 @@ describe("listDiscussionsForIssue", () => { const result = await listDiscussionsForIssueWithReactions( client, - "issue-1", + asUuid("issue-1"), { limit: 10 }, ); - expect(result.nodes[0].reactions).toEqual([ + expect(result.nodes[0]?.reactions).toEqual([ { emoji: "👍", count: 1, @@ -368,10 +370,14 @@ describe("listDiscussionsForProject", () => { }, }); - const result = await listDiscussionsForProject(client, "project-1", { - limit: 10, - after: "cur-0", - }); + const result = await listDiscussionsForProject( + client, + asUuid("project-1"), + { + limit: 10, + after: "cur-0", + }, + ); expect(result.nodes).toHaveLength(1); expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: null }); @@ -387,7 +393,7 @@ describe("listDiscussionsForProject", () => { vi.mocked(client.request).mockResolvedValue({ project: null }); await expect( - listDiscussionsForProject(client, "project-missing"), + listDiscussionsForProject(client, asUuid("project-missing")), ).rejects.toThrow('Project with ID "project-missing" not found'); }); @@ -404,11 +410,11 @@ describe("listDiscussionsForProject", () => { const result = await listDiscussionsForProjectWithReactions( client, - "project-1", + asUuid("project-1"), { limit: 10 }, ); - expect(result.nodes[0].reactions).toEqual([ + expect(result.nodes[0]?.reactions).toEqual([ { emoji: "👍", count: 1, @@ -430,7 +436,10 @@ describe("listDiscussionsForInitiative", () => { }, }); - const result = await listDiscussionsForInitiative(client, "initiative-1"); + const result = await listDiscussionsForInitiative( + client, + asUuid("initiative-1"), + ); expect(result.nodes).toHaveLength(1); expect(client.request).toHaveBeenCalledWith(expect.anything(), { @@ -446,7 +455,7 @@ describe("listDiscussionsForInitiative", () => { vi.mocked(client.request).mockResolvedValue({ initiative: null }); await expect( - listDiscussionsForInitiative(client, "initiative-missing"), + listDiscussionsForInitiative(client, asUuid("initiative-missing")), ).rejects.toThrow('Initiative with ID "initiative-missing" not found'); }); @@ -462,11 +471,11 @@ describe("listDiscussionsForInitiative", () => { const result = await listDiscussionsForInitiativeWithReactions( client, - "initiative-1", + asUuid("initiative-1"), { limit: 10 }, ); - expect(result.nodes[0].reactions).toEqual([ + expect(result.nodes[0]?.reactions).toEqual([ { emoji: "👍", count: 1, @@ -502,7 +511,7 @@ describe("listDiscussionReplies", () => { }, }); - const result = await listDiscussionReplies(client, "root-1", { + const result = await listDiscussionReplies(client, asUuid("root-1"), { limit: 5, }); @@ -542,7 +551,7 @@ describe("listDiscussionReplies", () => { }, }); - const result = await listDiscussionReplies(client, "root-1", { + const result = await listDiscussionReplies(client, asUuid("root-1"), { limit: 1, after: "reply-1", }); @@ -581,7 +590,9 @@ describe("listDiscussionReplies", () => { }, }); - const result = await listDiscussionReplies(client, "root-1", { limit: 10 }); + const result = await listDiscussionReplies(client, asUuid("root-1"), { + limit: 10, + }); expect(result.nodes.map((node) => node.id)).toEqual([ "z-parent", @@ -594,7 +605,7 @@ describe("listDiscussionReplies", () => { vi.mocked(client.request).mockResolvedValueOnce({ comment: null }); await expect( - listDiscussionReplies(client, "missing-thread"), + listDiscussionReplies(client, asUuid("missing-thread")), ).rejects.toThrow('Discussion thread ID "missing-thread" not found'); }); @@ -604,7 +615,9 @@ describe("listDiscussionReplies", () => { comment: comment("reply-1", "root-1"), }); - await expect(listDiscussionReplies(client, "reply-1")).rejects.toThrow( + await expect( + listDiscussionReplies(client, asUuid("reply-1")), + ).rejects.toThrow( 'Discussion thread ID "reply-1" must reference a root comment', ); }); @@ -629,12 +642,12 @@ describe("listDiscussionReplies", () => { const result = await listDiscussionRepliesWithReactions( client, - "root-1", + asUuid("root-1"), { limit: 10 }, "issue", ); - expect(result.nodes[0].reactions).toEqual([ + expect(result.nodes[0]?.reactions).toEqual([ { emoji: "👍", count: 1, @@ -651,7 +664,10 @@ describe("replyToDiscussion", () => { vi.mocked(client.request).mockResolvedValueOnce({ comment: null }); await expect( - replyToDiscussion(client, { threadId: "missing-thread", body: "nested" }), + replyToDiscussion(client, { + threadId: asUuid("missing-thread"), + body: "nested", + }), ).rejects.toThrow('Discussion thread ID "missing-thread" not found'); }); @@ -667,7 +683,10 @@ describe("replyToDiscussion", () => { }); await expect( - replyToDiscussion(client, { threadId: "reply-2", body: "nested reply" }), + replyToDiscussion(client, { + threadId: asUuid("reply-2"), + body: "nested reply", + }), ).rejects.toThrow( 'Discussion thread ID "reply-2" must reference a root comment', ); @@ -694,7 +713,7 @@ describe("replyToDiscussion", () => { await expect( replyToDiscussion(client, { - threadId: "root-1", + threadId: asUuid("root-1"), body: "nested reply", entityKind: "issue", }), @@ -703,10 +722,17 @@ describe("replyToDiscussion", () => { ); }); - it("creates a reply for root thread", async () => { + it("creates a reply for root thread and forwards the parent entity id", async () => { const client = createClientMock(); vi.mocked(client.request) - .mockResolvedValueOnce({ comment: comment("root-1") }) + .mockResolvedValueOnce({ + comment: { + ...comment("root-1"), + issueId: "issue-1", + projectId: null, + initiativeId: null, + }, + }) .mockResolvedValueOnce({ commentCreate: { success: true, @@ -715,12 +741,19 @@ describe("replyToDiscussion", () => { }); const result = await replyToDiscussion(client, { - threadId: "root-1", + threadId: asUuid("root-1"), body: "hello", }); expect(result.id).toBe("reply-1"); expect(result.parentId).toBe("root-1"); + expect(client.request).toHaveBeenNthCalledWith(2, StartDiscussionDocument, { + input: { + parentId: "root-1", + issueId: "issue-1", + body: "hello", + }, + }); }); }); @@ -739,17 +772,20 @@ describe("discussion mutation flows", () => { }); await expect( - startIssueDiscussion(client, { issueId: "issue-1", body: "issue body" }), + startIssueDiscussion(client, { + issueId: asUuid("issue-1"), + body: "issue body", + }), ).resolves.toMatchObject({ id: "c-issue" }); await expect( startProjectDiscussion(client, { - projectId: "project-1", + projectId: asUuid("project-1"), body: "project body", }), ).resolves.toMatchObject({ id: "c-project" }); await expect( startInitiativeDiscussion(client, { - initiativeId: "initiative-1", + initiativeId: asUuid("initiative-1"), body: "initiative body", }), ).resolves.toMatchObject({ id: "c-initiative" }); @@ -762,7 +798,10 @@ describe("discussion mutation flows", () => { }); await expect( - startIssueDiscussion(client, { issueId: "issue-1", body: "issue body" }), + startIssueDiscussion(client, { + issueId: asUuid("issue-1"), + body: "issue body", + }), ).rejects.toThrow("Failed to start discussion"); }); @@ -782,9 +821,11 @@ describe("discussion mutation flows", () => { }); await expect( - editDiscussionReply(client, "reply-1", { body: "updated" }), + editDiscussionReply(client, asUuid("reply-1"), { body: "updated" }), ).resolves.toMatchObject({ id: "reply-1", body: "updated" }); - await expect(deleteDiscussionReply(client, "reply-1")).resolves.toEqual({ + await expect( + deleteDiscussionReply(client, asUuid("reply-1")), + ).resolves.toEqual({ id: "reply-1", success: true, }); @@ -797,7 +838,7 @@ describe("discussion mutation flows", () => { }); await expect( - editDiscussionReply(client, "root-1", { body: "updated" }), + editDiscussionReply(client, asUuid("root-1"), { body: "updated" }), ).rejects.toThrow( 'Discussion reply ID "root-1" must reference a reply comment', ); @@ -809,7 +850,9 @@ describe("discussion mutation flows", () => { comment: comment("root-1"), }); - await expect(deleteDiscussionReply(client, "root-1")).rejects.toThrow( + await expect( + deleteDiscussionReply(client, asUuid("root-1")), + ).rejects.toThrow( 'Discussion reply ID "root-1" must reference a reply comment', ); }); @@ -830,9 +873,11 @@ describe("discussion mutation flows", () => { }); await expect( - editDiscussionComment(client, "root-1", { body: "updated" }), + editDiscussionComment(client, asUuid("root-1"), { body: "updated" }), ).resolves.toMatchObject({ id: "root-1", body: "updated" }); - await expect(deleteDiscussionComment(client, "root-1")).resolves.toEqual({ + await expect( + deleteDiscussionComment(client, asUuid("root-1")), + ).resolves.toEqual({ id: "root-1", success: true, }); @@ -850,7 +895,12 @@ describe("discussion mutation flows", () => { }); await expect( - editDiscussionReply(client, "reply-1", { body: "updated" }, "issue"), + editDiscussionReply( + client, + asUuid("reply-1"), + { body: "updated" }, + "issue", + ), ).rejects.toThrow( 'Discussion reply ID "reply-1" belongs to project, not issue', ); @@ -872,9 +922,11 @@ describe("discussion mutation flows", () => { }); await expect( - editDiscussionComment(client, "reply-1", { body: "updated" }), + editDiscussionComment(client, asUuid("reply-1"), { body: "updated" }), ).resolves.toMatchObject({ id: "reply-1", body: "updated" }); - await expect(deleteDiscussionComment(client, "reply-1")).resolves.toEqual({ + await expect( + deleteDiscussionComment(client, asUuid("reply-1")), + ).resolves.toEqual({ id: "reply-1", success: true, }); @@ -885,7 +937,7 @@ describe("discussion mutation flows", () => { vi.mocked(client.request).mockResolvedValueOnce({ comment: null }); await expect( - editDiscussionComment(client, "missing", { body: "updated" }), + editDiscussionComment(client, asUuid("missing"), { body: "updated" }), ).rejects.toThrow('Discussion comment ID "missing" not found'); }); @@ -898,7 +950,7 @@ describe("discussion mutation flows", () => { }); await expect( - editDiscussionComment(client, "root-1", { body: "updated" }), + editDiscussionComment(client, asUuid("root-1"), { body: "updated" }), ).rejects.toThrow("Failed to edit discussion comment"); }); @@ -910,9 +962,9 @@ describe("discussion mutation flows", () => { commentDelete: { success: false, entityId: "root-1" }, }); - await expect(deleteDiscussionComment(client, "root-1")).rejects.toThrow( - "Failed to delete discussion comment", - ); + await expect( + deleteDiscussionComment(client, asUuid("root-1")), + ).rejects.toThrow("Failed to delete discussion comment"); }); it("resolves and unresolves root discussion", async () => { @@ -938,11 +990,13 @@ describe("discussion mutation flows", () => { await expect( resolveDiscussion(client, { - threadId: "root-1", - resolvingCommentId: "reply-1", + threadId: asUuid("root-1"), + resolvingCommentId: asUuid("reply-1"), }), ).resolves.toMatchObject({ id: "root-1" }); - await expect(unresolveDiscussion(client, "root-1")).resolves.toMatchObject({ + await expect( + unresolveDiscussion(client, asUuid("root-1")), + ).resolves.toMatchObject({ id: "root-1", }); }); diff --git a/tests/unit/services/document-service.test.ts b/tests/unit/services/document-service.test.ts index d7dff3cb..063c83ce 100644 --- a/tests/unit/services/document-service.test.ts +++ b/tests/unit/services/document-service.test.ts @@ -1,12 +1,13 @@ // tests/unit/services/document-service.test.ts + import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { createDocument, deleteDocument, getDocument, listDocuments, - listDocumentsBySlugIds, updateDocument, } from "../../../src/services/document-service.js"; @@ -19,13 +20,15 @@ function mockGqlClient(response: Record<string, unknown>) { describe("getDocument", () => { it("returns document by ID", async () => { const client = mockGqlClient({ document: { id: "doc-1", title: "Test" } }); - const result = await getDocument(client, "doc-1"); + const result = await getDocument(client, asUuid("doc-1")); expect(result.id).toBe("doc-1"); }); it("throws when not found", async () => { const client = mockGqlClient({ document: null }); - await expect(getDocument(client, "missing")).rejects.toThrow("not found"); + await expect(getDocument(client, asUuid("missing"))).rejects.toThrow( + "not found", + ); }); }); @@ -59,7 +62,9 @@ describe("updateDocument", () => { document: { id: "doc-1", title: "Updated" }, }, }); - const result = await updateDocument(client, "doc-1", { title: "Updated" }); + const result = await updateDocument(client, asUuid("doc-1"), { + title: "Updated", + }); expect(result.title).toBe("Updated"); }); @@ -68,7 +73,7 @@ describe("updateDocument", () => { documentUpdate: { success: false }, }); await expect( - updateDocument(client, "doc-1", { title: "Updated" }), + updateDocument(client, asUuid("doc-1"), { title: "Updated" }), ).rejects.toThrow("Failed to update document"); }); }); @@ -131,39 +136,18 @@ describe("listDocuments", () => { }); }); -describe("listDocumentsBySlugIds", () => { - it("returns empty array for empty input", async () => { - const client = mockGqlClient({}); - const result = await listDocumentsBySlugIds(client, []); - expect(result).toEqual([]); - }); - - it("returns documents matching slugIds", async () => { - const client = mockGqlClient({ - documents: { - nodes: [ - { id: "1", slugId: "abc" }, - { id: "2", slugId: "def" }, - ], - }, - }); - const result = await listDocumentsBySlugIds(client, ["abc", "def"]); - expect(result).toHaveLength(2); - }); -}); - describe("deleteDocument", () => { it("returns id and success on success", async () => { const client = mockGqlClient({ documentDelete: { success: true, entity: { id: "doc-1" } }, }); - const result = await deleteDocument(client, "doc-1"); + const result = await deleteDocument(client, asUuid("doc-1")); expect(result).toEqual({ id: "doc-1", success: true }); }); it("throws when delete fails", async () => { const client = mockGqlClient({ documentDelete: { success: false } }); - await expect(deleteDocument(client, "doc-1")).rejects.toThrow( + await expect(deleteDocument(client, asUuid("doc-1"))).rejects.toThrow( "Failed to delete document", ); }); diff --git a/tests/unit/services/initiative-project-service.test.ts b/tests/unit/services/initiative-project-service.test.ts index 7112a0ce..648046e0 100644 --- a/tests/unit/services/initiative-project-service.test.ts +++ b/tests/unit/services/initiative-project-service.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { CreateInitiativeToProjectDocument, DeleteInitiativeToProjectDocument, @@ -38,8 +39,8 @@ describe("createInitiativeProjectLink", () => { await expect( createInitiativeProjectLink(client, { - initiativeId: "init-1", - projectId: "proj-1", + initiativeId: asUuid("init-1"), + projectId: asUuid("proj-1"), }), ).resolves.toEqual(link); @@ -63,8 +64,8 @@ describe("createInitiativeProjectLink", () => { await expect( createInitiativeProjectLink(client, { - initiativeId: "init-1", - projectId: "proj-1", + initiativeId: asUuid("init-1"), + projectId: asUuid("proj-1"), }), ).rejects.toThrow( 'Failed to create initiative-project link for initiative "init-1" and project "proj-1"', @@ -81,8 +82,8 @@ describe("createInitiativeProjectLink", () => { await expect( createInitiativeProjectLink(client, { - initiativeId: "init-1", - projectId: "proj-1", + initiativeId: asUuid("init-1"), + projectId: asUuid("proj-1"), }), ).rejects.toThrow( 'Failed to create initiative-project link for initiative "init-1" and project "proj-1"', @@ -100,7 +101,7 @@ describe("deleteInitiativeProjectLink", () => { }); await expect( - deleteInitiativeProjectLink(client, "link-1"), + deleteInitiativeProjectLink(client, asUuid("link-1")), ).resolves.toEqual({ id: "link-1", success: true, @@ -119,9 +120,9 @@ describe("deleteInitiativeProjectLink", () => { }, }); - await expect(deleteInitiativeProjectLink(client, "link-1")).rejects.toThrow( - 'Failed to delete initiative-project link "link-1"', - ); + await expect( + deleteInitiativeProjectLink(client, asUuid("link-1")), + ).rejects.toThrow('Failed to delete initiative-project link "link-1"'); }); it("throws when payload is missing", async () => { @@ -132,8 +133,8 @@ describe("deleteInitiativeProjectLink", () => { }, }); - await expect(deleteInitiativeProjectLink(client, "link-1")).rejects.toThrow( - 'Failed to delete initiative-project link "link-1"', - ); + await expect( + deleteInitiativeProjectLink(client, asUuid("link-1")), + ).rejects.toThrow('Failed to delete initiative-project link "link-1"'); }); }); diff --git a/tests/unit/services/initiative-relation-service.test.ts b/tests/unit/services/initiative-relation-service.test.ts index dc167caf..b57a93cf 100644 --- a/tests/unit/services/initiative-relation-service.test.ts +++ b/tests/unit/services/initiative-relation-service.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { CreateInitiativeRelationDocument, DeleteInitiativeRelationDocument, @@ -38,8 +39,8 @@ describe("createInitiativeRelation", () => { await expect( createInitiativeRelation(client, { - parentId: "init-parent", - childId: "init-child", + parentId: asUuid("init-parent"), + childId: asUuid("init-child"), }), ).resolves.toEqual(relation); @@ -63,8 +64,8 @@ describe("createInitiativeRelation", () => { await expect( createInitiativeRelation(client, { - parentId: "init-parent", - childId: "init-child", + parentId: asUuid("init-parent"), + childId: asUuid("init-child"), }), ).rejects.toThrow( 'Failed to create initiative relation from "init-parent" to "init-child"', @@ -81,8 +82,8 @@ describe("createInitiativeRelation", () => { await expect( createInitiativeRelation(client, { - parentId: "init-parent", - childId: "init-child", + parentId: asUuid("init-parent"), + childId: asUuid("init-child"), }), ).rejects.toThrow( 'Failed to create initiative relation from "init-parent" to "init-child"', @@ -99,7 +100,9 @@ describe("deleteInitiativeRelation", () => { }, }); - await expect(deleteInitiativeRelation(client, "rel-1")).resolves.toEqual({ + await expect( + deleteInitiativeRelation(client, asUuid("rel-1")), + ).resolves.toEqual({ id: "rel-1", success: true, }); @@ -117,9 +120,9 @@ describe("deleteInitiativeRelation", () => { }, }); - await expect(deleteInitiativeRelation(client, "rel-1")).rejects.toThrow( - 'Failed to delete initiative relation "rel-1"', - ); + await expect( + deleteInitiativeRelation(client, asUuid("rel-1")), + ).rejects.toThrow('Failed to delete initiative relation "rel-1"'); }); it("throws when payload is missing", async () => { @@ -130,8 +133,8 @@ describe("deleteInitiativeRelation", () => { }, }); - await expect(deleteInitiativeRelation(client, "rel-1")).rejects.toThrow( - 'Failed to delete initiative relation "rel-1"', - ); + await expect( + deleteInitiativeRelation(client, asUuid("rel-1")), + ).rejects.toThrow('Failed to delete initiative relation "rel-1"'); }); }); diff --git a/tests/unit/services/initiative-service.test.ts b/tests/unit/services/initiative-service.test.ts index d30bafd3..88ff01e8 100644 --- a/tests/unit/services/initiative-service.test.ts +++ b/tests/unit/services/initiative-service.test.ts @@ -1,6 +1,7 @@ import { type DocumentNode, type FragmentDefinitionNode, Kind } from "graphql"; import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { GetInitiativeDocument } from "../../../src/gql/graphql.js"; import { archiveInitiative, @@ -72,7 +73,7 @@ describe("listInitiatives", () => { after: "cursor-1", includeArchived: true, filter: { name: { eqIgnoreCase: "Growth" } }, - orderBy: { createdAt: "Asc" }, + orderBy: "createdAt", }); expect(client.request).toHaveBeenCalledWith(expect.anything(), { @@ -80,7 +81,7 @@ describe("listInitiatives", () => { after: "cursor-1", includeArchived: true, filter: { name: { eqIgnoreCase: "Growth" } }, - orderBy: { createdAt: "Asc" }, + orderBy: "createdAt", sort: undefined, }); }); @@ -104,7 +105,7 @@ describe("getInitiative", () => { }, }); - await expect(getInitiative(client, "init-1")).resolves.toEqual({ + await expect(getInitiative(client, asUuid("init-1"))).resolves.toEqual({ id: "init-1", name: "Growth", }); @@ -113,7 +114,7 @@ describe("getInitiative", () => { it("throws when initiative is not found", async () => { const client = mockGqlClient({ initiative: null }); - await expect(getInitiative(client, "missing")).rejects.toThrow( + await expect(getInitiative(client, asUuid("missing"))).rejects.toThrow( 'Initiative with ID "missing" not found', ); }); @@ -151,7 +152,9 @@ describe("updateInitiative", () => { it("rejects empty update input", async () => { const client = mockGqlClient({}); - await expect(updateInitiative(client, "init-1", {})).rejects.toThrow( + await expect( + updateInitiative(client, asUuid("init-1"), {}), + ).rejects.toThrow( "Invalid update options: at least one update field must be provided", ); }); @@ -165,7 +168,7 @@ describe("updateInitiative", () => { }); await expect( - updateInitiative(client, "init-1", { name: "Updated" }), + updateInitiative(client, asUuid("init-1"), { name: "Updated" }), ).resolves.toEqual({ id: "init-1", name: "Updated", @@ -178,7 +181,7 @@ describe("updateInitiative", () => { }); await expect( - updateInitiative(client, "init-1", { name: "Updated" }), + updateInitiative(client, asUuid("init-1"), { name: "Updated" }), ).rejects.toThrow('Failed to update initiative "init-1"'); }); }); @@ -192,7 +195,7 @@ describe("archiveInitiative", () => { }, }); - await expect(archiveInitiative(client, "init-1")).resolves.toEqual({ + await expect(archiveInitiative(client, asUuid("init-1"))).resolves.toEqual({ id: "init-1", name: "Growth", }); @@ -203,7 +206,7 @@ describe("archiveInitiative", () => { initiativeArchive: { success: false, entity: null }, }); - await expect(archiveInitiative(client, "init-1")).rejects.toThrow( + await expect(archiveInitiative(client, asUuid("init-1"))).rejects.toThrow( 'Failed to archive initiative "init-1"', ); }); @@ -218,7 +221,9 @@ describe("unarchiveInitiative", () => { }, }); - await expect(unarchiveInitiative(client, "init-1")).resolves.toEqual({ + await expect( + unarchiveInitiative(client, asUuid("init-1")), + ).resolves.toEqual({ id: "init-1", name: "Growth", }); @@ -229,7 +234,7 @@ describe("unarchiveInitiative", () => { initiativeUnarchive: { success: false, entity: null }, }); - await expect(unarchiveInitiative(client, "init-1")).rejects.toThrow( + await expect(unarchiveInitiative(client, asUuid("init-1"))).rejects.toThrow( 'Failed to unarchive initiative "init-1"', ); }); @@ -241,7 +246,7 @@ describe("deleteInitiative", () => { initiativeDelete: { success: true, entityId: "init-1" }, }); - await expect(deleteInitiative(client, "init-1")).resolves.toEqual({ + await expect(deleteInitiative(client, asUuid("init-1"))).resolves.toEqual({ id: "init-1", success: true, }); @@ -252,7 +257,7 @@ describe("deleteInitiative", () => { initiativeDelete: { success: false, entityId: null }, }); - await expect(deleteInitiative(client, "init-1")).rejects.toThrow( + await expect(deleteInitiative(client, asUuid("init-1"))).rejects.toThrow( 'Failed to delete initiative "init-1"', ); }); diff --git a/tests/unit/services/initiative-update-service.test.ts b/tests/unit/services/initiative-update-service.test.ts index 2f21fabb..da0426f5 100644 --- a/tests/unit/services/initiative-update-service.test.ts +++ b/tests/unit/services/initiative-update-service.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { ArchiveInitiativeUpdateDocument, CreateInitiativeUpdateDocument, @@ -40,7 +41,7 @@ describe("listInitiativeUpdates", () => { }); await listInitiativeUpdates(client, { - initiativeId: "init-1", + initiativeId: asUuid("init-1"), limit: 5, after: "cursor-1", includeArchived: true, @@ -62,7 +63,7 @@ describe("listInitiativeUpdates", () => { } as unknown as GraphQLClient; await expect( - listInitiativeUpdates(client, { initiativeId: "init-1" }), + listInitiativeUpdates(client, { initiativeId: asUuid("init-1") }), ).rejects.toThrow(requestError); expect(request).toHaveBeenCalledWith(ListInitiativeUpdatesDocument, { @@ -88,7 +89,9 @@ describe("getInitiativeUpdate", () => { }; const { client, request } = mockGqlClient({ initiativeUpdate: update }); - await expect(getInitiativeUpdate(client, "upd-1")).resolves.toEqual(update); + await expect(getInitiativeUpdate(client, asUuid("upd-1"))).resolves.toEqual( + update, + ); expect(request).toHaveBeenCalledWith(GetInitiativeUpdateDocument, { id: "upd-1", @@ -98,9 +101,9 @@ describe("getInitiativeUpdate", () => { it("throws when update is not found", async () => { const { client } = mockGqlClient({ initiativeUpdate: null }); - await expect(getInitiativeUpdate(client, "upd-missing")).rejects.toThrow( - 'Initiative update with ID "upd-missing" not found', - ); + await expect( + getInitiativeUpdate(client, asUuid("upd-missing")), + ).rejects.toThrow('Initiative update with ID "upd-missing" not found'); }); }); @@ -122,7 +125,7 @@ describe("createInitiativeUpdate", () => { await expect( createInitiativeUpdate(client, { - initiativeId: "init-1", + initiativeId: asUuid("init-1"), body: "Week 1", }), ).resolves.toEqual(update); @@ -142,7 +145,7 @@ describe("createInitiativeUpdate", () => { await expect( createInitiativeUpdate(client, { - initiativeId: "init-1", + initiativeId: asUuid("init-1"), body: "Week 1", }), ).rejects.toThrow("Failed to create initiative update"); @@ -155,7 +158,7 @@ describe("createInitiativeUpdate", () => { await expect( createInitiativeUpdate(client, { - initiativeId: "init-1", + initiativeId: asUuid("init-1"), body: "Week 1", }), ).rejects.toThrow("Failed to create initiative update"); @@ -166,7 +169,9 @@ describe("updateInitiativeUpdate", () => { it("rejects no-op update input", async () => { const { client } = mockGqlClient({}); - await expect(updateInitiativeUpdate(client, "upd-1", {})).rejects.toThrow( + await expect( + updateInitiativeUpdate(client, asUuid("upd-1"), {}), + ).rejects.toThrow( "Invalid update options: at least one update field must be provided", ); }); @@ -187,7 +192,7 @@ describe("updateInitiativeUpdate", () => { }); await expect( - updateInitiativeUpdate(client, "upd-1", { body: "Week 2" }), + updateInitiativeUpdate(client, asUuid("upd-1"), { body: "Week 2" }), ).resolves.toEqual(update); expect(request).toHaveBeenCalledWith(UpdateInitiativeUpdateDocument, { @@ -205,7 +210,7 @@ describe("updateInitiativeUpdate", () => { }); await expect( - updateInitiativeUpdate(client, "upd-1", { body: "Week 2" }), + updateInitiativeUpdate(client, asUuid("upd-1"), { body: "Week 2" }), ).rejects.toThrow('Failed to update initiative update "upd-1"'); }); @@ -215,7 +220,7 @@ describe("updateInitiativeUpdate", () => { }); await expect( - updateInitiativeUpdate(client, "upd-1", { body: "Week 2" }), + updateInitiativeUpdate(client, asUuid("upd-1"), { body: "Week 2" }), ).rejects.toThrow('Failed to update initiative update "upd-1"'); }); }); @@ -236,9 +241,9 @@ describe("archiveInitiativeUpdate", () => { initiativeUpdateArchive: { success: true, entity: archived }, }); - await expect(archiveInitiativeUpdate(client, "upd-1")).resolves.toEqual( - archived, - ); + await expect( + archiveInitiativeUpdate(client, asUuid("upd-1")), + ).resolves.toEqual(archived); expect(request).toHaveBeenCalledWith(ArchiveInitiativeUpdateDocument, { id: "upd-1", @@ -250,9 +255,9 @@ describe("archiveInitiativeUpdate", () => { initiativeUpdateArchive: { success: false, entity: { id: "upd-1" } }, }); - await expect(archiveInitiativeUpdate(client, "upd-1")).rejects.toThrow( - 'Failed to archive initiative update "upd-1"', - ); + await expect( + archiveInitiativeUpdate(client, asUuid("upd-1")), + ).rejects.toThrow('Failed to archive initiative update "upd-1"'); }); it("throws when mutation payload is missing", async () => { @@ -260,9 +265,9 @@ describe("archiveInitiativeUpdate", () => { initiativeUpdateArchive: { success: true, entity: null }, }); - await expect(archiveInitiativeUpdate(client, "upd-1")).rejects.toThrow( - 'Failed to archive initiative update "upd-1"', - ); + await expect( + archiveInitiativeUpdate(client, asUuid("upd-1")), + ).rejects.toThrow('Failed to archive initiative update "upd-1"'); }); }); @@ -282,9 +287,9 @@ describe("unarchiveInitiativeUpdate", () => { initiativeUpdateUnarchive: { success: true, entity: unarchived }, }); - await expect(unarchiveInitiativeUpdate(client, "upd-1")).resolves.toEqual( - unarchived, - ); + await expect( + unarchiveInitiativeUpdate(client, asUuid("upd-1")), + ).resolves.toEqual(unarchived); expect(request).toHaveBeenCalledWith(UnarchiveInitiativeUpdateDocument, { id: "upd-1", @@ -296,9 +301,9 @@ describe("unarchiveInitiativeUpdate", () => { initiativeUpdateUnarchive: { success: false, entity: { id: "upd-1" } }, }); - await expect(unarchiveInitiativeUpdate(client, "upd-1")).rejects.toThrow( - 'Failed to unarchive initiative update "upd-1"', - ); + await expect( + unarchiveInitiativeUpdate(client, asUuid("upd-1")), + ).rejects.toThrow('Failed to unarchive initiative update "upd-1"'); }); it("throws when mutation payload is missing", async () => { @@ -306,8 +311,8 @@ describe("unarchiveInitiativeUpdate", () => { initiativeUpdateUnarchive: { success: true, entity: null }, }); - await expect(unarchiveInitiativeUpdate(client, "upd-1")).rejects.toThrow( - 'Failed to unarchive initiative update "upd-1"', - ); + await expect( + unarchiveInitiativeUpdate(client, asUuid("upd-1")), + ).rejects.toThrow('Failed to unarchive initiative update "upd-1"'); }); }); diff --git a/tests/unit/services/issue-relation-service.test.ts b/tests/unit/services/issue-relation-service.test.ts index ebf3b2a1..b3bb642d 100644 --- a/tests/unit/services/issue-relation-service.test.ts +++ b/tests/unit/services/issue-relation-service.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; -import { IssueRelationType } from "../../../src/gql/graphql.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { createIssueRelation, deleteIssueRelation, findIssueRelation, + listIssueRelations, } from "../../../src/services/issue-relation-service.js"; function mockGqlClient(response: Record<string, unknown>): GraphQLClient { @@ -17,7 +18,7 @@ describe("createIssueRelation", () => { it("creates a relation and returns it", async () => { const relation = { id: "rel-1", - type: IssueRelationType.Blocks, + type: "blocks", relatedIssue: { id: "issue-2", identifier: "ENG-2" }, }; const client = mockGqlClient({ @@ -25,9 +26,9 @@ describe("createIssueRelation", () => { }); const result = await createIssueRelation(client, { - issueId: "issue-1", - relatedIssueId: "issue-2", - type: IssueRelationType.Blocks, + issueId: asUuid("issue-1"), + relatedIssueId: asUuid("issue-2"), + type: "blocks", }); expect(result).toEqual(relation); @@ -41,9 +42,9 @@ describe("createIssueRelation", () => { await expect( createIssueRelation(client, { - issueId: "issue-1", - relatedIssueId: "issue-2", - type: IssueRelationType.Blocks, + issueId: asUuid("issue-1"), + relatedIssueId: asUuid("issue-2"), + type: "blocks", }), ).rejects.toThrow("Failed to create issue relation"); }); @@ -57,7 +58,7 @@ describe("findIssueRelation", () => { nodes: [ { id: "rel-1", - type: IssueRelationType.Blocks, + type: "blocks", relatedIssue: { id: "target-id", identifier: "ENG-2" }, }, ], @@ -66,7 +67,11 @@ describe("findIssueRelation", () => { }, }); - const result = await findIssueRelation(client, "source-id", "target-id"); + const result = await findIssueRelation( + client, + asUuid("source-id"), + asUuid("target-id"), + ); expect(result).toBe("rel-1"); }); @@ -78,7 +83,7 @@ describe("findIssueRelation", () => { nodes: [ { id: "rel-2", - type: IssueRelationType.Blocks, + type: "blocks", issue: { id: "target-id", identifier: "ENG-1" }, }, ], @@ -86,7 +91,11 @@ describe("findIssueRelation", () => { }, }); - const result = await findIssueRelation(client, "source-id", "target-id"); + const result = await findIssueRelation( + client, + asUuid("source-id"), + asUuid("target-id"), + ); expect(result).toBe("rel-2"); }); @@ -94,7 +103,7 @@ describe("findIssueRelation", () => { const client = mockGqlClient({ issue: null }); await expect( - findIssueRelation(client, "non-existent-id", "target-id"), + findIssueRelation(client, asUuid("non-existent-id"), asUuid("target-id")), ).rejects.toThrow("not found"); }); @@ -107,18 +116,74 @@ describe("findIssueRelation", () => { }); await expect( - findIssueRelation(client, "source-id", "target-id"), + findIssueRelation(client, asUuid("source-id"), asUuid("target-id")), ).rejects.toThrow("not found"); }); }); +describe("listIssueRelations", () => { + it("returns issue metadata with forward and inverse relations", async () => { + const client = mockGqlClient({ + issue: { + id: "source-id", + identifier: "ENG-1", + relations: { + nodes: [ + { + id: "rel-1", + type: "blocks", + relatedIssue: { id: "target-id", identifier: "ENG-2" }, + }, + ], + }, + inverseRelations: { + nodes: [ + { + id: "rel-2", + type: "related", + issue: { id: "other-id", identifier: "ENG-3" }, + }, + ], + }, + }, + }); + + const result = await listIssueRelations(client, asUuid("source-id")); + + expect(result).toEqual({ + issueId: "source-id", + identifier: "ENG-1", + relations: [ + { + id: "rel-1", + type: "blocks", + relatedIssue: { id: "target-id", identifier: "ENG-2" }, + }, + { + id: "rel-2", + type: "related", + issue: { id: "other-id", identifier: "ENG-3" }, + }, + ], + }); + }); + + it("throws when issue is not found", async () => { + const client = mockGqlClient({ issue: null }); + + await expect(listIssueRelations(client, asUuid("missing"))).rejects.toThrow( + "not found", + ); + }); +}); + describe("deleteIssueRelation", () => { it("returns id and success", async () => { const client = mockGqlClient({ issueRelationDelete: { success: true, entityId: "rel-1" }, }); - const result = await deleteIssueRelation(client, "rel-1"); + const result = await deleteIssueRelation(client, asUuid("rel-1")); expect(result).toEqual({ id: "rel-1", success: true }); }); @@ -127,7 +192,7 @@ describe("deleteIssueRelation", () => { issueRelationDelete: { success: false }, }); - await expect(deleteIssueRelation(client, "rel-1")).rejects.toThrow( + await expect(deleteIssueRelation(client, asUuid("rel-1"))).rejects.toThrow( "Failed to delete issue relation", ); }); diff --git a/tests/unit/services/issue-service.test.ts b/tests/unit/services/issue-service.test.ts index 2906159a..9ef206e5 100644 --- a/tests/unit/services/issue-service.test.ts +++ b/tests/unit/services/issue-service.test.ts @@ -1,6 +1,7 @@ import { type DocumentNode, type FragmentDefinitionNode, Kind } from "graphql"; import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { ArchiveIssueDocument, DeleteIssueDocument, @@ -14,7 +15,6 @@ import { GetIssueByIdWithCommentsDocument, GetIssueByIdWithReactionsDocument, GetIssuesDocument, - PaginationOrderBy, SearchIssuesDocument, UnarchiveIssueDocument, } from "../../../src/gql/graphql.js"; @@ -115,7 +115,7 @@ describe("listIssues", () => { }); const result = await listIssues(client, { limit: 10 }); expect(result.nodes).toHaveLength(1); - expect(result.nodes[0].id).toBe("1"); + expect(result.nodes[0]?.id).toBe("1"); expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: "cursor1", @@ -145,7 +145,7 @@ describe("listIssues", () => { expect(client.request).toHaveBeenCalledWith(expect.anything(), { first: 25, after: undefined, - orderBy: PaginationOrderBy.UpdatedAt, + orderBy: "updatedAt", }); }); @@ -160,7 +160,7 @@ describe("listIssues", () => { expect(client.request).toHaveBeenCalledWith(expect.anything(), { first: 5, after: "cursor1", - orderBy: PaginationOrderBy.UpdatedAt, + orderBy: "updatedAt", }); }); @@ -197,7 +197,7 @@ describe("listIssues", () => { { team: { id: { eq: "team-uuid" } } }, ], }, - orderBy: PaginationOrderBy.UpdatedAt, + orderBy: "updatedAt", }); }); @@ -221,7 +221,7 @@ describe("listIssues", () => { first: 10, after: undefined, filter, - orderBy: PaginationOrderBy.UpdatedAt, + orderBy: "updatedAt", }); }); @@ -236,7 +236,7 @@ describe("listIssues", () => { expect(client.request).toHaveBeenCalledWith(GetIssuesDocument, { first: 25, after: undefined, - orderBy: PaginationOrderBy.UpdatedAt, + orderBy: "updatedAt", }); }); }); @@ -254,7 +254,7 @@ describe("getIssue", () => { }); const result = await getIssue( client, - "550e8400-e29b-41d4-a716-446655440000", + asUuid("550e8400-e29b-41d4-a716-446655440000"), ); expect(result.id).toBe("550e8400-e29b-41d4-a716-446655440000"); expect(result.comments.nodes).toEqual([{ id: "comment-1", body: "First" }]); @@ -266,7 +266,7 @@ describe("getIssue", () => { it("throws when issue not found by UUID", async () => { const client = mockGqlClient({ issue: null }); await expect( - getIssue(client, "550e8400-e29b-41d4-a716-446655440000"), + getIssue(client, asUuid("550e8400-e29b-41d4-a716-446655440000")), ).rejects.toThrow("not found"); }); }); @@ -323,7 +323,7 @@ describe("getIssueWithComments", () => { }, }, }); - const result = await getIssueWithComments(client, "issue-1"); + const result = await getIssueWithComments(client, asUuid("issue-1")); expect(result.comments.nodes[0]).toEqual({ id: "comment-1", @@ -368,7 +368,7 @@ describe("getIssueByIdentifierWithComments", () => { }); const result = await getIssueByIdentifierWithComments(client, "ENG", 42); - expect(result.comments.nodes[0].user.displayName).toBe("Ada"); + expect(result.comments.nodes[0]?.user?.displayName).toBe("Ada"); expect(client.request).toHaveBeenCalledWith( GetIssueByIdentifierWithCommentsDocument, { @@ -432,18 +432,18 @@ describe("getIssueWithCommentThreads", () => { }, }); - const result = await getIssueWithCommentThreads(client, "issue-1"); + const result = await getIssueWithCommentThreads(client, asUuid("issue-1")); expect(result.comments.nodes).toHaveLength(2); - expect(result.comments.nodes[0].id).toBe("comment-1"); - expect(result.comments.nodes[0].replies.map((reply) => reply.id)).toEqual([ + expect(result.comments.nodes[0]?.id).toBe("comment-1"); + expect(result.comments.nodes[0]?.replies.map((reply) => reply.id)).toEqual([ "comment-2", "comment-5", ]); expect( - result.comments.nodes[0].replies[0].replies.map((reply) => reply.id), + result.comments.nodes[0]?.replies[0]?.replies.map((reply) => reply.id), ).toEqual(["comment-4"]); - expect(result.comments.nodes[1].id).toBe("comment-3"); + expect(result.comments.nodes[1]?.id).toBe("comment-3"); }); }); @@ -486,7 +486,7 @@ describe("getIssueByIdentifierWithCommentThreads", () => { 42, ); - expect(result.comments.nodes[0].replies[0].id).toBe("comment-2"); + expect(result.comments.nodes[0]?.replies[0]?.id).toBe("comment-2"); expect(client.request).toHaveBeenCalledWith( GetIssueByIdentifierWithCommentsDocument, { @@ -507,7 +507,7 @@ describe("createIssue", () => { }); const result = await createIssue(client, { title: "New", - teamId: "team-uuid", + teamId: asUuid("team-uuid"), estimate: 5, }); expect(result.id).toBe("new-id"); @@ -521,7 +521,7 @@ describe("createIssue", () => { issueCreate: { success: false, issue: null }, }); await expect( - createIssue(client, { title: "Fail", teamId: "team-uuid" }), + createIssue(client, { title: "Fail", teamId: asUuid("team-uuid") }), ).rejects.toThrow("Failed to create issue"); }); }); @@ -539,7 +539,9 @@ describe("updateIssue", () => { }, }, }); - const result = await updateIssue(client, "issue-id", { estimate: 8 }); + const result = await updateIssue(client, asUuid("issue-id"), { + estimate: 8, + }); expect(result.id).toBe("issue-id"); expect(client.request).toHaveBeenCalledWith(expect.anything(), { id: "issue-id", @@ -554,7 +556,9 @@ describe("updateIssue", () => { issue: { id: "issue-id", identifier: "ENG-1", title: "Cleared" }, }, }); - const result = await updateIssue(client, "issue-id", { estimate: null }); + const result = await updateIssue(client, asUuid("issue-id"), { + estimate: null, + }); expect(result.id).toBe("issue-id"); expect(client.request).toHaveBeenCalledWith(expect.anything(), { id: "issue-id", @@ -567,7 +571,7 @@ describe("updateIssue", () => { issueUpdate: { success: false, issue: null }, }); await expect( - updateIssue(client, "issue-id", { title: "Fail" }), + updateIssue(client, asUuid("issue-id"), { title: "Fail" }), ).rejects.toThrow("Failed to update issue"); }); }); @@ -602,7 +606,7 @@ describe("getIssueWithReactions", () => { }, }); - const result = await getIssueWithReactions(client, "issue-1"); + const result = await getIssueWithReactions(client, asUuid("issue-1")); expect(result.reactions).toEqual([ { @@ -630,9 +634,9 @@ describe("getIssueWithReactions", () => { it("throws when issue not found by UUID", async () => { const client = mockGqlClient({ issue: null }); - await expect(getIssueWithReactions(client, "missing")).rejects.toThrow( - 'Issue with ID "missing" not found', - ); + await expect( + getIssueWithReactions(client, asUuid("missing")), + ).rejects.toThrow('Issue with ID "missing" not found'); }); }); @@ -706,7 +710,7 @@ describe("getIssueWithAttachments", () => { }, }, }); - const result = await getIssueWithAttachments(client, "issue-1"); + const result = await getIssueWithAttachments(client, asUuid("issue-1")); expect(result.id).toBe("issue-1"); expect(client.request).toHaveBeenCalledWith( GetIssueByIdWithAttachmentsDocument, @@ -716,9 +720,9 @@ describe("getIssueWithAttachments", () => { it("throws when issue not found", async () => { const client = mockGqlClient({ issue: null }); - await expect(getIssueWithAttachments(client, "missing")).rejects.toThrow( - "not found", - ); + await expect( + getIssueWithAttachments(client, asUuid("missing")), + ).rejects.toThrow("not found"); }); }); @@ -763,7 +767,7 @@ describe("searchIssues", () => { }); const result = await searchIssues(client, "test", { limit: 10 }); expect(result.nodes).toHaveLength(1); - expect(result.nodes[0].id).toBe("1"); + expect(result.nodes[0]?.id).toBe("1"); expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: "cursor1", @@ -828,7 +832,7 @@ describe("archiveIssue", () => { }, }); - const result = await archiveIssue(client, "issue-1"); + const result = await archiveIssue(client, asUuid("issue-1")); expect(result.id).toBe("issue-1"); expect(client.request).toHaveBeenCalledWith(ArchiveIssueDocument, { @@ -841,7 +845,7 @@ describe("archiveIssue", () => { issueArchive: { success: false, entity: null }, }); - await expect(archiveIssue(client, "issue-1")).rejects.toThrow( + await expect(archiveIssue(client, asUuid("issue-1"))).rejects.toThrow( 'Failed to archive issue "issue-1"', ); }); @@ -856,7 +860,7 @@ describe("unarchiveIssue", () => { }, }); - const result = await unarchiveIssue(client, "issue-1"); + const result = await unarchiveIssue(client, asUuid("issue-1")); expect(result.id).toBe("issue-1"); expect(client.request).toHaveBeenCalledWith(UnarchiveIssueDocument, { @@ -869,7 +873,7 @@ describe("unarchiveIssue", () => { issueUnarchive: { success: false, entity: null }, }); - await expect(unarchiveIssue(client, "issue-1")).rejects.toThrow( + await expect(unarchiveIssue(client, asUuid("issue-1"))).rejects.toThrow( 'Failed to unarchive issue "issue-1"', ); }); @@ -881,7 +885,7 @@ describe("deleteIssue", () => { issueDelete: { success: true, entity: { id: "issue-1" } }, }); - await expect(deleteIssue(client, "issue-1")).resolves.toEqual({ + await expect(deleteIssue(client, asUuid("issue-1"))).resolves.toEqual({ id: "issue-1", success: true, }); @@ -896,7 +900,7 @@ describe("deleteIssue", () => { issueDelete: { success: false, entity: null }, }); - await expect(deleteIssue(client, "issue-1")).rejects.toThrow( + await expect(deleteIssue(client, asUuid("issue-1"))).rejects.toThrow( 'Failed to delete issue "issue-1"', ); }); diff --git a/tests/unit/services/label-service.test.ts b/tests/unit/services/label-service.test.ts index 34c32657..58bb1a99 100644 --- a/tests/unit/services/label-service.test.ts +++ b/tests/unit/services/label-service.test.ts @@ -1,8 +1,13 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { + createLabel, + deleteLabel, + getLabel, listLabels, listProjectLabels, + updateLabel, } from "../../../src/services/label-service.js"; function mockGqlClient(response: Record<string, unknown>): GraphQLClient { @@ -11,6 +16,200 @@ function mockGqlClient(response: Record<string, unknown>): GraphQLClient { } as unknown as GraphQLClient; } +describe("getLabel", () => { + it("returns a label by id", async () => { + const client = mockGqlClient({ + issueLabel: { + id: "lbl-1", + name: "Bug", + color: "#ff0000", + description: "A bug", + }, + }); + + const result = await getLabel(client, asUuid("lbl-1")); + + expect(result).toEqual({ + id: "lbl-1", + name: "Bug", + color: "#ff0000", + description: "A bug", + type: "issue", + }); + }); + + it("throws when label not found", async () => { + const client = mockGqlClient({ issueLabel: null }); + + await expect(getLabel(client, asUuid("lbl-1"))).rejects.toThrow( + 'Label with ID "lbl-1" not found', + ); + }); +}); + +describe("createLabel", () => { + it("returns created issue label with type", async () => { + const client = mockGqlClient({ + issueLabelCreate: { + success: true, + issueLabel: { + id: "lbl-new", + name: "branch:unmerged", + color: "#B45309", + description: "Created from DBL branch workflow", + }, + }, + }); + + const result = await createLabel(client, { + name: "branch:unmerged", + teamId: asUuid("team-1"), + color: "#B45309", + description: "Created from DBL branch workflow", + }); + + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + input: { + name: "branch:unmerged", + teamId: "team-1", + color: "#B45309", + description: "Created from DBL branch workflow", + }, + }); + expect(result).toEqual({ + id: "lbl-new", + name: "branch:unmerged", + color: "#B45309", + description: "Created from DBL branch workflow", + type: "issue", + }); + }); + + it("throws on create failure", async () => { + const client = mockGqlClient({ + issueLabelCreate: { + success: false, + issueLabel: { + id: "lbl-new", + name: "branch:unmerged", + color: "#B45309", + description: null, + }, + }, + }); + + await expect( + createLabel(client, { name: "branch:unmerged" }), + ).rejects.toThrow('Failed to create label "branch:unmerged"'); + }); + + it("converts null create description to undefined", async () => { + const client = mockGqlClient({ + issueLabelCreate: { + success: true, + issueLabel: { + id: "lbl-new", + name: "branch:unmerged", + color: "#B45309", + description: null, + }, + }, + }); + + const result = await createLabel(client, { name: "branch:unmerged" }); + + expect(result.description).toBeUndefined(); + expect(result.type).toBe("issue"); + }); +}); + +describe("updateLabel", () => { + it("returns updated issue label", async () => { + const client = mockGqlClient({ + issueLabelUpdate: { + success: true, + issueLabel: { + id: "lbl-1", + name: "branch:merged", + color: "#1D4ED8", + description: "Updated from DBL branch workflow", + }, + }, + }); + + const result = await updateLabel(client, asUuid("lbl-1"), { + name: "branch:merged", + color: "#1D4ED8", + description: "Updated from DBL branch workflow", + }); + + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + id: "lbl-1", + input: { + name: "branch:merged", + color: "#1D4ED8", + description: "Updated from DBL branch workflow", + }, + }); + expect(result).toEqual({ + id: "lbl-1", + name: "branch:merged", + color: "#1D4ED8", + description: "Updated from DBL branch workflow", + type: "issue", + }); + }); + + it("throws on update failure", async () => { + const client = mockGqlClient({ + issueLabelUpdate: { + success: false, + issueLabel: { + id: "lbl-1", + name: "branch:merged", + color: "#1D4ED8", + description: null, + }, + }, + }); + + await expect( + updateLabel(client, asUuid("lbl-1"), { name: "branch:merged" }), + ).rejects.toThrow('Failed to update label "lbl-1"'); + }); +}); + +describe("deleteLabel", () => { + it("returns deleted label id", async () => { + const client = mockGqlClient({ + issueLabelDelete: { + success: true, + entityId: "lbl-1", + }, + }); + + const result = await deleteLabel(client, asUuid("lbl-1")); + + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + id: "lbl-1", + }); + expect(result).toEqual({ id: "lbl-1", success: true }); + }); + + it("throws on delete failure", async () => { + const client = mockGqlClient({ + issueLabelDelete: { + success: false, + entityId: "lbl-1", + }, + }); + + await expect(deleteLabel(client, asUuid("lbl-1"))).rejects.toThrow( + 'Failed to delete label "lbl-1"', + ); + }); +}); + describe("listLabels", () => { it("returns issue labels with type", async () => { const client = mockGqlClient({ @@ -92,7 +291,7 @@ describe("listLabels", () => { }, }); - await listLabels(client, "team-1"); + await listLabels(client, asUuid("team-1")); expect(client.request).toHaveBeenCalledWith(expect.anything(), { first: 50, @@ -126,7 +325,7 @@ describe("listLabels", () => { }, }); - await listLabels(client, "team-1", { scope: "team" }); + await listLabels(client, asUuid("team-1"), { scope: "team" }); expect(client.request).toHaveBeenCalledWith(expect.anything(), { first: 50, @@ -147,8 +346,8 @@ describe("listLabels", () => { const result = await listLabels(client); - expect(result.nodes[0].description).toBeUndefined(); - expect(result.nodes[0].type).toBe("issue"); + expect(result.nodes[0]?.description).toBeUndefined(); + expect(result.nodes[0]?.type).toBe("issue"); }); }); @@ -231,7 +430,7 @@ describe("listProjectLabels", () => { const result = await listProjectLabels(client); - expect(result.nodes[0].description).toBeUndefined(); - expect(result.nodes[0].type).toBe("project"); + expect(result.nodes[0]?.description).toBeUndefined(); + expect(result.nodes[0]?.type).toBe("project"); }); }); diff --git a/tests/unit/services/milestone-service.test.ts b/tests/unit/services/milestone-service.test.ts index aa04be91..4c50b797 100644 --- a/tests/unit/services/milestone-service.test.ts +++ b/tests/unit/services/milestone-service.test.ts @@ -1,6 +1,8 @@ // tests/unit/services/milestone-service.test.ts + import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { createMilestone, getMilestone, @@ -32,7 +34,7 @@ describe("listMilestones", () => { }, }, }); - const result = await listMilestones(client, "proj-1"); + const result = await listMilestones(client, asUuid("proj-1")); expect(result.nodes).toHaveLength(1); expect(result.nodes[0]).toEqual({ id: "ms-1", @@ -46,7 +48,7 @@ describe("listMilestones", () => { it("returns empty when project is null", async () => { const client = mockGqlClient({ project: null }); - const result = await listMilestones(client, "missing-proj"); + const result = await listMilestones(client, asUuid("missing-proj")); expect(result.nodes).toEqual([]); expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: null }); }); @@ -60,7 +62,7 @@ describe("listMilestones", () => { }, }, }); - await listMilestones(client, "proj-1", { after: "cur1" }); + await listMilestones(client, asUuid("proj-1"), { after: "cur1" }); expect(client.request).toHaveBeenCalledWith(expect.anything(), { projectId: "proj-1", first: 50, @@ -77,7 +79,7 @@ describe("listMilestones", () => { }, }, }); - await listMilestones(client, "proj-1"); + await listMilestones(client, asUuid("proj-1")); expect(client.request).toHaveBeenCalledWith(expect.anything(), { projectId: "proj-1", first: 50, @@ -99,14 +101,14 @@ describe("getMilestone", () => { issues: { nodes: [] }, }, }); - const result = await getMilestone(client, "ms-1"); + const result = await getMilestone(client, asUuid("ms-1")); expect(result.id).toBe("ms-1"); expect(result.name).toBe("v1.0"); }); it("throws when not found", async () => { const client = mockGqlClient({ projectMilestone: null }); - await expect(getMilestone(client, "missing-id")).rejects.toThrow( + await expect(getMilestone(client, asUuid("missing-id"))).rejects.toThrow( "not found", ); }); @@ -127,13 +129,36 @@ describe("createMilestone", () => { }, }); const result = await createMilestone(client, { - projectId: "proj-1", + projectId: asUuid("proj-1"), name: "v2.0", }); expect(result.id).toBe("ms-new"); expect(result.name).toBe("v2.0"); }); + it("passes input as a single GraphQL variable", async () => { + const client = mockGqlClient({ + projectMilestoneCreate: { + success: true, + projectMilestone: { + id: "ms-new", + name: "v2.0", + description: null, + targetDate: null, + sortOrder: 0, + }, + }, + }); + const input = { + projectId: asUuid("proj-1"), + name: "v2.0", + description: "desc", + targetDate: "2025-12-01", + }; + await createMilestone(client, input); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { input }); + }); + it("throws on failure", async () => { const client = mockGqlClient({ projectMilestoneCreate: { @@ -142,7 +167,7 @@ describe("createMilestone", () => { }, }); await expect( - createMilestone(client, { projectId: "proj-1", name: "Bad" }), + createMilestone(client, { projectId: asUuid("proj-1"), name: "Bad" }), ).rejects.toThrow("Failed to create milestone"); }); }); @@ -161,11 +186,34 @@ describe("updateMilestone", () => { }, }, }); - const result = await updateMilestone(client, "ms-1", { name: "v1.1" }); + const result = await updateMilestone(client, asUuid("ms-1"), { + name: "v1.1", + }); expect(result.id).toBe("ms-1"); expect(result.name).toBe("v1.1"); }); + it("passes id and input as GraphQL variables", async () => { + const client = mockGqlClient({ + projectMilestoneUpdate: { + success: true, + projectMilestone: { + id: "ms-1", + name: "v1.1", + description: null, + targetDate: null, + sortOrder: 0, + }, + }, + }); + const input = { name: "v1.1", description: "updated" }; + await updateMilestone(client, asUuid("ms-1"), input); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + id: "ms-1", + input, + }); + }); + it("throws on failure", async () => { const client = mockGqlClient({ projectMilestoneUpdate: { @@ -174,7 +222,7 @@ describe("updateMilestone", () => { }, }); await expect( - updateMilestone(client, "ms-1", { name: "Bad" }), + updateMilestone(client, asUuid("ms-1"), { name: "Bad" }), ).rejects.toThrow("Failed to update milestone"); }); }); diff --git a/tests/unit/services/milestone-service.variables.test.ts b/tests/unit/services/milestone-service.variables.test.ts new file mode 100644 index 00000000..fa61f937 --- /dev/null +++ b/tests/unit/services/milestone-service.variables.test.ts @@ -0,0 +1,76 @@ +import type { DocumentNode } from "graphql"; +import { describe, expect, it, vi } from "vitest"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; +import { + createMilestone, + updateMilestone, +} from "../../../src/services/milestone-service.js"; +import { assertVariablesMatchDocument } from "../helpers/assert-variables.js"; + +/** + * Regression guard for the milestone mutation variable shapes (issues #223 / + * #228): the milestone mutations follow the `$input` convention used by every + * other mutation in the codebase, so the service must pass `{ input }` / + * `{ id, input }`. These tests capture the variables object actually handed to + * `client.request` and assert every key is a variable the document declares. + */ +function mockGqlClient(response: Record<string, unknown>): { + client: GraphQLClient; + request: ReturnType<typeof vi.fn>; +} { + const request = vi.fn().mockResolvedValue(response); + return { client: { request } as unknown as GraphQLClient, request }; +} + +function lastCallVariables( + request: ReturnType<typeof vi.fn>, +): [DocumentNode, Record<string, unknown>] { + const [document, variables] = request.mock.calls[0] as [ + DocumentNode, + Record<string, unknown>, + ]; + return [document, variables]; +} + +describe("milestone service variable shapes (issues #223 / #228)", () => { + it("createMilestone passes only declared variables", async () => { + const { client, request } = mockGqlClient({ + projectMilestoneCreate: { + success: true, + projectMilestone: { id: "ms-new", name: "v2.0" }, + }, + }); + + await createMilestone(client, { + projectId: asUuid("proj-1"), + name: "v2.0", + description: "Second release", + targetDate: "2025-12-01", + }); + + const [document, variables] = lastCallVariables(request); + assertVariablesMatchDocument(document, variables); + expect(variables).toHaveProperty("input"); + }); + + it("updateMilestone passes only declared variables", async () => { + const { client, request } = mockGqlClient({ + projectMilestoneUpdate: { + success: true, + projectMilestone: { id: "ms-1", name: "v1.1" }, + }, + }); + + await updateMilestone(client, asUuid("ms-1"), { + name: "v1.1", + description: "Updated", + targetDate: "2026-01-01", + sortOrder: 2, + }); + + const [document, variables] = lastCallVariables(request); + assertVariablesMatchDocument(document, variables); + expect(variables).toHaveProperty("input"); + }); +}); diff --git a/tests/unit/services/project-service.test.ts b/tests/unit/services/project-service.test.ts index 6facab21..147befe3 100644 --- a/tests/unit/services/project-service.test.ts +++ b/tests/unit/services/project-service.test.ts @@ -1,7 +1,9 @@ // tests/unit/services/project-service.test.ts + import { type DocumentNode, type FragmentDefinitionNode, Kind } from "graphql"; import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { ArchiveProjectDocument, GetProjectDocument, @@ -146,11 +148,11 @@ describe("listProjects", () => { }); const result = await listProjects(client); expect(result.nodes).toHaveLength(1); - expect(result.nodes[0].id).toBe("proj-1"); - expect(result.nodes[0].name).toBe("Project Alpha"); - expect(result.nodes[0].state).toBe("started"); - expect(result.nodes[0].status.name).toBe("Started"); - expect(result.nodes[0].slugId).toBe("alpha"); + expect(result.nodes[0]?.id).toBe("proj-1"); + expect(result.nodes[0]?.name).toBe("Project Alpha"); + expect(result.nodes[0]?.state).toBe("started"); + expect(result.nodes[0]?.status.name).toBe("Started"); + expect(result.nodes[0]?.slugId).toBe("alpha"); expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: "c1" }); }); @@ -177,6 +179,7 @@ describe("listProjects", () => { expect(client.request).toHaveBeenCalledWith(expect.anything(), { first: 50, after: "cur1", + includeArchived: undefined, }); }); @@ -191,6 +194,22 @@ describe("listProjects", () => { expect(client.request).toHaveBeenCalledWith(expect.anything(), { first: 50, after: undefined, + includeArchived: undefined, + }); + }); + + it("passes includeArchived when requested", async () => { + const client = mockGqlClient({ + projects: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + await listProjects(client, { includeArchived: true }); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + first: 50, + after: undefined, + includeArchived: true, }); }); @@ -221,7 +240,7 @@ describe("listProjects", () => { }, }); const result = await listProjects(client); - expect(result.nodes[0].targetDate).toBeNull(); + expect(result.nodes[0]?.targetDate).toBeNull(); }); }); @@ -261,17 +280,68 @@ describe("getProject", () => { initiatives: { nodes: [{ id: "init-1", name: "Growth" }] }, }, }); - const result = await getProject(client, "proj-1"); + const result = await getProject(client, asUuid("proj-1")); expect(result.id).toBe("proj-1"); expect(result.name).toBe("Project Alpha"); expect(result.status.name).toBe("Started"); expect(result.content).toBe("# Project Alpha\nDetailed content here."); expect(result.members.nodes).toHaveLength(1); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + id: "proj-1", + milestonesFirst: 25, + skipMilestones: false, + issuesFirst: 50, + skipIssues: false, + }); + }); + + it("supports bounded detail expansion and zero skips", async () => { + const client = mockGqlClient({ + project: { + id: "proj-1", + name: "Project Alpha", + }, + }); + + await getProject(client, asUuid("proj-1"), { + milestonesFirst: 0, + issuesFirst: 0, + }); + + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + id: "proj-1", + milestonesFirst: 1, + skipMilestones: true, + issuesFirst: 1, + skipIssues: true, + }); + }); + + it("passes custom milestone and issue limits", async () => { + const client = mockGqlClient({ + project: { + id: "proj-1", + name: "Project Alpha", + }, + }); + + await getProject(client, asUuid("proj-1"), { + milestonesFirst: 5, + issuesFirst: 10, + }); + + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + id: "proj-1", + milestonesFirst: 5, + skipMilestones: false, + issuesFirst: 10, + skipIssues: false, + }); }); it("throws when project not found", async () => { const client = mockGqlClient({ project: null }); - await expect(getProject(client, "nonexistent")).rejects.toThrow( + await expect(getProject(client, asUuid("nonexistent"))).rejects.toThrow( 'Project with ID "nonexistent" not found', ); }); @@ -316,7 +386,7 @@ describe("createProject", () => { }); const result = await createProject(client, { name: "New Project", - teamIds: ["team-1"], + teamIds: [asUuid("team-1")], }); expect(result.id).toBe("proj-new"); expect(result.name).toBe("New Project"); @@ -327,7 +397,7 @@ describe("createProject", () => { projectCreate: { success: false, project: null }, }); await expect( - createProject(client, { name: "Fail", teamIds: ["team-1"] }), + createProject(client, { name: "Fail", teamIds: [asUuid("team-1")] }), ).rejects.toThrow('Failed to create project "Fail"'); }); }); @@ -369,7 +439,7 @@ describe("updateProject", () => { }, }, }); - const result = await updateProject(client, "proj-1", { + const result = await updateProject(client, asUuid("proj-1"), { name: "Updated Name", }); expect(result.id).toBe("proj-1"); @@ -382,7 +452,7 @@ describe("updateProject", () => { projectUpdate: { success: false, project: null }, }); await expect( - updateProject(client, "proj-1", { name: "Fail" }), + updateProject(client, asUuid("proj-1"), { name: "Fail" }), ).rejects.toThrow('Failed to update project "proj-1"'); }); }); @@ -396,7 +466,7 @@ describe("archiveProject", () => { }, }); - await expect(archiveProject(client, "proj-1")).resolves.toEqual({ + await expect(archiveProject(client, asUuid("proj-1"))).resolves.toEqual({ id: "proj-1", name: "Archived Project", }); @@ -411,7 +481,7 @@ describe("archiveProject", () => { projectArchive: { success: false, entity: null }, }); - await expect(archiveProject(client, "proj-1")).rejects.toThrow( + await expect(archiveProject(client, asUuid("proj-1"))).rejects.toThrow( 'Failed to archive project "proj-1"', ); }); @@ -426,7 +496,7 @@ describe("unarchiveProject", () => { }, }); - await expect(unarchiveProject(client, "proj-1")).resolves.toEqual({ + await expect(unarchiveProject(client, asUuid("proj-1"))).resolves.toEqual({ id: "proj-1", name: "Active Project", }); @@ -441,7 +511,7 @@ describe("unarchiveProject", () => { projectUnarchive: { success: false, entity: null }, }); - await expect(unarchiveProject(client, "proj-1")).rejects.toThrow( + await expect(unarchiveProject(client, asUuid("proj-1"))).rejects.toThrow( 'Failed to unarchive project "proj-1"', ); }); @@ -453,7 +523,7 @@ describe("deleteProject", () => { projectDelete: { success: true, entity: { id: "proj-1" } }, }); - await expect(deleteProject(client, "proj-1")).resolves.toEqual({ + await expect(deleteProject(client, asUuid("proj-1"))).resolves.toEqual({ id: "proj-1", success: true, }); @@ -468,7 +538,7 @@ describe("deleteProject", () => { projectDelete: { success: true, entity: null }, }); - await expect(deleteProject(client, "proj-1")).resolves.toEqual({ + await expect(deleteProject(client, asUuid("proj-1"))).resolves.toEqual({ id: "proj-1", success: true, }); @@ -479,7 +549,7 @@ describe("deleteProject", () => { projectDelete: { success: false, entity: null }, }); - await expect(deleteProject(client, "proj-1")).rejects.toThrow( + await expect(deleteProject(client, asUuid("proj-1"))).rejects.toThrow( 'Failed to delete project "proj-1"', ); }); diff --git a/tests/unit/services/reaction-service.test.ts b/tests/unit/services/reaction-service.test.ts index 3e3243f2..f91b4741 100644 --- a/tests/unit/services/reaction-service.test.ts +++ b/tests/unit/services/reaction-service.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { createReactionForComment, createReactionForIssue, @@ -45,7 +46,10 @@ describe("createReactionForIssue", () => { }); await expect( - createReactionForIssue(client, { issueId: "issue-1", emoji: "👍" }), + createReactionForIssue(client, { + issueId: asUuid("issue-1"), + emoji: "👍", + }), ).resolves.toEqual({ id: "r-2", emoji: "👍", @@ -78,7 +82,10 @@ describe("createReactionForIssue", () => { }); await expect( - createReactionForIssue(client, { issueId: "issue-1", emoji: "👍" }), + createReactionForIssue(client, { + issueId: asUuid("issue-1"), + emoji: "👍", + }), ).rejects.toThrow("Already reacted with emoji 👍"); }); @@ -103,7 +110,10 @@ describe("createReactionForIssue", () => { }); await expect( - createReactionForIssue(client, { issueId: "issue-1", emoji: " 👍 " }), + createReactionForIssue(client, { + issueId: asUuid("issue-1"), + emoji: " 👍 ", + }), ).rejects.toThrow("Already reacted with emoji 👍"); expect(client.request).toHaveBeenCalledTimes(2); }); @@ -118,7 +128,7 @@ describe("createReactionForIssue", () => { await expect( createReactionForIssue(client, { - issueId: "issue-missing", + issueId: asUuid("issue-missing"), emoji: "👍", }), ).rejects.toThrow('Issue with ID "issue-missing" not found'); @@ -159,7 +169,10 @@ describe("createReactionForComment", () => { }); await expect( - createReactionForComment(client, { commentId: "comment-1", emoji: "👍" }), + createReactionForComment(client, { + commentId: asUuid("comment-1"), + emoji: "👍", + }), ).resolves.toEqual({ id: "r-2", emoji: "👍", @@ -178,7 +191,7 @@ describe("createReactionForComment", () => { await expect( createReactionForComment(client, { - commentId: "comment-missing", + commentId: asUuid("comment-missing"), emoji: "👍", }), ).rejects.toThrow('Discussion comment ID "comment-missing" not found'); @@ -213,7 +226,7 @@ describe("deleteOwnReactionByEmoji", () => { await expect( deleteOwnReactionByEmoji(client, { kind: "comment", - id: "comment-1", + id: asUuid("comment-1"), emoji: "👍", }), ).resolves.toEqual({ id: "r-1", success: true }); @@ -246,7 +259,7 @@ describe("deleteOwnReactionByEmoji", () => { await expect( deleteOwnReactionByEmoji(client, { kind: "comment", - id: "comment-1", + id: asUuid("comment-1"), emoji: " 👍 ", }), ).resolves.toEqual({ id: "r-1", success: true }); @@ -276,7 +289,7 @@ describe("deleteOwnReactionByEmoji", () => { await expect( deleteOwnReactionByEmoji(client, { kind: "comment", - id: "comment-1", + id: asUuid("comment-1"), emoji: "👍", }), ).rejects.toThrow("No own reaction found with emoji 👍"); @@ -312,7 +325,7 @@ describe("deleteOwnReactionByEmoji", () => { await expect( deleteOwnReactionByEmoji(client, { kind: "comment", - id: "comment-1", + id: asUuid("comment-1"), emoji: "👍", }), ).rejects.toThrow("Multiple own reactions found with emoji 👍"); @@ -346,8 +359,8 @@ describe("deleteOwnReactionById", () => { await expect( deleteOwnReactionById(client, { kind: "issue", - id: "issue-1", - reactionId: "r-1", + id: asUuid("issue-1"), + reactionId: asUuid("r-1"), }), ).resolves.toEqual({ id: "r-1", success: true }); }); @@ -375,8 +388,8 @@ describe("deleteOwnReactionById", () => { await expect( deleteOwnReactionById(client, { kind: "issue", - id: "issue-1", - reactionId: "missing-reaction", + id: asUuid("issue-1"), + reactionId: asUuid("missing-reaction"), }), ).rejects.toThrow('Reaction "missing-reaction" not found'); }); @@ -404,8 +417,8 @@ describe("deleteOwnReactionById", () => { await expect( deleteOwnReactionById(client, { kind: "issue", - id: "issue-1", - reactionId: "r-1", + id: asUuid("issue-1"), + reactionId: asUuid("r-1"), }), ).rejects.toThrow('Reaction "r-1" is not owned by viewer'); }); diff --git a/tests/unit/services/team-service.test.ts b/tests/unit/services/team-service.test.ts index d659919a..ab87c316 100644 --- a/tests/unit/services/team-service.test.ts +++ b/tests/unit/services/team-service.test.ts @@ -1,12 +1,20 @@ // tests/unit/services/team-service.test.ts + import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; -import type { - TeamDetail, - TeamEstimateOption, - TeamEstimationSource, -} from "../../../src/common/types.js"; -import { getTeam, listTeams } from "../../../src/services/team-service.js"; +import { asUuid } from "../../../src/common/identifier.js"; +import { + addTeamMember, + createTeam, + getTeam, + listTeamMembers, + listTeams, + removeTeamMember, + type TeamDetail, + type TeamEstimateOption, + type TeamEstimationSource, + updateTeam, +} from "../../../src/services/team-service.js"; const assertTeamDetailShape = (value: TeamDetail): TeamDetail => value; const assertEstimateOption = (value: TeamEstimateOption): TeamEstimateOption => @@ -45,9 +53,9 @@ describe("listTeams", () => { }); const result = await listTeams(client); expect(result.nodes).toHaveLength(1); - expect(result.nodes[0].id).toBe("team-1"); - expect(result.nodes[0].key).toBe("ENG"); - expect(result.nodes[0].name).toBe("Engineering"); + expect(result.nodes[0]?.id).toBe("team-1"); + expect(result.nodes[0]?.key).toBe("ENG"); + expect(result.nodes[0]?.name).toBe("Engineering"); expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: "c1" }); }); @@ -128,7 +136,7 @@ describe("getTeam", () => { ]); const result = assertTeamDetailShape( - await getTeam(client, { id: "team-1" }), + await getTeam(client, { id: asUuid("team-1") }), ); expect(assertEstimationSource(result.estimationSource)).toBe("self"); @@ -206,7 +214,7 @@ describe("getTeam", () => { ]); const result = assertTeamDetailShape( - await getTeam(client, { id: "team-child" }), + await getTeam(client, { id: asUuid("team-child") }), ); expect(assertEstimationSource(result.estimationSource)).toBe("parent"); @@ -256,7 +264,7 @@ describe("getTeam", () => { }, ]); - const result = await getTeam(client, { id: "team-2" }); + const result = await getTeam(client, { id: asUuid("team-2") }); expect(result.validEstimates).toEqual([]); expect(result.estimationSource).toBe("self"); }); @@ -295,7 +303,7 @@ describe("getTeam", () => { }, ]); - const result = await getTeam(client, { id: "team-unknown" }); + const result = await getTeam(client, { id: asUuid("team-unknown") }); expect(result.validEstimates).toEqual([]); expect(result.estimationSource).toBe("self"); }); @@ -334,7 +342,7 @@ describe("getTeam", () => { }, ]); - const result = await getTeam(client, { id: "team-3" }); + const result = await getTeam(client, { id: asUuid("team-3") }); expect(result.validEstimates).toEqual([ { value: 1, label: "XS" }, { value: 2, label: "S" }, @@ -383,7 +391,7 @@ describe("getTeam", () => { .mockRejectedValueOnce(new Error("parent lookup failed")), } as unknown as GraphQLClient; - const result = await getTeam(client, { id: "team-child" }); + const result = await getTeam(client, { id: asUuid("team-child") }); expect(result.estimationSource).toBe("self_fallback"); expect(result.validEstimates).toEqual([ @@ -397,3 +405,248 @@ describe("getTeam", () => { ]); }); }); + +describe("createTeam", () => { + it("returns the created team", async () => { + const client = mockGqlClient({ + teamCreate: { + success: true, + team: { id: "team-new", key: "NEW", name: "New Team" }, + }, + }); + + const result = await createTeam(client, { name: "New Team", key: "NEW" }); + + expect(result.id).toBe("team-new"); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + input: { name: "New Team", key: "NEW" }, + }); + }); + + it("throws when the mutation fails", async () => { + const client = mockGqlClient({ + teamCreate: { success: false, team: null }, + }); + + await expect(createTeam(client, { name: "Fail" })).rejects.toThrow( + 'Failed to create team "Fail"', + ); + }); +}); + +describe("updateTeam", () => { + it("returns the updated team", async () => { + const client = mockGqlClient({ + teamUpdate: { + success: true, + team: { id: "team-1", key: "ENG", name: "Renamed" }, + }, + }); + + const result = await updateTeam(client, asUuid("team-1"), { + name: "Renamed", + }); + + expect(result.name).toBe("Renamed"); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + id: "team-1", + input: { name: "Renamed" }, + }); + }); + + it("throws when the mutation fails", async () => { + const client = mockGqlClient({ + teamUpdate: { success: false, team: null }, + }); + + await expect( + updateTeam(client, asUuid("team-1"), { name: "Renamed" }), + ).rejects.toThrow('Failed to update team "team-1"'); + }); +}); + +describe("listTeamMembers", () => { + it("returns the team's memberships", async () => { + const client = mockGqlClient({ + team: { + id: "team-1", + key: "ENG", + name: "Engineering", + memberships: { + nodes: [ + { id: "m1", owner: true, user: { id: "user-1", name: "Alice" } }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + + const result = await listTeamMembers(client, { id: asUuid("team-1") }); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.id).toBe("m1"); + }); + + it("paginates until all members are fetched", async () => { + const client = mockGqlClientWithSequence([ + { + team: { + id: "team-1", + key: "ENG", + name: "Engineering", + memberships: { + nodes: [ + { id: "m1", owner: true, user: { id: "user-1", name: "Alice" } }, + ], + pageInfo: { hasNextPage: true, endCursor: "cursor-1" }, + }, + }, + }, + { + team: { + id: "team-1", + key: "ENG", + name: "Engineering", + memberships: { + nodes: [ + { id: "m2", owner: false, user: { id: "user-2", name: "Bob" } }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + ]); + + const result = await listTeamMembers(client, { id: asUuid("team-1") }); + + expect(result.nodes.map((m) => m.id)).toEqual(["m1", "m2"]); + expect(client.request).toHaveBeenNthCalledWith(2, expect.anything(), { + id: "team-1", + after: "cursor-1", + }); + }); + + it("throws when the team is not found", async () => { + const client = mockGqlClient({ team: null }); + + await expect( + listTeamMembers(client, { id: asUuid("missing") }), + ).rejects.toThrow('Team "missing" not found'); + }); +}); + +describe("addTeamMember", () => { + it("returns the created membership", async () => { + const client = mockGqlClient({ + teamMembershipCreate: { + success: true, + teamMembership: { + id: "m1", + owner: false, + user: { id: "user-1", name: "Alice" }, + }, + }, + }); + + const result = await addTeamMember(client, { + teamId: asUuid("team-1"), + userId: asUuid("user-1"), + }); + + expect(result.id).toBe("m1"); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + input: { teamId: "team-1", userId: "user-1" }, + }); + }); + + it("passes owner when provided", async () => { + const client = mockGqlClient({ + teamMembershipCreate: { + success: true, + teamMembership: { + id: "m1", + owner: true, + user: { id: "user-1", name: "Alice" }, + }, + }, + }); + + await addTeamMember(client, { + teamId: asUuid("team-1"), + userId: asUuid("user-1"), + owner: true, + }); + + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + input: { teamId: "team-1", userId: "user-1", owner: true }, + }); + }); + + it("throws when the mutation fails", async () => { + const client = mockGqlClient({ + teamMembershipCreate: { success: false, teamMembership: null }, + }); + + await expect( + addTeamMember(client, { + teamId: asUuid("team-1"), + userId: asUuid("user-1"), + }), + ).rejects.toThrow('Failed to add user "user-1" to team "team-1"'); + }); +}); + +describe("removeTeamMember", () => { + it("resolves the membership id and deletes it", async () => { + const client = mockGqlClientWithSequence([ + { + team: { + id: "team-1", + key: "ENG", + name: "Engineering", + memberships: { + nodes: [ + { id: "m1", owner: false, user: { id: "user-1", name: "Alice" } }, + { id: "m2", owner: false, user: { id: "user-2", name: "Bob" } }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + { teamMembershipDelete: { success: true, entityId: "m2" } }, + ]); + + const result = await removeTeamMember(client, { + teamId: asUuid("team-1"), + userId: asUuid("user-2"), + }); + + expect(result).toEqual({ id: "m2", success: true }); + expect(client.request).toHaveBeenNthCalledWith(2, expect.anything(), { + id: "m2", + }); + }); + + it("throws when the user is not a team member", async () => { + const client = mockGqlClient({ + team: { + id: "team-1", + key: "ENG", + name: "Engineering", + memberships: { + nodes: [ + { id: "m1", owner: false, user: { id: "user-1", name: "Alice" } }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + + await expect( + removeTeamMember(client, { + teamId: asUuid("team-1"), + userId: asUuid("user-9"), + }), + ).rejects.toThrow('Team member "user-9" on team "team-1" not found'); + }); +}); diff --git a/tests/unit/services/user-service.test.ts b/tests/unit/services/user-service.test.ts index 0063b18b..f5d7fb63 100644 --- a/tests/unit/services/user-service.test.ts +++ b/tests/unit/services/user-service.test.ts @@ -21,8 +21,8 @@ describe("listUsers", () => { }, }); const result = await listUsers(client); - expect(result.nodes[0].name).toBe("Alice"); - expect(result.nodes[1].name).toBe("Zoe"); + expect(result.nodes[0]?.name).toBe("Alice"); + expect(result.nodes[1]?.name).toBe("Zoe"); }); it("returns empty result", async () => { diff --git a/tsconfig.json b/tsconfig.json index a4033c54..425e39b1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,12 +4,16 @@ "declaration": false, "declarationMap": false, "esModuleInterop": true, + "exactOptionalPropertyTypes": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true, "lib": ["ES2022", "DOM"], "module": "ESNext", "moduleResolution": "Bundler", "noEmitOnError": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noUncheckedIndexedAccess": true, "outDir": "./dist", "pretty": true, "removeComments": true, @@ -25,9 +29,9 @@ "exclude": [ "node_modules", "dist", - // Tests excluded from TypeScript compilation to prevent them from - // being compiled into dist/. Tests are type-checked and validated - // by Vitest at runtime, which provides sufficient type safety. + // Tests are excluded from the *build* so they are never emitted into + // dist/. They are NOT unchecked: tsconfig.test.json runs a dedicated + // `tsc --noEmit` over src + tests (see the "typecheck:test" script and CI). "tests", "**/*.test.ts", "**/*.spec.ts", diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 00000000..c4149329 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + // Allow importing the plain-CommonJS release scripts (scripts/release/*.cjs) + // from their tests without a declaration file. + "allowJs": true, + "types": ["node"] + }, + "include": [ + "src/**/*", + "tests/**/*", + "vitest.config.ts", + "vitest.base.config.ts", + "vitest.integration.config.ts" + ], + // Override the base config's exclude (which drops tests/ and *.test.ts) so + // the test files listed in "include" are actually type-checked here. + "exclude": ["node_modules", "dist"] +}