diff --git a/.github/workflows/ci-post-merge.yml b/.github/workflows/ci-post-merge.yml index b5c0d4af..8a2a6e5d 100644 --- a/.github/workflows/ci-post-merge.yml +++ b/.github/workflows/ci-post-merge.yml @@ -25,7 +25,7 @@ jobs: - name: Setup node v22 uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 cache: npm - name: Install deps diff --git a/.github/workflows/ci-validate.yml b/.github/workflows/ci-validate.yml index 0012041b..b5562e04 100644 --- a/.github/workflows/ci-validate.yml +++ b/.github/workflows/ci-validate.yml @@ -10,6 +10,7 @@ on: - synchronize - ready_for_review - reopened + - edited permissions: contents: read @@ -76,6 +77,32 @@ jobs: - name: TypeScript type check run: npx tsc --noEmit + commitlint: + name: Validate Commit Messages + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup node v22 + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: "npm" + + - name: Install deps + run: npm ci + + - name: Validate PR commit range + run: | + npx commitlint \ + --from "${{ github.event.pull_request.base.sha }}" \ + --to "${{ github.event.pull_request.head.sha }}" \ + --verbose + smoke-test: name: Run Package Install Smoke Test runs-on: ubuntu-latest @@ -88,7 +115,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 cache: "npm" - name: Install deps diff --git a/.github/workflows/release-promote-next-to-main.yml b/.github/workflows/release-promote-next-to-main.yml index 4536ad2e..3cf401cb 100644 --- a/.github/workflows/release-promote-next-to-main.yml +++ b/.github/workflows/release-promote-next-to-main.yml @@ -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@v2 + uses: actions/create-github-app-token@v3 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 ede79364..a2fa6816 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@v8 + uses: actions/github-script@v9 with: script: | const { owner, repo } = context.repo; @@ -73,7 +73,7 @@ jobs: - name: Create linearis-bot app token id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} @@ -100,7 +100,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 cache: npm registry-url: https://registry.npmjs.org diff --git a/.github/workflows/release-sync-main-back-to-next.yml b/.github/workflows/release-sync-main-back-to-next.yml index 27bffb40..0694fabb 100644 --- a/.github/workflows/release-sync-main-back-to-next.yml +++ b/.github/workflows/release-sync-main-back-to-next.yml @@ -27,7 +27,7 @@ jobs: - name: Create linearis-bot app token id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/AGENTS.md b/AGENTS.md index d5f07f5f..82724d7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,9 +61,10 @@ CLI Input → Command → Resolver → Service → JSON Output - Resolvers must not import services (or vice versa). - Commands must not import `GraphQLClient` directly. 3. **Client-layer contract:** - - Resolvers → `LinearSdkClient` only. + - 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). 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. @@ -83,8 +84,9 @@ Need a new GraphQL operation? Need to resolve a human-friendly ID? → Add/edit src/resolvers/*-resolver.ts - → Use LinearSdkClient, return UUID string + → 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) Need business logic / CRUD? → Add/edit src/services/*-service.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c2246f9..b64b0e33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,59 @@ +## [2026.4.9-next.8](https://github.com/linearis-oss/linearis/compare/v2026.4.9-next.7...v2026.4.9-next.8) (2026-04-27) + +### Bug Fixes + +* **comments:** constrain compatibility replies ([85a1d3a](https://github.com/linearis-oss/linearis/commit/85a1d3a1e2e1ecf46138632d637f00b046a1c12c)) + +## [2026.4.9-next.7](https://github.com/linearis-oss/linearis/compare/v2026.4.9-next.6...v2026.4.9-next.7) (2026-04-27) + +### Features + +* **cli:** add reaction workflows ([4d6a00c](https://github.com/linearis-oss/linearis/commit/4d6a00cbd9a2c9abe407d5071f3ccd5bbb81e01e)), closes [#83](https://github.com/linearis-oss/linearis/issues/83) +* **emoji:** add reaction input normalization ([bc82bc6](https://github.com/linearis-oss/linearis/commit/bc82bc6fa3e7c6ee64f6581b80206ec85bf8e9a8)), closes [#83](https://github.com/linearis-oss/linearis/issues/83) +* **graphql:** add reaction operations ([bd0ffac](https://github.com/linearis-oss/linearis/commit/bd0ffaccdba8659fedb6ae4074dc737e102b63b8)), closes [#83](https://github.com/linearis-oss/linearis/issues/83) +* **reactions:** add shared service ([0a29bb1](https://github.com/linearis-oss/linearis/commit/0a29bb1f6208a2ddb093f458a7d12d70a645ac91)), closes [#83](https://github.com/linearis-oss/linearis/issues/83) + +## [2026.4.9-next.6](https://github.com/linearis-oss/linearis/compare/v2026.4.9-next.5...v2026.4.9-next.6) (2026-04-27) + +### Bug Fixes + +* **ci:** rerun validation when PR base changes ([e92b7ca](https://github.com/linearis-oss/linearis/commit/e92b7cad13e667f25d2f2bc02901e50f94646a66)) +* resolve issue estimate team context via team resolver ([9c94ff9](https://github.com/linearis-oss/linearis/commit/9c94ff9fc5677e15380e94500933888a76f08e1d)) + +## [2026.4.9-next.5](https://github.com/linearis-oss/linearis/compare/v2026.4.9-next.4...v2026.4.9-next.5) (2026-04-27) + +### Bug Fixes + +* **deps:** update dependency @linear/sdk to v82 ([7e62fda](https://github.com/linearis-oss/linearis/commit/7e62fdacb56b9e14963303bb76dbd67a7056f554)) + +## [2026.4.9-next.4](https://github.com/linearis-oss/linearis/compare/v2026.4.9-next.3...v2026.4.9-next.4) (2026-04-25) + +### Features + +* **comments:** deprecate compatibility facade ([d097eee](https://github.com/linearis-oss/linearis/commit/d097eeee62dfa5446c866644ded70a779346b7e4)) +* **discussions:** add GraphQL and service layer ([1ae10e1](https://github.com/linearis-oss/linearis/commit/1ae10e1313722c58102c8269cc97886ed8891d8d)) +* **initiatives:** add discussion commands ([81bcd17](https://github.com/linearis-oss/linearis/commit/81bcd173f7caec48911e39738d6b4dd60672d7fc)) +* **issues:** add discussion commands ([6b3861c](https://github.com/linearis-oss/linearis/commit/6b3861cbfbdb6324270cda1a7977547e9253ff2e)) +* **projects:** add discussion commands ([ff64f2e](https://github.com/linearis-oss/linearis/commit/ff64f2edd7ceb5cdbc34de405b984582c86687d9)) + +## [2026.4.9-next.3](https://github.com/linearis-oss/linearis/compare/v2026.4.9-next.2...v2026.4.9-next.3) (2026-04-25) + +### Features + +* **issues:** batch-resolve search filter identifiers ([963af95](https://github.com/linearis-oss/linearis/commit/963af954dbc286f755f93b740b36fcca4626a2c4)), closes [#63](https://github.com/linearis-oss/linearis/issues/63) + +## [2026.4.9-next.2](https://github.com/linearis-oss/linearis/compare/v2026.4.9-next.1...v2026.4.9-next.2) (2026-04-24) + +### Features + +* **labels:** add issue label scope filters ([0c1874a](https://github.com/linearis-oss/linearis/commit/0c1874aad8cbadf6e4d729ad0940f1f3bcdd4106)), closes [#116](https://github.com/linearis-oss/linearis/issues/116) + +## [2026.4.9-next.1](https://github.com/linearis-oss/linearis/compare/v2026.4.8...v2026.4.9-next.1) (2026-04-24) + +### Bug Fixes + +* **issues:** honor explicit completed status filters ([607c954](https://github.com/linearis-oss/linearis/commit/607c954bb861519c9be5b5499c07844262b2b8de)), closes [#179](https://github.com/linearis-oss/linearis/issues/179) + ## [2026.4.8](https://github.com/linearis-oss/linearis/compare/v2026.4.7...v2026.4.8) (2026-04-23) ### Features diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4fbdf995..01464a42 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,6 +86,8 @@ type(scope): description | `test` | Adding or fixing tests | | `build` | Build system, dependencies | | `chore` | Maintenance, tooling | +| `ci` | CI workflow and automation changes | +| `revert` | Revert a previous commit | **Examples:** @@ -97,6 +99,12 @@ docs: update README with new commands Use imperative mood ("add" not "added"). Scope is optional. +Additional validation rules enforced locally and in CI: +- scopes must be lower-case (`feat(api): ...`, not `feat(API): ...`) +- subjects must be at least 10 characters long +- commit bodies and footers must be separated from the subject by a blank line when present +- PR commit ranges are validated in CI with commitlint, not only via the local hook + ## Linearis is opinionated, because its maintainer is I wish times were better and I wouldn't have to mention it, but they aren't and unfortunately, I do: diff --git a/README.md b/README.md index f5586e96..f1a2b059 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,7 @@ CLI tool for [Linear.app](https://linear.app) optimized for AI agents. JSON outp 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. -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, comments, cycles, documents, and files. If you need to manage custom workflows, integrations, or workspace settings, the MCP is the better choice. - -**This project scratches my own itches,** and satisfies my own usage patterns of working with Linear: I **do** work with tickets/issues and comments on the command line; I **do not** manage projects or workspaces etc. there. YMMV. +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. ## Installation @@ -68,8 +66,17 @@ linearis issues search "authentication bug" # Create an issue linearis issues create "Fix login flow" --team Platform --priority 2 -# Add a comment -linearis comments create ENG-42 --body "Investigating this now" +# Start a discussion thread on an issue +linearis issues discuss ENG-42 --body "Investigating this now" + +# List root discussion threads for an issue +linearis issues discussions ENG-42 + +# List replies in one root thread +linearis issues replies 6f4f28cd-4f53-4d76-ae95-80f1b6f6b87e + +# Reply to a thread (use a root discussion thread ID) +linearis issues reply 6f4f28cd-4f53-4d76-ae95-80f1b6f6b87e --body "I found the root cause" ``` For the full reference of every command and flag, run: @@ -78,6 +85,24 @@ For the full reference of every command and flag, run: 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: + +| Deprecated | Preferred | +|---|---| +| `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 ` | + +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 ### How agents use Linearis @@ -95,7 +120,7 @@ This means the agent never loads the full API surface into context. It pays for | | Linearis | Linear MCP | |---|---|---| | Context cost | ~500-700 tokens per interaction | ~13k tokens on connect | -| Coverage | Common operations (issues, comments, cycles, docs, files) | Full Linear API | +| 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 | @@ -116,9 +141,9 @@ 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, add a comment instead of editing 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 comments or descriptions. +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. ``` 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. diff --git a/commitlint.config.js b/commitlint.config.js index fa584fb6..b8db6daa 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -1 +1,26 @@ -export default { extends: ["@commitlint/config-conventional"] }; +export default { + extends: ["@commitlint/config-conventional"], + rules: { + "body-leading-blank": [2, "always"], + "footer-leading-blank": [2, "always"], + "scope-case": [2, "always", "lower-case"], + "subject-min-length": [2, "always", 10], + "type-enum": [ + 2, + "always", + [ + "build", + "chore", + "ci", + "docs", + "feat", + "fix", + "perf", + "refactor", + "revert", + "style", + "test", + ], + ], + }, +}; diff --git a/graphql/mutations/discussions.graphql b/graphql/mutations/discussions.graphql new file mode 100644 index 00000000..e728bedb --- /dev/null +++ b/graphql/mutations/discussions.graphql @@ -0,0 +1,62 @@ +fragment DiscussionMutationCommentFields on Comment { + id + body + createdAt + editedAt + parentId + resolvedAt + resolvingComment { + id + } + resolvingUser { + id + displayName + } + user { + id + displayName + } +} + +mutation StartDiscussion($input: CommentCreateInput!) { + commentCreate(input: $input) { + success + comment { + ...DiscussionMutationCommentFields + } + } +} + +mutation EditDiscussionReply($id: String!, $input: CommentUpdateInput!) { + commentUpdate(id: $id, input: $input) { + success + comment { + ...DiscussionMutationCommentFields + } + } +} + +mutation DeleteDiscussionReply($id: String!) { + commentDelete(id: $id) { + success + entityId + } +} + +mutation ResolveDiscussion($id: String!, $resolvingCommentId: String) { + commentResolve(id: $id, resolvingCommentId: $resolvingCommentId) { + success + comment { + ...DiscussionMutationCommentFields + } + } +} + +mutation UnresolveDiscussion($id: String!) { + commentUnresolve(id: $id) { + success + comment { + ...DiscussionMutationCommentFields + } + } +} diff --git a/graphql/mutations/reactions.graphql b/graphql/mutations/reactions.graphql new file mode 100644 index 00000000..de269553 --- /dev/null +++ b/graphql/mutations/reactions.graphql @@ -0,0 +1,35 @@ +fragment ReactionMutationFields on Reaction { + id + emoji + user { + id + displayName + } + externalUser { + id + name + } +} + +mutation CreateReaction($input: ReactionCreateInput!) { + reactionCreate(input: $input) { + success + reaction { + ...ReactionMutationFields + issue { + id + } + comment { + id + parentId + } + } + } +} + +mutation DeleteReaction($id: String!) { + reactionDelete(id: $id) { + success + entityId + } +} diff --git a/graphql/queries/discussions.graphql b/graphql/queries/discussions.graphql new file mode 100644 index 00000000..9c5f09ee --- /dev/null +++ b/graphql/queries/discussions.graphql @@ -0,0 +1,295 @@ +fragment DiscussionCommentFields on Comment { + id + body + createdAt + editedAt + parentId + resolvedAt + resolvingComment { + id + } + resolvingUser { + id + displayName + } + user { + id + displayName + } +} + +fragment DiscussionCommentFieldsWithReactions on Comment { + ...DiscussionCommentFields + reactions { + ...ReactionReadFields + } +} + +query GetDiscussionCommentContext($id: String!) { + comment(id: $id) { + ...DiscussionCommentFields + issueId + projectId + initiativeId + } +} + +query ListIssueDiscussionRoots($issueId: String!, $first: Int, $after: String) { + issue(id: $issueId) { + comments(first: $first, after: $after, filter: { parent: { null: true } }) { + nodes { + ...DiscussionCommentFields + } + pageInfo { + hasNextPage + endCursor + } + } + } +} + +query ListIssueDiscussionRootsWithReactions( + $issueId: String! + $first: Int + $after: String +) { + issue(id: $issueId) { + comments(first: $first, after: $after, filter: { parent: { null: true } }) { + nodes { + ...DiscussionCommentFieldsWithReactions + } + pageInfo { + hasNextPage + endCursor + } + } + } +} + +query ListProjectDiscussionRoots( + $projectId: String! + $first: Int + $after: String +) { + project(id: $projectId) { + comments(first: $first, after: $after, filter: { parent: { null: true } }) { + nodes { + ...DiscussionCommentFields + } + pageInfo { + hasNextPage + endCursor + } + } + } +} + +query ListProjectDiscussionRootsWithReactions( + $projectId: String! + $first: Int + $after: String +) { + project(id: $projectId) { + comments(first: $first, after: $after, filter: { parent: { null: true } }) { + nodes { + ...DiscussionCommentFieldsWithReactions + } + pageInfo { + hasNextPage + endCursor + } + } + } +} + +query ListInitiativeDiscussionRoots( + $initiativeId: ID! + $initiativeLookupId: String! + $first: Int + $after: String +) { + initiative: initiative(id: $initiativeLookupId) { + id + } + comments( + first: $first + after: $after + filter: { + initiative: { id: { eq: $initiativeId } } + parent: { null: true } + } + ) { + nodes { + ...DiscussionCommentFields + } + pageInfo { + hasNextPage + endCursor + } + } +} + +query ListInitiativeDiscussionRootsWithReactions( + $initiativeId: ID! + $initiativeLookupId: String! + $first: Int + $after: String +) { + initiative: initiative(id: $initiativeLookupId) { + id + } + comments( + first: $first + after: $after + filter: { + initiative: { id: { eq: $initiativeId } } + parent: { null: true } + } + ) { + nodes { + ...DiscussionCommentFieldsWithReactions + } + pageInfo { + hasNextPage + endCursor + } + } +} + +query ListIssueDiscussionReplyCandidates( + $issueId: ID! + $first: Int + $after: String +) { + comments( + first: $first + after: $after + orderBy: createdAt + filter: { issue: { id: { eq: $issueId } }, parent: { null: false } } + ) { + nodes { + ...DiscussionCommentFields + } + pageInfo { + hasNextPage + endCursor + } + } +} + +query ListIssueDiscussionReplyCandidatesWithReactions( + $issueId: ID! + $first: Int + $after: String +) { + comments( + first: $first + after: $after + orderBy: createdAt + filter: { issue: { id: { eq: $issueId } }, parent: { null: false } } + ) { + nodes { + ...DiscussionCommentFieldsWithReactions + } + pageInfo { + hasNextPage + endCursor + } + } +} + +query ListProjectDiscussionReplyCandidates( + $projectId: ID! + $first: Int + $after: String +) { + comments( + first: $first + after: $after + orderBy: createdAt + filter: { project: { id: { eq: $projectId } }, parent: { null: false } } + ) { + nodes { + ...DiscussionCommentFields + } + pageInfo { + hasNextPage + endCursor + } + } +} + +query ListProjectDiscussionReplyCandidatesWithReactions( + $projectId: ID! + $first: Int + $after: String +) { + comments( + first: $first + after: $after + orderBy: createdAt + filter: { project: { id: { eq: $projectId } }, parent: { null: false } } + ) { + nodes { + ...DiscussionCommentFieldsWithReactions + } + pageInfo { + hasNextPage + endCursor + } + } +} + +query ListInitiativeDiscussionReplyCandidates( + $initiativeId: ID! + $first: Int + $after: String +) { + comments( + first: $first + after: $after + orderBy: createdAt + filter: { + initiative: { id: { eq: $initiativeId } } + parent: { null: false } + } + ) { + nodes { + ...DiscussionCommentFields + } + pageInfo { + hasNextPage + endCursor + } + } +} + +query ListInitiativeDiscussionReplyCandidatesWithReactions( + $initiativeId: ID! + $first: Int + $after: String +) { + comments( + first: $first + after: $after + orderBy: createdAt + filter: { + initiative: { id: { eq: $initiativeId } } + parent: { null: false } + } + ) { + nodes { + ...DiscussionCommentFieldsWithReactions + } + pageInfo { + hasNextPage + endCursor + } + } +} + +query GetDiscussionComment($id: String!) { + comment(id: $id) { + ...DiscussionCommentFields + } +} diff --git a/graphql/queries/issues.graphql b/graphql/queries/issues.graphql index 2f365653..2afbf99e 100644 --- a/graphql/queries/issues.graphql +++ b/graphql/queries/issues.graphql @@ -136,6 +136,14 @@ fragment CompleteIssueWithCommentsFields on Issue { } } +# Complete issue fragment with all relationships, default comments, and reactions +fragment CompleteIssueWithReactionsFields on Issue { + ...CompleteIssueWithDefaultCommentsFields + reactions { + ...ReactionReadFields + } +} + # Complete issue search fragment with all relationships # # Combines all issue fragments into a comprehensive field selection. @@ -285,6 +293,25 @@ query GetIssueByIdentifierWithComments($teamKey: String!, $number: Float!) { } } +# Get single issue by UUID with root reactions +query GetIssueByIdWithReactions($id: String!) { + issue(id: $id) { + ...CompleteIssueWithReactionsFields + } +} + +# Get issue by identifier with root reactions +query GetIssueByIdentifierWithReactions($teamKey: String!, $number: Float!) { + issues( + filter: { team: { key: { eq: $teamKey } }, number: { eq: $number } } + first: 1 + ) { + nodes { + ...CompleteIssueWithReactionsFields + } + } +} + # Get issue team by issue ID # # Fetches the team associated with an issue by its ID. @@ -353,26 +380,50 @@ query FilteredSearchIssues( # Comprehensive batch resolve for update operations # # Resolves all necessary entity references in a single batch query -# before issue update. Includes labels, projects, teams, and parent issues. -# This prevents N+1 queries during update operations. +# before issue update. query BatchResolveForUpdate( - $labelNames: [String!] + $assigneeQuery: String $projectName: String + $projectId: ID + $labelNames: [String!] + $statusName: String + $cycleName: String $teamKey: String - $issueNumber: Float + $teamId: ID $milestoneName: String + $parentTeamKey: String + $parentIssueNumber: Float ) { - # Resolve labels if provided - labels: issueLabels(filter: { name: { in: $labelNames } }) { + assignees: users( + filter: { + or: [ + { displayName: { eqIgnoreCase: $assigneeQuery } } + { email: { eqIgnoreCase: $assigneeQuery } } + ] + } + first: 10 + ) { nodes { id name - isGroup - parent { - id - name - } - children { + email + displayName + } + } + + projects( + filter: { + or: [{ name: { eqIgnoreCase: $projectName } }, { id: { eq: $projectId } }] + } + first: 10 + ) { + nodes { + id + name + projectMilestones( + filter: { name: { eqIgnoreCase: $milestoneName } } + first: 10 + ) { nodes { id name @@ -381,37 +432,71 @@ query BatchResolveForUpdate( } } - # Resolve project if provided (case-insensitive to be user-friendly) - projects(filter: { name: { eqIgnoreCase: $projectName } }, first: 1) { + labels: issueLabels(filter: { name: { in: $labelNames } }) { nodes { id name - projectMilestones { - nodes { - id - name + } + } + + statuses: workflowStates( + filter: { + and: [ + { name: { eqIgnoreCase: $statusName } } + { + or: [ + { team: { key: { eq: $teamKey } } } + { team: { id: { eq: $teamId } } } + ] } + ] + } + first: 10 + ) { + nodes { + id + name + team { + id + key } } } - # Resolve milestone if provided (standalone query in case no project context) - milestones: projectMilestones( - filter: { name: { eq: $milestoneName } } - first: 1 + cycles( + filter: { + and: [ + { name: { eq: $cycleName } } + { + or: [ + { team: { key: { eq: $teamKey } } } + { team: { id: { eq: $teamId } } } + ] + } + ] + } + first: 10 ) { nodes { id name + isActive + isNext + isPrevious + number + startsAt + team { + id + key + } } } - # Resolve issue identifier if provided - issues( + parentIssues: issues( filter: { and: [ - { team: { key: { eq: $teamKey } } } - { number: { eq: $issueNumber } } + { team: { key: { eq: $parentTeamKey } } } + { number: { eq: $parentIssueNumber } } ] } first: 1 @@ -419,47 +504,172 @@ query BatchResolveForUpdate( nodes { id identifier - labels { + } + } +} + +# Comprehensive batch resolve for create operations +# +# Resolves all entity references needed for issue creation in a single +# batch query. +query BatchResolveForCreate( + $teamKey: String + $teamName: String + $teamId: ID + $assigneeQuery: String + $projectName: String + $projectId: ID + $labelNames: [String!] + $statusName: String + $cycleName: String + $milestoneName: String + $parentTeamKey: String + $parentIssueNumber: Float +) { + teams( + filter: { or: [{ key: { eq: $teamKey } }, { name: { eq: $teamName } }] } + first: 10 + ) { + nodes { + id + key + name + } + } + + assignees: users( + filter: { + or: [ + { displayName: { eqIgnoreCase: $assigneeQuery } } + { email: { eqIgnoreCase: $assigneeQuery } } + ] + } + first: 10 + ) { + nodes { + id + name + email + displayName + } + } + + projects( + filter: { + or: [{ name: { eqIgnoreCase: $projectName } }, { id: { eq: $projectId } }] + } + first: 10 + ) { + nodes { + id + name + projectMilestones( + filter: { name: { eqIgnoreCase: $milestoneName } } + first: 10 + ) { nodes { id name } } + } + } + + labels: issueLabels(filter: { name: { in: $labelNames } }) { + nodes { + id + name + } + } + + statuses: workflowStates( + filter: { + and: [ + { name: { eqIgnoreCase: $statusName } } + { + or: [ + { team: { key: { eq: $teamKey } } } + { team: { name: { eq: $teamName } } } + { team: { id: { eq: $teamId } } } + ] + } + ] + } + first: 10 + ) { + nodes { + id + name team { id key - name } - project { - id - projectMilestones { - nodes { - id - name - } + } + } + + cycles( + filter: { + and: [ + { name: { eq: $cycleName } } + { + or: [ + { team: { key: { eq: $teamKey } } } + { team: { name: { eq: $teamName } } } + { team: { id: { eq: $teamId } } } + ] } + ] + } + first: 10 + ) { + nodes { + id + name + isActive + isNext + isPrevious + number + startsAt + team { + id + key } } } + + parentIssues: issues( + filter: { + and: [ + { team: { key: { eq: $parentTeamKey } } } + { number: { eq: $parentIssueNumber } } + ] + } + first: 1 + ) { + nodes { + id + identifier + } + } } -# Comprehensive batch resolve for create operations -# -# Resolves all entity references needed for issue creation in a single -# batch query. Prevents N+1 queries during issue creation by -# pre-resolving teams, projects, labels, and parent issues. -query BatchResolveForCreate( +query BatchResolveForSearch( $teamKey: String $teamName: String + $teamId: ID + $assigneeQuery: String + $creatorQuery: String $projectName: String + $projectId: ID $labelNames: [String!] + $cycleName: String $parentTeamKey: String $parentIssueNumber: Float + $milestoneName: String ) { - # Resolve team if provided teams( filter: { or: [{ key: { eq: $teamKey } }, { name: { eq: $teamName } }] } - first: 1 + first: 10 ) { nodes { id @@ -468,41 +678,118 @@ query BatchResolveForCreate( } } - # Resolve project if provided (case-insensitive to be user-friendly) - projects(filter: { name: { eqIgnoreCase: $projectName } }, first: 1) { + assignees: users( + filter: { + or: [ + { displayName: { eqIgnoreCase: $assigneeQuery } } + { email: { eqIgnoreCase: $assigneeQuery } } + ] + } + first: 10 + ) { + nodes { + id + name + email + displayName + } + } + + creators: users( + filter: { + or: [ + { displayName: { eqIgnoreCase: $creatorQuery } } + { email: { eqIgnoreCase: $creatorQuery } } + ] + } + first: 10 + ) { + nodes { + id + name + email + displayName + } + } + + projects( + filter: { + or: [{ name: { eqIgnoreCase: $projectName } }, { id: { eq: $projectId } }] + } + first: 10 + ) { nodes { id name - projectMilestones { + projectMilestones( + filter: { name: { eqIgnoreCase: $milestoneName } } + first: 10 + ) { nodes { id name } } - # Projects don't own cycles directly, but include teams for context if needed } } - # Resolve labels if provided labels: issueLabels(filter: { name: { in: $labelNames } }) { nodes { id name - isGroup - parent { + } + } + + statuses: workflowStates( + filter: { + or: [ + { team: { key: { eq: $teamKey } } } + { team: { name: { eq: $teamName } } } + { team: { id: { eq: $teamId } } } + ] + } + first: 50 + ) { + nodes { + id + name + team { id - name + key } - children { - nodes { - id - name + } + } + + cycles( + filter: { + and: [ + { name: { eq: $cycleName } } + { + or: [ + { team: { key: { eq: $teamKey } } } + { team: { name: { eq: $teamName } } } + { team: { id: { eq: $teamId } } } + ] } + ] + } + first: 10 + ) { + nodes { + id + name + isActive + isNext + isPrevious + number + startsAt + team { + id + key } } } - # Resolve parent issue if provided parentIssues: issues( filter: { and: [ @@ -517,8 +804,35 @@ query BatchResolveForCreate( identifier } } +} + +query BatchResolveIssueLabelsPage($first: Int!, $after: String) { + issueLabels(first: $first, after: $after) { + nodes { + id + name + } + pageInfo { + hasNextPage + endCursor + } + } +} - # Resolve cycles by name (team-scoped lookup is preferred but we also provide global fallback) +query BatchResolveWorkflowStatesPage($first: Int!, $after: String) { + workflowStates(first: $first, after: $after) { + nodes { + id + name + team { + id + } + } + pageInfo { + hasNextPage + endCursor + } + } } # Complete issue fragment with attachments diff --git a/graphql/queries/projects.graphql b/graphql/queries/projects.graphql index b8e12e31..9b3e8a5f 100644 --- a/graphql/queries/projects.graphql +++ b/graphql/queries/projects.graphql @@ -110,6 +110,26 @@ query GetProjects($first: Int = 50, $after: String) { } } +# Project detail fields for opt-in reaction-aware reads +# +# Root project reactions are intentionally unsupported by the current schema. +# Keep the opt-in variant explicit for callers without changing the core +# project contract. Only paginated root discussion comments are included here +# with reactions and pageInfo. Replies are intentionally excluded and must be +# fetched via the dedicated project discussion queries. +fragment ProjectDetailFieldsWithReactions on Project { + ...ProjectDetailFields + comments(first: $first, after: $after, filter: { parent: { null: true } }) { + nodes { + ...DiscussionCommentFieldsWithReactions + } + pageInfo { + hasNextPage + endCursor + } + } +} + # Get a single project by ID # # Fetches complete project details including content, members, @@ -123,6 +143,16 @@ query GetProject($id: String!) { } } +# Get a single project by ID for opt-in reaction-aware reads +# +# Includes only paginated root discussion comments with reactions. Replies are +# intentionally excluded here and must come from dedicated discussion queries. +query GetProjectWithReactions($id: String!, $first: Int, $after: String) { + project(id: $id) { + ...ProjectDetailFieldsWithReactions + } +} + # List all project statuses in the workspace # # Fetches project statuses for name-to-UUID resolution. diff --git a/graphql/queries/reactions.graphql b/graphql/queries/reactions.graphql new file mode 100644 index 00000000..f943dbfd --- /dev/null +++ b/graphql/queries/reactions.graphql @@ -0,0 +1,35 @@ +# Root project and initiative reactions are intentionally omitted. +# Current generated schema exposes ReactionCreateInput.issueId and commentId, +# but not projectId or initiativeId for root entity reactions. + +fragment ReactionReadFields on Reaction { + id + emoji + user { + id + displayName + } + externalUser { + id + name + } +} + +query GetIssueReactions($id: String!) { + issue(id: $id) { + id + reactions { + ...ReactionReadFields + } + } +} + +query GetCommentReactions($id: String!) { + comment(id: $id) { + id + parentId + reactions { + ...ReactionReadFields + } + } +} diff --git a/package-lock.json b/package-lock.json index b90812a6..5ae65282 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,20 @@ { "name": "linearis", - "version": "2026.4.8", + "version": "2026.4.9-next.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.4.8", + "version": "2026.4.9-next.8", "license": "MIT", "dependencies": { - "@linear/sdk": "81.0.0", - "commander": "14.0.3" + "@linear/sdk": "82.1.0", + "commander": "14.0.3", + "node-emoji": "2.2.0" }, "bin": { + "linear": "dist/main.js", "linearis": "dist/main.js" }, "devDependencies": { @@ -27,8 +29,8 @@ "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/exec": "^7.1.0", "@semantic-release/git": "^10.0.1", - "@semantic-release/github": "^11.0.1", - "@semantic-release/npm": "^12.0.2", + "@semantic-release/github": "^12.0.0", + "@semantic-release/npm": "^13.0.0", "@semantic-release/release-notes-generator": "^14.1.0", "@types/node": "^24.0.0", "@vitest/coverage-v8": "^4.0.0", @@ -425,9 +427,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.12.tgz", - "integrity": "sha512-Rro7adQl3NLq/zJCIL98eElXKI8eEiBtoeu5TbXF/U3qbjuSc7Jb5rjUbeHHcquDWeSf3HnGP7XI5qGrlRk/pA==", + "version": "2.4.13", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.13.tgz", + "integrity": "sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -441,20 +443,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.4.12", - "@biomejs/cli-darwin-x64": "2.4.12", - "@biomejs/cli-linux-arm64": "2.4.12", - "@biomejs/cli-linux-arm64-musl": "2.4.12", - "@biomejs/cli-linux-x64": "2.4.12", - "@biomejs/cli-linux-x64-musl": "2.4.12", - "@biomejs/cli-win32-arm64": "2.4.12", - "@biomejs/cli-win32-x64": "2.4.12" + "@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" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.12.tgz", - "integrity": "sha512-BnMU4Pc3ciEVteVpZ0BK33MLr7X57F5w1dwDLDn+/iy/yTrA4Q/N2yftidFtsA4vrDh0FMXDpacNV/Tl3fbmng==", + "version": "2.4.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.13.tgz", + "integrity": "sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg==", "cpu": [ "arm64" ], @@ -469,9 +471,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.12.tgz", - "integrity": "sha512-x9uJ0bI1rJsWICp3VH8w/5PnAVD3A7SqzDpbrfoUQX1QyWrK5jSU4fRLo/wSgGeplCivbxBRKmt5Xq4/nWvq8A==", + "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==", "cpu": [ "x64" ], @@ -486,13 +488,16 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.12.tgz", - "integrity": "sha512-tOwuCuZZtKi1jVzbk/5nXmIsziOB6yqN8c9r9QM0EJYPU6DpQWf11uBOSCfFKKM4H3d9ZoarvlgMfbcuD051Pw==", + "version": "2.4.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.13.tgz", + "integrity": "sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -503,13 +508,16 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.12.tgz", - "integrity": "sha512-FhfpkAAlKL6kwvcVap0Hgp4AhZmtd3YImg0kK1jd7C/aSoh4SfsB2f++yG1rU0lr8Y5MCFJrcSkmssiL9Xnnig==", + "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==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -520,13 +528,16 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.12.tgz", - "integrity": "sha512-8pFeAnLU9QdW9jCIslB/v82bI0lhBmz2ZAKc8pVMFPO0t0wAHsoEkrUQUbMkIorTRIjbqyNZHA3lEXavsPWYSw==", + "version": "2.4.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.13.tgz", + "integrity": "sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -537,13 +548,16 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.12.tgz", - "integrity": "sha512-dwTIgZrGutzhkQCuvHynCkyW6hJxUuyZqKKO0YNfaS2GUoRO+tOvxXZqZB6SkWAOdfZTzwaw8IEdUnIkHKHoew==", + "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==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -554,9 +568,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.12.tgz", - "integrity": "sha512-B0DLnx0vA9ya/3v7XyCaP+/lCpnbWbMOfUFFve+xb5OxyYvdHaS55YsSddr228Y+JAFk58agCuZTsqNiw2a6ig==", + "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==", "cpu": [ "arm64" ], @@ -571,9 +585,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.12.tgz", - "integrity": "sha512-yMckRzTyZ83hkk8iDFWswqSdU8tvZxspJKnYNh7JZr/zhZNOlzH13k4ecboU6MurKExCe2HUkH75pGI/O2JwGA==", + "version": "2.4.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.13.tgz", + "integrity": "sha512-tTcMkXyBrmHi9BfrD2VNHs/5rYIUKETqsBlYOvSAABwBkJhSDVb5e7wPukftsQbO3WzQkXe6kaztC6WtUOXSoQ==", "cpu": [ "x64" ], @@ -882,9 +896,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, @@ -894,9 +908,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, @@ -1426,9 +1440,9 @@ } }, "node_modules/@graphql-codegen/cli": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-6.3.0.tgz", - "integrity": "sha512-tlzSaM2oSnG6x8+QVc+cJ7NMJe+CN4tnSm/B8Uny/IpgSkAqP+RG8xaDxnrzwQZ+lz1ZXrBkNM6vzAGZhOaOGw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-6.3.1.tgz", + "integrity": "sha512-I5KkyX1SgQZPojMeQTRydB6fml4cysZq/mIdhNW4rmqdoOcTgdMPq1Tl+wtRp1VpBAOrBazJUJh1nAqJMMSPIQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2659,9 +2673,9 @@ } }, "node_modules/@linear/sdk": { - "version": "81.0.0", - "resolved": "https://registry.npmjs.org/@linear/sdk/-/sdk-81.0.0.tgz", - "integrity": "sha512-9WYd4eRbFTFNLlWU625/aKLzSu5QfOZ7cYuoxkGZbCB44/8aEOQyCzjOifeSWvYgSMCoO0jF4+XnVtZjC5bf8g==", + "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" @@ -2671,9 +2685,9 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", - "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "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==", "dev": true, "license": "MIT", "optional": true, @@ -2793,13 +2807,13 @@ "license": "MIT" }, "node_modules/@octokit/plugin-paginate-rest": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-13.2.1.tgz", - "integrity": "sha512-Tj4PkZyIL6eBMYcG/76QGsedF0+dWVeLhYprTmuFVVxzDW7PQh23tM0TP0z+1MvSkxB29YFZwnUX+cXfTiSdyw==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/types": "^15.0.1" + "@octokit/types": "^16.0.0" }, "engines": { "node": ">= 20" @@ -2808,23 +2822,6 @@ "@octokit/core": ">=6" } }, - "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-26.0.0.tgz", - "integrity": "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-15.0.2.tgz", - "integrity": "sha512-rR+5VRjhYSer7sC51krfCctQhVTmjyUMAaShfPB8mscVa8tSoLyon3coxQmXu0ahJoLVWl8dSGD/3OGZlFV44Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^26.0.0" - } - }, "node_modules/@octokit/plugin-retry": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-8.1.0.tgz", @@ -2902,9 +2899,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", - "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "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": { @@ -2971,9 +2968,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "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==", "cpu": [ "arm64" ], @@ -2988,9 +2985,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "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==", "cpu": [ "arm64" ], @@ -3005,9 +3002,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", - "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "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==", "cpu": [ "x64" ], @@ -3022,9 +3019,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", - "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "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==", "cpu": [ "x64" ], @@ -3039,9 +3036,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", - "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "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==", "cpu": [ "arm" ], @@ -3056,13 +3053,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "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==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3073,13 +3073,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", - "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "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==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3090,13 +3093,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "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==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3107,13 +3113,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "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==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3124,13 +3133,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "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==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3141,13 +3153,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", - "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "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==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3158,9 +3173,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "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==", "cpu": [ "arm64" ], @@ -3175,9 +3190,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", - "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "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==", "cpu": [ "wasm32" ], @@ -3185,18 +3200,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.9.2", - "@emnapi/runtime": "1.9.2", - "@napi-rs/wasm-runtime": "^1.1.3" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", - "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "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==", "cpu": [ "arm64" ], @@ -3211,9 +3226,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", - "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "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==", "cpu": [ "x64" ], @@ -3228,9 +3243,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", - "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "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==", "dev": true, "license": "MIT" }, @@ -3489,14 +3504,14 @@ } }, "node_modules/@semantic-release/github": { - "version": "11.0.6", - "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-11.0.6.tgz", - "integrity": "sha512-ctDzdSMrT3H+pwKBPdyCPty6Y47X8dSrjd3aPZ5KKIKKWTwZBE9De8GtsH3TyAlw3Uyo2stegMx6rJMXKpJwJA==", + "version": "12.0.6", + "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.6.tgz", + "integrity": "sha512-aYYFkwHW3c6YtHwQF0t0+lAjlU+87NFOZuH2CvWFD0Ylivc7MwhZMiHOJ0FMpIgPpCVib/VUAcOwvrW0KnxQtA==", "dev": true, "license": "MIT", "dependencies": { "@octokit/core": "^7.0.0", - "@octokit/plugin-paginate-rest": "^13.0.0", + "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-retry": "^8.0.0", "@octokit/plugin-throttling": "^11.0.0", "@semantic-release/error": "^4.0.0", @@ -3510,10 +3525,11 @@ "mime": "^4.0.0", "p-filter": "^4.0.0", "tinyglobby": "^0.2.14", + "undici": "^7.0.0", "url-join": "^5.0.0" }, "engines": { - "node": ">=20.8.1" + "node": "^22.14.0 || >= 24.10.0" }, "peerDependencies": { "semantic-release": ">=24.1.0" @@ -3576,28 +3592,30 @@ } }, "node_modules/@semantic-release/npm": { - "version": "12.0.2", - "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-12.0.2.tgz", - "integrity": "sha512-+M9/Lb35IgnlUO6OSJ40Ie+hUsZLuph2fqXC/qrKn0fMvUU/jiCjpoL6zEm69vzcmaZJ8yNKtMBEKHWN49WBbQ==", + "version": "13.1.5", + "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-13.1.5.tgz", + "integrity": "sha512-Hq5UxzoatN3LHiq2rTsWS54nCdqJHlsssGERCo8WlvdfFA9LoN0vO+OuKVSjtNapIc/S8C2LBj206wKLHg62mg==", "dev": true, "license": "MIT", "dependencies": { + "@actions/core": "^3.0.0", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", + "env-ci": "^11.2.0", "execa": "^9.0.0", "fs-extra": "^11.0.0", "lodash-es": "^4.17.21", "nerf-dart": "^1.0.0", - "normalize-url": "^8.0.0", - "npm": "^10.9.3", + "normalize-url": "^9.0.0", + "npm": "^11.6.2", "rc": "^1.2.8", - "read-pkg": "^9.0.0", + "read-pkg": "^10.0.0", "registry-auth-token": "^5.0.0", "semver": "^7.1.2", "tempy": "^3.0.0" }, "engines": { - "node": ">=20.8.1" + "node": "^22.14.0 || >= 24.10.0" }, "peerDependencies": { "semantic-release": ">=20.1.0" @@ -3690,6 +3708,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@semantic-release/npm/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==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/@semantic-release/npm/node_modules/human-signals": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", @@ -3726,6 +3757,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@semantic-release/npm/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==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@semantic-release/npm/node_modules/normalize-package-data": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", + "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^9.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/@semantic-release/npm/node_modules/npm-run-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", @@ -3743,6 +3799,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@semantic-release/npm/node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@semantic-release/npm/node_modules/path-key": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", @@ -3756,6 +3830,55 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@semantic-release/npm/node_modules/read-pkg": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-10.1.0.tgz", + "integrity": "sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.4", + "normalize-package-data": "^8.0.0", + "parse-json": "^8.3.0", + "type-fest": "^5.4.4", + "unicorn-magic": "^0.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/read-pkg/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==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/read-pkg/node_modules/unicorn-magic": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@semantic-release/npm/node_modules/strip-final-newline": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", @@ -3840,7 +3963,6 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -3933,14 +4055,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz", - "integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", + "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.4", + "@vitest/utils": "4.1.5", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -3954,8 +4076,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.4", - "vitest": "4.1.4" + "@vitest/browser": "4.1.5", + "vitest": "4.1.5" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -3964,16 +4086,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", - "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -3982,13 +4104,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", - "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.4", + "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4009,9 +4131,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", - "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", "dev": true, "license": "MIT", "dependencies": { @@ -4022,13 +4144,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", - "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.4", + "@vitest/utils": "4.1.5", "pathe": "^2.0.3" }, "funding": { @@ -4036,14 +4158,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", - "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -4052,9 +4174,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", - "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", "dev": true, "license": "MIT", "funding": { @@ -4062,13 +4184,13 @@ } }, "node_modules/@vitest/ui": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.4.tgz", - "integrity": "sha512-EgFR7nlj5iTDYZYCvavjFokNYwr3c3ry0sFiCg+N7B233Nwp+NNx7eoF/XvMWDCKY71xXAG3kFkt97ZHBJVL8A==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.5.tgz", + "integrity": "sha512-3Z9HNFiV0IF1fk0JPiK+7kE1GcaIPefQQIBYur6PM5yFIq6agys3uqP/0t966e1wXfmjbRCHDe7qW236Xjwnag==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.4", + "@vitest/utils": "4.1.5", "fflate": "^0.8.2", "flatted": "^3.4.2", "pathe": "^2.0.3", @@ -4080,17 +4202,17 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.4" + "vitest": "4.1.5" } }, "node_modules/@vitest/utils": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", - "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.4", + "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4530,7 +4652,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -5215,7 +5336,6 @@ "version": "2.4.0", "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==", - "dev": true, "license": "MIT" }, "node_modules/env-ci": { @@ -7109,6 +7229,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7130,6 +7253,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7151,6 +7277,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7172,6 +7301,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7919,7 +8051,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", - "dev": true, "license": "MIT", "dependencies": { "@sindresorhus/is": "^4.6.0", @@ -7967,28 +8098,29 @@ } }, "node_modules/normalize-url": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", - "integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-9.0.0.tgz", + "integrity": "sha512-z9nC87iaZXXySbWWtTHfCFJyFvKaUAW6lODhikG7ILSbVgmwuFjUqkgnheHvAUcGedO29e2QGBRXMUD64aurqQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=14.16" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/npm": { - "version": "10.9.8", - "resolved": "https://registry.npmjs.org/npm/-/npm-10.9.8.tgz", - "integrity": "sha512-fYwb6ODSmHkqrJQQaCxY3M2lPf/mpgC7ik0HSzzIwG5CGtabRp4bNqikatvCoT42b5INQSqudVH0R7yVmC9hVg==", + "version": "11.13.0", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.13.0.tgz", + "integrity": "sha512-cRmhaghDWA1lFgl3Ug4/VxDJdPBK/U+tNtnrl9kXunFqhWw1x4xL5txkNn7qzPuVfvXOmXyjHpMwsuk2uisbkg==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", "@npmcli/config", "@npmcli/fs", "@npmcli/map-workspaces", + "@npmcli/metavuln-calculator", "@npmcli/package-json", "@npmcli/promise-spawn", "@npmcli/redact", @@ -7999,7 +8131,6 @@ "cacache", "chalk", "ci-info", - "cli-columns", "fastest-levenshtein", "fs-minipass", "glob", @@ -8013,7 +8144,6 @@ "libnpmdiff", "libnpmexec", "libnpmfund", - "libnpmhook", "libnpmorg", "libnpmpack", "libnpmpublish", @@ -8027,7 +8157,6 @@ "ms", "node-gyp", "nopt", - "normalize-package-data", "npm-audit-report", "npm-install-checks", "npm-package-arg", @@ -8050,8 +8179,7 @@ "tiny-relative-date", "treeverse", "validate-npm-package-name", - "which", - "write-file-atomic" + "which" ], "dev": true, "license": "Artistic-2.0", @@ -8064,80 +8192,77 @@ ], "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^8.0.5", - "@npmcli/config": "^9.0.0", - "@npmcli/fs": "^4.0.0", - "@npmcli/map-workspaces": "^4.0.2", - "@npmcli/package-json": "^6.2.0", - "@npmcli/promise-spawn": "^8.0.3", - "@npmcli/redact": "^3.2.2", - "@npmcli/run-script": "^9.1.0", - "@sigstore/tuf": "^3.1.1", - "abbrev": "^3.0.1", + "@npmcli/arborist": "^9.4.3", + "@npmcli/config": "^10.8.1", + "@npmcli/fs": "^5.0.0", + "@npmcli/map-workspaces": "^5.0.3", + "@npmcli/metavuln-calculator": "^9.0.3", + "@npmcli/package-json": "^7.0.5", + "@npmcli/promise-spawn": "^9.0.1", + "@npmcli/redact": "^4.0.0", + "@npmcli/run-script": "^10.0.4", + "@sigstore/tuf": "^4.0.2", + "abbrev": "^4.0.0", "archy": "~1.0.0", - "cacache": "^19.0.1", + "cacache": "^20.0.4", "chalk": "^5.6.2", "ci-info": "^4.4.0", - "cli-columns": "^4.0.0", "fastest-levenshtein": "^1.0.16", "fs-minipass": "^3.0.3", - "glob": "^10.5.0", + "glob": "^13.0.6", "graceful-fs": "^4.2.11", - "hosted-git-info": "^8.1.0", - "ini": "^5.0.0", - "init-package-json": "^7.0.2", - "is-cidr": "^5.1.1", - "json-parse-even-better-errors": "^4.0.0", - "libnpmaccess": "^9.0.0", - "libnpmdiff": "^7.0.5", - "libnpmexec": "^9.0.5", - "libnpmfund": "^6.0.5", - "libnpmhook": "^11.0.0", - "libnpmorg": "^7.0.0", - "libnpmpack": "^8.0.5", - "libnpmpublish": "^10.0.2", - "libnpmsearch": "^8.0.0", - "libnpmteam": "^7.0.0", - "libnpmversion": "^7.0.0", - "make-fetch-happen": "^14.0.3", - "minimatch": "^9.0.9", + "hosted-git-info": "^9.0.2", + "ini": "^6.0.0", + "init-package-json": "^8.2.5", + "is-cidr": "^6.0.4", + "json-parse-even-better-errors": "^5.0.0", + "libnpmaccess": "^10.0.3", + "libnpmdiff": "^8.1.6", + "libnpmexec": "^10.2.6", + "libnpmfund": "^7.0.20", + "libnpmorg": "^8.0.1", + "libnpmpack": "^9.1.6", + "libnpmpublish": "^11.1.3", + "libnpmsearch": "^9.0.1", + "libnpmteam": "^8.0.2", + "libnpmversion": "^8.0.3", + "make-fetch-happen": "^15.0.5", + "minimatch": "^10.2.5", "minipass": "^7.1.3", "minipass-pipeline": "^1.2.4", "ms": "^2.1.2", - "node-gyp": "^11.5.0", - "nopt": "^8.1.0", - "normalize-package-data": "^7.0.1", - "npm-audit-report": "^6.0.0", - "npm-install-checks": "^7.1.2", - "npm-package-arg": "^12.0.2", - "npm-pick-manifest": "^10.0.0", - "npm-profile": "^11.0.1", - "npm-registry-fetch": "^18.0.2", - "npm-user-validate": "^3.0.0", + "node-gyp": "^12.3.0", + "nopt": "^9.0.0", + "npm-audit-report": "^7.0.0", + "npm-install-checks": "^8.0.0", + "npm-package-arg": "^13.0.2", + "npm-pick-manifest": "^11.0.3", + "npm-profile": "^12.0.1", + "npm-registry-fetch": "^19.1.1", + "npm-user-validate": "^4.0.0", "p-map": "^7.0.4", - "pacote": "^19.0.1", - "parse-conflict-json": "^4.0.0", - "proc-log": "^5.0.0", + "pacote": "^21.5.0", + "parse-conflict-json": "^5.0.1", + "proc-log": "^6.1.0", "qrcode-terminal": "^0.12.0", - "read": "^4.1.0", + "read": "^5.0.1", "semver": "^7.7.4", "spdx-expression-parse": "^4.0.0", - "ssri": "^12.0.0", - "supports-color": "^9.4.0", - "tar": "^7.5.11", + "ssri": "^13.0.1", + "supports-color": "^10.2.2", + "tar": "^7.5.13", "text-table": "~0.2.0", - "tiny-relative-date": "^1.3.0", + "tiny-relative-date": "^2.0.2", "treeverse": "^3.0.0", - "validate-npm-package-name": "^6.0.2", - "which": "^5.0.0", - "write-file-atomic": "^6.0.0" + "validate-npm-package-name": "^7.0.2", + "which": "^6.0.1" }, "bin": { "npm": "bin/npm-cli.js", "npx": "bin/npx-cli.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm-run-path": { @@ -8153,71 +8278,13 @@ "node": ">=8" } }, - "node_modules/npm/node_modules/@isaacs/cliui": { - "version": "8.0.2", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.2.0", + "node_modules/npm/node_modules/@gar/promise-retry": { + "version": "1.0.3", "dev": true, "inBundle": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@isaacs/fs-minipass": { @@ -8239,7 +8306,7 @@ "license": "ISC" }, "node_modules/npm/node_modules/@npmcli/agent": { - "version": "3.0.0", + "version": "4.0.0", "dev": true, "inBundle": true, "license": "ISC", @@ -8247,84 +8314,82 @@ "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", + "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "8.0.5", + "version": "9.4.3", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { + "@gar/promise-retry": "^1.0.0", "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/fs": "^4.0.0", - "@npmcli/installed-package-contents": "^3.0.0", - "@npmcli/map-workspaces": "^4.0.1", - "@npmcli/metavuln-calculator": "^8.0.0", - "@npmcli/name-from-folder": "^3.0.0", - "@npmcli/node-gyp": "^4.0.0", - "@npmcli/package-json": "^6.0.1", - "@npmcli/query": "^4.0.0", - "@npmcli/redact": "^3.0.0", - "@npmcli/run-script": "^9.0.1", - "bin-links": "^5.0.0", - "cacache": "^19.0.1", - "common-ancestor-path": "^1.0.1", - "hosted-git-info": "^8.0.0", - "json-parse-even-better-errors": "^4.0.0", + "@npmcli/fs": "^5.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/metavuln-calculator": "^9.0.2", + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/query": "^5.0.0", + "@npmcli/redact": "^4.0.0", + "@npmcli/run-script": "^10.0.0", + "bin-links": "^6.0.0", + "cacache": "^20.0.1", + "common-ancestor-path": "^2.0.0", + "hosted-git-info": "^9.0.0", "json-stringify-nice": "^1.1.4", - "lru-cache": "^10.2.2", - "minimatch": "^9.0.4", - "nopt": "^8.0.0", - "npm-install-checks": "^7.1.0", - "npm-package-arg": "^12.0.0", - "npm-pick-manifest": "^10.0.0", - "npm-registry-fetch": "^18.0.1", - "pacote": "^19.0.0", - "parse-conflict-json": "^4.0.0", - "proc-log": "^5.0.0", - "proggy": "^3.0.0", + "lru-cache": "^11.2.1", + "minimatch": "^10.0.3", + "nopt": "^9.0.0", + "npm-install-checks": "^8.0.0", + "npm-package-arg": "^13.0.0", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "pacote": "^21.0.2", + "parse-conflict-json": "^5.0.1", + "proc-log": "^6.0.0", + "proggy": "^4.0.0", "promise-all-reject-late": "^1.0.0", "promise-call-limit": "^3.0.1", - "promise-retry": "^2.0.1", - "read-package-json-fast": "^4.0.0", "semver": "^7.3.7", - "ssri": "^12.0.0", + "ssri": "^13.0.0", "treeverse": "^3.0.0", - "walk-up-path": "^3.0.1" + "walk-up-path": "^4.0.0" }, "bin": { "arborist": "bin/index.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@npmcli/config": { - "version": "9.0.0", + "version": "10.8.1", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/map-workspaces": "^4.0.1", - "@npmcli/package-json": "^6.0.1", + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/package-json": "^7.0.0", "ci-info": "^4.0.0", - "ini": "^5.0.0", - "nopt": "^8.0.0", - "proc-log": "^5.0.0", + "ini": "^6.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.5", - "walk-up-path": "^3.0.1" + "walk-up-path": "^4.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@npmcli/fs": { - "version": "4.0.0", + "version": "5.0.0", "dev": true, "inBundle": true, "license": "ISC", @@ -8332,156 +8397,125 @@ "semver": "^7.3.5" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@npmcli/git": { - "version": "6.0.3", + "version": "7.0.2", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/promise-spawn": "^8.0.0", - "ini": "^5.0.0", - "lru-cache": "^10.0.1", - "npm-pick-manifest": "^10.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", + "@gar/promise-retry": "^1.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "ini": "^6.0.0", + "lru-cache": "^11.2.1", + "npm-pick-manifest": "^11.0.1", + "proc-log": "^6.0.0", "semver": "^7.3.5", - "which": "^5.0.0" + "which": "^6.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@npmcli/installed-package-contents": { - "version": "3.0.0", + "version": "4.0.0", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "npm-bundled": "^4.0.0", - "npm-normalize-package-bin": "^4.0.0" + "npm-bundled": "^5.0.0", + "npm-normalize-package-bin": "^5.0.0" }, "bin": { "installed-package-contents": "bin/index.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@npmcli/map-workspaces": { - "version": "4.0.2", + "version": "5.0.3", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/name-from-folder": "^3.0.0", - "@npmcli/package-json": "^6.0.0", - "glob": "^10.2.2", - "minimatch": "^9.0.0" + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "glob": "^13.0.0", + "minimatch": "^10.0.3" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { - "version": "8.0.1", + "version": "9.0.3", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "cacache": "^19.0.0", - "json-parse-even-better-errors": "^4.0.0", - "pacote": "^20.0.0", - "proc-log": "^5.0.0", + "cacache": "^20.0.0", + "json-parse-even-better-errors": "^5.0.0", + "pacote": "^21.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.5" }, "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/metavuln-calculator/node_modules/pacote": { - "version": "20.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^6.0.0", - "@npmcli/installed-package-contents": "^3.0.0", - "@npmcli/package-json": "^6.0.0", - "@npmcli/promise-spawn": "^8.0.0", - "@npmcli/run-script": "^9.0.0", - "cacache": "^19.0.0", - "fs-minipass": "^3.0.0", - "minipass": "^7.0.2", - "npm-package-arg": "^12.0.0", - "npm-packlist": "^9.0.0", - "npm-pick-manifest": "^10.0.0", - "npm-registry-fetch": "^18.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "sigstore": "^3.0.0", - "ssri": "^12.0.0", - "tar": "^7.5.10" - }, - "bin": { - "pacote": "bin/index.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@npmcli/name-from-folder": { - "version": "3.0.0", + "version": "4.0.0", "dev": true, "inBundle": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@npmcli/node-gyp": { - "version": "4.0.0", + "version": "5.0.0", "dev": true, "inBundle": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@npmcli/package-json": { - "version": "6.2.0", + "version": "7.0.5", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/git": "^6.0.0", - "glob": "^10.2.2", - "hosted-git-info": "^8.0.0", - "json-parse-even-better-errors": "^4.0.0", - "proc-log": "^5.0.0", + "@npmcli/git": "^7.0.0", + "glob": "^13.0.0", + "hosted-git-info": "^9.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", "semver": "^7.5.3", - "validate-npm-package-license": "^3.0.4" + "spdx-expression-parse": "^4.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@npmcli/promise-spawn": { - "version": "8.0.3", + "version": "9.0.1", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "which": "^5.0.0" + "which": "^6.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@npmcli/query": { - "version": "4.0.1", + "version": "5.0.0", "dev": true, "inBundle": true, "license": "ISC", @@ -8489,68 +8523,57 @@ "postcss-selector-parser": "^7.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@npmcli/redact": { - "version": "3.2.2", + "version": "4.0.0", "dev": true, "inBundle": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@npmcli/run-script": { - "version": "9.1.0", + "version": "10.0.4", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/node-gyp": "^4.0.0", - "@npmcli/package-json": "^6.0.0", - "@npmcli/promise-spawn": "^8.0.0", - "node-gyp": "^11.0.0", - "proc-log": "^5.0.0", - "which": "^5.0.0" + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "node-gyp": "^12.1.0", + "proc-log": "^6.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@sigstore/bundle": { - "version": "3.1.0", + "version": "4.0.0", "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { - "@sigstore/protobuf-specs": "^0.4.0" + "@sigstore/protobuf-specs": "^0.5.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@sigstore/core": { - "version": "2.0.0", + "version": "3.2.0", "dev": true, "inBundle": true, "license": "Apache-2.0", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@sigstore/protobuf-specs": { - "version": "0.4.3", + "version": "0.5.1", "dev": true, "inBundle": true, "license": "Apache-2.0", @@ -8559,47 +8582,47 @@ } }, "node_modules/npm/node_modules/@sigstore/sign": { - "version": "3.1.0", + "version": "4.1.1", "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { - "@sigstore/bundle": "^3.1.0", - "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.4.0", - "make-fetch-happen": "^14.0.2", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1" + "@gar/promise-retry": "^1.0.2", + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.0", + "@sigstore/protobuf-specs": "^0.5.0", + "make-fetch-happen": "^15.0.4", + "proc-log": "^6.1.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@sigstore/tuf": { - "version": "3.1.1", + "version": "4.0.2", "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { - "@sigstore/protobuf-specs": "^0.4.1", - "tuf-js": "^3.0.1" + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^4.1.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@sigstore/verify": { - "version": "2.1.1", + "version": "3.1.0", "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { - "@sigstore/bundle": "^3.1.0", - "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.4.1" + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@tufjs/canonical-json": { @@ -8611,43 +8634,35 @@ "node": "^16.14.0 || >=18.0.0" } }, - "node_modules/npm/node_modules/abbrev": { - "version": "3.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/agent-base": { - "version": "7.1.4", + "node_modules/npm/node_modules/@tufjs/models": { + "version": "4.1.0", "dev": true, "inBundle": true, "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^10.1.1" + }, "engines": { - "node": ">= 14" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/ansi-regex": { - "version": "5.0.1", + "node_modules/npm/node_modules/abbrev": { + "version": "4.0.0", "dev": true, "inBundle": true, - "license": "MIT", + "license": "ISC", "engines": { - "node": ">=8" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/ansi-styles": { - "version": "6.2.3", + "node_modules/npm/node_modules/agent-base": { + "version": "7.1.4", "dev": true, "inBundle": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">= 14" } }, "node_modules/npm/node_modules/aproba": { @@ -8663,69 +8678,73 @@ "license": "MIT" }, "node_modules/npm/node_modules/balanced-match": { - "version": "1.0.2", + "version": "4.0.4", "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/npm/node_modules/bin-links": { - "version": "5.0.0", + "version": "6.0.0", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "cmd-shim": "^7.0.0", - "npm-normalize-package-bin": "^4.0.0", - "proc-log": "^5.0.0", - "read-cmd-shim": "^5.0.0", - "write-file-atomic": "^6.0.0" + "cmd-shim": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "proc-log": "^6.0.0", + "read-cmd-shim": "^6.0.0", + "write-file-atomic": "^7.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/binary-extensions": { - "version": "2.3.0", + "version": "3.1.0", "dev": true, "inBundle": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=18.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/npm/node_modules/brace-expansion": { - "version": "2.0.2", + "version": "5.0.5", "dev": true, "inBundle": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/npm/node_modules/cacache": { - "version": "19.0.1", + "version": "20.0.4", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/fs": "^4.0.0", + "@npmcli/fs": "^5.0.0", "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", - "ssri": "^12.0.0", - "tar": "^7.4.3", - "unique-filename": "^4.0.0" + "ssri": "^13.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/chalk": { @@ -8765,90 +8784,30 @@ } }, "node_modules/npm/node_modules/cidr-regex": { - "version": "4.1.3", + "version": "5.0.4", "dev": true, "inBundle": true, "license": "BSD-2-Clause", - "dependencies": { - "ip-regex": "^5.0.0" - }, "engines": { - "node": ">=14" + "node": ">=20" } }, - "node_modules/npm/node_modules/cli-columns": { - "version": "4.0.0", + "node_modules/npm/node_modules/cmd-shim": { + "version": "8.0.0", "dev": true, "inBundle": true, - "license": "MIT", - "dependencies": { - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1" - }, + "license": "ISC", "engines": { - "node": ">= 10" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/cmd-shim": { - "version": "7.0.0", + "node_modules/npm/node_modules/common-ancestor-path": { + "version": "2.0.0", "dev": true, "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/color-convert": { - "version": "2.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/npm/node_modules/color-name": { - "version": "1.1.4", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/common-ancestor-path": { - "version": "1.0.1", - "dev": true, - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/cross-spawn": { - "version": "7.0.6", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/cross-spawn/node_modules/which": { - "version": "2.0.2", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": ">= 8" + "node": ">= 18" } }, "node_modules/npm/node_modules/cssesc": { @@ -8881,7 +8840,7 @@ } }, "node_modules/npm/node_modules/diff": { - "version": "5.2.2", + "version": "8.0.4", "dev": true, "inBundle": true, "license": "BSD-3-Clause", @@ -8889,28 +8848,6 @@ "node": ">=0.3.1" } }, - "node_modules/npm/node_modules/eastasianwidth": { - "version": "0.2.0", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/emoji-regex": { - "version": "8.0.0", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/encoding": { - "version": "0.1.13", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, "node_modules/npm/node_modules/env-paths": { "version": "2.2.1", "dev": true, @@ -8920,12 +8857,6 @@ "node": ">=6" } }, - "node_modules/npm/node_modules/err-code": { - "version": "2.0.3", - "dev": true, - "inBundle": true, - "license": "MIT" - }, "node_modules/npm/node_modules/exponential-backoff": { "version": "3.1.3", "dev": true, @@ -8941,39 +8872,6 @@ "node": ">= 4.9.1" } }, - "node_modules/npm/node_modules/fdir": { - "version": "6.5.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/npm/node_modules/foreground-child": { - "version": "3.3.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/npm/node_modules/fs-minipass": { "version": "3.0.3", "dev": true, @@ -8987,20 +8885,17 @@ } }, "node_modules/npm/node_modules/glob": { - "version": "10.5.0", + "version": "13.0.6", "dev": true, "inBundle": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -9013,15 +8908,15 @@ "license": "ISC" }, "node_modules/npm/node_modules/hosted-git-info": { - "version": "8.1.0", + "version": "9.0.2", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "lru-cache": "^10.0.1" + "lru-cache": "^11.1.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/http-cache-semantics": { @@ -9057,7 +8952,7 @@ } }, "node_modules/npm/node_modules/iconv-lite": { - "version": "0.6.3", + "version": "0.7.2", "dev": true, "inBundle": true, "license": "MIT", @@ -9067,54 +8962,48 @@ }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/npm/node_modules/ignore-walk": { - "version": "7.0.0", + "version": "8.0.0", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "minimatch": "^9.0.0" + "minimatch": "^10.0.3" }, "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/imurmurhash": { - "version": "0.1.4", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/ini": { - "version": "5.0.0", + "version": "6.0.0", "dev": true, "inBundle": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/init-package-json": { - "version": "7.0.2", + "version": "8.2.5", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/package-json": "^6.0.0", - "npm-package-arg": "^12.0.0", - "promzard": "^2.0.0", - "read": "^4.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4", - "validate-npm-package-name": "^6.0.0" + "@npmcli/package-json": "^7.0.0", + "npm-package-arg": "^13.0.0", + "promzard": "^3.0.1", + "read": "^5.0.1", + "semver": "^7.7.2", + "validate-npm-package-name": "^7.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/ip-address": { @@ -9126,67 +9015,34 @@ "node": ">= 12" } }, - "node_modules/npm/node_modules/ip-regex": { - "version": "5.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/npm/node_modules/is-cidr": { - "version": "5.1.1", + "version": "6.0.4", "dev": true, "inBundle": true, "license": "BSD-2-Clause", "dependencies": { - "cidr-regex": "^4.1.1" + "cidr-regex": "^5.0.4" }, "engines": { - "node": ">=14" - } - }, - "node_modules/npm/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" + "node": ">=20" } }, "node_modules/npm/node_modules/isexe": { - "version": "2.0.0", - "dev": true, - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/jackspeak": { - "version": "3.4.3", + "version": "4.0.0", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" + "engines": { + "node": ">=20" } }, "node_modules/npm/node_modules/json-parse-even-better-errors": { - "version": "4.0.0", + "version": "5.0.0", "dev": true, "inBundle": true, "license": "MIT", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/json-stringify-nice": { @@ -9220,209 +9076,202 @@ "license": "MIT" }, "node_modules/npm/node_modules/libnpmaccess": { - "version": "9.0.0", + "version": "10.0.3", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "npm-package-arg": "^12.0.0", - "npm-registry-fetch": "^18.0.1" + "npm-package-arg": "^13.0.0", + "npm-registry-fetch": "^19.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/libnpmdiff": { - "version": "7.0.5", + "version": "8.1.6", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^8.0.5", - "@npmcli/installed-package-contents": "^3.0.0", - "binary-extensions": "^2.3.0", - "diff": "^5.1.0", - "minimatch": "^9.0.4", - "npm-package-arg": "^12.0.0", - "pacote": "^19.0.0", - "tar": "^7.5.11" + "@npmcli/arborist": "^9.4.3", + "@npmcli/installed-package-contents": "^4.0.0", + "binary-extensions": "^3.0.0", + "diff": "^8.0.2", + "minimatch": "^10.0.3", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2", + "tar": "^7.5.1" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "9.0.5", + "version": "10.2.6", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^8.0.5", - "@npmcli/run-script": "^9.0.1", + "@gar/promise-retry": "^1.0.0", + "@npmcli/arborist": "^9.4.3", + "@npmcli/package-json": "^7.0.0", + "@npmcli/run-script": "^10.0.0", "ci-info": "^4.0.0", - "npm-package-arg": "^12.0.0", - "pacote": "^19.0.0", - "proc-log": "^5.0.0", - "read": "^4.0.0", - "read-package-json-fast": "^4.0.0", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2", + "proc-log": "^6.0.0", + "read": "^5.0.1", "semver": "^7.3.7", - "walk-up-path": "^3.0.1" + "signal-exit": "^4.1.0", + "walk-up-path": "^4.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/libnpmfund": { - "version": "6.0.5", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^8.0.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/libnpmhook": { - "version": "11.0.0", + "version": "7.0.20", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^18.0.1" + "@npmcli/arborist": "^9.4.3" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/libnpmorg": { - "version": "7.0.0", + "version": "8.0.1", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { "aproba": "^2.0.0", - "npm-registry-fetch": "^18.0.1" + "npm-registry-fetch": "^19.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/libnpmpack": { - "version": "8.0.5", + "version": "9.1.6", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^8.0.5", - "@npmcli/run-script": "^9.0.1", - "npm-package-arg": "^12.0.0", - "pacote": "^19.0.0" + "@npmcli/arborist": "^9.4.3", + "@npmcli/run-script": "^10.0.0", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/libnpmpublish": { - "version": "10.0.2", + "version": "11.1.3", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { + "@npmcli/package-json": "^7.0.0", "ci-info": "^4.0.0", - "normalize-package-data": "^7.0.0", - "npm-package-arg": "^12.0.0", - "npm-registry-fetch": "^18.0.1", - "proc-log": "^5.0.0", + "npm-package-arg": "^13.0.0", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.7", - "sigstore": "^3.0.0", - "ssri": "^12.0.0" + "sigstore": "^4.0.0", + "ssri": "^13.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/libnpmsearch": { - "version": "8.0.0", + "version": "9.0.1", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "npm-registry-fetch": "^18.0.1" + "npm-registry-fetch": "^19.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/libnpmteam": { - "version": "7.0.0", + "version": "8.0.2", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { "aproba": "^2.0.0", - "npm-registry-fetch": "^18.0.1" + "npm-registry-fetch": "^19.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/libnpmversion": { - "version": "7.0.0", + "version": "8.0.3", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/git": "^6.0.1", - "@npmcli/run-script": "^9.0.1", - "json-parse-even-better-errors": "^4.0.0", - "proc-log": "^5.0.0", + "@npmcli/git": "^7.0.0", + "@npmcli/run-script": "^10.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.7" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/lru-cache": { - "version": "10.4.3", + "version": "11.3.5", "dev": true, "inBundle": true, - "license": "ISC" + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/npm/node_modules/make-fetch-happen": { - "version": "14.0.3", + "version": "15.0.5", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/agent": "^3.0.0", - "cacache": "^19.0.1", + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "@npmcli/redact": "^4.0.0", + "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", + "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "ssri": "^12.0.0" + "proc-log": "^6.0.0", + "ssri": "^13.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/minimatch": { - "version": "9.0.9", + "version": "10.2.5", "dev": true, "inBundle": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -9450,52 +9299,34 @@ } }, "node_modules/npm/node_modules/minipass-fetch": { - "version": "4.0.1", + "version": "5.0.2", "dev": true, "inBundle": true, "license": "MIT", "dependencies": { "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", + "minipass-sized": "^2.0.0", "minizlib": "^3.0.1" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" }, "optionalDependencies": { - "encoding": "^0.1.13" + "iconv-lite": "^0.7.2" } }, "node_modules/npm/node_modules/minipass-flush": { - "version": "1.0.5", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", + "version": "1.0.6", "dev": true, "inBundle": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "yallist": "^4.0.0" + "minipass": "^7.1.3" }, "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/npm/node_modules/minipass-flush/node_modules/yallist": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC" - }, "node_modules/npm/node_modules/minipass-pipeline": { "version": "1.2.4", "dev": true, @@ -9527,35 +9358,17 @@ "license": "ISC" }, "node_modules/npm/node_modules/minipass-sized": { - "version": "1.0.3", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", + "version": "2.0.0", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "yallist": "^4.0.0" + "minipass": "^7.1.2" }, "engines": { "node": ">=8" } }, - "node_modules/npm/node_modules/minipass-sized/node_modules/yallist": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC" - }, "node_modules/npm/node_modules/minizlib": { "version": "3.1.0", "dev": true, @@ -9575,12 +9388,12 @@ "license": "MIT" }, "node_modules/npm/node_modules/mute-stream": { - "version": "2.0.0", + "version": "3.0.0", "dev": true, "inBundle": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/negotiator": { @@ -9593,7 +9406,7 @@ } }, "node_modules/npm/node_modules/node-gyp": { - "version": "11.5.0", + "version": "12.3.0", "dev": true, "inBundle": true, "license": "MIT", @@ -9601,73 +9414,59 @@ "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^14.0.3", - "nopt": "^8.0.0", - "proc-log": "^5.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.5", - "tar": "^7.4.3", + "tar": "^7.5.4", "tinyglobby": "^0.2.12", - "which": "^5.0.0" + "undici": "^6.25.0", + "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/nopt": { - "version": "8.1.0", + "version": "9.0.0", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "abbrev": "^3.0.0" + "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/normalize-package-data": { - "version": "7.0.1", - "dev": true, - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^8.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-audit-report": { - "version": "6.0.0", + "version": "7.0.0", "dev": true, "inBundle": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-bundled": { - "version": "4.0.0", + "version": "5.0.0", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "npm-normalize-package-bin": "^4.0.0" + "npm-normalize-package-bin": "^5.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-install-checks": { - "version": "7.1.2", + "version": "8.0.0", "dev": true, "inBundle": true, "license": "BSD-2-Clause", @@ -9675,99 +9474,100 @@ "semver": "^7.1.1" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-normalize-package-bin": { - "version": "4.0.0", + "version": "5.0.0", "dev": true, "inBundle": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-package-arg": { - "version": "12.0.2", + "version": "13.0.2", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "hosted-git-info": "^8.0.0", - "proc-log": "^5.0.0", + "hosted-git-info": "^9.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.5", - "validate-npm-package-name": "^6.0.0" + "validate-npm-package-name": "^7.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-packlist": { - "version": "9.0.0", + "version": "10.0.4", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "ignore-walk": "^7.0.0" + "ignore-walk": "^8.0.0", + "proc-log": "^6.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-pick-manifest": { - "version": "10.0.0", + "version": "11.0.3", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "npm-install-checks": "^7.1.0", - "npm-normalize-package-bin": "^4.0.0", - "npm-package-arg": "^12.0.0", + "npm-install-checks": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "npm-package-arg": "^13.0.0", "semver": "^7.3.5" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-profile": { - "version": "11.0.1", + "version": "12.0.1", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "npm-registry-fetch": "^18.0.0", - "proc-log": "^5.0.0" + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-registry-fetch": { - "version": "18.0.2", + "version": "19.1.1", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/redact": "^3.0.0", + "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", - "make-fetch-happen": "^14.0.0", + "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", + "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", - "npm-package-arg": "^12.0.0", - "proc-log": "^5.0.0" + "npm-package-arg": "^13.0.0", + "proc-log": "^6.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-user-validate": { - "version": "3.0.0", + "version": "4.0.0", "dev": true, "inBundle": true, "license": "BSD-2-Clause", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/p-map": { @@ -9782,94 +9582,67 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/npm/node_modules/package-json-from-dist": { - "version": "1.0.1", - "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0" - }, "node_modules/npm/node_modules/pacote": { - "version": "19.0.2", + "version": "21.5.0", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/git": "^6.0.0", - "@npmcli/installed-package-contents": "^3.0.0", - "@npmcli/package-json": "^6.0.0", - "@npmcli/promise-spawn": "^8.0.0", - "@npmcli/run-script": "^9.0.0", - "cacache": "^19.0.0", + "@gar/promise-retry": "^1.0.0", + "@npmcli/git": "^7.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "@npmcli/run-script": "^10.0.0", + "cacache": "^20.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", - "npm-package-arg": "^12.0.0", - "npm-packlist": "^9.0.0", - "npm-pick-manifest": "^10.0.0", - "npm-registry-fetch": "^18.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "sigstore": "^3.0.0", - "ssri": "^12.0.0", - "tar": "^7.5.10" + "npm-package-arg": "^13.0.0", + "npm-packlist": "^10.0.1", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "sigstore": "^4.0.0", + "ssri": "^13.0.0", + "tar": "^7.4.3" }, "bin": { "pacote": "bin/index.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/parse-conflict-json": { - "version": "4.0.0", + "version": "5.0.1", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "json-parse-even-better-errors": "^4.0.0", + "json-parse-even-better-errors": "^5.0.0", "just-diff": "^6.0.0", "just-diff-apply": "^5.2.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/path-key": { - "version": "3.1.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/path-scurry": { - "version": "1.11.1", + "version": "2.0.2", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/npm/node_modules/picomatch": { - "version": "4.0.3", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/npm/node_modules/postcss-selector-parser": { "version": "7.1.1", "dev": true, @@ -9884,21 +9657,21 @@ } }, "node_modules/npm/node_modules/proc-log": { - "version": "5.0.0", + "version": "6.1.0", "dev": true, "inBundle": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/proggy": { - "version": "3.0.0", + "version": "4.0.0", "dev": true, "inBundle": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/promise-all-reject-late": { @@ -9919,29 +9692,16 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/npm/node_modules/promise-retry": { - "version": "2.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/npm/node_modules/promzard": { - "version": "2.0.0", + "version": "3.0.1", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "read": "^4.0.0" + "read": "^5.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/qrcode-terminal": { @@ -9953,46 +9713,24 @@ } }, "node_modules/npm/node_modules/read": { - "version": "4.1.0", + "version": "5.0.1", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "mute-stream": "^2.0.0" + "mute-stream": "^3.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/read-cmd-shim": { - "version": "5.0.0", + "version": "6.0.0", "dev": true, "inBundle": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/read-package-json-fast": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "json-parse-even-better-errors": "^4.0.0", - "npm-normalize-package-bin": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/retry": { - "version": "0.12.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 4" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/safer-buffer": { @@ -10014,27 +9752,6 @@ "node": ">=10" } }, - "node_modules/npm/node_modules/shebang-command": { - "version": "2.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/shebang-regex": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/npm/node_modules/signal-exit": { "version": "4.1.0", "dev": true, @@ -10048,20 +9765,20 @@ } }, "node_modules/npm/node_modules/sigstore": { - "version": "3.1.0", + "version": "4.1.0", "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { - "@sigstore/bundle": "^3.1.0", - "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.4.0", - "@sigstore/sign": "^3.1.0", - "@sigstore/tuf": "^3.1.0", - "@sigstore/verify": "^2.1.0" + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0", + "@sigstore/sign": "^4.1.0", + "@sigstore/tuf": "^4.0.1", + "@sigstore/verify": "^3.1.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/smart-buffer": { @@ -10102,26 +9819,6 @@ "node": ">= 14" } }, - "node_modules/npm/node_modules/spdx-correct": { - "version": "3.2.0", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/npm/node_modules/spdx-correct/node_modules/spdx-expression-parse": { - "version": "3.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, "node_modules/npm/node_modules/spdx-exceptions": { "version": "2.5.0", "dev": true, @@ -10145,7 +9842,7 @@ "license": "CC0-1.0" }, "node_modules/npm/node_modules/ssri": { - "version": "12.0.0", + "version": "13.0.1", "dev": true, "inBundle": true, "license": "ISC", @@ -10153,77 +9850,23 @@ "minipass": "^7.0.3" }, "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/string-width": { - "version": "4.2.3", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/strip-ansi": { - "version": "6.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/supports-color": { - "version": "9.4.0", + "version": "10.2.2", "dev": true, "inBundle": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/supports-color?sponsor=1" } }, "node_modules/npm/node_modules/tar": { - "version": "7.5.11", + "version": "7.5.13", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", @@ -10245,19 +9888,19 @@ "license": "MIT" }, "node_modules/npm/node_modules/tiny-relative-date": { - "version": "1.3.0", + "version": "2.0.2", "dev": true, "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/tinyglobby": { - "version": "0.2.15", + "version": "0.2.16", "dev": true, "inBundle": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -10266,64 +9909,65 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/npm/node_modules/treeverse": { - "version": "3.0.0", + "node_modules/npm/node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", "dev": true, "inBundle": true, - "license": "ISC", + "license": "MIT", "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/npm/node_modules/tuf-js": { - "version": "3.1.0", + "node_modules/npm/node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", "dev": true, "inBundle": true, "license": "MIT", - "dependencies": { - "@tufjs/models": "3.0.1", - "debug": "^4.4.1", - "make-fetch-happen": "^14.0.3" - }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/npm/node_modules/tuf-js/node_modules/@tufjs/models": { - "version": "3.0.1", + "node_modules/npm/node_modules/treeverse": { + "version": "3.0.0", "dev": true, "inBundle": true, - "license": "MIT", - "dependencies": { - "@tufjs/canonical-json": "2.0.0", - "minimatch": "^9.0.5" - }, + "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/npm/node_modules/unique-filename": { - "version": "4.0.0", + "node_modules/npm/node_modules/tuf-js": { + "version": "4.1.0", "dev": true, "inBundle": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "unique-slug": "^5.0.0" + "@tufjs/models": "4.1.0", + "debug": "^4.4.3", + "make-fetch-happen": "^15.0.1" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/unique-slug": { - "version": "5.0.0", + "node_modules/npm/node_modules/undici": { + "version": "6.25.0", "dev": true, "inBundle": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, + "license": "MIT", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": ">=18.17" } }, "node_modules/npm/node_modules/util-deprecate": { @@ -10332,58 +9976,53 @@ "inBundle": true, "license": "MIT" }, - "node_modules/npm/node_modules/validate-npm-package-license": { - "version": "3.0.4", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "node_modules/npm/node_modules/validate-npm-package-license/node_modules/spdx-expression-parse": { - "version": "3.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, "node_modules/npm/node_modules/validate-npm-package-name": { - "version": "6.0.2", + "version": "7.0.2", "dev": true, "inBundle": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/walk-up-path": { - "version": "3.0.1", + "version": "4.0.0", "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "engines": { + "node": "20 || >=22" + } }, "node_modules/npm/node_modules/which": { - "version": "5.0.0", + "version": "6.0.1", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "isexe": "^3.1.1" + "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/write-file-atomic": { + "version": "7.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/which/node_modules/isexe": { - "version": "3.1.5", + "node_modules/npm/node_modules/yallist": { + "version": "5.0.0", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", @@ -10391,160 +10030,38 @@ "node": ">=18" } }, - "node_modules/npm/node_modules/wrap-ansi": { - "version": "8.1.0", + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "dev": true, - "inBundle": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": ">=0.10.0" } }, - "node_modules/npm/node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/onetime": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", "dev": true, - "inBundle": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "mimic-function": "^5.0.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.2.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "9.2.2", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/string-width": { - "version": "5.1.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.2.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/write-file-atomic": { - "version": "6.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/yallist": { - "version": "5.0.0", - "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -10915,9 +10432,9 @@ } }, "node_modules/postcss": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", - "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", "dev": true, "funding": [ { @@ -11202,14 +10719,14 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", - "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.124.0", - "@rolldown/pluginutils": "1.0.0-rc.15" + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" @@ -11218,21 +10735,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.15", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", - "@rolldown/binding-darwin-x64": "1.0.0-rc.15", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + "@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" } }, "node_modules/run-parallel": { @@ -11309,2231 +10826,224 @@ "signale": "^1.2.1", "yargs": "^18.0.0" }, - "bin": { - "semantic-release": "bin/semantic-release.js" - }, - "engines": { - "node": "^22.14.0 || >= 24.10.0" - } - }, - "node_modules/semantic-release/node_modules/@octokit/plugin-paginate-rest": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", - "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0" - }, - "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "@octokit/core": ">=6" - } - }, - "node_modules/semantic-release/node_modules/@semantic-release/error": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", - "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/semantic-release/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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/core": "^7.0.0", - "@octokit/plugin-paginate-rest": "^14.0.0", - "@octokit/plugin-retry": "^8.0.0", - "@octokit/plugin-throttling": "^11.0.0", - "@semantic-release/error": "^4.0.0", - "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", - "issue-parser": "^7.0.0", - "lodash-es": "^4.17.21", - "mime": "^4.0.0", - "p-filter": "^4.0.0", - "tinyglobby": "^0.2.14", - "undici": "^7.0.0", - "url-join": "^5.0.0" - }, - "engines": { - "node": "^22.14.0 || >= 24.10.0" - }, - "peerDependencies": { - "semantic-release": ">=24.1.0" - } - }, - "node_modules/semantic-release/node_modules/@semantic-release/npm": { - "version": "13.1.5", - "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-13.1.5.tgz", - "integrity": "sha512-Hq5UxzoatN3LHiq2rTsWS54nCdqJHlsssGERCo8WlvdfFA9LoN0vO+OuKVSjtNapIc/S8C2LBj206wKLHg62mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@actions/core": "^3.0.0", - "@semantic-release/error": "^4.0.0", - "aggregate-error": "^5.0.0", - "env-ci": "^11.2.0", - "execa": "^9.0.0", - "fs-extra": "^11.0.0", - "lodash-es": "^4.17.21", - "nerf-dart": "^1.0.0", - "normalize-url": "^9.0.0", - "npm": "^11.6.2", - "rc": "^1.2.8", - "read-pkg": "^10.0.0", - "registry-auth-token": "^5.0.0", - "semver": "^7.1.2", - "tempy": "^3.0.0" - }, - "engines": { - "node": "^22.14.0 || >= 24.10.0" - }, - "peerDependencies": { - "semantic-release": ">=20.1.0" - } - }, - "node_modules/semantic-release/node_modules/aggregate-error": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", - "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "clean-stack": "^5.2.0", - "indent-string": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/semantic-release/node_modules/clean-stack": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.3.0.tgz", - "integrity": "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "5.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "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", - "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^4.0.0", - "cross-spawn": "^7.0.6", - "figures": "^6.1.0", - "get-stream": "^9.0.0", - "human-signals": "^8.0.1", - "is-plain-obj": "^4.1.0", - "is-stream": "^4.0.1", - "npm-run-path": "^6.0.0", - "pretty-ms": "^9.2.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^4.0.0", - "yoctocolors": "^2.1.1" - }, - "engines": { - "node": "^18.19.0 || >=20.5.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/semantic-release/node_modules/execa/node_modules/get-stream": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", - "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sec-ant/readable-stream": "^0.4.1", - "is-stream": "^4.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "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==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^11.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/human-signals": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", - "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/semantic-release/node_modules/indent-string": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", - "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/semantic-release/node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/semantic-release/node_modules/normalize-package-data": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", - "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^9.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/normalize-url": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-9.0.0.tgz", - "integrity": "sha512-z9nC87iaZXXySbWWtTHfCFJyFvKaUAW6lODhikG7ILSbVgmwuFjUqkgnheHvAUcGedO29e2QGBRXMUD64aurqQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/semantic-release/node_modules/npm": { - "version": "11.12.1", - "resolved": "https://registry.npmjs.org/npm/-/npm-11.12.1.tgz", - "integrity": "sha512-zcoUuF1kezGSAo0CqtvoLXX3mkRqzuqYdL6Y5tdo8g69NVV3CkjQ6ZBhBgB4d7vGkPcV6TcvLi3GRKPDFX+xTA==", - "bundleDependencies": [ - "@isaacs/string-locale-compare", - "@npmcli/arborist", - "@npmcli/config", - "@npmcli/fs", - "@npmcli/map-workspaces", - "@npmcli/metavuln-calculator", - "@npmcli/package-json", - "@npmcli/promise-spawn", - "@npmcli/redact", - "@npmcli/run-script", - "@sigstore/tuf", - "abbrev", - "archy", - "cacache", - "chalk", - "ci-info", - "fastest-levenshtein", - "fs-minipass", - "glob", - "graceful-fs", - "hosted-git-info", - "ini", - "init-package-json", - "is-cidr", - "json-parse-even-better-errors", - "libnpmaccess", - "libnpmdiff", - "libnpmexec", - "libnpmfund", - "libnpmorg", - "libnpmpack", - "libnpmpublish", - "libnpmsearch", - "libnpmteam", - "libnpmversion", - "make-fetch-happen", - "minimatch", - "minipass", - "minipass-pipeline", - "ms", - "node-gyp", - "nopt", - "npm-audit-report", - "npm-install-checks", - "npm-package-arg", - "npm-pick-manifest", - "npm-profile", - "npm-registry-fetch", - "npm-user-validate", - "p-map", - "pacote", - "parse-conflict-json", - "proc-log", - "qrcode-terminal", - "read", - "semver", - "spdx-expression-parse", - "ssri", - "supports-color", - "tar", - "text-table", - "tiny-relative-date", - "treeverse", - "validate-npm-package-name", - "which" - ], - "dev": true, - "license": "Artistic-2.0", - "workspaces": [ - "docs", - "smoke-tests", - "mock-globals", - "mock-registry", - "workspaces/*" - ], - "dependencies": { - "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^9.4.2", - "@npmcli/config": "^10.8.1", - "@npmcli/fs": "^5.0.0", - "@npmcli/map-workspaces": "^5.0.3", - "@npmcli/metavuln-calculator": "^9.0.3", - "@npmcli/package-json": "^7.0.5", - "@npmcli/promise-spawn": "^9.0.1", - "@npmcli/redact": "^4.0.0", - "@npmcli/run-script": "^10.0.4", - "@sigstore/tuf": "^4.0.2", - "abbrev": "^4.0.0", - "archy": "~1.0.0", - "cacache": "^20.0.4", - "chalk": "^5.6.2", - "ci-info": "^4.4.0", - "fastest-levenshtein": "^1.0.16", - "fs-minipass": "^3.0.3", - "glob": "^13.0.6", - "graceful-fs": "^4.2.11", - "hosted-git-info": "^9.0.2", - "ini": "^6.0.0", - "init-package-json": "^8.2.5", - "is-cidr": "^6.0.3", - "json-parse-even-better-errors": "^5.0.0", - "libnpmaccess": "^10.0.3", - "libnpmdiff": "^8.1.5", - "libnpmexec": "^10.2.5", - "libnpmfund": "^7.0.19", - "libnpmorg": "^8.0.1", - "libnpmpack": "^9.1.5", - "libnpmpublish": "^11.1.3", - "libnpmsearch": "^9.0.1", - "libnpmteam": "^8.0.2", - "libnpmversion": "^8.0.3", - "make-fetch-happen": "^15.0.5", - "minimatch": "^10.2.4", - "minipass": "^7.1.3", - "minipass-pipeline": "^1.2.4", - "ms": "^2.1.2", - "node-gyp": "^12.2.0", - "nopt": "^9.0.0", - "npm-audit-report": "^7.0.0", - "npm-install-checks": "^8.0.0", - "npm-package-arg": "^13.0.2", - "npm-pick-manifest": "^11.0.3", - "npm-profile": "^12.0.1", - "npm-registry-fetch": "^19.1.1", - "npm-user-validate": "^4.0.0", - "p-map": "^7.0.4", - "pacote": "^21.5.0", - "parse-conflict-json": "^5.0.1", - "proc-log": "^6.1.0", - "qrcode-terminal": "^0.12.0", - "read": "^5.0.1", - "semver": "^7.7.4", - "spdx-expression-parse": "^4.0.0", - "ssri": "^13.0.1", - "supports-color": "^10.2.2", - "tar": "^7.5.11", - "text-table": "~0.2.0", - "tiny-relative-date": "^2.0.2", - "treeverse": "^3.0.0", - "validate-npm-package-name": "^7.0.2", - "which": "^6.0.1" - }, - "bin": { - "npm": "bin/npm-cli.js", - "npx": "bin/npx-cli.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm-run-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", - "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@gar/promise-retry": { - "version": "1.0.3", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@isaacs/string-locale-compare": { - "version": "1.1.0", - "dev": true, - "inBundle": true, - "license": "ISC" - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/agent": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^11.2.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.4.2", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@gar/promise-retry": "^1.0.0", - "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/fs": "^5.0.0", - "@npmcli/installed-package-contents": "^4.0.0", - "@npmcli/map-workspaces": "^5.0.0", - "@npmcli/metavuln-calculator": "^9.0.2", - "@npmcli/name-from-folder": "^4.0.0", - "@npmcli/node-gyp": "^5.0.0", - "@npmcli/package-json": "^7.0.0", - "@npmcli/query": "^5.0.0", - "@npmcli/redact": "^4.0.0", - "@npmcli/run-script": "^10.0.0", - "bin-links": "^6.0.0", - "cacache": "^20.0.1", - "common-ancestor-path": "^2.0.0", - "hosted-git-info": "^9.0.0", - "json-stringify-nice": "^1.1.4", - "lru-cache": "^11.2.1", - "minimatch": "^10.0.3", - "nopt": "^9.0.0", - "npm-install-checks": "^8.0.0", - "npm-package-arg": "^13.0.0", - "npm-pick-manifest": "^11.0.1", - "npm-registry-fetch": "^19.0.0", - "pacote": "^21.0.2", - "parse-conflict-json": "^5.0.1", - "proc-log": "^6.0.0", - "proggy": "^4.0.0", - "promise-all-reject-late": "^1.0.0", - "promise-call-limit": "^3.0.1", - "semver": "^7.3.7", - "ssri": "^13.0.0", - "treeverse": "^3.0.0", - "walk-up-path": "^4.0.0" - }, - "bin": { - "arborist": "bin/index.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/config": { - "version": "10.8.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/map-workspaces": "^5.0.0", - "@npmcli/package-json": "^7.0.0", - "ci-info": "^4.0.0", - "ini": "^6.0.0", - "nopt": "^9.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.5", - "walk-up-path": "^4.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/fs": { - "version": "5.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/git": { - "version": "7.0.2", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@gar/promise-retry": "^1.0.0", - "@npmcli/promise-spawn": "^9.0.0", - "ini": "^6.0.0", - "lru-cache": "^11.2.1", - "npm-pick-manifest": "^11.0.1", - "proc-log": "^6.0.0", - "semver": "^7.3.5", - "which": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/installed-package-contents": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-bundled": "^5.0.0", - "npm-normalize-package-bin": "^5.0.0" - }, - "bin": { - "installed-package-contents": "bin/index.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/map-workspaces": { - "version": "5.0.3", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/name-from-folder": "^4.0.0", - "@npmcli/package-json": "^7.0.0", - "glob": "^13.0.0", - "minimatch": "^10.0.3" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/metavuln-calculator": { - "version": "9.0.3", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "cacache": "^20.0.0", - "json-parse-even-better-errors": "^5.0.0", - "pacote": "^21.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.5" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/name-from-folder": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/node-gyp": { - "version": "5.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/package-json": { - "version": "7.0.5", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^7.0.0", - "glob": "^13.0.0", - "hosted-git-info": "^9.0.0", - "json-parse-even-better-errors": "^5.0.0", - "proc-log": "^6.0.0", - "semver": "^7.5.3", - "spdx-expression-parse": "^4.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/promise-spawn": { - "version": "9.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "which": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/query": { - "version": "5.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/redact": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/run-script": { - "version": "10.0.4", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/node-gyp": "^5.0.0", - "@npmcli/package-json": "^7.0.0", - "@npmcli/promise-spawn": "^9.0.0", - "node-gyp": "^12.1.0", - "proc-log": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@sigstore/bundle": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/protobuf-specs": "^0.5.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@sigstore/core": { - "version": "3.2.0", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@sigstore/protobuf-specs": { - "version": "0.5.0", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@sigstore/sign": { - "version": "4.1.1", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@gar/promise-retry": "^1.0.2", - "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.2.0", - "@sigstore/protobuf-specs": "^0.5.0", - "make-fetch-happen": "^15.0.4", - "proc-log": "^6.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@sigstore/tuf": { - "version": "4.0.2", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/protobuf-specs": "^0.5.0", - "tuf-js": "^4.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@sigstore/verify": { - "version": "3.1.0", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", - "@sigstore/protobuf-specs": "^0.5.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@tufjs/canonical-json": { - "version": "2.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/@tufjs/models": { - "version": "4.1.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "@tufjs/canonical-json": "2.0.0", - "minimatch": "^10.1.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/abbrev": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/agent-base": { - "version": "7.1.4", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/aproba": { - "version": "2.1.0", - "dev": true, - "inBundle": true, - "license": "ISC" - }, - "node_modules/semantic-release/node_modules/npm/node_modules/archy": { - "version": "1.0.0", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/semantic-release/node_modules/npm/node_modules/balanced-match": { - "version": "4.0.4", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/bin-links": { - "version": "6.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "cmd-shim": "^8.0.0", - "npm-normalize-package-bin": "^5.0.0", - "proc-log": "^6.0.0", - "read-cmd-shim": "^6.0.0", - "write-file-atomic": "^7.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/binary-extensions": { - "version": "3.1.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=18.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/brace-expansion": { - "version": "5.0.4", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/cacache": { - "version": "20.0.4", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^5.0.0", - "fs-minipass": "^3.0.0", - "glob": "^13.0.0", - "lru-cache": "^11.1.0", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^7.0.2", - "ssri": "^13.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/chalk": { - "version": "5.6.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/chownr": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/ci-info": { - "version": "4.4.0", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/cidr-regex": { - "version": "5.0.3", - "dev": true, - "inBundle": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/cmd-shim": { - "version": "8.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/common-ancestor-path": { - "version": "2.0.0", - "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">= 18" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/cssesc": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/debug": { - "version": "4.4.3", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/diff": { - "version": "8.0.3", - "dev": true, - "inBundle": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/env-paths": { - "version": "2.2.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/exponential-backoff": { - "version": "3.1.3", - "dev": true, - "inBundle": true, - "license": "Apache-2.0" - }, - "node_modules/semantic-release/node_modules/npm/node_modules/fastest-levenshtein": { - "version": "1.0.16", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 4.9.1" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/fs-minipass": { - "version": "3.0.3", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/glob": { - "version": "13.0.6", - "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/graceful-fs": { - "version": "4.2.11", - "dev": true, - "inBundle": true, - "license": "ISC" - }, - "node_modules/semantic-release/node_modules/npm/node_modules/hosted-git-info": { - "version": "9.0.2", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^11.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/http-cache-semantics": { - "version": "4.2.0", - "dev": true, - "inBundle": true, - "license": "BSD-2-Clause" - }, - "node_modules/semantic-release/node_modules/npm/node_modules/http-proxy-agent": { - "version": "7.0.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/https-proxy-agent": { - "version": "7.0.6", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/iconv-lite": { - "version": "0.7.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/ignore-walk": { - "version": "8.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "minimatch": "^10.0.3" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/ini": { - "version": "6.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/init-package-json": { - "version": "8.2.5", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/package-json": "^7.0.0", - "npm-package-arg": "^13.0.0", - "promzard": "^3.0.1", - "read": "^5.0.1", - "semver": "^7.7.2", - "validate-npm-package-name": "^7.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/ip-address": { - "version": "10.1.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/is-cidr": { - "version": "6.0.3", - "dev": true, - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "cidr-regex": "^5.0.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/isexe": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=20" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/json-parse-even-better-errors": { - "version": "5.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/json-stringify-nice": { - "version": "1.1.4", - "dev": true, - "inBundle": true, - "license": "ISC", - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/jsonparse": { - "version": "1.3.1", - "dev": true, - "engines": [ - "node >= 0.2.0" - ], - "inBundle": true, - "license": "MIT" - }, - "node_modules/semantic-release/node_modules/npm/node_modules/just-diff": { - "version": "6.0.2", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/semantic-release/node_modules/npm/node_modules/just-diff-apply": { - "version": "5.5.0", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/semantic-release/node_modules/npm/node_modules/libnpmaccess": { - "version": "10.0.3", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-package-arg": "^13.0.0", - "npm-registry-fetch": "^19.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/libnpmdiff": { - "version": "8.1.5", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^9.4.2", - "@npmcli/installed-package-contents": "^4.0.0", - "binary-extensions": "^3.0.0", - "diff": "^8.0.2", - "minimatch": "^10.0.3", - "npm-package-arg": "^13.0.0", - "pacote": "^21.0.2", - "tar": "^7.5.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/libnpmexec": { - "version": "10.2.5", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@gar/promise-retry": "^1.0.0", - "@npmcli/arborist": "^9.4.2", - "@npmcli/package-json": "^7.0.0", - "@npmcli/run-script": "^10.0.0", - "ci-info": "^4.0.0", - "npm-package-arg": "^13.0.0", - "pacote": "^21.0.2", - "proc-log": "^6.0.0", - "read": "^5.0.1", - "semver": "^7.3.7", - "signal-exit": "^4.1.0", - "walk-up-path": "^4.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/libnpmfund": { - "version": "7.0.19", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^9.4.2" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/libnpmorg": { - "version": "8.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^19.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/libnpmpack": { - "version": "9.1.5", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^9.4.2", - "@npmcli/run-script": "^10.0.0", - "npm-package-arg": "^13.0.0", - "pacote": "^21.0.2" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/libnpmpublish": { - "version": "11.1.3", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/package-json": "^7.0.0", - "ci-info": "^4.0.0", - "npm-package-arg": "^13.0.0", - "npm-registry-fetch": "^19.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.7", - "sigstore": "^4.0.0", - "ssri": "^13.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/libnpmsearch": { - "version": "9.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-registry-fetch": "^19.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/libnpmteam": { - "version": "8.0.2", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^19.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/libnpmversion": { - "version": "8.0.3", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^7.0.0", - "@npmcli/run-script": "^10.0.0", - "json-parse-even-better-errors": "^5.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.7" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/lru-cache": { - "version": "11.2.7", - "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/make-fetch-happen": { - "version": "15.0.5", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@gar/promise-retry": "^1.0.0", - "@npmcli/agent": "^4.0.0", - "@npmcli/redact": "^4.0.0", - "cacache": "^20.0.1", - "http-cache-semantics": "^4.1.1", - "minipass": "^7.0.2", - "minipass-fetch": "^5.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^1.0.0", - "proc-log": "^6.0.0", - "ssri": "^13.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/minimatch": { - "version": "10.2.4", - "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/minipass": { - "version": "7.1.3", - "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/minipass-collect": { - "version": "2.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/minipass-fetch": { - "version": "5.0.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^2.0.0", - "minizlib": "^3.0.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - }, - "optionalDependencies": { - "iconv-lite": "^0.7.2" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/minipass-flush": { - "version": "1.0.5", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/minipass-flush/node_modules/yallist": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC" - }, - "node_modules/semantic-release/node_modules/npm/node_modules/minipass-pipeline": { - "version": "1.2.4", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/minipass-pipeline/node_modules/yallist": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC" - }, - "node_modules/semantic-release/node_modules/npm/node_modules/minipass-sized": { - "version": "2.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/minizlib": { - "version": "3.1.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/ms": { - "version": "2.1.3", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/semantic-release/node_modules/npm/node_modules/mute-stream": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/negotiator": { - "version": "1.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/node-gyp": { - "version": "12.2.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^15.0.0", - "nopt": "^9.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.5", - "tar": "^7.5.4", - "tinyglobby": "^0.2.12", - "which": "^6.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/nopt": { - "version": "9.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "abbrev": "^4.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/npm-audit-report": { - "version": "7.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/npm-bundled": { - "version": "5.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-normalize-package-bin": "^5.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/npm-install-checks": { - "version": "8.0.0", - "dev": true, - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "semver": "^7.1.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/npm-normalize-package-bin": { - "version": "5.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/npm-package-arg": { - "version": "13.0.2", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "hosted-git-info": "^9.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^7.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/npm-packlist": { - "version": "10.0.4", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "ignore-walk": "^8.0.0", - "proc-log": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/npm-pick-manifest": { - "version": "11.0.3", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-install-checks": "^8.0.0", - "npm-normalize-package-bin": "^5.0.0", - "npm-package-arg": "^13.0.0", - "semver": "^7.3.5" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/npm-profile": { - "version": "12.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-registry-fetch": "^19.0.0", - "proc-log": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/npm-registry-fetch": { - "version": "19.1.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/redact": "^4.0.0", - "jsonparse": "^1.3.1", - "make-fetch-happen": "^15.0.0", - "minipass": "^7.0.2", - "minipass-fetch": "^5.0.0", - "minizlib": "^3.0.1", - "npm-package-arg": "^13.0.0", - "proc-log": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/npm-user-validate": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "BSD-2-Clause", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/p-map": { - "version": "7.0.4", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/pacote": { - "version": "21.5.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@gar/promise-retry": "^1.0.0", - "@npmcli/git": "^7.0.0", - "@npmcli/installed-package-contents": "^4.0.0", - "@npmcli/package-json": "^7.0.0", - "@npmcli/promise-spawn": "^9.0.0", - "@npmcli/run-script": "^10.0.0", - "cacache": "^20.0.0", - "fs-minipass": "^3.0.0", - "minipass": "^7.0.2", - "npm-package-arg": "^13.0.0", - "npm-packlist": "^10.0.1", - "npm-pick-manifest": "^11.0.1", - "npm-registry-fetch": "^19.0.0", - "proc-log": "^6.0.0", - "sigstore": "^4.0.0", - "ssri": "^13.0.0", - "tar": "^7.4.3" - }, - "bin": { - "pacote": "bin/index.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/parse-conflict-json": { - "version": "5.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "json-parse-even-better-errors": "^5.0.0", - "just-diff": "^6.0.0", - "just-diff-apply": "^5.2.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/path-scurry": { - "version": "2.0.2", - "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/proc-log": { - "version": "6.1.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/proggy": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/promise-all-reject-late": { - "version": "1.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/promise-call-limit": { - "version": "3.0.2", - "dev": true, - "inBundle": true, - "license": "ISC", - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/promzard": { - "version": "3.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "read": "^5.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/qrcode-terminal": { - "version": "0.12.0", - "dev": true, - "inBundle": true, - "bin": { - "qrcode-terminal": "bin/qrcode-terminal.js" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/read": { - "version": "5.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "mute-stream": "^3.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/read-cmd-shim": { - "version": "6.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/safer-buffer": { - "version": "2.1.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true - }, - "node_modules/semantic-release/node_modules/npm/node_modules/semver": { - "version": "7.7.4", - "dev": true, - "inBundle": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/signal-exit": { - "version": "4.1.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/sigstore": { - "version": "4.1.0", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", - "@sigstore/protobuf-specs": "^0.5.0", - "@sigstore/sign": "^4.1.0", - "@sigstore/tuf": "^4.0.1", - "@sigstore/verify": "^3.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/smart-buffer": { - "version": "4.2.0", - "dev": true, - "inBundle": true, - "license": "MIT", + "bin": { + "semantic-release": "bin/semantic-release.js" + }, "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" + "node": "^22.14.0 || >= 24.10.0" } }, - "node_modules/semantic-release/node_modules/npm/node_modules/socks": { - "version": "2.8.7", + "node_modules/semantic-release/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", "dev": true, - "inBundle": true, "license": "MIT", - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" + "node": ">=18" } }, - "node_modules/semantic-release/node_modules/npm/node_modules/socks-proxy-agent": { - "version": "8.0.5", + "node_modules/semantic-release/node_modules/aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", "dev": true, - "inBundle": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" }, "engines": { - "node": ">= 14" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/npm/node_modules/spdx-exceptions": { - "version": "2.5.0", - "dev": true, - "inBundle": true, - "license": "CC-BY-3.0" - }, - "node_modules/semantic-release/node_modules/npm/node_modules/spdx-expression-parse": { - "version": "4.0.0", + "node_modules/semantic-release/node_modules/clean-stack": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.3.0.tgz", + "integrity": "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==", "dev": true, - "inBundle": true, "license": "MIT", "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/spdx-license-ids": { - "version": "3.0.23", - "dev": true, - "inBundle": true, - "license": "CC0-1.0" - }, - "node_modules/semantic-release/node_modules/npm/node_modules/ssri": { - "version": "13.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" + "escape-string-regexp": "5.0.0" }, "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/supports-color": { - "version": "10.2.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=18" + "node": ">=14.16" }, "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/npm/node_modules/tar": { - "version": "7.5.11", + "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, - "inBundle": true, - "license": "BlueOak-1.0.0", + "license": "ISC", "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=18" + "node": ">=20" } }, - "node_modules/semantic-release/node_modules/npm/node_modules/text-table": { - "version": "0.2.0", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/semantic-release/node_modules/npm/node_modules/tiny-relative-date": { - "version": "2.0.2", + "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, - "inBundle": true, "license": "MIT" }, - "node_modules/semantic-release/node_modules/npm/node_modules/tinyglobby": { - "version": "0.2.15", + "node_modules/semantic-release/node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", "dev": true, - "inBundle": true, "license": "MIT", "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" }, "engines": { - "node": ">=12.0.0" + "node": "^18.19.0 || >=20.5.0" }, "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/semantic-release/node_modules/npm/node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", + "node_modules/semantic-release/node_modules/execa/node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", "dev": true, - "inBundle": true, "license": "MIT", - "engines": { - "node": ">=12.0.0" + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "engines": { + "node": ">=18" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/npm/node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", + "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, - "inBundle": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/npm/node_modules/treeverse": { - "version": "3.0.0", + "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==", "dev": true, - "inBundle": true, "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/tuf-js": { - "version": "4.1.0", - "dev": true, - "inBundle": true, - "license": "MIT", "dependencies": { - "@tufjs/models": "4.1.0", - "debug": "^4.4.3", - "make-fetch-happen": "^15.0.1" + "lru-cache": "^11.1.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/semantic-release/node_modules/npm/node_modules/util-deprecate": { - "version": "1.0.2", + "node_modules/semantic-release/node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", "dev": true, - "inBundle": true, - "license": "MIT" + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } }, - "node_modules/semantic-release/node_modules/npm/node_modules/validate-npm-package-name": { - "version": "7.0.2", + "node_modules/semantic-release/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", "dev": true, - "inBundle": true, - "license": "ISC", + "license": "MIT", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/npm/node_modules/walk-up-path": { - "version": "4.0.0", + "node_modules/semantic-release/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", "dev": true, - "inBundle": true, - "license": "ISC", + "license": "MIT", "engines": { - "node": "20 || >=22" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/npm/node_modules/which": { - "version": "6.0.1", + "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==", "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "isexe": "^4.0.0" - }, - "bin": { - "node-which": "bin/which.js" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "20 || >=22" } }, - "node_modules/semantic-release/node_modules/npm/node_modules/write-file-atomic": { - "version": "7.0.1", + "node_modules/semantic-release/node_modules/normalize-package-data": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", + "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", "dev": true, - "inBundle": true, - "license": "ISC", + "license": "BSD-2-Clause", "dependencies": { - "signal-exit": "^4.0.1" + "hosted-git-info": "^9.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/semantic-release/node_modules/npm/node_modules/yallist": { - "version": "5.0.0", + "node_modules/semantic-release/node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, "engines": { "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/semantic-release/node_modules/p-reduce": { @@ -13969,7 +11479,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", - "dev": true, "license": "MIT", "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" @@ -14479,14 +11988,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -14667,7 +12176,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", - "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -14819,17 +12327,17 @@ } }, "node_modules/vite": { - "version": "8.0.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", - "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.15", - "tinyglobby": "^0.2.15" + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -14897,19 +12405,19 @@ } }, "node_modules/vitest": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", - "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.4", - "@vitest/mocker": "4.1.4", - "@vitest/pretty-format": "4.1.4", - "@vitest/runner": "4.1.4", - "@vitest/snapshot": "4.1.4", - "@vitest/spy": "4.1.4", - "@vitest/utils": "4.1.4", + "@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", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -14937,12 +12445,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.4", - "@vitest/browser-preview": "4.1.4", - "@vitest/browser-webdriverio": "4.1.4", - "@vitest/coverage-istanbul": "4.1.4", - "@vitest/coverage-v8": "4.1.4", - "@vitest/ui": "4.1.4", + "@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", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index 1f5ecb48..0c632599 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.4.8", + "version": "2026.4.9-next.8", "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", @@ -67,8 +67,9 @@ }, "homepage": "https://github.com/linearis-oss/linearis#readme", "dependencies": { - "@linear/sdk": "81.0.0", - "commander": "14.0.3" + "@linear/sdk": "82.1.0", + "commander": "14.0.3", + "node-emoji": "2.2.0" }, "devDependencies": { "@biomejs/biome": "^2.3.14", @@ -90,8 +91,8 @@ "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/exec": "^7.1.0", "@semantic-release/git": "^10.0.1", - "@semantic-release/github": "^11.0.1", - "@semantic-release/npm": "^12.0.2", + "@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" }, diff --git a/src/commands/attachments.ts b/src/commands/attachments.ts index d074487b..f8f94342 100644 --- a/src/commands/attachments.ts +++ b/src/commands/attachments.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import { createContext } from "../common/context.js"; +import { createContext, getRootOpts } from "../common/context.js"; import { handleCommand, outputSuccess } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import type { AttachmentFilter } from "../gql/graphql.js"; @@ -87,7 +87,7 @@ export function setupAttachmentsCommands(program: Command): void { ListOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const issueId = await resolveIssueId(ctx.sdk, issue); const filter = buildAttachmentFilter(options); const result = await listAttachments(ctx.gql, issueId, filter); @@ -108,7 +108,7 @@ export function setupAttachmentsCommands(program: Command): void { CreateOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const issueId = await resolveIssueId(ctx.sdk, issue); const result = await createAttachment(ctx.gql, { issueId, @@ -126,7 +126,7 @@ export function setupAttachmentsCommands(program: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [id, , command] = args as [string, unknown, Command]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const result = await deleteAttachment(ctx.gql, id); outputSuccess(result); }), diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 0ee8ee99..7e0b9087 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -6,7 +6,7 @@ import { resolveApiToken, type TokenSource, } from "../common/auth.js"; -import { createGraphQLClient } from "../common/context.js"; +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"; @@ -124,7 +124,7 @@ export function setupAuthCommands(program: Command): void { try { if (!options.force) { try { - const rootOpts = command.parent!.parent!.opts() as CommandOptions; + const rootOpts = getRootOpts(command) as CommandOptions; const { token, source } = resolveApiToken(rootOpts); try { const viewer = await validateApiToken(token); @@ -202,7 +202,7 @@ export function setupAuthCommands(program: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [, command] = args as [CommandOptions, Command]; - const rootOpts = command.parent!.parent!.opts() as CommandOptions; + const rootOpts = getRootOpts(command) as CommandOptions; let token: string; let source: TokenSource; @@ -245,7 +245,7 @@ export function setupAuthCommands(program: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [, command] = args as [CommandOptions, Command]; - const rootOpts = command.parent!.parent!.opts() as CommandOptions; + const rootOpts = getRootOpts(command) as CommandOptions; clearToken(); diff --git a/src/commands/comments.ts b/src/commands/comments.ts index c1a185f2..094e926c 100644 --- a/src/commands/comments.ts +++ b/src/commands/comments.ts @@ -1,15 +1,24 @@ import type { Command } from "commander"; -import { type CommandOptions, createContext } from "../common/context.js"; +import { + type CommandOptions, + createContext, + getRootOpts, +} from "../common/context.js"; +import { resolveReactionEmojiInput } from "../common/emoji.js"; +import { invalidParameterError } from "../common/errors.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { resolveIssueId } from "../resolvers/issue-resolver.js"; import { - createComment, - deleteComment, - listComments, - replyToComment, - updateComment, -} from "../services/comment-service.js"; + createIssueDiscussionCommentReaction, + deleteDiscussionComment, + deleteIssueDiscussionCommentReactionByEmoji, + deleteIssueDiscussionCommentReactionById, + editDiscussionComment, + listDiscussionsForIssue, + replyToDiscussion, + startIssueDiscussion, +} from "../services/discussion-service.js"; interface CreateCommentOptions extends CommandOptions { body?: string; @@ -28,28 +37,49 @@ interface EditCommentOptions extends CommandOptions { body?: string; } +interface ReactionOptions extends CommandOptions { + shortcode?: string; +} + export const COMMENTS_META: DomainMeta = { name: "comments", - summary: "discussion threads on issues (list, create, reply, edit, delete)", + summary: + "deprecated compatibility facade for issue discussions with root-thread-only reply support", context: - "a comment is a text entry on an issue. comments support markdown and threaded replies via parentId.", + "the comments domain remains operational as an intentionally narrowed compatibility layer. compatibility mode supports replying by root thread ID only, nested-reply targets are not supported in compatibility mode, and edit/delete accept either root thread IDs or reply IDs for backward compatibility. new workflows should migrate to domain-centric issues discussion commands (issues discuss/discussions/replies/reply/edit-reply/delete-reply).", arguments: { issue: "issue identifier (UUID or ABC-123)", - comment: "comment identifier (UUID only)", + comment: "thread/reply identifier (UUID only)", }, - seeAlso: ["issues read "], + seeAlso: [ + "issues discuss ", + "issues discussions ", + "issues replies ", + "issues reply ", + "issues edit-reply ", + "issues delete-reply ", + ], }; export function setupCommentsCommands(program: Command): void { const comments = program .command("comments") - .description("Comment operations"); + .description( + "Deprecated compatibility facade for issue discussions. Prefer the `issues` discussion commands.", + ) + .addHelpText( + "after", + "\nDEPRECATED: kept for compatibility. Prefer `issues discuss`, `issues discussions`, `issues replies`, `issues reply`, `issues edit-reply`, and `issues delete-reply`.\nCompatibility mode only supports replying by root thread ID (nested-reply targets are not supported).\nCompatibility edit/delete accept root thread IDs and reply IDs.", + ); comments.action(() => comments.help()); comments .command("list ") - .description("list comments on an issue") + .description( + "deprecated compatibility: list root issue discussions (migrate to `issues discussions `)", + ) + .addHelpText("after", "\nPrefer: `issues discussions `") .addHelpText( "after", `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, @@ -63,11 +93,11 @@ export function setupCommentsCommands(program: Command): void { ListCommentOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const limit = parseLimit(options.limit || "25"); const resolvedIssueId = await resolveIssueId(ctx.sdk, issue); - const result = await listComments(ctx.gql, resolvedIssueId, { + const result = await listDiscussionsForIssue(ctx.gql, resolvedIssueId, { limit, after: options.after, }); @@ -78,7 +108,10 @@ export function setupCommentsCommands(program: Command): void { comments .command("create ") - .description("create a comment on an issue") + .description( + "deprecated compatibility: start an issue discussion (migrate to `issues discuss --body `)", + ) + .addHelpText("after", "\nPrefer: `issues discuss --body `") .addHelpText( "after", `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, @@ -91,14 +124,14 @@ export function setupCommentsCommands(program: Command): void { CreateCommentOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); if (!options.body) { - throw new Error("--body is required"); + throw invalidParameterError("--body", "is required"); } const resolvedIssueId = await resolveIssueId(ctx.sdk, issue); - const result = await createComment(ctx.gql, { + const result = await startIssueDiscussion(ctx.gql, { issueId: resolvedIssueId, body: options.body, }); @@ -108,25 +141,37 @@ export function setupCommentsCommands(program: Command): void { ); comments - .command("reply ") - .description("reply to a comment") + .command("reply ") + .description( + "deprecated compatibility: reply to a root discussion thread (requires root thread ID; nested-reply targets are not supported in compatibility mode; migrate to `issues reply --body `)", + ) + .addHelpText("after", "\nPrefer: `issues reply --body `") + .addHelpText( + "after", + "\nImportant: `` must be the root discussion thread ID, not a reply ID.", + ) + .addHelpText( + "after", + "\nNested-reply targets are not supported in compatibility mode.", + ) .option("--body ", "reply body (required, markdown supported)") .action( handleCommand(async (...args: unknown[]) => { - const [comment, options, command] = args as [ + const [thread, options, command] = args as [ string, ReplyCommentOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); if (!options.body) { - throw new Error("--body is required"); + throw invalidParameterError("--body", "is required"); } - const result = await replyToComment(ctx.gql, { - parentId: comment, + const result = await replyToDiscussion(ctx.gql, { + threadId: thread, body: options.body, + entityKind: "issue", }); outputSuccess(result); @@ -135,7 +180,10 @@ export function setupCommentsCommands(program: Command): void { comments .command("edit ") - .description("edit a comment") + .description( + "deprecated compatibility: edit a discussion comment (accepts root thread ID or reply ID; migrate reply workflows to `issues edit-reply --body `)", + ) + .addHelpText("after", "\nPrefer: `issues edit-reply --body `") .option("--body ", "new comment body (required, markdown supported)") .action( handleCommand(async (...args: unknown[]) => { @@ -144,13 +192,13 @@ export function setupCommentsCommands(program: Command): void { EditCommentOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); if (!options.body) { - throw new Error("--body is required"); + throw invalidParameterError("--body", "is required"); } - const result = await updateComment(ctx.gql, comment, { + const result = await editDiscussionComment(ctx.gql, comment, { body: options.body, }); @@ -160,13 +208,105 @@ export function setupCommentsCommands(program: Command): void { comments .command("delete ") - .description("delete a comment") + .description( + "deprecated compatibility: delete a discussion comment (accepts root thread ID or reply ID; migrate reply workflows to `issues delete-reply `)", + ) + .addHelpText("after", "\nPrefer: `issues delete-reply `") .action( handleCommand(async (...args: unknown[]) => { const [comment, , command] = args as [string, unknown, Command]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); + + const result = await deleteDiscussionComment(ctx.gql, comment); + + outputSuccess(result); + }), + ); + + comments + .command("react [emoji]") + .description( + "DEPRECATED compatibility command. Prefer: `issues threads react ` or `issues replies react `.", + ) + .addHelpText( + "after", + "\nDEPRECATED compatibility command. Prefer: `issues threads react ` or `issues replies react `.", + ) + .option("--shortcode ", "emoji shortcode (e.g. thumbs_up)") + .action( + handleCommand(async (...args: unknown[]) => { + const [comment, emoji, options, command] = args as [ + string, + string | undefined, + ReactionOptions, + Command, + ]; + const ctx = createContext(getRootOpts(command)); - const result = await deleteComment(ctx.gql, comment); + const result = await createIssueDiscussionCommentReaction(ctx.gql, { + commentId: comment, + emoji: resolveReactionEmojiInput(emoji, options.shortcode), + }); + + outputSuccess(result); + }), + ); + + comments + .command("unreact [emoji]") + .description( + "DEPRECATED compatibility command. Prefer: `issues threads unreact ` or `issues replies unreact `.", + ) + .addHelpText( + "after", + "\nDEPRECATED compatibility command. Prefer: `issues threads unreact ` or `issues replies unreact `.", + ) + .option("--shortcode ", "emoji shortcode (e.g. thumbs_up)") + .action( + handleCommand(async (...args: unknown[]) => { + const [comment, emoji, options, command] = args as [ + string, + string | undefined, + ReactionOptions, + Command, + ]; + const ctx = createContext(getRootOpts(command)); + + const result = await deleteIssueDiscussionCommentReactionByEmoji( + ctx.gql, + { + commentId: comment, + emoji: resolveReactionEmojiInput(emoji, options.shortcode), + }, + ); + + outputSuccess(result); + }), + ); + + comments + .command("unreact-id ") + .description( + "DEPRECATED compatibility command. Prefer: `issues threads unreact-id ` or `issues replies unreact-id `.", + ) + .addHelpText( + "after", + "\nDEPRECATED compatibility command. Prefer: `issues threads unreact-id ` or `issues replies unreact-id `.", + ) + .action( + handleCommand(async (...args: unknown[]) => { + const [comment, reactionId, , command] = args as [ + string, + string, + unknown, + Command, + ]; + const ctx = createContext(getRootOpts(command)); + + const result = await deleteIssueDiscussionCommentReactionById(ctx.gql, { + commentId: comment, + reactionId, + }); outputSuccess(result); }), diff --git a/src/commands/cycles.ts b/src/commands/cycles.ts index 85cd8a15..c13a9d46 100644 --- a/src/commands/cycles.ts +++ b/src/commands/cycles.ts @@ -1,5 +1,9 @@ import type { Command } from "commander"; -import { type CommandOptions, createContext } from "../common/context.js"; +import { + type CommandOptions, + createContext, + getRootOpts, +} from "../common/context.js"; import { invalidParameterError, notFoundError, @@ -63,7 +67,7 @@ export function setupCyclesCommands(program: Command): void { ); } - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); // Resolve team filter if provided const teamId = options.team @@ -123,7 +127,7 @@ export function setupCyclesCommands(program: Command): void { CycleReadOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const cycleId = await resolveCycleId(ctx.sdk, cycle, options.team); diff --git a/src/commands/documents.ts b/src/commands/documents.ts index bb3d8d48..8cc75a12 100644 --- a/src/commands/documents.ts +++ b/src/commands/documents.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import { createContext } from "../common/context.js"; +import { createContext, getRootOpts } from "../common/context.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import type { DocumentUpdateInput } from "../gql/graphql.js"; @@ -110,7 +110,7 @@ export function setupDocumentsCommands(program: Command): void { ); } - const rootOpts = command.parent!.parent!.opts(); + const rootOpts = getRootOpts(command); const ctx = createContext(rootOpts); const limit = parseLimit(options.limit || "50"); @@ -169,7 +169,7 @@ export function setupDocumentsCommands(program: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [document, , command] = args as [string, unknown, Command]; - const rootOpts = command.parent!.parent!.opts(); + const rootOpts = getRootOpts(command); const ctx = createContext(rootOpts); const documentResult = await getDocument(ctx.gql, document); @@ -190,7 +190,7 @@ export function setupDocumentsCommands(program: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [options, command] = args as [DocumentCreateOptions, Command]; - const rootOpts = command.parent!.parent!.opts(); + const rootOpts = getRootOpts(command); const ctx = createContext(rootOpts); const projectId = options.project @@ -248,7 +248,7 @@ export function setupDocumentsCommands(program: Command): void { DocumentUpdateOptions, Command, ]; - const rootOpts = command.parent!.parent!.opts(); + const rootOpts = getRootOpts(command); const ctx = createContext(rootOpts); const input: DocumentUpdateInput = {}; @@ -271,7 +271,7 @@ export function setupDocumentsCommands(program: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [document, , command] = args as [string, unknown, Command]; - const rootOpts = command.parent!.parent!.opts(); + const rootOpts = getRootOpts(command); const ctx = createContext(rootOpts); const result = await deleteDocument(ctx.gql, document); diff --git a/src/commands/files.ts b/src/commands/files.ts index 8cb9c08a..4230f9d5 100644 --- a/src/commands/files.ts +++ b/src/commands/files.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import { type CommandOptions, getApiToken } from "../common/auth.js"; +import { getRootOpts } from "../common/context.js"; import { handleCommand, outputSuccess } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { FileService } from "../services/file-service.js"; @@ -37,7 +38,7 @@ export function setupFilesCommands(program: Command): void { CommandOptions & { output?: string; overwrite?: boolean }, Command, ]; - const apiToken = getApiToken(command.parent!.parent!.opts()); + const apiToken = getApiToken(getRootOpts(command)); const fileService = new FileService(apiToken); const result = await fileService.downloadFile(url, { output: options.output, @@ -61,7 +62,7 @@ export function setupFilesCommands(program: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [filePath, , command] = args as [string, CommandOptions, Command]; - const apiToken = getApiToken(command.parent!.parent!.opts()); + const apiToken = getApiToken(getRootOpts(command)); const fileService = new FileService(apiToken); const result = await fileService.uploadFile(filePath); diff --git a/src/commands/initiatives/entity.ts b/src/commands/initiatives/entity.ts index 214ba9be..261bd13c 100644 --- a/src/commands/initiatives/entity.ts +++ b/src/commands/initiatives/entity.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import type { LinearSdkClient } from "../../client/linear-client.js"; -import { createContext } from "../../common/context.js"; +import { createContext, getRootOpts } from "../../common/context.js"; +import { resolveReactionEmojiInput } from "../../common/emoji.js"; import { invalidParameterError } from "../../common/errors.js"; import { handleCommand, @@ -20,6 +21,23 @@ import { import { resolveInitiativeId } from "../../resolvers/initiative-resolver.js"; import { resolveTeamId } from "../../resolvers/team-resolver.js"; import { resolveUserId } from "../../resolvers/user-resolver.js"; +import { + createDiscussionCommentReaction, + deleteDiscussionComment, + deleteDiscussionCommentReactionByEmoji, + deleteDiscussionCommentReactionById, + deleteDiscussionReply, + editDiscussionComment, + editDiscussionReply, + listDiscussionReplies, + listDiscussionRepliesWithReactions, + listDiscussionsForInitiative, + listDiscussionsForInitiativeWithReactions, + replyToDiscussion, + resolveDiscussion, + startInitiativeDiscussion, + unresolveDiscussion, +} from "../../services/discussion-service.js"; import { archiveInitiative, createInitiative, @@ -71,6 +89,99 @@ interface InitiativeListOptions extends InitiativeExpandOptions { interface InitiativeReadOptions extends InitiativeExpandOptions {} +interface DiscussionsOptions { + limit?: string; + after?: string; + withReactions?: boolean; +} + +interface DiscussionBodyOptions { + body?: string; +} + +interface ResolveDiscussionOptions { + withComment?: string; +} + +interface ReactionOptions { + shortcode?: string; +} + +function addCommentReactionCommands( + parent: ReturnType, + noun: "thread" | "reply", +): void { + parent + .command(`react <${noun}> [emoji]`) + .description(`add a reaction to a discussion ${noun}`) + .option("--shortcode ", "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); + }), + ); + + parent + .command(`unreact <${noun}> [emoji]`) + .description(`remove your reaction from a discussion ${noun} by emoji`) + .option("--shortcode ", "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); + }), + ); + + parent + .command(`unreact-id <${noun}> `) + .description( + `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); + }), + ); +} + interface InitiativeCreateOptions { description?: string; content?: string; @@ -100,14 +211,6 @@ type InitiativeSortBy = | "manual" | "owner"; -function rootOptions(command: Command): Record { - let current: Command = command; - while (current.parent) { - current = current.parent; - } - return current.opts(); -} - function parseSortOrder(value?: string): "asc" | "desc" | undefined { if (!value) return undefined; const normalized = value.toLowerCase(); @@ -389,7 +492,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [options, command] = args as [InitiativeListOptions, Command]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const sortOrder = parseSortOrder(options.sortOrder); const sortBy = parseSortBy(options.sortBy); @@ -450,7 +553,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { InitiativeReadOptions, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); // Read query already returns expanded fields. Keep flags accepted for @@ -462,6 +565,276 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { }), ); + initiatives + .command("discuss ") + .description("start a discussion thread on an initiative") + .option("--body ", "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); + }), + ); + + initiatives + .command("discussions ") + .description("list root discussion threads on an initiative") + .option("-l, --limit ", "max results", "25") + .option("--after ", "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); + }), + ); + + const initiativeThreads = initiatives + .command("threads") + .description("discussion thread reaction operations"); + addCommentReactionCommands(initiativeThreads, "thread"); + + const initiativeReplies = initiatives + .command("replies ") + .description("list replies in a root discussion thread") + .option("-l, --limit ", "max results", "50") + .option("--after ", "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", + ); + + outputSuccess(result); + }), + ); + addCommentReactionCommands(initiativeReplies, "reply"); + + initiatives + .command("reply ") + .description("reply to a root discussion thread") + .addHelpText( + "after", + "\nImportant: `` must be a root discussion thread ID.", + ) + .option("--body ", "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); + }), + ); + + initiatives + .command("edit ") + .description("edit a root discussion or reply comment") + .option("--body ", "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", + ); + + outputSuccess(result); + }), + ); + + initiatives + .command("edit-reply ") + .description("edit a discussion reply") + .option("--body ", "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", + ); + + outputSuccess(result); + }), + ); + + initiatives + .command("delete-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); + }), + ); + + initiatives + .command("delete-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); + }), + ); + + initiatives + .command("resolve ") + .description("resolve a discussion thread") + .option("--with-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); + }), + ); + + initiatives + .command("unresolve ") + .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"); + + outputSuccess(result); + }), + ); + initiatives .command("create ") .description("create a new initiative") @@ -478,7 +851,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { InitiativeCreateOptions, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const input: InitiativeCreateInput = { name }; @@ -530,7 +903,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { InitiativeUpdateOptions, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); const input: InitiativeUpdateInput = {}; @@ -583,7 +956,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [initiative, , command] = args as [string, unknown, Command]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); const result = await archiveInitiative(ctx.gql, initiativeId); outputSuccess(result); @@ -596,7 +969,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [initiative, , command] = args as [string, unknown, Command]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); const result = await unarchiveInitiative(ctx.gql, initiativeId); outputSuccess(result); @@ -609,7 +982,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [initiative, , command] = args as [string, unknown, Command]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const initiativeId = await resolveInitiativeId(ctx.sdk, 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 2cde2671..6b1d17e0 100644 --- a/src/commands/initiatives/projects.ts +++ b/src/commands/initiatives/projects.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import { createContext } from "../../common/context.js"; +import { createContext, getRootOpts } from "../../common/context.js"; import { handleCommand, outputSuccess } from "../../common/output.js"; import { resolveInitiativeId, @@ -11,14 +11,6 @@ import { deleteInitiativeProjectLink, } from "../../services/initiative-project-service.js"; -function rootOptions(command: Command): Record { - let current: Command = command; - while (current.parent) { - current = current.parent; - } - return current.opts(); -} - export function setupInitiativeProjectCommands(initiatives: Command): void { initiatives .command("add-project ") @@ -31,7 +23,7 @@ export function setupInitiativeProjectCommands(initiatives: Command): void { unknown, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); const projectId = await resolveProjectId(ctx.sdk, project); @@ -56,7 +48,7 @@ export function setupInitiativeProjectCommands(initiatives: Command): void { unknown, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); const projectId = await resolveProjectId(ctx.sdk, project); diff --git a/src/commands/initiatives/relations.ts b/src/commands/initiatives/relations.ts index 4fd4b728..58520a37 100644 --- a/src/commands/initiatives/relations.ts +++ b/src/commands/initiatives/relations.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import { createContext } from "../../common/context.js"; +import { createContext, getRootOpts } from "../../common/context.js"; import { handleCommand, outputSuccess } from "../../common/output.js"; import { resolveInitiativeId, @@ -10,14 +10,6 @@ import { deleteInitiativeRelation, } from "../../services/initiative-relation-service.js"; -function rootOptions(command: Command): Record { - let current: Command = command; - while (current.parent) { - current = current.parent; - } - return current.opts(); -} - export function setupInitiativeRelationCommands(initiatives: Command): void { initiatives .command("relate ") @@ -30,7 +22,7 @@ export function setupInitiativeRelationCommands(initiatives: Command): void { unknown, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const parentId = await resolveInitiativeId(ctx.sdk, parent); const childId = await resolveInitiativeId(ctx.sdk, child); @@ -55,7 +47,7 @@ export function setupInitiativeRelationCommands(initiatives: Command): void { unknown, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const parentId = await resolveInitiativeId(ctx.sdk, parent); const childId = await resolveInitiativeId(ctx.sdk, child); diff --git a/src/commands/initiatives/updates.ts b/src/commands/initiatives/updates.ts index 43828692..60629653 100644 --- a/src/commands/initiatives/updates.ts +++ b/src/commands/initiatives/updates.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import { createContext } from "../../common/context.js"; +import { createContext, getRootOpts } from "../../common/context.js"; import { invalidParameterError } from "../../common/errors.js"; import { handleCommand, @@ -39,14 +39,6 @@ interface InitiativeUpdatesUpdateOptions { health?: string; } -function rootOptions(command: Command): Record { - let current: Command = command; - while (current.parent) { - current = current.parent; - } - return current.opts(); -} - function parseHealth(value?: string): InitiativeUpdateHealthType | undefined { if (!value) return undefined; @@ -81,7 +73,7 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { InitiativeUpdatesListOptions, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const initiativeId = await resolveInitiativeId( ctx.sdk, @@ -105,7 +97,7 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [updateId, , command] = args as [string, unknown, Command]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const result = await getInitiativeUpdate(ctx.gql, updateId); outputSuccess(result); }), @@ -123,7 +115,7 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { InitiativeUpdatesCreateOptions, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const initiativeId = await resolveInitiativeId( ctx.sdk, @@ -158,7 +150,7 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { InitiativeUpdatesUpdateOptions, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const input: InitiativeUpdateUpdateInput = {}; @@ -189,7 +181,7 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [updateId, , command] = args as [string, unknown, Command]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const result = await archiveInitiativeUpdate(ctx.gql, updateId); outputSuccess(result); }), @@ -201,7 +193,7 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [updateId, , command] = args as [string, unknown, Command]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const result = await unarchiveInitiativeUpdate(ctx.gql, updateId); outputSuccess(result); }), diff --git a/src/commands/issues.ts b/src/commands/issues.ts index f9158c23..8d8080e9 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -1,6 +1,8 @@ import type { Command } from "commander"; import type { CommandContext } from "../common/context.js"; -import { createContext } from "../common/context.js"; +import { createContext, getRootOpts } from "../common/context.js"; +import { resolveReactionEmojiInput } from "../common/emoji.js"; +import { invalidParameterError } from "../common/errors.js"; import { validateEstimateAgainstTeamConfig } from "../common/estimate-validation.js"; import { isUuid, @@ -34,6 +36,23 @@ import { resolveTeamId, } from "../resolvers/team-resolver.js"; import { resolveUserId } from "../resolvers/user-resolver.js"; +import { + createDiscussionCommentReaction, + deleteDiscussionComment, + deleteDiscussionCommentReactionByEmoji, + deleteDiscussionCommentReactionById, + deleteDiscussionReply, + editDiscussionComment, + editDiscussionReply, + listDiscussionReplies, + listDiscussionRepliesWithReactions, + listDiscussionsForIssue, + listDiscussionsForIssueWithReactions, + replyToDiscussion, + resolveDiscussion, + startIssueDiscussion, + unresolveDiscussion, +} from "../services/discussion-service.js"; import { buildIssueFilter } from "../services/issue-filter.js"; import { createIssueRelation, @@ -49,14 +68,21 @@ import { getIssueByIdentifierWithAttachments, getIssueByIdentifierWithComments, getIssueByIdentifierWithCommentThreads, + getIssueByIdentifierWithReactions, getIssueWithAttachments, getIssueWithComments, getIssueWithCommentThreads, + getIssueWithReactions, listIssues, searchIssues, unarchiveIssue, updateIssue, } from "../services/issue-service.js"; +import { + createReactionForIssue, + deleteOwnReactionByEmoji, + deleteOwnReactionById, +} from "../services/reaction-service.js"; interface FilterOptions extends RawFilterFlags { limit: string; @@ -114,6 +140,117 @@ interface ReadOptions { withAttachments?: boolean; withComments?: boolean; withCommentThreads?: boolean; + withReactions?: boolean; +} + +function validateReadOptions(options: ReadOptions): void { + if ( + options.withReactions && + (options.withAttachments || + options.withComments || + options.withCommentThreads) + ) { + throw invalidParameterError( + "--with-reactions", + "cannot be combined with --with-attachments, --with-comments, or --with-comment-threads", + ); + } +} + +interface ReactionOptions { + shortcode?: string; +} + +interface DiscussionsOptions { + limit?: string; + after?: string; + withReactions?: boolean; +} + +interface DiscussionBodyOptions { + body?: string; +} + +interface ResolveDiscussionOptions { + withComment?: string; +} + +function addCommentReactionCommands( + parent: ReturnType, + noun: "thread" | "reply", +): void { + parent + .command(`react <${noun}> [emoji]`) + .description(`add a reaction to a discussion ${noun}`) + .option("--shortcode ", "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), + }); + + outputSuccess(result); + }), + ); + + parent + .command(`unreact <${noun}> [emoji]`) + .description(`remove your reaction from a discussion ${noun} by emoji`) + .option("--shortcode ", "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), + }); + + outputSuccess(result); + }), + ); + + parent + .command(`unreact-id <${noun}> `) + .description( + `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, + }); + + outputSuccess(result); + }), + ); } export const ISSUES_META: DomainMeta = { @@ -323,7 +460,7 @@ export function setupIssuesCommands(program: Command): void { ).action( handleCommand(async (...args: unknown[]) => { const [options, command] = args as [FilterOptions, Command]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const paginationOptions = { limit: parseLimit(options.limit), @@ -362,7 +499,7 @@ export function setupIssuesCommands(program: Command): void { FilterOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const paginationOptions = { limit: parseLimit(options.limit), @@ -390,6 +527,7 @@ export function setupIssuesCommands(program: Command): void { "--with-comment-threads", "group issue comments into root comments with replies", ) + .option("--with-reactions", "include normalized root issue reactions") .addHelpText( "after", `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, @@ -401,7 +539,8 @@ export function setupIssuesCommands(program: Command): void { ReadOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + validateReadOptions(options); + const ctx = createContext(getRootOpts(command)); if (options.withAttachments) { if (isUuid(issue)) { @@ -451,6 +590,22 @@ export function setupIssuesCommands(program: Command): void { return; } + 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; + } + if (isUuid(issue)) { const result = await getIssue(ctx.gql, issue); outputSuccess(result); @@ -466,6 +621,354 @@ export function setupIssuesCommands(program: Command): void { }), ); + issues + .command("react [emoji]") + .description("add a root reaction to an issue") + .option("--shortcode ", "emoji shortcode (e.g. thumbs_up)") + .addHelpText( + "after", + `\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), + }); + + outputSuccess(result); + }), + ); + + issues + .command("unreact [emoji]") + .description("remove your root reaction from an issue by emoji") + .option("--shortcode ", "emoji shortcode (e.g. thumbs_up)") + .addHelpText( + "after", + `\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), + }); + + outputSuccess(result); + }), + ); + + issues + .command("unreact-id ") + .description("remove your root reaction from an issue by reaction ID") + .addHelpText( + "after", + `\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, + }); + + outputSuccess(result); + }), + ); + + issues + .command("discuss ") + .description("start a discussion thread on an issue") + .addHelpText( + "after", + `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, + ) + .option("--body ", "discussion body (required, markdown supported)") + .action( + handleCommand(async (...args: unknown[]) => { + const [issue, options, command] = args as [ + string, + DiscussionBodyOptions, + Command, + ]; + const ctx = createContext(getRootOpts(command)); + + 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, + }); + + outputSuccess(result); + }), + ); + + issues + .command("discussions ") + .description("list root discussion threads on an issue") + .addHelpText( + "after", + `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, + ) + .option("-l, --limit ", "max results", "25") + .option("--after ", "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); + + outputSuccess(result); + }), + ); + + const issueThreads = issues + .command("threads") + .description("discussion thread reaction operations"); + addCommentReactionCommands(issueThreads, "thread"); + + const issueReplies = issues + .command("replies ") + .description("list replies in a root discussion thread") + .option("-l, --limit ", "max results", "50") + .option("--after ", "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, + "issue", + ) + : await listDiscussionReplies( + ctx.gql, + thread, + paginationOptions, + "issue", + ); + + outputSuccess(result); + }), + ); + addCommentReactionCommands(issueReplies, "reply"); + + issues + .command("reply ") + .description("reply to a root discussion thread") + .addHelpText( + "after", + "\nImportant: `` must be a root discussion thread ID.", + ) + .option("--body ", "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: "issue", + }); + + outputSuccess(result); + }), + ); + + issues + .command("edit ") + .description("edit a root discussion or reply comment") + .option("--body ", "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, + }, + "issue", + ); + + outputSuccess(result); + }), + ); + + issues + .command("edit-reply ") + .description("edit a discussion reply") + .option("--body ", "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, + }, + "issue", + ); + + outputSuccess(result); + }), + ); + + issues + .command("delete-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, "issue"); + + outputSuccess(result); + }), + ); + + issues + .command("delete-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, "issue"); + + outputSuccess(result); + }), + ); + + issues + .command("resolve ") + .description("resolve a discussion thread") + .option("--with-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", + }); + + outputSuccess(result); + }), + ); + + issues + .command("unresolve ") + .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, "issue"); + + outputSuccess(result); + }), + ); + issues .command("create ") .description("create new issue") @@ -492,7 +995,7 @@ export function setupIssuesCommands(program: Command): void { CreateOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const relationActions = parseRelationFlags(options); @@ -703,7 +1206,7 @@ export function setupIssuesCommands(program: Command): void { const relationActions = parseRelationFlags(options); - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const issueEstimateContext = parsedEstimate !== undefined @@ -848,7 +1351,7 @@ export function setupIssuesCommands(program: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [issue, , command] = args as [string, unknown, Command]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const issueId = await resolveIssueId(ctx.sdk, issue); const result = await archiveIssue(ctx.gql, issueId); outputSuccess(result); @@ -861,7 +1364,7 @@ export function setupIssuesCommands(program: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [issue, , command] = args as [string, unknown, Command]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const issueId = await resolveIssueId(ctx.sdk, issue); const result = await unarchiveIssue(ctx.gql, issueId); outputSuccess(result); @@ -874,7 +1377,7 @@ export function setupIssuesCommands(program: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [issue, , command] = args as [string, unknown, Command]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const issueId = await resolveIssueId(ctx.sdk, issue); const result = await deleteIssue(ctx.gql, issueId); outputSuccess(result); diff --git a/src/commands/labels.ts b/src/commands/labels.ts index baa70b19..330d0a09 100644 --- a/src/commands/labels.ts +++ b/src/commands/labels.ts @@ -1,10 +1,15 @@ import type { Command } from "commander"; -import { type CommandOptions, createContext } from "../common/context.js"; +import { + type CommandOptions, + createContext, + getRootOpts, +} from "../common/context.js"; import { invalidParameterError } from "../common/errors.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { resolveTeamId } from "../resolvers/team-resolver.js"; import { + type LabelScope, type LabelType, listLabels, listProjectLabels, @@ -13,6 +18,7 @@ import { interface ListLabelsOptions extends CommandOptions { team?: string; type?: string; + scope?: string; limit: string; after?: string; } @@ -25,6 +31,17 @@ function parseLabelType(value?: string): LabelType { throw invalidParameterError("--type", 'must be one of "issue" or "project"'); } +function parseLabelScope(value?: string): LabelScope | undefined { + if (value === undefined || value === "workspace" || value === "team") { + return value; + } + + throw invalidParameterError( + "--scope", + 'must be one of "workspace" or "team"', + ); +} + export const LABELS_META: DomainMeta = { name: "labels", summary: "categorization tags for issues and projects", @@ -51,17 +68,20 @@ export function setupLabelsCommands(program: Command): void { .command("list") .description("list available labels") .option("--type <type>", "label type: issue (default) or project", "issue") + .option("--scope <scope>", "issue label scope: workspace or team") .option("--team <team>", "filter by team (key, name, or UUID)") .option("-l, --limit <n>", "max results", "50") .option("--after <cursor>", "cursor for next page") .action( handleCommand(async (...args: unknown[]) => { const [options, command] = args as [ListLabelsOptions, Command]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const type = parseLabelType(options.type); + const scope = parseLabelScope(options.scope); const pagination = { limit: parseLimit(options.limit), after: options.after, + scope, }; if (type === "project") { @@ -72,10 +92,28 @@ export function setupLabelsCommands(program: Command): void { ); } + if (scope) { + throw invalidParameterError( + "--scope", + "cannot be used with --type project because project labels are always workspace-scoped", + ); + } + outputSuccess(await listProjectLabels(ctx.gql, pagination)); return; } + 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.sdk, options.team) : undefined; diff --git a/src/commands/milestones.ts b/src/commands/milestones.ts index efe8af4a..03715188 100644 --- a/src/commands/milestones.ts +++ b/src/commands/milestones.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import { createContext } from "../common/context.js"; +import { createContext, getRootOpts } from "../common/context.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import type { ProjectMilestoneUpdateInput } from "../gql/graphql.js"; @@ -72,7 +72,7 @@ export function setupMilestonesCommands(program: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [options, command] = args as [MilestoneListOptions, Command]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); // Resolve project ID const projectId = await resolveProjectId(ctx.sdk, options.project); @@ -99,7 +99,7 @@ export function setupMilestonesCommands(program: Command): void { MilestoneReadOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const milestoneId = await resolveMilestoneId( ctx.gql, @@ -132,7 +132,7 @@ export function setupMilestonesCommands(program: Command): void { MilestoneCreateOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); // Resolve project ID const projectId = await resolveProjectId(ctx.sdk, options.project); @@ -167,7 +167,7 @@ export function setupMilestonesCommands(program: Command): void { MilestoneUpdateOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const milestoneId = await resolveMilestoneId( ctx.gql, diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 57fae5f4..3bcddcc8 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; -import { createContext } from "../common/context.js"; +import { createContext, getRootOpts } from "../common/context.js"; +import { resolveReactionEmojiInput } from "../common/emoji.js"; import { invalidParameterError } from "../common/errors.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; @@ -11,6 +12,23 @@ import { import { resolveProjectStatusId } from "../resolvers/project-status-resolver.js"; import { resolveTeamId } from "../resolvers/team-resolver.js"; import { resolveUserId } from "../resolvers/user-resolver.js"; +import { + createDiscussionCommentReaction, + deleteDiscussionComment, + deleteDiscussionCommentReactionByEmoji, + deleteDiscussionCommentReactionById, + deleteDiscussionReply, + editDiscussionComment, + editDiscussionReply, + listDiscussionReplies, + listDiscussionRepliesWithReactions, + listDiscussionsForProject, + listDiscussionsForProjectWithReactions, + replyToDiscussion, + resolveDiscussion, + startProjectDiscussion, + unresolveDiscussion, +} from "../services/discussion-service.js"; import { archiveProject, createProject, @@ -26,6 +44,99 @@ interface ListOptions { after?: string; } +interface DiscussionsOptions { + limit?: string; + after?: string; + withReactions?: boolean; +} + +interface DiscussionBodyOptions { + body?: string; +} + +interface ResolveDiscussionOptions { + withComment?: string; +} + +interface ReactionOptions { + shortcode?: string; +} + +function addCommentReactionCommands( + parent: ReturnType<Command["command"]>, + noun: "thread" | "reply", +): void { + parent + .command(`react <${noun}> [emoji]`) + .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); + }), + ); + + parent + .command(`unreact <${noun}> [emoji]`) + .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); + }), + ); + + parent + .command(`unreact-id <${noun}> <reactionId>`) + .description( + `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); + }), + ); +} + interface CreateOptions { teams: string; description?: string; @@ -97,7 +208,7 @@ export function setupProjectsCommands(program: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [options, command] = args as [ListOptions, Command]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const result = await listProjects(ctx.gql, { limit: parseLimit(options.limit), after: options.after, @@ -112,13 +223,279 @@ export function setupProjectsCommands(program: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [project, , command] = args as [string, unknown, Command]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const projectId = await resolveProjectId(ctx.sdk, project); const result = await getProject(ctx.gql, projectId); outputSuccess(result); }), ); + projects + .command("discuss <project>") + .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)); + + 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, + }); + + outputSuccess(result); + }), + ); + + projects + .command("discussions <project>") + .description("list root discussion threads on a project") + .option("-l, --limit <n>", "max results", "25") + .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); + }), + ); + + const projectThreads = projects + .command("threads") + .description("discussion thread reaction operations"); + addCommentReactionCommands(projectThreads, "thread"); + + const projectReplies = projects + .command("replies <thread>") + .description("list replies in a root discussion thread") + .option("-l, --limit <n>", "max results", "50") + .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", + ); + + outputSuccess(result); + }), + ); + addCommentReactionCommands(projectReplies, "reply"); + + projects + .command("reply <thread>") + .description("reply to a root discussion thread") + .addHelpText( + "after", + "\nImportant: `<thread>` must be a root discussion thread ID.", + ) + .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: "project", + }); + + outputSuccess(result); + }), + ); + + projects + .command("edit <comment>") + .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, + }, + "project", + ); + + outputSuccess(result); + }), + ); + + projects + .command("edit-reply <reply>") + .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, + }, + "project", + ); + + 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)); + + const result = await deleteDiscussionComment( + ctx.gql, + comment, + "project", + ); + + 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)); + + const result = await deleteDiscussionReply(ctx.gql, reply, "project"); + + outputSuccess(result); + }), + ); + + projects + .command("resolve <thread>") + .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); + }), + ); + + 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)); + + const result = await unresolveDiscussion(ctx.gql, thread, "project"); + + outputSuccess(result); + }), + ); + projects .command("create <name>") .description("create a new project") @@ -139,7 +516,7 @@ export function setupProjectsCommands(program: Command): void { CreateOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const teamNames = options.teams .split(",") @@ -229,7 +606,7 @@ export function setupProjectsCommands(program: Command): void { UpdateOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const projectId = await resolveProjectId(ctx.sdk, project); @@ -316,7 +693,7 @@ export function setupProjectsCommands(program: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [project, , command] = args as [string, unknown, Command]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const projectId = await resolveProjectId(ctx.sdk, project); const result = await archiveProject(ctx.gql, projectId); outputSuccess(result); @@ -329,7 +706,7 @@ export function setupProjectsCommands(program: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [project, , command] = args as [string, unknown, Command]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const projectId = await resolveProjectId(ctx.sdk, project, { includeArchived: true, }); @@ -344,7 +721,7 @@ export function setupProjectsCommands(program: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [project, , command] = args as [string, unknown, Command]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const projectId = await resolveProjectId(ctx.sdk, project, { includeArchived: true, }); diff --git a/src/commands/teams.ts b/src/commands/teams.ts index 5ca0f4a8..09b471ef 100644 --- a/src/commands/teams.ts +++ b/src/commands/teams.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import { createContext } from "../common/context.js"; +import { createContext, getRootOpts } from "../common/context.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { resolveTeamId } from "../resolvers/team-resolver.js"; @@ -32,7 +32,7 @@ export function setupTeamsCommands(program: Command): void { { limit: string; after?: string }, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const result = await listTeams(ctx.gql, { limit: parseLimit(options.limit), after: options.after, @@ -48,7 +48,7 @@ export function setupTeamsCommands(program: Command): void { handleCommand(async (...args: unknown[]) => { const team = args[0] as string; const command = args.at(-1) as Command; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const teamId = await resolveTeamId(ctx.sdk, team); const result = await getTeam(ctx.gql, { id: teamId }); outputSuccess(result); diff --git a/src/commands/users.ts b/src/commands/users.ts index 59df6c00..bb68a54e 100644 --- a/src/commands/users.ts +++ b/src/commands/users.ts @@ -1,5 +1,9 @@ import type { Command } from "commander"; -import { type CommandOptions, createContext } from "../common/context.js"; +import { + type CommandOptions, + createContext, + getRootOpts, +} from "../common/context.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { listUsers } from "../services/user-service.js"; @@ -35,7 +39,7 @@ export function setupUsersCommands(program: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [options, command] = args as [ListUsersOptions, Command]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const result = await listUsers(ctx.gql, options.active || false, { limit: parseLimit(options.limit), after: options.after, diff --git a/src/common/context.ts b/src/common/context.ts index 82a93cee..4baa1d62 100644 --- a/src/common/context.ts +++ b/src/common/context.ts @@ -1,3 +1,4 @@ +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"; @@ -20,3 +21,13 @@ export function createContext(options: CommandOptions): CommandContext { export function createGraphQLClient(token: string): GraphQLClient { return new GraphQLClient(token); } + +export function getRootOpts(command: Command): CommandOptions { + let current: Command = command; + + while (current.parent) { + current = current.parent; + } + + return current.opts() as CommandOptions; +} diff --git a/src/common/emoji.ts b/src/common/emoji.ts new file mode 100644 index 00000000..18c3fffa --- /dev/null +++ b/src/common/emoji.ts @@ -0,0 +1,53 @@ +import { emojify, get } from "node-emoji"; + +const SHORTCODE_ALIASES = new Map<string, string>([["thumbs_up", "+1"]]); + +function lookupEmojiByShortcode(shortcode: string): string | undefined { + const directEmoji = get(shortcode); + if (directEmoji) { + return directEmoji; + } + + const alias = SHORTCODE_ALIASES.get(shortcode); + return alias ? get(alias) : undefined; +} + +export function normalizeReactionEmojiInput(raw: string): string { + const emoji = raw.trim(); + if (!emoji) { + throw new Error("emoji must not be empty"); + } + return emoji; +} + +export function resolveReactionEmojiInput( + positionalEmoji: string | undefined, + shortcode: string | undefined, +): string { + const normalizedPositionalEmoji = positionalEmoji?.trim(); + const normalizedShortcode = shortcode?.trim(); + + if (normalizedPositionalEmoji && normalizedShortcode) { + throw new Error("cannot provide both positional emoji and --shortcode"); + } + + if (normalizedPositionalEmoji) { + return normalizeReactionEmojiInput(normalizedPositionalEmoji); + } + + if (!normalizedShortcode) { + throw new Error("emoji or --shortcode is required"); + } + + const emojified = emojify(`:${normalizedShortcode}:`); + const emoji = + emojified !== `:${normalizedShortcode}:` + ? emojified + : lookupEmojiByShortcode(normalizedShortcode); + + if (!emoji) { + throw new Error(`unknown emoji shortcode "${normalizedShortcode}"`); + } + + return normalizeReactionEmojiInput(emoji); +} diff --git a/src/common/resolve-filters.ts b/src/common/resolve-filters.ts index f7e8fe3a..ccd3fcac 100644 --- a/src/common/resolve-filters.ts +++ b/src/common/resolve-filters.ts @@ -1,11 +1,5 @@ -import { resolveCycleId } from "../resolvers/cycle-resolver.js"; -import { resolveIssueId } from "../resolvers/issue-resolver.js"; -import { resolveLabelIds } from "../resolvers/label-resolver.js"; +import { resolveSearchFilterIds } from "../resolvers/issue-filter-resolver.js"; import { resolveMilestoneId } from "../resolvers/milestone-resolver.js"; -import { resolveProjectId } from "../resolvers/project-resolver.js"; -import { resolveStatusId } from "../resolvers/status-resolver.js"; -import { resolveTeamId } from "../resolvers/team-resolver.js"; -import { resolveUserId } from "../resolvers/user-resolver.js"; import type { CommandContext } from "./context.js"; import { invalidParameterError } from "./errors.js"; import { parseDueDate } from "./identifier.js"; @@ -101,62 +95,49 @@ export async function resolveFilterOptions( validateDateRange(opts.updatedAfter, opts.updatedBefore, "updated date"); // 4. ID resolution - const resolved: IssueFilterOptions = {}; + const hasResolvableFilters = + opts.team !== undefined || + opts.assignee !== undefined || + opts.creator !== undefined || + opts.project !== undefined || + parsedStatusNames !== undefined || + parsedLabelNames !== undefined || + opts.cycle !== undefined || + opts.parent !== undefined; - if (opts.team) { - resolved.teamId = await resolveTeamId(ctx.sdk, opts.team); - } - if (opts.assignee) { - resolved.assigneeId = await resolveUserId(ctx.sdk, opts.assignee); - } - if (opts.creator) { - resolved.creatorId = await resolveUserId(ctx.sdk, opts.creator); - } - if (opts.project) { - resolved.projectId = await resolveProjectId(ctx.sdk, opts.project); - } - if (parsedStatusNames) { - const statusIds = await Promise.all( - parsedStatusNames.map((s) => - resolveStatusId(ctx.sdk, s, resolved.teamId), - ), - ); - resolved.stateIds = statusIds; - } - if (parsedLabelNames) { - resolved.labelIds = await resolveLabelIds(ctx.sdk, parsedLabelNames); - } - if (opts.cycle) { - resolved.cycleId = await resolveCycleId( - ctx.sdk, - opts.cycle, - resolved.teamId, - ); - } - if (opts.parent) { - resolved.parentId = await resolveIssueId(ctx.sdk, opts.parent); - } - if (opts.milestone) { - resolved.milestoneId = await resolveMilestoneId( - ctx.gql, - ctx.sdk, - opts.milestone, - resolved.projectId, - ); - } + 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, + }) + : {}; - resolved.priority = parsedPriority; - resolved.estimate = parsedEstimate; - resolved.dueBefore = opts.dueBefore; - resolved.dueAfter = opts.dueAfter; - resolved.createdAfter = opts.createdAfter; - resolved.createdBefore = opts.createdBefore; - resolved.completedAfter = opts.completedAfter; - resolved.completedBefore = opts.completedBefore; - resolved.updatedAfter = opts.updatedAfter; - resolved.updatedBefore = opts.updatedBefore; - resolved.hasBlockers = opts.hasBlockers; - resolved.isBlocking = opts.isBlocking; + const milestoneId = opts.milestone + ? await resolveMilestoneId(ctx.gql, ctx.sdk, opts.milestone, opts.project) + : undefined; + + const resolved: IssueFilterOptions = { + ...batchResolved, + milestoneId, + priority: parsedPriority, + estimate: parsedEstimate, + dueBefore: opts.dueBefore, + dueAfter: opts.dueAfter, + createdAfter: opts.createdAfter, + createdBefore: opts.createdBefore, + completedAfter: opts.completedAfter, + completedBefore: opts.completedBefore, + updatedAfter: opts.updatedAfter, + updatedBefore: opts.updatedBefore, + hasBlockers: opts.hasBlockers, + isBlocking: opts.isBlocking, + }; return resolved; } diff --git a/src/resolvers/issue-filter-resolver.ts b/src/resolvers/issue-filter-resolver.ts new file mode 100644 index 00000000..8fdf3164 --- /dev/null +++ b/src/resolvers/issue-filter-resolver.ts @@ -0,0 +1,79 @@ +import type { LinearSdkClient } from "../client/linear-client.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; + assignee?: string; + creator?: string; + project?: string; + statusNames?: string[]; + labelNames?: string[]; + cycle?: string; + parent?: string; +} + +export interface SearchFilterResolution { + teamId?: string; + assigneeId?: string; + creatorId?: string; + projectId?: string; + stateIds?: string[]; + labelIds?: string[]; + cycleId?: string; + parentId?: string; +} + +export async function resolveSearchFilterIds( + sdkClient: LinearSdkClient, + input: SearchFilterResolutionInput, +): Promise<SearchFilterResolution> { + const resolved: SearchFilterResolution = {}; + + if (input.team) { + resolved.teamId = await resolveTeamId(sdkClient, input.team); + } + + if (input.assignee) { + resolved.assigneeId = await resolveUserId(sdkClient, input.assignee); + } + + if (input.creator) { + resolved.creatorId = await resolveUserId(sdkClient, input.creator); + } + + if (input.project) { + resolved.projectId = await resolveProjectId(sdkClient, input.project); + } + + if (input.statusNames && input.statusNames.length > 0) { + resolved.stateIds = await Promise.all( + input.statusNames.map((status) => + resolveStatusId(sdkClient, status, resolved.teamId), + ), + ); + } + + if (input.labelNames && input.labelNames.length > 0) { + resolved.labelIds = await resolveLabelIds(sdkClient, input.labelNames); + } + + if (input.cycle) { + resolved.cycleId = await resolveCycleId( + sdkClient, + input.cycle, + resolved.teamId ?? input.team, + ); + } + + if (input.parent) { + resolved.parentId = await resolveIssueId(sdkClient, input.parent); + } + + return resolved; +} diff --git a/src/resolvers/issue-resolver.ts b/src/resolvers/issue-resolver.ts index 1a1b7b45..07d6e8c8 100644 --- a/src/resolvers/issue-resolver.ts +++ b/src/resolvers/issue-resolver.ts @@ -1,107 +1,42 @@ import type { LinearSdkClient } from "../client/linear-client.js"; import { notFoundError } from "../common/errors.js"; import { isUuid, parseIssueIdentifier } from "../common/identifier.js"; -import type { TeamEstimateContext } from "./team-resolver.js"; - -type TeamEstimationType = - | "notUsed" - | "exponential" - | "fibonacci" - | "linear" - | "tShirt"; - -type IssueEstimateNode = { - id: string; - team: { - id: string; - key: string; - name: string; - issueEstimationType: TeamEstimationType; - issueEstimationExtended: boolean; - issueEstimationAllowZero: boolean; - }; -}; +import { + resolveTeamEstimateContext, + type TeamEstimateContext, +} from "./team-resolver.js"; function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null; } -function isTeamEstimationType(value: unknown): value is TeamEstimationType { - return ( - value === "notUsed" || - value === "exponential" || - value === "fibonacci" || - value === "linear" || - value === "tShirt" - ); -} - -function toIssueEstimateNode( - node: unknown, - issueIdOrIdentifier: string, -): IssueEstimateNode { - if (!isRecord(node)) { - throw new Error( - `Issue "${issueIdOrIdentifier}" is missing required team estimation context`, - ); +function isPromiseLike(value: unknown): value is PromiseLike<unknown> { + if ((typeof value !== "object" && typeof value !== "function") || !value) { + return false; } - const issueId = node.id; - const team = node.team; + return typeof (value as { then?: unknown }).then === "function"; +} - if (typeof issueId !== "string" || !isRecord(team)) { - throw new Error( - `Issue "${issueIdOrIdentifier}" is missing required team estimation context`, - ); - } +async function resolveRelationValue(value: unknown): Promise<unknown> { + return isPromiseLike(value) ? await value : value; +} - const teamId = team.id; - const teamKey = team.key; - const teamName = team.name; - const issueEstimationType = team.issueEstimationType; - const issueEstimationExtended = team.issueEstimationExtended; - const issueEstimationAllowZero = team.issueEstimationAllowZero; - - if ( - typeof teamId !== "string" || - typeof teamKey !== "string" || - typeof teamName !== "string" || - !isTeamEstimationType(issueEstimationType) || - typeof issueEstimationExtended !== "boolean" || - typeof issueEstimationAllowZero !== "boolean" - ) { - throw new Error( - `Issue "${issueIdOrIdentifier}" is missing required team estimation context`, - ); - } +function getTeamLookupFromRelation(team: unknown): string | undefined { + if (!isRecord(team)) return undefined; - return { - id: issueId, - team: { - id: teamId, - key: teamKey, - name: teamName, - issueEstimationType, - issueEstimationExtended, - issueEstimationAllowZero, - }, - }; + if (typeof team.id === "string") return team.id; + if (typeof team.key === "string") return team.key; + + return undefined; } -function mapIssueNodeToEstimateContext( - node: IssueEstimateNode, -): IssueEstimateContext { - return { - issueId: node.id, - team: { - teamId: node.team.id, - teamKey: node.team.key, - teamName: node.team.name, - issueEstimationType: node.team.issueEstimationType, - issueEstimationExtended: node.team.issueEstimationExtended, - issueEstimationAllowZero: node.team.issueEstimationAllowZero, - }, - }; +async function getIssueTeamLookup( + node: Record<string, unknown>, +): Promise<string | undefined> { + if (typeof node.teamId === "string") return node.teamId; + + return getTeamLookupFromRelation(await resolveRelationValue(node.team)); } export interface IssueEstimateContext { @@ -146,28 +81,45 @@ export async function resolveIssueEstimateContext( client: LinearSdkClient, issueIdOrIdentifier: string, ): Promise<IssueEstimateContext> { - const issues = isUuid(issueIdOrIdentifier) - ? await client.sdk.issues({ + const issueIsUuid = isUuid(issueIdOrIdentifier); + const issues = await (issueIsUuid + ? client.sdk.issues({ filter: { id: { eq: issueIdOrIdentifier } }, first: 1, }) - : await client.sdk.issues({ - filter: { - number: { - eq: parseIssueIdentifier(issueIdOrIdentifier).issueNumber, + : (() => { + const { teamKey, issueNumber } = + parseIssueIdentifier(issueIdOrIdentifier); + + return client.sdk.issues({ + filter: { + number: { eq: issueNumber }, + team: { key: { eq: teamKey } }, }, - team: { - key: { eq: parseIssueIdentifier(issueIdOrIdentifier).teamKey }, - }, - }, - first: 1, - }); + first: 1, + }); + })()); if (issues.nodes.length === 0) { throw notFoundError("Issue", issueIdOrIdentifier); } - return mapIssueNodeToEstimateContext( - toIssueEstimateNode(issues.nodes[0], issueIdOrIdentifier), - ); + const issueNode = issues.nodes[0]; + if (!isRecord(issueNode) || typeof issueNode.id !== "string") { + throw new Error( + `Issue "${issueIdOrIdentifier}" is missing required team context`, + ); + } + + const teamLookup = await getIssueTeamLookup(issueNode); + if (!teamLookup) { + throw new Error( + `Issue "${issueIdOrIdentifier}" is missing required team context`, + ); + } + + return { + issueId: issueNode.id, + team: await resolveTeamEstimateContext(client, teamLookup), + }; } diff --git a/src/services/discussion-service.ts b/src/services/discussion-service.ts new file mode 100644 index 00000000..6571ed9e --- /dev/null +++ b/src/services/discussion-service.ts @@ -0,0 +1,943 @@ +import type { GraphQLClient } from "../client/graphql-client.js"; +import type { PaginatedResult, PaginationOptions } from "../common/types.js"; +import { + type CommentCreateInput, + type CommentUpdateInput, + DeleteDiscussionReplyDocument, + type DeleteDiscussionReplyMutation, + type DiscussionCommentFieldsFragment, + type DiscussionCommentFieldsWithReactionsFragment, + EditDiscussionReplyDocument, + type EditDiscussionReplyMutation, + GetDiscussionCommentContextDocument, + type GetDiscussionCommentContextQuery, + ListInitiativeDiscussionReplyCandidatesDocument, + type ListInitiativeDiscussionReplyCandidatesQuery, + 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, + type StartDiscussionMutation, + UnresolveDiscussionDocument, + type UnresolveDiscussionMutation, +} from "../gql/graphql.js"; +import { + createReactionForComment, + deleteOwnReactionByEmoji, + deleteOwnReactionById, + normalizeReactions, +} from "./reaction-service.js"; + +export type DiscussionThread = DiscussionCommentFieldsFragment; +export type DiscussionThreadWithReactions = Omit< + DiscussionCommentFieldsWithReactionsFragment, + "reactions" +> & { reactions: ReturnType<typeof normalizeReactions> }; +export type DiscussionEntityKind = "issue" | "project" | "initiative"; + +const DEFAULT_ROOT_LIMIT = 25; +const DEFAULT_REPLY_LIMIT = 50; +const DISCUSSION_REPLY_FETCH_LIMIT = 250; + +type DiscussionThreadContext = NonNullable< + GetDiscussionCommentContextQuery["comment"] +>; + +type DiscussionCommentContext = DiscussionThreadContext; + +type DiscussionReplyCandidateQuery = + | ListIssueDiscussionReplyCandidatesQuery + | ListProjectDiscussionReplyCandidatesQuery + | ListInitiativeDiscussionReplyCandidatesQuery; + +type DiscussionReplyCandidateWithReactionsQuery = + | ListIssueDiscussionReplyCandidatesWithReactionsQuery + | ListProjectDiscussionReplyCandidatesWithReactionsQuery + | ListInitiativeDiscussionReplyCandidatesWithReactionsQuery; + +type DiscussionReactionTarget = "thread" | "reply"; +type CreateDiscussionReactionResult = Awaited< + ReturnType<typeof createReactionForComment> +>; +type DeleteDiscussionReactionResult = Awaited< + ReturnType<typeof deleteOwnReactionByEmoji> +>; + +interface DiscussionReactionTargetInput { + commentId: string; + target: DiscussionReactionTarget; + expectedEntityKind?: DiscussionEntityKind; +} + +interface CreateDiscussionReactionInput extends DiscussionReactionTargetInput { + emoji: string; +} + +interface DeleteDiscussionReactionByEmojiInput + extends DiscussionReactionTargetInput { + emoji: string; +} + +interface DeleteDiscussionReactionByIdInput + extends DiscussionReactionTargetInput { + reactionId: string; +} + +function normalizeDiscussionCommentReactions< + T extends { reactions: Parameters<typeof normalizeReactions>[0] }, +>( + comment: T, +): Omit<T, "reactions"> & { + reactions: ReturnType<typeof normalizeReactions>; +} { + return { + ...comment, + reactions: normalizeReactions(comment.reactions), + }; +} + +function normalizeDiscussionCommentsReactions< + T extends { reactions: Parameters<typeof normalizeReactions>[0] }, +>( + comments: readonly T[], +): Array< + Omit<T, "reactions"> & { reactions: ReturnType<typeof normalizeReactions> } +> { + return comments.map(normalizeDiscussionCommentReactions); +} + +function getDiscussionEntityKind( + comment: Pick< + DiscussionCommentContext, + "issueId" | "projectId" | "initiativeId" + >, +): DiscussionEntityKind { + if (comment.issueId) { + return "issue"; + } + + if (comment.projectId) { + return "project"; + } + + if (comment.initiativeId) { + return "initiative"; + } + + throw new Error("Discussion comment has no supported parent entity"); +} + +function assertExpectedDiscussionEntityKind( + comment: DiscussionCommentContext, + expectedEntityKind: DiscussionEntityKind | undefined, + label: "thread" | "reply" | "comment", +): void { + if (!expectedEntityKind) { + return; + } + + const actualEntityKind = getDiscussionEntityKind(comment); + + if (actualEntityKind !== expectedEntityKind) { + throw new Error( + `Discussion ${label} ID "${comment.id}" belongs to ${actualEntityKind}, not ${expectedEntityKind}`, + ); + } +} + +async function assertDiscussionCommentExists( + client: GraphQLClient, + id: string, + expectedEntityKind?: DiscussionEntityKind, + label: "comment" | "reply" = "comment", +): Promise<DiscussionCommentContext> { + const result = await client.request<GetDiscussionCommentContextQuery>( + GetDiscussionCommentContextDocument, + { id }, + ); + + if (!result.comment) { + throw new Error(`Discussion comment ID "${id}" not found`); + } + + assertExpectedDiscussionEntityKind(result.comment, expectedEntityKind, label); + + return result.comment; +} + +async function assertRootDiscussionThread( + client: GraphQLClient, + threadId: string, + expectedEntityKind?: DiscussionEntityKind, +): Promise<DiscussionThreadContext> { + const result = await client.request<GetDiscussionCommentContextQuery>( + GetDiscussionCommentContextDocument, + { id: threadId }, + ); + + if (!result.comment) { + throw new Error(`Discussion thread ID "${threadId}" not found`); + } + + if (result.comment.parentId) { + throw new Error( + `Discussion thread ID "${threadId}" must reference a root comment`, + ); + } + + assertExpectedDiscussionEntityKind( + result.comment, + expectedEntityKind, + "thread", + ); + + return result.comment; +} + +async function assertReplyComment( + client: GraphQLClient, + commentId: string, + expectedEntityKind?: DiscussionEntityKind, +): Promise<DiscussionCommentContext> { + const comment = await assertDiscussionCommentExists( + client, + commentId, + expectedEntityKind, + "reply", + ); + + if (!comment.parentId) { + throw new Error( + `Discussion reply ID "${commentId}" must reference a reply comment`, + ); + } + + return comment; +} + +async function assertDiscussionReactionTarget( + client: GraphQLClient, + input: DiscussionReactionTargetInput, +): Promise<void> { + if (input.target === "thread") { + await assertRootDiscussionThread( + client, + input.commentId, + input.expectedEntityKind, + ); + return; + } + + await assertReplyComment(client, input.commentId, input.expectedEntityKind); +} + +function compareDiscussionCommentsChronologically( + a: Pick<DiscussionCommentFieldsFragment, "createdAt" | "editedAt" | "id">, + b: Pick<DiscussionCommentFieldsFragment, "createdAt" | "editedAt" | "id">, +): number { + const createdAtComparison = a.createdAt.localeCompare(b.createdAt); + + if (createdAtComparison !== 0) { + return createdAtComparison; + } + + const editedAtComparison = (a.editedAt ?? "").localeCompare(b.editedAt ?? ""); + + if (editedAtComparison !== 0) { + return editedAtComparison; + } + + return a.id.localeCompare(b.id); +} + +function getDiscussionThreadEntity( + thread: DiscussionThreadContext, +): + | { kind: "issue"; id: string } + | { kind: "project"; id: string } + | { kind: "initiative"; id: string } { + if (thread.issueId) { + return { kind: "issue", id: thread.issueId }; + } + + if (thread.projectId) { + return { kind: "project", id: thread.projectId }; + } + + if (thread.initiativeId) { + return { kind: "initiative", id: thread.initiativeId }; + } + + throw new Error( + `Discussion thread ID "${thread.id}" has no supported parent entity`, + ); +} + +async function listDiscussionReplyCandidates( + client: GraphQLClient, + thread: DiscussionThreadContext, +): Promise<DiscussionCommentFieldsFragment[]> { + const entity = getDiscussionThreadEntity(thread); + const nodes: DiscussionCommentFieldsFragment[] = []; + let after: string | undefined; + + while (true) { + let result: DiscussionReplyCandidateQuery; + + if (entity.kind === "issue") { + result = await client.request<ListIssueDiscussionReplyCandidatesQuery>( + ListIssueDiscussionReplyCandidatesDocument, + { + issueId: entity.id, + first: DISCUSSION_REPLY_FETCH_LIMIT, + after, + }, + ); + } else if (entity.kind === "project") { + result = await client.request<ListProjectDiscussionReplyCandidatesQuery>( + ListProjectDiscussionReplyCandidatesDocument, + { + projectId: entity.id, + first: DISCUSSION_REPLY_FETCH_LIMIT, + after, + }, + ); + } else { + result = + await client.request<ListInitiativeDiscussionReplyCandidatesQuery>( + ListInitiativeDiscussionReplyCandidatesDocument, + { + initiativeId: entity.id, + first: DISCUSSION_REPLY_FETCH_LIMIT, + after, + }, + ); + } + + nodes.push(...result.comments.nodes); + + if ( + !result.comments.pageInfo.hasNextPage || + !result.comments.pageInfo.endCursor + ) { + break; + } + + after = result.comments.pageInfo.endCursor; + } + + return nodes.sort(compareDiscussionCommentsChronologically); +} + +async function listDiscussionReplyCandidatesWithReactions( + client: GraphQLClient, + thread: DiscussionThreadContext, +): Promise<DiscussionThreadWithReactions[]> { + const entity = getDiscussionThreadEntity(thread); + const nodes: DiscussionCommentFieldsWithReactionsFragment[] = []; + let after: string | undefined; + + while (true) { + let result: DiscussionReplyCandidateWithReactionsQuery; + + if (entity.kind === "issue") { + result = + await client.request<ListIssueDiscussionReplyCandidatesWithReactionsQuery>( + 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, + }, + ); + } else { + result = + await client.request<ListInitiativeDiscussionReplyCandidatesWithReactionsQuery>( + ListInitiativeDiscussionReplyCandidatesWithReactionsDocument, + { + initiativeId: entity.id, + first: DISCUSSION_REPLY_FETCH_LIMIT, + after, + }, + ); + } + + nodes.push(...result.comments.nodes); + + if ( + !result.comments.pageInfo.hasNextPage || + !result.comments.pageInfo.endCursor + ) { + break; + } + + after = result.comments.pageInfo.endCursor; + } + + return normalizeDiscussionCommentsReactions( + nodes.sort(compareDiscussionCommentsChronologically), + ); +} + +function filterThreadReplies<T extends DiscussionCommentFieldsFragment>( + comments: readonly T[], + threadId: string, +): T[] { + const childrenByParentId = new Map<string, T[]>(); + + for (const comment of comments) { + if (!comment.parentId) { + continue; + } + + const siblings = childrenByParentId.get(comment.parentId) ?? []; + siblings.push(comment); + siblings.sort(compareDiscussionCommentsChronologically); + childrenByParentId.set(comment.parentId, siblings); + } + + const replies: T[] = []; + const stack = [...(childrenByParentId.get(threadId) ?? [])].reverse(); + + while (stack.length > 0) { + const current = stack.pop(); + + if (!current) { + continue; + } + + replies.push(current); + + const children = childrenByParentId.get(current.id); + + if (!children) { + continue; + } + + for (let i = children.length - 1; i >= 0; i -= 1) { + stack.push(children[i]); + } + } + + return replies; +} + +function paginateDiscussionReplies<T extends DiscussionCommentFieldsFragment>( + replies: readonly T[], + limit: number, + after?: string, +): PaginatedResult<T> { + const startIndex = + after === undefined + ? 0 + : replies.findIndex((reply) => reply.id === after) + 1; + + if (after !== undefined && startIndex === 0) { + throw new Error(`Discussion reply cursor "${after}" not found`); + } + + const nodes = replies.slice(startIndex, startIndex + limit); + + return { + nodes, + pageInfo: { + hasNextPage: startIndex + limit < replies.length, + endCursor: nodes.at(-1)?.id ?? null, + }, + }; +} + +async function startDiscussion( + client: GraphQLClient, + input: CommentCreateInput, +): Promise<StartDiscussionMutation["commentCreate"]["comment"]> { + const result = await client.request<StartDiscussionMutation>( + StartDiscussionDocument, + { input }, + ); + + if (!result.commentCreate.success || !result.commentCreate.comment) { + throw new Error("Failed to start discussion"); + } + + return result.commentCreate.comment; +} + +export async function createDiscussionCommentReaction( + client: GraphQLClient, + input: CreateDiscussionReactionInput, +): Promise<CreateDiscussionReactionResult> { + await assertDiscussionReactionTarget(client, input); + + return createReactionForComment(client, { + commentId: input.commentId, + emoji: input.emoji, + }); +} + +export async function createIssueDiscussionCommentReaction( + client: GraphQLClient, + input: { commentId: string; emoji: string }, +): Promise<CreateDiscussionReactionResult> { + await assertDiscussionCommentExists(client, input.commentId, "issue"); + + return createReactionForComment(client, { + commentId: input.commentId, + emoji: input.emoji, + }); +} + +export async function deleteDiscussionCommentReactionByEmoji( + client: GraphQLClient, + input: DeleteDiscussionReactionByEmojiInput, +): Promise<DeleteDiscussionReactionResult> { + await assertDiscussionReactionTarget(client, input); + + return deleteOwnReactionByEmoji(client, { + kind: "comment", + id: input.commentId, + emoji: input.emoji, + }); +} + +export async function deleteIssueDiscussionCommentReactionByEmoji( + client: GraphQLClient, + input: { commentId: string; emoji: string }, +): Promise<DeleteDiscussionReactionResult> { + await assertDiscussionCommentExists(client, input.commentId, "issue"); + + return deleteOwnReactionByEmoji(client, { + kind: "comment", + id: input.commentId, + emoji: input.emoji, + }); +} + +export async function deleteDiscussionCommentReactionById( + client: GraphQLClient, + input: DeleteDiscussionReactionByIdInput, +): Promise<DeleteDiscussionReactionResult> { + await assertDiscussionReactionTarget(client, input); + + return deleteOwnReactionById(client, { + kind: "comment", + id: input.commentId, + reactionId: input.reactionId, + }); +} + +export async function deleteIssueDiscussionCommentReactionById( + client: GraphQLClient, + input: { commentId: string; reactionId: string }, +): Promise<DeleteDiscussionReactionResult> { + await assertDiscussionCommentExists(client, input.commentId, "issue"); + + return deleteOwnReactionById(client, { + kind: "comment", + id: input.commentId, + reactionId: input.reactionId, + }); +} + +export async function listDiscussionsForIssue( + client: GraphQLClient, + issueId: string, + options: PaginationOptions = {}, +): Promise<PaginatedResult<DiscussionThread>> { + const { limit = DEFAULT_ROOT_LIMIT, after } = options; + const result = await client.request<ListIssueDiscussionRootsQuery>( + ListIssueDiscussionRootsDocument, + { + issueId, + first: limit, + after, + }, + ); + + if (!result.issue) { + throw new Error(`Issue with ID "${issueId}" not found`); + } + + return { + nodes: result.issue.comments.nodes, + pageInfo: result.issue.comments.pageInfo, + }; +} + +export async function listDiscussionsForIssueWithReactions( + client: GraphQLClient, + issueId: string, + options: PaginationOptions = {}, +): Promise<PaginatedResult<DiscussionThreadWithReactions>> { + const { limit = DEFAULT_ROOT_LIMIT, after } = options; + const result = + await client.request<ListIssueDiscussionRootsWithReactionsQuery>( + ListIssueDiscussionRootsWithReactionsDocument, + { + issueId, + first: limit, + after, + }, + ); + + if (!result.issue) { + throw new Error(`Issue with ID "${issueId}" not found`); + } + + return { + nodes: normalizeDiscussionCommentsReactions(result.issue.comments.nodes), + pageInfo: result.issue.comments.pageInfo, + }; +} + +export async function listDiscussionsForProject( + client: GraphQLClient, + projectId: string, + options: PaginationOptions = {}, +): Promise<PaginatedResult<DiscussionThread>> { + const { limit = DEFAULT_ROOT_LIMIT, after } = options; + const result = await client.request<ListProjectDiscussionRootsQuery>( + ListProjectDiscussionRootsDocument, + { + projectId, + first: limit, + after, + }, + ); + + if (!result.project) { + throw new Error(`Project with ID "${projectId}" not found`); + } + + return { + nodes: result.project.comments.nodes, + pageInfo: result.project.comments.pageInfo, + }; +} + +export async function listDiscussionsForProjectWithReactions( + client: GraphQLClient, + projectId: string, + options: PaginationOptions = {}, +): Promise<PaginatedResult<DiscussionThreadWithReactions>> { + const { limit = DEFAULT_ROOT_LIMIT, after } = options; + const result = + await client.request<ListProjectDiscussionRootsWithReactionsQuery>( + ListProjectDiscussionRootsWithReactionsDocument, + { + projectId, + first: limit, + after, + }, + ); + + if (!result.project) { + throw new Error(`Project with ID "${projectId}" not found`); + } + + return { + nodes: normalizeDiscussionCommentsReactions(result.project.comments.nodes), + pageInfo: result.project.comments.pageInfo, + }; +} + +export async function listDiscussionsForInitiative( + client: GraphQLClient, + initiativeId: string, + 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, + }, + ); + + if (!result.initiative) { + throw new Error(`Initiative with ID "${initiativeId}" not found`); + } + + return { + nodes: result.comments.nodes, + pageInfo: result.comments.pageInfo, + }; +} + +export async function listDiscussionsForInitiativeWithReactions( + client: GraphQLClient, + initiativeId: string, + 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, + }, + ); + + if (!result.initiative) { + throw new Error(`Initiative with ID "${initiativeId}" not found`); + } + + return { + nodes: normalizeDiscussionCommentsReactions(result.comments.nodes), + pageInfo: result.comments.pageInfo, + }; +} + +export async function listDiscussionReplies( + client: GraphQLClient, + threadId: string, + options: PaginationOptions = {}, + expectedEntityKind?: DiscussionEntityKind, +): Promise<PaginatedResult<DiscussionCommentFieldsFragment>> { + const thread = await assertRootDiscussionThread( + client, + threadId, + expectedEntityKind, + ); + const candidates = await listDiscussionReplyCandidates(client, thread); + const replies = filterThreadReplies(candidates, threadId); + const { limit = DEFAULT_REPLY_LIMIT, after } = options; + + return paginateDiscussionReplies(replies, limit, after); +} + +export async function listDiscussionRepliesWithReactions( + client: GraphQLClient, + threadId: string, + options: PaginationOptions = {}, + expectedEntityKind?: DiscussionEntityKind, +): Promise<PaginatedResult<DiscussionThreadWithReactions>> { + const thread = await assertRootDiscussionThread( + client, + threadId, + expectedEntityKind, + ); + const candidates = await listDiscussionReplyCandidatesWithReactions( + client, + thread, + ); + const replies = filterThreadReplies(candidates, threadId); + const { limit = DEFAULT_REPLY_LIMIT, after } = options; + + return paginateDiscussionReplies(replies, limit, after); +} + +export async function startIssueDiscussion( + client: GraphQLClient, + input: { issueId: string; 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 }, +): Promise<StartDiscussionMutation["commentCreate"]["comment"]> { + return startDiscussion(client, { + projectId: input.projectId, + body: input.body, + }); +} + +export async function startInitiativeDiscussion( + client: GraphQLClient, + input: { initiativeId: string; body: string }, +): Promise<StartDiscussionMutation["commentCreate"]["comment"]> { + return startDiscussion(client, { + initiativeId: input.initiativeId, + body: input.body, + }); +} + +export async function replyToDiscussion( + client: GraphQLClient, + input: { threadId: string; 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, + }, + }, + ); + + if (!result.commentCreate.success || !result.commentCreate.comment) { + throw new Error("Failed to create discussion reply"); + } + + return result.commentCreate.comment; +} + +export async function editDiscussionReply( + client: GraphQLClient, + id: string, + 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"); + } + + return result.commentUpdate.comment; +} + +export async function deleteDiscussionReply( + client: GraphQLClient, + id: string, + expectedEntityKind?: DiscussionEntityKind, +): Promise<{ id: string; success: true }> { + await assertReplyComment(client, id, expectedEntityKind); + + const result = await client.request<DeleteDiscussionReplyMutation>( + DeleteDiscussionReplyDocument, + { id }, + ); + + if (!result.commentDelete.success) { + throw new Error("Failed to delete discussion reply"); + } + + return { + id: result.commentDelete.entityId, + success: true, + }; +} + +export async function editDiscussionComment( + client: GraphQLClient, + id: string, + 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"); + } + + return result.commentUpdate.comment; +} + +export async function deleteDiscussionComment( + client: GraphQLClient, + id: string, + expectedEntityKind?: DiscussionEntityKind, +): Promise<{ id: string; success: true }> { + await assertDiscussionCommentExists(client, id, expectedEntityKind); + + const result = await client.request<DeleteDiscussionReplyMutation>( + DeleteDiscussionReplyDocument, + { id }, + ); + + if (!result.commentDelete.success) { + throw new Error("Failed to delete discussion comment"); + } + + return { + id: result.commentDelete.entityId, + success: true, + }; +} + +export async function resolveDiscussion( + client: GraphQLClient, + input: { + threadId: string; + resolvingCommentId?: string; + 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"); + } + + return result.commentResolve.comment; +} + +export async function unresolveDiscussion( + client: GraphQLClient, + threadId: string, + 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"); + } + + return result.commentUnresolve.comment; +} diff --git a/src/services/issue-service.ts b/src/services/issue-service.ts index d0b77805..7f4e0adf 100644 --- a/src/services/issue-service.ts +++ b/src/services/issue-service.ts @@ -33,11 +33,15 @@ import { type GetIssueByIdentifierWithAttachmentsQuery, GetIssueByIdentifierWithCommentsDocument, type GetIssueByIdentifierWithCommentsQuery, + GetIssueByIdentifierWithReactionsDocument, + type GetIssueByIdentifierWithReactionsQuery, type GetIssueByIdQuery, GetIssueByIdWithAttachmentsDocument, type GetIssueByIdWithAttachmentsQuery, GetIssueByIdWithCommentsDocument, type GetIssueByIdWithCommentsQuery, + GetIssueByIdWithReactionsDocument, + type GetIssueByIdWithReactionsQuery, GetIssuesDocument, type GetIssuesQuery, type IssueCreateInput, @@ -52,12 +56,29 @@ import { UpdateIssueDocument, type UpdateIssueMutation, } from "../gql/graphql.js"; +import { normalizeReactions } from "./reaction-service.js"; const NON_COMPLETED_ISSUES_FILTER: IssueFilter = { state: { type: { neq: "completed" } }, }; +function hasExplicitStateFilter(filter: IssueFilter): boolean { + if (filter.state) { + return true; + } + + if (filter.and?.some(hasExplicitStateFilter)) { + return true; + } + + return filter.or?.some(hasExplicitStateFilter) ?? false; +} + function buildListIssuesFilter(filter: IssueFilter): IssueFilter { + if (hasExplicitStateFilter(filter)) { + return filter; + } + return { and: [NON_COMPLETED_ISSUES_FILTER, filter], }; @@ -147,6 +168,31 @@ function threadIssueComments( }; } +type NormalizedIssueReactions = ReturnType<typeof normalizeReactions>; + +type IssueDetailWithReactions = Omit< + NonNullable<GetIssueByIdWithReactionsQuery["issue"]>, + "reactions" +> & { + reactions: NormalizedIssueReactions; +}; + +type IssueByIdentifierWithReactions = Omit< + GetIssueByIdentifierWithReactionsQuery["issues"]["nodes"][0], + "reactions" +> & { + reactions: NormalizedIssueReactions; +}; + +function normalizeIssueReactions< + T extends { reactions: Parameters<typeof normalizeReactions>[0] }, +>(issue: T): Omit<T, "reactions"> & { reactions: NormalizedIssueReactions } { + return { + ...issue, + reactions: normalizeReactions(issue.reactions), + }; +} + export async function listIssues( client: GraphQLClient, options: PaginationOptions = {}, @@ -263,6 +309,37 @@ export async function getIssueByIdentifierWithCommentThreads( return threadIssueComments(issue); } +export async function getIssueWithReactions( + client: GraphQLClient, + id: string, +): Promise<IssueDetailWithReactions> { + const result = await client.request<GetIssueByIdWithReactionsQuery>( + GetIssueByIdWithReactionsDocument, + { id }, + ); + if (!result.issue) { + throw new Error(`Issue with ID "${id}" not found`); + } + return normalizeIssueReactions(result.issue); +} + +export async function getIssueByIdentifierWithReactions( + client: GraphQLClient, + teamKey: string, + issueNumber: number, +): Promise<IssueByIdentifierWithReactions> { + const result = await client.request<GetIssueByIdentifierWithReactionsQuery>( + GetIssueByIdentifierWithReactionsDocument, + { teamKey, number: issueNumber }, + ); + if (!result.issues.nodes.length) { + throw new Error( + `Issue with identifier "${teamKey}-${issueNumber}" not found`, + ); + } + return normalizeIssueReactions(result.issues.nodes[0]); +} + export async function getIssueWithAttachments( client: GraphQLClient, id: string, diff --git a/src/services/label-service.ts b/src/services/label-service.ts index 9b08d782..9d8f485a 100644 --- a/src/services/label-service.ts +++ b/src/services/label-service.ts @@ -5,9 +5,11 @@ import { type GetLabelsQuery, GetProjectLabelsDocument, type GetProjectLabelsQuery, + type IssueLabelFilter, } from "../gql/graphql.js"; export type LabelType = "issue" | "project"; +export type LabelScope = "workspace" | "team"; export interface Label { id: string; @@ -17,13 +19,36 @@ export interface Label { type: LabelType; } +export interface ListLabelOptions extends PaginationOptions { + scope?: LabelScope; +} + +function buildIssueLabelFilter( + teamId?: string, + scope?: LabelScope, +): IssueLabelFilter | undefined { + if (scope === "workspace") { + return { team: { null: true } }; + } + + if (scope === "team" && teamId) { + return { team: { id: { eq: teamId }, null: false } }; + } + + if (teamId) { + return { team: { id: { eq: teamId } } }; + } + + return undefined; +} + export async function listLabels( client: GraphQLClient, teamId?: string, - options: PaginationOptions = {}, + options: ListLabelOptions = {}, ): Promise<PaginatedResult<Label>> { - const { limit = 50, after } = options; - const filter = teamId ? { team: { id: { eq: teamId } } } : undefined; + const { limit = 50, after, scope } = options; + const filter = buildIssueLabelFilter(teamId, scope); const result = await client.request<GetLabelsQuery>(GetLabelsDocument, { first: limit, diff --git a/src/services/reaction-service.ts b/src/services/reaction-service.ts new file mode 100644 index 00000000..c8fe73c1 --- /dev/null +++ b/src/services/reaction-service.ts @@ -0,0 +1,289 @@ +import type { GraphQLClient } from "../client/graphql-client.js"; +import { normalizeReactionEmojiInput } from "../common/emoji.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"; + +type ReactionNode = ReactionReadFieldsFragment; + +interface NormalizedReactionUser { + id: string; + displayName: string; + type: "user" | "external"; +} + +interface NormalizedReactionGroup { + emoji: string; + count: number; + users: NormalizedReactionUser[]; + reactionIds: string[]; +} + +interface ReactionLookupInput { + kind: "issue" | "comment"; + id: string; +} + +interface DeleteOwnReactionByEmojiInput extends ReactionLookupInput { + emoji: string; +} + +interface DeleteOwnReactionByIdInput extends ReactionLookupInput { + reactionId: string; +} + +function compareNormalizedUsers( + a: NormalizedReactionUser, + b: NormalizedReactionUser, +): number { + const nameComparison = a.displayName.localeCompare(b.displayName); + + if (nameComparison !== 0) { + return nameComparison; + } + + const typeComparison = a.type.localeCompare(b.type); + + if (typeComparison !== 0) { + return typeComparison; + } + + return a.id.localeCompare(b.id); +} + +function normalizeReactionUser( + reaction: ReactionNode, +): NormalizedReactionUser | undefined { + if (reaction.user) { + return { + id: reaction.user.id, + displayName: reaction.user.displayName, + type: "user", + }; + } + + if (reaction.externalUser) { + return { + id: reaction.externalUser.id, + displayName: reaction.externalUser.name, + type: "external", + }; + } + + return undefined; +} + +async function getViewerId(client: GraphQLClient): Promise<string> { + const result = await client.request<GetViewerQuery>(GetViewerDocument); + return result.viewer.id; +} + +async function getTargetReactions( + client: GraphQLClient, + input: ReactionLookupInput, +): Promise<ReactionNode[]> { + if (input.kind === "issue") { + const result = await client.request<GetIssueReactionsQuery>( + GetIssueReactionsDocument, + { id: input.id }, + ); + + if (!result.issue) { + throw new Error(`Issue with ID "${input.id}" not found`); + } + + return result.issue.reactions; + } + + const result = await client.request<GetCommentReactionsQuery>( + GetCommentReactionsDocument, + { id: input.id }, + ); + + if (!result.comment) { + throw new Error(`Discussion comment ID "${input.id}" not found`); + } + + return result.comment.reactions; +} + +async function createReaction( + client: GraphQLClient, + input: ReactionCreateInput, + duplicateLookup: ReactionLookupInput, +): Promise<CreateReactionMutation["reactionCreate"]["reaction"]> { + const normalizedEmoji = normalizeReactionEmojiInput(input.emoji); + const normalizedInput = { ...input, emoji: normalizedEmoji }; + const viewerId = await getViewerId(client); + const existingReactions = await getTargetReactions(client, duplicateLookup); + + const duplicateReaction = existingReactions.find( + (reaction) => + reaction.emoji === normalizedEmoji && reaction.user?.id === viewerId, + ); + + if (duplicateReaction) { + throw new Error(`Already reacted with emoji ${normalizedEmoji}`); + } + + const result = await client.request<CreateReactionMutation>( + CreateReactionDocument, + { input: normalizedInput }, + ); + + if (!result.reactionCreate.success) { + throw new Error("Failed to create reaction"); + } + + return result.reactionCreate.reaction; +} + +async function deleteReaction( + client: GraphQLClient, + reactionId: string, +): Promise<{ id: string; success: boolean }> { + const result = await client.request<DeleteReactionMutation>( + DeleteReactionDocument, + { id: reactionId }, + ); + + if (!result.reactionDelete.success) { + throw new Error("Failed to delete reaction"); + } + + return { id: result.reactionDelete.entityId, success: true }; +} + +export function normalizeReactions( + reactions: readonly ReactionNode[], +): NormalizedReactionGroup[] { + const reactionsByEmoji = new Map<string, ReactionNode[]>(); + + for (const reaction of reactions) { + const existing = reactionsByEmoji.get(reaction.emoji); + + if (existing) { + existing.push(reaction); + continue; + } + + reactionsByEmoji.set(reaction.emoji, [reaction]); + } + + return [...reactionsByEmoji.entries()] + .sort((leftEntry, rightEntry) => { + const [leftEmoji, leftReactions] = leftEntry; + const [rightEmoji, rightReactions] = rightEntry; + const countComparison = rightReactions.length - leftReactions.length; + + if (countComparison !== 0) { + return countComparison; + } + + return leftEmoji.localeCompare(rightEmoji); + }) + .map(([emoji, groupedReactions]) => { + const users = groupedReactions + .map(normalizeReactionUser) + .filter((user): user is NormalizedReactionUser => user !== undefined) + .sort(compareNormalizedUsers); + + const reactionIds = groupedReactions + .map((reaction) => reaction.id) + .sort((left, right) => left.localeCompare(right)); + + return { + emoji, + count: groupedReactions.length, + users, + reactionIds, + }; + }); +} + +export async function createReactionForIssue( + client: GraphQLClient, + input: { + issueId: string; + emoji: string; + }, +): Promise<CreateReactionMutation["reactionCreate"]["reaction"]> { + return createReaction( + client, + { issueId: input.issueId, emoji: input.emoji }, + { kind: "issue", id: input.issueId }, + ); +} + +export async function createReactionForComment( + client: GraphQLClient, + input: { + commentId: string; + emoji: string; + }, +): Promise<CreateReactionMutation["reactionCreate"]["reaction"]> { + return createReaction( + client, + { commentId: input.commentId, emoji: input.emoji }, + { kind: "comment", id: input.commentId }, + ); +} + +export async function deleteOwnReactionByEmoji( + client: GraphQLClient, + input: DeleteOwnReactionByEmojiInput, +): Promise<{ id: string; success: boolean }> { + const normalizedEmoji = normalizeReactionEmojiInput(input.emoji); + const viewerId = await getViewerId(client); + const existingReactions = await getTargetReactions(client, input); + + const matchingReactions = existingReactions.filter( + (reaction) => + reaction.emoji === normalizedEmoji && reaction.user?.id === viewerId, + ); + + if (matchingReactions.length === 0) { + throw new Error(`No own reaction found with emoji ${normalizedEmoji}`); + } + + if (matchingReactions.length > 1) { + throw new Error( + `Multiple own reactions found with emoji ${normalizedEmoji}`, + ); + } + + return deleteReaction(client, matchingReactions[0].id); +} + +export async function deleteOwnReactionById( + client: GraphQLClient, + input: DeleteOwnReactionByIdInput, +): Promise<{ id: string; success: boolean }> { + const viewerId = await getViewerId(client); + const existingReactions = await getTargetReactions(client, input); + + const reaction = existingReactions.find( + (candidate) => candidate.id === input.reactionId, + ); + + if (!reaction) { + throw new Error(`Reaction "${input.reactionId}" not found`); + } + + if (reaction.user?.id !== viewerId) { + throw new Error(`Reaction "${input.reactionId}" is not owned by viewer`); + } + + return deleteReaction(client, reaction.id); +} diff --git a/tests/unit/commands/attachments.test.ts b/tests/unit/commands/attachments.test.ts index 6dc37b2d..89cc47f0 100644 --- a/tests/unit/commands/attachments.test.ts +++ b/tests/unit/commands/attachments.test.ts @@ -6,6 +6,7 @@ vi.mock("../../../src/common/context.js", () => ({ gql: { request: vi.fn() }, sdk: {}, })), + getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), })); vi.mock("../../../src/common/output.js", async (importOriginal) => { diff --git a/tests/unit/commands/auth.test.ts b/tests/unit/commands/auth.test.ts index 2186f7fa..888ccfea 100644 --- a/tests/unit/commands/auth.test.ts +++ b/tests/unit/commands/auth.test.ts @@ -24,6 +24,7 @@ vi.mock("../../../src/services/auth-service.js", () => ({ vi.mock("../../../src/common/context.js", () => ({ createGraphQLClient: vi.fn(() => ({})), + getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), })); vi.mock("../../../src/common/auth.js", async (importOriginal) => { diff --git a/tests/unit/commands/comments.test.ts b/tests/unit/commands/comments.test.ts new file mode 100644 index 00000000..f16ee3c6 --- /dev/null +++ b/tests/unit/commands/comments.test.ts @@ -0,0 +1,415 @@ +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() }, + sdk: { sdk: {} }, + })), + 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/services/discussion-service.js", () => ({ + createIssueDiscussionCommentReaction: vi + .fn() + .mockResolvedValue({ id: "reaction-1" }), + deleteIssueDiscussionCommentReactionByEmoji: vi + .fn() + .mockResolvedValue({ id: "reaction-1", success: true }), + deleteIssueDiscussionCommentReactionById: vi + .fn() + .mockResolvedValue({ id: "reaction-1", success: true }), + listDiscussionsForIssue: vi.fn().mockResolvedValue({ + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }), + startIssueDiscussion: vi.fn().mockResolvedValue({ id: "discussion-root-1" }), + replyToDiscussion: vi.fn().mockResolvedValue({ id: "discussion-reply-1" }), + editDiscussionComment: vi + .fn() + .mockResolvedValue({ id: "discussion-comment-1" }), + deleteDiscussionComment: vi + .fn() + .mockResolvedValue({ id: "discussion-comment-1", success: true }), +})); + +import { setupCommentsCommands } from "../../../src/commands/comments.js"; +import { resolveIssueId } from "../../../src/resolvers/issue-resolver.js"; +import { + createIssueDiscussionCommentReaction, + deleteDiscussionComment, + deleteIssueDiscussionCommentReactionByEmoji, + deleteIssueDiscussionCommentReactionById, + editDiscussionComment, + listDiscussionsForIssue, + replyToDiscussion, + startIssueDiscussion, +} from "../../../src/services/discussion-service.js"; + +function createProgram(): Command { + const program = new Command(); + program.option("--api-token <token>"); + setupCommentsCommands(program); + return program; +} + +describe("comments compatibility delegation", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + }); + + it("comments help includes deprecation status and migration hints", () => { + const program = createProgram(); + const comments = program.commands.find( + (command) => command.name() === "comments", + ); + + expect(comments).toBeDefined(); + + const commentsHelp = comments!.helpInformation(); + + expect(commentsHelp).toContain( + "Deprecated compatibility facade for issue discussions", + ); + expect(commentsHelp).toMatch(/Prefer the `issues`\s+discussion commands/i); + expect(commentsHelp).toMatch( + /migrate to `issues\s+discussions\s+<issue>`/i, + ); + expect(commentsHelp).toMatch( + /nested-reply\s+targets are not\s+supported in compatibility mode/i, + ); + }); + + it("comments reply help clarifies root discussion thread ID semantics", () => { + const program = createProgram(); + const comments = program.commands.find( + (command) => command.name() === "comments", + ); + + expect(comments).toBeDefined(); + + const reply = comments!.commands.find( + (command) => command.name() === "reply", + ); + + expect(reply).toBeDefined(); + + const replyHelp = reply!.helpInformation(); + + expect(replyHelp).toContain( + "deprecated compatibility: reply to a root discussion thread", + ); + expect(replyHelp).toMatch( + /migrate\s+to `issues reply <thread> --body <text>`/, + ); + expect(replyHelp).toMatch(/requires root\s+thread ID/); + expect(replyHelp).toMatch( + /Nested-reply targets are not\s+supported in compatibility mode/i, + ); + expect(replyHelp).toContain("reply [options] <thread>"); + }); + + it("comments list resolves issue and delegates to listDiscussionsForIssue", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "comments", + "list", + "ENG-42", + "--limit", + "10", + "--after", + "cursor-1", + ]); + + expect(resolveIssueId).toHaveBeenCalledWith(expect.anything(), "ENG-42"); + expect(listDiscussionsForIssue).toHaveBeenCalledWith( + expect.anything(), + "resolved-issue-uuid", + { limit: 10, after: "cursor-1" }, + ); + }); + + it("comments create resolves issue and delegates to startIssueDiscussion", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "comments", + "create", + "ENG-42", + "--body", + "Kickoff discussion", + ]); + + expect(resolveIssueId).toHaveBeenCalledWith(expect.anything(), "ENG-42"); + expect(startIssueDiscussion).toHaveBeenCalledWith(expect.anything(), { + issueId: "resolved-issue-uuid", + body: "Kickoff discussion", + }); + }); + + it("comments create requires --body", async () => { + const program = createProgram(); + + await program.parseAsync(["node", "test", "comments", "create", "ENG-42"]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Invalid --body: is required"), + ); + expect(startIssueDiscussion).not.toHaveBeenCalled(); + }); + + it("comments reply constrains replies to issue discussion threads", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "comments", + "reply", + "thread-1", + "--body", + "Reply body", + ]); + + expect(replyToDiscussion).toHaveBeenCalledWith(expect.anything(), { + threadId: "thread-1", + body: "Reply body", + entityKind: "issue", + }); + }); + + it("comments edit delegates to editDiscussionComment", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "comments", + "edit", + "reply-1", + "--body", + "Edited body", + ]); + + expect(editDiscussionComment).toHaveBeenCalledWith( + expect.anything(), + "reply-1", + { body: "Edited body" }, + ); + }); + + it("comments reply requires --body", async () => { + const program = createProgram(); + + await program.parseAsync(["node", "test", "comments", "reply", "thread-1"]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Invalid --body: is required"), + ); + expect(replyToDiscussion).not.toHaveBeenCalled(); + }); + + it("comments edit requires --body", async () => { + const program = createProgram(); + + await program.parseAsync(["node", "test", "comments", "edit", "reply-1"]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Invalid --body: is required"), + ); + expect(editDiscussionComment).not.toHaveBeenCalled(); + }); + + it("comments edit accepts root thread IDs for compatibility", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "comments", + "edit", + "root-1", + "--body", + "Edited root", + ]); + + expect(editDiscussionComment).toHaveBeenCalledWith( + expect.anything(), + "root-1", + { body: "Edited root" }, + ); + }); + + it("comments delete delegates to deleteDiscussionComment", async () => { + const program = createProgram(); + + await program.parseAsync(["node", "test", "comments", "delete", "reply-1"]); + + expect(deleteDiscussionComment).toHaveBeenCalledWith( + expect.anything(), + "reply-1", + ); + }); + + it("comments delete accepts root thread IDs for compatibility", async () => { + const program = createProgram(); + + await program.parseAsync(["node", "test", "comments", "delete", "root-1"]); + + expect(deleteDiscussionComment).toHaveBeenCalledWith( + expect.anything(), + "root-1", + ); + }); + + it("comments react help points users to domain-native issue reaction commands", () => { + const program = createProgram(); + const comments = program.commands.find( + (command) => command.name() === "comments", + ); + const react = comments!.commands.find( + (command) => command.name() === "react", + ); + + expect(react).toBeDefined(); + expect(react!.helpInformation()).toMatch( + /DEPRECATED compatibility command/i, + ); + expect(react!.helpInformation()).toMatch( + /Prefer: `issues threads react <thread>`|`issues replies react <reply>`/, + ); + }); + + it("comments react delegates to comment reaction service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "comments", + "react", + "comment-1", + "👍", + ]); + + expect(createIssueDiscussionCommentReaction).toHaveBeenCalledWith( + expect.anything(), + { + commentId: "comment-1", + emoji: "👍", + }, + ); + }); + + it("comments react supports shortcode emoji input", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "comments", + "react", + "comment-1", + "--shortcode", + "thumbs_up", + ]); + + expect(createIssueDiscussionCommentReaction).toHaveBeenCalledWith( + expect.anything(), + { + commentId: "comment-1", + emoji: "👍", + }, + ); + }); + + it("comments unreact delegates to comment reaction service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "comments", + "unreact", + "comment-1", + "👍", + ]); + + expect(deleteIssueDiscussionCommentReactionByEmoji).toHaveBeenCalledWith( + expect.anything(), + { + commentId: "comment-1", + emoji: "👍", + }, + ); + }); + + it("comments unreact supports shortcode emoji input", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "comments", + "unreact", + "comment-1", + "--shortcode", + "thumbs_up", + ]); + + expect(deleteIssueDiscussionCommentReactionByEmoji).toHaveBeenCalledWith( + expect.anything(), + { + commentId: "comment-1", + emoji: "👍", + }, + ); + }); + + it("comments unreact-id delegates to comment reaction service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "comments", + "unreact-id", + "comment-1", + "reaction-1", + ]); + + expect(deleteIssueDiscussionCommentReactionById).toHaveBeenCalledWith( + expect.anything(), + { + commentId: "comment-1", + reactionId: "reaction-1", + }, + ); + }); +}); diff --git a/tests/unit/commands/initiatives.test.ts b/tests/unit/commands/initiatives.test.ts index 3e24db0f..36ce0afb 100644 --- a/tests/unit/commands/initiatives.test.ts +++ b/tests/unit/commands/initiatives.test.ts @@ -6,6 +6,7 @@ vi.mock("../../../src/common/context.js", () => ({ gql: { request: vi.fn() }, sdk: { sdk: {} }, })), + getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), })); vi.mock("../../../src/common/output.js", async (importOriginal) => { @@ -102,11 +103,93 @@ vi.mock("../../../src/services/initiative-update-service.js", () => ({ .mockResolvedValue({ id: "resolved-update-uuid" }), })); +vi.mock("../../../src/services/discussion-service.js", () => ({ + startInitiativeDiscussion: vi + .fn() + .mockResolvedValue({ id: "discussion-root-1" }), + listDiscussionsForInitiative: vi.fn().mockResolvedValue({ + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }), + listDiscussionReplies: vi.fn().mockResolvedValue({ + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }), + listDiscussionsForInitiativeWithReactions: vi.fn().mockResolvedValue({ + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }), + listDiscussionRepliesWithReactions: vi.fn().mockResolvedValue({ + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }), + replyToDiscussion: vi.fn().mockResolvedValue({ id: "discussion-reply-1" }), + editDiscussionReply: vi.fn().mockResolvedValue({ id: "discussion-reply-1" }), + deleteDiscussionReply: vi + .fn() + .mockResolvedValue({ id: "discussion-reply-1", success: true }), + editDiscussionComment: vi + .fn() + .mockResolvedValue({ id: "discussion-comment-1" }), + deleteDiscussionComment: vi + .fn() + .mockResolvedValue({ id: "discussion-comment-1", success: true }), + resolveDiscussion: vi.fn().mockResolvedValue({ id: "discussion-root-1" }), + unresolveDiscussion: vi.fn().mockResolvedValue({ id: "discussion-root-1" }), + createDiscussionCommentReaction: vi + .fn() + .mockResolvedValue({ id: "reaction-1" }), + deleteDiscussionCommentReactionByEmoji: vi + .fn() + .mockResolvedValue({ id: "reaction-1", success: true }), + deleteDiscussionCommentReactionById: vi + .fn() + .mockResolvedValue({ id: "reaction-1", success: true }), +})); + import { setupInitiativesCommands } from "../../../src/commands/initiatives/index.js"; +import { getRootOpts } from "../../../src/common/context.js"; import { outputSuccess } from "../../../src/common/output.js"; import { resolveInitiativeId } from "../../../src/resolvers/initiative-resolver.js"; import { resolveProjectId } from "../../../src/resolvers/project-resolver.js"; import { resolveUserId } from "../../../src/resolvers/user-resolver.js"; +import { + createDiscussionCommentReaction, + deleteDiscussionComment, + deleteDiscussionCommentReactionByEmoji, + deleteDiscussionCommentReactionById, + deleteDiscussionReply, + editDiscussionComment, + editDiscussionReply, + listDiscussionReplies, + listDiscussionRepliesWithReactions, + listDiscussionsForInitiative, + listDiscussionsForInitiativeWithReactions, + replyToDiscussion, + resolveDiscussion, + startInitiativeDiscussion, + unresolveDiscussion, +} from "../../../src/services/discussion-service.js"; import { createInitiativeProjectLink, deleteInitiativeProjectLink, @@ -442,3 +525,465 @@ describe("initiative updates wiring", () => { ); }); }); + +describe("initiative discussion commands", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + }); + + it("wires discuss with initiative resolver and discussion service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "initiatives", + "discuss", + "Growth", + "--body", + "Kickoff thread", + ]); + + expect(resolveInitiativeId).toHaveBeenCalledWith( + expect.anything(), + "Growth", + ); + expect(startInitiativeDiscussion).toHaveBeenCalledWith(expect.anything(), { + initiativeId: "resolved-initiative-uuid", + body: "Kickoff thread", + }); + expect(outputSuccess).toHaveBeenCalledWith({ id: "discussion-root-1" }); + }); + + it("validates discuss requires --body", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "initiatives", + "discuss", + "Growth", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Invalid --body: is required"), + ); + expect(startInitiativeDiscussion).not.toHaveBeenCalled(); + }); + + it("wires discussions with pagination", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "initiatives", + "discussions", + "Growth", + "--limit", + "10", + "--after", + "cursor-1", + ]); + + expect(resolveInitiativeId).toHaveBeenCalledWith( + expect.anything(), + "Growth", + ); + expect(listDiscussionsForInitiative).toHaveBeenCalledWith( + expect.anything(), + "resolved-initiative-uuid", + { limit: 10, after: "cursor-1" }, + ); + expect(outputSuccess).toHaveBeenCalledWith({ + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }); + }); + + it("wires discussions --with-reactions to reaction-aware service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "initiatives", + "discussions", + "Growth", + "--with-reactions", + ]); + + expect(listDiscussionsForInitiativeWithReactions).toHaveBeenCalledWith( + expect.anything(), + "resolved-initiative-uuid", + { limit: 25, after: undefined }, + ); + expect(listDiscussionsForInitiative).not.toHaveBeenCalled(); + }); + + it("wires replies with pagination", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "initiatives", + "replies", + "thread-1", + "--limit", + "15", + "--after", + "cursor-2", + ]); + + expect(listDiscussionReplies).toHaveBeenCalledWith( + expect.anything(), + "thread-1", + { + limit: 15, + after: "cursor-2", + }, + "initiative", + ); + expect(outputSuccess).toHaveBeenCalledWith({ + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }); + }); + + it("wires replies --with-reactions to reaction-aware service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "initiatives", + "replies", + "thread-1", + "--with-reactions", + ]); + + expect(listDiscussionRepliesWithReactions).toHaveBeenCalledWith( + expect.anything(), + "thread-1", + { limit: 50, after: undefined }, + "initiative", + ); + expect(listDiscussionReplies).not.toHaveBeenCalled(); + }); + + it("wires reply", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "initiatives", + "reply", + "thread-1", + "--body", + "Nested reply", + ]); + + expect(replyToDiscussion).toHaveBeenCalledWith(expect.anything(), { + threadId: "thread-1", + body: "Nested reply", + entityKind: "initiative", + }); + expect(outputSuccess).toHaveBeenCalledWith({ id: "discussion-reply-1" }); + }); + + it("validates reply requires --body", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "initiatives", + "reply", + "thread-1", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Invalid --body: is required"), + ); + expect(replyToDiscussion).not.toHaveBeenCalled(); + }); + + it("documents generic edit/delete as root-or-reply while strict reply commands stay reply-only", () => { + const program = createProgram(); + const initiatives = program.commands.find( + (command) => command.name() === "initiatives", + ); + + const edit = initiatives?.commands.find( + (command) => command.name() === "edit", + ); + const del = initiatives?.commands.find( + (command) => command.name() === "delete-comment", + ); + const editReply = initiatives?.commands.find( + (command) => command.name() === "edit-reply", + ); + const deleteReply = initiatives?.commands.find( + (command) => command.name() === "delete-reply", + ); + + expect(edit?.description()).toContain("root discussion or reply"); + expect(del?.description()).toContain("root discussion or reply"); + expect(editReply?.description()).toBe("edit a discussion reply"); + expect(deleteReply?.description()).toBe("delete a discussion reply"); + }); + + it("wires generic edit to discussion comment service", async () => { + const program = createProgram(); + const commentId = "11111111-1111-4111-8111-111111111111"; + + await program.parseAsync([ + "node", + "test", + "initiatives", + "edit", + commentId, + "--body", + "Edited", + ]); + + expect(editDiscussionComment).toHaveBeenCalledWith( + expect.anything(), + commentId, + { body: "Edited" }, + "initiative", + ); + expect(outputSuccess).toHaveBeenCalledWith({ id: "discussion-comment-1" }); + }); + + it("wires edit-reply", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "initiatives", + "edit-reply", + "reply-1", + "--body", + "Edited", + ]); + + expect(editDiscussionReply).toHaveBeenCalledWith( + expect.anything(), + "reply-1", + { body: "Edited" }, + "initiative", + ); + expect(outputSuccess).toHaveBeenCalledWith({ id: "discussion-reply-1" }); + }); + + it("validates edit-reply requires --body", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "initiatives", + "edit-reply", + "reply-1", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Invalid --body: is required"), + ); + expect(editDiscussionReply).not.toHaveBeenCalled(); + }); + + it("wires generic delete-comment to discussion comment service", async () => { + const program = createProgram(); + const commentId = "11111111-1111-4111-8111-111111111111"; + + await program.parseAsync([ + "node", + "test", + "initiatives", + "delete-comment", + commentId, + ]); + + expect(deleteDiscussionComment).toHaveBeenCalledWith( + expect.anything(), + commentId, + "initiative", + ); + expect(outputSuccess).toHaveBeenCalledWith({ + id: "discussion-comment-1", + success: true, + }); + }); + + it("wires delete-reply", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "initiatives", + "delete-reply", + "reply-1", + ]); + + expect(deleteDiscussionReply).toHaveBeenCalledWith( + expect.anything(), + "reply-1", + "initiative", + ); + expect(outputSuccess).toHaveBeenCalledWith({ + id: "discussion-reply-1", + success: true, + }); + }); + + it("wires resolve and forwards --with-comment", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "initiatives", + "resolve", + "thread-1", + "--with-comment", + "comment-123", + ]); + + expect(resolveDiscussion).toHaveBeenCalledWith(expect.anything(), { + threadId: "thread-1", + resolvingCommentId: "comment-123", + entityKind: "initiative", + }); + expect(outputSuccess).toHaveBeenCalledWith({ id: "discussion-root-1" }); + }); + + it("wires unresolve", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "initiatives", + "unresolve", + "thread-1", + ]); + + expect(unresolveDiscussion).toHaveBeenCalledWith( + expect.anything(), + "thread-1", + "initiative", + ); + expect(outputSuccess).toHaveBeenCalledWith({ id: "discussion-root-1" }); + }); + + it("initiatives threads react reads options from the root command", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "--api-token", + "root-token", + "initiatives", + "threads", + "react", + "thread-1", + "👍", + ]); + + expect(getRootOpts).toHaveBeenCalledWith(expect.any(Command)); + }); + + it("initiatives threads react delegates to comment reaction service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "initiatives", + "threads", + "react", + "thread-1", + "🎉", + ]); + + expect(createDiscussionCommentReaction).toHaveBeenCalledWith( + expect.anything(), + { + commentId: "thread-1", + target: "thread", + expectedEntityKind: "initiative", + emoji: "🎉", + }, + ); + }); + + it("initiatives replies unreact supports --shortcode", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "initiatives", + "replies", + "unreact", + "reply-1", + "--shortcode", + "thumbs_up", + ]); + + expect(deleteDiscussionCommentReactionByEmoji).toHaveBeenCalledWith( + expect.anything(), + { + commentId: "reply-1", + target: "reply", + expectedEntityKind: "initiative", + emoji: "👍", + }, + ); + }); + + it("initiatives replies unreact-id delegates to comment reaction service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "initiatives", + "replies", + "unreact-id", + "reply-1", + "reaction-123", + ]); + + expect(deleteDiscussionCommentReactionById).toHaveBeenCalledWith( + expect.anything(), + { + commentId: "reply-1", + target: "reply", + expectedEntityKind: "initiative", + reactionId: "reaction-123", + }, + ); + }); +}); diff --git a/tests/unit/commands/issues.test.ts b/tests/unit/commands/issues.test.ts index f855b0ce..389e790f 100644 --- a/tests/unit/commands/issues.test.ts +++ b/tests/unit/commands/issues.test.ts @@ -8,6 +8,7 @@ vi.mock("../../../src/common/context.js", () => ({ gql: { request: vi.fn() }, sdk: { sdk: {} }, })), + getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), })); vi.mock("../../../src/common/output.js", async (importOriginal) => { @@ -102,6 +103,14 @@ vi.mock("../../../src/services/issue-service.js", () => ({ attachments: { nodes: [{ id: "att-1", title: "PR #42" }] }, }), getIssueByIdentifierWithAttachments: vi.fn(), + getIssueWithReactions: vi.fn().mockResolvedValue({ + id: "resolved-issue-uuid", + reactions: [{ emoji: "👍", count: 1, users: [], reactionIds: ["r-1"] }], + }), + getIssueByIdentifierWithReactions: vi.fn().mockResolvedValue({ + id: "resolved-issue-uuid", + reactions: [{ emoji: "👍", count: 1, users: [], reactionIds: ["r-1"] }], + }), listIssues: vi.fn().mockResolvedValue([]), searchIssues: vi.fn().mockResolvedValue([]), })); @@ -112,6 +121,81 @@ vi.mock("../../../src/services/issue-relation-service.js", () => ({ findIssueRelation: vi.fn(), })); +vi.mock("../../../src/services/reaction-service.js", () => ({ + createReactionForIssue: vi.fn().mockResolvedValue({ id: "reaction-1" }), + createReactionForComment: vi.fn().mockResolvedValue({ id: "reaction-1" }), + deleteOwnReactionByEmoji: vi + .fn() + .mockResolvedValue({ id: "reaction-1", success: true }), + deleteOwnReactionById: vi + .fn() + .mockResolvedValue({ id: "reaction-1", success: true }), +})); + +vi.mock("../../../src/services/discussion-service.js", () => ({ + startIssueDiscussion: vi.fn().mockResolvedValue({ id: "discussion-root-1" }), + listDiscussionsForIssue: vi.fn().mockResolvedValue({ + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }), + listDiscussionReplies: vi.fn().mockResolvedValue({ + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }), + listDiscussionsForIssueWithReactions: vi.fn().mockResolvedValue({ + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }), + listDiscussionRepliesWithReactions: vi.fn().mockResolvedValue({ + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }), + replyToDiscussion: vi.fn().mockResolvedValue({ id: "discussion-reply-1" }), + editDiscussionReply: vi.fn().mockResolvedValue({ id: "discussion-reply-1" }), + deleteDiscussionReply: vi.fn().mockResolvedValue({ + id: "discussion-reply-1", + success: true, + }), + editDiscussionComment: vi + .fn() + .mockResolvedValue({ id: "discussion-comment-1" }), + deleteDiscussionComment: vi.fn().mockResolvedValue({ + id: "discussion-comment-1", + success: true, + }), + resolveDiscussion: vi.fn().mockResolvedValue({ id: "discussion-root-1" }), + unresolveDiscussion: vi.fn().mockResolvedValue({ id: "discussion-root-1" }), + createDiscussionCommentReaction: vi + .fn() + .mockResolvedValue({ id: "reaction-1" }), + deleteDiscussionCommentReactionByEmoji: vi + .fn() + .mockResolvedValue({ id: "reaction-1", success: true }), + deleteDiscussionCommentReactionById: vi + .fn() + .mockResolvedValue({ id: "reaction-1", success: true }), +})); + import { setupIssuesCommands } from "../../../src/commands/issues.js"; import { resolveIssueEstimateContext, @@ -122,6 +206,22 @@ import { resolveTeamId, } from "../../../src/resolvers/team-resolver.js"; import { resolveUserId } from "../../../src/resolvers/user-resolver.js"; +import { + createDiscussionCommentReaction, + deleteDiscussionComment, + deleteDiscussionCommentReactionById, + deleteDiscussionReply, + editDiscussionComment, + editDiscussionReply, + listDiscussionReplies, + listDiscussionRepliesWithReactions, + listDiscussionsForIssue, + listDiscussionsForIssueWithReactions, + replyToDiscussion, + resolveDiscussion, + startIssueDiscussion, + unresolveDiscussion, +} from "../../../src/services/discussion-service.js"; import { archiveIssue, createIssue, @@ -131,14 +231,21 @@ import { getIssueByIdentifierWithAttachments, getIssueByIdentifierWithComments, getIssueByIdentifierWithCommentThreads, + getIssueByIdentifierWithReactions, getIssueWithAttachments, getIssueWithComments, getIssueWithCommentThreads, + getIssueWithReactions, listIssues, searchIssues, unarchiveIssue, updateIssue, } from "../../../src/services/issue-service.js"; +import { + createReactionForIssue, + deleteOwnReactionByEmoji, + deleteOwnReactionById, +} from "../../../src/services/reaction-service.js"; function createProgram(): Command { const program = new Command(); @@ -1064,6 +1171,78 @@ describe("issues read", () => { expect(getIssueWithComments).not.toHaveBeenCalled(); }); + it.each([ + ["--with-attachments"], + ["--with-comments"], + ["--with-comment-threads"], + ])("rejects issues read %s with --with-reactions", async (flag) => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "read", + "550e8400-e29b-41d4-a716-446655440000", + flag, + "--with-reactions", + ]); + + expect(console.error).toHaveBeenCalledWith( + JSON.stringify( + { + error: + "Invalid --with-reactions: cannot be combined with --with-attachments, --with-comments, or --with-comment-threads", + }, + null, + 2, + ), + ); + expect(process.exit).toHaveBeenCalledWith(1); + expect(getIssueWithAttachments).not.toHaveBeenCalled(); + expect(getIssueWithComments).not.toHaveBeenCalled(); + expect(getIssueWithCommentThreads).not.toHaveBeenCalled(); + expect(getIssueWithReactions).not.toHaveBeenCalled(); + }); + + it("issues read --with-reactions routes to reaction-aware issue read for UUIDs", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "read", + "550e8400-e29b-41d4-a716-446655440000", + "--with-reactions", + ]); + + expect(getIssueWithReactions).toHaveBeenCalledWith( + expect.anything(), + "550e8400-e29b-41d4-a716-446655440000", + ); + expect(getIssueWithComments).not.toHaveBeenCalled(); + expect(getIssueWithAttachments).not.toHaveBeenCalled(); + }); + + it("issues read --with-reactions routes to reaction-aware issue read for identifiers", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "read", + "ENG-42", + "--with-reactions", + ]); + + expect(getIssueByIdentifierWithReactions).toHaveBeenCalledWith( + expect.anything(), + "ENG", + 42, + ); + expect(getIssueByIdentifierWithComments).not.toHaveBeenCalled(); + expect(getIssueByIdentifierWithAttachments).not.toHaveBeenCalled(); + }); + it("calls getIssueWithAttachments when flag is set with UUID", async () => { const program = createProgram(); await program.parseAsync([ @@ -1100,6 +1279,91 @@ describe("issues read", () => { }); }); +describe("issues reaction commands", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + }); + + it("issues react resolves issue and delegates to reaction service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "issues", + "react", + "ENG-42", + "👍", + ]); + + expect(resolveIssueId).toHaveBeenCalledWith(expect.anything(), "ENG-42"); + expect(createReactionForIssue).toHaveBeenCalledWith(expect.anything(), { + issueId: "resolved-issue-uuid", + emoji: "👍", + }); + }); + + it("issues react supports --shortcode", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "issues", + "react", + "ENG-42", + "--shortcode", + "thumbs_up", + ]); + + expect(createReactionForIssue).toHaveBeenCalledWith(expect.anything(), { + issueId: "resolved-issue-uuid", + emoji: "👍", + }); + }); + + it("issues unreact resolves issue and deletes viewer reaction by emoji", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "issues", + "unreact", + "ENG-42", + "👍", + ]); + + expect(deleteOwnReactionByEmoji).toHaveBeenCalledWith(expect.anything(), { + kind: "issue", + id: "resolved-issue-uuid", + emoji: "👍", + }); + }); + + it("issues unreact-id resolves issue and deletes viewer reaction by id", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "issues", + "unreact-id", + "ENG-42", + "reaction-123", + ]); + + expect(deleteOwnReactionById).toHaveBeenCalledWith(expect.anything(), { + kind: "issue", + id: "resolved-issue-uuid", + reactionId: "reaction-123", + }); + }); +}); + describe("issues lifecycle commands", () => { beforeEach(() => { vi.clearAllMocks(); @@ -1145,6 +1409,402 @@ describe("issues lifecycle commands", () => { }); }); +describe("issues discussion commands", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + }); + + it("issues discuss resolves issue and starts thread", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "issues", + "discuss", + "ENG-42", + "--body", + "Need decision", + ]); + + expect(resolveIssueId).toHaveBeenCalledWith(expect.anything(), "ENG-42"); + expect(startIssueDiscussion).toHaveBeenCalledWith(expect.anything(), { + issueId: "resolved-issue-uuid", + body: "Need decision", + }); + }); + + it("issues discuss requires --body", async () => { + const program = createProgram(); + + await program.parseAsync(["node", "test", "issues", "discuss", "ENG-42"]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Invalid --body: is required"), + ); + expect(startIssueDiscussion).not.toHaveBeenCalled(); + }); + + it("issues discussions resolves issue and forwards pagination", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "issues", + "discussions", + "ENG-42", + "--limit", + "10", + "--after", + "cursor-1", + ]); + + expect(resolveIssueId).toHaveBeenCalledWith(expect.anything(), "ENG-42"); + expect(listDiscussionsForIssue).toHaveBeenCalledWith( + expect.anything(), + "resolved-issue-uuid", + { limit: 10, after: "cursor-1" }, + ); + }); + + it("issues discussions --with-reactions routes to reaction-aware service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "issues", + "discussions", + "ENG-42", + "--with-reactions", + ]); + + expect(listDiscussionsForIssueWithReactions).toHaveBeenCalledWith( + expect.anything(), + "resolved-issue-uuid", + { limit: 25, after: undefined }, + ); + expect(listDiscussionsForIssue).not.toHaveBeenCalled(); + }); + + it("issues replies forwards pagination", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "issues", + "replies", + "thread-1", + "--limit", + "15", + "--after", + "cursor-2", + ]); + + expect(listDiscussionReplies).toHaveBeenCalledWith( + expect.anything(), + "thread-1", + { + limit: 15, + after: "cursor-2", + }, + "issue", + ); + }); + + it("issues replies --with-reactions routes to reaction-aware service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "issues", + "replies", + "thread-1", + "--with-reactions", + ]); + + expect(listDiscussionRepliesWithReactions).toHaveBeenCalledWith( + expect.anything(), + "thread-1", + { limit: 50, after: undefined }, + "issue", + ); + expect(listDiscussionReplies).not.toHaveBeenCalled(); + }); + + it("issues reply requires --body", async () => { + const program = createProgram(); + + await program.parseAsync(["node", "test", "issues", "reply", "thread-1"]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Invalid --body: is required"), + ); + expect(replyToDiscussion).not.toHaveBeenCalled(); + }); + + it("issues reply delegates to discussion service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "issues", + "reply", + "thread-1", + "--body", + "Nested reply", + ]); + + expect(replyToDiscussion).toHaveBeenCalledWith(expect.anything(), { + threadId: "thread-1", + body: "Nested reply", + entityKind: "issue", + }); + }); + + it("issues delete-comment deletes root or reply discussion comments", async () => { + const program = createProgram(); + const commentId = "11111111-1111-4111-8111-111111111111"; + + await program.parseAsync([ + "node", + "test", + "issues", + "delete-comment", + commentId, + ]); + + expect(deleteDiscussionComment).toHaveBeenCalledWith( + expect.anything(), + commentId, + "issue", + ); + expect(deleteIssue).not.toHaveBeenCalled(); + }); + + it("issues generic edit/delete help documents root or reply IDs while strict reply commands stay reply-only", () => { + const program = createProgram(); + const issues = program.commands.find( + (command) => command.name() === "issues", + ); + + const edit = issues?.commands.find((command) => command.name() === "edit"); + const del = issues?.commands.find( + (command) => command.name() === "delete-comment", + ); + const editReply = issues?.commands.find( + (command) => command.name() === "edit-reply", + ); + const deleteReply = issues?.commands.find( + (command) => command.name() === "delete-reply", + ); + + expect(edit?.description()).toContain("root discussion or reply"); + expect(del?.description()).toContain("root discussion or reply"); + expect(editReply?.description()).toBe("edit a discussion reply"); + expect(deleteReply?.description()).toBe("delete a discussion reply"); + }); + + it("issues edit delegates to generic discussion comment service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "issues", + "edit", + "11111111-1111-4111-8111-111111111111", + "--body", + "Edited", + ]); + + expect(editDiscussionComment).toHaveBeenCalledWith( + expect.anything(), + "11111111-1111-4111-8111-111111111111", + { body: "Edited" }, + "issue", + ); + }); + + it("issues edit-reply delegates to discussion service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "issues", + "edit-reply", + "reply-1", + "--body", + "Edited", + ]); + + expect(editDiscussionReply).toHaveBeenCalledWith( + expect.anything(), + "reply-1", + { body: "Edited" }, + "issue", + ); + }); + + it("issues edit-reply requires --body", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "issues", + "edit-reply", + "reply-1", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Invalid --body: is required"), + ); + expect(editDiscussionReply).not.toHaveBeenCalled(); + }); + + it("issues delete-comment delegates to generic discussion comment service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "issues", + "delete-comment", + "11111111-1111-4111-8111-111111111111", + ]); + + expect(deleteDiscussionComment).toHaveBeenCalledWith( + expect.anything(), + "11111111-1111-4111-8111-111111111111", + "issue", + ); + }); + + it("issues delete-reply delegates to discussion service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "issues", + "delete-reply", + "reply-1", + ]); + + expect(deleteDiscussionReply).toHaveBeenCalledWith( + expect.anything(), + "reply-1", + "issue", + ); + }); + + it("issues resolve delegates to discussion service", async () => { + const program = createProgram(); + + await program.parseAsync(["node", "test", "issues", "resolve", "thread-1"]); + + expect(resolveDiscussion).toHaveBeenCalledWith(expect.anything(), { + threadId: "thread-1", + entityKind: "issue", + }); + }); + + it("issues resolve forwards --with-comment as resolvingCommentId", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "issues", + "resolve", + "thread-1", + "--with-comment", + "comment-123", + ]); + + expect(resolveDiscussion).toHaveBeenCalledWith(expect.anything(), { + threadId: "thread-1", + resolvingCommentId: "comment-123", + entityKind: "issue", + }); + }); + + it("issues unresolve delegates to discussion service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "issues", + "unresolve", + "thread-1", + ]); + + expect(unresolveDiscussion).toHaveBeenCalledWith( + expect.anything(), + "thread-1", + "issue", + ); + }); + + it("issues threads react delegates to comment reaction service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "issues", + "threads", + "react", + "thread-1", + "🎉", + ]); + + expect(createDiscussionCommentReaction).toHaveBeenCalledWith( + expect.anything(), + { + commentId: "thread-1", + target: "thread", + expectedEntityKind: "issue", + emoji: "🎉", + }, + ); + }); + + it("issues replies unreact-id delegates to comment reaction service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "issues", + "replies", + "unreact-id", + "reply-1", + "reaction-123", + ]); + + expect(deleteDiscussionCommentReactionById).toHaveBeenCalledWith( + expect.anything(), + { + commentId: "reply-1", + target: "reply", + expectedEntityKind: "issue", + reactionId: "reaction-123", + }, + ); + }); +}); + describe("issues create relations", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/tests/unit/commands/labels.test.ts b/tests/unit/commands/labels.test.ts index 56176b18..4388da0c 100644 --- a/tests/unit/commands/labels.test.ts +++ b/tests/unit/commands/labels.test.ts @@ -6,6 +6,7 @@ vi.mock("../../../src/common/context.js", () => ({ gql: { request: vi.fn() }, sdk: { sdk: {} }, })), + getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), })); vi.mock("../../../src/common/output.js", async (importOriginal) => { @@ -71,6 +72,7 @@ describe("labels list", () => { expect(listLabels).toHaveBeenCalledWith(expect.anything(), undefined, { limit: 50, after: undefined, + scope: undefined, }); expect(listProjectLabels).not.toHaveBeenCalled(); expect(resolveTeamId).not.toHaveBeenCalled(); @@ -103,6 +105,55 @@ describe("labels list", () => { { limit: 10, after: "cur1", + scope: undefined, + }, + ); + expect(listProjectLabels).not.toHaveBeenCalled(); + }); + + it("passes workspace scope without team resolution", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "list", + "--scope", + "workspace", + ]); + + expect(listLabels).toHaveBeenCalledWith(expect.anything(), undefined, { + limit: 50, + after: undefined, + scope: "workspace", + }); + expect(resolveTeamId).not.toHaveBeenCalled(); + expect(listProjectLabels).not.toHaveBeenCalled(); + }); + + it("resolves team for explicit team scope", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "list", + "--scope", + "team", + "--team", + "ENG", + ]); + + expect(resolveTeamId).toHaveBeenCalledWith(expect.anything(), "ENG"); + expect(listLabels).toHaveBeenCalledWith( + expect.anything(), + "resolved-team-uuid", + { + limit: 50, + after: undefined, + scope: "team", }, ); expect(listProjectLabels).not.toHaveBeenCalled(); @@ -123,6 +174,7 @@ describe("labels list", () => { expect(listLabels).toHaveBeenCalledWith(expect.anything(), undefined, { limit: 50, after: undefined, + scope: undefined, }); expect(listProjectLabels).not.toHaveBeenCalled(); expect(resolveTeamId).not.toHaveBeenCalled(); @@ -189,6 +241,80 @@ describe("labels list validation", () => { expect(resolveTeamId).not.toHaveBeenCalled(); }); + it("rejects unsupported scope values", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "list", + "--scope", + "org", + ]); + + const errorOutput = JSON.parse( + vi.mocked(console.error).mock.calls[0][0] as string, + ) as { error: string }; + + expect(errorOutput.error).toBe( + 'Invalid --scope: must be one of "workspace" or "team"', + ); + expect(listLabels).not.toHaveBeenCalled(); + expect(listProjectLabels).not.toHaveBeenCalled(); + expect(resolveTeamId).not.toHaveBeenCalled(); + }); + + it("rejects team scope without a team filter", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "list", + "--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(listLabels).not.toHaveBeenCalled(); + expect(listProjectLabels).not.toHaveBeenCalled(); + expect(resolveTeamId).not.toHaveBeenCalled(); + }); + + it("rejects team filters for workspace scope", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "list", + "--scope", + "workspace", + "--team", + "ENG", + ]); + + const errorOutput = JSON.parse( + vi.mocked(console.error).mock.calls[0][0] as string, + ) as { error: string }; + + expect(errorOutput.error).toBe( + "Invalid --team: cannot be used with --scope workspace", + ); + expect(listLabels).not.toHaveBeenCalled(); + expect(listProjectLabels).not.toHaveBeenCalled(); + expect(resolveTeamId).not.toHaveBeenCalled(); + }); + it("rejects team filters for project labels", async () => { const program = createProgram(); @@ -214,4 +340,30 @@ describe("labels list validation", () => { expect(listProjectLabels).not.toHaveBeenCalled(); expect(resolveTeamId).not.toHaveBeenCalled(); }); + + it("rejects scope filters for project labels", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "list", + "--type", + "project", + "--scope", + "workspace", + ]); + + const errorOutput = JSON.parse( + vi.mocked(console.error).mock.calls[0][0] as string, + ) as { error: string }; + + expect(errorOutput.error).toBe( + "Invalid --scope: cannot be used with --type project because project labels are always workspace-scoped", + ); + expect(listLabels).not.toHaveBeenCalled(); + expect(listProjectLabels).not.toHaveBeenCalled(); + expect(resolveTeamId).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/commands/projects.test.ts b/tests/unit/commands/projects.test.ts index d093a965..9f142991 100644 --- a/tests/unit/commands/projects.test.ts +++ b/tests/unit/commands/projects.test.ts @@ -6,6 +6,7 @@ vi.mock("../../../src/common/context.js", () => ({ gql: { request: vi.fn() }, sdk: { sdk: {} }, })), + getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), })); vi.mock("../../../src/common/output.js", async (importOriginal) => { @@ -44,9 +45,92 @@ vi.mock("../../../src/services/project-service.js", () => ({ updateProject: vi.fn().mockResolvedValue({ id: "proj-1" }), })); +vi.mock("../../../src/services/discussion-service.js", () => ({ + startProjectDiscussion: vi + .fn() + .mockResolvedValue({ id: "discussion-root-1" }), + listDiscussionsForProject: vi.fn().mockResolvedValue({ + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }), + listDiscussionReplies: vi.fn().mockResolvedValue({ + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }), + listDiscussionsForProjectWithReactions: vi.fn().mockResolvedValue({ + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }), + listDiscussionRepliesWithReactions: vi.fn().mockResolvedValue({ + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }), + replyToDiscussion: vi.fn().mockResolvedValue({ id: "discussion-reply-1" }), + editDiscussionReply: vi.fn().mockResolvedValue({ id: "discussion-reply-1" }), + deleteDiscussionReply: vi.fn().mockResolvedValue({ + id: "discussion-reply-1", + success: true, + }), + editDiscussionComment: vi + .fn() + .mockResolvedValue({ id: "discussion-comment-1" }), + deleteDiscussionComment: vi.fn().mockResolvedValue({ + id: "discussion-comment-1", + success: true, + }), + resolveDiscussion: vi.fn().mockResolvedValue({ id: "discussion-root-1" }), + unresolveDiscussion: vi.fn().mockResolvedValue({ id: "discussion-root-1" }), + createDiscussionCommentReaction: vi + .fn() + .mockResolvedValue({ id: "reaction-1" }), + deleteDiscussionCommentReactionByEmoji: vi + .fn() + .mockResolvedValue({ id: "reaction-1", success: true }), + deleteDiscussionCommentReactionById: vi + .fn() + .mockResolvedValue({ id: "reaction-1", success: true }), +})); + import { setupProjectsCommands } from "../../../src/commands/projects.js"; import { outputSuccess } from "../../../src/common/output.js"; import { resolveProjectId } from "../../../src/resolvers/project-resolver.js"; +import { + createDiscussionCommentReaction, + deleteDiscussionComment, + deleteDiscussionCommentReactionByEmoji, + deleteDiscussionCommentReactionById, + deleteDiscussionReply, + editDiscussionComment, + editDiscussionReply, + listDiscussionReplies, + listDiscussionRepliesWithReactions, + listDiscussionsForProject, + listDiscussionsForProjectWithReactions, + replyToDiscussion, + resolveDiscussion, + startProjectDiscussion, + unresolveDiscussion, +} from "../../../src/services/discussion-service.js"; import { archiveProject, createProject, @@ -265,6 +349,462 @@ describe("projects create --priority", () => { }); }); +describe("projects discussion commands", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + }); + + it("projects discuss resolves project and delegates to discussion service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "projects", + "discuss", + "My Project", + "--body", + "Kickoff thread", + ]); + + expect(resolveProjectId).toHaveBeenCalledWith( + expect.anything(), + "My Project", + ); + expect(startProjectDiscussion).toHaveBeenCalledWith(expect.anything(), { + projectId: "resolved-project-uuid", + body: "Kickoff thread", + }); + expect(outputSuccess).toHaveBeenCalledWith({ id: "discussion-root-1" }); + }); + + it("projects discuss requires --body", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "projects", + "discuss", + "My Project", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Invalid --body: is required"), + ); + expect(startProjectDiscussion).not.toHaveBeenCalled(); + }); + + it("projects discussions resolves project and forwards pagination", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "projects", + "discussions", + "My Project", + "--limit", + "10", + "--after", + "cursor-1", + ]); + + expect(resolveProjectId).toHaveBeenCalledWith( + expect.anything(), + "My Project", + ); + expect(listDiscussionsForProject).toHaveBeenCalledWith( + expect.anything(), + "resolved-project-uuid", + { limit: 10, after: "cursor-1" }, + ); + expect(outputSuccess).toHaveBeenCalledWith({ + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }); + }); + + it("projects discussions --with-reactions routes to reaction-aware service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "projects", + "discussions", + "My Project", + "--with-reactions", + ]); + + expect(listDiscussionsForProjectWithReactions).toHaveBeenCalledWith( + expect.anything(), + "resolved-project-uuid", + { limit: 25, after: undefined }, + ); + expect(listDiscussionsForProject).not.toHaveBeenCalled(); + }); + + it("projects replies forwards pagination", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "projects", + "replies", + "thread-1", + "--limit", + "15", + "--after", + "cursor-2", + ]); + + expect(listDiscussionReplies).toHaveBeenCalledWith( + expect.anything(), + "thread-1", + { + limit: 15, + after: "cursor-2", + }, + "project", + ); + expect(outputSuccess).toHaveBeenCalledWith({ + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }); + }); + + it("projects replies --with-reactions routes to reaction-aware service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "projects", + "replies", + "thread-1", + "--with-reactions", + ]); + + expect(listDiscussionRepliesWithReactions).toHaveBeenCalledWith( + expect.anything(), + "thread-1", + { limit: 50, after: undefined }, + "project", + ); + expect(listDiscussionReplies).not.toHaveBeenCalled(); + }); + + it("projects reply delegates to discussion service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "projects", + "reply", + "thread-1", + "--body", + "Nested reply", + ]); + + expect(replyToDiscussion).toHaveBeenCalledWith(expect.anything(), { + threadId: "thread-1", + body: "Nested reply", + entityKind: "project", + }); + expect(outputSuccess).toHaveBeenCalledWith({ id: "discussion-reply-1" }); + }); + + it("projects reply requires --body", async () => { + const program = createProgram(); + + await program.parseAsync(["node", "test", "projects", "reply", "thread-1"]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Invalid --body: is required"), + ); + expect(replyToDiscussion).not.toHaveBeenCalled(); + }); + + it("projects generic edit/delete help documents root or reply IDs while strict reply commands stay reply-only", () => { + const program = createProgram(); + const projects = program.commands.find( + (command) => command.name() === "projects", + ); + + const edit = projects?.commands.find( + (command) => command.name() === "edit", + ); + const del = projects?.commands.find( + (command) => command.name() === "delete-comment", + ); + const editReply = projects?.commands.find( + (command) => command.name() === "edit-reply", + ); + const deleteReply = projects?.commands.find( + (command) => command.name() === "delete-reply", + ); + + expect(edit?.description()).toContain("root discussion or reply"); + expect(del?.description()).toContain("root discussion or reply"); + expect(editReply?.description()).toBe("edit a discussion reply"); + expect(deleteReply?.description()).toBe("delete a discussion reply"); + }); + + it("projects edit delegates to generic discussion comment service", async () => { + const program = createProgram(); + const commentId = "11111111-1111-4111-8111-111111111111"; + + await program.parseAsync([ + "node", + "test", + "projects", + "edit", + commentId, + "--body", + "Edited", + ]); + + expect(editDiscussionComment).toHaveBeenCalledWith( + expect.anything(), + commentId, + { body: "Edited" }, + "project", + ); + expect(outputSuccess).toHaveBeenCalledWith({ id: "discussion-comment-1" }); + }); + + it("projects edit-reply delegates to discussion service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "projects", + "edit-reply", + "reply-1", + "--body", + "Edited", + ]); + + expect(editDiscussionReply).toHaveBeenCalledWith( + expect.anything(), + "reply-1", + { body: "Edited" }, + "project", + ); + expect(outputSuccess).toHaveBeenCalledWith({ id: "discussion-reply-1" }); + }); + + it("projects edit-reply requires --body", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "projects", + "edit-reply", + "reply-1", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Invalid --body: is required"), + ); + expect(editDiscussionReply).not.toHaveBeenCalled(); + }); + + it("projects delete-comment delegates to generic discussion comment service", async () => { + const program = createProgram(); + const commentId = "11111111-1111-4111-8111-111111111111"; + + await program.parseAsync([ + "node", + "test", + "projects", + "delete-comment", + commentId, + ]); + + expect(deleteDiscussionComment).toHaveBeenCalledWith( + expect.anything(), + commentId, + "project", + ); + expect(outputSuccess).toHaveBeenCalledWith({ + id: "discussion-comment-1", + success: true, + }); + }); + + it("projects delete-reply delegates to discussion service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "projects", + "delete-reply", + "reply-1", + ]); + + expect(deleteDiscussionReply).toHaveBeenCalledWith( + expect.anything(), + "reply-1", + "project", + ); + expect(outputSuccess).toHaveBeenCalledWith({ + id: "discussion-reply-1", + success: true, + }); + }); + + it("projects resolve delegates to discussion service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "projects", + "resolve", + "thread-1", + ]); + + expect(resolveDiscussion).toHaveBeenCalledWith(expect.anything(), { + threadId: "thread-1", + entityKind: "project", + }); + expect(outputSuccess).toHaveBeenCalledWith({ id: "discussion-root-1" }); + }); + + it("projects resolve forwards --with-comment as resolvingCommentId", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "projects", + "resolve", + "thread-1", + "--with-comment", + "comment-123", + ]); + + expect(resolveDiscussion).toHaveBeenCalledWith(expect.anything(), { + threadId: "thread-1", + resolvingCommentId: "comment-123", + entityKind: "project", + }); + expect(outputSuccess).toHaveBeenCalledWith({ id: "discussion-root-1" }); + }); + + it("projects unresolve delegates to discussion service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "projects", + "unresolve", + "thread-1", + ]); + + expect(unresolveDiscussion).toHaveBeenCalledWith( + expect.anything(), + "thread-1", + "project", + ); + expect(outputSuccess).toHaveBeenCalledWith({ id: "discussion-root-1" }); + }); + + it("projects threads react delegates to comment reaction service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "projects", + "threads", + "react", + "thread-1", + "🎉", + ]); + + expect(createDiscussionCommentReaction).toHaveBeenCalledWith( + expect.anything(), + { + commentId: "thread-1", + target: "thread", + expectedEntityKind: "project", + emoji: "🎉", + }, + ); + }); + + it("projects threads unreact supports --shortcode", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "projects", + "threads", + "unreact", + "thread-1", + "--shortcode", + "thumbs_up", + ]); + + expect(deleteDiscussionCommentReactionByEmoji).toHaveBeenCalledWith( + expect.anything(), + { + commentId: "thread-1", + target: "thread", + expectedEntityKind: "project", + emoji: "👍", + }, + ); + }); + + it("projects replies unreact-id delegates to comment reaction service", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "projects", + "replies", + "unreact-id", + "reply-1", + "reaction-123", + ]); + + expect(deleteDiscussionCommentReactionById).toHaveBeenCalledWith( + expect.anything(), + { + commentId: "reply-1", + target: "reply", + expectedEntityKind: "project", + reactionId: "reaction-123", + }, + ); + }); +}); + describe("projects update", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/tests/unit/commands/teams.test.ts b/tests/unit/commands/teams.test.ts index b71bd249..febd052b 100644 --- a/tests/unit/commands/teams.test.ts +++ b/tests/unit/commands/teams.test.ts @@ -6,6 +6,7 @@ vi.mock("../../../src/common/context.js", () => ({ gql: { request: vi.fn() }, sdk: { sdk: {} }, })), + getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), })); vi.mock("../../../src/common/output.js", async (importOriginal) => { diff --git a/tests/unit/common/context.test.ts b/tests/unit/common/context.test.ts new file mode 100644 index 00000000..53d5efcd --- /dev/null +++ b/tests/unit/common/context.test.ts @@ -0,0 +1,35 @@ +import { Command } from "commander"; +import { describe, expect, it } from "vitest"; +import { getRootOpts } from "../../../src/common/context.js"; + +describe("getRootOpts", () => { + it("returns root options for nested commands", () => { + const root = new Command(); + root.option("--api-token <token>"); + root.option("--json"); + + const child = root.command("issues"); + const grandchild = child.command("list"); + + root.parse(["--api-token", "token-123", "--json", "issues", "list"], { + from: "user", + }); + + expect(getRootOpts(grandchild)).toEqual( + expect.objectContaining({ apiToken: "token-123", json: true }), + ); + }); + + it("returns the command's own options when already at root", () => { + const root = new Command(); + root.option("--api-token <token>"); + + root.parse(["--api-token", "root-token"], { + from: "user", + }); + + expect(getRootOpts(root)).toEqual( + expect.objectContaining({ apiToken: "root-token" }), + ); + }); +}); diff --git a/tests/unit/common/emoji.test.ts b/tests/unit/common/emoji.test.ts new file mode 100644 index 00000000..339b64a4 --- /dev/null +++ b/tests/unit/common/emoji.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeReactionEmojiInput, + resolveReactionEmojiInput, +} from "../../../src/common/emoji.js"; + +describe("resolveReactionEmojiInput", () => { + it("accepts positional emoji", () => { + expect(resolveReactionEmojiInput("👍", undefined)).toBe("👍"); + }); + + it("accepts shortcode through the primary emojify path", () => { + expect(resolveReactionEmojiInput(undefined, "smile")).toBe("😄"); + }); + + it("accepts thumbs_up through the fallback alias path", () => { + expect(resolveReactionEmojiInput(undefined, "thumbs_up")).toBe("👍"); + }); + + it("treats whitespace-only shortcode input as missing", () => { + expect(() => resolveReactionEmojiInput(undefined, " ")).toThrow( + "emoji or --shortcode is required", + ); + }); + + it("rejects unknown shortcode", () => { + expect(() => + resolveReactionEmojiInput(undefined, "nonexistent_shortcode"), + ).toThrow('unknown emoji shortcode "nonexistent_shortcode"'); + }); + + it("rejects missing emoji input", () => { + expect(() => resolveReactionEmojiInput(undefined, undefined)).toThrow( + "emoji or --shortcode is required", + ); + }); + + it("treats whitespace-only positional input as absent when shortcode is provided", () => { + expect(resolveReactionEmojiInput(" ", "smile")).toBe("😄"); + }); + + it("rejects mixed positional emoji and shortcode", () => { + expect(() => resolveReactionEmojiInput("👍", "thumbs_up")).toThrow( + "cannot provide both positional emoji and --shortcode", + ); + }); + + it("treats whitespace-only shortcode as absent when positional emoji is provided", () => { + expect(resolveReactionEmojiInput("👍", " ")).toBe("👍"); + }); +}); + +describe("normalizeReactionEmojiInput", () => { + it("trims whitespace around emoji", () => { + expect(normalizeReactionEmojiInput(" 👍 ")).toBe("👍"); + }); + + it("rejects empty emoji", () => { + expect(() => normalizeReactionEmojiInput(" ")).toThrow( + "emoji must not be empty", + ); + }); +}); diff --git a/tests/unit/common/resolve-filters.test.ts b/tests/unit/common/resolve-filters.test.ts index 38135a39..827ab20d 100644 --- a/tests/unit/common/resolve-filters.test.ts +++ b/tests/unit/common/resolve-filters.test.ts @@ -3,28 +3,22 @@ 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"; - -vi.mock("../../../src/resolvers/team-resolver.js", () => ({ - resolveTeamId: vi.fn().mockResolvedValue("team-uuid"), -})); -vi.mock("../../../src/resolvers/user-resolver.js", () => ({ - resolveUserId: vi.fn().mockResolvedValue("user-uuid"), -})); -vi.mock("../../../src/resolvers/project-resolver.js", () => ({ - resolveProjectId: vi.fn().mockResolvedValue("project-uuid"), -})); -vi.mock("../../../src/resolvers/status-resolver.js", () => ({ - resolveStatusId: vi.fn().mockResolvedValue("status-uuid"), -})); -vi.mock("../../../src/resolvers/label-resolver.js", () => ({ - resolveLabelIds: vi.fn().mockResolvedValue(["label-uuid-1", "label-uuid-2"]), -})); -vi.mock("../../../src/resolvers/cycle-resolver.js", () => ({ - resolveCycleId: vi.fn().mockResolvedValue("cycle-uuid"), -})); -vi.mock("../../../src/resolvers/issue-resolver.js", () => ({ - resolveIssueId: vi.fn().mockResolvedValue("issue-uuid"), +import { resolveSearchFilterIds } from "../../../src/resolvers/issue-filter-resolver.js"; +import { resolveMilestoneId } from "../../../src/resolvers/milestone-resolver.js"; + +vi.mock("../../../src/resolvers/issue-filter-resolver.js", () => ({ + resolveSearchFilterIds: vi.fn().mockResolvedValue({ + teamId: "team-uuid", + assigneeId: "user-uuid", + creatorId: "user-uuid", + projectId: "project-uuid", + stateIds: ["status-uuid"], + labelIds: ["label-uuid-1", "label-uuid-2"], + cycleId: "cycle-uuid", + parentId: "issue-uuid", + }), })); + vi.mock("../../../src/resolvers/milestone-resolver.js", () => ({ resolveMilestoneId: vi.fn().mockResolvedValue("milestone-uuid"), })); @@ -42,9 +36,11 @@ describe("resolveFilterOptions", () => { }); it("returns empty options when no flags provided", async () => { - const ctx = mockContext(); - const result = await resolveFilterOptions(ctx, {}); + const result = await resolveFilterOptions(mockContext(), {}); + + expect(resolveSearchFilterIds).not.toHaveBeenCalled(); expect(result).toEqual({ + milestoneId: undefined, priority: undefined, estimate: undefined, dueBefore: undefined, @@ -60,334 +56,158 @@ describe("resolveFilterOptions", () => { }); }); - it("resolves team ID via resolver", async () => { - const { resolveTeamId } = await import( - "../../../src/resolvers/team-resolver.js" - ); - const ctx = mockContext(); - const result = await resolveFilterOptions(ctx, { team: "ENG" }); - expect(resolveTeamId).toHaveBeenCalledWith(ctx.sdk, "ENG"); - expect(result.teamId).toBe("team-uuid"); - }); - - it("resolves assignee ID via resolver", async () => { - const { resolveUserId } = await import( - "../../../src/resolvers/user-resolver.js" - ); - const ctx = mockContext(); - const result = await resolveFilterOptions(ctx, { assignee: "alice" }); - expect(resolveUserId).toHaveBeenCalledWith(ctx.sdk, "alice"); - expect(result.assigneeId).toBe("user-uuid"); - }); - - it("resolves creator ID via resolver", async () => { - const { resolveUserId } = await import( - "../../../src/resolvers/user-resolver.js" - ); - const ctx = mockContext(); - const result = await resolveFilterOptions(ctx, { creator: "bob" }); - expect(resolveUserId).toHaveBeenCalledWith(ctx.sdk, "bob"); - expect(result.creatorId).toBe("user-uuid"); - }); - - it("resolves project ID via resolver", async () => { - const { resolveProjectId } = await import( - "../../../src/resolvers/project-resolver.js" - ); - const ctx = mockContext(); - const result = await resolveFilterOptions(ctx, { project: "Backend" }); - expect(resolveProjectId).toHaveBeenCalledWith(ctx.sdk, "Backend"); - expect(result.projectId).toBe("project-uuid"); - }); - - it("resolves comma-separated status IDs with team dependency", async () => { - const { resolveStatusId } = await import( - "../../../src/resolvers/status-resolver.js" - ); - const ctx = mockContext(); - const result = await resolveFilterOptions(ctx, { - team: "ENG", - status: "Todo,In Progress", - }); - expect(resolveStatusId).toHaveBeenCalledWith(ctx.sdk, "Todo", "team-uuid"); - expect(resolveStatusId).toHaveBeenCalledWith( - ctx.sdk, - "In Progress", - "team-uuid", - ); - expect(result.stateIds).toEqual(["status-uuid", "status-uuid"]); - }); - - it("resolves comma-separated label IDs", async () => { - const { resolveLabelIds } = await import( - "../../../src/resolvers/label-resolver.js" - ); - const ctx = mockContext(); - const result = await resolveFilterOptions(ctx, { label: "Bug,Critical" }); - expect(resolveLabelIds).toHaveBeenCalledWith(ctx.sdk, ["Bug", "Critical"]); - expect(result.labelIds).toEqual(["label-uuid-1", "label-uuid-2"]); - }); - - it("resolves cycle ID with resolved team ID", async () => { - const { resolveCycleId } = await import( - "../../../src/resolvers/cycle-resolver.js" - ); - const ctx = mockContext(); - const result = await resolveFilterOptions(ctx, { + it("calls resolveSearchFilterIds once after validation", async () => { + const result = await resolveFilterOptions(mockContext(), { team: "ENG", + assignee: "alice", + creator: "bob", + project: "Backend", + status: "Todo", + label: "Bug,Critical", cycle: "Sprint 1", + parent: "ENG-123", + milestone: "v1.0", + priority: "2", + estimate: "5", }); - expect(resolveCycleId).toHaveBeenCalledWith( - ctx.sdk, - "Sprint 1", - "team-uuid", - ); - expect(result.cycleId).toBe("cycle-uuid"); - }); - it("resolves parent issue ID", async () => { - const { resolveIssueId } = await import( - "../../../src/resolvers/issue-resolver.js" - ); - const ctx = mockContext(); - const result = await resolveFilterOptions(ctx, { parent: "ENG-123" }); - expect(resolveIssueId).toHaveBeenCalledWith(ctx.sdk, "ENG-123"); - expect(result.parentId).toBe("issue-uuid"); - }); - - it("resolves milestone ID with resolved project ID", async () => { - const { resolveMilestoneId } = await import( - "../../../src/resolvers/milestone-resolver.js" - ); - const ctx = mockContext(); - const result = await resolveFilterOptions(ctx, { + expect(resolveSearchFilterIds).toHaveBeenCalledTimes(1); + expect(resolveSearchFilterIds).toHaveBeenCalledWith(expect.anything(), { + team: "ENG", + assignee: "alice", + creator: "bob", project: "Backend", - milestone: "v1.0", + statusNames: ["Todo"], + labelNames: ["Bug", "Critical"], + cycle: "Sprint 1", + parent: "ENG-123", }); expect(resolveMilestoneId).toHaveBeenCalledWith( - ctx.gql, - ctx.sdk, + expect.anything(), + expect.anything(), "v1.0", - "project-uuid", + "Backend", ); - expect(result.milestoneId).toBe("milestone-uuid"); - }); - - it("parses priority string to number", async () => { - const ctx = mockContext(); - const result = await resolveFilterOptions(ctx, { priority: "2" }); + expect(result.teamId).toBe("team-uuid"); + expect(result.stateIds).toEqual(["status-uuid"]); expect(result.priority).toBe(2); - }); - - it("parses estimate string to number", async () => { - const ctx = mockContext(); - const result = await resolveFilterOptions(ctx, { estimate: "5" }); expect(result.estimate).toBe(5); }); - it("passes through date values unchanged", async () => { - const ctx = mockContext(); - const result = await resolveFilterOptions(ctx, { - dueBefore: "2025-12-31", - dueAfter: "2025-01-01", - createdAfter: "2025-02-01", - createdBefore: "2025-11-01", - }); - expect(result.dueBefore).toBe("2025-12-31"); - expect(result.dueAfter).toBe("2025-01-01"); - expect(result.createdAfter).toBe("2025-02-01"); - expect(result.createdBefore).toBe("2025-11-01"); - }); - - it("passes through boolean flags", async () => { - const ctx = mockContext(); - const result = await resolveFilterOptions(ctx, { - hasBlockers: true, - isBlocking: true, - }); - expect(result.hasBlockers).toBe(true); - expect(result.isBlocking).toBe(true); - }); - - // Validation error cases - it("throws on invalid priority string", async () => { - const ctx = mockContext(); - await expect( - resolveFilterOptions(ctx, { priority: "abc" }), - ).rejects.toThrow("--priority"); - }); - - it("throws on decimal priority", async () => { - const ctx = mockContext(); + it("throws on invalid priority string before network call", async () => { await expect( - resolveFilterOptions(ctx, { priority: "1.5" }), + resolveFilterOptions(mockContext(), { priority: "abc" }), ).rejects.toThrow("--priority"); + expect(resolveSearchFilterIds).not.toHaveBeenCalled(); }); - it("throws on out-of-range priority", async () => { - const ctx = mockContext(); - await expect(resolveFilterOptions(ctx, { priority: "5" })).rejects.toThrow( - "priority", - ); - }); - - it("throws on invalid estimate string", async () => { - const ctx = mockContext(); - await expect( - resolveFilterOptions(ctx, { estimate: "abc" }), - ).rejects.toThrow("--estimate"); - }); - - it("throws on partially numeric estimate", async () => { - const ctx = mockContext(); + it("throws on invalid estimate string before network call", async () => { await expect( - resolveFilterOptions(ctx, { estimate: "2abc" }), + resolveFilterOptions(mockContext(), { estimate: "2abc" }), ).rejects.toThrow("--estimate"); - }); - - it("throws on negative estimate", async () => { - const ctx = mockContext(); - await expect(resolveFilterOptions(ctx, { estimate: "-1" })).rejects.toThrow( - "estimate", - ); + expect(resolveSearchFilterIds).not.toHaveBeenCalled(); }); it("throws when --status used without --team", async () => { - const ctx = mockContext(); await expect( - resolveFilterOptions(ctx, { status: "In Progress" }), + resolveFilterOptions(mockContext(), { status: "In Progress" }), ).rejects.toThrow("--team"); + expect(resolveSearchFilterIds).not.toHaveBeenCalled(); }); it("allows status UUID without --team", async () => { - const { resolveTeamId } = await import( - "../../../src/resolvers/team-resolver.js" - ); - const { resolveStatusId } = await import( - "../../../src/resolvers/status-resolver.js" - ); - const ctx = mockContext(); - const result = await resolveFilterOptions(ctx, { + await resolveFilterOptions(mockContext(), { status: "550e8400-e29b-41d4-a716-446655440000", }); - expect(resolveTeamId).not.toHaveBeenCalled(); - expect(resolveStatusId).toHaveBeenCalledWith( - ctx.sdk, - "550e8400-e29b-41d4-a716-446655440000", - undefined, - ); - expect(result.stateIds).toEqual(["status-uuid"]); + + expect(resolveSearchFilterIds).toHaveBeenCalledWith(expect.anything(), { + team: undefined, + assignee: undefined, + creator: undefined, + project: undefined, + statusNames: ["550e8400-e29b-41d4-a716-446655440000"], + labelNames: undefined, + cycle: undefined, + parent: undefined, + }); }); it("throws on malformed status list before making resolver calls", async () => { - const { resolveTeamId } = await import( - "../../../src/resolvers/team-resolver.js" - ); - const { resolveStatusId } = await import( - "../../../src/resolvers/status-resolver.js" - ); - const ctx = mockContext(); await expect( - resolveFilterOptions(ctx, { team: "ENG", status: "Todo,,Done" }), + resolveFilterOptions(mockContext(), { + team: "ENG", + status: "Todo,,Done", + }), ).rejects.toThrow("empty"); - expect(resolveTeamId).not.toHaveBeenCalled(); - expect(resolveStatusId).not.toHaveBeenCalled(); + expect(resolveSearchFilterIds).not.toHaveBeenCalled(); }); it("throws on malformed label list before making resolver calls", async () => { - const { resolveLabelIds } = await import( - "../../../src/resolvers/label-resolver.js" - ); - const ctx = mockContext(); await expect( - resolveFilterOptions(ctx, { label: "bug, ,ux" }), + resolveFilterOptions(mockContext(), { label: "bug, ,ux" }), ).rejects.toThrow("empty"); - expect(resolveLabelIds).not.toHaveBeenCalled(); + expect(resolveSearchFilterIds).not.toHaveBeenCalled(); }); it("throws when --cycle used without --team", async () => { - const ctx = mockContext(); await expect( - resolveFilterOptions(ctx, { cycle: "Sprint 1" }), + resolveFilterOptions(mockContext(), { cycle: "Sprint 1" }), ).rejects.toThrow("--team"); + expect(resolveSearchFilterIds).not.toHaveBeenCalled(); }); it("allows cycle UUID without --team", async () => { - const { resolveTeamId } = await import( - "../../../src/resolvers/team-resolver.js" - ); - const { resolveCycleId } = await import( - "../../../src/resolvers/cycle-resolver.js" - ); - const ctx = mockContext(); - const result = await resolveFilterOptions(ctx, { + await resolveFilterOptions(mockContext(), { cycle: "550e8400-e29b-41d4-a716-446655440001", }); - expect(resolveTeamId).not.toHaveBeenCalled(); - expect(resolveCycleId).toHaveBeenCalledWith( - ctx.sdk, - "550e8400-e29b-41d4-a716-446655440001", - undefined, - ); - expect(result.cycleId).toBe("cycle-uuid"); + + expect(resolveSearchFilterIds).toHaveBeenCalledWith(expect.anything(), { + team: undefined, + assignee: undefined, + creator: undefined, + project: undefined, + statusNames: undefined, + labelNames: undefined, + cycle: "550e8400-e29b-41d4-a716-446655440001", + parent: undefined, + }); }); it("throws when --milestone used without --project", async () => { - const ctx = mockContext(); await expect( - resolveFilterOptions(ctx, { milestone: "v1.0" }), + resolveFilterOptions(mockContext(), { milestone: "v1.0" }), ).rejects.toThrow("--project"); + expect(resolveSearchFilterIds).not.toHaveBeenCalled(); }); it("allows milestone UUID without --project", async () => { - const { resolveProjectId } = await import( - "../../../src/resolvers/project-resolver.js" - ); - const { resolveMilestoneId } = await import( - "../../../src/resolvers/milestone-resolver.js" - ); - const ctx = mockContext(); - const result = await resolveFilterOptions(ctx, { + await resolveFilterOptions(mockContext(), { milestone: "550e8400-e29b-41d4-a716-446655440002", }); - expect(resolveProjectId).not.toHaveBeenCalled(); + + expect(resolveSearchFilterIds).not.toHaveBeenCalled(); expect(resolveMilestoneId).toHaveBeenCalledWith( - ctx.gql, - ctx.sdk, + expect.anything(), + expect.anything(), "550e8400-e29b-41d4-a716-446655440002", undefined, ); - expect(result.milestoneId).toBe("milestone-uuid"); }); - it("throws on contradictory date range", async () => { - const ctx = mockContext(); + it("throws on contradictory date range before network call", async () => { await expect( - resolveFilterOptions(ctx, { + resolveFilterOptions(mockContext(), { dueAfter: "2025-12-31", dueBefore: "2025-01-01", }), ).rejects.toThrow("due date"); + expect(resolveSearchFilterIds).not.toHaveBeenCalled(); }); it("throws on invalid due date format with flag-specific message", async () => { - const ctx = mockContext(); await expect( - resolveFilterOptions(ctx, { dueBefore: "not-a-date" }), + resolveFilterOptions(mockContext(), { dueBefore: "not-a-date" }), ).rejects.toThrow("--due-before"); - }); - - it("throws on invalid created date format with flag-specific message", async () => { - const ctx = mockContext(); - await expect( - resolveFilterOptions(ctx, { createdBefore: "not-a-date" }), - ).rejects.toThrow("--created-before"); - }); - - it("throws on impossible completed date with flag-specific message", async () => { - const ctx = mockContext(); - await expect( - resolveFilterOptions(ctx, { completedAfter: "2025-02-30" }), - ).rejects.toThrow("--completed-after"); + expect(resolveSearchFilterIds).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/resolvers/issue-filter-resolver.test.ts b/tests/unit/resolvers/issue-filter-resolver.test.ts new file mode 100644 index 00000000..ef1b6a13 --- /dev/null +++ b/tests/unit/resolvers/issue-filter-resolver.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { LinearSdkClient } from "../../../src/client/linear-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(), + 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, +})); + +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"); + + const result = await resolveSearchFilterIds(sdk, { + team: "ENG", + statusNames: ["Todo"], + cycle: "Sprint 1", + }); + + expect(resolveTeamIdMock).toHaveBeenCalledWith(sdk, "ENG"); + expect(resolveStatusIdMock).toHaveBeenCalledWith(sdk, "Todo", "team-uuid"); + expect(resolveCycleIdMock).toHaveBeenCalledWith( + sdk, + "Sprint 1", + "team-uuid", + ); + expect(result).toEqual({ + teamId: "team-uuid", + stateIds: ["state-uuid"], + cycleId: "cycle-uuid", + }); + }); + + it("falls back to raw team input for cycle lookup when team not pre-resolved", async () => { + const sdk = {} as unknown as LinearSdkClient; + + resolveCycleIdMock.mockResolvedValue("cycle-uuid"); + + const result = await resolveSearchFilterIds(sdk, { + cycle: "Sprint 2", + team: "Engineering", + }); + + expect(resolveCycleIdMock).toHaveBeenCalledWith( + sdk, + "Sprint 2", + "Engineering", + ); + expect(result).toEqual({ cycleId: "cycle-uuid" }); + }); +}); diff --git a/tests/unit/resolvers/issue-resolver.test.ts b/tests/unit/resolvers/issue-resolver.test.ts index a7cdb5ea..a7342fd1 100644 --- a/tests/unit/resolvers/issue-resolver.test.ts +++ b/tests/unit/resolvers/issue-resolver.test.ts @@ -8,29 +8,52 @@ import { type IssueNode = { id: string; - team?: { - id: string; - key: string; - name: string; - issueEstimationType: - | "notUsed" - | "exponential" - | "fibonacci" - | "linear" - | "tShirt"; - issueEstimationExtended: boolean; - issueEstimationAllowZero: boolean; - }; + teamId?: string; + team?: + | { + id?: string; + key?: string; + } + | Promise<{ + id?: string; + key?: string; + }>; }; -function mockSdkClient(nodes: IssueNode[]) { +type TeamNode = { + id: string; + key: string; + name: string; + issueEstimationType: + | "notUsed" + | "exponential" + | "fibonacci" + | "linear" + | "tShirt"; + issueEstimationExtended: boolean; + issueEstimationAllowZero: boolean; +}; + +function mockSdkClient(issueNodes: IssueNode[], teamNodes: TeamNode[] = []) { return { sdk: { - issues: vi.fn().mockResolvedValue({ nodes }), + issues: vi.fn().mockResolvedValue({ nodes: issueNodes }), + teams: vi.fn().mockResolvedValue({ nodes: teamNodes }), }, } as unknown as LinearSdkClient; } +const teamId = "550e8400-e29b-41d4-a716-446655440001"; + +const exponentialTeam: TeamNode = { + id: teamId, + key: "ENG", + name: "Engineering", + issueEstimationType: "exponential", + issueEstimationExtended: false, + issueEstimationAllowZero: false, +}; + describe("resolveIssueId", () => { it("returns UUID as-is", async () => { const client = mockSdkClient([]); @@ -56,27 +79,18 @@ describe("resolveIssueId", () => { }); describe("resolveIssueEstimateContext", () => { - it("resolves by identifier with issueId + nested team estimate context fields", async () => { - const client = mockSdkClient([ - { - id: "issue-uuid", - team: { - id: "team-uuid", - key: "ENG", - name: "Engineering", - issueEstimationType: "exponential", - issueEstimationExtended: false, - issueEstimationAllowZero: false, - }, - }, - ]); + 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], + ); await expect( resolveIssueEstimateContext(client, "ENG-42"), ).resolves.toEqual({ issueId: "issue-uuid", team: { - teamId: "team-uuid", + teamId, teamKey: "ENG", teamName: "Engineering", issueEstimationType: "exponential", @@ -84,34 +98,121 @@ describe("resolveIssueEstimateContext", () => { issueEstimationAllowZero: false, }, }); + + expect(client.sdk.issues).toHaveBeenCalledWith({ + filter: { + number: { eq: 42 }, + team: { key: { eq: "ENG" } }, + }, + first: 1, + }); + expect(client.sdk.teams).toHaveBeenCalledWith({ + filter: { id: { eq: teamId } }, + first: 1, + }); }); - it("resolves by UUID and verifies sdk issues filter id eq", async () => { - const issues = vi.fn().mockResolvedValue({ - nodes: [ + it("resolves by UUID and uses sdk issues filter id eq", async () => { + const client = mockSdkClient( + [{ id: "issue-uuid", teamId }], + [exponentialTeam], + ); + + await resolveIssueEstimateContext( + client, + "550e8400-e29b-41d4-a716-446655440000", + ); + + expect(client.sdk.issues).toHaveBeenCalledWith({ + 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: "team-uuid", - key: "OPS", - name: "Operations", - issueEstimationType: "linear", - issueEstimationExtended: true, - issueEstimationAllowZero: true, + id: teamId, + key: "ENG", }, }, ], + [exponentialTeam], + ); + + await expect( + resolveIssueEstimateContext(client, "ENG-42"), + ).resolves.toMatchObject({ + issueId: "issue-uuid", + team: { + teamId, + teamKey: "ENG", + }, }); + }); - const client = { sdk: { issues } } as unknown as LinearSdkClient; + 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 resolveIssueEstimateContext( - client, - "550e8400-e29b-41d4-a716-446655440000", + 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], ); - expect(issues).toHaveBeenCalledWith({ - filter: { id: { eq: "550e8400-e29b-41d4-a716-446655440000" } }, + await resolveIssueEstimateContext(client, "ENG-42"); + + expect(client.sdk.teams).toHaveBeenCalledWith({ + filter: { key: { eq: "ENG" } }, first: 1, }); }); @@ -124,19 +225,11 @@ describe("resolveIssueEstimateContext", () => { ).rejects.toThrow('Issue "ENG-999" not found'); }); - it("throws when issue team estimation context is missing", async () => { - const issues = vi.fn().mockResolvedValue({ - nodes: [ - { - id: "issue-uuid", - }, - ], - }); - - const client = { sdk: { issues } } as unknown as LinearSdkClient; + 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 estimation context', + 'Issue "ENG-42" is missing required team context', ); }); }); diff --git a/tests/unit/services/discussion-service.test.ts b/tests/unit/services/discussion-service.test.ts new file mode 100644 index 00000000..056994df --- /dev/null +++ b/tests/unit/services/discussion-service.test.ts @@ -0,0 +1,949 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { + GetDiscussionCommentContextDocument, + type ListIssueDiscussionRootsQuery, +} from "../../../src/gql/graphql.js"; + +vi.mock("../../../src/services/reaction-service.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/services/reaction-service.js") + >(); + return { + ...actual, + createReactionForComment: vi.fn().mockResolvedValue({ id: "reaction-1" }), + deleteOwnReactionByEmoji: vi + .fn() + .mockResolvedValue({ id: "reaction-1", success: true }), + deleteOwnReactionById: vi + .fn() + .mockResolvedValue({ id: "reaction-1", success: true }), + }; +}); + +import { + createDiscussionCommentReaction, + createIssueDiscussionCommentReaction, + deleteDiscussionComment, + deleteDiscussionCommentReactionByEmoji, + deleteDiscussionCommentReactionById, + deleteDiscussionReply, + deleteIssueDiscussionCommentReactionByEmoji, + deleteIssueDiscussionCommentReactionById, + editDiscussionComment, + editDiscussionReply, + listDiscussionReplies, + listDiscussionRepliesWithReactions, + listDiscussionsForInitiative, + listDiscussionsForInitiativeWithReactions, + listDiscussionsForIssue, + listDiscussionsForIssueWithReactions, + listDiscussionsForProject, + listDiscussionsForProjectWithReactions, + replyToDiscussion, + resolveDiscussion, + startInitiativeDiscussion, + startIssueDiscussion, + startProjectDiscussion, + unresolveDiscussion, +} from "../../../src/services/discussion-service.js"; +import { + createReactionForComment, + deleteOwnReactionByEmoji, + deleteOwnReactionById, +} from "../../../src/services/reaction-service.js"; + +function createClientMock(): GraphQLClient { + return { + request: vi.fn(), + } as unknown as GraphQLClient; +} + +const MOCK_USER = { id: "user-1", displayName: "Test User" }; +const REACTION_USER = { id: "user-1", displayName: "Ada" }; + +function comment(id: string, parentId: string | null = null) { + return { + id, + body: `comment-${id}`, + createdAt: "2025-01-15T10:00:00.000Z", + editedAt: null, + parentId, + resolvedAt: null, + resolvingComment: null, + resolvingUser: null, + user: MOCK_USER, + }; +} + +function commentWithReaction(id: string, parentId: string | null = null) { + return { + ...comment(id, parentId), + reactions: [ + { + id: "r-1", + emoji: "👍", + user: REACTION_USER, + externalUser: null, + }, + ], + }; +} + +describe("discussion comment reactions", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates thread reaction after validating root comment and entity kind", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValue({ + comment: { + ...comment("thread-1"), + issueId: "issue-1", + projectId: null, + initiativeId: null, + }, + }); + + await expect( + createDiscussionCommentReaction(client, { + commentId: "thread-1", + target: "thread", + expectedEntityKind: "issue", + emoji: "👍", + }), + ).resolves.toEqual({ id: "reaction-1" }); + + expect(client.request).toHaveBeenCalledWith( + GetDiscussionCommentContextDocument, + { id: "thread-1" }, + ); + expect(createReactionForComment).toHaveBeenCalledWith(expect.anything(), { + commentId: "thread-1", + emoji: "👍", + }); + }); + + it("rejects thread reaction when comment is a reply", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValue({ + comment: { + ...comment("reply-1", "thread-1"), + issueId: "issue-1", + projectId: null, + initiativeId: null, + }, + }); + + await expect( + createDiscussionCommentReaction(client, { + commentId: "reply-1", + target: "thread", + expectedEntityKind: "issue", + emoji: "👍", + }), + ).rejects.toThrow( + 'Discussion thread ID "reply-1" must reference a root comment', + ); + + expect(createReactionForComment).not.toHaveBeenCalled(); + }); + + it("rejects reply reaction when entity kind does not match", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValue({ + comment: { + ...comment("reply-1", "thread-1"), + issueId: null, + projectId: "project-1", + initiativeId: null, + }, + }); + + await expect( + deleteDiscussionCommentReactionByEmoji(client, { + commentId: "reply-1", + target: "reply", + expectedEntityKind: "issue", + emoji: "👍", + }), + ).rejects.toThrow( + 'Discussion reply ID "reply-1" belongs to project, not issue', + ); + + expect(deleteOwnReactionByEmoji).not.toHaveBeenCalled(); + }); + + it("deletes reply reaction by id after validation", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValue({ + comment: { + ...comment("reply-1", "thread-1"), + issueId: null, + projectId: null, + initiativeId: "initiative-1", + }, + }); + + await expect( + deleteDiscussionCommentReactionById(client, { + commentId: "reply-1", + target: "reply", + expectedEntityKind: "initiative", + reactionId: "reaction-1", + }), + ).resolves.toEqual({ id: "reaction-1", success: true }); + + expect(deleteOwnReactionById).toHaveBeenCalledWith(expect.anything(), { + kind: "comment", + id: "reply-1", + reactionId: "reaction-1", + }); + }); + + it("creates deprecated issue comment reaction after validating issue ownership", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValue({ + comment: { + ...comment("comment-1"), + issueId: "issue-1", + projectId: null, + initiativeId: null, + }, + }); + + await expect( + createIssueDiscussionCommentReaction(client, { + commentId: "comment-1", + emoji: "👍", + }), + ).resolves.toEqual({ id: "reaction-1" }); + + expect(createReactionForComment).toHaveBeenCalledWith(expect.anything(), { + commentId: "comment-1", + emoji: "👍", + }); + }); + + it("rejects deprecated issue comment reaction for non-issue comment", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValue({ + comment: { + ...comment("comment-1"), + issueId: null, + projectId: "project-1", + initiativeId: null, + }, + }); + + await expect( + createIssueDiscussionCommentReaction(client, { + commentId: "comment-1", + emoji: "👍", + }), + ).rejects.toThrow( + 'Discussion comment ID "comment-1" belongs to project, not issue', + ); + + expect(createReactionForComment).not.toHaveBeenCalled(); + }); + + it("rejects deprecated issue comment unreact by emoji for missing comment", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValue({ comment: null }); + + await expect( + deleteIssueDiscussionCommentReactionByEmoji(client, { + commentId: "comment-1", + emoji: "👍", + }), + ).rejects.toThrow('Discussion comment ID "comment-1" not found'); + + expect(deleteOwnReactionByEmoji).not.toHaveBeenCalled(); + }); + + it("deletes deprecated issue comment reaction by id after validation", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValue({ + comment: { + ...comment("comment-1"), + issueId: "issue-1", + projectId: null, + initiativeId: null, + }, + }); + + await expect( + deleteIssueDiscussionCommentReactionById(client, { + commentId: "comment-1", + reactionId: "reaction-1", + }), + ).resolves.toEqual({ id: "reaction-1", success: true }); + + expect(deleteOwnReactionById).toHaveBeenCalledWith(expect.anything(), { + kind: "comment", + id: "comment-1", + reactionId: "reaction-1", + }); + }); +}); + +describe("listDiscussionsForIssue", () => { + it("returns root threads only", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValue({ + issue: { + comments: { + nodes: [comment("root-1"), comment("root-2")], + pageInfo: { hasNextPage: true, endCursor: "root-cursor-1" }, + }, + }, + } satisfies ListIssueDiscussionRootsQuery); + + const result = await listDiscussionsForIssue(client, "issue-1", { + limit: 2, + after: "root-cursor-0", + }); + + expect(result.nodes.map((node) => node.id)).toEqual(["root-1", "root-2"]); + expect(result.pageInfo).toEqual({ + hasNextPage: true, + endCursor: "root-cursor-1", + }); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + issueId: "issue-1", + first: 2, + after: "root-cursor-0", + }); + }); + + it("throws when issue is missing", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValue({ issue: null }); + + await expect( + listDiscussionsForIssue(client, "issue-missing"), + ).rejects.toThrow('Issue with ID "issue-missing" not found'); + }); + + it("listDiscussionsForIssueWithReactions normalizes thread reactions", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValue({ + issue: { + comments: { + nodes: [commentWithReaction("root-1")], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + + const result = await listDiscussionsForIssueWithReactions( + client, + "issue-1", + { limit: 10 }, + ); + + expect(result.nodes[0].reactions).toEqual([ + { + emoji: "👍", + count: 1, + users: [{ id: "user-1", displayName: "Ada", type: "user" }], + reactionIds: ["r-1"], + }, + ]); + }); +}); + +describe("listDiscussionsForProject", () => { + it("returns root threads only", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValue({ + project: { + comments: { + nodes: [comment("root-1")], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + + const result = await listDiscussionsForProject(client, "project-1", { + limit: 10, + after: "cur-0", + }); + + expect(result.nodes).toHaveLength(1); + expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: null }); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + projectId: "project-1", + first: 10, + after: "cur-0", + }); + }); + + it("throws when project is missing", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValue({ project: null }); + + await expect( + listDiscussionsForProject(client, "project-missing"), + ).rejects.toThrow('Project with ID "project-missing" not found'); + }); + + it("listDiscussionsForProjectWithReactions normalizes thread reactions", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValue({ + project: { + comments: { + nodes: [commentWithReaction("root-1")], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + + const result = await listDiscussionsForProjectWithReactions( + client, + "project-1", + { limit: 10 }, + ); + + expect(result.nodes[0].reactions).toEqual([ + { + emoji: "👍", + count: 1, + users: [{ id: "user-1", displayName: "Ada", type: "user" }], + reactionIds: ["r-1"], + }, + ]); + }); +}); + +describe("listDiscussionsForInitiative", () => { + it("returns root threads only", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValue({ + initiative: { id: "initiative-1" }, + comments: { + nodes: [comment("root-1")], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + + const result = await listDiscussionsForInitiative(client, "initiative-1"); + + expect(result.nodes).toHaveLength(1); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + initiativeId: "initiative-1", + initiativeLookupId: "initiative-1", + first: 25, + after: undefined, + }); + }); + + it("throws when initiative is missing", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValue({ initiative: null }); + + await expect( + listDiscussionsForInitiative(client, "initiative-missing"), + ).rejects.toThrow('Initiative with ID "initiative-missing" not found'); + }); + + it("listDiscussionsForInitiativeWithReactions normalizes thread reactions", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValue({ + initiative: { id: "initiative-1" }, + comments: { + nodes: [commentWithReaction("root-1")], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + + const result = await listDiscussionsForInitiativeWithReactions( + client, + "initiative-1", + { limit: 10 }, + ); + + expect(result.nodes[0].reactions).toEqual([ + { + emoji: "👍", + count: 1, + users: [{ id: "user-1", displayName: "Ada", type: "user" }], + reactionIds: ["r-1"], + }, + ]); + }); +}); + +describe("listDiscussionReplies", () => { + it("returns deeply nested replies beyond fixed query depth", async () => { + const client = createClientMock(); + vi.mocked(client.request) + .mockResolvedValueOnce({ + comment: { + ...comment("root-1"), + issueId: "issue-1", + projectId: null, + initiativeId: null, + }, + }) + .mockResolvedValueOnce({ + comments: { + nodes: [ + comment("reply-1", "root-1"), + comment("reply-2", "reply-1"), + comment("reply-3", "reply-2"), + comment("reply-4", "reply-3"), + comment("reply-5", "reply-4"), + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + + const result = await listDiscussionReplies(client, "root-1", { + limit: 5, + }); + + expect(result.nodes.map((node) => node.id)).toEqual([ + "reply-1", + "reply-2", + "reply-3", + "reply-4", + "reply-5", + ]); + expect(result.pageInfo).toEqual({ + hasNextPage: false, + endCursor: "reply-5", + }); + }); + + it("paginates thread replies with reply id cursors", async () => { + const client = createClientMock(); + vi.mocked(client.request) + .mockResolvedValueOnce({ + comment: { + ...comment("root-1"), + issueId: "issue-1", + projectId: null, + initiativeId: null, + }, + }) + .mockResolvedValueOnce({ + comments: { + nodes: [ + comment("reply-1", "root-1"), + comment("other-1", "other-root"), + comment("reply-2", "reply-1"), + comment("reply-3", "reply-2"), + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + + const result = await listDiscussionReplies(client, "root-1", { + limit: 1, + after: "reply-1", + }); + + expect(result.nodes.map((node) => node.id)).toEqual(["reply-2"]); + expect(result.pageInfo).toEqual({ + hasNextPage: true, + endCursor: "reply-2", + }); + }); + + it("keeps descendants when candidates arrive before their parent", async () => { + const client = createClientMock(); + vi.mocked(client.request) + .mockResolvedValueOnce({ + comment: { + ...comment("root-1"), + issueId: "issue-1", + projectId: null, + initiativeId: null, + }, + }) + .mockResolvedValueOnce({ + comments: { + nodes: [ + { + ...comment("a-child", "z-parent"), + createdAt: "2025-01-15T10:00:00.000Z", + }, + { + ...comment("z-parent", "root-1"), + createdAt: "2025-01-15T10:00:00.000Z", + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + + const result = await listDiscussionReplies(client, "root-1", { limit: 10 }); + + expect(result.nodes.map((node) => node.id)).toEqual([ + "z-parent", + "a-child", + ]); + }); + + it("throws when thread id does not exist", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValueOnce({ comment: null }); + + await expect( + listDiscussionReplies(client, "missing-thread"), + ).rejects.toThrow('Discussion thread ID "missing-thread" not found'); + }); + + it("rejects non-root thread id", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValueOnce({ + comment: comment("reply-1", "root-1"), + }); + + await expect(listDiscussionReplies(client, "reply-1")).rejects.toThrow( + 'Discussion thread ID "reply-1" must reference a root comment', + ); + }); + + it("listDiscussionRepliesWithReactions normalizes reply reactions", async () => { + const client = createClientMock(); + vi.mocked(client.request) + .mockResolvedValueOnce({ + comment: { + ...comment("root-1"), + issueId: "issue-1", + projectId: null, + initiativeId: null, + }, + }) + .mockResolvedValueOnce({ + comments: { + nodes: [commentWithReaction("reply-1", "root-1")], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + + const result = await listDiscussionRepliesWithReactions( + client, + "root-1", + { limit: 10 }, + "issue", + ); + + expect(result.nodes[0].reactions).toEqual([ + { + emoji: "👍", + count: 1, + users: [{ id: "user-1", displayName: "Ada", type: "user" }], + reactionIds: ["r-1"], + }, + ]); + }); +}); + +describe("replyToDiscussion", () => { + it("throws when thread id does not exist", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValueOnce({ comment: null }); + + await expect( + replyToDiscussion(client, { threadId: "missing-thread", body: "nested" }), + ).rejects.toThrow('Discussion thread ID "missing-thread" not found'); + }); + + it("rejects non-root parent thread id", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValueOnce({ + comment: { + ...comment("reply-2", "root-1"), + issueId: "issue-1", + projectId: null, + initiativeId: null, + }, + }); + + await expect( + replyToDiscussion(client, { threadId: "reply-2", body: "nested reply" }), + ).rejects.toThrow( + 'Discussion thread ID "reply-2" must reference a root comment', + ); + + expect(client.request).toHaveBeenCalledTimes(1); + expect(client.request).toHaveBeenCalledWith( + GetDiscussionCommentContextDocument, + { + id: "reply-2", + }, + ); + }); + + it("rejects root thread from different entity kind", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValueOnce({ + comment: { + ...comment("root-1"), + issueId: null, + projectId: "project-1", + initiativeId: null, + }, + }); + + await expect( + replyToDiscussion(client, { + threadId: "root-1", + body: "nested reply", + entityKind: "issue", + }), + ).rejects.toThrow( + 'Discussion thread ID "root-1" belongs to project, not issue', + ); + }); + + it("creates a reply for root thread", async () => { + const client = createClientMock(); + vi.mocked(client.request) + .mockResolvedValueOnce({ comment: comment("root-1") }) + .mockResolvedValueOnce({ + commentCreate: { + success: true, + comment: comment("reply-1", "root-1"), + }, + }); + + const result = await replyToDiscussion(client, { + threadId: "root-1", + body: "hello", + }); + + expect(result.id).toBe("reply-1"); + expect(result.parentId).toBe("root-1"); + }); +}); + +describe("discussion mutation flows", () => { + it("starts issue/project/initiative discussions", async () => { + const client = createClientMock(); + vi.mocked(client.request) + .mockResolvedValueOnce({ + commentCreate: { success: true, comment: comment("c-issue") }, + }) + .mockResolvedValueOnce({ + commentCreate: { success: true, comment: comment("c-project") }, + }) + .mockResolvedValueOnce({ + commentCreate: { success: true, comment: comment("c-initiative") }, + }); + + await expect( + startIssueDiscussion(client, { issueId: "issue-1", body: "issue body" }), + ).resolves.toMatchObject({ id: "c-issue" }); + await expect( + startProjectDiscussion(client, { + projectId: "project-1", + body: "project body", + }), + ).resolves.toMatchObject({ id: "c-project" }); + await expect( + startInitiativeDiscussion(client, { + initiativeId: "initiative-1", + body: "initiative body", + }), + ).resolves.toMatchObject({ id: "c-initiative" }); + }); + + it("fails to start issue discussion when create fails", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValueOnce({ + commentCreate: { success: false, comment: null }, + }); + + await expect( + startIssueDiscussion(client, { issueId: "issue-1", body: "issue body" }), + ).rejects.toThrow("Failed to start discussion"); + }); + + it("edits and deletes replies", async () => { + const client = createClientMock(); + vi.mocked(client.request) + .mockResolvedValueOnce({ comment: comment("reply-1", "root-1") }) + .mockResolvedValueOnce({ + commentUpdate: { + success: true, + comment: { ...comment("reply-1", "root-1"), body: "updated" }, + }, + }) + .mockResolvedValueOnce({ comment: comment("reply-1", "root-1") }) + .mockResolvedValueOnce({ + commentDelete: { success: true, entityId: "reply-1" }, + }); + + await expect( + editDiscussionReply(client, "reply-1", { body: "updated" }), + ).resolves.toMatchObject({ id: "reply-1", body: "updated" }); + await expect(deleteDiscussionReply(client, "reply-1")).resolves.toEqual({ + id: "reply-1", + success: true, + }); + }); + + it("rejects editing a root comment via editDiscussionReply", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValueOnce({ + comment: comment("root-1"), + }); + + await expect( + editDiscussionReply(client, "root-1", { body: "updated" }), + ).rejects.toThrow( + 'Discussion reply ID "root-1" must reference a reply comment', + ); + }); + + it("rejects deleting a root comment via deleteDiscussionReply", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValueOnce({ + comment: comment("root-1"), + }); + + await expect(deleteDiscussionReply(client, "root-1")).rejects.toThrow( + 'Discussion reply ID "root-1" must reference a reply comment', + ); + }); + + it("supports compatibility edit/delete for root comments", async () => { + const client = createClientMock(); + vi.mocked(client.request) + .mockResolvedValueOnce({ comment: comment("root-1") }) + .mockResolvedValueOnce({ + commentUpdate: { + success: true, + comment: { ...comment("root-1"), body: "updated" }, + }, + }) + .mockResolvedValueOnce({ comment: comment("root-1") }) + .mockResolvedValueOnce({ + commentDelete: { success: true, entityId: "root-1" }, + }); + + await expect( + editDiscussionComment(client, "root-1", { body: "updated" }), + ).resolves.toMatchObject({ id: "root-1", body: "updated" }); + await expect(deleteDiscussionComment(client, "root-1")).resolves.toEqual({ + id: "root-1", + success: true, + }); + }); + + it("rejects editing reply from different entity kind", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValueOnce({ + comment: { + ...comment("reply-1", "root-1"), + issueId: null, + projectId: "project-1", + initiativeId: null, + }, + }); + + await expect( + editDiscussionReply(client, "reply-1", { body: "updated" }, "issue"), + ).rejects.toThrow( + 'Discussion reply ID "reply-1" belongs to project, not issue', + ); + }); + + it("supports compatibility edit/delete for reply comments", async () => { + const client = createClientMock(); + vi.mocked(client.request) + .mockResolvedValueOnce({ comment: comment("reply-1", "root-1") }) + .mockResolvedValueOnce({ + commentUpdate: { + success: true, + comment: { ...comment("reply-1", "root-1"), body: "updated" }, + }, + }) + .mockResolvedValueOnce({ comment: comment("reply-1", "root-1") }) + .mockResolvedValueOnce({ + commentDelete: { success: true, entityId: "reply-1" }, + }); + + await expect( + editDiscussionComment(client, "reply-1", { body: "updated" }), + ).resolves.toMatchObject({ id: "reply-1", body: "updated" }); + await expect(deleteDiscussionComment(client, "reply-1")).resolves.toEqual({ + id: "reply-1", + success: true, + }); + }); + + it("fails compatibility edit/delete when target comment is missing", async () => { + const client = createClientMock(); + vi.mocked(client.request).mockResolvedValueOnce({ comment: null }); + + await expect( + editDiscussionComment(client, "missing", { body: "updated" }), + ).rejects.toThrow('Discussion comment ID "missing" not found'); + }); + + it("fails compatibility edit when update mutation fails", async () => { + const client = createClientMock(); + vi.mocked(client.request) + .mockResolvedValueOnce({ comment: comment("root-1") }) + .mockResolvedValueOnce({ + commentUpdate: { success: false, comment: null }, + }); + + await expect( + editDiscussionComment(client, "root-1", { body: "updated" }), + ).rejects.toThrow("Failed to edit discussion comment"); + }); + + it("fails compatibility delete when delete mutation fails", async () => { + const client = createClientMock(); + vi.mocked(client.request) + .mockResolvedValueOnce({ comment: comment("root-1") }) + .mockResolvedValueOnce({ + commentDelete: { success: false, entityId: "root-1" }, + }); + + await expect(deleteDiscussionComment(client, "root-1")).rejects.toThrow( + "Failed to delete discussion comment", + ); + }); + + it("resolves and unresolves root discussion", async () => { + const client = createClientMock(); + vi.mocked(client.request) + .mockResolvedValueOnce({ comment: comment("root-1") }) + .mockResolvedValueOnce({ + commentResolve: { + success: true, + comment: { + ...comment("root-1"), + resolvedAt: "2025-01-16T10:00:00.000Z", + }, + }, + }) + .mockResolvedValueOnce({ comment: comment("root-1") }) + .mockResolvedValueOnce({ + commentUnresolve: { + success: true, + comment: comment("root-1"), + }, + }); + + await expect( + resolveDiscussion(client, { + threadId: "root-1", + resolvingCommentId: "reply-1", + }), + ).resolves.toMatchObject({ id: "root-1" }); + await expect(unresolveDiscussion(client, "root-1")).resolves.toMatchObject({ + id: "root-1", + }); + }); +}); diff --git a/tests/unit/services/initiative-service.test.ts b/tests/unit/services/initiative-service.test.ts index ed4fb8f8..d30bafd3 100644 --- a/tests/unit/services/initiative-service.test.ts +++ b/tests/unit/services/initiative-service.test.ts @@ -1,5 +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 { GetInitiativeDocument } from "../../../src/gql/graphql.js"; import { archiveInitiative, createInitiative, @@ -16,6 +18,46 @@ function mockGqlClient(response: Record<string, unknown>): GraphQLClient { } as unknown as GraphQLClient; } +function getFragment( + document: DocumentNode, + name: string, +): FragmentDefinitionNode { + const fragment = document.definitions.find( + (definition): definition is FragmentDefinitionNode => + definition.kind === Kind.FRAGMENT_DEFINITION && + definition.name.value === name, + ); + + if (!fragment) { + throw new Error(`Fragment ${name} not found`); + } + + return fragment; +} + +describe("initiative reaction-aware read documents", () => { + it("keeps initiative detail reads free of out-of-scope reaction fragments", () => { + const baseFragment = getFragment( + GetInitiativeDocument, + "InitiativeExpandedFields", + ); + + expect( + baseFragment.selectionSet.selections + .filter((selection) => selection.kind === Kind.FIELD) + .map((selection) => selection.name.value), + ).toContain("initiativeUpdates"); + + expect(() => + getFragment(GetInitiativeDocument, "InitiativeUpdateFieldsWithReactions"), + ).toThrow("Fragment InitiativeUpdateFieldsWithReactions not found"); + + expect(() => + getFragment(GetInitiativeDocument, "InitiativeFieldsWithReactions"), + ).toThrow("Fragment InitiativeFieldsWithReactions not found"); + }); +}); + describe("listInitiatives", () => { it("forwards pagination, includeArchived, filter, and orderBy", async () => { const client = mockGqlClient({ diff --git a/tests/unit/services/issue-service.test.ts b/tests/unit/services/issue-service.test.ts index 235bd483..2906159a 100644 --- a/tests/unit/services/issue-service.test.ts +++ b/tests/unit/services/issue-service.test.ts @@ -9,8 +9,10 @@ import { GetIssueByIdentifierDocument, GetIssueByIdentifierWithAttachmentsDocument, GetIssueByIdentifierWithCommentsDocument, + GetIssueByIdentifierWithReactionsDocument, GetIssueByIdWithAttachmentsDocument, GetIssueByIdWithCommentsDocument, + GetIssueByIdWithReactionsDocument, GetIssuesDocument, PaginationOrderBy, SearchIssuesDocument, @@ -25,9 +27,11 @@ import { getIssueByIdentifierWithAttachments, getIssueByIdentifierWithComments, getIssueByIdentifierWithCommentThreads, + getIssueByIdentifierWithReactions, getIssueWithAttachments, getIssueWithComments, getIssueWithCommentThreads, + getIssueWithReactions, listIssues, searchIssues, unarchiveIssue, @@ -197,6 +201,30 @@ describe("listIssues", () => { }); }); + it("does not prepend the non-completed filter when an explicit state filter is provided", async () => { + const client = mockGqlClient({ + issues: { + nodes: [{ id: "1", title: "Done issue" }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + const filter = { + and: [ + { team: { id: { eq: "team-uuid" } } }, + { state: { id: { in: ["done-status-id"] } } }, + ], + }; + + await listIssues(client, { limit: 10 }, filter); + + expect(client.request).toHaveBeenCalledWith(FilteredSearchIssuesDocument, { + first: 10, + after: undefined, + filter, + orderBy: PaginationOrderBy.UpdatedAt, + }); + }); + it("uses GetIssues query when no filter provided (no regression)", async () => { const client = mockGqlClient({ issues: { @@ -544,6 +572,129 @@ describe("updateIssue", () => { }); }); +describe("getIssueWithReactions", () => { + it("returns issue by UUID with normalized grouped reactions", async () => { + const client = mockGqlClient({ + issue: { + id: "issue-1", + title: "Found", + comments: { nodes: [{ id: "comment-1", body: "First" }] }, + reactions: [ + { + id: "r-2", + emoji: "👍", + user: { id: "user-2", displayName: "Bob" }, + externalUser: null, + }, + { + id: "r-1", + emoji: "👍", + user: { id: "user-1", displayName: "Ada" }, + externalUser: null, + }, + { + id: "r-3", + emoji: "🎉", + user: null, + externalUser: { id: "ext-1", name: "Zed" }, + }, + ], + }, + }); + + const result = await getIssueWithReactions(client, "issue-1"); + + expect(result.reactions).toEqual([ + { + emoji: "👍", + count: 2, + users: [ + { id: "user-1", displayName: "Ada", type: "user" }, + { id: "user-2", displayName: "Bob", type: "user" }, + ], + reactionIds: ["r-1", "r-2"], + }, + { + emoji: "🎉", + count: 1, + users: [{ id: "ext-1", displayName: "Zed", type: "external" }], + reactionIds: ["r-3"], + }, + ]); + expect(client.request).toHaveBeenCalledWith( + GetIssueByIdWithReactionsDocument, + { id: "issue-1" }, + ); + }); + + 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', + ); + }); +}); + +describe("getIssueByIdentifierWithReactions", () => { + it("returns issue by identifier with normalized grouped reactions", async () => { + const client = mockGqlClient({ + issues: { + nodes: [ + { + id: "issue-1", + title: "Found", + comments: { nodes: [{ id: "comment-1", body: "First" }] }, + reactions: [ + { + id: "r-2", + emoji: "👍", + user: { id: "user-2", displayName: "Bob" }, + externalUser: null, + }, + { + id: "r-1", + emoji: "👍", + user: { id: "user-1", displayName: "Ada" }, + externalUser: null, + }, + ], + }, + ], + }, + }); + + const result = await getIssueByIdentifierWithReactions(client, "ENG", 42); + + expect(result.reactions).toEqual([ + { + emoji: "👍", + count: 2, + users: [ + { id: "user-1", displayName: "Ada", type: "user" }, + { id: "user-2", displayName: "Bob", type: "user" }, + ], + reactionIds: ["r-1", "r-2"], + }, + ]); + expect(client.request).toHaveBeenCalledWith( + GetIssueByIdentifierWithReactionsDocument, + { + teamKey: "ENG", + number: 42, + }, + ); + }); + + it("throws when issue not found by identifier", async () => { + const client = mockGqlClient({ issues: { nodes: [] } }); + + await expect( + getIssueByIdentifierWithReactions(client, "ENG", 999), + ).rejects.toThrow('Issue with identifier "ENG-999" not found'); + }); +}); + describe("getIssueWithAttachments", () => { it("returns issue with attachments by UUID", async () => { const client = mockGqlClient({ diff --git a/tests/unit/services/label-service.test.ts b/tests/unit/services/label-service.test.ts index 2d0ad7af..34c32657 100644 --- a/tests/unit/services/label-service.test.ts +++ b/tests/unit/services/label-service.test.ts @@ -101,6 +101,40 @@ describe("listLabels", () => { }); }); + it("filters workspace issue labels by null team", async () => { + const client = mockGqlClient({ + issueLabels: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + + await listLabels(client, undefined, { scope: "workspace" }); + + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + first: 50, + after: undefined, + filter: { team: { null: true } }, + }); + }); + + it("keeps team scope on the resolved team filter", async () => { + const client = mockGqlClient({ + issueLabels: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + + await listLabels(client, "team-1", { scope: "team" }); + + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + first: 50, + after: undefined, + filter: { team: { id: { eq: "team-1" }, null: false } }, + }); + }); + it("converts null description to undefined", async () => { const client = mockGqlClient({ issueLabels: { diff --git a/tests/unit/services/project-service.test.ts b/tests/unit/services/project-service.test.ts index 8aa6a451..6facab21 100644 --- a/tests/unit/services/project-service.test.ts +++ b/tests/unit/services/project-service.test.ts @@ -1,7 +1,12 @@ // 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 { ArchiveProjectDocument } from "../../../src/gql/graphql.js"; +import { + ArchiveProjectDocument, + GetProjectDocument, + GetProjectWithReactionsDocument, +} from "../../../src/gql/graphql.js"; import { archiveProject, createProject, @@ -18,6 +23,96 @@ function mockGqlClient(response: Record<string, unknown>): GraphQLClient { } as unknown as GraphQLClient; } +function getFragment( + document: DocumentNode, + name: string, +): FragmentDefinitionNode { + const fragment = document.definitions.find( + (definition): definition is FragmentDefinitionNode => + definition.kind === Kind.FRAGMENT_DEFINITION && + definition.name.value === name, + ); + + if (!fragment) { + throw new Error(`Fragment ${name} not found`); + } + + return fragment; +} + +describe("project reaction-aware read documents", () => { + it("adds only paginated root discussion comments with reactions to the opt-in project read", () => { + const baseFragment = getFragment(GetProjectDocument, "ProjectDetailFields"); + const reactionFragment = getFragment( + GetProjectWithReactionsDocument, + "ProjectDetailFieldsWithReactions", + ); + + const reactionSelections = reactionFragment.selectionSet.selections.filter( + (selection) => + selection.kind === Kind.FRAGMENT_SPREAD || + selection.kind === Kind.FIELD, + ); + + expect( + reactionSelections.map((selection) => + selection.kind === Kind.FRAGMENT_SPREAD + ? `...${selection.name.value}` + : selection.name.value, + ), + ).toEqual(["...ProjectDetailFields", "comments"]); + + const commentsField = reactionSelections.find( + (selection) => + selection.kind === Kind.FIELD && selection.name.value === "comments", + ); + + expect(commentsField).toBeDefined(); + if (!commentsField || commentsField.kind !== Kind.FIELD) { + throw new Error("comments field not found"); + } + + expect( + commentsField.arguments?.map((argument) => argument.name.value), + ).toEqual(["first", "after", "filter"]); + + const filterArgument = commentsField.arguments?.find( + (argument) => argument.name.value === "filter", + ); + expect(filterArgument).toBeDefined(); + + const commentsSelections = commentsField.selectionSet?.selections.filter( + (selection) => selection.kind === Kind.FIELD, + ); + expect( + commentsSelections?.map((selection) => selection.name.value), + ).toEqual(["nodes", "pageInfo"]); + + const nodesField = commentsSelections?.find( + (selection) => selection.name.value === "nodes", + ); + expect(nodesField?.selectionSet?.selections).toHaveLength(1); + expect(nodesField?.selectionSet?.selections[0]).toMatchObject({ + kind: Kind.FRAGMENT_SPREAD, + name: { value: "DiscussionCommentFieldsWithReactions" }, + }); + + const pageInfoField = commentsSelections?.find( + (selection) => selection.name.value === "pageInfo", + ); + expect( + pageInfoField?.selectionSet?.selections + .filter((selection) => selection.kind === Kind.FIELD) + .map((selection) => selection.name.value), + ).toEqual(["hasNextPage", "endCursor"]); + + const baseFieldNames = baseFragment.selectionSet.selections + .filter((selection) => selection.kind === Kind.FIELD) + .map((selection) => selection.name.value); + expect(baseFieldNames).not.toContain("comments"); + }); +}); + describe("listProjects", () => { it("returns projects", async () => { const client = mockGqlClient({ diff --git a/tests/unit/services/reaction-service.test.ts b/tests/unit/services/reaction-service.test.ts new file mode 100644 index 00000000..3e3243f2 --- /dev/null +++ b/tests/unit/services/reaction-service.test.ts @@ -0,0 +1,455 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { + createReactionForComment, + createReactionForIssue, + deleteOwnReactionByEmoji, + deleteOwnReactionById, + normalizeReactions, +} from "../../../src/services/reaction-service.js"; + +function createClient(): GraphQLClient { + return { request: vi.fn() } as unknown as GraphQLClient; +} + +describe("createReactionForIssue", () => { + it("creates a reaction when the viewer has not used that emoji yet", async () => { + const client = createClient(); + vi.mocked(client.request) + .mockResolvedValueOnce({ + viewer: { id: "user-1", name: "Ada", email: "ada@example.com" }, + }) + .mockResolvedValueOnce({ + issue: { + id: "issue-1", + reactions: [ + { + id: "r-1", + emoji: "🎉", + user: { id: "user-2", displayName: "Bob" }, + externalUser: null, + }, + ], + }, + }) + .mockResolvedValueOnce({ + reactionCreate: { + success: true, + reaction: { + id: "r-2", + emoji: "👍", + user: { id: "user-1", displayName: "Ada" }, + externalUser: null, + }, + }, + }); + + await expect( + createReactionForIssue(client, { issueId: "issue-1", emoji: "👍" }), + ).resolves.toEqual({ + id: "r-2", + emoji: "👍", + user: { id: "user-1", displayName: "Ada" }, + externalUser: null, + }); + expect(client.request).toHaveBeenNthCalledWith(3, expect.anything(), { + input: { issueId: "issue-1", emoji: "👍" }, + }); + }); + + it("rejects duplicate viewer reaction before mutation", async () => { + const client = createClient(); + vi.mocked(client.request) + .mockResolvedValueOnce({ + viewer: { id: "user-1", name: "Ada", email: "ada@example.com" }, + }) + .mockResolvedValueOnce({ + issue: { + id: "issue-1", + reactions: [ + { + id: "r-1", + emoji: "👍", + user: { id: "user-1", displayName: "Ada" }, + externalUser: null, + }, + ], + }, + }); + + await expect( + createReactionForIssue(client, { issueId: "issue-1", emoji: "👍" }), + ).rejects.toThrow("Already reacted with emoji 👍"); + }); + + it("normalizes emoji before duplicate viewer reaction checks", async () => { + const client = createClient(); + vi.mocked(client.request) + .mockResolvedValueOnce({ + viewer: { id: "user-1", name: "Ada", email: "ada@example.com" }, + }) + .mockResolvedValueOnce({ + issue: { + id: "issue-1", + reactions: [ + { + id: "r-1", + emoji: "👍", + user: { id: "user-1", displayName: "Ada" }, + externalUser: null, + }, + ], + }, + }); + + await expect( + createReactionForIssue(client, { issueId: "issue-1", emoji: " 👍 " }), + ).rejects.toThrow("Already reacted with emoji 👍"); + expect(client.request).toHaveBeenCalledTimes(2); + }); + + it("fails clearly when the issue target does not exist", async () => { + const client = createClient(); + vi.mocked(client.request) + .mockResolvedValueOnce({ + viewer: { id: "user-1", name: "Ada", email: "ada@example.com" }, + }) + .mockResolvedValueOnce({ issue: null }); + + await expect( + createReactionForIssue(client, { + issueId: "issue-missing", + emoji: "👍", + }), + ).rejects.toThrow('Issue with ID "issue-missing" not found'); + }); +}); + +describe("createReactionForComment", () => { + it("creates a reaction for a comment when the viewer has not used that emoji yet", async () => { + const client = createClient(); + vi.mocked(client.request) + .mockResolvedValueOnce({ + viewer: { id: "user-1", name: "Ada", email: "ada@example.com" }, + }) + .mockResolvedValueOnce({ + comment: { + id: "comment-1", + parentId: null, + reactions: [ + { + id: "r-1", + emoji: "👀", + user: { id: "user-2", displayName: "Bob" }, + externalUser: null, + }, + ], + }, + }) + .mockResolvedValueOnce({ + reactionCreate: { + success: true, + reaction: { + id: "r-2", + emoji: "👍", + user: { id: "user-1", displayName: "Ada" }, + externalUser: null, + }, + }, + }); + + await expect( + createReactionForComment(client, { commentId: "comment-1", emoji: "👍" }), + ).resolves.toEqual({ + id: "r-2", + emoji: "👍", + user: { id: "user-1", displayName: "Ada" }, + externalUser: null, + }); + }); + + it("fails clearly when the comment target does not exist", async () => { + const client = createClient(); + vi.mocked(client.request) + .mockResolvedValueOnce({ + viewer: { id: "user-1", name: "Ada", email: "ada@example.com" }, + }) + .mockResolvedValueOnce({ comment: null }); + + await expect( + createReactionForComment(client, { + commentId: "comment-missing", + emoji: "👍", + }), + ).rejects.toThrow('Discussion comment ID "comment-missing" not found'); + }); +}); + +describe("deleteOwnReactionByEmoji", () => { + it("deletes viewer-owned matching reaction", async () => { + const client = createClient(); + vi.mocked(client.request) + .mockResolvedValueOnce({ + viewer: { id: "user-1", name: "Ada", email: "ada@example.com" }, + }) + .mockResolvedValueOnce({ + comment: { + id: "comment-1", + parentId: null, + reactions: [ + { + id: "r-1", + emoji: "👍", + user: { id: "user-1", displayName: "Ada" }, + externalUser: null, + }, + ], + }, + }) + .mockResolvedValueOnce({ + reactionDelete: { success: true, entityId: "r-1" }, + }); + + await expect( + deleteOwnReactionByEmoji(client, { + kind: "comment", + id: "comment-1", + emoji: "👍", + }), + ).resolves.toEqual({ id: "r-1", success: true }); + }); + + it("normalizes emoji before matching viewer-owned reactions", async () => { + const client = createClient(); + vi.mocked(client.request) + .mockResolvedValueOnce({ + viewer: { id: "user-1", name: "Ada", email: "ada@example.com" }, + }) + .mockResolvedValueOnce({ + comment: { + id: "comment-1", + parentId: null, + reactions: [ + { + id: "r-1", + emoji: "👍", + user: { id: "user-1", displayName: "Ada" }, + externalUser: null, + }, + ], + }, + }) + .mockResolvedValueOnce({ + reactionDelete: { success: true, entityId: "r-1" }, + }); + + await expect( + deleteOwnReactionByEmoji(client, { + kind: "comment", + id: "comment-1", + emoji: " 👍 ", + }), + ).resolves.toEqual({ id: "r-1", success: true }); + }); + + it("fails when the viewer has no matching reaction for the emoji", async () => { + const client = createClient(); + vi.mocked(client.request) + .mockResolvedValueOnce({ + viewer: { id: "user-1", name: "Ada", email: "ada@example.com" }, + }) + .mockResolvedValueOnce({ + comment: { + id: "comment-1", + parentId: null, + reactions: [ + { + id: "r-1", + emoji: "👍", + user: { id: "user-2", displayName: "Bob" }, + externalUser: null, + }, + ], + }, + }); + + await expect( + deleteOwnReactionByEmoji(client, { + kind: "comment", + id: "comment-1", + emoji: "👍", + }), + ).rejects.toThrow("No own reaction found with emoji 👍"); + }); + + it("fails when the viewer has multiple matching reactions for the emoji", async () => { + const client = createClient(); + vi.mocked(client.request) + .mockResolvedValueOnce({ + viewer: { id: "user-1", name: "Ada", email: "ada@example.com" }, + }) + .mockResolvedValueOnce({ + comment: { + id: "comment-1", + parentId: null, + reactions: [ + { + id: "r-1", + emoji: "👍", + user: { id: "user-1", displayName: "Ada" }, + externalUser: null, + }, + { + id: "r-2", + emoji: "👍", + user: { id: "user-1", displayName: "Ada" }, + externalUser: null, + }, + ], + }, + }); + + await expect( + deleteOwnReactionByEmoji(client, { + kind: "comment", + id: "comment-1", + emoji: "👍", + }), + ).rejects.toThrow("Multiple own reactions found with emoji 👍"); + }); +}); + +describe("deleteOwnReactionById", () => { + it("deletes a viewer-owned reaction by id", async () => { + const client = createClient(); + vi.mocked(client.request) + .mockResolvedValueOnce({ + viewer: { id: "user-1", name: "Ada", email: "ada@example.com" }, + }) + .mockResolvedValueOnce({ + issue: { + id: "issue-1", + reactions: [ + { + id: "r-1", + emoji: "👍", + user: { id: "user-1", displayName: "Ada" }, + externalUser: null, + }, + ], + }, + }) + .mockResolvedValueOnce({ + reactionDelete: { success: true, entityId: "r-1" }, + }); + + await expect( + deleteOwnReactionById(client, { + kind: "issue", + id: "issue-1", + reactionId: "r-1", + }), + ).resolves.toEqual({ id: "r-1", success: true }); + }); + + it("fails when the reaction id does not exist on the target", async () => { + const client = createClient(); + vi.mocked(client.request) + .mockResolvedValueOnce({ + viewer: { id: "user-1", name: "Ada", email: "ada@example.com" }, + }) + .mockResolvedValueOnce({ + issue: { + id: "issue-1", + reactions: [ + { + id: "r-1", + emoji: "👍", + user: { id: "user-1", displayName: "Ada" }, + externalUser: null, + }, + ], + }, + }); + + await expect( + deleteOwnReactionById(client, { + kind: "issue", + id: "issue-1", + reactionId: "missing-reaction", + }), + ).rejects.toThrow('Reaction "missing-reaction" not found'); + }); + + it("fails when the reaction is not owned by the viewer", async () => { + const client = createClient(); + vi.mocked(client.request) + .mockResolvedValueOnce({ + viewer: { id: "user-1", name: "Ada", email: "ada@example.com" }, + }) + .mockResolvedValueOnce({ + issue: { + id: "issue-1", + reactions: [ + { + id: "r-1", + emoji: "👍", + user: { id: "user-2", displayName: "Bob" }, + externalUser: null, + }, + ], + }, + }); + + await expect( + deleteOwnReactionById(client, { + kind: "issue", + id: "issue-1", + reactionId: "r-1", + }), + ).rejects.toThrow('Reaction "r-1" is not owned by viewer'); + }); +}); + +describe("normalizeReactions", () => { + it("groups and sorts workspace and external users deterministically", () => { + const result = normalizeReactions([ + { + id: "r-2", + emoji: "👍", + user: { id: "u-2", displayName: "Bob" }, + externalUser: null, + }, + { + id: "r-1", + emoji: "👍", + user: { id: "u-1", displayName: "Ada" }, + externalUser: null, + }, + { + id: "r-3", + emoji: "🎉", + user: null, + externalUser: { id: "x-1", name: "CI Bot" }, + }, + ]); + + expect(result).toEqual([ + { + emoji: "👍", + count: 2, + users: [ + { id: "u-1", displayName: "Ada", type: "user" }, + { id: "u-2", displayName: "Bob", type: "user" }, + ], + reactionIds: ["r-1", "r-2"], + }, + { + emoji: "🎉", + count: 1, + users: [{ id: "x-1", displayName: "CI Bot", type: "external" }], + reactionIds: ["r-3"], + }, + ]); + }); +});