From 607c954bb861519c9be5b5499c07844262b2b8de Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 24 Apr 2026 15:52:58 +0000 Subject: [PATCH 01/32] fix(issues): honor explicit completed status filters Closes #179 --- src/services/issue-service.ts | 16 +++++++++++++++ tests/unit/services/issue-service.test.ts | 24 +++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/services/issue-service.ts b/src/services/issue-service.ts index d0b7780..328a099 100644 --- a/src/services/issue-service.ts +++ b/src/services/issue-service.ts @@ -57,7 +57,23 @@ 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], }; diff --git a/tests/unit/services/issue-service.test.ts b/tests/unit/services/issue-service.test.ts index 235bd48..bbf9137 100644 --- a/tests/unit/services/issue-service.test.ts +++ b/tests/unit/services/issue-service.test.ts @@ -197,6 +197,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: { From 11b4a74f4151c21c0457109e6b1f16335ddfcf2c Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 24 Apr 2026 19:31:00 +0000 Subject: [PATCH 02/32] chore(release): 2026.4.9-next.1 [skip ci] ## [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) --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c2246f..694bd57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [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/package-lock.json b/package-lock.json index b90812a..4047425 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linearis", - "version": "2026.4.8", + "version": "2026.4.9-next.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.4.8", + "version": "2026.4.9-next.1", "license": "MIT", "dependencies": { "@linear/sdk": "81.0.0", diff --git a/package.json b/package.json index 1f5ecb4..7e9ad62 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.4.8", + "version": "2026.4.9-next.1", "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", From 0c1874aad8cbadf6e4d729ad0940f1f3bcdd4106 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:41:56 +0200 Subject: [PATCH 03/32] feat(labels): add issue label scope filters Closes #116 --- src/commands/labels.ts | 34 +++++ src/services/label-service.ts | 31 ++++- tests/unit/commands/labels.test.ts | 151 ++++++++++++++++++++++ tests/unit/services/label-service.test.ts | 34 +++++ 4 files changed, 247 insertions(+), 3 deletions(-) diff --git a/src/commands/labels.ts b/src/commands/labels.ts index baa70b1..e6b6d3a 100644 --- a/src/commands/labels.ts +++ b/src/commands/labels.ts @@ -5,6 +5,7 @@ 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 +14,7 @@ import { interface ListLabelsOptions extends CommandOptions { team?: string; type?: string; + scope?: string; limit: string; after?: string; } @@ -25,6 +27,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,6 +64,7 @@ export function setupLabelsCommands(program: Command): void { .command("list") .description("list available labels") .option("--type ", "label type: issue (default) or project", "issue") + .option("--scope ", "issue label scope: workspace or team") .option("--team ", "filter by team (key, name, or UUID)") .option("-l, --limit ", "max results", "50") .option("--after ", "cursor for next page") @@ -59,9 +73,11 @@ export function setupLabelsCommands(program: Command): void { const [options, command] = args as [ListLabelsOptions, Command]; const ctx = createContext(command.parent!.parent!.opts()); 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 +88,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/services/label-service.ts b/src/services/label-service.ts index 9b08d78..9d8f485 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> { - 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(GetLabelsDocument, { first: limit, diff --git a/tests/unit/commands/labels.test.ts b/tests/unit/commands/labels.test.ts index 56176b1..33bd4d0 100644 --- a/tests/unit/commands/labels.test.ts +++ b/tests/unit/commands/labels.test.ts @@ -71,6 +71,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 +104,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 +173,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 +240,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 +339,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/services/label-service.test.ts b/tests/unit/services/label-service.test.ts index 2d0ad7a..34c3265 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: { From e593539083d0eb01b7325e5eb05bb2ed14a3f441 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 24 Apr 2026 19:49:25 +0000 Subject: [PATCH 04/32] chore(release): 2026.4.9-next.2 [skip ci] ## [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) --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 694bd57..18a78d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [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 diff --git a/package-lock.json b/package-lock.json index 4047425..6ae1404 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linearis", - "version": "2026.4.9-next.1", + "version": "2026.4.9-next.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.4.9-next.1", + "version": "2026.4.9-next.2", "license": "MIT", "dependencies": { "@linear/sdk": "81.0.0", diff --git a/package.json b/package.json index 7e9ad62..6167d63 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.4.9-next.1", + "version": "2026.4.9-next.2", "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", From 963af954dbc286f755f93b740b36fcca4626a2c4 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:51:05 +0200 Subject: [PATCH 05/32] feat(issues): batch-resolve search filter identifiers Use batch GraphQL payload for issue search filter lookups. Keep resolver-layer ID semantics and team-scoped cycle/status resolution. Refs #63 --- graphql/queries/issues.graphql | 401 +++++++++++++++--- src/common/resolve-filters.ts | 103 ++--- src/resolvers/issue-filter-resolver.ts | 79 ++++ tests/unit/common/resolve-filters.test.ts | 374 +++++----------- .../resolvers/issue-filter-resolver.test.ts | 100 +++++ 5 files changed, 662 insertions(+), 395 deletions(-) create mode 100644 src/resolvers/issue-filter-resolver.ts create mode 100644 tests/unit/resolvers/issue-filter-resolver.test.ts diff --git a/graphql/queries/issues.graphql b/graphql/queries/issues.graphql index 2f36565..7d4bf09 100644 --- a/graphql/queries/issues.graphql +++ b/graphql/queries/issues.graphql @@ -353,26 +353,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 +405,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 +477,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 +651,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 - projectMilestones { + 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 } } - # 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 +777,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/src/common/resolve-filters.ts b/src/common/resolve-filters.ts index f7e8fe3..ccd3fca 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 0000000..8fdf316 --- /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 { + 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/tests/unit/common/resolve-filters.test.ts b/tests/unit/common/resolve-filters.test.ts index 38135a3..827ab20 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 0000000..ef1b6a1 --- /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" }); + }); +}); From 1cc4babeffc3a0baf17224b8ba66ed6987fc7671 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:51:16 +0200 Subject: [PATCH 06/32] docs(agents): clarify resolver client exceptions Document SDK-first resolver contract. Allow explicit GraphQL exceptions when SDK capability is missing. Refs #63 --- AGENTS.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d5f07f5..82724d7 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 From 5aded6ac0bbdd930801ccaef884723a84c68ff89 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 25 Apr 2026 13:53:44 +0000 Subject: [PATCH 07/32] chore(release): 2026.4.9-next.3 [skip ci] ## [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) --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18a78d9..011e9da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [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 diff --git a/package-lock.json b/package-lock.json index 6ae1404..f46e144 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linearis", - "version": "2026.4.9-next.2", + "version": "2026.4.9-next.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.4.9-next.2", + "version": "2026.4.9-next.3", "license": "MIT", "dependencies": { "@linear/sdk": "81.0.0", diff --git a/package.json b/package.json index 6167d63..2289f61 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.4.9-next.2", + "version": "2026.4.9-next.3", "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", From 1ae10e1313722c58102c8269cc97886ed8891d8d Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:00:03 +0200 Subject: [PATCH 08/32] feat(discussions): add GraphQL and service layer Add GraphQL operations for discussion roots, replies, and resolve state changes. Implement discussion service helpers for listing root threads and replies, creating root discussions and replies, editing and deleting root/reply comments, and resolving or unresolving threads. Add service tests covering happy paths, nested reply retrieval, domain validation, pagination behavior, and primary error cases. --- graphql/mutations/discussions.graphql | 62 ++ graphql/queries/discussions.graphql | 159 +++++ src/services/discussion-service.ts | 615 ++++++++++++++++++ .../unit/services/discussion-service.test.ts | 587 +++++++++++++++++ 4 files changed, 1423 insertions(+) create mode 100644 graphql/mutations/discussions.graphql create mode 100644 graphql/queries/discussions.graphql create mode 100644 src/services/discussion-service.ts create mode 100644 tests/unit/services/discussion-service.test.ts diff --git a/graphql/mutations/discussions.graphql b/graphql/mutations/discussions.graphql new file mode 100644 index 0000000..e728bed --- /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/queries/discussions.graphql b/graphql/queries/discussions.graphql new file mode 100644 index 0000000..94348a6 --- /dev/null +++ b/graphql/queries/discussions.graphql @@ -0,0 +1,159 @@ +fragment DiscussionCommentFields on Comment { + id + body + createdAt + editedAt + parentId + resolvedAt + resolvingComment { + id + } + resolvingUser { + id + displayName + } + user { + id + displayName + } +} + +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 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 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 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 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 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 GetDiscussionComment($id: String!) { + comment(id: $id) { + ...DiscussionCommentFields + } +} diff --git a/src/services/discussion-service.ts b/src/services/discussion-service.ts new file mode 100644 index 0000000..a0a4fa2 --- /dev/null +++ b/src/services/discussion-service.ts @@ -0,0 +1,615 @@ +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, + EditDiscussionReplyDocument, + type EditDiscussionReplyMutation, + GetDiscussionCommentContextDocument, + type GetDiscussionCommentContextQuery, + ListInitiativeDiscussionReplyCandidatesDocument, + type ListInitiativeDiscussionReplyCandidatesQuery, + ListInitiativeDiscussionRootsDocument, + type ListInitiativeDiscussionRootsQuery, + ListIssueDiscussionReplyCandidatesDocument, + type ListIssueDiscussionReplyCandidatesQuery, + ListIssueDiscussionRootsDocument, + type ListIssueDiscussionRootsQuery, + ListProjectDiscussionReplyCandidatesDocument, + type ListProjectDiscussionReplyCandidatesQuery, + ListProjectDiscussionRootsDocument, + type ListProjectDiscussionRootsQuery, + ResolveDiscussionDocument, + type ResolveDiscussionMutation, + StartDiscussionDocument, + type StartDiscussionMutation, + UnresolveDiscussionDocument, + type UnresolveDiscussionMutation, +} from "../gql/graphql.js"; + +export type DiscussionThread = DiscussionCommentFieldsFragment; +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; + +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 { + const result = await client.request( + 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 { + const result = await client.request( + 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 { + 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; +} + +function compareDiscussionCommentsChronologically( + a: Pick, + b: Pick, +): 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 { + const entity = getDiscussionThreadEntity(thread); + const nodes: DiscussionCommentFieldsFragment[] = []; + let after: string | undefined; + + while (true) { + let result: DiscussionReplyCandidateQuery; + + if (entity.kind === "issue") { + result = await client.request( + ListIssueDiscussionReplyCandidatesDocument, + { + issueId: entity.id, + first: DISCUSSION_REPLY_FETCH_LIMIT, + after, + }, + ); + } else if (entity.kind === "project") { + result = await client.request( + ListProjectDiscussionReplyCandidatesDocument, + { + projectId: entity.id, + first: DISCUSSION_REPLY_FETCH_LIMIT, + after, + }, + ); + } else { + result = + await client.request( + 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); +} + +function filterThreadReplies( + comments: readonly DiscussionCommentFieldsFragment[], + threadId: string, +): DiscussionCommentFieldsFragment[] { + const childrenByParentId = new Map< + string, + DiscussionCommentFieldsFragment[] + >(); + + 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: DiscussionCommentFieldsFragment[] = []; + 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( + replies: readonly DiscussionCommentFieldsFragment[], + limit: number, + after?: string, +): PaginatedResult { + 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 { + const result = await client.request( + StartDiscussionDocument, + { input }, + ); + + if (!result.commentCreate.success || !result.commentCreate.comment) { + throw new Error("Failed to start discussion"); + } + + return result.commentCreate.comment; +} + +export async function listDiscussionsForIssue( + client: GraphQLClient, + issueId: string, + options: PaginationOptions = {}, +): Promise> { + const { limit = DEFAULT_ROOT_LIMIT, after } = options; + const result = await client.request( + 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 listDiscussionsForProject( + client: GraphQLClient, + projectId: string, + options: PaginationOptions = {}, +): Promise> { + const { limit = DEFAULT_ROOT_LIMIT, after } = options; + const result = await client.request( + 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 listDiscussionsForInitiative( + client: GraphQLClient, + initiativeId: string, + options: PaginationOptions = {}, +): Promise> { + const { limit = DEFAULT_ROOT_LIMIT, after } = options; + const result = await client.request( + 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 listDiscussionReplies( + client: GraphQLClient, + threadId: string, + options: PaginationOptions = {}, + expectedEntityKind?: DiscussionEntityKind, +): Promise> { + 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 startIssueDiscussion( + client: GraphQLClient, + input: { issueId: string; body: string }, +): Promise { + return startDiscussion(client, { issueId: input.issueId, body: input.body }); +} + +export async function startProjectDiscussion( + client: GraphQLClient, + input: { projectId: string; body: string }, +): Promise { + return startDiscussion(client, { + projectId: input.projectId, + body: input.body, + }); +} + +export async function startInitiativeDiscussion( + client: GraphQLClient, + input: { initiativeId: string; body: string }, +): Promise { + return startDiscussion(client, { + initiativeId: input.initiativeId, + body: input.body, + }); +} + +export async function replyToDiscussion( + client: GraphQLClient, + input: { threadId: string; body: string; entityKind?: DiscussionEntityKind }, +): Promise { + await assertRootDiscussionThread(client, input.threadId, input.entityKind); + + const result = await client.request( + 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 { + await assertReplyComment(client, id, expectedEntityKind); + + const result = await client.request( + 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( + 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 { + await assertDiscussionCommentExists(client, id, expectedEntityKind); + + const result = await client.request( + 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( + 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 { + await assertRootDiscussionThread(client, input.threadId, input.entityKind); + + const result = await client.request( + 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 { + await assertRootDiscussionThread(client, threadId, expectedEntityKind); + + const result = await client.request( + UnresolveDiscussionDocument, + { id: threadId }, + ); + + if (!result.commentUnresolve.success || !result.commentUnresolve.comment) { + throw new Error("Failed to unresolve discussion"); + } + + return result.commentUnresolve.comment; +} diff --git a/tests/unit/services/discussion-service.test.ts b/tests/unit/services/discussion-service.test.ts new file mode 100644 index 0000000..91bbafd --- /dev/null +++ b/tests/unit/services/discussion-service.test.ts @@ -0,0 +1,587 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { + GetDiscussionCommentContextDocument, + type ListIssueDiscussionRootsQuery, +} from "../../../src/gql/graphql.js"; +import { + deleteDiscussionComment, + deleteDiscussionReply, + editDiscussionComment, + editDiscussionReply, + listDiscussionReplies, + listDiscussionsForInitiative, + listDiscussionsForIssue, + listDiscussionsForProject, + replyToDiscussion, + resolveDiscussion, + startInitiativeDiscussion, + startIssueDiscussion, + startProjectDiscussion, + unresolveDiscussion, +} from "../../../src/services/discussion-service.js"; + +function createClientMock(): GraphQLClient { + return { + request: vi.fn(), + } as unknown as GraphQLClient; +} + +const MOCK_USER = { id: "user-1", displayName: "Test User" }; + +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, + }; +} + +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'); + }); +}); + +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'); + }); +}); + +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'); + }); +}); + +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', + ); + }); +}); + +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", + }); + }); +}); From 6b3861cbfbdb6324270cda1a7977547e9253ff2e Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:00:22 +0200 Subject: [PATCH 09/32] feat(issues): add discussion commands Add issue-scoped discussion commands for starting root threads, listing roots and replies, replying, editing, deleting, resolving, and unresolving discussions. Preserve issue delete behavior by using for discussion deletion and keep reply-specific commands alongside generic root-or-reply edit and delete flows. Add command tests for wiring, validation, pagination, and output behavior. --- src/commands/issues.ts | 269 ++++++++++++++++++++++ tests/unit/commands/issues.test.ts | 356 +++++++++++++++++++++++++++++ 2 files changed, 625 insertions(+) diff --git a/src/commands/issues.ts b/src/commands/issues.ts index f9158c2..b4c372e 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import type { CommandContext } from "../common/context.js"; import { createContext } from "../common/context.js"; +import { invalidParameterError } from "../common/errors.js"; import { validateEstimateAgainstTeamConfig } from "../common/estimate-validation.js"; import { isUuid, @@ -34,6 +35,18 @@ import { resolveTeamId, } from "../resolvers/team-resolver.js"; import { resolveUserId } from "../resolvers/user-resolver.js"; +import { + deleteDiscussionComment, + deleteDiscussionReply, + editDiscussionComment, + editDiscussionReply, + listDiscussionReplies, + listDiscussionsForIssue, + replyToDiscussion, + resolveDiscussion, + startIssueDiscussion, + unresolveDiscussion, +} from "../services/discussion-service.js"; import { buildIssueFilter } from "../services/issue-filter.js"; import { createIssueRelation, @@ -116,6 +129,19 @@ interface ReadOptions { withCommentThreads?: boolean; } +interface DiscussionsOptions { + limit?: string; + after?: string; +} + +interface DiscussionBodyOptions { + body?: string; +} + +interface ResolveDiscussionOptions { + withComment?: string; +} + export const ISSUES_META: DomainMeta = { name: "issues", summary: "work items with status, priority, assignee, labels", @@ -466,6 +492,249 @@ export function setupIssuesCommands(program: Command): void { }), ); + 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(command.parent!.parent!.opts()); + + 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") + .action( + handleCommand(async (...args: unknown[]) => { + const [issue, options, command] = args as [ + string, + DiscussionsOptions, + Command, + ]; + const ctx = createContext(command.parent!.parent!.opts()); + + const issueId = await resolveIssueId(ctx.sdk, issue); + const result = await listDiscussionsForIssue(ctx.gql, issueId, { + limit: parseLimit(options.limit || "25"), + after: options.after, + }); + + outputSuccess(result); + }), + ); + + issues + .command("replies ") + .description("list replies in a root discussion thread") + .option("-l, --limit ", "max results", "50") + .option("--after ", "cursor for next page") + .action( + handleCommand(async (...args: unknown[]) => { + const [thread, options, command] = args as [ + string, + DiscussionsOptions, + Command, + ]; + const ctx = createContext(command.parent!.parent!.opts()); + + const result = await listDiscussionReplies( + ctx.gql, + thread, + { + limit: parseLimit(options.limit || "50"), + after: options.after, + }, + "issue", + ); + + outputSuccess(result); + }), + ); + + 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(command.parent!.parent!.opts()); + + 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(command.parent!.parent!.opts()); + + 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(command.parent!.parent!.opts()); + + 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(command.parent!.parent!.opts()); + + 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(command.parent!.parent!.opts()); + + 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(command.parent!.parent!.opts()); + + 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(command.parent!.parent!.opts()); + + const result = await unresolveDiscussion(ctx.gql, thread, "issue"); + + outputSuccess(result); + }), + ); + issues .command("create ") .description("create new issue") diff --git a/tests/unit/commands/issues.test.ts b/tests/unit/commands/issues.test.ts index f855b0c..78608b9 100644 --- a/tests/unit/commands/issues.test.ts +++ b/tests/unit/commands/issues.test.ts @@ -112,6 +112,43 @@ vi.mock("../../../src/services/issue-relation-service.js", () => ({ findIssueRelation: vi.fn(), })); +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, + }, + }), + 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" }), +})); + import { setupIssuesCommands } from "../../../src/commands/issues.js"; import { resolveIssueEstimateContext, @@ -122,6 +159,18 @@ import { resolveTeamId, } from "../../../src/resolvers/team-resolver.js"; import { resolveUserId } from "../../../src/resolvers/user-resolver.js"; +import { + deleteDiscussionComment, + deleteDiscussionReply, + editDiscussionComment, + editDiscussionReply, + listDiscussionReplies, + listDiscussionsForIssue, + replyToDiscussion, + resolveDiscussion, + startIssueDiscussion, + unresolveDiscussion, +} from "../../../src/services/discussion-service.js"; import { archiveIssue, createIssue, @@ -1145,6 +1194,313 @@ 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 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 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", + ); + }); +}); + describe("issues create relations", () => { beforeEach(() => { vi.clearAllMocks(); From ff64f2edd7ceb5cdbc34de405b984582c86687d9 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:00:23 +0200 Subject: [PATCH 10/32] feat(projects): add discussion commands Add project-scoped discussion commands for starting root threads, listing roots and replies, replying, editing, deleting, resolving, and unresolving project discussions. Keep entity deletion separate by using discussion-specific delete commands and preserve reply-specific edit/delete operations beside generic root-or-reply flows. Add command tests for wiring, validation, pagination, and output behavior. --- src/commands/projects.ts | 264 ++++++++++++++++++ tests/unit/commands/projects.test.ts | 393 +++++++++++++++++++++++++++ 2 files changed, 657 insertions(+) diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 57fae5f..c456eb7 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -11,6 +11,18 @@ import { import { resolveProjectStatusId } from "../resolvers/project-status-resolver.js"; import { resolveTeamId } from "../resolvers/team-resolver.js"; import { resolveUserId } from "../resolvers/user-resolver.js"; +import { + deleteDiscussionComment, + deleteDiscussionReply, + editDiscussionComment, + editDiscussionReply, + listDiscussionReplies, + listDiscussionsForProject, + replyToDiscussion, + resolveDiscussion, + startProjectDiscussion, + unresolveDiscussion, +} from "../services/discussion-service.js"; import { archiveProject, createProject, @@ -26,6 +38,19 @@ interface ListOptions { after?: string; } +interface DiscussionsOptions { + limit?: string; + after?: string; +} + +interface DiscussionBodyOptions { + body?: string; +} + +interface ResolveDiscussionOptions { + withComment?: string; +} + interface CreateOptions { teams: string; description?: string; @@ -119,6 +144,245 @@ export function setupProjectsCommands(program: Command): void { }), ); + 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(command.parent!.parent!.opts()); + + 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") + .action( + handleCommand(async (...args: unknown[]) => { + const [project, options, command] = args as [ + string, + DiscussionsOptions, + Command, + ]; + const ctx = createContext(command.parent!.parent!.opts()); + + const projectId = await resolveProjectId(ctx.sdk, project); + const result = await listDiscussionsForProject(ctx.gql, projectId, { + limit: parseLimit(options.limit || "25"), + after: options.after, + }); + + outputSuccess(result); + }), + ); + + 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") + .action( + handleCommand(async (...args: unknown[]) => { + const [thread, options, command] = args as [ + string, + DiscussionsOptions, + Command, + ]; + const ctx = createContext(command.parent!.parent!.opts()); + + const result = await listDiscussionReplies( + ctx.gql, + thread, + { + limit: parseLimit(options.limit || "50"), + after: options.after, + }, + "project", + ); + + outputSuccess(result); + }), + ); + + 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(command.parent!.parent!.opts()); + + 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(command.parent!.parent!.opts()); + + 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(command.parent!.parent!.opts()); + + 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(command.parent!.parent!.opts()); + + 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(command.parent!.parent!.opts()); + + 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(command.parent!.parent!.opts()); + + 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(command.parent!.parent!.opts()); + + const result = await unresolveDiscussion(ctx.gql, thread, "project"); + + outputSuccess(result); + }), + ); + projects .command("create <name>") .description("create a new project") diff --git a/tests/unit/commands/projects.test.ts b/tests/unit/commands/projects.test.ts index d093a96..1565fd0 100644 --- a/tests/unit/commands/projects.test.ts +++ b/tests/unit/commands/projects.test.ts @@ -44,9 +44,60 @@ 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, + }, + }), + 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" }), +})); + import { setupProjectsCommands } from "../../../src/commands/projects.js"; import { outputSuccess } from "../../../src/common/output.js"; import { resolveProjectId } from "../../../src/resolvers/project-resolver.js"; +import { + deleteDiscussionComment, + deleteDiscussionReply, + editDiscussionComment, + editDiscussionReply, + listDiscussionReplies, + listDiscussionsForProject, + replyToDiscussion, + resolveDiscussion, + startProjectDiscussion, + unresolveDiscussion, +} from "../../../src/services/discussion-service.js"; import { archiveProject, createProject, @@ -265,6 +316,348 @@ 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 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 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" }); + }); +}); + describe("projects update", () => { beforeEach(() => { vi.clearAllMocks(); From 81bcd173f7caec48911e39738d6b4dd60672d7fc Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:00:25 +0200 Subject: [PATCH 11/32] feat(initiatives): add discussion commands Add initiative-scoped discussion commands for starting root threads, listing roots and replies, replying, editing, deleting, resolving, and unresolving initiative discussions. Align initiative discussion UX and validation with the issue and project command surfaces, including domain- scoped discussion safety checks. Add command tests for wiring, validation, pagination, and output behavior. --- src/commands/initiatives/entity.ts | 272 +++++++++++++++++ tests/unit/commands/initiatives.test.ts | 379 ++++++++++++++++++++++++ 2 files changed, 651 insertions(+) diff --git a/src/commands/initiatives/entity.ts b/src/commands/initiatives/entity.ts index 214ba9b..4694916 100644 --- a/src/commands/initiatives/entity.ts +++ b/src/commands/initiatives/entity.ts @@ -20,6 +20,18 @@ import { import { resolveInitiativeId } from "../../resolvers/initiative-resolver.js"; import { resolveTeamId } from "../../resolvers/team-resolver.js"; import { resolveUserId } from "../../resolvers/user-resolver.js"; +import { + deleteDiscussionComment, + deleteDiscussionReply, + editDiscussionComment, + editDiscussionReply, + listDiscussionReplies, + listDiscussionsForInitiative, + replyToDiscussion, + resolveDiscussion, + startInitiativeDiscussion, + unresolveDiscussion, +} from "../../services/discussion-service.js"; import { archiveInitiative, createInitiative, @@ -71,6 +83,19 @@ interface InitiativeListOptions extends InitiativeExpandOptions { interface InitiativeReadOptions extends InitiativeExpandOptions {} +interface DiscussionsOptions { + limit?: string; + after?: string; +} + +interface DiscussionBodyOptions { + body?: string; +} + +interface ResolveDiscussionOptions { + withComment?: string; +} + interface InitiativeCreateOptions { description?: string; content?: string; @@ -462,6 +487,253 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { }), ); + initiatives + .command("discuss <initiative>") + .description("start a discussion thread on an initiative") + .option("--body <text>", "discussion body (required, markdown supported)") + .action( + handleCommand(async (...args: unknown[]) => { + const [initiative, options, command] = args as [ + string, + DiscussionBodyOptions, + Command, + ]; + const ctx = createContext(rootOptions(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 <initiative>") + .description("list root discussion threads on an initiative") + .option("-l, --limit <n>", "max results", "25") + .option("--after <cursor>", "cursor for next page") + .action( + handleCommand(async (...args: unknown[]) => { + const [initiative, options, command] = args as [ + string, + DiscussionsOptions, + Command, + ]; + const ctx = createContext(rootOptions(command)); + + const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); + const result = await listDiscussionsForInitiative( + ctx.gql, + initiativeId, + { + limit: parseLimit(options.limit || "25"), + after: options.after, + }, + ); + + outputSuccess(result); + }), + ); + + initiatives + .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") + .action( + handleCommand(async (...args: unknown[]) => { + const [thread, options, command] = args as [ + string, + DiscussionsOptions, + Command, + ]; + const ctx = createContext(rootOptions(command)); + + const result = await listDiscussionReplies( + ctx.gql, + thread, + { + limit: parseLimit(options.limit || "50"), + after: options.after, + }, + "initiative", + ); + + outputSuccess(result); + }), + ); + + initiatives + .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(rootOptions(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 <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(rootOptions(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 <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(rootOptions(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 <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(rootOptions(command)); + + const result = await deleteDiscussionComment( + ctx.gql, + comment, + "initiative", + ); + + outputSuccess(result); + }), + ); + + initiatives + .command("delete-reply <reply>") + .description("delete a discussion reply") + .action( + handleCommand(async (...args: unknown[]) => { + const [reply, , command] = args as [string, unknown, Command]; + const ctx = createContext(rootOptions(command)); + + const result = await deleteDiscussionReply( + ctx.gql, + reply, + "initiative", + ); + + outputSuccess(result); + }), + ); + + initiatives + .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(rootOptions(command)); + + const result = await resolveDiscussion(ctx.gql, { + threadId: thread, + resolvingCommentId: options.withComment, + entityKind: "initiative", + }); + + outputSuccess(result); + }), + ); + + initiatives + .command("unresolve <thread>") + .description("unresolve a discussion thread") + .action( + handleCommand(async (...args: unknown[]) => { + const [thread, , command] = args as [string, unknown, Command]; + const ctx = createContext(rootOptions(command)); + + const result = await unresolveDiscussion(ctx.gql, thread, "initiative"); + + outputSuccess(result); + }), + ); + initiatives .command("create <name>") .description("create a new initiative") diff --git a/tests/unit/commands/initiatives.test.ts b/tests/unit/commands/initiatives.test.ts index 3e24db0..c0cf5e4 100644 --- a/tests/unit/commands/initiatives.test.ts +++ b/tests/unit/commands/initiatives.test.ts @@ -102,11 +102,60 @@ 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, + }, + }), + 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" }), +})); + import { setupInitiativesCommands } from "../../../src/commands/initiatives/index.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 { + deleteDiscussionComment, + deleteDiscussionReply, + editDiscussionComment, + editDiscussionReply, + listDiscussionReplies, + listDiscussionsForInitiative, + replyToDiscussion, + resolveDiscussion, + startInitiativeDiscussion, + unresolveDiscussion, +} from "../../../src/services/discussion-service.js"; import { createInitiativeProjectLink, deleteInitiativeProjectLink, @@ -442,3 +491,333 @@ 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 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 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" }); + }); +}); From d097eeee62dfa5446c866644ded70a779346b7e4 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:00:26 +0200 Subject: [PATCH 12/32] feat(comments): deprecate compatibility facade Keep legacy command group available as a deprecated compatibility facade over the discussion service. Make deprecation and migration guidance explicit in help text and metadata, while preserving narrowed root-thread reply compatibility and root-or-reply edit/delete support. Add command tests for compatibility delegation, help text, validation, and root-versus-reply behavior. --- src/commands/comments.ts | 92 ++++++--- tests/unit/commands/comments.test.ts | 274 +++++++++++++++++++++++++++ 2 files changed, 339 insertions(+), 27 deletions(-) create mode 100644 tests/unit/commands/comments.test.ts diff --git a/src/commands/comments.ts b/src/commands/comments.ts index c1a185f..589c6f2 100644 --- a/src/commands/comments.ts +++ b/src/commands/comments.ts @@ -1,15 +1,16 @@ import type { Command } from "commander"; import { type CommandOptions, createContext } 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 { resolveIssueId } from "../resolvers/issue-resolver.js"; import { - createComment, - deleteComment, - listComments, - replyToComment, - updateComment, -} from "../services/comment-service.js"; + deleteDiscussionComment, + editDiscussionComment, + listDiscussionsForIssue, + replyToDiscussion, + startIssueDiscussion, +} from "../services/discussion-service.js"; interface CreateCommentOptions extends CommandOptions { body?: string; @@ -30,26 +31,43 @@ interface EditCommentOptions extends CommandOptions { 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 <issue>"], + seeAlso: [ + "issues discuss <issue>", + "issues discussions <issue>", + "issues replies <thread>", + "issues reply <thread>", + "issues edit-reply <reply>", + "issues delete-reply <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 <issue>") - .description("list comments on an issue") + .description( + "deprecated compatibility: list root issue discussions (migrate to `issues discussions <issue>`)", + ) + .addHelpText("after", "\nPrefer: `issues discussions <issue>`") .addHelpText( "after", `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, @@ -67,7 +85,7 @@ export function setupCommentsCommands(program: Command): void { 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 +96,10 @@ export function setupCommentsCommands(program: Command): void { comments .command("create <issue>") - .description("create a comment on an issue") + .description( + "deprecated compatibility: start an issue discussion (migrate to `issues discuss <issue> --body <text>`)", + ) + .addHelpText("after", "\nPrefer: `issues discuss <issue> --body <text>`") .addHelpText( "after", `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, @@ -94,11 +115,11 @@ export function setupCommentsCommands(program: Command): void { const ctx = createContext(command.parent!.parent!.opts()); 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,12 +129,23 @@ export function setupCommentsCommands(program: Command): void { ); comments - .command("reply <comment>") - .description("reply to a comment") + .command("reply <thread>") + .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 <thread> --body <text>`)", + ) + .addHelpText("after", "\nPrefer: `issues reply <thread> --body <text>`") + .addHelpText( + "after", + "\nImportant: `<thread>` must be the root discussion thread ID, not a reply ID.", + ) + .addHelpText( + "after", + "\nNested-reply targets are not supported in compatibility mode.", + ) .option("--body <text>", "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, @@ -121,11 +153,11 @@ export function setupCommentsCommands(program: Command): void { const ctx = createContext(command.parent!.parent!.opts()); 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, }); @@ -135,7 +167,10 @@ export function setupCommentsCommands(program: Command): void { comments .command("edit <comment>") - .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 <reply> --body <text>`)", + ) + .addHelpText("after", "\nPrefer: `issues edit-reply <reply> --body <text>`") .option("--body <text>", "new comment body (required, markdown supported)") .action( handleCommand(async (...args: unknown[]) => { @@ -147,10 +182,10 @@ export function setupCommentsCommands(program: Command): void { const ctx = createContext(command.parent!.parent!.opts()); 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 +195,16 @@ export function setupCommentsCommands(program: Command): void { comments .command("delete <comment>") - .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 <reply>`)", + ) + .addHelpText("after", "\nPrefer: `issues delete-reply <reply>`") .action( handleCommand(async (...args: unknown[]) => { const [comment, , command] = args as [string, unknown, Command]; const ctx = createContext(command.parent!.parent!.opts()); - const result = await deleteComment(ctx.gql, comment); + const result = await deleteDiscussionComment(ctx.gql, comment); outputSuccess(result); }), diff --git a/tests/unit/commands/comments.test.ts b/tests/unit/commands/comments.test.ts new file mode 100644 index 0000000..226a706 --- /dev/null +++ b/tests/unit/commands/comments.test.ts @@ -0,0 +1,274 @@ +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: {} }, + })), +})); + +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", () => ({ + 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 { + deleteDiscussionComment, + 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 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 delegates to replyToDiscussion", 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", + }); + }); + + 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", + ); + }); +}); From 7631b38090b874670ca892c0d883f8f73b8a2024 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:00:27 +0200 Subject: [PATCH 13/32] docs(discussions): add migration guidance Document domain-centric discussion workflows in the README, including root-thread listing, nested reply listing, and reply examples. Add migration guidance from deprecated commands to the newer issue discussion commands and note compatibility limits where they still apply. --- README.md | 41 +++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f5586e9..f1a2b05 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 <domain> 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 <domain> 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 <issue> --body <text>` | `linearis issues discuss <issue> --body <text>` | +| `linearis comments list <issue>` | `linearis issues discussions <issue>` | +| `linearis comments reply <thread> --body <text>` | `linearis issues reply <thread> --body <text>` | +| `linearis comments edit <reply> --body <text>` | `linearis issues edit-reply <reply> --body <text>` | +| `linearis comments delete <reply>` | `linearis issues delete-reply <reply>` | + +Notes: +- `issues discussions <issue>` lists **root** threads. +- Use `issues replies <thread>` 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. From 938c4575bf221baa4c074b038d3fc9a55f21e137 Mon Sep 17 00:00:00 2001 From: semantic-release-bot <semantic-release-bot@martynus.net> Date: Sat, 25 Apr 2026 19:04:57 +0000 Subject: [PATCH 14/32] chore(release): 2026.4.9-next.4 [skip ci] ## [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)) --- CHANGELOG.md | 10 ++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 011e9da..7fff8c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## [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 diff --git a/package-lock.json b/package-lock.json index f46e144..b54e2dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linearis", - "version": "2026.4.9-next.3", + "version": "2026.4.9-next.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.4.9-next.3", + "version": "2026.4.9-next.4", "license": "MIT", "dependencies": { "@linear/sdk": "81.0.0", diff --git a/package.json b/package.json index 2289f61..d566327 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.4.9-next.3", + "version": "2026.4.9-next.4", "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", From a8aa2bd562e55b46164b683704d38f0e0af4954e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 02:32:16 +0000 Subject: [PATCH 15/32] chore(deps): update dev dependencies (non-major) --- package-lock.json | 423 +++++++++++++++++++++++++--------------------- 1 file changed, 233 insertions(+), 190 deletions(-) diff --git a/package-lock.json b/package-lock.json index b54e2dd..a3d5acc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "commander": "14.0.3" }, "bin": { + "linear": "dist/main.js", "linearis": "dist/main.js" }, "devDependencies": { @@ -425,9 +426,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 +442,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 +470,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 +487,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 +507,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 +527,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 +547,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 +567,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 +584,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 +895,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 +907,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 +1439,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": { @@ -2671,9 +2684,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, @@ -2902,9 +2915,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 +2984,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 +3001,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 +3018,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 +3035,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 +3052,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 +3069,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 +3089,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 +3109,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 +3129,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 +3149,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 +3169,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 +3189,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 +3206,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 +3216,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 +3242,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 +3259,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" }, @@ -3933,14 +3964,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 +3985,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 +3995,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 +4013,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 +4040,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 +4053,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 +4067,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 +4083,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 +4093,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 +4111,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" }, @@ -7109,6 +7140,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7130,6 +7164,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7151,6 +7188,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7172,6 +7212,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10915,9 +10958,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 +11245,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 +11261,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": { @@ -14479,14 +14522,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" @@ -14819,17 +14862,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 +14940,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 +14980,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" From 20a532c21ced7a912b5d1c7a52a84cc879d88de7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 05:20:28 +0000 Subject: [PATCH 16/32] chore(deps): update github actions --- .github/workflows/ci-post-merge.yml | 2 +- .github/workflows/ci-validate.yml | 2 +- .github/workflows/release-promote-next-to-main.yml | 2 +- .github/workflows/release-publish.yml | 6 +++--- .github/workflows/release-sync-main-back-to-next.yml | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci-post-merge.yml b/.github/workflows/ci-post-merge.yml index b5c0d4a..8a2a6e5 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 0012041..206b604 100644 --- a/.github/workflows/ci-validate.yml +++ b/.github/workflows/ci-validate.yml @@ -88,7 +88,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 4536ad2..3cf401c 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 ede7936..a2fa681 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 27bffb4..0694fab 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 }} From 7e62fdacb56b9e14963303bb76dbd67a7056f554 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 06:42:09 +0000 Subject: [PATCH 17/32] fix(deps): update dependency @linear/sdk to v82 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index a3d5acc..f189950 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2026.4.9-next.4", "license": "MIT", "dependencies": { - "@linear/sdk": "81.0.0", + "@linear/sdk": "82.1.0", "commander": "14.0.3" }, "bin": { @@ -2672,9 +2672,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" diff --git a/package.json b/package.json index d566327..8aa2017 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ }, "homepage": "https://github.com/linearis-oss/linearis#readme", "dependencies": { - "@linear/sdk": "81.0.0", + "@linear/sdk": "82.1.0", "commander": "14.0.3" }, "devDependencies": { From f88d7c650366eeaae2b39d1c65f1a823d8671e65 Mon Sep 17 00:00:00 2001 From: semantic-release-bot <semantic-release-bot@martynus.net> Date: Mon, 27 Apr 2026 06:44:19 +0000 Subject: [PATCH 18/32] chore(release): 2026.4.9-next.5 [skip ci] ## [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)) --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fff8c1..f5c3d45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [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 diff --git a/package-lock.json b/package-lock.json index f189950..55148ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linearis", - "version": "2026.4.9-next.4", + "version": "2026.4.9-next.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.4.9-next.4", + "version": "2026.4.9-next.5", "license": "MIT", "dependencies": { "@linear/sdk": "82.1.0", diff --git a/package.json b/package.json index 8aa2017..2316682 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.4.9-next.4", + "version": "2026.4.9-next.5", "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", From 183f26aaef62fbd8dcffca03937bfa17faba96f3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 06:45:42 +0000 Subject: [PATCH 19/32] chore(deps): update semantic-release monorepo --- package-lock.json | 3880 ++++++++------------------------------------- package.json | 4 +- 2 files changed, 677 insertions(+), 3207 deletions(-) diff --git a/package-lock.json b/package-lock.json index 55148ee..ce0615a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,8 +28,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", @@ -2806,13 +2806,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" @@ -2821,23 +2821,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", @@ -3520,14 +3503,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", @@ -3541,10 +3524,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" @@ -3607,28 +3591,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" @@ -3721,6 +3707,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", @@ -3757,6 +3756,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", @@ -3774,6 +3798,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", @@ -3787,6 +3829,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", @@ -8010,28 +8101,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", @@ -8042,7 +8134,6 @@ "cacache", "chalk", "ci-info", - "cli-columns", "fastest-levenshtein", "fs-minipass", "glob", @@ -8056,7 +8147,6 @@ "libnpmdiff", "libnpmexec", "libnpmfund", - "libnpmhook", "libnpmorg", "libnpmpack", "libnpmpublish", @@ -8070,7 +8160,6 @@ "ms", "node-gyp", "nopt", - "normalize-package-data", "npm-audit-report", "npm-install-checks", "npm-package-arg", @@ -8093,8 +8182,7 @@ "tiny-relative-date", "treeverse", "validate-npm-package-name", - "which", - "write-file-atomic" + "which" ], "dev": true, "license": "Artistic-2.0", @@ -8107,80 +8195,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": { @@ -8196,71 +8281,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": { @@ -8282,7 +8309,7 @@ "license": "ISC" }, "node_modules/npm/node_modules/@npmcli/agent": { - "version": "3.0.0", + "version": "4.0.0", "dev": true, "inBundle": true, "license": "ISC", @@ -8290,84 +8317,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", - "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", - "treeverse": "^3.0.0", - "walk-up-path": "^3.0.1" - }, - "bin": { - "arborist": "bin/index.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.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", + "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/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", @@ -8375,156 +8400,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", @@ -8532,68 +8526,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", @@ -8602,47 +8585,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": { @@ -8654,43 +8637,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": { @@ -8706,69 +8681,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": { @@ -8808,90 +8787,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", + "license": "BlueOak-1.0.0", "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" - }, - "engines": { - "node": ">= 8" + "node": ">= 18" } }, "node_modules/npm/node_modules/cssesc": { @@ -8924,7 +8843,7 @@ } }, "node_modules/npm/node_modules/diff": { - "version": "5.2.2", + "version": "8.0.4", "dev": true, "inBundle": true, "license": "BSD-3-Clause", @@ -8932,28 +8851,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, @@ -8963,12 +8860,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, @@ -8984,39 +8875,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, @@ -9030,20 +8888,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" @@ -9056,15 +8911,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": { @@ -9100,7 +8955,7 @@ } }, "node_modules/npm/node_modules/iconv-lite": { - "version": "0.6.3", + "version": "0.7.2", "dev": true, "inBundle": true, "license": "MIT", @@ -9110,54 +8965,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": { @@ -9169,67 +9018,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": { @@ -9263,209 +9079,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" @@ -9493,52 +9302,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, @@ -9570,35 +9361,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, @@ -9618,12 +9391,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": { @@ -9636,7 +9409,7 @@ } }, "node_modules/npm/node_modules/node-gyp": { - "version": "11.5.0", + "version": "12.3.0", "dev": true, "inBundle": true, "license": "MIT", @@ -9644,73 +9417,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", @@ -9718,99 +9477,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": { @@ -9825,94 +9585,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, @@ -9927,21 +9660,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": { @@ -9962,29 +9695,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": { @@ -9996,50 +9716,28 @@ } }, "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": "^20.17.0 || >=22.9.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_modules/npm/node_modules/safer-buffer": { - "version": "2.1.2", + "node_modules/npm/node_modules/safer-buffer": { + "version": "2.1.2", "dev": true, "inBundle": true, "license": "MIT", @@ -10057,27 +9755,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, @@ -10091,20 +9768,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": { @@ -10145,26 +9822,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, @@ -10188,7 +9845,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", @@ -10196,77 +9853,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", @@ -10288,19 +9891,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" @@ -10309,64 +9912,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": { @@ -10375,58 +9979,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", @@ -10434,160 +10033,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" @@ -11359,22 +10836,6 @@ "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", @@ -11385,68 +10846,6 @@ "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", @@ -11633,1952 +11032,23 @@ "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==", + "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": ">=20" + "node": ">=18" }, "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", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/socks": { - "version": "2.8.7", - "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_modules/semantic-release/node_modules/npm/node_modules/socks-proxy-agent": { - "version": "8.0.5", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "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", - "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" - }, - "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" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/tar": { - "version": "7.5.11", - "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "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", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/semantic-release/node_modules/npm/node_modules/tinyglobby": { - "version": "0.2.15", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/tinyglobby/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/semantic-release/node_modules/npm/node_modules/tinyglobby/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/semantic-release/node_modules/npm/node_modules/treeverse": { - "version": "3.0.0", - "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" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/util-deprecate": { - "version": "1.0.2", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/semantic-release/node_modules/npm/node_modules/validate-npm-package-name": { - "version": "7.0.2", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/walk-up-path": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/semantic-release/node_modules/npm/node_modules/which": { - "version": "6.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "isexe": "^4.0.0" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/semantic-release/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/semantic-release/node_modules/npm/node_modules/yallist": { - "version": "5.0.0", - "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/semantic-release/node_modules/p-reduce": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-3.0.0.tgz", diff --git a/package.json b/package.json index 2316682..5bd1cdf 100644 --- a/package.json +++ b/package.json @@ -90,8 +90,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" }, From e92b7cad13e667f25d2f2bc02901e50f94646a66 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:19:41 +0200 Subject: [PATCH 20/32] fix(ci): rerun validation when PR base changes --- .github/workflows/ci-validate.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci-validate.yml b/.github/workflows/ci-validate.yml index 206b604..3a70d83 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 From 69fa0f5edeeed566bd636e9c93b21bc209960f35 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:35:22 +0200 Subject: [PATCH 21/32] test: cover issue estimate team context resolution --- tests/unit/resolvers/issue-resolver.test.ts | 205 ++++++++++++++------ 1 file changed, 149 insertions(+), 56 deletions(-) diff --git a/tests/unit/resolvers/issue-resolver.test.ts b/tests/unit/resolvers/issue-resolver.test.ts index a7cdb5e..781c48f 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: vi.fn().mockResolvedValue({ 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: vi.fn().mockResolvedValue({ 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', ); }); }); From 9c94ff9fc5677e15380e94500933888a76f08e1d Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:44:43 +0200 Subject: [PATCH 22/32] fix: resolve issue estimate team context via team resolver --- src/resolvers/issue-resolver.ts | 160 +++++++------------- tests/unit/resolvers/issue-resolver.test.ts | 8 +- 2 files changed, 60 insertions(+), 108 deletions(-) diff --git a/src/resolvers/issue-resolver.ts b/src/resolvers/issue-resolver.ts index 1a1b7b4..07d6e8c 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/tests/unit/resolvers/issue-resolver.test.ts b/tests/unit/resolvers/issue-resolver.test.ts index 781c48f..a7342fd 100644 --- a/tests/unit/resolvers/issue-resolver.test.ts +++ b/tests/unit/resolvers/issue-resolver.test.ts @@ -14,10 +14,10 @@ type IssueNode = { id?: string; key?: string; } - | (() => Promise<{ + | Promise<{ id?: string; key?: string; - }>); + }>; }; type TeamNode = { @@ -176,7 +176,7 @@ describe("resolveIssueEstimateContext", () => { [ { id: "issue-uuid", - team: vi.fn().mockResolvedValue({ id: teamId, key: "ENG" }), + team: Promise.resolve({ id: teamId, key: "ENG" }), }, ], [exponentialTeam], @@ -203,7 +203,7 @@ describe("resolveIssueEstimateContext", () => { [ { id: "issue-uuid", - team: vi.fn().mockResolvedValue({ key: "ENG" }), + team: Promise.resolve({ key: "ENG" }), }, ], [exponentialTeam], From 47324f42abdcd6b1850bc6365f8d8b1dbc058705 Mon Sep 17 00:00:00 2001 From: semantic-release-bot <semantic-release-bot@martynus.net> Date: Mon, 27 Apr 2026 07:32:35 +0000 Subject: [PATCH 23/32] chore(release): 2026.4.9-next.6 [skip ci] ## [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)) --- CHANGELOG.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5c3d45..f89df7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [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 diff --git a/package-lock.json b/package-lock.json index ce0615a..eb7adb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linearis", - "version": "2026.4.9-next.5", + "version": "2026.4.9-next.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.4.9-next.5", + "version": "2026.4.9-next.6", "license": "MIT", "dependencies": { "@linear/sdk": "82.1.0", diff --git a/package.json b/package.json index 5bd1cdf..17c23d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.4.9-next.5", + "version": "2026.4.9-next.6", "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", From bd0ffaccdba8659fedb6ae4074dc737e102b63b8 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:01:21 +0200 Subject: [PATCH 24/32] feat(graphql): add reaction operations Closes #83 --- graphql/mutations/reactions.graphql | 35 +++++++ graphql/queries/discussions.graphql | 136 ++++++++++++++++++++++++++++ graphql/queries/issues.graphql | 27 ++++++ graphql/queries/projects.graphql | 30 ++++++ graphql/queries/reactions.graphql | 35 +++++++ 5 files changed, 263 insertions(+) create mode 100644 graphql/mutations/reactions.graphql create mode 100644 graphql/queries/reactions.graphql diff --git a/graphql/mutations/reactions.graphql b/graphql/mutations/reactions.graphql new file mode 100644 index 0000000..de26955 --- /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 index 94348a6..9c5f09e 100644 --- a/graphql/queries/discussions.graphql +++ b/graphql/queries/discussions.graphql @@ -18,6 +18,13 @@ fragment DiscussionCommentFields on Comment { } } +fragment DiscussionCommentFieldsWithReactions on Comment { + ...DiscussionCommentFields + reactions { + ...ReactionReadFields + } +} + query GetDiscussionCommentContext($id: String!) { comment(id: $id) { ...DiscussionCommentFields @@ -41,6 +48,24 @@ query ListIssueDiscussionRoots($issueId: String!, $first: Int, $after: String) { } } +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 @@ -59,6 +84,24 @@ query ListProjectDiscussionRoots( } } +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! @@ -86,6 +129,33 @@ query ListInitiativeDiscussionRoots( } } +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 @@ -107,6 +177,27 @@ query ListIssueDiscussionReplyCandidates( } } +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 @@ -128,6 +219,27 @@ query ListProjectDiscussionReplyCandidates( } } +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 @@ -152,6 +264,30 @@ query ListInitiativeDiscussionReplyCandidates( } } +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 7d4bf09..2afbf99 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. diff --git a/graphql/queries/projects.graphql b/graphql/queries/projects.graphql index b8e12e3..9b3e8a5 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 0000000..f943dbf --- /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 + } + } +} From bc82bc6fa3e7c6ee64f6581b80206ec85bf8e9a8 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:01:23 +0200 Subject: [PATCH 25/32] feat(emoji): add reaction input normalization Closes #83 --- package-lock.json | 9 ++--- package.json | 3 +- src/common/emoji.ts | 53 +++++++++++++++++++++++++++ tests/unit/common/emoji.test.ts | 63 +++++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 8 deletions(-) create mode 100644 src/common/emoji.ts create mode 100644 tests/unit/common/emoji.test.ts diff --git a/package-lock.json b/package-lock.json index eb7adb9..cf73838 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "MIT", "dependencies": { "@linear/sdk": "82.1.0", - "commander": "14.0.3" + "commander": "14.0.3", + "node-emoji": "2.2.0" }, "bin": { "linear": "dist/main.js", @@ -3962,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" @@ -4652,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" @@ -5337,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": { @@ -8053,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", @@ -11482,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" @@ -12180,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" diff --git a/package.json b/package.json index 17c23d1..122cc1a 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,8 @@ "homepage": "https://github.com/linearis-oss/linearis#readme", "dependencies": { "@linear/sdk": "82.1.0", - "commander": "14.0.3" + "commander": "14.0.3", + "node-emoji": "2.2.0" }, "devDependencies": { "@biomejs/biome": "^2.3.14", diff --git a/src/common/emoji.ts b/src/common/emoji.ts new file mode 100644 index 0000000..18c3fff --- /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/tests/unit/common/emoji.test.ts b/tests/unit/common/emoji.test.ts new file mode 100644 index 0000000..339b64a --- /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", + ); + }); +}); From 0a29bb1f6208a2ddb093f458a7d12d70a645ac91 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:01:24 +0200 Subject: [PATCH 26/32] feat(reactions): add shared service Closes #83 --- src/services/reaction-service.ts | 289 ++++++++++++ tests/unit/services/reaction-service.test.ts | 455 +++++++++++++++++++ 2 files changed, 744 insertions(+) create mode 100644 src/services/reaction-service.ts create mode 100644 tests/unit/services/reaction-service.test.ts diff --git a/src/services/reaction-service.ts b/src/services/reaction-service.ts new file mode 100644 index 0000000..c8fe73c --- /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/services/reaction-service.test.ts b/tests/unit/services/reaction-service.test.ts new file mode 100644 index 0000000..3e3243f --- /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"], + }, + ]); + }); +}); From 4d6a00cbd9a2c9abe407d5071f3ccd5bbb81e01e Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:01:25 +0200 Subject: [PATCH 27/32] feat(cli): add reaction workflows Closes #83 --- src/commands/comments.ts | 97 +++++ src/commands/initiatives/entity.ts | 145 ++++++- src/commands/issues.ts | 266 ++++++++++++- src/commands/projects.ts | 145 ++++++- src/services/discussion-service.ts | 350 ++++++++++++++++- src/services/issue-service.ts | 61 +++ tests/unit/commands/comments.test.ts | 141 ++++++- tests/unit/commands/initiatives.test.ts | 146 +++++++ tests/unit/commands/issues.test.ts | 303 +++++++++++++++ tests/unit/commands/projects.test.ts | 146 +++++++ .../unit/services/discussion-service.test.ts | 364 +++++++++++++++++- .../unit/services/initiative-service.test.ts | 42 ++ tests/unit/services/issue-service.test.ts | 127 ++++++ tests/unit/services/project-service.test.ts | 97 ++++- 14 files changed, 2374 insertions(+), 56 deletions(-) diff --git a/src/commands/comments.ts b/src/commands/comments.ts index 589c6f2..24c466c 100644 --- a/src/commands/comments.ts +++ b/src/commands/comments.ts @@ -1,11 +1,15 @@ import type { Command } from "commander"; import { type CommandOptions, createContext } 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 { + createIssueDiscussionCommentReaction, deleteDiscussionComment, + deleteIssueDiscussionCommentReactionByEmoji, + deleteIssueDiscussionCommentReactionById, editDiscussionComment, listDiscussionsForIssue, replyToDiscussion, @@ -29,6 +33,10 @@ interface EditCommentOptions extends CommandOptions { body?: string; } +interface ReactionOptions extends CommandOptions { + shortcode?: string; +} + export const COMMENTS_META: DomainMeta = { name: "comments", summary: @@ -210,6 +218,95 @@ export function setupCommentsCommands(program: Command): void { }), ); + comments + .command("react <comment> [emoji]") + .description( + "DEPRECATED compatibility command. Prefer: `issues threads react <thread>` or `issues replies react <reply>`.", + ) + .addHelpText( + "after", + "\nDEPRECATED compatibility command. Prefer: `issues threads react <thread>` or `issues replies react <reply>`.", + ) + .option("--shortcode <name>", "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(command.parent!.parent!.opts()); + + const result = await createIssueDiscussionCommentReaction(ctx.gql, { + commentId: comment, + emoji: resolveReactionEmojiInput(emoji, options.shortcode), + }); + + outputSuccess(result); + }), + ); + + comments + .command("unreact <comment> [emoji]") + .description( + "DEPRECATED compatibility command. Prefer: `issues threads unreact <thread>` or `issues replies unreact <reply>`.", + ) + .addHelpText( + "after", + "\nDEPRECATED compatibility command. Prefer: `issues threads unreact <thread>` or `issues replies unreact <reply>`.", + ) + .option("--shortcode <name>", "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(command.parent!.parent!.opts()); + + const result = await deleteIssueDiscussionCommentReactionByEmoji( + ctx.gql, + { + commentId: comment, + emoji: resolveReactionEmojiInput(emoji, options.shortcode), + }, + ); + + outputSuccess(result); + }), + ); + + comments + .command("unreact-id <comment> <reactionId>") + .description( + "DEPRECATED compatibility command. Prefer: `issues threads unreact-id <thread> <reactionId>` or `issues replies unreact-id <reply> <reactionId>`.", + ) + .addHelpText( + "after", + "\nDEPRECATED compatibility command. Prefer: `issues threads unreact-id <thread> <reactionId>` or `issues replies unreact-id <reply> <reactionId>`.", + ) + .action( + handleCommand(async (...args: unknown[]) => { + const [comment, reactionId, , command] = args as [ + string, + string, + unknown, + Command, + ]; + const ctx = createContext(command.parent!.parent!.opts()); + + const result = await deleteIssueDiscussionCommentReactionById(ctx.gql, { + commentId: comment, + reactionId, + }); + + outputSuccess(result); + }), + ); + comments .command("usage") .description("show detailed usage for comments") diff --git a/src/commands/initiatives/entity.ts b/src/commands/initiatives/entity.ts index 4694916..e6c736c 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 { resolveReactionEmojiInput } from "../../common/emoji.js"; import { invalidParameterError } from "../../common/errors.js"; import { handleCommand, @@ -21,12 +22,17 @@ 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, @@ -86,6 +92,7 @@ interface InitiativeReadOptions extends InitiativeExpandOptions {} interface DiscussionsOptions { limit?: string; after?: string; + withReactions?: boolean; } interface DiscussionBodyOptions { @@ -96,6 +103,85 @@ 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(rootOptions(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 <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(rootOptions(command)); + const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { + commentId, + target: noun, + expectedEntityKind: "initiative", + 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(rootOptions(command)); + const result = await deleteDiscussionCommentReactionById(ctx.gql, { + commentId, + target: noun, + expectedEntityKind: "initiative", + reactionId, + }); + outputSuccess(result); + }), + ); +} + interface InitiativeCreateOptions { description?: string; content?: string; @@ -519,6 +605,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .description("list root discussion threads on an initiative") .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 [initiative, options, command] = args as [ @@ -529,24 +616,37 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { const ctx = createContext(rootOptions(command)); const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); - const result = await listDiscussionsForInitiative( - ctx.gql, - initiativeId, - { - limit: parseLimit(options.limit || "25"), - after: options.after, - }, - ); + 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); }), ); - initiatives + const initiativeThreads = initiatives + .command("threads") + .description("discussion thread reaction operations"); + addCommentReactionCommands(initiativeThreads, "thread"); + + const initiativeReplies = initiatives .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 [ @@ -556,19 +656,28 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { ]; const ctx = createContext(rootOptions(command)); - const result = await listDiscussionReplies( - ctx.gql, - thread, - { - limit: parseLimit(options.limit || "50"), - after: options.after, - }, - "initiative", - ); + 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 <thread>") diff --git a/src/commands/issues.ts b/src/commands/issues.ts index b4c372e..49862cd 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import type { CommandContext } from "../common/context.js"; import { createContext } from "../common/context.js"; +import { resolveReactionEmojiInput } from "../common/emoji.js"; import { invalidParameterError } from "../common/errors.js"; import { validateEstimateAgainstTeamConfig } from "../common/estimate-validation.js"; import { @@ -36,12 +37,17 @@ import { } 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, @@ -62,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; @@ -127,11 +140,31 @@ 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 { @@ -142,6 +175,92 @@ interface ResolveDiscussionOptions { withComment?: string; } +function rootOptions(command: Command): Record<string, unknown> { + let current: Command = command; + while (current.parent) { + current = current.parent; + } + return current.opts(); +} + +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(rootOptions(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 <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(rootOptions(command)); + const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { + commentId, + target: noun, + expectedEntityKind: "issue", + 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(rootOptions(command)); + const result = await deleteDiscussionCommentReactionById(ctx.gql, { + commentId, + target: noun, + expectedEntityKind: "issue", + reactionId, + }); + + outputSuccess(result); + }), + ); +} + export const ISSUES_META: DomainMeta = { name: "issues", summary: "work items with status, priority, assignee, labels", @@ -416,6 +535,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.`, @@ -427,6 +547,7 @@ export function setupIssuesCommands(program: Command): void { ReadOptions, Command, ]; + validateReadOptions(options); const ctx = createContext(command.parent!.parent!.opts()); if (options.withAttachments) { @@ -477,6 +598,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); @@ -492,6 +629,88 @@ export function setupIssuesCommands(program: Command): void { }), ); + issues + .command("react <issue> [emoji]") + .description("add a root reaction to an issue") + .option("--shortcode <name>", "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(command.parent!.parent!.opts()); + const issueId = await resolveIssueId(ctx.sdk, issue); + const result = await createReactionForIssue(ctx.gql, { + issueId, + emoji: resolveReactionEmojiInput(emoji, options.shortcode), + }); + + outputSuccess(result); + }), + ); + + issues + .command("unreact <issue> [emoji]") + .description("remove your root reaction from an issue by emoji") + .option("--shortcode <name>", "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(command.parent!.parent!.opts()); + 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 <issue> <reactionId>") + .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(command.parent!.parent!.opts()); + const issueId = await resolveIssueId(ctx.sdk, issue); + const result = await deleteOwnReactionById(ctx.gql, { + kind: "issue", + id: issueId, + reactionId, + }); + + outputSuccess(result); + }), + ); + issues .command("discuss <issue>") .description("start a discussion thread on an issue") @@ -532,6 +751,7 @@ export function setupIssuesCommands(program: Command): void { ) .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 [issue, options, command] = args as [ @@ -542,20 +762,33 @@ export function setupIssuesCommands(program: Command): void { const ctx = createContext(command.parent!.parent!.opts()); const issueId = await resolveIssueId(ctx.sdk, issue); - const result = await listDiscussionsForIssue(ctx.gql, issueId, { + 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); }), ); - issues + const issueThreads = issues + .command("threads") + .description("discussion thread reaction operations"); + addCommentReactionCommands(issueThreads, "thread"); + + const issueReplies = issues .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 [ @@ -565,19 +798,28 @@ export function setupIssuesCommands(program: Command): void { ]; const ctx = createContext(command.parent!.parent!.opts()); - const result = await listDiscussionReplies( - ctx.gql, - thread, - { - limit: parseLimit(options.limit || "50"), - after: options.after, - }, - "issue", - ); + 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 <thread>") diff --git a/src/commands/projects.ts b/src/commands/projects.ts index c456eb7..dbb8a00 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 { 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"; @@ -12,12 +13,17 @@ 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, @@ -41,6 +47,7 @@ interface ListOptions { interface DiscussionsOptions { limit?: string; after?: string; + withReactions?: boolean; } interface DiscussionBodyOptions { @@ -51,6 +58,93 @@ interface ResolveDiscussionOptions { withComment?: string; } +interface ReactionOptions { + shortcode?: string; +} + +function rootOptions(command: Command): Record<string, unknown> { + let current: Command = command; + while (current.parent) { + current = current.parent; + } + return current.opts(); +} + +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(rootOptions(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(rootOptions(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(rootOptions(command)); + const result = await deleteDiscussionCommentReactionById(ctx.gql, { + commentId, + target: noun, + expectedEntityKind: "project", + reactionId, + }); + outputSuccess(result); + }), + ); +} + interface CreateOptions { teams: string; description?: string; @@ -176,6 +270,7 @@ export function setupProjectsCommands(program: Command): void { .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 [ @@ -186,20 +281,37 @@ export function setupProjectsCommands(program: Command): void { const ctx = createContext(command.parent!.parent!.opts()); const projectId = await resolveProjectId(ctx.sdk, project); - const result = await listDiscussionsForProject(ctx.gql, projectId, { + 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); }), ); - projects + 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 [ @@ -209,19 +321,28 @@ export function setupProjectsCommands(program: Command): void { ]; const ctx = createContext(command.parent!.parent!.opts()); - const result = await listDiscussionReplies( - ctx.gql, - thread, - { - limit: parseLimit(options.limit || "50"), - after: options.after, - }, - "project", - ); + 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>") diff --git a/src/services/discussion-service.ts b/src/services/discussion-service.ts index a0a4fa2..6571ed9 100644 --- a/src/services/discussion-service.ts +++ b/src/services/discussion-service.ts @@ -6,22 +6,35 @@ import { 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, @@ -29,8 +42,18 @@ import { 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; @@ -48,6 +71,62 @@ type DiscussionReplyCandidateQuery = | 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, @@ -157,6 +236,22 @@ async function assertReplyComment( 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">, @@ -255,14 +350,71 @@ async function listDiscussionReplyCandidates( return nodes.sort(compareDiscussionCommentsChronologically); } -function filterThreadReplies( - comments: readonly DiscussionCommentFieldsFragment[], +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, -): DiscussionCommentFieldsFragment[] { - const childrenByParentId = new Map< - string, - DiscussionCommentFieldsFragment[] - >(); +): T[] { + const childrenByParentId = new Map<string, T[]>(); for (const comment of comments) { if (!comment.parentId) { @@ -275,7 +427,7 @@ function filterThreadReplies( childrenByParentId.set(comment.parentId, siblings); } - const replies: DiscussionCommentFieldsFragment[] = []; + const replies: T[] = []; const stack = [...(childrenByParentId.get(threadId) ?? [])].reverse(); while (stack.length > 0) { @@ -301,11 +453,11 @@ function filterThreadReplies( return replies; } -function paginateDiscussionReplies( - replies: readonly DiscussionCommentFieldsFragment[], +function paginateDiscussionReplies<T extends DiscussionCommentFieldsFragment>( + replies: readonly T[], limit: number, after?: string, -): PaginatedResult<DiscussionCommentFieldsFragment> { +): PaginatedResult<T> { const startIndex = after === undefined ? 0 @@ -342,6 +494,82 @@ async function startDiscussion( 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, @@ -367,6 +595,32 @@ export async function listDiscussionsForIssue( }; } +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, @@ -392,6 +646,32 @@ export async function listDiscussionsForProject( }; } +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, @@ -418,6 +698,33 @@ export async function listDiscussionsForInitiative( }; } +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, @@ -436,6 +743,27 @@ export async function listDiscussionReplies( 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 }, diff --git a/src/services/issue-service.ts b/src/services/issue-service.ts index 328a099..7f4e0ad 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,6 +56,7 @@ import { UpdateIssueDocument, type UpdateIssueMutation, } from "../gql/graphql.js"; +import { normalizeReactions } from "./reaction-service.js"; const NON_COMPLETED_ISSUES_FILTER: IssueFilter = { state: { type: { neq: "completed" } }, @@ -163,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 = {}, @@ -279,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/tests/unit/commands/comments.test.ts b/tests/unit/commands/comments.test.ts index 226a706..2c18835 100644 --- a/tests/unit/commands/comments.test.ts +++ b/tests/unit/commands/comments.test.ts @@ -22,6 +22,15 @@ vi.mock("../../../src/resolvers/issue-resolver.js", () => ({ })); 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: { @@ -44,7 +53,10 @@ vi.mock("../../../src/services/discussion-service.js", () => ({ import { setupCommentsCommands } from "../../../src/commands/comments.js"; import { resolveIssueId } from "../../../src/resolvers/issue-resolver.js"; import { + createIssueDiscussionCommentReaction, deleteDiscussionComment, + deleteIssueDiscussionCommentReactionByEmoji, + deleteIssueDiscussionCommentReactionById, editDiscussionComment, listDiscussionsForIssue, replyToDiscussion, @@ -80,7 +92,9 @@ describe("comments compatibility delegation", () => { "Deprecated compatibility facade for issue discussions", ); expect(commentsHelp).toMatch(/Prefer the `issues`\s+discussion commands/i); - expect(commentsHelp).toMatch(/migrate to `issues discussions\s+<issue>`/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, ); @@ -271,4 +285,129 @@ describe("comments compatibility delegation", () => { "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 c0cf5e4..0bd6639 100644 --- a/tests/unit/commands/initiatives.test.ts +++ b/tests/unit/commands/initiatives.test.ts @@ -124,6 +124,24 @@ vi.mock("../../../src/services/discussion-service.js", () => ({ 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 @@ -137,6 +155,15 @@ vi.mock("../../../src/services/discussion-service.js", () => ({ .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"; @@ -145,12 +172,17 @@ import { resolveInitiativeId } from "../../../src/resolvers/initiative-resolver. 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, @@ -576,6 +608,26 @@ describe("initiative discussion commands", () => { }); }); + 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(); @@ -611,6 +663,27 @@ describe("initiative discussion commands", () => { }); }); + 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(); @@ -820,4 +893,77 @@ describe("initiative discussion commands", () => { ); expect(outputSuccess).toHaveBeenCalledWith({ id: "discussion-root-1" }); }); + + 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 78608b9..a581f49 100644 --- a/tests/unit/commands/issues.test.ts +++ b/tests/unit/commands/issues.test.ts @@ -102,6 +102,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 +120,17 @@ 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({ @@ -132,6 +151,24 @@ vi.mock("../../../src/services/discussion-service.js", () => ({ 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({ @@ -147,6 +184,15 @@ vi.mock("../../../src/services/discussion-service.js", () => ({ }), 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"; @@ -160,12 +206,16 @@ import { } 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, @@ -180,14 +230,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(); @@ -1113,6 +1170,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([ @@ -1149,6 +1278,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(); @@ -1256,6 +1470,26 @@ describe("issues discussion commands", () => { ); }); + 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(); @@ -1282,6 +1516,27 @@ describe("issues discussion commands", () => { ); }); + 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(); @@ -1499,6 +1754,54 @@ describe("issues discussion commands", () => { "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", () => { diff --git a/tests/unit/commands/projects.test.ts b/tests/unit/commands/projects.test.ts index 1565fd0..1989062 100644 --- a/tests/unit/commands/projects.test.ts +++ b/tests/unit/commands/projects.test.ts @@ -66,6 +66,24 @@ vi.mock("../../../src/services/discussion-service.js", () => ({ 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({ @@ -81,18 +99,32 @@ vi.mock("../../../src/services/discussion-service.js", () => ({ }), 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, @@ -400,6 +432,26 @@ describe("projects discussion commands", () => { }); }); + 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(); @@ -435,6 +487,27 @@ describe("projects discussion commands", () => { }); }); + 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(); @@ -656,6 +729,79 @@ describe("projects discussion commands", () => { ); 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", () => { diff --git a/tests/unit/services/discussion-service.test.ts b/tests/unit/services/discussion-service.test.ts index 91bbafd..056994d 100644 --- a/tests/unit/services/discussion-service.test.ts +++ b/tests/unit/services/discussion-service.test.ts @@ -1,18 +1,46 @@ -import { describe, expect, it, vi } from "vitest"; +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, @@ -20,6 +48,11 @@ import { startProjectDiscussion, unresolveDiscussion, } from "../../../src/services/discussion-service.js"; +import { + createReactionForComment, + deleteOwnReactionByEmoji, + deleteOwnReactionById, +} from "../../../src/services/reaction-service.js"; function createClientMock(): GraphQLClient { return { @@ -28,6 +61,7 @@ function createClientMock(): 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 { @@ -43,6 +77,219 @@ function comment(id: string, parentId: string | null = null) { }; } +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(); @@ -80,6 +327,33 @@ describe("listDiscussionsForIssue", () => { 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", () => { @@ -116,6 +390,33 @@ describe("listDiscussionsForProject", () => { 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", () => { @@ -148,6 +449,32 @@ describe("listDiscussionsForInitiative", () => { 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", () => { @@ -281,6 +608,41 @@ describe("listDiscussionReplies", () => { '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", () => { diff --git a/tests/unit/services/initiative-service.test.ts b/tests/unit/services/initiative-service.test.ts index ed4fb8f..d30bafd 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 bbf9137..2906159 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, @@ -568,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/project-service.test.ts b/tests/unit/services/project-service.test.ts index 8aa6a45..6facab2 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({ From 32be58b2d396f0d6a93d0532eadede128ab01dae Mon Sep 17 00:00:00 2001 From: semantic-release-bot <semantic-release-bot@martynus.net> Date: Mon, 27 Apr 2026 09:17:42 +0000 Subject: [PATCH 28/32] chore(release): 2026.4.9-next.7 [skip ci] ## [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) --- CHANGELOG.md | 9 +++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f89df7b..c8f4287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## [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 diff --git a/package-lock.json b/package-lock.json index cf73838..fde0479 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linearis", - "version": "2026.4.9-next.6", + "version": "2026.4.9-next.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.4.9-next.6", + "version": "2026.4.9-next.7", "license": "MIT", "dependencies": { "@linear/sdk": "82.1.0", diff --git a/package.json b/package.json index 122cc1a..2a3de16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.4.9-next.6", + "version": "2026.4.9-next.7", "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", From 705218ff877c715b1d7d4177e7772bf9cb67451a Mon Sep 17 00:00:00 2001 From: Hermes Agent <hermes@example.com> Date: Sun, 26 Apr 2026 17:50:07 +0000 Subject: [PATCH 29/32] chore(commitlint): enforce stricter validation Refs #97 --- .github/workflows/ci-validate.yml | 26 ++++++++++++++++++++++++++ CONTRIBUTING.md | 8 ++++++++ commitlint.config.js | 27 ++++++++++++++++++++++++++- 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-validate.yml b/.github/workflows/ci-validate.yml index 3a70d83..b5562e0 100644 --- a/.github/workflows/ci-validate.yml +++ b/.github/workflows/ci-validate.yml @@ -77,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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4fbdf99..01464a4 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/commitlint.config.js b/commitlint.config.js index fa584fb..b8db6da 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", + ], + ], + }, +}; From 85a1d3a1e2e1ecf46138632d637f00b046a1c12c Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:29:44 +0200 Subject: [PATCH 30/32] fix(comments): constrain compatibility replies --- src/commands/comments.ts | 1 + tests/unit/commands/comments.test.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commands/comments.ts b/src/commands/comments.ts index 24c466c..3b2ec1f 100644 --- a/src/commands/comments.ts +++ b/src/commands/comments.ts @@ -167,6 +167,7 @@ export function setupCommentsCommands(program: Command): void { const result = await replyToDiscussion(ctx.gql, { threadId: thread, body: options.body, + entityKind: "issue", }); outputSuccess(result); diff --git a/tests/unit/commands/comments.test.ts b/tests/unit/commands/comments.test.ts index 2c18835..9f4a99a 100644 --- a/tests/unit/commands/comments.test.ts +++ b/tests/unit/commands/comments.test.ts @@ -183,7 +183,7 @@ describe("comments compatibility delegation", () => { expect(startIssueDiscussion).not.toHaveBeenCalled(); }); - it("comments reply delegates to replyToDiscussion", async () => { + it("comments reply constrains replies to issue discussion threads", async () => { const program = createProgram(); await program.parseAsync([ @@ -199,6 +199,7 @@ describe("comments compatibility delegation", () => { expect(replyToDiscussion).toHaveBeenCalledWith(expect.anything(), { threadId: "thread-1", body: "Reply body", + entityKind: "issue", }); }); From 8ee5931c957d0fd826d0b98fd4d155405643f9f9 Mon Sep 17 00:00:00 2001 From: semantic-release-bot <semantic-release-bot@martynus.net> Date: Mon, 27 Apr 2026 09:32:09 +0000 Subject: [PATCH 31/32] chore(release): 2026.4.9-next.8 [skip ci] ## [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)) --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8f4287..b64b0e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [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 diff --git a/package-lock.json b/package-lock.json index fde0479..5ae6528 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linearis", - "version": "2026.4.9-next.7", + "version": "2026.4.9-next.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.4.9-next.7", + "version": "2026.4.9-next.8", "license": "MIT", "dependencies": { "@linear/sdk": "82.1.0", diff --git a/package.json b/package.json index 2a3de16..0c63259 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.4.9-next.7", + "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", From 2963db6217d9b3cf11ee2177f15bfb3c3d398e41 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:39:24 +0200 Subject: [PATCH 32/32] refactor(context): use getRootOpts for root options Refs #61 --- src/commands/attachments.ts | 8 ++-- src/commands/auth.ts | 8 ++-- src/commands/comments.ts | 22 ++++++---- src/commands/cycles.ts | 10 +++-- src/commands/documents.ts | 12 ++--- src/commands/files.ts | 5 ++- src/commands/initiatives/entity.ts | 50 +++++++++------------ src/commands/initiatives/projects.ts | 14 ++---- src/commands/initiatives/relations.ts | 14 ++---- src/commands/initiatives/updates.ts | 22 +++------- src/commands/issues.ts | 58 +++++++++++-------------- src/commands/labels.ts | 8 +++- src/commands/milestones.ts | 10 ++--- src/commands/projects.ts | 50 +++++++++------------ src/commands/teams.ts | 6 +-- src/commands/users.ts | 8 +++- src/common/context.ts | 11 +++++ tests/unit/commands/attachments.test.ts | 1 + tests/unit/commands/auth.test.ts | 1 + tests/unit/commands/comments.test.ts | 1 + tests/unit/commands/initiatives.test.ts | 20 +++++++++ tests/unit/commands/issues.test.ts | 1 + tests/unit/commands/labels.test.ts | 1 + tests/unit/commands/projects.test.ts | 1 + tests/unit/commands/teams.test.ts | 1 + tests/unit/common/context.test.ts | 35 +++++++++++++++ 26 files changed, 210 insertions(+), 168 deletions(-) create mode 100644 tests/unit/common/context.test.ts diff --git a/src/commands/attachments.ts b/src/commands/attachments.ts index d074487..f8f9434 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 0ee8ee9..7e0b908 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 3b2ec1f..094e926 100644 --- a/src/commands/comments.ts +++ b/src/commands/comments.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 { resolveReactionEmojiInput } from "../common/emoji.js"; import { invalidParameterError } from "../common/errors.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; @@ -89,7 +93,7 @@ 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); @@ -120,7 +124,7 @@ export function setupCommentsCommands(program: Command): void { CreateCommentOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); if (!options.body) { throw invalidParameterError("--body", "is required"); @@ -158,7 +162,7 @@ export function setupCommentsCommands(program: Command): void { ReplyCommentOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); if (!options.body) { throw invalidParameterError("--body", "is required"); @@ -188,7 +192,7 @@ export function setupCommentsCommands(program: Command): void { EditCommentOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); if (!options.body) { throw invalidParameterError("--body", "is required"); @@ -211,7 +215,7 @@ export function setupCommentsCommands(program: Command): void { .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); @@ -237,7 +241,7 @@ export function setupCommentsCommands(program: Command): void { ReactionOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const result = await createIssueDiscussionCommentReaction(ctx.gql, { commentId: comment, @@ -266,7 +270,7 @@ export function setupCommentsCommands(program: Command): void { ReactionOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const result = await deleteIssueDiscussionCommentReactionByEmoji( ctx.gql, @@ -297,7 +301,7 @@ export function setupCommentsCommands(program: Command): void { unknown, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const result = await deleteIssueDiscussionCommentReactionById(ctx.gql, { commentId: comment, diff --git a/src/commands/cycles.ts b/src/commands/cycles.ts index 85cd8a1..c13a9d4 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 bb3d8d4..8cc75a1 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 8cb9c08..4230f9d 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 e6c736c..261bd13 100644 --- a/src/commands/initiatives/entity.ts +++ b/src/commands/initiatives/entity.ts @@ -1,6 +1,6 @@ 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 { @@ -123,7 +123,7 @@ function addCommentReactionCommands( ReactionOptions, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const result = await createDiscussionCommentReaction(ctx.gql, { commentId, target: noun, @@ -146,7 +146,7 @@ function addCommentReactionCommands( ReactionOptions, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { commentId, target: noun, @@ -170,7 +170,7 @@ function addCommentReactionCommands( unknown, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const result = await deleteDiscussionCommentReactionById(ctx.gql, { commentId, target: noun, @@ -211,14 +211,6 @@ type InitiativeSortBy = | "manual" | "owner"; -function rootOptions(command: Command): Record<string, unknown> { - 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(); @@ -500,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); @@ -561,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 @@ -584,7 +576,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { DiscussionBodyOptions, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); if (!options.body) { throw invalidParameterError("--body", "is required"); @@ -613,7 +605,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { DiscussionsOptions, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); const paginationOptions = { @@ -654,7 +646,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { DiscussionsOptions, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const paginationOptions = { limit: parseLimit(options.limit || "50"), @@ -694,7 +686,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { DiscussionBodyOptions, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); if (!options.body) { throw invalidParameterError("--body", "is required"); @@ -721,7 +713,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { DiscussionBodyOptions, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); if (!options.body) { throw invalidParameterError("--body", "is required"); @@ -751,7 +743,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { DiscussionBodyOptions, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); if (!options.body) { throw invalidParameterError("--body", "is required"); @@ -776,7 +768,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [comment, , command] = args as [string, unknown, Command]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const result = await deleteDiscussionComment( ctx.gql, @@ -794,7 +786,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [reply, , command] = args as [string, unknown, Command]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const result = await deleteDiscussionReply( ctx.gql, @@ -817,7 +809,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { ResolveDiscussionOptions, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const result = await resolveDiscussion(ctx.gql, { threadId: thread, @@ -835,7 +827,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [thread, , command] = args as [string, unknown, Command]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const result = await unresolveDiscussion(ctx.gql, thread, "initiative"); @@ -859,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 }; @@ -911,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 = {}; @@ -964,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); @@ -977,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); @@ -990,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 2cde267..6b1d17e 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<string, unknown> { - let current: Command = command; - while (current.parent) { - current = current.parent; - } - return current.opts(); -} - export function setupInitiativeProjectCommands(initiatives: Command): void { initiatives .command("add-project <initiative> <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 4fd4b72..58520a3 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<string, unknown> { - let current: Command = command; - while (current.parent) { - current = current.parent; - } - return current.opts(); -} - export function setupInitiativeRelationCommands(initiatives: Command): void { initiatives .command("relate <parent> <child>") @@ -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 4382869..6062965 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<string, unknown> { - 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 49862cd..8d8080e 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -1,6 +1,6 @@ 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"; @@ -175,14 +175,6 @@ interface ResolveDiscussionOptions { withComment?: string; } -function rootOptions(command: Command): Record<string, unknown> { - let current: Command = command; - while (current.parent) { - current = current.parent; - } - return current.opts(); -} - function addCommentReactionCommands( parent: ReturnType<Command["command"]>, noun: "thread" | "reply", @@ -199,7 +191,7 @@ function addCommentReactionCommands( ReactionOptions, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const result = await createDiscussionCommentReaction(ctx.gql, { commentId, target: noun, @@ -223,7 +215,7 @@ function addCommentReactionCommands( ReactionOptions, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { commentId, target: noun, @@ -248,7 +240,7 @@ function addCommentReactionCommands( unknown, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const result = await deleteDiscussionCommentReactionById(ctx.gql, { commentId, target: noun, @@ -468,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), @@ -507,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), @@ -548,7 +540,7 @@ export function setupIssuesCommands(program: Command): void { Command, ]; validateReadOptions(options); - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); if (options.withAttachments) { if (isUuid(issue)) { @@ -645,7 +637,7 @@ export function setupIssuesCommands(program: Command): void { ReactionOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const issueId = await resolveIssueId(ctx.sdk, issue); const result = await createReactionForIssue(ctx.gql, { issueId, @@ -672,7 +664,7 @@ export function setupIssuesCommands(program: Command): void { ReactionOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const issueId = await resolveIssueId(ctx.sdk, issue); const result = await deleteOwnReactionByEmoji(ctx.gql, { kind: "issue", @@ -699,7 +691,7 @@ export function setupIssuesCommands(program: Command): void { unknown, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const issueId = await resolveIssueId(ctx.sdk, issue); const result = await deleteOwnReactionById(ctx.gql, { kind: "issue", @@ -726,7 +718,7 @@ export function setupIssuesCommands(program: Command): void { DiscussionBodyOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); if (!options.body) { throw invalidParameterError("--body", "is required"); @@ -759,7 +751,7 @@ export function setupIssuesCommands(program: Command): void { DiscussionsOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const issueId = await resolveIssueId(ctx.sdk, issue); const paginationOptions = { @@ -796,7 +788,7 @@ export function setupIssuesCommands(program: Command): void { DiscussionsOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const paginationOptions = { limit: parseLimit(options.limit || "50"), @@ -836,7 +828,7 @@ export function setupIssuesCommands(program: Command): void { DiscussionBodyOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); if (!options.body) { throw invalidParameterError("--body", "is required"); @@ -863,7 +855,7 @@ export function setupIssuesCommands(program: Command): void { DiscussionBodyOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); if (!options.body) { throw invalidParameterError("--body", "is required"); @@ -893,7 +885,7 @@ export function setupIssuesCommands(program: Command): void { DiscussionBodyOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); if (!options.body) { throw invalidParameterError("--body", "is required"); @@ -918,7 +910,7 @@ export function setupIssuesCommands(program: Command): void { .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, "issue"); @@ -932,7 +924,7 @@ export function setupIssuesCommands(program: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [reply, , command] = args as [string, unknown, Command]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const result = await deleteDiscussionReply(ctx.gql, reply, "issue"); @@ -951,7 +943,7 @@ export function setupIssuesCommands(program: Command): void { ResolveDiscussionOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const result = await resolveDiscussion(ctx.gql, { threadId: thread, @@ -969,7 +961,7 @@ export function setupIssuesCommands(program: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [thread, , command] = args as [string, unknown, Command]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const result = await unresolveDiscussion(ctx.gql, thread, "issue"); @@ -1003,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); @@ -1214,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 @@ -1359,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); @@ -1372,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); @@ -1385,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 e6b6d3a..330d0a0 100644 --- a/src/commands/labels.ts +++ b/src/commands/labels.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 } from "../common/errors.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; @@ -71,7 +75,7 @@ export function setupLabelsCommands(program: Command): void { .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 = { diff --git a/src/commands/milestones.ts b/src/commands/milestones.ts index efe8af4..0371518 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 dbb8a00..3bcddcc 100644 --- a/src/commands/projects.ts +++ b/src/commands/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 { resolveReactionEmojiInput } from "../common/emoji.js"; import { invalidParameterError } from "../common/errors.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; @@ -62,14 +62,6 @@ interface ReactionOptions { shortcode?: string; } -function rootOptions(command: Command): Record<string, unknown> { - let current: Command = command; - while (current.parent) { - current = current.parent; - } - return current.opts(); -} - function addCommentReactionCommands( parent: ReturnType<Command["command"]>, noun: "thread" | "reply", @@ -86,7 +78,7 @@ function addCommentReactionCommands( ReactionOptions, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const result = await createDiscussionCommentReaction(ctx.gql, { commentId, target: noun, @@ -109,7 +101,7 @@ function addCommentReactionCommands( ReactionOptions, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { commentId, target: noun, @@ -133,7 +125,7 @@ function addCommentReactionCommands( unknown, Command, ]; - const ctx = createContext(rootOptions(command)); + const ctx = createContext(getRootOpts(command)); const result = await deleteDiscussionCommentReactionById(ctx.gql, { commentId, target: noun, @@ -216,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, @@ -231,7 +223,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 getProject(ctx.gql, projectId); outputSuccess(result); @@ -249,7 +241,7 @@ export function setupProjectsCommands(program: Command): void { DiscussionBodyOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); if (!options.body) { throw invalidParameterError("--body", "is required"); @@ -278,7 +270,7 @@ export function setupProjectsCommands(program: Command): void { DiscussionsOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const projectId = await resolveProjectId(ctx.sdk, project); const paginationOptions = { @@ -319,7 +311,7 @@ export function setupProjectsCommands(program: Command): void { DiscussionsOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const paginationOptions = { limit: parseLimit(options.limit || "50"), @@ -359,7 +351,7 @@ export function setupProjectsCommands(program: Command): void { DiscussionBodyOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); if (!options.body) { throw invalidParameterError("--body", "is required"); @@ -386,7 +378,7 @@ export function setupProjectsCommands(program: Command): void { DiscussionBodyOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); if (!options.body) { throw invalidParameterError("--body", "is required"); @@ -416,7 +408,7 @@ export function setupProjectsCommands(program: Command): void { DiscussionBodyOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); if (!options.body) { throw invalidParameterError("--body", "is required"); @@ -441,7 +433,7 @@ export function setupProjectsCommands(program: Command): void { .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, @@ -459,7 +451,7 @@ export function setupProjectsCommands(program: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [reply, , command] = args as [string, unknown, Command]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const result = await deleteDiscussionReply(ctx.gql, reply, "project"); @@ -478,7 +470,7 @@ export function setupProjectsCommands(program: Command): void { ResolveDiscussionOptions, Command, ]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const result = await resolveDiscussion(ctx.gql, { threadId: thread, @@ -496,7 +488,7 @@ export function setupProjectsCommands(program: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [thread, , command] = args as [string, unknown, Command]; - const ctx = createContext(command.parent!.parent!.opts()); + const ctx = createContext(getRootOpts(command)); const result = await unresolveDiscussion(ctx.gql, thread, "project"); @@ -524,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(",") @@ -614,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); @@ -701,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); @@ -714,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, }); @@ -729,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 5ca0f4a..09b471e 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 59df6c0..bb68a54 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 82a93ce..4baa1d6 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/tests/unit/commands/attachments.test.ts b/tests/unit/commands/attachments.test.ts index 6dc37b2..89cc47f 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 2186f7f..888ccfe 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 index 9f4a99a..f16ee3c 100644 --- a/tests/unit/commands/comments.test.ts +++ b/tests/unit/commands/comments.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/commands/initiatives.test.ts b/tests/unit/commands/initiatives.test.ts index 0bd6639..36ce0af 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) => { @@ -167,6 +168,7 @@ vi.mock("../../../src/services/discussion-service.js", () => ({ })); 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"; @@ -894,6 +896,24 @@ describe("initiative discussion commands", () => { 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(); diff --git a/tests/unit/commands/issues.test.ts b/tests/unit/commands/issues.test.ts index a581f49..389e790 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) => { diff --git a/tests/unit/commands/labels.test.ts b/tests/unit/commands/labels.test.ts index 33bd4d0..4388da0 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) => { diff --git a/tests/unit/commands/projects.test.ts b/tests/unit/commands/projects.test.ts index 1989062..9f14299 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) => { diff --git a/tests/unit/commands/teams.test.ts b/tests/unit/commands/teams.test.ts index b71bd24..febd052 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 0000000..53d5efc --- /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" }), + ); + }); +});