From 745ca56465e51f2981740a1153eedc01a40e0431 Mon Sep 17 00:00:00 2001 From: Jacek Juraszek Date: Wed, 10 Jun 2026 20:09:44 +0200 Subject: [PATCH 01/79] fix(discussions): forward parent entity id when replying to a thread replyToDiscussion built the CommentCreateInput with only parentId + body, omitting the entity id (issueId/projectId/initiativeId) that Linear requires even on a threaded reply, so every reply failed with: Argument Validation Error - Exactly one of ... issueId must be defined. The thread context fetched by assertRootDiscussionThread already carries all three ids; forward the right one via the existing getDiscussionThreadEntity helper. No extra network call. Closes #226 --- src/services/discussion-service.ts | 14 +++++++++++++- .../unit/services/discussion-service.test.ts | 19 +++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/services/discussion-service.ts b/src/services/discussion-service.ts index 6571ed9e..1ebcc18c 100644 --- a/src/services/discussion-service.ts +++ b/src/services/discussion-service.ts @@ -795,13 +795,25 @@ export async function replyToDiscussion( client: GraphQLClient, input: { threadId: string; body: string; entityKind?: DiscussionEntityKind }, ): Promise { - await assertRootDiscussionThread(client, input.threadId, input.entityKind); + const thread = await assertRootDiscussionThread( + client, + input.threadId, + input.entityKind, + ); + const entity = getDiscussionThreadEntity(thread); + const entityField = + entity.kind === "issue" + ? { issueId: entity.id } + : entity.kind === "project" + ? { projectId: entity.id } + : { initiativeId: entity.id }; const result = await client.request( StartDiscussionDocument, { input: { parentId: input.threadId, + ...entityField, body: input.body, }, }, diff --git a/tests/unit/services/discussion-service.test.ts b/tests/unit/services/discussion-service.test.ts index 056994df..045c64b2 100644 --- a/tests/unit/services/discussion-service.test.ts +++ b/tests/unit/services/discussion-service.test.ts @@ -3,6 +3,7 @@ import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { GetDiscussionCommentContextDocument, type ListIssueDiscussionRootsQuery, + StartDiscussionDocument, } from "../../../src/gql/graphql.js"; vi.mock("../../../src/services/reaction-service.js", async (importOriginal) => { @@ -703,10 +704,17 @@ describe("replyToDiscussion", () => { ); }); - it("creates a reply for root thread", async () => { + it("creates a reply for root thread and forwards the parent entity id", async () => { const client = createClientMock(); vi.mocked(client.request) - .mockResolvedValueOnce({ comment: comment("root-1") }) + .mockResolvedValueOnce({ + comment: { + ...comment("root-1"), + issueId: "issue-1", + projectId: null, + initiativeId: null, + }, + }) .mockResolvedValueOnce({ commentCreate: { success: true, @@ -721,6 +729,13 @@ describe("replyToDiscussion", () => { expect(result.id).toBe("reply-1"); expect(result.parentId).toBe("root-1"); + expect(client.request).toHaveBeenNthCalledWith(2, StartDiscussionDocument, { + input: { + parentId: "root-1", + issueId: "issue-1", + body: "hello", + }, + }); }); }); From 62ba4e66e2b551f164ea62c832c5591af1a5dfe0 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 16 Jun 2026 11:24:44 +0000 Subject: [PATCH 02/79] chore(release): 2026.6.0-next.1 [skip ci] ## [2026.6.0-next.1](https://github.com/linearis-oss/linearis/compare/v2026.5.0...v2026.6.0-next.1) (2026-06-16) ### Bug Fixes * **discussions:** forward parent entity id when replying to a thread ([745ca56](https://github.com/linearis-oss/linearis/commit/745ca56465e51f2981740a1153eedc01a40e0431)), closes [#226](https://github.com/linearis-oss/linearis/issues/226) --- 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 628e2d7f..c612d970 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [2026.6.0-next.1](https://github.com/linearis-oss/linearis/compare/v2026.5.0...v2026.6.0-next.1) (2026-06-16) + +### Bug Fixes + +* **discussions:** forward parent entity id when replying to a thread ([745ca56](https://github.com/linearis-oss/linearis/commit/745ca56465e51f2981740a1153eedc01a40e0431)), closes [#226](https://github.com/linearis-oss/linearis/issues/226) + ## [2026.5.0](https://github.com/linearis-oss/linearis/compare/v2026.4.9...v2026.5.0) (2026-06-16) ### Bug Fixes diff --git a/package-lock.json b/package-lock.json index 5ba80a2c..6a4855ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linearis", - "version": "2026.5.0", + "version": "2026.6.0-next.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.5.0", + "version": "2026.6.0-next.1", "license": "MIT", "dependencies": { "@linear/sdk": "82.1.0", diff --git a/package.json b/package.json index b37ffdf5..90e130d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.5.0", + "version": "2026.6.0-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 1a0c8c921e9b65f52ff1ac5e589934aa8f6c96ba Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:11:18 +0200 Subject: [PATCH 03/79] test(milestone): guard against input-wrapped variable regression Add regression tests asserting createMilestone and updateMilestone pass only flat variables declared by their GraphQL documents. Refs #228 --- tests/unit/helpers/assert-variables.ts | 39 ++++++++++ .../milestone-service.variables.test.ts | 75 +++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 tests/unit/helpers/assert-variables.ts create mode 100644 tests/unit/services/milestone-service.variables.test.ts diff --git a/tests/unit/helpers/assert-variables.ts b/tests/unit/helpers/assert-variables.ts new file mode 100644 index 00000000..0a996eed --- /dev/null +++ b/tests/unit/helpers/assert-variables.ts @@ -0,0 +1,39 @@ +import { type DocumentNode, Kind } from "graphql"; +import { expect } from "vitest"; + +/** + * Collect the names of every variable declared by the operation(s) in a + * GraphQL document (e.g. `$projectId`, `$name` → "projectId", "name"). + */ +export function declaredVariableNames(doc: DocumentNode): Set { + const names = new Set(); + for (const definition of doc.definitions) { + if (definition.kind !== Kind.OPERATION_DEFINITION) { + continue; + } + for (const variable of definition.variableDefinitions ?? []) { + names.add(variable.variable.name.value); + } + } + return names; +} + +/** + * Assert that every top-level key of the variables object passed to + * `client.request` corresponds to a variable actually declared by the + * document. This catches the `{ input }`-vs-flat-variable class of bug + * (see issue #228): passing `{ input: {...} }` against a mutation that + * declares flat `$projectId`/`$name`/... variables would surface an + * undeclared "input" key here. + */ +export function assertVariablesMatchDocument( + doc: DocumentNode, + variables: Record, +): void { + const declared = declaredVariableNames(doc); + const undeclared = Object.keys(variables).filter((key) => !declared.has(key)); + expect( + undeclared, + `Variables ${JSON.stringify(undeclared)} are not declared by the document (declared: ${JSON.stringify([...declared])})`, + ).toEqual([]); +} diff --git a/tests/unit/services/milestone-service.variables.test.ts b/tests/unit/services/milestone-service.variables.test.ts new file mode 100644 index 00000000..6f44ec6e --- /dev/null +++ b/tests/unit/services/milestone-service.variables.test.ts @@ -0,0 +1,75 @@ +import type { DocumentNode } from "graphql"; +import { describe, expect, it, vi } from "vitest"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { + createMilestone, + updateMilestone, +} from "../../../src/services/milestone-service.js"; +import { assertVariablesMatchDocument } from "../helpers/assert-variables.js"; + +/** + * Regression guard for issue #228: the milestone mutations declare flat + * variables (`$projectId`, `$name`, ...), so the service must pass flat + * variables — not `{ input }` / `{ id, input }`. These tests capture the + * variables object actually handed to `client.request` and assert every key + * is a variable the document declares. + */ +function mockGqlClient(response: Record): { + client: GraphQLClient; + request: ReturnType; +} { + const request = vi.fn().mockResolvedValue(response); + return { client: { request } as unknown as GraphQLClient, request }; +} + +function lastCallVariables( + request: ReturnType, +): [DocumentNode, Record] { + const [document, variables] = request.mock.calls[0] as [ + DocumentNode, + Record, + ]; + return [document, variables]; +} + +describe("milestone service variable shapes (issue #228)", () => { + it("createMilestone passes only declared variables", async () => { + const { client, request } = mockGqlClient({ + projectMilestoneCreate: { + success: true, + projectMilestone: { id: "ms-new", name: "v2.0" }, + }, + }); + + await createMilestone(client, { + projectId: "proj-1", + name: "v2.0", + description: "Second release", + targetDate: "2025-12-01", + }); + + const [document, variables] = lastCallVariables(request); + assertVariablesMatchDocument(document, variables); + expect(variables).not.toHaveProperty("input"); + }); + + it("updateMilestone passes only declared variables", async () => { + const { client, request } = mockGqlClient({ + projectMilestoneUpdate: { + success: true, + projectMilestone: { id: "ms-1", name: "v1.1" }, + }, + }); + + await updateMilestone(client, "ms-1", { + name: "v1.1", + description: "Updated", + targetDate: "2026-01-01", + sortOrder: 2, + }); + + const [document, variables] = lastCallVariables(request); + assertVariablesMatchDocument(document, variables); + expect(variables).not.toHaveProperty("input"); + }); +}); From e0f880c8e700b889b0565cae787675c62b3c3eb8 Mon Sep 17 00:00:00 2001 From: Ralf Schimmel Date: Mon, 22 Jun 2026 22:38:21 +0200 Subject: [PATCH 04/79] feat(issues): restore relation commands --- graphql/mutations/issue-relations.graphql | 16 ++ src/commands/issues.ts | 183 +++++++++++++++++- src/services/issue-relation-service.ts | 32 +++ tests/unit/commands/issues.test.ts | 147 +++++++++++++- .../services/issue-relation-service.test.ts | 57 ++++++ 5 files changed, 425 insertions(+), 10 deletions(-) diff --git a/graphql/mutations/issue-relations.graphql b/graphql/mutations/issue-relations.graphql index 021bc34a..19a6f1f6 100644 --- a/graphql/mutations/issue-relations.graphql +++ b/graphql/mutations/issue-relations.graphql @@ -6,9 +6,16 @@ fragment IssueRelationFields on IssueRelation { id type + createdAt + issue { + id + identifier + title + } relatedIssue { id identifier + title } } @@ -16,9 +23,16 @@ fragment IssueRelationFields on IssueRelation { fragment InverseIssueRelationFields on IssueRelation { id type + createdAt issue { id identifier + title + } + relatedIssue { + id + identifier + title } } @@ -44,6 +58,8 @@ mutation DeleteIssueRelation($id: String!) { # Used by --remove-relation to locate the relation ID before deletion query GetIssueRelations($issueId: String!) { issue(id: $issueId) { + id + identifier relations { nodes { ...IssueRelationFields diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 8d8080e9..086b2e32 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -58,6 +58,7 @@ import { createIssueRelation, deleteIssueRelation, findIssueRelation, + listIssueRelations, } from "../services/issue-relation-service.js"; import { archiveIssue, @@ -107,6 +108,7 @@ interface CreateOptions { blockedBy?: string; relatesTo?: string; duplicateOf?: string; + similarTo?: string; } interface UpdateOptions { @@ -133,6 +135,7 @@ interface UpdateOptions { blockedBy?: string; relatesTo?: string; duplicateOf?: string; + similarTo?: string; removeRelation?: string; } @@ -284,15 +287,29 @@ export const ISSUES_META: DomainMeta = { }; interface RelationAction { - type: "blocks" | "blockedBy" | "relatesTo" | "duplicateOf" | "remove"; + type: + | "blocks" + | "blockedBy" + | "relatesTo" + | "duplicateOf" + | "similarTo" + | "remove"; targets: string[]; } +interface RelationAddOptions { + blocks?: string; + related?: string; + duplicate?: string; + similar?: string; +} + function parseRelationFlags(flags: { blocks?: string; blockedBy?: string; relatesTo?: string; duplicateOf?: string; + similarTo?: string; removeRelation?: string; }): RelationAction[] { const entries: Array<{ @@ -303,6 +320,7 @@ function parseRelationFlags(flags: { { type: "blockedBy", raw: flags.blockedBy }, { type: "relatesTo", raw: flags.relatesTo }, { type: "duplicateOf", raw: flags.duplicateOf }, + { type: "similarTo", raw: flags.similarTo }, { type: "remove", raw: flags.removeRelation }, ]; @@ -327,7 +345,7 @@ function parseRelationFlags(flags: { ]; if (targets.length === 0) { throw new Error( - `Relation flag --${type === "remove" ? "remove-relation" : type} must not be empty`, + `Relation flag ${relationFlagName(type)} must not be empty`, ); } actions.push({ type, targets }); @@ -340,19 +358,90 @@ function parseRelationFlags(flags: { const prev = seen.get(target); if (prev) { throw new Error( - `${target} appears in multiple relation flags (${prev} and --${action.type === "remove" ? "remove-relation" : action.type})`, + `${target} appears in multiple relation flags (${prev} and ${relationFlagName(action.type)})`, ); } - seen.set( - target, - `--${action.type === "remove" ? "remove-relation" : action.type}`, - ); + seen.set(target, relationFlagName(action.type)); } } return actions; } +function relationFlagName(type: RelationAction["type"]): string { + switch (type) { + case "blocks": + return "--blocks"; + case "blockedBy": + return "--blocked-by"; + case "relatesTo": + return "--relates-to"; + case "duplicateOf": + return "--duplicate-of"; + case "similarTo": + return "--similar-to"; + case "remove": + return "--remove-relation"; + } +} + +function relationTypeFromAddFlag( + type: "blocks" | "related" | "duplicate" | "similar", +): IssueRelationType { + switch (type) { + case "blocks": + return IssueRelationType.Blocks; + case "related": + return IssueRelationType.Related; + case "duplicate": + return IssueRelationType.Duplicate; + case "similar": + return IssueRelationType.Similar; + } +} + +function parseRelationAddOptions(options: RelationAddOptions): { + type: IssueRelationType; + targets: string[]; +} { + const typeFlags = [ + options.blocks ? "blocks" : null, + options.related ? "related" : null, + options.duplicate ? "duplicate" : null, + options.similar ? "similar" : null, + ].filter((type): type is keyof RelationAddOptions => type !== null); + + if (typeFlags.length === 0) { + throw new Error( + "Must specify one of --blocks, --related, --duplicate, or --similar", + ); + } + + if (typeFlags.length > 1) { + throw new Error("Cannot specify multiple relation types"); + } + + const type = typeFlags[0]; + const rawTargets = options[type] ?? ""; + const targets = [ + ...new Set( + rawTargets + .split(",") + .map((target) => target.trim()) + .filter(Boolean), + ), + ]; + + if (targets.length === 0) { + throw new Error("At least one related issue ID must be provided"); + } + + return { + type: relationTypeFromAddFlag(type), + targets, + }; +} + async function resolveAndApplyRelations( ctx: CommandContext, issueId: string, @@ -400,6 +489,13 @@ async function resolveAndApplyRelations( type: IssueRelationType.Duplicate, }); break; + case "similarTo": + await createIssueRelation(ctx.gql, { + issueId, + relatedIssueId: targetId, + type: IssueRelationType.Similar, + }); + break; case "remove": { const relationId = await findIssueRelation( ctx.gql, @@ -450,6 +546,77 @@ export function setupIssuesCommands(program: Command): void { issues.action(() => issues.help()); + const relations = issues + .command("relations") + .description("Issue relation operations"); + + relations.action(() => relations.help()); + + relations + .command("list ") + .description("list relations for an issue") + .action( + handleCommand(async (...args: unknown[]) => { + const [issue, , command] = args as [string, unknown, Command]; + const ctx = createContext(getRootOpts(command)); + const issueId = await resolveIssueId(ctx.sdk, issue); + const result = await listIssueRelations(ctx.gql, issueId); + + outputSuccess(result); + }), + ); + + relations + .command("add ") + .description("add relation(s) to an issue") + .option("--blocks ", "issues this issue blocks (comma-separated)") + .option("--related ", "related issues (comma-separated)") + .option( + "--duplicate ", + "issues this is a duplicate of (comma-separated)", + ) + .option("--similar ", "similar issues (comma-separated)") + .action( + handleCommand(async (...args: unknown[]) => { + const [issue, options, command] = args as [ + string, + RelationAddOptions, + Command, + ]; + const relation = parseRelationAddOptions(options); + const ctx = createContext(getRootOpts(command)); + const sourceIssueId = await resolveIssueId(ctx.sdk, issue); + const targetIds = await Promise.all( + relation.targets.map((target) => resolveIssueId(ctx.sdk, target)), + ); + + const created = await Promise.all( + targetIds.map((targetId) => + createIssueRelation(ctx.gql, { + issueId: sourceIssueId, + relatedIssueId: targetId, + type: relation.type, + }), + ), + ); + + outputSuccess(created); + }), + ); + + relations + .command("remove ") + .description("remove a relation by UUID") + .action( + handleCommand(async (...args: unknown[]) => { + const [relation, , command] = args as [string, unknown, Command]; + const ctx = createContext(getRootOpts(command)); + const result = await deleteIssueRelation(ctx.gql, relation); + + outputSuccess(result); + }), + ); + addFilterOptions( issues .command("list") @@ -988,6 +1155,7 @@ export function setupIssuesCommands(program: Command): void { .option("--blocked-by ", "this issue is blocked by ") .option("--relates-to ", "this issue relates to ") .option("--duplicate-of ", "this issue duplicates ") + .option("--similar-to ", "this issue is similar to ") .action( handleCommand(async (...args: unknown[]) => { const [title, options, command] = args as [ @@ -1140,6 +1308,7 @@ export function setupIssuesCommands(program: Command): void { .option("--blocked-by ", "add blocked-by relation") .option("--relates-to ", "add relates-to relation") .option("--duplicate-of ", "add duplicate relation") + .option("--similar-to ", "add similar relation") .option("--remove-relation ", "remove relation with ") .action( handleCommand(async (...args: unknown[]) => { diff --git a/src/services/issue-relation-service.ts b/src/services/issue-relation-service.ts index 995035fa..9fec051f 100644 --- a/src/services/issue-relation-service.ts +++ b/src/services/issue-relation-service.ts @@ -11,6 +11,8 @@ import { type IssueRelationType, } from "../gql/graphql.js"; +type IssueRelationsIssue = NonNullable; + export async function createIssueRelation( client: GraphQLClient, input: { @@ -29,6 +31,36 @@ export async function createIssueRelation( return result.issueRelationCreate.issueRelation; } +export async function listIssueRelations( + client: GraphQLClient, + issueId: string, +): Promise<{ + issueId: string; + identifier: string; + relations: Array< + | IssueRelationsIssue["relations"]["nodes"][0] + | IssueRelationsIssue["inverseRelations"]["nodes"][0] + >; +}> { + const result = await client.request( + GetIssueRelationsDocument, + { issueId }, + ); + + if (!result.issue) { + throw notFoundError("Issue", issueId); + } + + return { + issueId: result.issue.id, + identifier: result.issue.identifier, + relations: [ + ...result.issue.relations.nodes, + ...result.issue.inverseRelations.nodes, + ], + }; +} + export async function findIssueRelation( client: GraphQLClient, issueId: string, diff --git a/tests/unit/commands/issues.test.ts b/tests/unit/commands/issues.test.ts index 389e790f..1c0f39ed 100644 --- a/tests/unit/commands/issues.test.ts +++ b/tests/unit/commands/issues.test.ts @@ -116,9 +116,17 @@ vi.mock("../../../src/services/issue-service.js", () => ({ })); vi.mock("../../../src/services/issue-relation-service.js", () => ({ - createIssueRelation: vi.fn(), - deleteIssueRelation: vi.fn(), - findIssueRelation: vi.fn(), + createIssueRelation: vi.fn().mockResolvedValue({ id: "relation-uuid" }), + deleteIssueRelation: vi.fn().mockResolvedValue({ + id: "relation-uuid", + success: true, + }), + findIssueRelation: vi.fn().mockResolvedValue("relation-uuid"), + listIssueRelations: vi.fn().mockResolvedValue({ + issueId: "resolved-issue-uuid", + identifier: "ENG-42", + relations: [], + }), })); vi.mock("../../../src/services/reaction-service.js", () => ({ @@ -1915,6 +1923,28 @@ describe("issues create relations", () => { ); expect(createIssueRelation).toHaveBeenCalledTimes(1); }); + + it("creates similar relation", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "create", + "Title", + "--team", + "ENG", + "--similar-to", + "DAT-103", + ]); + const { createIssueRelation } = await import( + "../../../src/services/issue-relation-service.js" + ); + expect(createIssueRelation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ type: "similar" }), + ); + }); }); describe("issues update relations", () => { @@ -1998,4 +2028,115 @@ describe("issues update relations", () => { ); expect(process.exit).toHaveBeenCalledWith(1); }); + + it("adds similar relation", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "update", + "ENG-42", + "--similar-to", + "DAT-103", + ]); + const { createIssueRelation } = await import( + "../../../src/services/issue-relation-service.js" + ); + expect(createIssueRelation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ type: "similar" }), + ); + }); +}); + +describe("issues relations subcommands", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + }); + + it("lists relations for an issue", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "relations", + "list", + "ENG-42", + ]); + + const { listIssueRelations } = await import( + "../../../src/services/issue-relation-service.js" + ); + expect(listIssueRelations).toHaveBeenCalledWith( + expect.anything(), + "resolved-issue-uuid", + ); + }); + + it("adds comma-separated relations", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "relations", + "add", + "ENG-42", + "--similar", + "DAT-103,DAT-104", + ]); + + const { createIssueRelation } = await import( + "../../../src/services/issue-relation-service.js" + ); + expect(createIssueRelation).toHaveBeenCalledTimes(2); + expect(createIssueRelation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ type: "similar" }), + ); + }); + + it("rejects add without relation type", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "relations", + "add", + "ENG-42", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining( + "Must specify one of --blocks, --related, --duplicate, or --similar", + ), + ); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("removes relation by UUID", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "relations", + "remove", + "relation-uuid", + ]); + + const { deleteIssueRelation } = await import( + "../../../src/services/issue-relation-service.js" + ); + expect(deleteIssueRelation).toHaveBeenCalledWith( + expect.anything(), + "relation-uuid", + ); + }); }); diff --git a/tests/unit/services/issue-relation-service.test.ts b/tests/unit/services/issue-relation-service.test.ts index ebf3b2a1..b0ca5c02 100644 --- a/tests/unit/services/issue-relation-service.test.ts +++ b/tests/unit/services/issue-relation-service.test.ts @@ -5,6 +5,7 @@ import { createIssueRelation, deleteIssueRelation, findIssueRelation, + listIssueRelations, } from "../../../src/services/issue-relation-service.js"; function mockGqlClient(response: Record): GraphQLClient { @@ -112,6 +113,62 @@ describe("findIssueRelation", () => { }); }); +describe("listIssueRelations", () => { + it("returns issue metadata with forward and inverse relations", async () => { + const client = mockGqlClient({ + issue: { + id: "source-id", + identifier: "ENG-1", + relations: { + nodes: [ + { + id: "rel-1", + type: IssueRelationType.Blocks, + relatedIssue: { id: "target-id", identifier: "ENG-2" }, + }, + ], + }, + inverseRelations: { + nodes: [ + { + id: "rel-2", + type: IssueRelationType.Related, + issue: { id: "other-id", identifier: "ENG-3" }, + }, + ], + }, + }, + }); + + const result = await listIssueRelations(client, "source-id"); + + expect(result).toEqual({ + issueId: "source-id", + identifier: "ENG-1", + relations: [ + { + id: "rel-1", + type: IssueRelationType.Blocks, + relatedIssue: { id: "target-id", identifier: "ENG-2" }, + }, + { + id: "rel-2", + type: IssueRelationType.Related, + issue: { id: "other-id", identifier: "ENG-3" }, + }, + ], + }); + }); + + it("throws when issue is not found", async () => { + const client = mockGqlClient({ issue: null }); + + await expect(listIssueRelations(client, "missing")).rejects.toThrow( + "not found", + ); + }); +}); + describe("deleteIssueRelation", () => { it("returns id and success", async () => { const client = mockGqlClient({ From 731d52d9adddd8ce1daa8bd6dcec2f8ff2e2112c Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 2 Jul 2026 10:32:52 +0000 Subject: [PATCH 05/79] chore(release): 2026.6.0-next.2 [skip ci] ## [2026.6.0-next.2](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.1...v2026.6.0-next.2) (2026-07-02) ### Features * **issues:** restore relation commands ([e0f880c](https://github.com/linearis-oss/linearis/commit/e0f880c8e700b889b0565cae787675c62b3c3eb8)) --- 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 c612d970..4945a9e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [2026.6.0-next.2](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.1...v2026.6.0-next.2) (2026-07-02) + +### Features + +* **issues:** restore relation commands ([e0f880c](https://github.com/linearis-oss/linearis/commit/e0f880c8e700b889b0565cae787675c62b3c3eb8)) + ## [2026.6.0-next.1](https://github.com/linearis-oss/linearis/compare/v2026.5.0...v2026.6.0-next.1) (2026-06-16) ### Bug Fixes diff --git a/package-lock.json b/package-lock.json index 6a4855ae..2edac8e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linearis", - "version": "2026.6.0-next.1", + "version": "2026.6.0-next.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.6.0-next.1", + "version": "2026.6.0-next.2", "license": "MIT", "dependencies": { "@linear/sdk": "82.1.0", diff --git a/package.json b/package.json index 90e130d4..c3ec0b3b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.6.0-next.1", + "version": "2026.6.0-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 309aaab662ab0dc79731f2c288f97d7b75d74caf Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:15:13 +0200 Subject: [PATCH 06/79] fix(milestones): use $input convention for milestone mutations Align CreateProjectMilestone and UpdateProjectMilestone with the $input argument shape used by every other mutation in the codebase, fixing the runtime GraphQL variable error on milestone create and the incorrect variables sent on update. Closes #223 --- graphql/mutations/project-milestones.graphql | 31 ++----------- src/services/milestone-service.ts | 4 +- tests/unit/services/milestone-service.test.ts | 44 +++++++++++++++++++ 3 files changed, 50 insertions(+), 29 deletions(-) diff --git a/graphql/mutations/project-milestones.graphql b/graphql/mutations/project-milestones.graphql index 7889675b..49e34a91 100644 --- a/graphql/mutations/project-milestones.graphql +++ b/graphql/mutations/project-milestones.graphql @@ -7,20 +7,8 @@ # Create a new project milestone # # Creates a new project milestone and returns the created project milestone data. -mutation CreateProjectMilestone( - $projectId: String! - $name: String! - $description: String - $targetDate: TimelessDate -) { - projectMilestoneCreate( - input: { - projectId: $projectId - name: $name - description: $description - targetDate: $targetDate - } - ) { +mutation CreateProjectMilestone($input: ProjectMilestoneCreateInput!) { + projectMilestoneCreate(input: $input) { success projectMilestone { id @@ -43,20 +31,9 @@ mutation CreateProjectMilestone( # Updates an existing project milestone and returns the updated project milestone data. mutation UpdateProjectMilestone( $id: String! - $name: String - $description: String - $targetDate: TimelessDate - $sortOrder: Float + $input: ProjectMilestoneUpdateInput! ) { - projectMilestoneUpdate( - id: $id - input: { - name: $name - description: $description - targetDate: $targetDate - sortOrder: $sortOrder - } - ) { + projectMilestoneUpdate(id: $id, input: $input) { success projectMilestone { id diff --git a/src/services/milestone-service.ts b/src/services/milestone-service.ts index eb7bf43d..9a9081ad 100644 --- a/src/services/milestone-service.ts +++ b/src/services/milestone-service.ts @@ -63,7 +63,7 @@ export async function createMilestone( ): Promise { const result = await client.request( CreateProjectMilestoneDocument, - input, + { input }, ); if ( @@ -83,7 +83,7 @@ export async function updateMilestone( ): Promise { const result = await client.request( UpdateProjectMilestoneDocument, - { id, ...input }, + { id, input }, ); if ( diff --git a/tests/unit/services/milestone-service.test.ts b/tests/unit/services/milestone-service.test.ts index aa04be91..6618d6cc 100644 --- a/tests/unit/services/milestone-service.test.ts +++ b/tests/unit/services/milestone-service.test.ts @@ -134,6 +134,29 @@ describe("createMilestone", () => { expect(result.name).toBe("v2.0"); }); + it("passes input as a single GraphQL variable", async () => { + const client = mockGqlClient({ + projectMilestoneCreate: { + success: true, + projectMilestone: { + id: "ms-new", + name: "v2.0", + description: null, + targetDate: null, + sortOrder: 0, + }, + }, + }); + const input = { + projectId: "proj-1", + name: "v2.0", + description: "desc", + targetDate: "2025-12-01", + }; + await createMilestone(client, input); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { input }); + }); + it("throws on failure", async () => { const client = mockGqlClient({ projectMilestoneCreate: { @@ -166,6 +189,27 @@ describe("updateMilestone", () => { expect(result.name).toBe("v1.1"); }); + it("passes id and input as GraphQL variables", async () => { + const client = mockGqlClient({ + projectMilestoneUpdate: { + success: true, + projectMilestone: { + id: "ms-1", + name: "v1.1", + description: null, + targetDate: null, + sortOrder: 0, + }, + }, + }); + const input = { name: "v1.1", description: "updated" }; + await updateMilestone(client, "ms-1", input); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + id: "ms-1", + input, + }); + }); + it("throws on failure", async () => { const client = mockGqlClient({ projectMilestoneUpdate: { From e371b2945fa2129d85e62bb23103f103aee5f9a9 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:25:35 +0200 Subject: [PATCH 07/79] test(milestones): align variable-shape guard with $input convention The #228 guard test asserted milestone mutations pass flat variables, but #223 aligned CreateProjectMilestone/UpdateProjectMilestone with the $input convention used by every other mutation. Update the guard to expect the `{ input }` / `{ id, input }` shape so both fixes agree. Refs #223, #228 --- tests/unit/helpers/assert-variables.ts | 9 +++++---- .../services/milestone-service.variables.test.ts | 16 ++++++++-------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/tests/unit/helpers/assert-variables.ts b/tests/unit/helpers/assert-variables.ts index 0a996eed..c5b40bb0 100644 --- a/tests/unit/helpers/assert-variables.ts +++ b/tests/unit/helpers/assert-variables.ts @@ -21,10 +21,11 @@ export function declaredVariableNames(doc: DocumentNode): Set { /** * Assert that every top-level key of the variables object passed to * `client.request` corresponds to a variable actually declared by the - * document. This catches the `{ input }`-vs-flat-variable class of bug - * (see issue #228): passing `{ input: {...} }` against a mutation that - * declares flat `$projectId`/`$name`/... variables would surface an - * undeclared "input" key here. + * document. This catches the input-shape-vs-declared-variable class of bug + * (see issues #223 / #228): passing variables whose keys do not match the + * mutation's declared variables (e.g. flat `$projectId`/`$name`/... against a + * document declaring `$input`, or vice versa) would surface an undeclared key + * here. */ export function assertVariablesMatchDocument( doc: DocumentNode, diff --git a/tests/unit/services/milestone-service.variables.test.ts b/tests/unit/services/milestone-service.variables.test.ts index 6f44ec6e..95d58018 100644 --- a/tests/unit/services/milestone-service.variables.test.ts +++ b/tests/unit/services/milestone-service.variables.test.ts @@ -8,11 +8,11 @@ import { import { assertVariablesMatchDocument } from "../helpers/assert-variables.js"; /** - * Regression guard for issue #228: the milestone mutations declare flat - * variables (`$projectId`, `$name`, ...), so the service must pass flat - * variables — not `{ input }` / `{ id, input }`. These tests capture the - * variables object actually handed to `client.request` and assert every key - * is a variable the document declares. + * Regression guard for the milestone mutation variable shapes (issues #223 / + * #228): the milestone mutations follow the `$input` convention used by every + * other mutation in the codebase, so the service must pass `{ input }` / + * `{ id, input }`. These tests capture the variables object actually handed to + * `client.request` and assert every key is a variable the document declares. */ function mockGqlClient(response: Record): { client: GraphQLClient; @@ -32,7 +32,7 @@ function lastCallVariables( return [document, variables]; } -describe("milestone service variable shapes (issue #228)", () => { +describe("milestone service variable shapes (issues #223 / #228)", () => { it("createMilestone passes only declared variables", async () => { const { client, request } = mockGqlClient({ projectMilestoneCreate: { @@ -50,7 +50,7 @@ describe("milestone service variable shapes (issue #228)", () => { const [document, variables] = lastCallVariables(request); assertVariablesMatchDocument(document, variables); - expect(variables).not.toHaveProperty("input"); + expect(variables).toHaveProperty("input"); }); it("updateMilestone passes only declared variables", async () => { @@ -70,6 +70,6 @@ describe("milestone service variable shapes (issue #228)", () => { const [document, variables] = lastCallVariables(request); assertVariablesMatchDocument(document, variables); - expect(variables).not.toHaveProperty("input"); + expect(variables).toHaveProperty("input"); }); }); From 5220447e833c19cd3d8860c48a187b1744f824a3 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 2 Jul 2026 10:35:54 +0000 Subject: [PATCH 08/79] chore(release): 2026.6.0-next.3 [skip ci] ## [2026.6.0-next.3](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.2...v2026.6.0-next.3) (2026-07-02) ### Bug Fixes * **milestones:** use $input convention for milestone mutations ([309aaab](https://github.com/linearis-oss/linearis/commit/309aaab662ab0dc79731f2c288f97d7b75d74caf)), closes [#223](https://github.com/linearis-oss/linearis/issues/223) --- 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 4945a9e3..138c0465 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [2026.6.0-next.3](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.2...v2026.6.0-next.3) (2026-07-02) + +### Bug Fixes + +* **milestones:** use $input convention for milestone mutations ([309aaab](https://github.com/linearis-oss/linearis/commit/309aaab662ab0dc79731f2c288f97d7b75d74caf)), closes [#223](https://github.com/linearis-oss/linearis/issues/223) + ## [2026.6.0-next.2](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.1...v2026.6.0-next.2) (2026-07-02) ### Features diff --git a/package-lock.json b/package-lock.json index 2edac8e2..d676f9cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linearis", - "version": "2026.6.0-next.2", + "version": "2026.6.0-next.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.6.0-next.2", + "version": "2026.6.0-next.3", "license": "MIT", "dependencies": { "@linear/sdk": "82.1.0", diff --git a/package.json b/package.json index c3ec0b3b..57a5e696 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.6.0-next.2", + "version": "2026.6.0-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 682bde9489176b4dffa26f3e4fd40bdb633fe77c Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:23:10 +0200 Subject: [PATCH 09/79] feat(output): add --compact and --fields flags for token-efficient output Add two global output-shaping flags for stdout success payloads: - --compact emits single-line JSON (no indentation). - --fields keeps only comma-separated dot-paths, preserving nested object shape and traversing arrays mid-path (e.g. labels.nodes.name). Options are set once per process via a Commander preAction hook that reads root opts through the existing getRootOpts() helper, so none of the ~120 outputSuccess() call sites change. Error envelopes stay pretty-printed. Adds pickFields/parseFieldsList helpers with tests. Closes #220 --- src/common/auth.ts | 2 + src/common/output.ts | 61 ++++++++++++++- src/main.ts | 14 +++- tests/unit/common/output.test.ts | 127 ++++++++++++++++++++++++++++++- 4 files changed, 200 insertions(+), 4 deletions(-) diff --git a/src/common/auth.ts b/src/common/auth.ts index 86c94c0d..33b66378 100644 --- a/src/common/auth.ts +++ b/src/common/auth.ts @@ -5,6 +5,8 @@ import { getStoredToken } from "./token-storage.js"; export interface CommandOptions { apiToken?: string; + compact?: boolean; + fields?: string[]; } export type TokenSource = "flag" | "env" | "stored" | "legacy"; diff --git a/src/common/output.ts b/src/common/output.ts index 3bb42ea3..f851f670 100644 --- a/src/common/output.ts +++ b/src/common/output.ts @@ -4,8 +4,67 @@ import { invalidParameterError, } from "./errors.js"; +interface OutputOptions { + compact?: boolean; + fields?: string[]; // raw dot-paths, e.g. ["identifier", "state.name"] +} + +let currentOutputOptions: OutputOptions = {}; + +/** + * Set once per process by the preAction hook in main.ts. Also used to reset + * state between unit tests. + */ +export function setOutputOptions(opts: OutputOptions): void { + currentOutputOptions = opts; +} + +/** Commander option parser for `--fields`: "a, b ,, c" -> ["a","b","c"]. */ +export function parseFieldsList(value: string): string[] { + return value + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +/** + * Recursively project `value` down to the given dot-path segments, preserving + * nested object shape and traversing arrays mid-path. Missing keys are skipped + * silently; a path that stops at a subtree keeps that whole subtree. + */ +export function pickFields(value: unknown, paths: string[][]): unknown { + if (Array.isArray(value)) { + return value.map((item) => pickFields(item, paths)); + } + if (value === null || typeof value !== "object") { + return value; // path descends past a scalar; nothing to pick + } + const src = value as Record; + const byHead = new Map(); + for (const [head, ...tail] of paths) { + if (head === undefined) continue; + const tails = byHead.get(head) ?? []; + if (tail.length > 0) tails.push(tail); + byHead.set(head, tails); + } + const out: Record = {}; + for (const [head, tails] of byHead) { + if (!(head in src)) continue; + out[head] = tails.length > 0 ? pickFields(src[head], tails) : src[head]; + } + return out; +} + export function outputSuccess(data: unknown): void { - console.log(JSON.stringify(data, null, 2)); + const { compact, fields } = currentOutputOptions; + const shaped = + fields && fields.length > 0 + ? pickFields( + data, + fields.map((p) => p.split(".")), + ) + : data; + console.log(JSON.stringify(shaped, null, compact ? undefined : 2)); } export function outputError(error: Error): void { diff --git a/src/main.ts b/src/main.ts index fa1360a1..1a302fad 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,6 +27,8 @@ import { import { PROJECTS_META, setupProjectsCommands } from "./commands/projects.js"; import { setupTeamsCommands, TEAMS_META } from "./commands/teams.js"; import { setupUsersCommands, USERS_META } from "./commands/users.js"; +import { getRootOpts } from "./common/context.js"; +import { parseFieldsList, setOutputOptions } from "./common/output.js"; import { type DomainMeta, formatDomainUsage, @@ -37,7 +39,17 @@ program .name("linearis") .description("CLI for Linear.app with JSON output") .version(pkg.version) - .option("--api-token ", "Linear API token"); + .option("--api-token ", "Linear API token") + .option("--compact", "emit single-line JSON (no indentation)") + .option( + "--fields ", + "comma-separated dot-paths to include (e.g. identifier,title,state.name)", + parseFieldsList, + ); + +program.hook("preAction", (_thisCommand, actionCommand) => { + setOutputOptions(getRootOpts(actionCommand)); +}); const allMetas: DomainMeta[] = [ AUTH_META, diff --git a/tests/unit/common/output.test.ts b/tests/unit/common/output.test.ts index e82d1e47..08c0d8a7 100644 --- a/tests/unit/common/output.test.ts +++ b/tests/unit/common/output.test.ts @@ -1,16 +1,21 @@ // tests/unit/common/output.test.ts -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { AuthenticationError } from "../../../src/common/errors.js"; import { handleCommand, outputAuthError, outputError, outputSuccess, + parseFieldsList, parseLimit, + pickFields, + setOutputOptions, } from "../../../src/common/output.js"; describe("outputSuccess", () => { - it("writes JSON to stdout", () => { + beforeEach(() => setOutputOptions({})); + + it("writes indented JSON to stdout by default", () => { const spy = vi.spyOn(console, "log").mockImplementation(() => {}); outputSuccess({ id: "123", title: "Test" }); expect(spy).toHaveBeenCalledWith( @@ -18,6 +23,124 @@ describe("outputSuccess", () => { ); spy.mockRestore(); }); + + it("emits single-line JSON when compact is set", () => { + setOutputOptions({ compact: true }); + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + outputSuccess({ id: "123", title: "Test" }); + expect(spy).toHaveBeenCalledWith('{"id":"123","title":"Test"}'); + spy.mockRestore(); + }); + + it("filters shape when fields are set", () => { + setOutputOptions({ fields: ["identifier", "state.name"] }); + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + outputSuccess({ + identifier: "ENG-1", + title: "Fix login bug", + state: { id: "s1", name: "In Progress", type: "started" }, + }); + expect(spy).toHaveBeenCalledWith( + JSON.stringify( + { identifier: "ENG-1", state: { name: "In Progress" } }, + null, + 2, + ), + ); + spy.mockRestore(); + }); + + it("combines compact and fields (issue example)", () => { + setOutputOptions({ + compact: true, + fields: ["identifier", "title", "state.name"], + }); + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + outputSuccess([ + { + identifier: "ENG-1", + title: "Fix login bug", + state: { id: "s1", name: "In Progress", type: "started" }, + assignee: { id: "u1" }, + }, + ]); + expect(spy).toHaveBeenCalledWith( + '[{"identifier":"ENG-1","title":"Fix login bug","state":{"name":"In Progress"}}]', + ); + spy.mockRestore(); + }); +}); + +describe("parseFieldsList", () => { + it("splits on comma", () => { + expect(parseFieldsList("identifier,title,state.name")).toEqual([ + "identifier", + "title", + "state.name", + ]); + }); + + it("trims whitespace and drops empty entries", () => { + expect(parseFieldsList(" a , b ,, c ")).toEqual(["a", "b", "c"]); + }); +}); + +describe("pickFields", () => { + it("picks a single top-level field", () => { + expect(pickFields({ a: 1, b: 2 }, [["a"]])).toEqual({ a: 1 }); + }); + + it("picks a nested field", () => { + expect( + pickFields({ state: { name: "Todo", type: "unstarted" } }, [ + ["state", "name"], + ]), + ).toEqual({ state: { name: "Todo" } }); + }); + + it("merges sibling paths under one head", () => { + expect( + pickFields({ state: { id: "s1", name: "Todo", type: "unstarted" } }, [ + ["state", "name"], + ["state", "type"], + ]), + ).toEqual({ state: { name: "Todo", type: "unstarted" } }); + }); + + it("traverses arrays mid-path", () => { + expect( + pickFields( + { labels: { nodes: [{ name: "bug", id: "1" }, { name: "ux" }] } }, + [["labels", "nodes", "name"]], + ), + ).toEqual({ labels: { nodes: [{ name: "bug" }, { name: "ux" }] } }); + }); + + it("projects each element of a top-level array", () => { + expect( + pickFields( + [ + { id: "1", x: 1 }, + { id: "2", x: 2 }, + ], + [["id"]], + ), + ).toEqual([{ id: "1" }, { id: "2" }]); + }); + + it("keeps the whole subtree when a path stops at an object", () => { + const state = { id: "s1", name: "Todo" }; + expect(pickFields({ state, title: "t" }, [["state"]])).toEqual({ state }); + }); + + it("skips missing keys silently", () => { + expect(pickFields({ a: 1 }, [["a"], ["missing"]])).toEqual({ a: 1 }); + }); + + it("returns scalars unchanged when a path over-descends", () => { + expect(pickFields({ a: 5 }, [["a", "deep"]])).toEqual({ a: 5 }); + expect(pickFields({ a: null }, [["a", "deep"]])).toEqual({ a: null }); + }); }); describe("outputError", () => { From a624313e0d1cb74ed77ddbf1f87a24c3262cdc72 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:36:21 +0200 Subject: [PATCH 10/79] fix(output): harden pickFields against prototype-chain keys Match only own properties via Object.hasOwn and write results with Object.defineProperty so a user-supplied --fields __proto__ cannot invoke the prototype setter (previously mutated the result object's prototype and dropped the field). Inherited members like toString and constructor are no longer picked. Derive OutputOptions from CommandOptions via Pick so the two cannot drift. Refs #220 --- src/common/output.ts | 25 +++++++++++++++++-------- tests/unit/common/output.test.ts | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/common/output.ts b/src/common/output.ts index f851f670..0737ce01 100644 --- a/src/common/output.ts +++ b/src/common/output.ts @@ -1,13 +1,13 @@ +import type { CommandOptions } from "./auth.js"; import { AUTH_ERROR_CODE, AuthenticationError, invalidParameterError, } from "./errors.js"; -interface OutputOptions { - compact?: boolean; - fields?: string[]; // raw dot-paths, e.g. ["identifier", "state.name"] -} +// Derived from CommandOptions so the two can never drift; `fields` holds raw +// dot-paths, e.g. ["identifier", "state.name"]. +type OutputOptions = Pick; let currentOutputOptions: OutputOptions = {}; @@ -29,8 +29,11 @@ export function parseFieldsList(value: string): string[] { /** * Recursively project `value` down to the given dot-path segments, preserving - * nested object shape and traversing arrays mid-path. Missing keys are skipped - * silently; a path that stops at a subtree keeps that whole subtree. + * nested object shape and traversing arrays mid-path. Only own properties are + * matched (inherited members like `toString`/`constructor` are never picked), + * and results are written with `Object.defineProperty` so a user-supplied + * `--fields __proto__` cannot invoke the prototype setter. Missing keys are + * skipped silently; a path that stops at a subtree keeps that whole subtree. */ export function pickFields(value: unknown, paths: string[][]): unknown { if (Array.isArray(value)) { @@ -49,8 +52,14 @@ export function pickFields(value: unknown, paths: string[][]): unknown { } const out: Record = {}; for (const [head, tails] of byHead) { - if (!(head in src)) continue; - out[head] = tails.length > 0 ? pickFields(src[head], tails) : src[head]; + if (!Object.hasOwn(src, head)) continue; + const picked = tails.length > 0 ? pickFields(src[head], tails) : src[head]; + Object.defineProperty(out, head, { + value: picked, + enumerable: true, + writable: true, + configurable: true, + }); } return out; } diff --git a/tests/unit/common/output.test.ts b/tests/unit/common/output.test.ts index 08c0d8a7..a5c56d9e 100644 --- a/tests/unit/common/output.test.ts +++ b/tests/unit/common/output.test.ts @@ -141,6 +141,26 @@ describe("pickFields", () => { expect(pickFields({ a: 5 }, [["a", "deep"]])).toEqual({ a: 5 }); expect(pickFields({ a: null }, [["a", "deep"]])).toEqual({ a: null }); }); + + it("never matches inherited (non-own) properties", () => { + const result = pickFields({ a: 1 }, [["toString"], ["constructor"]]); + expect(result).toEqual({}); + expect(Object.hasOwn(result as object, "toString")).toBe(false); + }); + + it("does not invoke the prototype setter for a __proto__ path", () => { + const result = pickFields({ a: 1 }, [["__proto__", "x"]]); + expect(result).toEqual({}); + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + }); + + it("preserves a legitimate own __proto__ key without polluting the result", () => { + const input = JSON.parse('{"__proto__":{"x":9},"a":1}') as unknown; + const result = pickFields(input, [["__proto__", "x"], ["a"]]); + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + expect((result as { a: number }).a).toBe(1); + expect((result as Record).__proto__.x).toBe(9); + }); }); describe("outputError", () => { From 5ea8768ba25ddce4de5bf1f3ed7565ae92e826a0 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 2 Jul 2026 10:43:26 +0000 Subject: [PATCH 11/79] chore(release): 2026.6.0-next.4 [skip ci] ## [2026.6.0-next.4](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.3...v2026.6.0-next.4) (2026-07-02) ### Features * **output:** add --compact and --fields flags for token-efficient output ([682bde9](https://github.com/linearis-oss/linearis/commit/682bde9489176b4dffa26f3e4fd40bdb633fe77c)), closes [#220](https://github.com/linearis-oss/linearis/issues/220) ### Bug Fixes * **output:** harden pickFields against prototype-chain keys ([a624313](https://github.com/linearis-oss/linearis/commit/a624313e0d1cb74ed77ddbf1f87a24c3262cdc72)), closes [#220](https://github.com/linearis-oss/linearis/issues/220) --- 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 138c0465..988f86f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## [2026.6.0-next.4](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.3...v2026.6.0-next.4) (2026-07-02) + +### Features + +* **output:** add --compact and --fields flags for token-efficient output ([682bde9](https://github.com/linearis-oss/linearis/commit/682bde9489176b4dffa26f3e4fd40bdb633fe77c)), closes [#220](https://github.com/linearis-oss/linearis/issues/220) + +### Bug Fixes + +* **output:** harden pickFields against prototype-chain keys ([a624313](https://github.com/linearis-oss/linearis/commit/a624313e0d1cb74ed77ddbf1f87a24c3262cdc72)), closes [#220](https://github.com/linearis-oss/linearis/issues/220) + ## [2026.6.0-next.3](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.2...v2026.6.0-next.3) (2026-07-02) ### Bug Fixes diff --git a/package-lock.json b/package-lock.json index d676f9cf..de889eea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linearis", - "version": "2026.6.0-next.3", + "version": "2026.6.0-next.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.6.0-next.3", + "version": "2026.6.0-next.4", "license": "MIT", "dependencies": { "@linear/sdk": "82.1.0", diff --git a/package.json b/package.json index 57a5e696..c6737cce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.6.0-next.3", + "version": "2026.6.0-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 f096adda7aa20c946a25c7c06fefec56d9684975 Mon Sep 17 00:00:00 2001 From: Ralf Schimmel Date: Mon, 22 Jun 2026 22:31:40 +0200 Subject: [PATCH 12/79] feat: restore document attachment compatibility --- graphql/mutations/documents.graphql | 3 +- src/commands/attachments.ts | 54 +++++- src/commands/documents.ts | 109 ++++++------ tests/unit/commands/attachments.test.ts | 100 +++++++++++ tests/unit/commands/documents.test.ts | 218 ++++++++++++++++++++++++ 5 files changed, 417 insertions(+), 67 deletions(-) create mode 100644 tests/unit/commands/documents.test.ts diff --git a/graphql/mutations/documents.graphql b/graphql/mutations/documents.graphql index fdbbc492..27f6f574 100644 --- a/graphql/mutations/documents.graphql +++ b/graphql/mutations/documents.graphql @@ -2,8 +2,7 @@ # GraphQL mutations for Linear documents # # Documents are standalone entities that can be associated with projects, -# initiatives, or teams. To link a document to an issue, use the -# attachments API (see attachments.graphql). +# initiatives, issues, or teams. # ------------------------------------------------------------ # Create a new document mutation diff --git a/src/commands/attachments.ts b/src/commands/attachments.ts index f8f94342..f1be1d42 100644 --- a/src/commands/attachments.ts +++ b/src/commands/attachments.ts @@ -1,8 +1,12 @@ import type { Command } from "commander"; import { createContext, getRootOpts } from "../common/context.js"; +import { invalidParameterError } from "../common/errors.js"; import { handleCommand, outputSuccess } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; -import type { AttachmentFilter } from "../gql/graphql.js"; +import type { + AttachmentCreateInput, + AttachmentFilter, +} from "../gql/graphql.js"; import { resolveIssueId } from "../resolvers/issue-resolver.js"; import { createAttachment, @@ -28,6 +32,7 @@ export const ATTACHMENTS_META: DomainMeta = { }; interface ListOptions { + issue?: string; sourceType?: string; title?: string; createdAfter?: string; @@ -35,9 +40,31 @@ interface ListOptions { } interface CreateOptions { + issue?: string; title: string; url: string; subtitle?: string; + comment?: string; + iconUrl?: string; +} + +function resolveIssueArgument( + positionalIssue: string | undefined, + optionIssue: string | undefined, +): string { + if (positionalIssue && optionIssue) { + throw invalidParameterError( + "--issue", + "cannot be combined with positional issue", + ); + } + + const issue = positionalIssue ?? optionIssue; + if (!issue) { + throw invalidParameterError("issue", "is required"); + } + + return issue; } function buildAttachmentFilter( @@ -71,8 +98,9 @@ export function setupAttachmentsCommands(program: Command): void { attachments.action(() => attachments.help()); attachments - .command("list ") + .command("list [issue]") .description("list attachments on an issue") + .option("--issue ", "issue identifier (alias for positional issue)") .option( "--source-type ", "filter by source type (e.g. github, slack)", @@ -83,12 +111,13 @@ export function setupAttachmentsCommands(program: Command): void { .action( handleCommand(async (...args: unknown[]) => { const [issue, options, command] = args as [ - string, + string | undefined, ListOptions, Command, ]; + const issueIdentifier = resolveIssueArgument(issue, options.issue); const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); + const issueId = await resolveIssueId(ctx.sdk, issueIdentifier); const filter = buildAttachmentFilter(options); const result = await listAttachments(ctx.gql, issueId, filter); outputSuccess(result); @@ -96,26 +125,33 @@ export function setupAttachmentsCommands(program: Command): void { ); attachments - .command("create ") + .command("create [issue]") .description("create an attachment on an issue") + .option("--issue ", "issue identifier (alias for positional issue)") .requiredOption("--title ", "attachment title") .requiredOption("--url <url>", "attachment URL") .option("--subtitle <text>", "attachment subtitle") + .option("--comment <text>", "comment body to create with the attachment") + .option("--icon-url <url>", "attachment icon URL") .action( handleCommand(async (...args: unknown[]) => { const [issue, options, command] = args as [ - string, + string | undefined, CreateOptions, Command, ]; + const issueIdentifier = resolveIssueArgument(issue, options.issue); const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); - const result = await createAttachment(ctx.gql, { + const issueId = await resolveIssueId(ctx.sdk, issueIdentifier); + const input: AttachmentCreateInput = { issueId, title: options.title, url: options.url, ...(options.subtitle && { subtitle: options.subtitle }), - }); + ...(options.comment && { commentBody: options.comment }), + ...(options.iconUrl && { iconUrl: options.iconUrl }), + }; + const result = await createAttachment(ctx.gql, input); outputSuccess(result); }), ); diff --git a/src/commands/documents.ts b/src/commands/documents.ts index 8cc75a12..b3739c3d 100644 --- a/src/commands/documents.ts +++ b/src/commands/documents.ts @@ -1,21 +1,18 @@ import type { Command } from "commander"; import { createContext, getRootOpts } from "../common/context.js"; +import { invalidParameterError } from "../common/errors.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; -import type { DocumentUpdateInput } from "../gql/graphql.js"; +import type { DocumentFilter, DocumentUpdateInput } from "../gql/graphql.js"; import { resolveIssueId } from "../resolvers/issue-resolver.js"; import { resolveProjectId } from "../resolvers/project-resolver.js"; import { resolveTeamId } from "../resolvers/team-resolver.js"; -import { - createAttachment, - listAttachments, -} from "../services/attachment-service.js"; +import { listAttachments } from "../services/attachment-service.js"; import { createDocument, deleteDocument, getDocument, listDocuments, - listDocumentsBySlugIds, updateDocument, } from "../services/document-service.js"; @@ -27,6 +24,7 @@ interface DocumentCreateOptions { icon?: string; color?: string; issue?: string; + attachTo?: string; } interface DocumentUpdateOptions { @@ -66,11 +64,30 @@ export function extractDocumentIdFromUrl(url: string): string | null { return docSlug.substring(lastHyphenIndex + 1) || null; } catch { - // URL constructor throws on malformed input — treat as unresolvable + // URL constructor throws on malformed input — treat as unresolvable. return null; } } +function buildIssueDocumentFilter( + issueId: string, + legacyDocumentSlugIds: string[], +): DocumentFilter { + const issueFilter: DocumentFilter = { issue: { id: { eq: issueId } } }; + if (legacyDocumentSlugIds.length === 0) { + return issueFilter; + } + + return { + or: [ + issueFilter, + ...legacyDocumentSlugIds.map((slugId) => ({ + slugId: { eq: slugId }, + })), + ], + }; +} + export const DOCUMENTS_META: DomainMeta = { name: "documents", summary: "long-form markdown docs attached to projects or issues", @@ -115,48 +132,35 @@ export function setupDocumentsCommands(program: Command): void { const limit = parseLimit(options.limit || "50"); + let projectId: string | undefined; + if (options.project) { + projectId = await resolveProjectId(ctx.sdk, options.project); + } + + let issueId: string | undefined; if (options.issue) { - const issueId = await resolveIssueId(ctx.sdk, options.issue); - const attachments = await listAttachments(ctx.gql, issueId); + issueId = await resolveIssueId(ctx.sdk, options.issue); + } - const documentSlugIds = [ + let filter: DocumentFilter | undefined; + if (projectId) { + filter = { project: { id: { eq: projectId } } }; + } else if (issueId) { + const attachments = await listAttachments(ctx.gql, issueId); + const legacyDocumentSlugIds = [ ...new Set( attachments .map((att) => extractDocumentIdFromUrl(att.url)) .filter((id): id is string => id !== null), ), ]; - - if (documentSlugIds.length === 0) { - outputSuccess({ - nodes: [], - pageInfo: { hasNextPage: false, endCursor: null }, - }); - return; - } - - const documents = await listDocumentsBySlugIds( - ctx.gql, - documentSlugIds, - ); - outputSuccess({ - nodes: documents, - pageInfo: { hasNextPage: false, endCursor: null }, - }); - return; - } - - let projectId: string | undefined; - if (options.project) { - projectId = await resolveProjectId(ctx.sdk, options.project); + filter = buildIssueDocumentFilter(issueId, legacyDocumentSlugIds); } const documents = await listDocuments(ctx.gql, { limit, after: options.after, - filter: projectId - ? { project: { id: { eq: projectId } } } - : undefined, + filter, }); outputSuccess(documents); @@ -187,9 +191,18 @@ export function setupDocumentsCommands(program: Command): void { .option("--icon <icon>", "document icon") .option("--color <color>", "icon color") .option("--issue <issue>", "also attach document to issue (e.g., ABC-123)") + .option("--attach-to <issue>", "alias for --issue") .action( handleCommand(async (...args: unknown[]) => { const [options, command] = args as [DocumentCreateOptions, Command]; + if (options.issue && options.attachTo) { + throw invalidParameterError( + "--attach-to", + "cannot be combined with --issue", + ); + } + + const issueIdentifier = options.issue ?? options.attachTo; const rootOpts = getRootOpts(command); const ctx = createContext(rootOpts); @@ -199,36 +212,20 @@ export function setupDocumentsCommands(program: Command): void { const teamId = options.team ? await resolveTeamId(ctx.sdk, options.team) : undefined; + const issueId = issueIdentifier + ? await resolveIssueId(ctx.sdk, issueIdentifier) + : undefined; const document = await createDocument(ctx.gql, { title: options.title, content: options.content, projectId, teamId, + issueId, icon: options.icon, color: options.color, }); - if (options.issue) { - const issueId = await resolveIssueId(ctx.sdk, options.issue); - - try { - await createAttachment(ctx.gql, { - issueId, - url: document.url, - title: document.title, - }); - } catch (attachError) { - const errorMessage = - attachError instanceof Error - ? attachError.message - : String(attachError); - throw new Error( - `Document created (${document.id}) but failed to attach to issue "${options.issue}": ${errorMessage}.`, - ); - } - } - outputSuccess(document); }), ); diff --git a/tests/unit/commands/attachments.test.ts b/tests/unit/commands/attachments.test.ts index 89cc47f0..4d9d702d 100644 --- a/tests/unit/commands/attachments.test.ts +++ b/tests/unit/commands/attachments.test.ts @@ -72,6 +72,53 @@ describe("attachments list", () => { ); }); + it("accepts --issue as an alias for the issue argument", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "attachments", + "list", + "--issue", + "ENG-42", + ]); + + expect(resolveIssueId).toHaveBeenCalledWith(expect.anything(), "ENG-42"); + expect(listAttachments).toHaveBeenCalledWith( + expect.anything(), + "resolved-issue-uuid", + undefined, + ); + }); + + it("rejects combining positional issue and --issue", async () => { + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); + vi.spyOn(console, "error").mockImplementation(() => {}); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "attachments", + "list", + "ENG-42", + "--issue", + "ENG-43", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining( + "Invalid --issue: cannot be combined with positional issue", + ), + ); + expect(listAttachments).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + + exitSpy.mockRestore(); + }); + it("passes source-type filter", async () => { const program = createProgram(); await program.parseAsync([ @@ -178,6 +225,59 @@ describe("attachments create", () => { }), ); }); + + it("accepts --issue as an alias for the issue argument", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "attachments", + "create", + "--issue", + "ENG-42", + "--title", + "My PR", + "--url", + "https://github.com/org/repo/pull/1", + ]); + + expect(resolveIssueId).toHaveBeenCalledWith(expect.anything(), "ENG-42"); + expect(createAttachment).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + issueId: "resolved-issue-uuid", + title: "My PR", + url: "https://github.com/org/repo/pull/1", + }), + ); + }); + + it("passes optional comment and icon URL", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "attachments", + "create", + "ENG-42", + "--title", + "Build", + "--url", + "https://ci.example.com/build/1", + "--comment", + "Build is green", + "--icon-url", + "https://ci.example.com/icon.png", + ]); + + expect(createAttachment).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + commentBody: "Build is green", + iconUrl: "https://ci.example.com/icon.png", + }), + ); + }); }); describe("attachments delete", () => { diff --git a/tests/unit/commands/documents.test.ts b/tests/unit/commands/documents.test.ts new file mode 100644 index 00000000..8f779619 --- /dev/null +++ b/tests/unit/commands/documents.test.ts @@ -0,0 +1,218 @@ +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../../src/common/context.js", () => ({ + createContext: vi.fn(() => ({ + gql: { request: vi.fn() }, + sdk: { sdk: {} }, + })), + getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), +})); + +vi.mock("../../../src/common/output.js", async (importOriginal) => { + const actual = + await importOriginal<typeof import("../../../src/common/output.js")>(); + return { + ...actual, + outputSuccess: vi.fn(), + }; +}); + +vi.mock("../../../src/resolvers/issue-resolver.js", () => ({ + resolveIssueId: vi.fn().mockResolvedValue("resolved-issue-uuid"), +})); + +vi.mock("../../../src/resolvers/project-resolver.js", () => ({ + resolveProjectId: vi.fn().mockResolvedValue("resolved-project-uuid"), +})); + +vi.mock("../../../src/resolvers/team-resolver.js", () => ({ + resolveTeamId: vi.fn().mockResolvedValue("resolved-team-uuid"), +})); + +vi.mock("../../../src/services/attachment-service.js", () => ({ + listAttachments: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../../../src/services/document-service.js", () => ({ + createDocument: vi.fn().mockResolvedValue({ + id: "doc-1", + title: "Runbook", + url: "https://linear.app/example/document/runbook-abc123", + }), + deleteDocument: vi.fn().mockResolvedValue({ id: "doc-1", success: true }), + getDocument: vi.fn().mockResolvedValue({ id: "doc-1", title: "Runbook" }), + listDocuments: vi.fn().mockResolvedValue({ + nodes: [{ id: "doc-1", title: "Runbook" }], + pageInfo: { hasNextPage: false, endCursor: null }, + }), + updateDocument: vi.fn().mockResolvedValue({ id: "doc-1", title: "Runbook" }), +})); + +import { setupDocumentsCommands } from "../../../src/commands/documents.js"; +import { resolveIssueId } from "../../../src/resolvers/issue-resolver.js"; +import { resolveTeamId } from "../../../src/resolvers/team-resolver.js"; +import { listAttachments } from "../../../src/services/attachment-service.js"; +import { + createDocument, + listDocuments, +} from "../../../src/services/document-service.js"; + +function createProgram(): Command { + const program = new Command(); + program.option("--api-token <token>"); + setupDocumentsCommands(program); + return program; +} + +describe("documents list", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + it("uses the document issue filter for --issue", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "documents", + "list", + "--issue", + "ENG-42", + ]); + + expect(resolveIssueId).toHaveBeenCalledWith(expect.anything(), "ENG-42"); + expect(listDocuments).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + filter: { issue: { id: { eq: "resolved-issue-uuid" } } }, + }), + ); + }); + + it("includes legacy document URL attachments in the issue filter", async () => { + vi.mocked(listAttachments).mockResolvedValueOnce([ + { + id: "att-1", + title: "Runbook", + subtitle: null, + url: "https://linear.app/example/document/runbook-abc123", + sourceType: null, + metadata: {}, + source: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ]); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "documents", + "list", + "--issue", + "ENG-42", + ]); + + expect(listDocuments).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + filter: { + or: [ + { issue: { id: { eq: "resolved-issue-uuid" } } }, + { slugId: { eq: "abc123" } }, + ], + }, + }), + ); + }); +}); + +describe("documents create", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + it("passes issueId directly when --issue is provided", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "documents", + "create", + "--title", + "Runbook", + "--issue", + "ENG-42", + ]); + + expect(resolveIssueId).toHaveBeenCalledWith(expect.anything(), "ENG-42"); + expect(createDocument).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + title: "Runbook", + issueId: "resolved-issue-uuid", + }), + ); + }); + + it("accepts --attach-to as an alias for --issue", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "documents", + "create", + "--title", + "Runbook", + "--team", + "ENG", + "--attach-to", + "ENG-42", + ]); + + expect(resolveTeamId).toHaveBeenCalledWith(expect.anything(), "ENG"); + expect(resolveIssueId).toHaveBeenCalledWith(expect.anything(), "ENG-42"); + expect(createDocument).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + teamId: "resolved-team-uuid", + issueId: "resolved-issue-uuid", + }), + ); + }); + + it("rejects combining --issue and --attach-to before creating", async () => { + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); + vi.spyOn(console, "error").mockImplementation(() => {}); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "documents", + "create", + "--title", + "Runbook", + "--issue", + "ENG-42", + "--attach-to", + "ENG-43", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining( + "Invalid --attach-to: cannot be combined with --issue", + ), + ); + expect(createDocument).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + + exitSpy.mockRestore(); + }); +}); From eb4fdba3163cb5fc3ffdf5c1b854cfbda4b5083c Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:49:37 +0200 Subject: [PATCH 13/79] refactor(documents): remove orphaned listDocumentsBySlugIds helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The document list --issue path now filters via the native document→issue relation (with a legacy slugId OR branch), leaving listDocumentsBySlugIds without any production caller. Drop the dead service function and its tests. Refs #234 --- src/services/document-service.ts | 21 ------------------- tests/unit/services/document-service.test.ts | 22 -------------------- 2 files changed, 43 deletions(-) diff --git a/src/services/document-service.ts b/src/services/document-service.ts index 98c5d6ec..ad801e85 100644 --- a/src/services/document-service.ts +++ b/src/services/document-service.ts @@ -96,27 +96,6 @@ export async function listDocuments( }; } -export async function listDocumentsBySlugIds( - client: GraphQLClient, - slugIds: string[], -): Promise<DocumentListItem[]> { - if (slugIds.length === 0) { - return []; - } - - const result = await client.request<ListDocumentsQuery>( - ListDocumentsDocument, - { - first: slugIds.length, - filter: { - slugId: { in: slugIds }, - }, - }, - ); - - return result.documents?.nodes ?? []; -} - export async function deleteDocument( client: GraphQLClient, id: string, diff --git a/tests/unit/services/document-service.test.ts b/tests/unit/services/document-service.test.ts index d7dff3cb..899aeafa 100644 --- a/tests/unit/services/document-service.test.ts +++ b/tests/unit/services/document-service.test.ts @@ -6,7 +6,6 @@ import { deleteDocument, getDocument, listDocuments, - listDocumentsBySlugIds, updateDocument, } from "../../../src/services/document-service.js"; @@ -131,27 +130,6 @@ describe("listDocuments", () => { }); }); -describe("listDocumentsBySlugIds", () => { - it("returns empty array for empty input", async () => { - const client = mockGqlClient({}); - const result = await listDocumentsBySlugIds(client, []); - expect(result).toEqual([]); - }); - - it("returns documents matching slugIds", async () => { - const client = mockGqlClient({ - documents: { - nodes: [ - { id: "1", slugId: "abc" }, - { id: "2", slugId: "def" }, - ], - }, - }); - const result = await listDocumentsBySlugIds(client, ["abc", "def"]); - expect(result).toHaveLength(2); - }); -}); - describe("deleteDocument", () => { it("returns id and success on success", async () => { const client = mockGqlClient({ From 89245c92db16d92307bb53080654bc665c2fbfe9 Mon Sep 17 00:00:00 2001 From: semantic-release-bot <semantic-release-bot@martynus.net> Date: Thu, 2 Jul 2026 11:07:40 +0000 Subject: [PATCH 14/79] chore(release): 2026.6.0-next.5 [skip ci] ## [2026.6.0-next.5](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.4...v2026.6.0-next.5) (2026-07-02) ### Features * restore document attachment compatibility ([f096add](https://github.com/linearis-oss/linearis/commit/f096adda7aa20c946a25c7c06fefec56d9684975)) --- 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 988f86f4..2753a64c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [2026.6.0-next.5](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.4...v2026.6.0-next.5) (2026-07-02) + +### Features + +* restore document attachment compatibility ([f096add](https://github.com/linearis-oss/linearis/commit/f096adda7aa20c946a25c7c06fefec56d9684975)) + ## [2026.6.0-next.4](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.3...v2026.6.0-next.4) (2026-07-02) ### Features diff --git a/package-lock.json b/package-lock.json index de889eea..8829c11b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linearis", - "version": "2026.6.0-next.4", + "version": "2026.6.0-next.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.6.0-next.4", + "version": "2026.6.0-next.5", "license": "MIT", "dependencies": { "@linear/sdk": "82.1.0", diff --git a/package.json b/package.json index c6737cce..b566a5b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.6.0-next.4", + "version": "2026.6.0-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 e42a52b81efca7f0d71d6d2da6261e0ade264505 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:05:04 +0200 Subject: [PATCH 15/79] ci(conductor): add shared Conductor settings.toml Configure setup, run, and archive scripts for Conductor workspaces: npm install setup (runs codegen + lefthook via prepare), concurrent run_mode, npm run clean archive, and test/build/check run scripts. --- .conductor/settings.toml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .conductor/settings.toml diff --git a/.conductor/settings.toml b/.conductor/settings.toml new file mode 100644 index 00000000..1c893dff --- /dev/null +++ b/.conductor/settings.toml @@ -0,0 +1,23 @@ +#:schema https://conductor.build/schemas/settings.repo.schema.json +"$schema" = "https://conductor.build/schemas/settings.repo.schema.json" + +[scripts] +# `npm install` also runs the `prepare` hook: GraphQL codegen (src/gql/) + lefthook install. +setup = "npm install" +# Stateless JSON-output CLI — no shared port/db/stack — so workspaces can run side by side. +run_mode = "concurrent" +# Remove per-workspace build output on archive; leaves source untouched. +archive = "npm run clean" + +[scripts.run.test] +command = "npm test" +default = true +icon = "test-tube" + +[scripts.run.build] +command = "npm run build" +icon = "package" + +[scripts.run.check] +command = "npm run check:ci" +icon = "wrench" From 43a9e1b9ecb8c3cd59afd3065573589c993af77e Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:34:44 +0200 Subject: [PATCH 16/79] chore: introduce knip dead-code detection Add knip to detect unused files, exports, types, and dependencies, and remove the dead code it surfaced. - Add knip.json (ignore generated src/gql, semantic-release plugins used only in .releaserc.cjs) plus knip/knip:ci npm scripts. - Add a required "Detect Dead Code" PR job that posts a self-updating sticky comment and fails on findings. - Remove dead code: extractEmbeds/EmbedInfo/stripCodeContexts from embed-parser, and un-export extractDocumentIdFromUrl, PageInfo, and declaredVariableNames (used only within their own files). - Drop unused @graphql-codegen/introspection and schema-ast dev deps. - Document the workflow in AGENTS.md and docs, and fix the stale dependency table in docs/build-system.md. --- .github/workflows/ci-validate.yml | 73 ++ AGENTS.md | 9 +- docs/build-system.md | 2 - docs/development.md | 26 + knip.json | 10 + package-lock.json | 1178 +++++++++++++++++++++--- package.json | 23 +- src/commands/documents.ts | 2 +- src/common/embed-parser.ts | 53 -- src/common/types.ts | 2 +- tests/unit/helpers/assert-variables.ts | 2 +- 11 files changed, 1176 insertions(+), 204 deletions(-) create mode 100644 knip.json diff --git a/.github/workflows/ci-validate.yml b/.github/workflows/ci-validate.yml index b5562e04..016089c1 100644 --- a/.github/workflows/ci-validate.yml +++ b/.github/workflows/ci-validate.yml @@ -77,6 +77,79 @@ jobs: - name: TypeScript type check run: npx tsc --noEmit + knip: + name: Detect Dead Code + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup node v22 + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: "npm" + + - name: Install deps + run: npm ci + + # Build runs codegen (prebuild) so src/gql exists and imports resolve. + - name: Build project + run: npm run build + + - name: Run knip + id: knip + run: | + set +e + npm run --silent knip:ci > knip-report.md + echo "exit_code=$?" >> "$GITHUB_OUTPUT" + set -e + + - name: Compose PR comment + if: always() + run: | + if [ "${{ steps.knip.outputs.exit_code }}" = "0" ]; then + { + echo '## ✅ knip — no dead code' + echo + echo 'No unused files, exports, types, or dependencies detected.' + } > knip-comment.md + else + { + echo '## 🧹 knip found dead code' + echo + cat knip-report.md + echo + echo '### Fix it locally' + echo + echo '```bash' + echo 'npm run generate # ensure generated GraphQL types exist' + echo 'npm run knip # see the full report' + echo 'npx knip --fix --allow-remove-files # auto-remove unused exports/files, then review the diff' + echo '```' + echo + echo 'If a finding is a false positive (dynamically-wired code knip cannot trace), add a narrow entry to `knip.json` explaining why.' + } > knip-comment.md + fi + + - name: Post / update PR comment + if: always() + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: knip-dead-code + path: knip-comment.md + + - name: Fail if dead code found + if: steps.knip.outputs.exit_code != '0' + run: | + echo "::error::knip found dead code — see the PR comment for details" + exit 1 + commitlint: name: Validate Commit Messages runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 82724d7a..eb504b6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -218,9 +218,16 @@ npm run check:ci # biome lint + format check npx tsc --noEmit # type check npm test # unit tests npm run build # full build (includes codegen + usage generation) +npm run knip # dead-code check (unused files/exports/types/deps) ``` -All four must pass. CI runs these on every push and PR. +All five must pass. CI runs the first four on every push and PR; the `knip` +check runs on PRs only, where it is required — it posts a self-updating comment +listing any dead code, and `npx knip --fix --allow-remove-files` auto-removes +most findings. Run +`npm run generate` (or `npm run build`) first so generated GraphQL types exist. +Genuine false positives (dynamically-wired code knip cannot trace) are suppressed +in `knip.json`, not left unaddressed. ## Extended Documentation diff --git a/docs/build-system.md b/docs/build-system.md index e3706ebe..092cb10f 100644 --- a/docs/build-system.md +++ b/docs/build-system.md @@ -144,8 +144,6 @@ npm run test:commands # Run command coverage analysis |---|---|---| | `@graphql-codegen/cli` | ^6.1.1 | GraphQL code generation CLI | | `@graphql-codegen/client-preset` | ^5.2.2 | Typed document node generation | -| `@graphql-codegen/introspection` | 5.0.0 | Schema introspection plugin | -| `@graphql-codegen/schema-ast` | ^5.0.0 | Schema AST generation | | `@types/node` | ^22.0.0 | Node.js type definitions | | `@vitest/coverage-v8` | ^2.1.8 | V8-based code coverage | | `@vitest/ui` | ^2.1.8 | Browser-based test UI | diff --git a/docs/development.md b/docs/development.md index 21308cec..b733ca62 100644 --- a/docs/development.md +++ b/docs/development.md @@ -319,6 +319,32 @@ A typical feature addition touches four layers. Here is the sequence: | `npm run test:coverage` | Run tests with coverage | | `npm run test:commands` | Check command coverage | | `npm run generate` | Regenerate GraphQL types | +| `npm run knip` | Detect dead code (unused files, exports, types, deps) | + +## Dead-Code Checks (knip) + +[knip](https://knip.dev) finds unused files, exports, exported types, and +dependencies. It runs as a required PR check (the `knip` job in +`ci-validate.yml`), which posts a single self-updating comment with any findings +and fails the check until they are resolved. + +```bash +npm run generate # ensure generated GraphQL types exist first +npm run knip # report dead code +npx knip --fix --allow-remove-files # auto-remove unused exports/files, then review the diff +``` + +Configuration lives in `knip.json`: + +- `src/gql/**` is ignored — it is generated by codegen and must never be + dead-code-linted (mirrors the Biome ignore in `biome.json`). +- A few `@semantic-release/*` plugins are listed under `ignoreDependencies` + because they are referenced only in `.releaserc.cjs` (knip's semantic-release + plugin cannot fully trace them, but they are genuinely used at release time). + +When a finding is a real false positive — dynamically-wired code knip cannot +trace — add a narrow entry to `knip.json` explaining why, rather than deleting +live code or leaving the check red. ## Project Structure diff --git a/knip.json b/knip.json new file mode 100644 index 00000000..39a1645f --- /dev/null +++ b/knip.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://unpkg.com/knip@6/schema.json", + "project": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs"], + "ignore": ["src/gql/**"], + "ignoreDependencies": [ + "@semantic-release/github", + "@semantic-release/npm", + "@semantic-release/release-notes-generator" + ] +} diff --git a/package-lock.json b/package-lock.json index 8829c11b..daae0c1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,8 +23,6 @@ "@commitlint/config-conventional": "^20.4.1", "@graphql-codegen/cli": "^6.1.1", "@graphql-codegen/client-preset": "^5.2.2", - "@graphql-codegen/introspection": "5.0.2", - "@graphql-codegen/schema-ast": "^5.0.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/exec": "^7.1.0", @@ -36,6 +34,7 @@ "@vitest/coverage-v8": "^4.0.0", "@vitest/ui": "^4.0.0", "clean-publish": "^6.0.5", + "knip": "^6.24.0", "lefthook": "^2.1.0", "semantic-release": "^25.0.1", "tsx": "^4.20.5", @@ -1574,24 +1573,6 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, - "node_modules/@graphql-codegen/introspection": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@graphql-codegen/introspection/-/introspection-5.0.2.tgz", - "integrity": "sha512-2Y1xC4A/6yudxvpyHLF6wcrZSm1BBGsaxabbZJCWebImXdYNU+yAdbiiaHfYrHMUEVgPnjo/qo4gt0m8JqeRHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.3.0", - "@graphql-codegen/visitor-plugin-common": "^6.3.0", - "tslib": "^2.8.0" - }, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" - } - }, "node_modules/@graphql-codegen/plugin-helpers": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-6.3.0.tgz", @@ -2685,14 +2666,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -2776,137 +2757,866 @@ "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", "dev": true, "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" - } + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT" + }, + "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/@octokit/plugin-retry": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-8.1.0.tgz", + "integrity": "sha512-O1FZgXeiGb2sowEr/hYTr6YunGdSAFWnr2fyW39Ah85H8O33ELASQxcvOFF5LE6Tjekcyu2ms4qAzJVhSaJxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=7" + } + }, + "node_modules/@octokit/plugin-throttling": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-11.0.3.tgz", + "integrity": "sha512-34eE0RkFCKycLl2D2kq7W+LovheM/ex3AwZCYN8udpi6bxsyjZidb2McXs69hZhLmJlDqTSP8cH+jSRpiaijBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": "^7.0.0" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.8.tgz", + "integrity": "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.3", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "fast-content-type-parse": "^3.0.0", + "json-with-bigint": "^3.5.3", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.137.0.tgz", + "integrity": "sha512-KDs+0VPdEmasOkpuJHW9V5WCF+cvYdMQv2Jd+aJXt+cxIx12NToRQRbXaRwUEDsZw+/jMk81Ve8ZFbjUkJTOwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.137.0.tgz", + "integrity": "sha512-WhALNzfy3x/RfC6bsqX+csavuUY0yHHE7XfgPE5M542uhoBZUUoGTPG+nkMbGoG4+gcfss5s7urMyn5QBHu0sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.137.0.tgz", + "integrity": "sha512-bFPr5hgmNMOMoyPTGtdsK4Ug21RovIPojRMgDDhSp1LtCnc/DkLwGONKjgRjszg677RlGnkYSviQ8hHaUPOVYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.137.0.tgz", + "integrity": "sha512-CL5dMm1asqXIDZHg14FLxj3Mc36w8PI7xCWh1uA4is6z8g2XrIILoTcQYOxDbwzuk34RDPX5IAGUxZr6LA9KAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.137.0.tgz", + "integrity": "sha512-79h8rYGnSlKPGWo7mHr2ixO6ea7aW8B0CT965SZ8SLbNnCOH5aOYBTeVXUY6eMvEaiLyWr8Skuiugr5pDYgLGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.137.0.tgz", + "integrity": "sha512-ASgmlSimhGyr0lksgVIo6hibz1obnDq4qJbiMX/AzltfgPnanRrzG1Q+23g8ljOHOjv6dsznkUuCYL3gg0sY1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.137.0.tgz", + "integrity": "sha512-AU2J9aa22Sx32wRGnDjybOU9TQXXQUud5sdUi+ZB0XxwM8aToWLweV+yA0wlQm0yIUVqljquqoHCYEq9II8gJQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.137.0.tgz", + "integrity": "sha512-GdEtiG89yMr7XkUGxifgodXEEm2f+xW2f9CpDjlgAnBOwhTmrpQMvhOGobLVKUyzf/qHBXW16smk5zbF3nZU6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.137.0.tgz", + "integrity": "sha512-EGJ+Bs8iXx8KBH8DQ5BLoEm5lnHaYjlh4/8j8vFhrr/6z4tqONy5BZDzLpKmmNWlN6Hlc5r8YOuBVHqZ9vRFEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.137.0.tgz", + "integrity": "sha512-vzFUQENy/fnbSe5DZWovq6tIBc1uhuMztanSW6rz1e9WdQE4gHwYuD7ZII6JnrJifd1R3RSoqiZbgRFlVL2tYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.137.0.tgz", + "integrity": "sha512-SfVI14HBQs9gtLcUD5hTt5hsNbdrqSUNg9S8muN+LhVQ5nf1WwH3hAoK6B9NKgdYgWAQSXFXGiiBedQ4r/BKuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.137.0.tgz", + "integrity": "sha512-e7Ppy4FCIFNQxT/ikSeIWFoQ0l+N9vgtRBtLcyZXeolTzApyVoPqEXsYPrcdM/9i0Bwk8knvYd37vaEMxHyi6g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.137.0.tgz", + "integrity": "sha512-Bho5qFwdhqsIFR7gipYEUlqvi3SRrY8sugxXig380MIaakBB1PyU9+7dBiBVScfImTNWhijUxdBwqrprGdq5WA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.137.0.tgz", + "integrity": "sha512-36mGWtg7PyFzjJwGDkH6/F4o2nIDEoKXLPr/X/lwqklkomQwJJt1I5GJVmGhovUEmgPK5WAeAZMqlFCehwiy9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.137.0.tgz", + "integrity": "sha512-/Jqx6+N7A44n2BdvUr7pXhVr2vFjs6WGH3unZRczwrfiH0H1zY0QwKQMG/dtRiTlKGDKGukznPT8lx84/oEsZg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.137.0.tgz", + "integrity": "sha512-9Uj0qHNNl+OgT1UTGwF7ixIXU6T1u2SbMidmgPy/h1h/fl2gRS6YpAxxY1gwHofcWjoTwkoMFd8xs5Vuj6GOFA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.137.0.tgz", + "integrity": "sha512-gW2vfkytNGgMVADiuzdvOfw0mWG9za20F/1fCJsif5aBMAvWJTSbpIXbIe0XkOe0VENk+PadpQ7cZgUy2sUJcA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.5" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.137.0.tgz", + "integrity": "sha512-x+pFANF0yL5uK/6T7lu6SlR5qid6sp//eZXKLq5iNsIE+EQg6EaS8/wsW7E91nXXjpnPhSoMOHXShSVhGRdn8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.137.0.tgz", + "integrity": "sha512-sQUqym80PFi6McRsIqfJrSu2JrSClEZIXXD+/FjAFoULEKzOPsldIdFBG96xdX8aVMzCNQ9792FPx3MfkEIrFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.137.0.tgz", + "integrity": "sha512-2AsevxlvNN4WKxpEn3RtqD5zbqMaXF+T7JXblsP4gVuY+vC9dXS4ED/PwfRCliFqoeisYS3Iro4DHzxr0TEvVA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.21.3.tgz", + "integrity": "sha512-eNU11A2WNizh04v3uyaJCootrHIaS0B9aHYXvAvVnPNk4xYSjMUjHnhQ6dewPN2MRYDskV85d1N0Aw0WNWhcyg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.21.3.tgz", + "integrity": "sha512-8Q+ZjTLvn2dIcWsrmhdrEihm7q+ag/k+mkry7Z+t0QbbHaVxXQfvH9AewyVMh/WrpEKhQ3DDgx9fYbqeCpeOEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.21.3.tgz", + "integrity": "sha512-wkh0qKZGHXVUDxFw3oA1TXnU2BDYY/r775oJflGeIr8uDPPoN2pk8gijQIzYRT6hoql/lg3+Tx/SaTn9e2/aGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.21.3.tgz", + "integrity": "sha512-HbNc23FAQYbuyDV2vBWMez4u4mrsm5RAkniGZAWqr6lYZ3N4beeqIb776jzwRl8qL2zRhHVXpUj97X0QgogVzg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.21.3.tgz", + "integrity": "sha512-K6xNsTUPEUdfrn0+kbMq5nOUB5w1C5pavPQngt4TM2FpN91lP0PBe2srSpamb4d69O7h86oAi/qWX/kZNRSjkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.21.3.tgz", + "integrity": "sha512-VcFmOpcpWX1zoEy8M58tR2M9YxM+Z9RuQhqAx5q0CTmrruaP7Gveejg75hzd/5sg5nk9G3aLALEa3hE2FsmmTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.21.3.tgz", + "integrity": "sha512-quVoxFLBy43hWaQbbDtQNRwAX5vX76mv7n64icAtQcJ3eNgVeblqmkupF/hAneNthdqSlnd1sTjb3aQSaDPaCQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.21.3.tgz", + "integrity": "sha512-X0AqNZgcD07Q4V3RDK18/vYOj/HQT/FnmEFGYS2jTWqY7JO13ryE3TEs3eAIgUJhBnNkpEaiXqz3VK8M7qQhWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.21.3.tgz", + "integrity": "sha512-YkaQnaKYdbuaXvRt5Qd0GpbihzVnyfR6z1SpYfIUC6RTu4NF7lDKPjVkYb+jRI2gedVO2rVpN35Y6akG6ud4Lw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.21.3.tgz", + "integrity": "sha512-gB9HwhrPiFqUzDeEq+y/CgAijz1YdI6BnXz5GaH2Pa9cWdutchlkGFAiAuGb/PjVQpiK6NFKzFuztxrweoit7A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.21.3.tgz", + "integrity": "sha512-zjDWBlYk8QGv0H8dsPUWqkfjYIIjG2TvspGkzXL0eImbgxtZorA/klKeHyolevoT3Kvbi+1iMr9Lhrh7jf54Og==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.21.3.tgz", + "integrity": "sha512-4UfsQvacV388y1zpXL7C1x1FNYaV52JtuNRiuzrfQA2z1z6ElVrsidkGsrvQ5EgeSq1Pj7kaKqrgGkvFuxJ/tw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.21.3.tgz", + "integrity": "sha512-b5uH+HKH0MP5mNBYaK75SKsJbw52URqrx2LavYdq6wb0l3ExAG5niYRP9DWUNHdKilpaBVM2bXk9HNWrH3ew7Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@octokit/graphql": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", - "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.21.3.tgz", + "integrity": "sha512-PjYlmilBpNRh2ntXNYAK3Am5w/nPfEpnU/96iNx7CI8EzAn12J4JRiec63wHJTH31nLoCNxBg/829pN+3CfG3Q==", + "cpu": [ + "x64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "@octokit/request": "^10.0.6", - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 20" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@octokit/openapi-types": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", - "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.21.3.tgz", + "integrity": "sha512-QTBAb7JuHlZ7JUEyM8UiQi2f7m/L4swBhP2TNpYIDc9Wp/wRw1G/8sl6i13aIzQAXH7LKIm294LeOHd0lQR8zA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "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==", + "node_modules/@oxc-resolver/binding-openharmony-arm64": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.21.3.tgz", + "integrity": "sha512-4j1DFwjwv36ec9kds0jU/ucQ5Ha4ERO/H95BxR5JFf0kqUUAJ1kwII7XhTc1vZrkdJkvLGC9Q2MbpObpum8RBg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0" - }, - "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "@octokit/core": ">=6" - } + "optional": true, + "os": [ + "openharmony" + ] }, - "node_modules/@octokit/plugin-retry": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-8.1.0.tgz", - "integrity": "sha512-O1FZgXeiGb2sowEr/hYTr6YunGdSAFWnr2fyW39Ah85H8O33ELASQxcvOFF5LE6Tjekcyu2ms4qAzJVhSaJxTw==", + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.21.3.tgz", + "integrity": "sha512-i8oluoel5kru/j1WNrjmQSiA3GQ7wvIYVR1IwIoZtKogAhya2iub+ZKIeSIkcJOrnzQ18Tzl/F+kL3fYOxZLvA==", + "cpu": [ + "wasm32" + ], "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "bottleneck": "^2.15.3" + "@emnapi/core": "1.11.0", + "@emnapi/runtime": "1.11.0", + "@napi-rs/wasm-runtime": "^1.1.5" }, "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "@octokit/core": ">=7" + "node": ">=14.0.0" } }, - "node_modules/@octokit/plugin-throttling": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-11.0.3.tgz", - "integrity": "sha512-34eE0RkFCKycLl2D2kq7W+LovheM/ex3AwZCYN8udpi6bxsyjZidb2McXs69hZhLmJlDqTSP8cH+jSRpiaijBg==", + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz", + "integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@octokit/types": "^16.0.0", - "bottleneck": "^2.15.3" - }, - "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "@octokit/core": "^7.0.0" + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" } }, - "node_modules/@octokit/request": { - "version": "10.0.8", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.8.tgz", - "integrity": "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==", + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz", + "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@octokit/endpoint": "^11.0.3", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "fast-content-type-parse": "^3.0.0", - "json-with-bigint": "^3.5.3", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" + "tslib": "^2.4.0" } }, - "node_modules/@octokit/request-error": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", - "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@octokit/types": "^16.0.0" - }, - "engines": { - "node": ">= 20" + "tslib": "^2.4.0" } }, - "node_modules/@octokit/types": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", - "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.21.3.tgz", + "integrity": "sha512-M/8dw8dD6aOs+NlPJax401CZB9I7Aut84isQLgALGGwke4Afvw+/7yYhZb94yXf6t2sPLhQLmSmtSV+2FhsOWg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^27.0.0" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.21.3.tgz", + "integrity": "sha512-H7BCt/VnS9hnmMp42eGhZ99izSCRvlnWwy/N71K1/J8QoExwY4262Z8QiEkMDtduRJrztayDxETTckmUuAVL9Q==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", @@ -3992,9 +4702,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -5740,6 +6450,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fd-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", + "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "walk-up-path": "^4.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -5881,6 +6601,22 @@ "dev": true, "license": "ISC" }, + "node_modules/formatly": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", + "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fd-package-json": "^2.0.0" + }, + "bin": { + "formatly": "bin/index.mjs" + }, + "engines": { + "node": ">=18.3.0" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -5995,9 +6731,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", - "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", "dev": true, "license": "MIT", "dependencies": { @@ -6813,9 +7549,9 @@ } }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { @@ -6923,6 +7659,58 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/knip": { + "version": "6.24.0", + "resolved": "https://registry.npmjs.org/knip/-/knip-6.24.0.tgz", + "integrity": "sha512-PokLlgeEjLh1rAsB7ts+52wZ37HBr1nDhE6NNONwEaXdeZGCJOkP7ZlIAI2Gtu8xohquzTWy75bc/1diI9shQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/webpro" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/knip" + } + ], + "license": "ISC", + "dependencies": { + "fdir": "^6.5.0", + "formatly": "^0.3.0", + "get-tsconfig": "4.14.0", + "jiti": "^2.7.0", + "oxc-parser": "^0.137.0", + "oxc-resolver": "11.21.3", + "picomatch": "^4.0.4", + "smol-toml": "^1.6.1", + "strip-json-comments": "5.0.3", + "tinyglobby": "^0.2.17", + "unbash": "^4.0.1", + "yaml": "^2.9.0", + "zod": "^4.1.11" + }, + "bin": { + "knip": "bin/knip.js", + "knip-bun": "bin/knip-bun.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/knip/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lefthook": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.1.6.tgz", @@ -10067,6 +10855,85 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/oxc-parser": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.137.0.tgz", + "integrity": "sha512-yFImD+WLElJpLKy8llG1qe4DCmMsL18peRp8XP1JKfig/gISbJkglnpDtX2aTmAn10kZF7164HbN2H8QPsXxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.137.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.137.0", + "@oxc-parser/binding-android-arm64": "0.137.0", + "@oxc-parser/binding-darwin-arm64": "0.137.0", + "@oxc-parser/binding-darwin-x64": "0.137.0", + "@oxc-parser/binding-freebsd-x64": "0.137.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.137.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.137.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.137.0", + "@oxc-parser/binding-linux-arm64-musl": "0.137.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.137.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.137.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.137.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.137.0", + "@oxc-parser/binding-linux-x64-gnu": "0.137.0", + "@oxc-parser/binding-linux-x64-musl": "0.137.0", + "@oxc-parser/binding-openharmony-arm64": "0.137.0", + "@oxc-parser/binding-wasm32-wasi": "0.137.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.137.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.137.0", + "@oxc-parser/binding-win32-x64-msvc": "0.137.0" + } + }, + "node_modules/oxc-parser/node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/oxc-resolver": { + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.21.3.tgz", + "integrity": "sha512-2Mx3fKQz7+xgrBONjsxOgCGtMHOn38/HxMzW1I5efwXB5a4lRN0Vp40gYUJFBWJslcrvwoofTrqoTnLbwTd3pA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.21.3", + "@oxc-resolver/binding-android-arm64": "11.21.3", + "@oxc-resolver/binding-darwin-arm64": "11.21.3", + "@oxc-resolver/binding-darwin-x64": "11.21.3", + "@oxc-resolver/binding-freebsd-x64": "11.21.3", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.21.3", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.21.3", + "@oxc-resolver/binding-linux-arm64-gnu": "11.21.3", + "@oxc-resolver/binding-linux-arm64-musl": "11.21.3", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.21.3", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.21.3", + "@oxc-resolver/binding-linux-riscv64-musl": "11.21.3", + "@oxc-resolver/binding-linux-s390x-gnu": "11.21.3", + "@oxc-resolver/binding-linux-x64-gnu": "11.21.3", + "@oxc-resolver/binding-linux-x64-musl": "11.21.3", + "@oxc-resolver/binding-openharmony-arm64": "11.21.3", + "@oxc-resolver/binding-wasm32-wasi": "11.21.3", + "@oxc-resolver/binding-win32-arm64-msvc": "11.21.3", + "@oxc-resolver/binding-win32-x64-msvc": "11.21.3" + } + }, "node_modules/p-each-series": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-3.0.0.tgz", @@ -11530,6 +12397,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/smol-toml": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz", + "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/snake-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", @@ -11988,9 +12868,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -12145,6 +13025,16 @@ "node": ">=0.8.0" } }, + "node_modules/unbash": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/unbash/-/unbash-4.0.2.tgz", + "integrity": "sha512-8gwNZ29+0/3zmXw7ToIHZtg6wK37xnniRUdBt7B27xZxaxfgR5tGMaGHT0t0dLtBV9fXE7zurh0s6Z1DHVjfWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, "node_modules/unc-path-regex": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", @@ -12494,6 +13384,16 @@ } } }, + "node_modules/walk-up-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -12668,9 +13568,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "dev": true, "license": "ISC", "bin": { @@ -12750,6 +13650,16 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index b566a5b4..504a804a 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,8 @@ "lint:check": "biome lint .", "check": "biome check --write .", "check:ci": "biome check .", + "knip": "knip --no-config-hints", + "knip:ci": "knip --no-config-hints --reporter markdown", "verify:packed-binaries": "node scripts/verify-packed-binaries.mjs", "release": "npm test && npm run build && npm run verify:packed-binaries && npx clean-publish --access public", "prestart": "npm run generate", @@ -77,16 +79,6 @@ "@commitlint/config-conventional": "^20.4.1", "@graphql-codegen/cli": "^6.1.1", "@graphql-codegen/client-preset": "^5.2.2", - "@graphql-codegen/introspection": "5.0.2", - "@graphql-codegen/schema-ast": "^5.0.0", - "@types/node": "^24.0.0", - "@vitest/coverage-v8": "^4.0.0", - "@vitest/ui": "^4.0.0", - "clean-publish": "^6.0.5", - "lefthook": "^2.1.0", - "tsx": "^4.20.5", - "typescript": "^6.0.0", - "vitest": "^4.0.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/exec": "^7.1.0", @@ -94,7 +86,16 @@ "@semantic-release/github": "^12.0.0", "@semantic-release/npm": "^13.0.0", "@semantic-release/release-notes-generator": "^14.1.0", - "semantic-release": "^25.0.1" + "@types/node": "^24.0.0", + "@vitest/coverage-v8": "^4.0.0", + "@vitest/ui": "^4.0.0", + "clean-publish": "^6.0.5", + "knip": "^6.24.0", + "lefthook": "^2.1.0", + "semantic-release": "^25.0.1", + "tsx": "^4.20.5", + "typescript": "^6.0.0", + "vitest": "^4.0.0" }, "graphql": { "schema": "https://api.linear.app/graphql", diff --git a/src/commands/documents.ts b/src/commands/documents.ts index b3739c3d..f73f32da 100644 --- a/src/commands/documents.ts +++ b/src/commands/documents.ts @@ -43,7 +43,7 @@ interface DocumentListOptions { } /** Extracts slug ID from a Linear document URL (e.g. /workspace/document/title-slug-abc123 -> abc123). */ -export function extractDocumentIdFromUrl(url: string): string | null { +function extractDocumentIdFromUrl(url: string): string | null { try { const parsed = new URL(url); if (!parsed.hostname.includes("linear.app")) { diff --git a/src/common/embed-parser.ts b/src/common/embed-parser.ts index 594b6137..2a16a0e0 100644 --- a/src/common/embed-parser.ts +++ b/src/common/embed-parser.ts @@ -1,56 +1,3 @@ -export interface EmbedInfo { - label: string; - url: string; - /** ISO timestamp when the signed URL expires (1 hour from generation) */ - expiresAt: string; -} - -/** Removes code blocks and inline code to avoid extracting URLs from code examples. */ -function stripCodeContexts(content: string): string { - // Remove escaped backticks - let cleaned = content.replace(/\\`/g, ""); - - // Remove fenced code blocks (```...```) - greedy match with dotall behavior - cleaned = cleaned.replace(/```[\s\S]*?```/g, ""); - - // Remove inline code (`...`) - cleaned = cleaned.replace(/`[^`]+`/g, ""); - - return cleaned; -} - -/** Extracts Linear upload URLs from markdown image and link syntax. */ -export function extractEmbeds(content: string): EmbedInfo[] { - if (!content) { - return []; - } - - // Strip code contexts to avoid extracting URLs from code examples - const cleanedContent = stripCodeContexts(content); - - const embeds: EmbedInfo[] = []; - const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString(); - - // Match both image ![label](url) and link [label](url) syntax - const patterns = [ - /!\[([^\]]*)\]\(([^)]+)\)/g, // images - /(?<!!)\[([^\]]+)\]\(([^)]+)\)/g, // links - ]; - - for (const regex of patterns) { - for (const match of cleanedContent.matchAll(regex)) { - const label = match[1] || "file"; - const url = match[2]; - - if (isLinearUploadUrl(url)) { - embeds.push({ label, url, expiresAt }); - } - } - } - - return embeds; -} - export function isLinearUploadUrl(url: string): boolean { if (!url) { return false; diff --git a/src/common/types.ts b/src/common/types.ts index 1759ba1e..e3647e20 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -51,7 +51,7 @@ import type { } from "../gql/graphql.js"; // Pagination types -export type PageInfo = GetIssuesQuery["issues"]["pageInfo"]; +type PageInfo = GetIssuesQuery["issues"]["pageInfo"]; export interface PaginatedResult<T> { nodes: T[]; diff --git a/tests/unit/helpers/assert-variables.ts b/tests/unit/helpers/assert-variables.ts index c5b40bb0..9f0d2691 100644 --- a/tests/unit/helpers/assert-variables.ts +++ b/tests/unit/helpers/assert-variables.ts @@ -5,7 +5,7 @@ import { expect } from "vitest"; * Collect the names of every variable declared by the operation(s) in a * GraphQL document (e.g. `$projectId`, `$name` → "projectId", "name"). */ -export function declaredVariableNames(doc: DocumentNode): Set<string> { +function declaredVariableNames(doc: DocumentNode): Set<string> { const names = new Set<string>(); for (const definition of doc.definitions) { if (definition.kind !== Kind.OPERATION_DEFINITION) { From 182cfcb2cf91ae462aa9da09a43c3cfbb71c11c7 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:26:51 +0200 Subject: [PATCH 17/79] refactor(codegen): map GraphQL custom scalars to concrete types Map Linear's custom scalars (DateTime, Duration, UUID, JSON, etc.) to concrete TypeScript types and default unmapped scalars to `unknown`, eliminating the codegen `any` default. Widen the initiative date-range helper to accept the resulting nullable comparator types. Refs #199 --- codegen.config.ts | 19 +++++++++++++++++++ src/commands/initiatives/entity.ts | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/codegen.config.ts b/codegen.config.ts index c7f9486e..c1111882 100644 --- a/codegen.config.ts +++ b/codegen.config.ts @@ -9,6 +9,25 @@ const config: CodegenConfig = { presetConfig: { fragmentMasking: false, }, + config: { + // Any custom scalar reachable from our operations that is not mapped + // below falls back to `unknown` (safe) instead of the codegen default + // `any`. + defaultScalarType: "unknown", + scalars: { + DateTime: { input: "string", output: "string" }, + DateTimeOrDuration: { input: "string", output: "string" }, + TimelessDate: { input: "string", output: "string" }, + TimelessDateOrDuration: { input: "string", output: "string" }, + Duration: { input: "string | number", output: "string" }, + UUID: { input: "string", output: "string" }, + JSON: { input: "unknown", output: "unknown" }, + JSONObject: { + input: "Record<string, unknown>", + output: "Record<string, unknown>", + }, + }, + }, }, }, }; diff --git a/src/commands/initiatives/entity.ts b/src/commands/initiatives/entity.ts index 261bd13c..27ee50e8 100644 --- a/src/commands/initiatives/entity.ts +++ b/src/commands/initiatives/entity.ts @@ -311,7 +311,7 @@ function parseSortOrderNumber(value?: string): number | undefined { } function applyNullableDateRange( - target: { gte?: string; lte?: string }, + target: { gte?: string | null; lte?: string | null }, after?: string, before?: string, ): void { From 655d6f8c8f2dca0ebafb77be3af1a7ac14a12a66 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:20:13 +0200 Subject: [PATCH 18/79] ci: centralize workflow setup and pin actions to commit SHAs Extract the checkout-adjacent Node setup/install/build boilerplate repeated across every workflow into a single composite action (.github/actions/setup), with Node version sourced from a new .nvmrc. Pin all third-party actions to commit SHAs (with version comments) instead of floating tags, add an actionlint job, and run unit tests on a Node 22/24 matrix instead of 22 only. Move the prepare npm script into scripts/prepare.mjs, which skips codegen and lefthook install under CI so npm ci no longer hits the live Linear GraphQL API on every job. Drop the now-redundant renovate.json override that pinned release-publish to a minimum Node version. --- .github/actions/setup/action.yml | 47 ++++++++ .github/workflows/ci-post-merge.yml | 17 +-- .github/workflows/ci-validate.yml | 112 ++++++++---------- .../release-promote-next-to-main.yml | 4 +- .github/workflows/release-publish.yml | 18 +-- .../release-sync-main-back-to-next.yml | 4 +- .nvmrc | 1 + package.json | 2 +- renovate.json | 9 +- scripts/prepare.mjs | 29 +++++ 10 files changed, 140 insertions(+), 103 deletions(-) create mode 100644 .github/actions/setup/action.yml create mode 100644 .nvmrc create mode 100644 scripts/prepare.mjs diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 00000000..e6738a78 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,47 @@ +name: Setup Node and install +description: >- + Sets up Node (version from .nvmrc), restores the npm cache, runs `npm ci`, and + optionally builds the project. Centralizes the checkout-adjacent boilerplate + shared by every CI job. The caller must run actions/checkout first so this + local action and .nvmrc are present on disk. + +inputs: + build: + description: Whether to run `npm run build` after install (runs codegen + tsc + usage). + required: false + default: "true" + node-version: + description: >- + Explicit Node version to use, overriding .nvmrc. Leave empty to use the + project default from .nvmrc. Set by matrix jobs that test multiple versions. + required: false + default: "" + registry-url: + description: Optional npm registry URL to configure for publishing (leave empty for CI-only jobs). + required: false + default: "" + +runs: + using: composite + steps: + # node-version (when set by a matrix job) takes precedence over + # node-version-file; an empty node-version falls back to .nvmrc. + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: ${{ inputs.node-version }} + node-version-file: .nvmrc + cache: npm + registry-url: ${{ inputs.registry-url }} + + # `npm ci` runs `prepare`, which is CI-guarded (scripts/prepare.mjs) so it + # does NOT hit the live Linear GraphQL schema here. Jobs needing generated + # types must run the build step below (or `npm pack`). + - name: Install dependencies + shell: bash + run: npm ci + + - name: Build project + if: ${{ inputs.build == 'true' }} + shell: bash + run: npm run build diff --git a/.github/workflows/ci-post-merge.yml b/.github/workflows/ci-post-merge.yml index 8a2a6e5d..f1d289f6 100644 --- a/.github/workflows/ci-post-merge.yml +++ b/.github/workflows/ci-post-merge.yml @@ -15,24 +15,15 @@ concurrency: jobs: sentinel: - name: Post-merge sentinel (node v22) + name: Post-merge sentinel runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - name: Setup node v22 - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: npm - - - name: Install deps - run: npm ci - - - name: Build project - run: npm run build + - name: Setup and build + uses: ./.github/actions/setup - name: Verify packed binaries run: npm run verify:packed-binaries diff --git a/.github/workflows/ci-validate.yml b/.github/workflows/ci-validate.yml index 016089c1..b515fd44 100644 --- a/.github/workflows/ci-validate.yml +++ b/.github/workflows/ci-validate.yml @@ -10,6 +10,10 @@ on: - synchronize - ready_for_review - reopened + # `edited` re-runs validation when the PR base branch changes, since the + # base-dependent guards below (changelog/plan-file history) compare + # against it. It also fires on title/body edits — a small amount of extra + # runs we accept to keep base-change reruns correct. - edited permissions: @@ -23,53 +27,32 @@ jobs: test: name: Run Unit Tests on Node v${{ matrix.node-version }} runs-on: ubuntu-latest - strategy: + fail-fast: false matrix: - node-version: [22] - + # 22 = engines support floor; 24 = project default (.nvmrc). + node-version: [22, 24] steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - name: Setup node v${{ matrix.node-version }} - uses: actions/setup-node@v6 + - name: Setup and build + uses: ./.github/actions/setup with: node-version: ${{ matrix.node-version }} - cache: "npm" - - - name: Install deps - run: npm ci - - - name: Build project - run: npm run build - name: Run unit tests run: npm test lint: - strategy: - matrix: - node-version: [22] - - name: Run Code Checks on Node v${{ matrix.node-version }} + name: Run Code Checks runs-on: ubuntu-latest - steps: - name: Checkout code - uses: actions/checkout@v6 - - - name: Setup node v${{ matrix.node-version }} - uses: actions/setup-node@v6 - with: - node-version: ${{ matrix.node-version }} - cache: "npm" - - - name: Install deps - run: npm ci + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - name: Build project - run: npm run build + - name: Setup and build + uses: ./.github/actions/setup - name: Biome check run: npm run check:ci @@ -77,6 +60,24 @@ jobs: - name: TypeScript type check run: npx tsc --noEmit + actionlint: + name: Lint Workflows + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Run actionlint + uses: docker://rhysd/actionlint@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 # v1.7.12 + with: + args: -color + env: + # SC2016 (single-quoted `${...}` don't expand) fires on the many + # intentional literal strings we echo into PR comments (markdown + + # bash examples that must stay literal). Exclude just that one + # info-level rule; all other shellcheck findings still fail the job. + SHELLCHECK_OPTS: --exclude=SC2016 + knip: name: Detect Dead Code runs-on: ubuntu-latest @@ -87,20 +88,11 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 - - - name: Setup node v22 - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: "npm" - - - name: Install deps - run: npm ci + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 # Build runs codegen (prebuild) so src/gql exists and imports resolve. - - name: Build project - run: npm run build + - name: Setup and build + uses: ./.github/actions/setup - name: Run knip id: knip @@ -139,7 +131,7 @@ jobs: - name: Post / update PR comment if: always() - uses: marocchino/sticky-pull-request-comment@v2 + uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4 with: header: knip-dead-code path: knip-comment.md @@ -156,18 +148,14 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 - - name: Setup node v22 - uses: actions/setup-node@v6 + - name: Setup (no build) + uses: ./.github/actions/setup with: - node-version: 22 - cache: "npm" - - - name: Install deps - run: npm ci + build: "false" - name: Validate PR commit range run: | @@ -183,16 +171,14 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - name: Setup Node.js - uses: actions/setup-node@v6 + # No build here: verify:packed-binaries runs `npm pack`, whose `prepack` + # builds (and generates) the package the same way a consumer install would. + - name: Setup (no build) + uses: ./.github/actions/setup with: - node-version: 24 - cache: "npm" - - - name: Install deps - run: npm ci + build: "false" - name: Verify packed binaries run: npm run verify:packed-binaries @@ -205,7 +191,7 @@ jobs: contents: read pull-requests: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 - name: Check for docs/plans/*.md in branch history @@ -246,10 +232,8 @@ jobs: name: Guard CHANGELOG History in PR runs-on: ubuntu-latest if: github.event_name == 'pull_request' - permissions: - contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 diff --git a/.github/workflows/release-promote-next-to-main.yml b/.github/workflows/release-promote-next-to-main.yml index 3cf401cb..1743ec80 100644 --- a/.github/workflows/release-promote-next-to-main.yml +++ b/.github/workflows/release-promote-next-to-main.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: next fetch-depth: 0 @@ -48,7 +48,7 @@ jobs: - name: Generate linearis-bot app token if: ${{ steps.commits-check.outputs.has_commits == 'true' }} id: app-token - uses: actions/create-github-app-token@v3 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ secrets.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index a2fa6816..86ff5e1a 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Guard workflow_dispatch caller permissions if: ${{ github.event_name == 'workflow_dispatch' }} - uses: actions/github-script@v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const { owner, repo } = context.repo; @@ -65,7 +65,7 @@ jobs: echo "Releasing from branch: $branch" - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 ref: ${{ steps.target.outputs.branch }} @@ -73,7 +73,7 @@ jobs: - name: Create linearis-bot app token id: app-token - uses: actions/create-github-app-token@v3 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ secrets.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} @@ -97,19 +97,11 @@ jobs: git config user.name "${{ steps.app-bot.outputs.name }}" git config user.email "${{ steps.app-bot.outputs.email }}" - - name: Setup Node.js - uses: actions/setup-node@v6 + - name: Setup, install and build + uses: ./.github/actions/setup with: - node-version: 24 - cache: npm registry-url: https://registry.npmjs.org - - name: Install dependencies - run: npm ci - - - name: Build - run: npm run build - - name: Verify npm auth env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release-sync-main-back-to-next.yml b/.github/workflows/release-sync-main-back-to-next.yml index 0694fabb..ba65f0e9 100644 --- a/.github/workflows/release-sync-main-back-to-next.yml +++ b/.github/workflows/release-sync-main-back-to-next.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout next - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: next fetch-depth: 0 @@ -27,7 +27,7 @@ jobs: - name: Create linearis-bot app token id: app-token - uses: actions/create-github-app-token@v3 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ secrets.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..a45fd52c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/package.json b/package.json index 504a804a..45a356dc 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "prestart": "npm run generate", "predev": "npm run generate", "prebuild": "npm run generate && npm run generate:usage", - "prepare": "npm run generate && lefthook install", + "prepare": "node scripts/prepare.mjs", "prepack": "npm run build", "prepublishOnly": "npm test", "release:dry-run": "semantic-release --dry-run --no-ci", diff --git a/renovate.json b/renovate.json index c2fd4a6d..f2f81624 100644 --- a/renovate.json +++ b/renovate.json @@ -23,16 +23,9 @@ "groupName": "dev dependencies (non-major)" }, { - "description": "Group GitHub Actions updates", + "description": "Group GitHub Actions updates (SHA-pinned actions + docker digests)", "matchManagers": ["github-actions"], "groupName": "github actions" - }, - { - "description": "Pin release-publish workflow to minimum supported Node version", - "matchManagers": ["github-actions"], - "matchPackageNames": ["node"], - "matchFileNames": [".github/workflows/release-publish.yml"], - "enabled": false } ] } diff --git a/scripts/prepare.mjs b/scripts/prepare.mjs new file mode 100644 index 00000000..68dca72a --- /dev/null +++ b/scripts/prepare.mjs @@ -0,0 +1,29 @@ +import { execFileSync } from "node:child_process"; + +// WHY THIS EXISTS — do not remove the CI guard. +// +// npm runs the `prepare` lifecycle script on every `npm ci` / `npm install`. +// Our `generate` step (graphql-codegen) introspects the LIVE Linear schema at +// https://api.linear.app/graphql (see codegen.config.ts). If `prepare` ran in +// CI, every job's `npm ci` — roughly eight installs per pipeline run — would +// make a network call to Linear's API, coupling build/test/release reliability +// to Linear's uptime and adding latency plus flakiness. `lefthook install` +// (local git hooks) is also pointless inside CI. +// +// So: skip both when running in CI. Every CI job that actually needs the +// generated types runs `npm run build` explicitly (its `prebuild` runs +// `generate`) or `npm pack` (whose `prepack` builds) — codegen still happens +// where it's needed, just not on install. Locally (no CI env var) `prepare` +// behaves normally: generate types + install git hooks. GitHub Actions sets +// CI=true automatically, as do most other CI providers. +if (process.env.CI) { + console.log( + "CI detected — skipping generate + lefthook install (see scripts/prepare.mjs)", + ); + process.exit(0); +} + +// `shell: true` so npm/npx resolve on Windows, where they are `.cmd` shims +// that cannot be launched without a shell. +execFileSync("npm", ["run", "generate"], { stdio: "inherit", shell: true }); +execFileSync("npx", ["lefthook", "install"], { stdio: "inherit", shell: true }); From 390dd385cded3c3249e3ab9ad13864b1d377b810 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:37:17 +0200 Subject: [PATCH 19/79] fix(ci): skip PR comment posting for fork pull requests GITHUB_TOKEN is forced read-only for pull_request events from forks, so posting a comment via gh pr comment fails there regardless of declared workflow permissions. --- .github/workflows/ci-validate.yml | 44 ++++++++++++++++++------------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci-validate.yml b/.github/workflows/ci-validate.yml index b515fd44..c87aeae4 100644 --- a/.github/workflows/ci-validate.yml +++ b/.github/workflows/ci-validate.yml @@ -130,7 +130,9 @@ jobs: fi - name: Post / update PR comment - if: always() + # GITHUB_TOKEN is forced read-only for pull_request events from forks, + # so posting a comment would fail there regardless of permissions:. + if: always() && github.event.pull_request.head.repo.full_name == github.repository uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4 with: header: knip-dead-code @@ -206,23 +208,29 @@ jobs: if [ -n "$plan_files" ]; then echo "::error::Found plan files in branch history" - printf '%s\n' \ - '<!-- linearis:guard-plan-files -->' \ - '' \ - '> [!WARNING]' \ - '> Found `docs/plans/*.md` files in this branch'"'"'s history.' \ - '>' \ - '> Plan files in `docs/plans/` are working artifacts created by AI agents during the design phase. Once the implementation they describe is complete and the PR is ready for review, these files serve no further purpose — they are not reference docs, not changelogs, and not part of the shipped project.' \ - '>' \ - '> Leaving them in the commit history would add noise and suggest unresolved or incomplete work.' \ - '>' \ - '> **Remove them by rebasing and dropping the commits that introduced them:**' \ - '> ```bash' \ - '> git rebase -i main' \ - '> # drop the commits that added docs/plans/*.md, then force-push' \ - '> git push --force-with-lease' \ - '> ```' \ - | gh pr comment ${{ github.event.pull_request.number }} --body-file - + # GITHUB_TOKEN is forced read-only for pull_request events from + # forks, so posting a comment would fail there regardless of + # permissions:. The ::error:: above still surfaces in the checks + # UI, so skip the comment rather than fail this step on forks. + if [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then + printf '%s\n' \ + '<!-- linearis:guard-plan-files -->' \ + '' \ + '> [!WARNING]' \ + '> Found `docs/plans/*.md` files in this branch'"'"'s history.' \ + '>' \ + '> Plan files in `docs/plans/` are working artifacts created by AI agents during the design phase. Once the implementation they describe is complete and the PR is ready for review, these files serve no further purpose — they are not reference docs, not changelogs, and not part of the shipped project.' \ + '>' \ + '> Leaving them in the commit history would add noise and suggest unresolved or incomplete work.' \ + '>' \ + '> **Remove them by rebasing and dropping the commits that introduced them:**' \ + '> ```bash' \ + '> git rebase -i main' \ + '> # drop the commits that added docs/plans/*.md, then force-push' \ + '> git push --force-with-lease' \ + '> ```' \ + | gh pr comment ${{ github.event.pull_request.number }} --body-file - + fi exit 1 fi From 6344da830acd5ceb87e576a7d12121969264c15a Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:57:39 +0200 Subject: [PATCH 20/79] ci(knip): post PR comment via workflow_run for fork PRs Fork PRs get a read-only GITHUB_TOKEN, so the knip job cannot post its dead-code comment directly. Split the flow: the knip job uploads the rendered comment and PR number as an artifact, and a new workflow_run- triggered workflow (which gets a writable token in the base repo context) downloads it and posts the sticky comment. --- .github/workflows/ci-validate.yml | 25 +++++++++----- .github/workflows/knip-comment.yml | 54 ++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/knip-comment.yml diff --git a/.github/workflows/ci-validate.yml b/.github/workflows/ci-validate.yml index c87aeae4..1f2a8a53 100644 --- a/.github/workflows/ci-validate.yml +++ b/.github/workflows/ci-validate.yml @@ -84,7 +84,6 @@ jobs: if: github.event_name == 'pull_request' permissions: contents: read - pull-requests: write steps: - name: Checkout code @@ -129,14 +128,24 @@ jobs: } > knip-comment.md fi - - name: Post / update PR comment - # GITHUB_TOKEN is forced read-only for pull_request events from forks, - # so posting a comment would fail there regardless of permissions:. - if: always() && github.event.pull_request.head.repo.full_name == github.repository - uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4 + - name: Save PR number + if: always() + run: echo "${{ github.event.pull_request.number }}" > pr-number.txt + + # GITHUB_TOKEN is forced read-only for pull_request events from forks, + # so this job can't post the comment itself regardless of permissions:. + # Upload the rendered comment + PR number as an artifact; a separate + # workflow_run-triggered workflow (which does get a writable token) + # downloads it and posts the comment. See .github/workflows/knip-comment.yml. + - name: Upload knip comment artifact + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - header: knip-dead-code - path: knip-comment.md + name: knip-comment + path: | + knip-comment.md + pr-number.txt + retention-days: 1 - name: Fail if dead code found if: steps.knip.outputs.exit_code != '0' diff --git a/.github/workflows/knip-comment.yml b/.github/workflows/knip-comment.yml new file mode 100644 index 00000000..e6255b88 --- /dev/null +++ b/.github/workflows/knip-comment.yml @@ -0,0 +1,54 @@ +name: Post Knip Comment + +# Runs after "Validate CI" completes, in the base repo's context (so it gets +# a writable GITHUB_TOKEN even for fork PRs) instead of running with the +# PR's own code. See the knip job in ci-validate.yml for the untrusted half +# of this split — it builds/runs the PR's code with a read-only token and +# uploads the rendered comment as an artifact for this workflow to post. +on: + workflow_run: + workflows: + - Validate CI + types: + - completed + +permissions: + contents: read + +jobs: + comment: + name: Post / Update Knip PR Comment + runs-on: ubuntu-latest + # Run for PR builds regardless of pass/fail: the knip job intentionally + # fails "Validate CI" when it finds dead code, and that's exactly when the + # comment matters most. The artifact is uploaded with `if: always()` before + # that failing step, so it exists on both success and failure. Only skip + # `cancelled` runs (e.g. superseded by cancel-in-progress concurrency), + # which may never reach the upload step and thus have no artifact. + if: >- + github.event.workflow_run.event == 'pull_request' + && github.event.workflow_run.conclusion != 'cancelled' + permissions: + # actions: read is required for download-artifact to fetch the artifact + # from a different workflow run (via run-id); pull-requests: write is for + # posting the comment. + actions: read + pull-requests: write + steps: + - name: Download knip comment artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: knip-comment + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Read PR number + id: pr + run: echo "number=$(cat pr-number.txt)" >> "$GITHUB_OUTPUT" + + - name: Post / update PR comment + uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4 + with: + header: knip-dead-code + path: knip-comment.md + number: ${{ steps.pr.outputs.number }} From 1f35779fb07b42beb30d13a9e96917aa2d63d03e Mon Sep 17 00:00:00 2001 From: Ralf Schimmel <mail@ralfschimmel.nl> Date: Mon, 22 Jun 2026 22:21:52 +0200 Subject: [PATCH 21/79] feat(projects): restore project workflow options --- graphql/mutations/projects.graphql | 10 +- graphql/queries/projects.graphql | 41 +++- src/commands/projects.ts | 164 ++++++++++++++-- src/services/project-service.ts | 30 ++- tests/unit/commands/projects.test.ts | 205 ++++++++++++++++++++ tests/unit/services/project-service.test.ts | 68 +++++++ 6 files changed, 489 insertions(+), 29 deletions(-) diff --git a/graphql/mutations/projects.graphql b/graphql/mutations/projects.graphql index b4e3c134..b0ce91c0 100644 --- a/graphql/mutations/projects.graphql +++ b/graphql/mutations/projects.graphql @@ -13,7 +13,7 @@ mutation CreateProject($input: ProjectCreateInput!) { projectCreate(input: $input) { success project { - ...ProjectDetailFields + ...ProjectDetailWithDefaultConnectionsFields } } } @@ -25,7 +25,7 @@ mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) { projectUpdate(id: $id, input: $input) { success project { - ...ProjectDetailFields + ...ProjectDetailWithDefaultConnectionsFields } } } @@ -34,7 +34,7 @@ mutation ArchiveProject($id: String!) { projectArchive(id: $id) { success entity { - ...ProjectDetailFields + ...ProjectDetailWithDefaultConnectionsFields } } } @@ -43,7 +43,7 @@ mutation UnarchiveProject($id: String!) { projectUnarchive(id: $id) { success entity { - ...ProjectDetailFields + ...ProjectDetailWithDefaultConnectionsFields } } } @@ -52,7 +52,7 @@ mutation DeleteProject($id: String!) { projectDelete(id: $id) { success entity { - ...ProjectDetailFields + ...ProjectDetailWithDefaultConnectionsFields } } } diff --git a/graphql/queries/projects.graphql b/graphql/queries/projects.graphql index 9b3e8a5f..ae57f23f 100644 --- a/graphql/queries/projects.graphql +++ b/graphql/queries/projects.graphql @@ -75,17 +75,21 @@ fragment ProjectDetailFields on Project { name } } - projectMilestones { + initiatives { nodes { id name - targetDate } } - initiatives { +} + +fragment ProjectDetailWithDefaultConnectionsFields on Project { + ...ProjectDetailFields + projectMilestones { nodes { id name + targetDate } } } @@ -98,8 +102,9 @@ fragment ProjectDetailFields on Project { # Variables: # $first: Maximum number of projects to return (default: 50) # $after: Cursor for pagination -query GetProjects($first: Int = 50, $after: String) { - projects(first: $first, after: $after) { +# $includeArchived: Include archived projects +query GetProjects($first: Int = 50, $after: String, $includeArchived: Boolean) { + projects(first: $first, after: $after, includeArchived: $includeArchived) { nodes { ...ProjectListFields } @@ -137,9 +142,33 @@ fragment ProjectDetailFieldsWithReactions on Project { # # Variables: # $id: Project UUID -query GetProject($id: String!) { +# $milestonesFirst: Maximum number of milestones to return +# $skipMilestones: Omit projectMilestones from the response +# $issuesFirst: Maximum number of issues to return +# $skipIssues: Omit issues from the response +query GetProject( + $id: String! + $milestonesFirst: Int! + $skipMilestones: Boolean! + $issuesFirst: Int! + $skipIssues: Boolean! +) { project(id: $id) { ...ProjectDetailFields + projectMilestones(first: $milestonesFirst) @skip(if: $skipMilestones) { + nodes { + id + name + description + targetDate + sortOrder + } + } + issues(first: $issuesFirst) @skip(if: $skipIssues) { + nodes { + ...CompleteIssueFields + } + } } } diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 3bcddcc8..c5dc7d5c 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -42,6 +42,12 @@ import { interface ListOptions { limit: string; after?: string; + includeArchived?: boolean; +} + +interface ReadOptions { + milestonesFirst: string; + issuesFirst: string; } interface DiscussionsOptions { @@ -138,9 +144,12 @@ function addCommentReactionCommands( } interface CreateOptions { - teams: string; + teams?: string; + team?: string; description?: string; content?: string; + icon?: string; + color?: string; lead?: string; members?: string; priority?: string; @@ -154,13 +163,19 @@ interface UpdateOptions { name?: string; description?: string; content?: string; + icon?: string; + color?: string; lead?: string; + clearLead?: boolean; members?: string; priority?: string; status?: string; startDate?: string; + clearStartDate?: boolean; targetDate?: string; + clearTargetDate?: boolean; teams?: string; + team?: string; labels?: string; } @@ -193,6 +208,52 @@ function parsePriority(value: string): number { return priority; } +function parseNonNegativeIntegerOption(name: string, value: string): number { + if (!/^\d+$/.test(value)) { + throw invalidParameterError(name, `must be a non-negative integer`); + } + return Number.parseInt(value, 10); +} + +function parseCommaSeparatedOption(name: string, value: string): string[] { + const values = value + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + + if (values.length === 0) { + throw invalidParameterError(name, "must include at least one value"); + } + + return values; +} + +function getCreateTeamNames(options: CreateOptions): string[] { + if (options.team && options.teams) { + throw invalidParameterError("--team", "cannot be combined with --teams"); + } + + const teams = options.teams ?? options.team; + if (!teams) { + throw invalidParameterError("--teams", "is required"); + } + + return parseCommaSeparatedOption(options.teams ? "--teams" : "--team", teams); +} + +function getUpdateTeamNames(options: UpdateOptions): string[] | undefined { + if (options.team && options.teams) { + throw invalidParameterError("--team", "cannot be combined with --teams"); + } + + const teams = options.teams ?? options.team; + if (!teams) { + return undefined; + } + + return parseCommaSeparatedOption(options.teams ? "--teams" : "--team", teams); +} + export function setupProjectsCommands(program: Command): void { const projects = program .command("projects") @@ -205,6 +266,7 @@ export function setupProjectsCommands(program: Command): void { .description("list projects") .option("-l, --limit <n>", "max results", "100") .option("--after <cursor>", "cursor for next page") + .option("--include-archived", "include archived projects") .action( handleCommand(async (...args: unknown[]) => { const [options, command] = args as [ListOptions, Command]; @@ -212,6 +274,7 @@ export function setupProjectsCommands(program: Command): void { const result = await listProjects(ctx.gql, { limit: parseLimit(options.limit), after: options.after, + includeArchived: options.includeArchived, }); outputSuccess(result); }), @@ -220,12 +283,35 @@ export function setupProjectsCommands(program: Command): void { projects .command("read <project>") .description("get full project details") + .option( + "--milestones-first <n>", + "how many milestones to fetch; 0 omits milestones", + "25", + ) + .option( + "--issues-first <n>", + "how many issues to fetch; 0 omits issues", + "50", + ) .action( handleCommand(async (...args: unknown[]) => { - const [project, , command] = args as [string, unknown, Command]; + const [project, options, command] = args as [ + string, + ReadOptions, + Command, + ]; const ctx = createContext(getRootOpts(command)); const projectId = await resolveProjectId(ctx.sdk, project); - const result = await getProject(ctx.gql, projectId); + const result = await getProject(ctx.gql, projectId, { + milestonesFirst: parseNonNegativeIntegerOption( + "--milestones-first", + options.milestonesFirst, + ), + issuesFirst: parseNonNegativeIntegerOption( + "--issues-first", + options.issuesFirst, + ), + }); outputSuccess(result); }), ); @@ -499,9 +585,12 @@ export function setupProjectsCommands(program: Command): void { projects .command("create <name>") .description("create a new project") - .requiredOption("--teams <teams>", "comma-separated team names or UUIDs") + .option("--teams <teams>", "comma-separated team names or UUIDs") + .option("--team <team>", "team name or UUID (alias for --teams)") .option("--description <text>", "project description") .option("--content <text>", "project content (markdown)") + .option("--icon <icon>", "project icon") + .option("--color <color>", "project color") .option("--lead <user>", "project lead (name, email, or UUID)") .option("--members <users>", "comma-separated member names or UUIDs") .option("--priority <0-4>", "0=none 1=urgent 2=high 3=medium 4=low") @@ -518,10 +607,7 @@ export function setupProjectsCommands(program: Command): void { ]; const ctx = createContext(getRootOpts(command)); - const teamNames = options.teams - .split(",") - .map((t) => t.trim()) - .filter(Boolean); + const teamNames = getCreateTeamNames(options); const teamIds = await Promise.all( teamNames.map((t) => resolveTeamId(ctx.sdk, t)), ); @@ -539,6 +625,14 @@ export function setupProjectsCommands(program: Command): void { input.content = options.content; } + if (options.icon !== undefined) { + input.icon = options.icon; + } + + if (options.color !== undefined) { + input.color = options.color; + } + if (options.lead) { input.leadId = await resolveUserId(ctx.sdk, options.lead); } @@ -591,13 +685,19 @@ export function setupProjectsCommands(program: Command): void { .option("--name <name>", "new name") .option("--description <text>", "new description") .option("--content <text>", "new content (markdown)") + .option("--icon <icon>", "new icon") + .option("--color <color>", "new color") .option("--lead <user>", "new lead (name, email, or UUID)") + .option("--clear-lead", "remove project lead") .option("--members <users>", "comma-separated member names or UUIDs") .option("--priority <0-4>", "new priority") .option("--status <status>", "new status name or UUID") .option("--start-date <date>", "new start date (YYYY-MM-DD)") + .option("--clear-start-date", "remove start date") .option("--target-date <date>", "new target date (YYYY-MM-DD)") + .option("--clear-target-date", "remove target date") .option("--teams <teams>", "comma-separated team names or UUIDs") + .option("--team <team>", "team name or UUID (alias for --teams)") .option("--labels <labels>", "comma-separated label names or UUIDs") .action( handleCommand(async (...args: unknown[]) => { @@ -608,6 +708,27 @@ export function setupProjectsCommands(program: Command): void { ]; const ctx = createContext(getRootOpts(command)); + if (options.lead && options.clearLead) { + throw invalidParameterError( + "--lead", + "cannot be combined with --clear-lead", + ); + } + + if (options.startDate && options.clearStartDate) { + throw invalidParameterError( + "--start-date", + "cannot be combined with --clear-start-date", + ); + } + + if (options.targetDate && options.clearTargetDate) { + throw invalidParameterError( + "--target-date", + "cannot be combined with --clear-target-date", + ); + } + const projectId = await resolveProjectId(ctx.sdk, project); const input: ProjectUpdateInput = {}; @@ -624,7 +745,17 @@ export function setupProjectsCommands(program: Command): void { input.content = options.content; } - if (options.lead) { + if (options.icon !== undefined) { + input.icon = options.icon; + } + + if (options.color !== undefined) { + input.color = options.color; + } + + if (options.clearLead) { + input.leadId = null; + } else if (options.lead) { input.leadId = await resolveUserId(ctx.sdk, options.lead); } @@ -649,19 +780,20 @@ export function setupProjectsCommands(program: Command): void { ); } - if (options.startDate) { + if (options.clearStartDate) { + input.startDate = null; + } else if (options.startDate) { input.startDate = options.startDate; } - if (options.targetDate) { + if (options.clearTargetDate) { + input.targetDate = null; + } else if (options.targetDate) { input.targetDate = options.targetDate; } - if (options.teams) { - const teamNames = options.teams - .split(",") - .map((t) => t.trim()) - .filter(Boolean); + const teamNames = getUpdateTeamNames(options); + if (teamNames) { input.teamIds = await Promise.all( teamNames.map((t) => resolveTeamId(ctx.sdk, t)), ); diff --git a/src/services/project-service.ts b/src/services/project-service.ts index 38eb27c8..0907dea7 100644 --- a/src/services/project-service.ts +++ b/src/services/project-service.ts @@ -29,14 +29,31 @@ import { type UpdateProjectMutation, } from "../gql/graphql.js"; +export interface ProjectListOptions extends PaginationOptions { + includeArchived?: boolean; +} + +export interface ProjectDetailOptions { + milestonesFirst?: number; + issuesFirst?: number; +} + +const DEFAULT_PROJECT_MILESTONES_FIRST = 25; +const DEFAULT_PROJECT_ISSUES_FIRST = 50; + +function connectionFirstOrOneWhenSkipped(value: number): number { + return value === 0 ? 1 : value; +} + export async function listProjects( client: GraphQLClient, - options: PaginationOptions = {}, + options: ProjectListOptions = {}, ): Promise<PaginatedResult<ProjectListItem>> { - const { limit = 50, after } = options; + const { limit = 50, after, includeArchived } = options; const result = await client.request<GetProjectsQuery>(GetProjectsDocument, { first: limit, after, + includeArchived, }); return { @@ -48,9 +65,18 @@ export async function listProjects( export async function getProject( client: GraphQLClient, id: string, + options: ProjectDetailOptions = {}, ): Promise<ProjectDetail> { + const milestonesFirst = + options.milestonesFirst ?? DEFAULT_PROJECT_MILESTONES_FIRST; + const issuesFirst = options.issuesFirst ?? DEFAULT_PROJECT_ISSUES_FIRST; + const result = await client.request<GetProjectQuery>(GetProjectDocument, { id, + milestonesFirst: connectionFirstOrOneWhenSkipped(milestonesFirst), + skipMilestones: milestonesFirst === 0, + issuesFirst: connectionFirstOrOneWhenSkipped(issuesFirst), + skipIssues: issuesFirst === 0, }); if (!result.project) { diff --git a/tests/unit/commands/projects.test.ts b/tests/unit/commands/projects.test.ts index 9f142991..6f35c93c 100644 --- a/tests/unit/commands/projects.test.ts +++ b/tests/unit/commands/projects.test.ts @@ -114,6 +114,7 @@ vi.mock("../../../src/services/discussion-service.js", () => ({ import { setupProjectsCommands } from "../../../src/commands/projects.js"; import { outputSuccess } from "../../../src/common/output.js"; import { resolveProjectId } from "../../../src/resolvers/project-resolver.js"; +import { resolveTeamId } from "../../../src/resolvers/team-resolver.js"; import { createDiscussionCommentReaction, deleteDiscussionComment, @@ -136,6 +137,7 @@ import { createProject, deleteProject, getProject, + listProjects, unarchiveProject, updateProject, } from "../../../src/services/project-service.js"; @@ -147,6 +149,34 @@ function createProgram(): Command { return program; } +describe("projects list", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + }); + + it("passes includeArchived to project listing", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "list", + "--include-archived", + "--limit", + "25", + ]); + + expect(listProjects).toHaveBeenCalledWith(expect.anything(), { + limit: 25, + after: undefined, + includeArchived: true, + }); + }); +}); + describe("projects read", () => { beforeEach(() => { vi.clearAllMocks(); @@ -172,9 +202,49 @@ describe("projects read", () => { expect(getProject).toHaveBeenCalledWith( expect.anything(), "resolved-project-uuid", + { milestonesFirst: 25, issuesFirst: 50 }, ); expect(outputSuccess).toHaveBeenCalledWith({ id: "proj-1" }); }); + + it("passes project detail expansion limits including zero", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "read", + "My Project", + "--milestones-first", + "0", + "--issues-first", + "10", + ]); + + expect(getProject).toHaveBeenCalledWith( + expect.anything(), + "resolved-project-uuid", + { milestonesFirst: 0, issuesFirst: 10 }, + ); + }); + + it("rejects negative project detail expansion limits", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "read", + "My Project", + "--issues-first", + "-1", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Invalid --issues-first"), + ); + expect(getProject).not.toHaveBeenCalled(); + }); }); describe("projects lifecycle", () => { @@ -349,6 +419,141 @@ describe("projects create --priority", () => { }); }); +describe("projects create compatibility options", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + }); + + it("accepts singular --team and forwards icon and color", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "create", + "My Project", + "--team", + "ENG", + "--icon", + "rocket", + "--color", + "#ff0000", + ]); + + expect(resolveTeamId).toHaveBeenCalledWith(expect.anything(), "ENG"); + expect(createProject).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + teamIds: ["resolved-team-uuid"], + icon: "rocket", + color: "#ff0000", + }), + ); + }); + + it("rejects combining --team and --teams", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "create", + "My Project", + "--team", + "ENG", + "--teams", + "DES", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("cannot be combined with --teams"), + ); + expect(createProject).not.toHaveBeenCalled(); + }); +}); + +describe("projects update compatibility options", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + }); + + it("clears lead and lifecycle dates", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "update", + "My Project", + "--clear-lead", + "--clear-start-date", + "--clear-target-date", + ]); + + expect(updateProject).toHaveBeenCalledWith( + expect.anything(), + "resolved-project-uuid", + expect.objectContaining({ + leadId: null, + startDate: null, + targetDate: null, + }), + ); + }); + + it("updates icon, color, and singular team alias", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "update", + "My Project", + "--icon", + "target", + "--color", + "#00ff00", + "--team", + "ENG", + ]); + + expect(updateProject).toHaveBeenCalledWith( + expect.anything(), + "resolved-project-uuid", + expect.objectContaining({ + icon: "target", + color: "#00ff00", + teamIds: ["resolved-team-uuid"], + }), + ); + }); + + it("rejects clear flags combined with replacement values", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "update", + "My Project", + "--lead", + "Ada", + "--clear-lead", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("cannot be combined with --clear-lead"), + ); + expect(updateProject).not.toHaveBeenCalled(); + }); +}); + describe("projects discussion commands", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/tests/unit/services/project-service.test.ts b/tests/unit/services/project-service.test.ts index 6facab21..25dfd8d6 100644 --- a/tests/unit/services/project-service.test.ts +++ b/tests/unit/services/project-service.test.ts @@ -177,6 +177,7 @@ describe("listProjects", () => { expect(client.request).toHaveBeenCalledWith(expect.anything(), { first: 50, after: "cur1", + includeArchived: undefined, }); }); @@ -191,6 +192,22 @@ describe("listProjects", () => { expect(client.request).toHaveBeenCalledWith(expect.anything(), { first: 50, after: undefined, + includeArchived: undefined, + }); + }); + + it("passes includeArchived when requested", async () => { + const client = mockGqlClient({ + projects: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + await listProjects(client, { includeArchived: true }); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + first: 50, + after: undefined, + includeArchived: true, }); }); @@ -267,6 +284,57 @@ describe("getProject", () => { expect(result.status.name).toBe("Started"); expect(result.content).toBe("# Project Alpha\nDetailed content here."); expect(result.members.nodes).toHaveLength(1); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + id: "proj-1", + milestonesFirst: 25, + skipMilestones: false, + issuesFirst: 50, + skipIssues: false, + }); + }); + + it("supports bounded detail expansion and zero skips", async () => { + const client = mockGqlClient({ + project: { + id: "proj-1", + name: "Project Alpha", + }, + }); + + await getProject(client, "proj-1", { + milestonesFirst: 0, + issuesFirst: 0, + }); + + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + id: "proj-1", + milestonesFirst: 1, + skipMilestones: true, + issuesFirst: 1, + skipIssues: true, + }); + }); + + it("passes custom milestone and issue limits", async () => { + const client = mockGqlClient({ + project: { + id: "proj-1", + name: "Project Alpha", + }, + }); + + await getProject(client, "proj-1", { + milestonesFirst: 5, + issuesFirst: 10, + }); + + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + id: "proj-1", + milestonesFirst: 5, + skipMilestones: false, + issuesFirst: 10, + skipIssues: false, + }); }); it("throws when project not found", async () => { From ae86863191b84a7bd759ee55fe6d6b26c5f7a205 Mon Sep 17 00:00:00 2001 From: semantic-release-bot <semantic-release-bot@martynus.net> Date: Thu, 2 Jul 2026 19:06:17 +0000 Subject: [PATCH 22/79] chore(release): 2026.6.0-next.6 [skip ci] ## [2026.6.0-next.6](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.5...v2026.6.0-next.6) (2026-07-02) ### Features * **projects:** restore project workflow options ([1f35779](https://github.com/linearis-oss/linearis/commit/1f35779fb07b42beb30d13a9e96917aa2d63d03e)) ### Bug Fixes * **ci:** skip PR comment posting for fork pull requests ([390dd38](https://github.com/linearis-oss/linearis/commit/390dd385cded3c3249e3ab9ad13864b1d377b810)) --- 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 2753a64c..04420413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## [2026.6.0-next.6](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.5...v2026.6.0-next.6) (2026-07-02) + +### Features + +* **projects:** restore project workflow options ([1f35779](https://github.com/linearis-oss/linearis/commit/1f35779fb07b42beb30d13a9e96917aa2d63d03e)) + +### Bug Fixes + +* **ci:** skip PR comment posting for fork pull requests ([390dd38](https://github.com/linearis-oss/linearis/commit/390dd385cded3c3249e3ab9ad13864b1d377b810)) + ## [2026.6.0-next.5](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.4...v2026.6.0-next.5) (2026-07-02) ### Features diff --git a/package-lock.json b/package-lock.json index daae0c1b..b39ddc85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linearis", - "version": "2026.6.0-next.5", + "version": "2026.6.0-next.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.6.0-next.5", + "version": "2026.6.0-next.6", "license": "MIT", "dependencies": { "@linear/sdk": "82.1.0", diff --git a/package.json b/package.json index 45a356dc..99da019c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.6.0-next.5", + "version": "2026.6.0-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 1a5479fb360319e7647b04391a99603ea6390163 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:08:19 +0000 Subject: [PATCH 23/79] chore(deps): update github actions --- .github/workflows/ci-post-merge.yml | 2 +- .github/workflows/ci-validate.yml | 16 ++++++++-------- .github/workflows/knip-comment.yml | 2 +- .../workflows/release-promote-next-to-main.yml | 2 +- .github/workflows/release-publish.yml | 2 +- .../workflows/release-sync-main-back-to-next.yml | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci-post-merge.yml b/.github/workflows/ci-post-merge.yml index f1d289f6..eaa7fa25 100644 --- a/.github/workflows/ci-post-merge.yml +++ b/.github/workflows/ci-post-merge.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup and build uses: ./.github/actions/setup diff --git a/.github/workflows/ci-validate.yml b/.github/workflows/ci-validate.yml index 1f2a8a53..f82d8ae7 100644 --- a/.github/workflows/ci-validate.yml +++ b/.github/workflows/ci-validate.yml @@ -34,7 +34,7 @@ jobs: node-version: [22, 24] steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup and build uses: ./.github/actions/setup @@ -49,7 +49,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup and build uses: ./.github/actions/setup @@ -65,7 +65,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Run actionlint uses: docker://rhysd/actionlint@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 # v1.7.12 @@ -87,7 +87,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # Build runs codegen (prebuild) so src/gql exists and imports resolve. - name: Setup and build @@ -159,7 +159,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 @@ -182,7 +182,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # No build here: verify:packed-binaries runs `npm pack`, whose `prepack` # builds (and generates) the package the same way a consumer install would. @@ -202,7 +202,7 @@ jobs: contents: read pull-requests: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 - name: Check for docs/plans/*.md in branch history @@ -250,7 +250,7 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'pull_request' steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 diff --git a/.github/workflows/knip-comment.yml b/.github/workflows/knip-comment.yml index e6255b88..bab79f04 100644 --- a/.github/workflows/knip-comment.yml +++ b/.github/workflows/knip-comment.yml @@ -47,7 +47,7 @@ jobs: run: echo "number=$(cat pr-number.txt)" >> "$GITHUB_OUTPUT" - name: Post / update PR comment - uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4 + uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 with: header: knip-dead-code path: knip-comment.md diff --git a/.github/workflows/release-promote-next-to-main.yml b/.github/workflows/release-promote-next-to-main.yml index 1743ec80..175daa2b 100644 --- a/.github/workflows/release-promote-next-to-main.yml +++ b/.github/workflows/release-promote-next-to-main.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: next fetch-depth: 0 diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 86ff5e1a..3847df5c 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -65,7 +65,7 @@ jobs: echo "Releasing from branch: $branch" - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 ref: ${{ steps.target.outputs.branch }} diff --git a/.github/workflows/release-sync-main-back-to-next.yml b/.github/workflows/release-sync-main-back-to-next.yml index ba65f0e9..569b09ce 100644 --- a/.github/workflows/release-sync-main-back-to-next.yml +++ b/.github/workflows/release-sync-main-back-to-next.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout next - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: next fetch-depth: 0 From c4a9b5b3714a8cec625908a7c1428b42560477c1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:33:40 +0000 Subject: [PATCH 24/79] chore(deps): update dependency clean-publish to v7 --- package-lock.json | 20 ++++++++++---------- package.json | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index b39ddc85..ac741959 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ "@types/node": "^24.0.0", "@vitest/coverage-v8": "^4.0.0", "@vitest/ui": "^4.0.0", - "clean-publish": "^6.0.5", + "clean-publish": "^7.0.0", "knip": "^6.24.0", "lefthook": "^2.1.0", "semantic-release": "^25.0.1", @@ -5375,23 +5375,23 @@ "license": "MIT" }, "node_modules/clean-publish": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/clean-publish/-/clean-publish-6.0.5.tgz", - "integrity": "sha512-Iqm/EDPQFLY0I8kktg61Nt8V/5fiXYNkNR5UsHcLKmj4vp7a0a7EGZmNEbN2Hg77frQlHNljT/MruK5Wr/Rtog==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/clean-publish/-/clean-publish-7.1.0.tgz", + "integrity": "sha512-fnsFHW6fIq7Sr5Pf9YUsxB1up5pEA13DHg4Q/bZdphWssmIUuKV7G5Kaq2Q0X6UsCFm//uFbONOWTi7m4DvMTg==", "dev": true, "license": "MIT", "dependencies": { "lilconfig": "^3.1.3", "picomatch": "^4.0.4", - "tinyexec": "^1.0.4", - "tinyglobby": "^0.2.15" + "tinyexec": "^1.1.2", + "tinyglobby": "^0.2.16" }, "bin": { "clean-publish": "clean-publish.js", "clear-package-json": "clear-package-json.js" }, "engines": { - "node": ">= 20.0.0" + "node": ">= 22.0.0" } }, "node_modules/clean-stack": { @@ -12858,9 +12858,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", - "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 99da019c..2446d402 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "@types/node": "^24.0.0", "@vitest/coverage-v8": "^4.0.0", "@vitest/ui": "^4.0.0", - "clean-publish": "^6.0.5", + "clean-publish": "^7.0.0", "knip": "^6.24.0", "lefthook": "^2.1.0", "semantic-release": "^25.0.1", From f98c898a7acd0a636214612008721bededa5c968 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:36:13 +0000 Subject: [PATCH 25/79] chore(deps): update commitlint monorepo to v21 --- package-lock.json | 380 ++++++++++++++++++++++++++-------------------- package.json | 4 +- 2 files changed, 217 insertions(+), 167 deletions(-) diff --git a/package-lock.json b/package-lock.json index ac741959..1ff6eb35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,8 +19,8 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.14", - "@commitlint/cli": "^20.4.1", - "@commitlint/config-conventional": "^20.4.1", + "@commitlint/cli": "^21.0.0", + "@commitlint/config-conventional": "^21.0.0", "@graphql-codegen/cli": "^6.1.1", "@graphql-codegen/client-preset": "^5.2.2", "@semantic-release/changelog": "^6.0.3", @@ -612,251 +612,332 @@ } }, "node_modules/@commitlint/cli": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.5.0.tgz", - "integrity": "sha512-yNkyN/tuKTJS3wdVfsZ2tXDM4G4Gi7z+jW54Cki8N8tZqwKBltbIvUUrSbT4hz1bhW/h0CdR+5sCSpXD+wMKaQ==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.1.0.tgz", + "integrity": "sha512-CVwY6TxGv5naEaWxBdgNHko1xgL95Mb4WcIqp9iik33H0ctVqRv6YtekCntayhEP0T/apuiGvHu5HcCwFuVxEA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/format": "^20.5.0", - "@commitlint/lint": "^20.5.0", - "@commitlint/load": "^20.5.0", - "@commitlint/read": "^20.5.0", - "@commitlint/types": "^20.5.0", + "@commitlint/config-conventional": "^21.1.0", + "@commitlint/format": "^21.1.0", + "@commitlint/lint": "^21.1.0", + "@commitlint/load": "^21.1.0", + "@commitlint/read": "^21.1.0", + "@commitlint/types": "^21.1.0", "tinyexec": "^1.0.0", - "yargs": "^17.0.0" + "yargs": "^18.0.0" }, "bin": { "commitlint": "cli.js" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/cli/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@commitlint/cli/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@commitlint/cli/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@commitlint/cli/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@commitlint/cli/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/@commitlint/cli/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/@commitlint/config-conventional": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.5.0.tgz", - "integrity": "sha512-t3Ni88rFw1XMa4nZHgOKJ8fIAT9M2j5TnKyTqJzsxea7FUetlNdYFus9dz+MhIRZmc16P0PPyEfh6X2d/qw8SA==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.1.0.tgz", + "integrity": "sha512-BIFl8xM+3SLy3jrblUC3wmQLCVbLty+++6o859BDCmybVrQdXmIWO+dlkGIbv/M2bBoC55wGuh0zGiw3TPjL1g==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.5.0", + "@commitlint/types": "^21.1.0", "conventional-changelog-conventionalcommits": "^9.2.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/config-validator": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.5.0.tgz", - "integrity": "sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.1.0.tgz", + "integrity": "sha512-gHczt1xqQSwfNqBmOI3HjejtTljkiBEUneExMmTBLD0WwTC78lAqDvNMyydbySt3DhpH0F9oX7Vvuks6s5XPFw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.5.0", + "@commitlint/types": "^21.1.0", "ajv": "^8.11.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/ensure": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.5.0.tgz", - "integrity": "sha512-IpHqAUesBeW1EDDdjzJeaOxU9tnogLAyXLRBn03SHlj1SGENn2JGZqSWGkFvBJkJzfXAuCNtsoYzax+ZPS+puw==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.1.0.tgz", + "integrity": "sha512-/S8Mo3Q1NtQUYDQjDmyQVPxfIwtnxq+guzMOkuGk8OSdwlzanm1WB9wDPIuuzlbMDDnBNbiAuBEUCcCNlfjrTQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.5.0", - "lodash.camelcase": "^4.3.0", - "lodash.kebabcase": "^4.1.1", - "lodash.snakecase": "^4.1.1", - "lodash.startcase": "^4.4.0", - "lodash.upperfirst": "^4.3.1" + "@commitlint/types": "^21.1.0", + "es-toolkit": "^1.46.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/execute-rule": { - "version": "20.0.0", - "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-20.0.0.tgz", - "integrity": "sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-21.0.1.tgz", + "integrity": "sha512-RifH+FmImozKBE6mozhF4K3r2RRKP7SMi/Q/zLCmExtp5e05lhHOUYqGBlFBAGNHaZxU/WYw1XuugYK9jQzqnA==", "dev": true, "license": "MIT", "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/format": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-20.5.0.tgz", - "integrity": "sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.1.0.tgz", + "integrity": "sha512-ySymqKYBfjNrQ5N4W/l1iF2ISW1W7Eu/Oi/wRxlri31N0yjNyzUyUzQwyuZLDzTXIlMs4IZ7hIOfAZx8lO18gA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.5.0", + "@commitlint/types": "^21.1.0", "picocolors": "^1.1.1" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/is-ignored": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.5.0.tgz", - "integrity": "sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.1.0.tgz", + "integrity": "sha512-RoRh1/YI+fYH+aid5lMQ2UD0vZ3p3Vf1KeUWT1ir3H/p/7T/6SFv1OiXLgLwUT8dP72EVWeEIyOfkiSWLZYVvw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.5.0", + "@commitlint/types": "^21.1.0", "semver": "^7.6.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/lint": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.5.0.tgz", - "integrity": "sha512-jiM3hNUdu04jFBf1VgPdjtIPvbuVfDTBAc6L98AWcoLjF5sYqkulBHBzlVWll4rMF1T5zeQFB6r//a+s+BBKlA==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.1.0.tgz", + "integrity": "sha512-0DbfVVUjAWBfixW6v7CXXWVxMcj6Ukf/oB7O8NAbouP3jxmqUaC4eVQphxl3B3M0ii3cCQiR3sRAYxICwU2gAA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/is-ignored": "^20.5.0", - "@commitlint/parse": "^20.5.0", - "@commitlint/rules": "^20.5.0", - "@commitlint/types": "^20.5.0" + "@commitlint/is-ignored": "^21.1.0", + "@commitlint/parse": "^21.1.0", + "@commitlint/rules": "^21.1.0", + "@commitlint/types": "^21.1.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/load": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.5.0.tgz", - "integrity": "sha512-sLhhYTL/KxeOTZjjabKDhwidGZan84XKK1+XFkwDYL/4883kIajcz/dZFAhBJmZPtL8+nBx6bnkzA95YxPeDPw==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.1.0.tgz", + "integrity": "sha512-juiClVEcoreNB0TNVkseO2EmNcpEs/Yhnmgbnm/hQAKBFRynKwIaoNIljXkx/3yvZcMO0EE8I2XOEI7d5KZG8Q==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^20.5.0", - "@commitlint/execute-rule": "^20.0.0", - "@commitlint/resolve-extends": "^20.5.0", - "@commitlint/types": "^20.5.0", + "@commitlint/config-validator": "^21.1.0", + "@commitlint/execute-rule": "^21.0.1", + "@commitlint/resolve-extends": "^21.1.0", + "@commitlint/types": "^21.1.0", "cosmiconfig": "^9.0.1", "cosmiconfig-typescript-loader": "^6.1.0", + "es-toolkit": "^1.46.0", "is-plain-obj": "^4.1.0", - "lodash.mergewith": "^4.6.2", "picocolors": "^1.1.1" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/message": { - "version": "20.4.3", - "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-20.4.3.tgz", - "integrity": "sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==", + "version": "21.0.2", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-21.0.2.tgz", + "integrity": "sha512-5n4aqHGD/FNnom/D5L8i7cYtV+xjuXcBL832C3w9VglEsZzIsoHpJsvxzJ7cgiOsOdc/2jU4t5+7qMHh7GBX3g==", "dev": true, "license": "MIT", "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/parse": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-20.5.0.tgz", - "integrity": "sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.1.0.tgz", + "integrity": "sha512-HdAqbbjQS8eEtbR74Ysg2VNmbvAfeWLVYMkip/lHibNrtjRsC/97XAYN3/H5P0pEJtDfyTb3iLs8x6y0eu4OYA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.5.0", + "@commitlint/types": "^21.1.0", "conventional-changelog-angular": "^8.2.0", "conventional-commits-parser": "^6.3.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/read": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-20.5.0.tgz", - "integrity": "sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.1.0.tgz", + "integrity": "sha512-ID7m79aw8d0dMlxuXHD2QGxEX3Fhl/mUPA80WwEW5VgeOpUHNahhwWJefDdoBDVZcDfbHuf429NrcK0gxQsQjA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/top-level": "^20.4.3", - "@commitlint/types": "^20.5.0", + "@commitlint/top-level": "^21.0.2", + "@commitlint/types": "^21.1.0", "git-raw-commits": "^5.0.0", - "minimist": "^1.2.8", "tinyexec": "^1.0.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/resolve-extends": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.5.0.tgz", - "integrity": "sha512-3SHPWUW2v0tyspCTcfSsYml0gses92l6TlogwzvM2cbxDgmhSRc+fldDjvGkCXJrjSM87BBaWYTPWwwyASZRrg==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.1.0.tgz", + "integrity": "sha512-SANYkxJDfMl3TvnyALWHEaiF5nc6FFaOnh7VvfxjT4X2vD4i2gVHhmfMm1fsrBwDRX98/XyM1XDo5sAd/KXcyQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^20.5.0", - "@commitlint/types": "^20.5.0", - "global-directory": "^4.0.1", - "import-meta-resolve": "^4.0.0", - "lodash.mergewith": "^4.6.2", + "@commitlint/config-validator": "^21.1.0", + "@commitlint/types": "^21.1.0", + "es-toolkit": "^1.46.0", + "global-directory": "^5.0.0", "resolve-from": "^5.0.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/rules": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.5.0.tgz", - "integrity": "sha512-5NdQXQEdnDPT5pK8O39ZA7HohzPRHEsDGU23cyVCNPQy4WegAbAwrQk3nIu7p2sl3dutPk8RZd91yKTrMTnRkQ==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.1.0.tgz", + "integrity": "sha512-fOPEYSmKn1ZJptjLmCEjJfYqz0PUYr8ng6VY2ZW26sB7KtENR90CmAXHEmScBbOIZip+d/+OwqK12DFBuHTqsQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/ensure": "^20.5.0", - "@commitlint/message": "^20.4.3", - "@commitlint/to-lines": "^20.0.0", - "@commitlint/types": "^20.5.0" + "@commitlint/ensure": "^21.1.0", + "@commitlint/message": "^21.0.2", + "@commitlint/to-lines": "^21.0.1", + "@commitlint/types": "^21.1.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/to-lines": { - "version": "20.0.0", - "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-20.0.0.tgz", - "integrity": "sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-21.0.1.tgz", + "integrity": "sha512-bd1BFII7p1EQZre9Kaj+kKaMFP3cFCdt21K7DItVux9XP5WjLgJ0/Uy1pJJh9aPwVJ6SKg62PxqlZaHI8hQAXw==", "dev": true, "license": "MIT", "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/top-level": { - "version": "20.4.3", - "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-20.4.3.tgz", - "integrity": "sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==", + "version": "21.0.2", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-21.0.2.tgz", + "integrity": "sha512-s9KKM+e+mXgFeIh4n7KmOGAVT3mkJ3Fp1bBYHIK5pjeUwlEMzp/tZfb5u0Poa680AsQTXMEMRxZi1vQ9m2X5ug==", "dev": true, "license": "MIT", "dependencies": { "escalade": "^3.2.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/types": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-20.5.0.tgz", - "integrity": "sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.1.0.tgz", + "integrity": "sha512-YodnnnH1Cp+08nP8HGNJAIuB6L3/vdCTHVRTfF8Ik/wRCLOTsU9zwv3yO1cSPQRDa9CLYtE+UJ2K67r7CwMSFw==", "dev": true, "license": "MIT", "dependencies": { @@ -864,7 +945,7 @@ "picocolors": "^1.1.1" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@conventional-changelog/git-client": { @@ -5012,9 +5093,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { @@ -6220,6 +6301,17 @@ "dev": true, "license": "MIT" }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "dev": true, + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz", @@ -6424,9 +6516,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", "dev": true, "funding": [ { @@ -6789,16 +6881,16 @@ } }, "node_modules/global-directory": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", - "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-5.0.0.tgz", + "integrity": "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==", "dev": true, "license": "MIT", "dependencies": { - "ini": "4.1.1" + "ini": "6.0.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -7237,13 +7329,13 @@ "license": "ISC" }, "node_modules/ini": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", - "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", "dev": true, "license": "ISC", "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/into-stream": { @@ -8286,13 +8378,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.capitalize": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/lodash.capitalize/-/lodash.capitalize-4.2.1.tgz", @@ -8321,27 +8406,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.kebabcase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", - "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.mergewith": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.snakecase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -8349,13 +8413,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.startcase": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", - "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.uniqby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", @@ -8363,13 +8420,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.upperfirst": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", - "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", - "dev": true, - "license": "MIT" - }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", diff --git a/package.json b/package.json index 2446d402..acfd45b2 100644 --- a/package.json +++ b/package.json @@ -75,8 +75,8 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.14", - "@commitlint/cli": "^20.4.1", - "@commitlint/config-conventional": "^20.4.1", + "@commitlint/cli": "^21.0.0", + "@commitlint/config-conventional": "^21.0.0", "@graphql-codegen/cli": "^6.1.1", "@graphql-codegen/client-preset": "^5.2.2", "@semantic-release/changelog": "^6.0.3", From 9aa58aceab4b002e791a92166acf0d58929027f6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:38:47 +0000 Subject: [PATCH 26/79] chore(deps): update dev dependencies (non-major) --- package-lock.json | 920 +++++++++++++++++++++------------------------- 1 file changed, 409 insertions(+), 511 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1ff6eb35..4ed84056 100644 --- a/package-lock.json +++ b/package-lock.json @@ -426,9 +426,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.13.tgz", - "integrity": "sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.1.tgz", + "integrity": "sha512-IXWLCxKmae+rI7LOHS1B3EbVisQ6GRAWbhN9msa6KjNCyFWrvKZWR4oUdinaNssrV852OrSHuSPa95h1GPJc7Q==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -442,20 +442,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.4.13", - "@biomejs/cli-darwin-x64": "2.4.13", - "@biomejs/cli-linux-arm64": "2.4.13", - "@biomejs/cli-linux-arm64-musl": "2.4.13", - "@biomejs/cli-linux-x64": "2.4.13", - "@biomejs/cli-linux-x64-musl": "2.4.13", - "@biomejs/cli-win32-arm64": "2.4.13", - "@biomejs/cli-win32-x64": "2.4.13" + "@biomejs/cli-darwin-arm64": "2.5.1", + "@biomejs/cli-darwin-x64": "2.5.1", + "@biomejs/cli-linux-arm64": "2.5.1", + "@biomejs/cli-linux-arm64-musl": "2.5.1", + "@biomejs/cli-linux-x64": "2.5.1", + "@biomejs/cli-linux-x64-musl": "2.5.1", + "@biomejs/cli-win32-arm64": "2.5.1", + "@biomejs/cli-win32-x64": "2.5.1" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.13.tgz", - "integrity": "sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-npqDzvqv7vFaWRiNN1Te71siRgPaqS9MpqgYCdP/CrUbkJ7ApezaeaKjueKHRN/JH/6lRjJQAHi8acQDCAz22w==", "cpu": [ "arm64" ], @@ -470,9 +470,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.13.tgz", - "integrity": "sha512-BKrJklbaFN4p1Ts4kPBczo+PkbsHQg57kmJ+vON9u2t6uN5okYHaSr7h/MutPCWQgg2lglaWoSmm+zhYW+oOkg==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.1.tgz", + "integrity": "sha512-RgwTqPAM8g2tn1j+b5oRjF/DbSBX8a4gwojtuG9XuhfK7GgomvZ9+T+tqjXiVbjLEeGJOoL6VEk8mvRTVeSybw==", "cpu": [ "x64" ], @@ -487,9 +487,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.13.tgz", - "integrity": "sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.1.tgz", + "integrity": "sha512-yhV35CzZh38VyMvTEXi3JTjxZBs++oCKK9KG8vB6VI5+uvQvZNR3BFWEKKzuOmx9DJJj7sQpZ4LQJcmbGTs3+Q==", "cpu": [ "arm64" ], @@ -507,9 +507,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.13.tgz", - "integrity": "sha512-U5MsuBQW25dXaYtqWWSPM3P96H6Y+fHuja3TQpMNnylocHW0tEbtFTDlUj6oM+YJLntvEkQy4grBvQNUD4+RCg==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-WMcvMLgByyTqVxGlq918NBBYliq9FRR9GAQVETHb+VjGVqXCZFfHlZHC1FX4ibuYY/Hg6TJE3rHU0xVrdJXNRw==", "cpu": [ "arm64" ], @@ -527,9 +527,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.13.tgz", - "integrity": "sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.1.tgz", + "integrity": "sha512-J/7uHSX7NfoYDI7HijAkd8lnQIOrRb2W7j3X+tw4R+N5ExvXGsyXFiGdQcfcxfOmNQmZVSQOCDk757fwpzqQcg==", "cpu": [ "x64" ], @@ -547,9 +547,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.13.tgz", - "integrity": "sha512-Z601MienRgTBDza/+u2CH3RSrWoXo9rtr8NK6A4KJzqGgfxx+H3VlyLgTJ4sRo40T3pIsqpTmiOQEvYzQvBRvQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-ANTowtlLmPYm5yeMckWY8Xzb9Ix+JJP3tgHR/n6xRj1VWyIzzWtfRfih9hv9VmClwadpBvZduISZIbBsIlYG3A==", "cpu": [ "x64" ], @@ -567,9 +567,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.13.tgz", - "integrity": "sha512-Px9PS2B5/Q183bUwy/5VHqp3J2lzdOCeVGzMpphYfl8oSa7VDCqenBdqWpy6DCy/en4Rbf/Y1RieZF6dJPcc9A==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.1.tgz", + "integrity": "sha512-zgXnKNgWPC4iPF7Y1lR3STUeCUuZRpD6IiOrC7TZTlh0Lx6FiVUT05myuMQHQ9D+1cc7uyMldi4forE6lp0ivQ==", "cpu": [ "arm64" ], @@ -584,9 +584,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.13.tgz", - "integrity": "sha512-tTcMkXyBrmHi9BfrD2VNHs/5rYIUKETqsBlYOvSAABwBkJhSDVb5e7wPukftsQbO3WzQkXe6kaztC6WtUOXSoQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.1.tgz", + "integrity": "sha512-6uxpR9hvaglANkZemeSiN/FhYgkGasrEGn267eXIWvjrjJ2LhDlk251IhjVJq6MXzkV2/bcXwLwSroLyPtqRZg==", "cpu": [ "x64" ], @@ -976,21 +976,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -999,9 +999,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -1054,9 +1054,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz", - "integrity": "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -1071,9 +1071,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.1.tgz", - "integrity": "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -1088,9 +1088,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.1.tgz", - "integrity": "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -1105,9 +1105,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.1.tgz", - "integrity": "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -1122,9 +1122,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz", - "integrity": "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -1139,9 +1139,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.1.tgz", - "integrity": "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -1156,9 +1156,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.1.tgz", - "integrity": "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -1173,9 +1173,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.1.tgz", - "integrity": "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -1190,9 +1190,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.1.tgz", - "integrity": "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -1207,9 +1207,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.1.tgz", - "integrity": "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -1224,9 +1224,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.1.tgz", - "integrity": "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -1241,9 +1241,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.1.tgz", - "integrity": "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -1258,9 +1258,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.1.tgz", - "integrity": "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -1275,9 +1275,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.1.tgz", - "integrity": "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -1292,9 +1292,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.1.tgz", - "integrity": "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -1309,9 +1309,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.1.tgz", - "integrity": "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -1326,9 +1326,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.1.tgz", - "integrity": "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -1343,9 +1343,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.1.tgz", - "integrity": "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -1360,9 +1360,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.1.tgz", - "integrity": "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -1377,9 +1377,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.1.tgz", - "integrity": "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -1394,9 +1394,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.1.tgz", - "integrity": "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -1411,9 +1411,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.1.tgz", - "integrity": "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -1428,9 +1428,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.1.tgz", - "integrity": "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -1445,9 +1445,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.1.tgz", - "integrity": "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -1462,9 +1462,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.1.tgz", - "integrity": "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -1479,9 +1479,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz", - "integrity": "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -3275,40 +3275,6 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@oxc-parser/binding-win32-arm64-msvc": { "version": "0.137.0", "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.137.0.tgz", @@ -3361,9 +3327,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", "dev": true, "license": "MIT", "funding": { @@ -3660,17 +3626,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { "version": "11.21.3", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.21.3.tgz", @@ -3759,9 +3714,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", "cpu": [ "arm64" ], @@ -3776,9 +3731,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", "cpu": [ "arm64" ], @@ -3793,9 +3748,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", "cpu": [ "x64" ], @@ -3810,9 +3765,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", "cpu": [ "x64" ], @@ -3827,9 +3782,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", "cpu": [ "arm" ], @@ -3844,9 +3799,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", "cpu": [ "arm64" ], @@ -3864,9 +3819,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", "cpu": [ "arm64" ], @@ -3884,9 +3839,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", "cpu": [ "ppc64" ], @@ -3904,9 +3859,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", "cpu": [ "s390x" ], @@ -3924,9 +3879,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", "cpu": [ "x64" ], @@ -3944,9 +3899,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", "cpu": [ "x64" ], @@ -3964,9 +3919,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", "cpu": [ "arm64" ], @@ -3981,9 +3936,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", "cpu": [ "wasm32" ], @@ -3991,18 +3946,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", "cpu": [ "arm64" ], @@ -4017,9 +3972,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", "cpu": [ "x64" ], @@ -4034,9 +3989,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -4295,9 +4250,9 @@ } }, "node_modules/@semantic-release/github": { - "version": "12.0.6", - "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.6.tgz", - "integrity": "sha512-aYYFkwHW3c6YtHwQF0t0+lAjlU+87NFOZuH2CvWFD0Ylivc7MwhZMiHOJ0FMpIgPpCVib/VUAcOwvrW0KnxQtA==", + "version": "12.0.8", + "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.8.tgz", + "integrity": "sha512-tej5AAgK5X9wHRoDmYhecMXEHEkFeGOY1XsEblKxu8pIQwahzf1STYyr7iPU6Lpbg6C5I3N2w/ocXrBo+L7jhw==", "dev": true, "license": "MIT", "dependencies": { @@ -4309,8 +4264,8 @@ "aggregate-error": "^5.0.0", "debug": "^4.3.4", "dir-glob": "^3.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", + "http-proxy-agent": "^9.0.0", + "https-proxy-agent": "^9.0.0", "issue-parser": "^7.0.0", "lodash-es": "^4.17.21", "mime": "^4.0.0", @@ -4697,9 +4652,9 @@ } }, "node_modules/@semantic-release/release-notes-generator": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.0.tgz", - "integrity": "sha512-CcyDRk7xq+ON/20YNR+1I/jP7BYKICr1uKd1HHpROSnnTdGqOTburi4jcRiTYz0cpfhxSloQO3cGhnoot7IEkA==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.1.tgz", + "integrity": "sha512-Pbd2e2XRMUD0OxehHpgd5/YghsE76cddkRHSoDvKLK+OCy4Ewxn49rWR631MEUU01lgwF/uyVXvbnVuu6+Z6VA==", "dev": true, "license": "MIT", "dependencies": { @@ -4708,9 +4663,7 @@ "conventional-commits-filter": "^5.0.0", "conventional-commits-parser": "^6.0.0", "debug": "^4.0.0", - "get-stream": "^7.0.0", "import-from-esm": "^2.0.0", - "into-stream": "^7.0.0", "lodash-es": "^4.17.21", "read-package-up": "^11.0.0" }, @@ -4819,13 +4772,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", - "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/normalize-package-data": { @@ -4846,14 +4799,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", - "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.9", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -4867,8 +4820,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.5", - "vitest": "4.1.5" + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -4877,16 +4830,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -4895,13 +4848,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.5", + "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4922,9 +4875,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { @@ -4935,13 +4888,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.9", "pathe": "^2.0.3" }, "funding": { @@ -4949,14 +4902,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -4965,9 +4918,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, "license": "MIT", "funding": { @@ -4975,13 +4928,13 @@ } }, "node_modules/@vitest/ui": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.5.tgz", - "integrity": "sha512-3Z9HNFiV0IF1fk0JPiK+7kE1GcaIPefQQIBYur6PM5yFIq6agys3uqP/0t966e1wXfmjbRCHDe7qW236Xjwnag==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.9.tgz", + "integrity": "sha512-U/cRvtqfEPj27FI1n9cyUvi4vXXdcLhjJiI+InYKdk8hP4VrS6RXOjGL7rfFaeBc37iRKANsR6eEzIoC7lmgBQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.9", "fflate": "^0.8.2", "flatted": "^3.4.2", "pathe": "^2.0.3", @@ -4993,17 +4946,17 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.5" + "vitest": "4.1.9" } }, "node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", + "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -5069,13 +5022,13 @@ } }, "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 14" + "node": ">= 20" } }, "node_modules/aggregate-error": { @@ -6313,9 +6266,9 @@ ] }, "node_modules/esbuild": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz", - "integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -6326,32 +6279,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.1", - "@esbuild/android-arm": "0.27.1", - "@esbuild/android-arm64": "0.27.1", - "@esbuild/android-x64": "0.27.1", - "@esbuild/darwin-arm64": "0.27.1", - "@esbuild/darwin-x64": "0.27.1", - "@esbuild/freebsd-arm64": "0.27.1", - "@esbuild/freebsd-x64": "0.27.1", - "@esbuild/linux-arm": "0.27.1", - "@esbuild/linux-arm64": "0.27.1", - "@esbuild/linux-ia32": "0.27.1", - "@esbuild/linux-loong64": "0.27.1", - "@esbuild/linux-mips64el": "0.27.1", - "@esbuild/linux-ppc64": "0.27.1", - "@esbuild/linux-riscv64": "0.27.1", - "@esbuild/linux-s390x": "0.27.1", - "@esbuild/linux-x64": "0.27.1", - "@esbuild/netbsd-arm64": "0.27.1", - "@esbuild/netbsd-x64": "0.27.1", - "@esbuild/openbsd-arm64": "0.27.1", - "@esbuild/openbsd-x64": "0.27.1", - "@esbuild/openharmony-arm64": "0.27.1", - "@esbuild/sunos-x64": "0.27.1", - "@esbuild/win32-arm64": "0.27.1", - "@esbuild/win32-ia32": "0.27.1", - "@esbuild/win32-x64": "0.27.1" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { @@ -6418,19 +6371,6 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/execa/node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/execa/node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -6722,17 +6662,6 @@ "node": ">=12.20.0" } }, - "node_modules/from2": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", - "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "readable-stream": "^2.0.0" - } - }, "node_modules/fs-extra": { "version": "11.3.4", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", @@ -6810,13 +6739,13 @@ } }, "node_modules/get-stream": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-7.0.1.tgz", - "integrity": "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", "dev": true, "license": "MIT", "engines": { - "node": ">=16" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -7162,31 +7091,33 @@ "license": "MIT" }, "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" }, "engines": { - "node": ">= 14" + "node": ">= 20" } }, "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" }, "engines": { - "node": ">= 14" + "node": ">= 20" } }, "node_modules/human-signals": { @@ -7338,23 +7269,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/into-stream": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-7.0.0.tgz", - "integrity": "sha512-2dYz766i9HprMBasCMvHMuazJ7u4WzhJwo5kb3iPSiW/iRYV6uPari3zHoqZlnuaR7V1bEiNMxikhp37rdBXbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "from2": "^2.3.0", - "p-is-promise": "^3.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -7804,9 +7718,9 @@ } }, "node_modules/lefthook": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.1.6.tgz", - "integrity": "sha512-w9sBoR0mdN+kJc3SB85VzpiAAl451/rxdCRcZlwW71QLjkeH3EBQFgc4VMj5apePychYDHAlqEWTB8J8JK/j1Q==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.1.9.tgz", + "integrity": "sha512-bwDaIOViTktE8kJLf9jP0p+H2/RDTlFFlc43Am2YgUsX22hI6Sq4RbzsrecwzY5y+MHTipOH7WsmWSEniePHWQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -7814,22 +7728,22 @@ "lefthook": "bin/index.js" }, "optionalDependencies": { - "lefthook-darwin-arm64": "2.1.6", - "lefthook-darwin-x64": "2.1.6", - "lefthook-freebsd-arm64": "2.1.6", - "lefthook-freebsd-x64": "2.1.6", - "lefthook-linux-arm64": "2.1.6", - "lefthook-linux-x64": "2.1.6", - "lefthook-openbsd-arm64": "2.1.6", - "lefthook-openbsd-x64": "2.1.6", - "lefthook-windows-arm64": "2.1.6", - "lefthook-windows-x64": "2.1.6" + "lefthook-darwin-arm64": "2.1.9", + "lefthook-darwin-x64": "2.1.9", + "lefthook-freebsd-arm64": "2.1.9", + "lefthook-freebsd-x64": "2.1.9", + "lefthook-linux-arm64": "2.1.9", + "lefthook-linux-x64": "2.1.9", + "lefthook-openbsd-arm64": "2.1.9", + "lefthook-openbsd-x64": "2.1.9", + "lefthook-windows-arm64": "2.1.9", + "lefthook-windows-x64": "2.1.9" } }, "node_modules/lefthook-darwin-arm64": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-2.1.6.tgz", - "integrity": "sha512-hyB7eeiX78BS66f70byTJacDLC/xV1vgMv9n+idFUsrM7J3Udd/ag9Ag5NP3t0eN0EqQqAtrNnt35EH01lxnRQ==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-2.1.9.tgz", + "integrity": "sha512-119HryNcvr4nqn0wUIrNPgpMEPn9yMQzEcW/lezRsnb56PCJriJB92+MCySPVcWDxJnZef7o0T3jdnPNiSH7Qg==", "cpu": [ "arm64" ], @@ -7841,9 +7755,9 @@ ] }, "node_modules/lefthook-darwin-x64": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/lefthook-darwin-x64/-/lefthook-darwin-x64-2.1.6.tgz", - "integrity": "sha512-5Ka6cFxiH83krt+OMRQtmS6zqoZR5SLXSudLjTbZA1c3ZqF0+dqkeb4XcB6plx6WR0GFizabuc6Bi3iXPIe1eQ==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/lefthook-darwin-x64/-/lefthook-darwin-x64-2.1.9.tgz", + "integrity": "sha512-dwo5Tke2XcQCM56DGHgFKBfRbJIL6xs2wZ0zG1TUVZgl4t4mQUt6LiZ4V/ZQfYHTZF9qywvXoIlR5N35qOaiVQ==", "cpu": [ "x64" ], @@ -7855,9 +7769,9 @@ ] }, "node_modules/lefthook-freebsd-arm64": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/lefthook-freebsd-arm64/-/lefthook-freebsd-arm64-2.1.6.tgz", - "integrity": "sha512-VswyOg5CVN3rMaOJ2HtnkltiMKgFHW/wouWxXsV8RxSa4tgWOKxM0EmSXi8qc2jX+LRga6B0uOY6toXS01zWxA==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/lefthook-freebsd-arm64/-/lefthook-freebsd-arm64-2.1.9.tgz", + "integrity": "sha512-+09PVap6nl6xsaHch5JLtq7WvIR++U1Q2MzA2ai0M4uB/VP3AqrvKqHw6+9hjyKnIH+HHL83uqi77EAY+LaxLA==", "cpu": [ "arm64" ], @@ -7869,9 +7783,9 @@ ] }, "node_modules/lefthook-freebsd-x64": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/lefthook-freebsd-x64/-/lefthook-freebsd-x64-2.1.6.tgz", - "integrity": "sha512-vXsCUFYuVwrVWwcypB7Zt2Hf+5pl1V1la7ZfvGYZaTRURu0zF/XUnMF/nOz/PebGv0f4x/iOWXWwP7E42xRWsg==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/lefthook-freebsd-x64/-/lefthook-freebsd-x64-2.1.9.tgz", + "integrity": "sha512-8XresjKIYpkE9ARgCtBEZgJZxAU3T4MIqzj4zNy15XRT59I1Us+QdqXTNm+pkZ41Yd2X/nxs2Pkvbq3NWWlIGw==", "cpu": [ "x64" ], @@ -7883,9 +7797,9 @@ ] }, "node_modules/lefthook-linux-arm64": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/lefthook-linux-arm64/-/lefthook-linux-arm64-2.1.6.tgz", - "integrity": "sha512-WDJiQhJdZOvKORZd+kF/ms2l6NSsXzdA9ahflyr65V90AC4jES223W8VtEMbGPUtHuGWMEZ/v/XvwlWv0Ioz9g==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/lefthook-linux-arm64/-/lefthook-linux-arm64-2.1.9.tgz", + "integrity": "sha512-1oNIQfwrPe6rgU2KcDM3aF6+hpZDCKx1TmawQKpXUY5gVsbZ7MqX0Sk/1lnnWxqPm+kQQ5f6J2dpFWd+4xH8jg==", "cpu": [ "arm64" ], @@ -7897,9 +7811,9 @@ ] }, "node_modules/lefthook-linux-x64": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/lefthook-linux-x64/-/lefthook-linux-x64-2.1.6.tgz", - "integrity": "sha512-C18nCd7nTX1AVL4TcvwMmLAO1VI1OuGluIOTjiPkBQ746Ls1HhL5rl//jMPACmT28YmxIQJ2ZcLPNmhvEVBZvw==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/lefthook-linux-x64/-/lefthook-linux-x64-2.1.9.tgz", + "integrity": "sha512-fT+7Q+BJyGp+CslFQkNXmdFRgyVXsPHPi9NAsDX0a6QOyNnoORByAsvx6zeAKuF5rL3BBgNfho1/v2RuGxGy9w==", "cpu": [ "x64" ], @@ -7911,9 +7825,9 @@ ] }, "node_modules/lefthook-openbsd-arm64": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/lefthook-openbsd-arm64/-/lefthook-openbsd-arm64-2.1.6.tgz", - "integrity": "sha512-mZOMxM8HiPxVFXDO3PtCUbH4GB8rkveXhsgXF27oAZTYVzQ3gO9vT6r/pxit6msqRXz3fvcwimLVJgb8eRsa8A==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/lefthook-openbsd-arm64/-/lefthook-openbsd-arm64-2.1.9.tgz", + "integrity": "sha512-4bVuafBk3dddVNo0+3hMbjcJs4mqYAstxpPMmX2ufkudSTYFNIhWoqwuGVQV/SS/xdcOKJAldW4qayAzed2ysw==", "cpu": [ "arm64" ], @@ -7925,9 +7839,9 @@ ] }, "node_modules/lefthook-openbsd-x64": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-2.1.6.tgz", - "integrity": "sha512-sG9ALLZSnnMOfXu+B7SmxFhJhuoAh4bqi5En5aaHJET48TqrLOcWWZuH+7ArFM6gr/U5KfSUvdmHFmY8WqCcIg==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-2.1.9.tgz", + "integrity": "sha512-PmPoMmLP/wQQWcQ9u2YH86bTZ3UCfBsxuEmVTEyPU2U8R1qSTp5r/Gs3G8cN5Mxo91XB9oBERtF1n+xD3W6aVA==", "cpu": [ "x64" ], @@ -7939,9 +7853,9 @@ ] }, "node_modules/lefthook-windows-arm64": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/lefthook-windows-arm64/-/lefthook-windows-arm64-2.1.6.tgz", - "integrity": "sha512-lD8yFWY4Csuljd0Rqs7EQaySC0VvDf7V3rN1FhRMUISTRDHutebIom1Loc8ckQPvKYGC6mftT9k0GvipsS+Brw==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/lefthook-windows-arm64/-/lefthook-windows-arm64-2.1.9.tgz", + "integrity": "sha512-KphfkBKmwBnmolyrdhIl3lrBaOyTcCgXBT2AB/9OHnEXhOLvv5uTCUkrD4YRAxXPtFKq6UvnapIeoL3GZq0bdA==", "cpu": [ "arm64" ], @@ -7953,9 +7867,9 @@ ] }, "node_modules/lefthook-windows-x64": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/lefthook-windows-x64/-/lefthook-windows-x64-2.1.6.tgz", - "integrity": "sha512-q4z2n3xucLscoWiyMwFViEj3N8MDSkPulMwcJYuCYFHoPhP1h+icqNu7QRLGYj6AnVrCQweiUJY3Tb2X+GbD/A==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/lefthook-windows-x64/-/lefthook-windows-x64-2.1.9.tgz", + "integrity": "sha512-2qlUtkJHZ3MyUxgV5XTEmcrIoNZA07iwaquoswAcqv/1MeBFXlD+O+koFRfrzWng2O5WYEbpJnd8tvaYnV8fTA==", "cpu": [ "x64" ], @@ -8821,9 +8735,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, "funding": [ { @@ -10943,16 +10857,6 @@ "@oxc-parser/binding-win32-x64-msvc": "0.137.0" } }, - "node_modules/oxc-parser/node_modules/@oxc-project/types": { - "version": "0.137.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", - "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, "node_modules/oxc-resolver": { "version": "11.21.3", "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.21.3.tgz", @@ -11029,16 +10933,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-is-promise": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", - "integrity": "sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -11349,9 +11243,9 @@ } }, "node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -11369,7 +11263,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -11407,6 +11301,24 @@ "dev": true, "license": "ISC" }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -11636,14 +11548,14 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -11652,21 +11564,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" } }, "node_modules/run-parallel": { @@ -11708,9 +11620,9 @@ "license": "MIT" }, "node_modules/semantic-release": { - "version": "25.0.3", - "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.3.tgz", - "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", + "version": "25.0.5", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.5.tgz", + "integrity": "sha512-mn61SUJwtM8ThrWn2WmgLVpwVJeG/hPSupua1psdMoufmwRIPyvRLkRkL0JDXkP67OntlLWUYnBnfVc8EDO3/g==", "dev": true, "license": "MIT", "dependencies": { @@ -11859,23 +11771,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/semantic-release/node_modules/hosted-git-info": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", - "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", "dev": true, "license": "ISC", "dependencies": { @@ -11922,9 +11821,9 @@ } }, "node_modules/semantic-release/node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -12103,9 +12002,9 @@ } }, "node_modules/semantic-release/node_modules/type-fest": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", - "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", "dev": true, "license": "(MIT OR CC0-1.0)", "dependencies": { @@ -13005,14 +12904,13 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" @@ -13106,9 +13004,9 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, @@ -13267,17 +13165,17 @@ } }, "node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", - "tinyglobby": "^0.2.16" + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -13293,7 +13191,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -13345,19 +13243,19 @@ } }, "node_modules/vitest": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.5", - "@vitest/mocker": "4.1.5", - "@vitest/pretty-format": "4.1.5", - "@vitest/runner": "4.1.5", - "@vitest/snapshot": "4.1.5", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -13385,12 +13283,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.5", - "@vitest/browser-preview": "4.1.5", - "@vitest/browser-webdriverio": "4.1.5", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/ui": "4.1.5", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" From e48bd3913e828bbadca1c88b9f5f3a9dd6f2aaa4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:39:10 +0000 Subject: [PATCH 27/79] chore(deps): update graphqlcodegenerator monorepo --- package-lock.json | 1283 ++++++++++++++++----------------------------- package.json | 4 +- 2 files changed, 464 insertions(+), 823 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4ed84056..aab841ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,8 +21,8 @@ "@biomejs/biome": "^2.3.14", "@commitlint/cli": "^21.0.0", "@commitlint/config-conventional": "^21.0.0", - "@graphql-codegen/cli": "^6.1.1", - "@graphql-codegen/client-preset": "^5.2.2", + "@graphql-codegen/cli": "^7.0.0", + "@graphql-codegen/client-preset": "^6.0.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/exec": "^7.1.0", @@ -358,9 +358,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "dev": true, "license": "MIT", "engines": { @@ -634,92 +634,6 @@ "node": ">=22.12.0" } }, - "node_modules/@commitlint/cli/node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@commitlint/cli/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@commitlint/cli/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@commitlint/cli/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@commitlint/cli/node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/@commitlint/cli/node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, "node_modules/@commitlint/config-conventional": { "version": "21.1.0", "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.1.0.tgz", @@ -1503,13 +1417,13 @@ "license": "MIT" }, "node_modules/@graphql-codegen/add": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-6.0.1.tgz", - "integrity": "sha512-MSylSekjpVWbOBw2A/2ssk1fPY54sYb6Qk2C4AX5u7s2R+2pMQ9ws7DTXo8VU9qwTgWwVp6vGfdQ0AMpAn4Iug==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-7.0.1.tgz", + "integrity": "sha512-kWw6RMu9ysBw1wcgcgf9mOnswc5M3ekOApDTiaJC/UZNTEYins01srZHYTP7z3P/WlGGC844BRtjwh3U2kNd/A==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.3.0", + "@graphql-codegen/plugin-helpers": "^7.0.1", "tslib": "^2.8.0" }, "engines": { @@ -1520,18 +1434,18 @@ } }, "node_modules/@graphql-codegen/cli": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-6.3.1.tgz", - "integrity": "sha512-I5KkyX1SgQZPojMeQTRydB6fml4cysZq/mIdhNW4rmqdoOcTgdMPq1Tl+wtRp1VpBAOrBazJUJh1nAqJMMSPIQ==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-7.1.3.tgz", + "integrity": "sha512-mMYwpvpqJjjHoA/c6HBjdlbT8JqFC6W85RB80tpHACapufBnLlyNtYHYeOYAoUuU1n3cGQi1if1pKHnjLgS/eQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/generator": "^7.18.13", "@babel/template": "^7.18.10", "@babel/types": "^7.18.13", - "@graphql-codegen/client-preset": "^5.3.0", - "@graphql-codegen/core": "^5.0.2", - "@graphql-codegen/plugin-helpers": "^6.3.0", + "@graphql-codegen/client-preset": "^6.0.1", + "@graphql-codegen/core": "^6.1.0", + "@graphql-codegen/plugin-helpers": "^7.0.1", "@graphql-tools/apollo-engine-loader": "^8.0.28", "@graphql-tools/code-file-loader": "^8.1.28", "@graphql-tools/git-loader": "^8.0.32", @@ -1542,30 +1456,31 @@ "@graphql-tools/merge": "^9.0.6", "@graphql-tools/url-loader": "^9.0.6", "@graphql-tools/utils": "^11.0.0", - "@inquirer/prompts": "^7.8.2", + "@inquirer/prompts": "^8.3.2", "@whatwg-node/fetch": "^0.10.0", - "chalk": "^4.1.0", + "chalk": "^5.6.0", "cosmiconfig": "^9.0.0", - "debounce": "^2.0.0", - "detect-indent": "^6.0.0", + "debounce": "^3.0.0", + "detect-indent": "^7.0.0", "graphql-config": "^5.1.6", "is-glob": "^4.0.1", "jiti": "^2.3.0", "json-to-pretty-yaml": "^1.2.2", - "listr2": "^9.0.0", - "log-symbols": "^4.0.0", + "listr2": "^10.2.1", + "log-symbols": "^7.0.0", "micromatch": "^4.0.5", "shell-quote": "^1.7.3", "string-env-interpolation": "^1.0.1", - "ts-log": "^2.2.3", + "ts-log": "^3.0.0", "tslib": "^2.4.0", "yaml": "^2.3.1", - "yargs": "^17.0.0" + "yargs": "^18.0.0" }, "bin": { - "gql-gen": "cjs/bin.js", - "graphql-code-generator": "cjs/bin.js", - "graphql-codegen": "cjs/bin.js", + "gql-gen": "esm/bin.js", + "graphql-code-generator": "esm/bin.js", + "graphql-codegen": "esm/bin.js", + "graphql-codegen-cjs": "cjs/bin.js", "graphql-codegen-esm": "esm/bin.js" }, "engines": { @@ -1581,22 +1496,35 @@ } } }, + "node_modules/@graphql-codegen/cli/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/@graphql-codegen/client-preset": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-5.3.0.tgz", - "integrity": "sha512-K9FON+j7qyxAUDuSGqI3ofb7lWTBs16oPTYpu14lhdL4DKZQSHLyc8EMYU9e3KcyQ/13gU/d6culOppzAuexLA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-6.0.1.tgz", + "integrity": "sha512-6wh0ZHG9WzBD6bE4AVOO6VCCMXK2orxHuXxaNKj+sj1w0qZ3Y3WIjZnqZLg6JZrHCIs/e+gy3T15Dc2pH8IbHA==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.20.2", "@babel/template": "^7.20.7", - "@graphql-codegen/add": "^6.0.1", - "@graphql-codegen/gql-tag-operations": "5.2.0", - "@graphql-codegen/plugin-helpers": "^6.3.0", - "@graphql-codegen/typed-document-node": "^6.1.8", - "@graphql-codegen/typescript": "^5.0.10", - "@graphql-codegen/typescript-operations": "^5.1.0", - "@graphql-codegen/visitor-plugin-common": "^6.3.0", + "@graphql-codegen/add": "^7.0.1", + "@graphql-codegen/gql-tag-operations": "^6.0.1", + "@graphql-codegen/plugin-helpers": "^7.0.1", + "@graphql-codegen/typed-document-node": "^7.0.1", + "@graphql-codegen/typescript": "^6.0.2", + "@graphql-codegen/typescript-operations": "^6.0.3", + "@graphql-codegen/visitor-plugin-common": "^7.0.3", "@graphql-tools/documents": "^1.0.0", "@graphql-tools/utils": "^11.0.0", "@graphql-typed-document-node/core": "3.2.0", @@ -1615,36 +1543,36 @@ } } }, - "node_modules/@graphql-codegen/core": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@graphql-codegen/core/-/core-5.0.2.tgz", - "integrity": "sha512-7RX0wwjoWPlLG/tUmpaTK91ZZqHcACNWpRL0nGnnJaJrORie9pgmX8JPrcwBgYiHSC+3ERo9xY91RFPem/VrpQ==", + "node_modules/@graphql-codegen/client-preset/node_modules/@graphql-codegen/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-zyLfKsFJ7TRkQ0PyaUVuiAek9TSbtVJwwBoOuaE9RAWr45+9Y5W1LYldpiSTcyfxKVSIniE7Gj0V87qzrpdyYw==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.3.0", - "@graphql-tools/schema": "^10.0.0", - "@graphql-tools/utils": "^11.0.0", - "tslib": "^2.8.0" + "@graphql-codegen/plugin-helpers": "^7.0.1", + "@graphql-codegen/schema-ast": "^6.0.1", + "@graphql-codegen/visitor-plugin-common": "^7.0.3", + "auto-bind": "^5.0.0", + "tslib": "~2.8.0" }, "engines": { "node": ">=16" }, "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, - "node_modules/@graphql-codegen/gql-tag-operations": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/gql-tag-operations/-/gql-tag-operations-5.2.0.tgz", - "integrity": "sha512-B9gtJ4ziqpIv+7mHqwjtpYLFOuv0GmmRGpNDoWKM2VIx4OQqgI84d6OHKYCVeO7yu3mUr0QPvUgkSyuLVrdukA==", + "node_modules/@graphql-codegen/core": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/core/-/core-6.1.0.tgz", + "integrity": "sha512-jReAzuCYlrSBJHW2bfBpDl/vMRCw0yQEoTvGi9K+3OTsazDXEQGOpCVfj8p/xO2h7ynu5Yrvzo0sUylVv0CnwA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.3.0", - "@graphql-codegen/visitor-plugin-common": "^6.3.0", + "@graphql-codegen/plugin-helpers": "^7.0.1", + "@graphql-tools/schema": "^10.0.0", "@graphql-tools/utils": "^11.0.0", - "auto-bind": "~4.0.0", "tslib": "^2.8.0" }, "engines": { @@ -1654,17 +1582,17 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, - "node_modules/@graphql-codegen/plugin-helpers": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-6.3.0.tgz", - "integrity": "sha512-Auc+/B7okDx9+pVgLVliZtZLYh6iltWXlnzzM+bRE+zh1T4r3hKbnr8xAmtT937ArfSgk5GHcQHr8LfPYnrRBg==", + "node_modules/@graphql-codegen/gql-tag-operations": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/gql-tag-operations/-/gql-tag-operations-6.0.1.tgz", + "integrity": "sha512-eHYUIchZLG6G+kafeKnUByL2Nkmb8Uj2vg33UVLFj8XJ2coC4b1iRDWxCdTXbupZrN0FaM0QRRBLs3zBEAzcJg==", "dev": true, "license": "MIT", "dependencies": { + "@graphql-codegen/plugin-helpers": "^7.0.1", + "@graphql-codegen/visitor-plugin-common": "^7.0.3", "@graphql-tools/utils": "^11.0.0", - "change-case-all": "1.0.15", - "common-tags": "1.8.2", - "import-from": "4.0.0", + "auto-bind": "^5.0.0", "tslib": "^2.8.0" }, "engines": { @@ -1674,15 +1602,17 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, - "node_modules/@graphql-codegen/schema-ast": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@graphql-codegen/schema-ast/-/schema-ast-5.0.2.tgz", - "integrity": "sha512-jl1F/9IjRkJisEb9B0ayG4QGqYlPldLRy8ojDdmL9NE1NsdB5ROfxQnSqyC3g+wuvBhWX7kZgMRQYn3RU1I5bA==", + "node_modules/@graphql-codegen/plugin-helpers": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-7.0.1.tgz", + "integrity": "sha512-S2X0YT3XQbP2haqhIeku8GOXo2j8QuBu7BrLsOEHz4UeMu78y3rja1Q4ri3oJ0jq4dMgaQlazoVHI/A+FAKMGw==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.3.0", "@graphql-tools/utils": "^11.0.0", + "change-case-all": "^2.1.0", + "common-tags": "1.8.2", + "import-from": "4.0.0", "tslib": "^2.8.0" }, "engines": { @@ -1692,17 +1622,15 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, - "node_modules/@graphql-codegen/typed-document-node": { - "version": "6.1.8", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typed-document-node/-/typed-document-node-6.1.8.tgz", - "integrity": "sha512-+qDdiJSQ7Ol+vpLMAH8ZJok50CvlYxA6seQ7cwEa3emXt8MmH5hh3zdc9unQlPc7bynoJHRCgoKk7E0B7hry0w==", + "node_modules/@graphql-codegen/schema-ast": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/schema-ast/-/schema-ast-6.0.1.tgz", + "integrity": "sha512-P16b6XCWXfcrA4fkuAyqoy883USAULifv8YWgEOrNKDAnr2DR+Kr85jSomknIUTY39wiuvisv4/lrdXobwK6sA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.3.0", - "@graphql-codegen/visitor-plugin-common": "^6.3.0", - "auto-bind": "~4.0.0", - "change-case-all": "1.0.15", + "@graphql-codegen/plugin-helpers": "^7.0.1", + "@graphql-tools/utils": "^11.0.0", "tslib": "^2.8.0" }, "engines": { @@ -1712,37 +1640,37 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, - "node_modules/@graphql-codegen/typescript": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-5.0.10.tgz", - "integrity": "sha512-Pa8OFmL9TdhEYnLYJLYA9EhP8eEeivP/YDYq4Nb8LQaL7GXm4TGX8zELYaCM9Fu8M3iZb7iQGMt7qc+1lXz8XQ==", + "node_modules/@graphql-codegen/typed-document-node": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typed-document-node/-/typed-document-node-7.0.3.tgz", + "integrity": "sha512-l/4KenYJG5D8Aj6Aa2KPeS0fdMIIi04Qx28d4SLwMWuyFU9WXspU5mR9YMNDRZzaTgBtGR8aMIl9RzyiWf5uUw==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.3.0", - "@graphql-codegen/schema-ast": "^5.0.2", - "@graphql-codegen/visitor-plugin-common": "^6.3.0", - "auto-bind": "~4.0.0", + "@graphql-codegen/plugin-helpers": "^7.0.1", + "@graphql-codegen/visitor-plugin-common": "^7.1.0", + "auto-bind": "^5.0.0", + "change-case-all": "^2.1.0", "tslib": "^2.8.0" }, "engines": { "node": ">=16" }, "peerDependencies": { - "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "node_modules/@graphql-codegen/typescript-operations": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-5.1.0.tgz", - "integrity": "sha512-JlmjbFl0EnsfMDIYvTE1Q0kAOrntVEZ+ZfBqWTP91g4e0F/TzuwJ/V4tiFmeDf5dx/rf9AK4VkPehIdxu7TYhw==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-6.0.5.tgz", + "integrity": "sha512-amsfjYbLIbYUIPr481hlXXjgnm5knel3v6EXJ8oThhcbOoT332XJjzjohZaTjij7eqebOewzzRXLPWi8z+mgSA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.3.0", - "@graphql-codegen/typescript": "^5.0.10", - "@graphql-codegen/visitor-plugin-common": "^6.3.0", - "auto-bind": "~4.0.0", + "@graphql-codegen/plugin-helpers": "^7.0.1", + "@graphql-codegen/schema-ast": "^6.0.1", + "@graphql-codegen/visitor-plugin-common": "^7.1.2", + "auto-bind": "^5.0.0", "tslib": "^2.8.0" }, "engines": { @@ -1759,18 +1687,18 @@ } }, "node_modules/@graphql-codegen/visitor-plugin-common": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-6.3.0.tgz", - "integrity": "sha512-vGBoE+4huzZyNhyGSAhXAkdROHlwKxxuziZm4XtP1mxe7nuI+VgyOmXebafLijbmuDsptPXQN0C/htL54O8hrg==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-7.1.2.tgz", + "integrity": "sha512-1TdaKeDBj91ZYcJO9fMHHo02HEoWCSYhlsi4B7EGto7cqQa2htraAi+sCYIxQuccxNsRmM6apQ+O5+cJj1Bs7w==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.3.0", + "@graphql-codegen/plugin-helpers": "^7.0.1", "@graphql-tools/optimize": "^2.0.0", "@graphql-tools/relay-operation-optimizer": "^7.1.1", "@graphql-tools/utils": "^11.0.0", - "auto-bind": "~4.0.0", - "change-case-all": "1.0.15", + "auto-bind": "^5.0.0", + "change-case-all": "^2.1.0", "dependency-graph": "^1.0.0", "graphql-tag": "^2.11.0", "parse-filepath": "^1.0.2", @@ -2170,14 +2098,14 @@ } }, "node_modules/@graphql-tools/relay-operation-optimizer": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-7.1.3.tgz", - "integrity": "sha512-Vzh5QORIqX0KtwxgNepl/T16a85Br7YbOxxxmnyVpS7yza9vBjkrERbvAwADcYyPH7kyShmH1Gu5+88+vCVhuA==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-7.1.5.tgz", + "integrity": "sha512-B3nscUeWT3wYucrvbJcmU8sAVlkCp+WhZ5wVlK432AfnLjDYUNLobHYzQnnU7tT5NncMXusPiS8K4YG4iUPjrw==", "dev": true, "license": "MIT", "dependencies": { "@ardatan/relay-compiler": "^13.0.1", - "@graphql-tools/utils": "^11.0.1", + "@graphql-tools/utils": "^11.1.1", "tslib": "^2.4.0" }, "engines": { @@ -2233,9 +2161,9 @@ } }, "node_modules/@graphql-tools/utils": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.0.1.tgz", - "integrity": "sha512-pNyCOb95ab/z3zkkiPwIPYxigX7IcpyFVcgD1XACDEvg/7yGnKCESx3k/XHEeneKYx/aWKGzEh/uuf6M6Q8HOw==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.1.1.tgz", + "integrity": "sha512-MuWwacINZZV6mX1ZSk6CcV4XVQLsbKBG/hLd0QPhe4GHxjqr2ATjKsnnwlB0TKI+QQvj2U8ewu8WeAzz9kC2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -2281,30 +2209,29 @@ } }, "node_modules/@inquirer/ansi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", - "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } }, "node_modules/@inquirer/checkbox": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", - "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.1.tgz", + "integrity": "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2316,17 +2243,17 @@ } }, "node_modules/@inquirer/confirm": { - "version": "5.1.21", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", - "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2338,23 +2265,22 @@ } }, "node_modules/@inquirer/core": { - "version": "10.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", - "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", "cli-width": "^4.1.0", - "mute-stream": "^2.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.3" + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2365,73 +2291,19 @@ } } }, - "node_modules/@inquirer/core/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@inquirer/core/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@inquirer/core/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@inquirer/core/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@inquirer/editor": { - "version": "4.2.23", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", - "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.2.2.tgz", + "integrity": "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/external-editor": "^1.0.3", - "@inquirer/type": "^3.0.10" + "@inquirer/core": "^11.2.1", + "@inquirer/external-editor": "^3.0.3", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2443,18 +2315,17 @@ } }, "node_modules/@inquirer/expand": { - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", - "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.1.tgz", + "integrity": "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2466,17 +2337,17 @@ } }, "node_modules/@inquirer/external-editor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", - "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.3.tgz", + "integrity": "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==", "dev": true, "license": "MIT", "dependencies": { "chardet": "^2.1.1", - "iconv-lite": "^0.7.0" + "iconv-lite": "^0.7.2" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2488,27 +2359,27 @@ } }, "node_modules/@inquirer/figures": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", - "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } }, "node_modules/@inquirer/input": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", - "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.2.tgz", + "integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2520,17 +2391,17 @@ } }, "node_modules/@inquirer/number": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", - "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.1.1.tgz", + "integrity": "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2542,18 +2413,18 @@ } }, "node_modules/@inquirer/password": { - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", - "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.1.1.tgz", + "integrity": "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2565,25 +2436,25 @@ } }, "node_modules/@inquirer/prompts": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", - "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.5.2.tgz", + "integrity": "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/checkbox": "^4.3.2", - "@inquirer/confirm": "^5.1.21", - "@inquirer/editor": "^4.2.23", - "@inquirer/expand": "^4.0.23", - "@inquirer/input": "^4.3.1", - "@inquirer/number": "^3.0.23", - "@inquirer/password": "^4.0.23", - "@inquirer/rawlist": "^4.1.11", - "@inquirer/search": "^3.2.2", - "@inquirer/select": "^4.4.2" + "@inquirer/checkbox": "^5.2.1", + "@inquirer/confirm": "^6.1.1", + "@inquirer/editor": "^5.2.2", + "@inquirer/expand": "^5.1.1", + "@inquirer/input": "^5.1.2", + "@inquirer/number": "^4.1.1", + "@inquirer/password": "^5.1.1", + "@inquirer/rawlist": "^5.3.1", + "@inquirer/search": "^4.2.1", + "@inquirer/select": "^5.2.1" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2595,18 +2466,17 @@ } }, "node_modules/@inquirer/rawlist": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", - "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.1.tgz", + "integrity": "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2618,19 +2488,18 @@ } }, "node_modules/@inquirer/search": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", - "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.2.1.tgz", + "integrity": "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2642,20 +2511,19 @@ } }, "node_modules/@inquirer/select": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", - "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.1.tgz", + "integrity": "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2667,13 +2535,13 @@ } }, "node_modules/@inquirer/type": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", - "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -5172,13 +5040,13 @@ "license": "MIT" }, "node_modules/auto-bind": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-4.0.0.tgz", - "integrity": "sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -5265,17 +5133,6 @@ "node": ">=6" } }, - "node_modules/camel-case": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", - "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "pascal-case": "^3.1.2", - "tslib": "^2.0.3" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001767", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz", @@ -5297,18 +5154,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/capital-case": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/capital-case/-/capital-case-1.0.4.tgz", - "integrity": "sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3", - "upper-case-first": "^2.0.2" - } - }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -5353,43 +5198,23 @@ } }, "node_modules/change-case": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/change-case/-/change-case-4.1.2.tgz", - "integrity": "sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "camel-case": "^4.1.2", - "capital-case": "^1.0.4", - "constant-case": "^3.0.4", - "dot-case": "^3.0.4", - "header-case": "^2.0.4", - "no-case": "^3.0.4", - "param-case": "^3.0.4", - "pascal-case": "^3.1.2", - "path-case": "^3.0.4", - "sentence-case": "^3.0.4", - "snake-case": "^3.0.4", - "tslib": "^2.0.3" - } + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" }, "node_modules/change-case-all": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/change-case-all/-/change-case-all-1.0.15.tgz", - "integrity": "sha512-3+GIFhk3sNuvFAJKU46o26OdzudQlPNBCu1ZQi3cMeMHhty1bhDxu2WrEilVNYaGvqUtR1VSigFcJOiS13dRhQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/change-case-all/-/change-case-all-2.1.0.tgz", + "integrity": "sha512-v6b0WWWkZUMHVuYk82l+WROgkUm4qEN2w5hKRNWtEOYwWqUGoi8C6xH0l1RLF1EoWqDFK6MFclmN3od6ws3/uw==", "dev": true, "license": "MIT", "dependencies": { - "change-case": "^4.1.2", - "is-lower-case": "^2.0.2", - "is-upper-case": "^2.0.2", - "lower-case": "^2.0.2", - "lower-case-first": "^2.0.2", - "sponge-case": "^1.0.1", - "swap-case": "^2.0.2", - "title-case": "^3.0.3", - "upper-case": "^2.0.2", - "upper-case-first": "^2.0.2" + "change-case": "^5.2.0", + "sponge-case": "^2.0.2", + "swap-case": "^3.0.2", + "title-case": "^3.0.3" } }, "node_modules/char-regex": { @@ -5402,9 +5227,9 @@ } }, "node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", "dev": true, "license": "MIT" }, @@ -5557,14 +5382,14 @@ } }, "node_modules/cli-truncate": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", - "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", "dev": true, "license": "MIT", "dependencies": { - "slice-ansi": "^7.1.0", - "string-width": "^8.0.0" + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" }, "engines": { "node": ">=20" @@ -5573,15 +5398,48 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/cli-truncate/node_modules/string-width": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.1.tgz", - "integrity": "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { "node": ">=20" @@ -5601,41 +5459,61 @@ } }, "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "dev": true, "license": "ISC", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=12" + "node": ">=20" } }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/cliui/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/color-convert": { @@ -5658,13 +5536,6 @@ "dev": true, "license": "MIT" }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", @@ -5713,18 +5584,6 @@ "dev": true, "license": "ISC" }, - "node_modules/constant-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/constant-case/-/constant-case-3.0.4.tgz", - "integrity": "sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3", - "upper-case": "^2.0.2" - } - }, "node_modules/conventional-changelog-angular": { "version": "8.3.1", "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz", @@ -5945,13 +5804,13 @@ "license": "MIT" }, "node_modules/debounce": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/debounce/-/debounce-2.2.0.tgz", - "integrity": "sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-3.0.0.tgz", + "integrity": "sha512-64byRbF0/AirwbuHqB3/ZpMG9/nckDa6ZA0yd6UnaQNwbbemCOwvz2sL5sjXLHhZHADyiwLm0M5qMhltUUx+TA==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -5996,13 +5855,16 @@ } }, "node_modules/detect-indent": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", - "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-7.0.2.tgz", + "integrity": "sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/detect-libc": { @@ -6028,17 +5890,6 @@ "node": ">=8" } }, - "node_modules/dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, "node_modules/dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -6455,6 +6306,23 @@ "node": ">=8.6.0" } }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, "node_modules/fast-uri": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", @@ -6472,6 +6340,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -6557,19 +6435,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/figures/node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -6726,9 +6591,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "dev": true, "license": "MIT", "engines": { @@ -6962,9 +6827,9 @@ } }, "node_modules/graphql-tag": { - "version": "2.12.6", - "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", - "integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==", + "version": "2.12.7", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.7.tgz", + "integrity": "sha512-xnE/NFzy+0eIesvAsREJZ284zTl/wYuBAvpsFSDhRGRdRHdnE90M21Q3xAWyYInb0J756c6x0pIQ62+vtvOs1Q==", "dev": true, "license": "MIT", "dependencies": { @@ -6974,7 +6839,7 @@ "node": ">=10" }, "peerDependencies": { - "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "node_modules/graphql-ws": { @@ -7036,17 +6901,6 @@ "node": ">=8" } }, - "node_modules/header-case": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.4.tgz", - "integrity": "sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "capital-case": "^1.0.4", - "tslib": "^2.0.3" - } - }, "node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -7158,9 +7012,9 @@ } }, "node_modules/immutable": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", - "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.8.tgz", + "integrity": "sha512-TM5YqrGeTsVIPPpILzeqZ8D2Zc2TvNgSDi88zPF2a4cyqQdWV/wVWBDRDbNzzrLeRWScrFcOX9lW2iX6GOtUDw==", "dev": true, "license": "MIT" }, @@ -7333,16 +7187,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-lower-case/-/is-lower-case-2.0.2.tgz", - "integrity": "sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -7416,26 +7260,16 @@ } }, "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-upper-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-upper-case/-/is-upper-case-2.0.2.tgz", - "integrity": "sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-windows": { @@ -8174,61 +8008,52 @@ "license": "MIT" }, "node_modules/listr2": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", - "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.2.tgz", + "integrity": "sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw==", "dev": true, "license": "MIT", "dependencies": { - "cli-truncate": "^5.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", + "cli-truncate": "^5.2.0", + "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" + "wrap-ansi": "^10.0.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.13.0" } }, - "node_modules/listr2/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, "node_modules/listr2/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/listr2/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", + "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -8335,17 +8160,17 @@ "license": "MIT" }, "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -8427,26 +8252,6 @@ "loose-envify": "cli.js" } }, - "node_modules/lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/lower-case-first": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case-first/-/lower-case-first-2.0.2.tgz", - "integrity": "sha512-EVm/rR94FJTZi3zefZ82fLWab+GX14LJN4HrWBcuo6Evmsl9hEfnqxgcHCKb9q+mNf6EVdsjx/qucYFIIB84pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -8713,13 +8518,13 @@ "license": "MIT" }, "node_modules/mute-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", - "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", "dev": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/mz": { @@ -8767,17 +8572,6 @@ "dev": true, "license": "MIT" }, - "node_modules/no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" - } - }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -11021,17 +10815,6 @@ "node": ">=4" } }, - "node_modules/param-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", - "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", - "dev": true, - "license": "MIT", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -11116,28 +10899,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pascal-case": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", - "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/path-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/path-case/-/path-case-3.0.4.tgz", - "integrity": "sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, "node_modules/path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", @@ -11705,28 +11466,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/semantic-release/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, "node_modules/semantic-release/node_modules/execa": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", @@ -11970,24 +11709,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/semantic-release/node_modules/strip-final-newline": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", @@ -12030,52 +11751,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/semantic-release/node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/semantic-release/node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -12102,18 +11777,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/sentence-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/sentence-case/-/sentence-case-3.0.4.tgz", - "integrity": "sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3", - "upper-case-first": "^2.0.2" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -12359,17 +12022,6 @@ "url": "https://github.com/sponsors/cyyynthia" } }, - "node_modules/snake-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", - "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -12444,14 +12096,11 @@ } }, "node_modules/sponge-case": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/sponge-case/-/sponge-case-1.0.1.tgz", - "integrity": "sha512-dblb9Et4DAtiZ5YSUZHLl4XhH4uK80GhAZrVXdN4O2P4gQ40Wa5UIOPUHlA/nFd2PLblBZWUioLMMAVrgpoYcA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/sponge-case/-/sponge-case-2.0.3.tgz", + "integrity": "sha512-i4h9ZGRfxV6Xw3mpZSFOfbXjf0cQcYmssGWutgNIfFZ2VM+YIWfD71N/kjjwK6X/AAHzBr+rciEcn/L34S8TGw==", "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } + "license": "MIT" }, "node_modules/stackback": { "version": "0.0.2", @@ -12628,14 +12277,11 @@ } }, "node_modules/swap-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/swap-case/-/swap-case-2.0.2.tgz", - "integrity": "sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/swap-case/-/swap-case-3.0.3.tgz", + "integrity": "sha512-6p4op8wE9CQv7uDFzulI6YXUw4lD9n4oQierdbFThEKVWVQcbQcUjdP27W8XE7V4QnWmnq9jueSHceyyQnqQVA==", "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } + "license": "MIT" }, "node_modules/sync-fetch": { "version": "0.6.0", @@ -12890,11 +12536,15 @@ } }, "node_modules/ts-log": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/ts-log/-/ts-log-2.2.7.tgz", - "integrity": "sha512-320x5Ggei84AxzlXp91QkIGSw5wgaLT6GeAH0KsqDmRZdVWW2OiSeVvElVoatk3f7nicwXlElXsoFkARiGE2yg==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/ts-log/-/ts-log-3.0.2.tgz", + "integrity": "sha512-esq6hx2lM66sQV1YcFkIYTqrWWabmqBqobKHyn1CswdI5FgfQhkmiKiRWVGBNlIbdjBxEIkNvMIwLKKPgRYZLQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20", + "npm": ">=10" + } }, "node_modules/tslib": { "version": "2.8.1", @@ -13109,26 +12759,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/upper-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-2.0.2.tgz", - "integrity": "sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/upper-case-first": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/upper-case-first/-/upper-case-first-2.0.2.tgz", - "integrity": "sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, "node_modules/url-join": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", @@ -13532,32 +13162,56 @@ } }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", "dev": true, "license": "MIT", "dependencies": { - "cliui": "^8.0.1", + "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", + "string-width": "^7.2.0", "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "yargs-parser": "^22.0.0" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "dev": true, "license": "ISC", "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/yocto-queue": { @@ -13586,19 +13240,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", - "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/package.json b/package.json index acfd45b2..19bc1aa5 100644 --- a/package.json +++ b/package.json @@ -77,8 +77,8 @@ "@biomejs/biome": "^2.3.14", "@commitlint/cli": "^21.0.0", "@commitlint/config-conventional": "^21.0.0", - "@graphql-codegen/cli": "^6.1.1", - "@graphql-codegen/client-preset": "^5.2.2", + "@graphql-codegen/cli": "^7.0.0", + "@graphql-codegen/client-preset": "^6.0.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/exec": "^7.1.0", From 3c7369a8b29a1ead34614a6d0750d213bca599b9 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:53:05 +0200 Subject: [PATCH 28/79] refactor(issues): use string literals for IssueRelationType client-preset v6 generates GraphQL enums as string-literal union types instead of runtime enum objects, so IssueRelationType.Blocks et al. are no longer usable as values. Replace them with their literal values, which type-check against the union. Part of #218 --- src/commands/issues.ts | 24 +++++++++---------- .../services/issue-relation-service.test.ts | 19 +++++++-------- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 086b2e32..b08c0a9f 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -17,10 +17,10 @@ import { import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { resolveFilterOptions } from "../common/resolve-filters.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; -import { - type IssueCreateInput, +import type { + IssueCreateInput, IssueRelationType, - type IssueUpdateInput, + IssueUpdateInput, } from "../gql/graphql.js"; import { resolveCycleId } from "../resolvers/cycle-resolver.js"; import { @@ -390,13 +390,13 @@ function relationTypeFromAddFlag( ): IssueRelationType { switch (type) { case "blocks": - return IssueRelationType.Blocks; + return "blocks"; case "related": - return IssueRelationType.Related; + return "related"; case "duplicate": - return IssueRelationType.Duplicate; + return "duplicate"; case "similar": - return IssueRelationType.Similar; + return "similar"; } } @@ -465,35 +465,35 @@ async function resolveAndApplyRelations( await createIssueRelation(ctx.gql, { issueId, relatedIssueId: targetId, - type: IssueRelationType.Blocks, + type: "blocks", }); break; case "blockedBy": await createIssueRelation(ctx.gql, { issueId: targetId, relatedIssueId: issueId, - type: IssueRelationType.Blocks, + type: "blocks", }); break; case "relatesTo": await createIssueRelation(ctx.gql, { issueId, relatedIssueId: targetId, - type: IssueRelationType.Related, + type: "related", }); break; case "duplicateOf": await createIssueRelation(ctx.gql, { issueId, relatedIssueId: targetId, - type: IssueRelationType.Duplicate, + type: "duplicate", }); break; case "similarTo": await createIssueRelation(ctx.gql, { issueId, relatedIssueId: targetId, - type: IssueRelationType.Similar, + type: "similar", }); break; case "remove": { diff --git a/tests/unit/services/issue-relation-service.test.ts b/tests/unit/services/issue-relation-service.test.ts index b0ca5c02..e8585e30 100644 --- a/tests/unit/services/issue-relation-service.test.ts +++ b/tests/unit/services/issue-relation-service.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; -import { IssueRelationType } from "../../../src/gql/graphql.js"; import { createIssueRelation, deleteIssueRelation, @@ -18,7 +17,7 @@ describe("createIssueRelation", () => { it("creates a relation and returns it", async () => { const relation = { id: "rel-1", - type: IssueRelationType.Blocks, + type: "blocks", relatedIssue: { id: "issue-2", identifier: "ENG-2" }, }; const client = mockGqlClient({ @@ -28,7 +27,7 @@ describe("createIssueRelation", () => { const result = await createIssueRelation(client, { issueId: "issue-1", relatedIssueId: "issue-2", - type: IssueRelationType.Blocks, + type: "blocks", }); expect(result).toEqual(relation); @@ -44,7 +43,7 @@ describe("createIssueRelation", () => { createIssueRelation(client, { issueId: "issue-1", relatedIssueId: "issue-2", - type: IssueRelationType.Blocks, + type: "blocks", }), ).rejects.toThrow("Failed to create issue relation"); }); @@ -58,7 +57,7 @@ describe("findIssueRelation", () => { nodes: [ { id: "rel-1", - type: IssueRelationType.Blocks, + type: "blocks", relatedIssue: { id: "target-id", identifier: "ENG-2" }, }, ], @@ -79,7 +78,7 @@ describe("findIssueRelation", () => { nodes: [ { id: "rel-2", - type: IssueRelationType.Blocks, + type: "blocks", issue: { id: "target-id", identifier: "ENG-1" }, }, ], @@ -123,7 +122,7 @@ describe("listIssueRelations", () => { nodes: [ { id: "rel-1", - type: IssueRelationType.Blocks, + type: "blocks", relatedIssue: { id: "target-id", identifier: "ENG-2" }, }, ], @@ -132,7 +131,7 @@ describe("listIssueRelations", () => { nodes: [ { id: "rel-2", - type: IssueRelationType.Related, + type: "related", issue: { id: "other-id", identifier: "ENG-3" }, }, ], @@ -148,12 +147,12 @@ describe("listIssueRelations", () => { relations: [ { id: "rel-1", - type: IssueRelationType.Blocks, + type: "blocks", relatedIssue: { id: "target-id", identifier: "ENG-2" }, }, { id: "rel-2", - type: IssueRelationType.Related, + type: "related", issue: { id: "other-id", identifier: "ENG-3" }, }, ], From 632cd25ad17c80e52e426f12310b3433d84409fe Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:53:12 +0200 Subject: [PATCH 29/79] refactor(issues): use string literal for PaginationOrderBy client-preset v6 emits PaginationOrderBy as a string-literal union rather than a runtime enum, so PaginationOrderBy.UpdatedAt is no longer a value. Use the "updatedAt" literal and drop the now type-only import. Part of #218 --- src/services/issue-service.ts | 5 ++--- tests/unit/services/issue-service.test.ts | 11 +++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/services/issue-service.ts b/src/services/issue-service.ts index 7f4e0adf..c984bcc2 100644 --- a/src/services/issue-service.ts +++ b/src/services/issue-service.ts @@ -47,7 +47,6 @@ import { type IssueCreateInput, type IssueFilter, type IssueUpdateInput, - PaginationOrderBy, SearchIssuesDocument, type SearchIssuesQuery, type SearchIssuesQueryVariables, @@ -207,7 +206,7 @@ export async function listIssues( first: limit, after, filter: buildListIssuesFilter(filter), - orderBy: PaginationOrderBy.UpdatedAt, + orderBy: "updatedAt", }, ); return { @@ -219,7 +218,7 @@ export async function listIssues( const result = await client.request<GetIssuesQuery>(GetIssuesDocument, { first: limit, after, - orderBy: PaginationOrderBy.UpdatedAt, + orderBy: "updatedAt", }); return { nodes: result.issues?.nodes ?? [], diff --git a/tests/unit/services/issue-service.test.ts b/tests/unit/services/issue-service.test.ts index 2906159a..9728d604 100644 --- a/tests/unit/services/issue-service.test.ts +++ b/tests/unit/services/issue-service.test.ts @@ -14,7 +14,6 @@ import { GetIssueByIdWithCommentsDocument, GetIssueByIdWithReactionsDocument, GetIssuesDocument, - PaginationOrderBy, SearchIssuesDocument, UnarchiveIssueDocument, } from "../../../src/gql/graphql.js"; @@ -145,7 +144,7 @@ describe("listIssues", () => { expect(client.request).toHaveBeenCalledWith(expect.anything(), { first: 25, after: undefined, - orderBy: PaginationOrderBy.UpdatedAt, + orderBy: "updatedAt", }); }); @@ -160,7 +159,7 @@ describe("listIssues", () => { expect(client.request).toHaveBeenCalledWith(expect.anything(), { first: 5, after: "cursor1", - orderBy: PaginationOrderBy.UpdatedAt, + orderBy: "updatedAt", }); }); @@ -197,7 +196,7 @@ describe("listIssues", () => { { team: { id: { eq: "team-uuid" } } }, ], }, - orderBy: PaginationOrderBy.UpdatedAt, + orderBy: "updatedAt", }); }); @@ -221,7 +220,7 @@ describe("listIssues", () => { first: 10, after: undefined, filter, - orderBy: PaginationOrderBy.UpdatedAt, + orderBy: "updatedAt", }); }); @@ -236,7 +235,7 @@ describe("listIssues", () => { expect(client.request).toHaveBeenCalledWith(GetIssuesDocument, { first: 25, after: undefined, - orderBy: PaginationOrderBy.UpdatedAt, + orderBy: "updatedAt", }); }); }); From dcf96295810774755aae7e7bd222c1b7eae924a5 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:53:17 +0200 Subject: [PATCH 30/79] refactor(initiatives): use string literals for GraphQL enums client-preset v6 generates InitiativeStatus, InitiativeUpdateHealthType, PaginationOrderBy, PaginationSortOrder and PaginationNulls as string-literal union types instead of runtime enums. Replace the enum-member value references with their literals and drop the imports that are no longer referenced as values. Part of #218 --- src/commands/initiatives/entity.ts | 33 ++++++++++++----------------- src/commands/initiatives/updates.ts | 12 +++++------ 2 files changed, 19 insertions(+), 26 deletions(-) diff --git a/src/commands/initiatives/entity.ts b/src/commands/initiatives/entity.ts index 27ee50e8..8eb4e657 100644 --- a/src/commands/initiatives/entity.ts +++ b/src/commands/initiatives/entity.ts @@ -8,15 +8,13 @@ import { outputSuccess, parseLimit, } from "../../common/output.js"; -import { - type InitiativeCreateInput, - type InitiativeSortInput, +import type { + InitiativeCreateInput, + InitiativeSortInput, InitiativeStatus, - type InitiativeUpdateInput, - type ListInitiativesQueryVariables, - PaginationNulls, + InitiativeUpdateInput, + ListInitiativesQueryVariables, PaginationOrderBy, - PaginationSortOrder, } from "../../gql/graphql.js"; import { resolveInitiativeId } from "../../resolvers/initiative-resolver.js"; import { resolveTeamId } from "../../resolvers/team-resolver.js"; @@ -243,8 +241,8 @@ function parseSortBy(value?: string): InitiativeSortBy | undefined { function mapSortByToPaginationOrderBy( sortBy?: InitiativeSortBy, ): PaginationOrderBy | undefined { - if (sortBy === "createdAt") return PaginationOrderBy.CreatedAt; - if (sortBy === "updatedAt") return PaginationOrderBy.UpdatedAt; + if (sortBy === "createdAt") return "createdAt"; + if (sortBy === "updatedAt") return "updatedAt"; return undefined; } @@ -254,15 +252,10 @@ function mapSortByToInitiativeSort( ): ListInitiativesQueryVariables["sort"] | undefined { if (!sortBy) return undefined; - const order = - sortOrder === "desc" - ? PaginationSortOrder.Descending - : PaginationSortOrder.Ascending; - const withNulls = { - order, - nulls: PaginationNulls.Last, - }; + order: sortOrder === "desc" ? "Descending" : "Ascending", + nulls: "last", + } as const; const sortEntry: InitiativeSortInput = sortBy === "manual" @@ -288,9 +281,9 @@ function parseInitiativeStatus(value?: string): InitiativeStatus | undefined { if (!value) return undefined; const normalized = value.toLowerCase(); - if (normalized === "planned") return InitiativeStatus.Planned; - if (normalized === "active") return InitiativeStatus.Active; - if (normalized === "completed") return InitiativeStatus.Completed; + if (normalized === "planned") return "Planned"; + if (normalized === "active") return "Active"; + if (normalized === "completed") return "Completed"; throw invalidParameterError( "--status", diff --git a/src/commands/initiatives/updates.ts b/src/commands/initiatives/updates.ts index 60629653..988786fd 100644 --- a/src/commands/initiatives/updates.ts +++ b/src/commands/initiatives/updates.ts @@ -6,10 +6,10 @@ import { outputSuccess, parseLimit, } from "../../common/output.js"; -import { - type InitiativeUpdateCreateInput, +import type { + InitiativeUpdateCreateInput, InitiativeUpdateHealthType, - type InitiativeUpdateUpdateInput, + InitiativeUpdateUpdateInput, } from "../../gql/graphql.js"; import { resolveInitiativeId } from "../../resolvers/initiative-resolver.js"; import { @@ -43,9 +43,9 @@ function parseHealth(value?: string): InitiativeUpdateHealthType | undefined { if (!value) return undefined; const normalized = value.trim().toLowerCase(); - if (normalized === "ontrack") return InitiativeUpdateHealthType.OnTrack; - if (normalized === "atrisk") return InitiativeUpdateHealthType.AtRisk; - if (normalized === "offtrack") return InitiativeUpdateHealthType.OffTrack; + if (normalized === "ontrack") return "onTrack"; + if (normalized === "atrisk") return "atRisk"; + if (normalized === "offtrack") return "offTrack"; throw invalidParameterError( "--health", From 334d4815847bcf376e4501d3af376d856adb14fc Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:08:42 +0200 Subject: [PATCH 31/79] refactor(commands): simplify enum mapping helpers --- src/commands/initiatives/entity.ts | 13 +++++++------ src/commands/issues.ts | 17 +---------------- 2 files changed, 8 insertions(+), 22 deletions(-) diff --git a/src/commands/initiatives/entity.ts b/src/commands/initiatives/entity.ts index 8eb4e657..00dc3380 100644 --- a/src/commands/initiatives/entity.ts +++ b/src/commands/initiatives/entity.ts @@ -241,9 +241,7 @@ function parseSortBy(value?: string): InitiativeSortBy | undefined { function mapSortByToPaginationOrderBy( sortBy?: InitiativeSortBy, ): PaginationOrderBy | undefined { - if (sortBy === "createdAt") return "createdAt"; - if (sortBy === "updatedAt") return "updatedAt"; - return undefined; + return sortBy === "createdAt" || sortBy === "updatedAt" ? sortBy : undefined; } function mapSortByToInitiativeSort( @@ -277,13 +275,16 @@ function mapSortByToInitiativeSort( return [sortEntry]; } +const INITIATIVE_STATUS_VALUES = ["Planned", "Active", "Completed"] as const; + function parseInitiativeStatus(value?: string): InitiativeStatus | undefined { if (!value) return undefined; const normalized = value.toLowerCase(); - if (normalized === "planned") return "Planned"; - if (normalized === "active") return "Active"; - if (normalized === "completed") return "Completed"; + const match = INITIATIVE_STATUS_VALUES.find( + (status) => status.toLowerCase() === normalized, + ); + if (match) return match; throw invalidParameterError( "--status", diff --git a/src/commands/issues.ts b/src/commands/issues.ts index b08c0a9f..3bd482b5 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -385,21 +385,6 @@ function relationFlagName(type: RelationAction["type"]): string { } } -function relationTypeFromAddFlag( - type: "blocks" | "related" | "duplicate" | "similar", -): IssueRelationType { - switch (type) { - case "blocks": - return "blocks"; - case "related": - return "related"; - case "duplicate": - return "duplicate"; - case "similar": - return "similar"; - } -} - function parseRelationAddOptions(options: RelationAddOptions): { type: IssueRelationType; targets: string[]; @@ -437,7 +422,7 @@ function parseRelationAddOptions(options: RelationAddOptions): { } return { - type: relationTypeFromAddFlag(type), + type, targets, }; } From 6ca39522a0845cb84af0ed14cdfd5ba5de23677e Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:30:26 +0200 Subject: [PATCH 32/79] feat(cli): add passive update notifier and version command Adds a `version` command (with a `version check` subcommand) and an inline, stderr-only "update available" notifier that runs before every command. The notifier only fires on interactive TTY runs, caches the npm registry lookup on disk for 24h, and fails silently so it never affects command output or the JSON contract agents rely on. Honors NO_UPDATE_NOTIFIER, LINEARIS_NO_UPDATE_CHECK, and CI. --- src/commands/version.ts | 65 +++++++ src/common/update-notifier.ts | 197 +++++++++++++++++++++ src/main.ts | 9 +- tests/unit/common/update-notifier.test.ts | 205 ++++++++++++++++++++++ 4 files changed, 474 insertions(+), 2 deletions(-) create mode 100644 src/commands/version.ts create mode 100644 src/common/update-notifier.ts create mode 100644 tests/unit/common/update-notifier.test.ts diff --git a/src/commands/version.ts b/src/commands/version.ts new file mode 100644 index 00000000..3c6e8168 --- /dev/null +++ b/src/commands/version.ts @@ -0,0 +1,65 @@ +import type { Command } from "commander"; +import pkg from "../../package.json" with { type: "json" }; +import { handleCommand, outputSuccess } from "../common/output.js"; +import { + channelFor, + fetchLatestVersion, + isNewer, + writeCache, +} from "../common/update-notifier.js"; +import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; + +export const VERSION_META: DomainMeta = { + name: "version", + summary: "show the installed version and check for updates", + context: [ + "reports the installed linearis version and its release channel (latest or", + "next). `version check` queries the npm registry for the newest version on", + "that channel and reports whether an update is available. interactive runs", + "also print a one-line hint to stderr; set NO_UPDATE_NOTIFIER=1 to silence.", + ].join("\n"), + arguments: {}, + seeAlso: [], +}; + +export function setupVersionCommands(program: Command): void { + const version = program + .command("version") + .description("show the installed version"); + + version.action( + handleCommand(async () => { + outputSuccess({ + version: pkg.version, + channel: channelFor(pkg.version), + }); + }), + ); + + version + .command("check") + .description("check the npm registry for a newer version") + .action( + handleCommand(async () => { + const channel = channelFor(pkg.version); + const latest = await fetchLatestVersion(channel); + const updateAvailable = latest ? isNewer(latest, pkg.version) : false; + if (latest) { + writeCache({ channel, latest, checkedAt: Date.now() }); + } + outputSuccess({ + current: pkg.version, + latest, + channel, + updateAvailable, + }); + }), + ); + + version + .command("usage") + .description("show detailed usage for version") + .action(() => { + console.log(formatDomainUsage(version, VERSION_META)); + }); +} diff --git a/src/common/update-notifier.ts b/src/common/update-notifier.ts new file mode 100644 index 00000000..f13dfbea --- /dev/null +++ b/src/common/update-notifier.ts @@ -0,0 +1,197 @@ +import fs from "node:fs"; +import path from "node:path"; +import { ensureTokenDir, getTokenDir } from "./token-storage.js"; + +/** + * Passive "update available" notifier, run inline before every command. + * + * Design constraints (Linearis emits JSON on stdout for agents): + * - The hint is written to **stderr only**, never stdout, so it can never + * corrupt the JSON contract that agents parse. + * - It is shown only on interactive runs (`process.stdout.isTTY`). Agents and + * scripts pipe stdout, so they are never nagged and make no network calls. + * - The registry lookup is cached on disk; only a stale cache (older than + * CHECK_INTERVAL_MS) triggers a network call, so the common path is instant. + * - Every operation fails silently; a version check must never affect a command. + */ + +const CACHE_FILE = "update-check.json"; +const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h +const NPM_DIST_TAGS_URL = + "https://registry.npmjs.org/-/package/linearis/dist-tags"; +const FETCH_TIMEOUT_MS = 3000; + +export type Channel = "latest" | "next"; + +export interface UpdateCacheData { + channel: Channel; + latest: string; + checkedAt: number; +} + +/** "next" when the installed version carries a `-next` prerelease, else "latest". */ +export function channelFor(version: string): Channel { + return /-next\b/.test(version) ? "next" : "latest"; +} + +function cachePath(): string { + return path.join(getTokenDir(), CACHE_FILE); +} + +/** Read the last cached registry lookup, or null if missing/corrupt. */ +export function readCache(): UpdateCacheData | null { + try { + const data = JSON.parse(fs.readFileSync(cachePath(), "utf8")) as + | UpdateCacheData + | undefined; + if ( + data && + typeof data.latest === "string" && + typeof data.checkedAt === "number" && + (data.channel === "latest" || data.channel === "next") + ) { + return data; + } + } catch { + // missing or corrupt cache — treat as absent + } + return null; +} + +/** Persist a registry lookup for the next invocation to read. */ +export function writeCache(data: UpdateCacheData): void { + ensureTokenDir(); + fs.writeFileSync(cachePath(), JSON.stringify(data), "utf8"); +} + +/** + * Compare two versions of the form `YYYY.M.P` with an optional `-tag.N` + * prerelease suffix. Returns >0 if `a` > `b`, <0 if `a` < `b`, 0 if equal. + * A release (no prerelease) outranks a prerelease sharing the same core. + */ +export function compareVersions(a: string, b: string): number { + const [coreA, preA = ""] = a.split("-"); + const [coreB, preB = ""] = b.split("-"); + const numsA = coreA.split(".").map((n) => Number.parseInt(n, 10) || 0); + const numsB = coreB.split(".").map((n) => Number.parseInt(n, 10) || 0); + const len = Math.max(numsA.length, numsB.length); + for (let i = 0; i < len; i++) { + const diff = (numsA[i] ?? 0) - (numsB[i] ?? 0); + if (diff !== 0) return Math.sign(diff); + } + if (preA === preB) return 0; + if (preA === "") return 1; // a is a release, b a prerelease of same core + if (preB === "") return -1; + return comparePrerelease(preA, preB); +} + +function comparePrerelease(a: string, b: string): number { + const as = a.split("."); + const bs = b.split("."); + const len = Math.max(as.length, bs.length); + for (let i = 0; i < len; i++) { + const x = as[i]; + const y = bs[i]; + if (x === undefined) return -1; // shorter prerelease sorts lower + if (y === undefined) return 1; + const nx = Number.parseInt(x, 10); + const ny = Number.parseInt(y, 10); + if (!Number.isNaN(nx) && !Number.isNaN(ny)) { + if (nx !== ny) return Math.sign(nx - ny); + } else if (x !== y) { + return x < y ? -1 : 1; + } + } + return 0; +} + +/** True when `candidate` is a strictly newer version than `current`. */ +export function isNewer(candidate: string, current: string): boolean { + return compareVersions(candidate, current) > 0; +} + +/** Respect the de-facto `NO_UPDATE_NOTIFIER`, a project escape hatch, and CI. */ +export function updateChecksDisabled( + env: NodeJS.ProcessEnv = process.env, +): boolean { + return Boolean( + env.NO_UPDATE_NOTIFIER || env.LINEARIS_NO_UPDATE_CHECK || env.CI, + ); +} + +/** The one-line stderr hint shown when an update is available. */ +export function formatUpdateNotice( + current: string, + latest: string, + channel: Channel, +): string { + const tag = channel === "next" ? "@next" : "@latest"; + return [ + `▲ linearis update available: ${current} → ${latest}`, + ` run: npm install -g linearis${tag}`, + " silence: set NO_UPDATE_NOTIFIER=1", + ].join("\n"); +} + +/** Query the npm registry for the newest version on a dist-tag channel. */ +export async function fetchLatestVersion( + channel: Channel, +): Promise<string | null> { + try { + const res = await fetch(NPM_DIST_TAGS_URL, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!res.ok) return null; + const tags = (await res.json()) as Record<string, string>; + const version = tags[channel]; + return typeof version === "string" ? version : null; + } catch { + return null; + } +} + +/** + * On interactive runs, print a one-line hint to stderr when a newer version is + * available. Reads from the on-disk cache; refreshes it inline only when stale. + * Never blocks agents/scripts, never throws. + */ +export async function maybeNotifyUpdate(currentVersion: string): Promise<void> { + try { + // Agents/scripts consume stdout non-interactively — never nag them, and + // never make a network call on their behalf. + if (!process.stdout.isTTY) return; + if (updateChecksDisabled()) return; + + const channel = channelFor(currentVersion); + let cache = readCache(); + const stale = + !cache || + cache.channel !== channel || + Date.now() - cache.checkedAt > CHECK_INTERVAL_MS; + if (stale) { + const latest = await fetchLatestVersion(channel); + // Advance checkedAt even when the lookup fails so a failed check backs + // off for CHECK_INTERVAL_MS instead of re-fetching on every command. + // Carry the prior latest when the registry is unreachable, falling back + // to the current version (which never triggers a notice) when there is + // no prior cache to reuse. + const resolvedLatest = + latest ?? (cache?.channel === channel ? cache.latest : currentVersion); + cache = { channel, latest: resolvedLatest, checkedAt: Date.now() }; + writeCache(cache); + } + + if ( + cache && + cache.channel === channel && + isNewer(cache.latest, currentVersion) + ) { + process.stderr.write( + `${formatUpdateNotice(currentVersion, cache.latest, channel)}\n`, + ); + } + } catch { + // Update checks must never affect the command outcome. + } +} diff --git a/src/main.ts b/src/main.ts index 1a302fad..8cd5305c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,8 +27,10 @@ import { import { PROJECTS_META, setupProjectsCommands } from "./commands/projects.js"; import { setupTeamsCommands, TEAMS_META } from "./commands/teams.js"; import { setupUsersCommands, USERS_META } from "./commands/users.js"; +import { setupVersionCommands, VERSION_META } from "./commands/version.js"; import { getRootOpts } from "./common/context.js"; import { parseFieldsList, setOutputOptions } from "./common/output.js"; +import { maybeNotifyUpdate } from "./common/update-notifier.js"; import { type DomainMeta, formatDomainUsage, @@ -47,8 +49,9 @@ program parseFieldsList, ); -program.hook("preAction", (_thisCommand, actionCommand) => { +program.hook("preAction", async (_thisCommand, actionCommand) => { setOutputOptions(getRootOpts(actionCommand)); + await maybeNotifyUpdate(pkg.version); }); const allMetas: DomainMeta[] = [ @@ -65,6 +68,7 @@ const allMetas: DomainMeta[] = [ TEAMS_META, USERS_META, INITIATIVES_META, + VERSION_META, ]; program.action(() => console.log(formatOverview(pkg.version, allMetas))); @@ -82,6 +86,7 @@ setupTeamsCommands(program); setupUsersCommands(program); setupInitiativesCommands(program); setupDocumentsCommands(program); +setupVersionCommands(program); program .command("usage") @@ -104,4 +109,4 @@ program } }); -program.parse(); +program.parseAsync(); diff --git a/tests/unit/common/update-notifier.test.ts b/tests/unit/common/update-notifier.test.ts new file mode 100644 index 00000000..abfab1c6 --- /dev/null +++ b/tests/unit/common/update-notifier.test.ts @@ -0,0 +1,205 @@ +import fs from "node:fs"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:fs"); + +vi.mock("../../../src/common/token-storage.js", () => ({ + getTokenDir: vi.fn(() => "/tmp/linearis-test"), + ensureTokenDir: vi.fn(), +})); + +import { + channelFor, + compareVersions, + formatUpdateNotice, + isNewer, + maybeNotifyUpdate, + readCache, + type UpdateCacheData, + updateChecksDisabled, +} from "../../../src/common/update-notifier.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("channelFor", () => { + it("returns 'next' for prerelease versions", () => { + expect(channelFor("2026.6.0-next.5")).toBe("next"); + }); + + it("returns 'latest' for stable versions", () => { + expect(channelFor("2026.6.0")).toBe("latest"); + }); +}); + +describe("compareVersions", () => { + it("orders by calver core", () => { + expect(compareVersions("2026.7.0", "2026.6.0")).toBeGreaterThan(0); + expect(compareVersions("2026.6.1", "2026.6.0")).toBeGreaterThan(0); + expect(compareVersions("2027.1.0", "2026.12.0")).toBeGreaterThan(0); + expect(compareVersions("2026.6.0", "2026.6.0")).toBe(0); + }); + + it("ranks a release above a prerelease of the same core", () => { + expect(compareVersions("2026.6.0", "2026.6.0-next.5")).toBeGreaterThan(0); + expect(compareVersions("2026.6.0-next.5", "2026.6.0")).toBeLessThan(0); + }); + + it("orders prerelease counters numerically", () => { + expect( + compareVersions("2026.6.0-next.10", "2026.6.0-next.9"), + ).toBeGreaterThan(0); + expect(compareVersions("2026.6.0-next.2", "2026.6.0-next.2")).toBe(0); + }); +}); + +describe("isNewer", () => { + it("is true only for strictly newer candidates", () => { + expect(isNewer("2026.6.0-next.6", "2026.6.0-next.5")).toBe(true); + expect(isNewer("2026.6.0-next.5", "2026.6.0-next.5")).toBe(false); + expect(isNewer("2026.6.0-next.4", "2026.6.0-next.5")).toBe(false); + }); +}); + +describe("updateChecksDisabled", () => { + it("respects the standard and project-specific opt-out env vars", () => { + expect(updateChecksDisabled({ NO_UPDATE_NOTIFIER: "1" })).toBe(true); + expect(updateChecksDisabled({ LINEARIS_NO_UPDATE_CHECK: "1" })).toBe(true); + expect(updateChecksDisabled({ CI: "true" })).toBe(true); + expect(updateChecksDisabled({})).toBe(false); + }); +}); + +describe("formatUpdateNotice", () => { + it("uses the channel-specific install tag", () => { + const next = formatUpdateNotice( + "2026.6.0-next.5", + "2026.6.0-next.6", + "next", + ); + expect(next).toContain("2026.6.0-next.5 → 2026.6.0-next.6"); + expect(next).toContain("npm install -g linearis@next"); + expect(next).toContain("NO_UPDATE_NOTIFIER=1"); + + const latest = formatUpdateNotice("2026.6.0", "2026.7.0", "latest"); + expect(latest).toContain("npm install -g linearis@latest"); + }); +}); + +describe("readCache", () => { + it("returns parsed cache data when valid", () => { + const cache: UpdateCacheData = { + channel: "next", + latest: "2026.6.0-next.6", + checkedAt: 123, + }; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(cache)); + expect(readCache()).toEqual(cache); + }); + + it("returns null on a missing file", () => { + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error("ENOENT"); + }); + expect(readCache()).toBeNull(); + }); + + it("returns null on corrupt or malformed cache", () => { + vi.mocked(fs.readFileSync).mockReturnValue("not json"); + expect(readCache()).toBeNull(); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ channel: "bogus", latest: 1 }), + ); + expect(readCache()).toBeNull(); + }); +}); + +describe("maybeNotifyUpdate", () => { + const originalIsTTY = process.stdout.isTTY; + let stderrSpy: ReturnType<typeof vi.spyOn>; + + function setStdoutTTY(value: boolean): void { + Object.defineProperty(process.stdout, "isTTY", { + value, + configurable: true, + }); + } + + beforeEach(() => { + stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true); + }); + + afterEach(() => { + Object.defineProperty(process.stdout, "isTTY", { + value: originalIsTTY, + configurable: true, + }); + stderrSpy.mockRestore(); + }); + + it("stays silent when stdout is not a TTY (agent/piped use)", async () => { + setStdoutTTY(false); + await maybeNotifyUpdate("2026.6.0-next.5"); + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + it("stays silent when update checks are disabled", async () => { + setStdoutTTY(true); + vi.stubEnv("NO_UPDATE_NOTIFIER", "1"); + await maybeNotifyUpdate("2026.6.0-next.5"); + expect(stderrSpy).not.toHaveBeenCalled(); + vi.unstubAllEnvs(); + }); + + it("prints a hint from a fresh cache without hitting the network", async () => { + setStdoutTTY(true); + vi.stubEnv("NO_UPDATE_NOTIFIER", ""); + vi.stubEnv("LINEARIS_NO_UPDATE_CHECK", ""); + vi.stubEnv("CI", ""); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const cache: UpdateCacheData = { + channel: "next", + latest: "2026.6.0-next.9", + checkedAt: Date.now(), + }; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(cache)); + await maybeNotifyUpdate("2026.6.0-next.5"); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(stderrSpy).toHaveBeenCalledOnce(); + expect(String(stderrSpy.mock.calls[0]?.[0])).toContain("update available"); + fetchSpy.mockRestore(); + vi.unstubAllEnvs(); + }); + + it("backs off by refreshing the cache when a stale lookup fails", async () => { + setStdoutTTY(true); + vi.stubEnv("NO_UPDATE_NOTIFIER", ""); + vi.stubEnv("LINEARIS_NO_UPDATE_CHECK", ""); + vi.stubEnv("CI", ""); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockRejectedValue(new Error("offline")); + const staleCache: UpdateCacheData = { + channel: "next", + latest: "2026.6.0-next.9", + checkedAt: 0, // older than CHECK_INTERVAL_MS → stale + }; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(staleCache)); + const before = Date.now(); + + await maybeNotifyUpdate("2026.6.0-next.5"); + + // checkedAt is advanced (so the next command backs off) while the prior + // latest is carried, so the notice still shows from cached data. + expect(fs.writeFileSync).toHaveBeenCalledOnce(); + const written = JSON.parse( + String(vi.mocked(fs.writeFileSync).mock.calls[0]?.[1]), + ) as UpdateCacheData; + expect(written.latest).toBe("2026.6.0-next.9"); + expect(written.checkedAt).toBeGreaterThanOrEqual(before); + expect(stderrSpy).toHaveBeenCalledOnce(); + fetchSpy.mockRestore(); + vi.unstubAllEnvs(); + }); +}); From ccc03c8b22d9af9d6014ce36edbf5c365fe143a1 Mon Sep 17 00:00:00 2001 From: semantic-release-bot <semantic-release-bot@martynus.net> Date: Thu, 2 Jul 2026 20:19:47 +0000 Subject: [PATCH 33/79] chore(release): 2026.6.0-next.7 [skip ci] ## [2026.6.0-next.7](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.6...v2026.6.0-next.7) (2026-07-02) ### Features * **cli:** add passive update notifier and version command ([6ca3952](https://github.com/linearis-oss/linearis/commit/6ca39522a0845cb84af0ed14cdfd5ba5de23677e)) --- 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 04420413..8f09fbbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [2026.6.0-next.7](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.6...v2026.6.0-next.7) (2026-07-02) + +### Features + +* **cli:** add passive update notifier and version command ([6ca3952](https://github.com/linearis-oss/linearis/commit/6ca39522a0845cb84af0ed14cdfd5ba5de23677e)) + ## [2026.6.0-next.6](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.5...v2026.6.0-next.6) (2026-07-02) ### Features diff --git a/package-lock.json b/package-lock.json index aab841ed..10e964fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linearis", - "version": "2026.6.0-next.6", + "version": "2026.6.0-next.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.6.0-next.6", + "version": "2026.6.0-next.7", "license": "MIT", "dependencies": { "@linear/sdk": "82.1.0", diff --git a/package.json b/package.json index 19bc1aa5..00f8bdaf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.6.0-next.6", + "version": "2026.6.0-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 5a7a4cd2009b4a4c6e155461f086f6bced7c99e3 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:34:32 +0200 Subject: [PATCH 34/79] refactor(client): infer request result and variables from TypedDocumentNode Change GraphQLClient.request to accept a TypedDocumentNode<TResult, TVariables> so both the result and variables types are inferred from the generated document. This removes redundant explicit generic type arguments across resolvers and services and lets TypeScript catch mismatched documents, result types, and variables at the call site. Adds the @graphql-typed-document-node/core dependency. Closes #194 --- package-lock.json | 1 + package.json | 1 + src/client/graphql-client.ts | 28 ++- src/resolvers/initiative-resolver.ts | 13 +- src/resolvers/milestone-resolver.ts | 19 +- src/resolvers/project-status-resolver.ts | 9 +- src/services/attachment-service.ts | 21 +- src/services/auth-service.ts | 4 +- src/services/comment-service.ts | 28 +-- src/services/cycle-service.ts | 6 +- src/services/discussion-service.ts | 251 ++++++++------------ src/services/document-service.ts | 35 +-- src/services/initiative-project-service.ts | 18 +- src/services/initiative-relation-service.ts | 20 +- src/services/initiative-service.ts | 70 ++---- src/services/initiative-update-service.ts | 53 ++--- src/services/issue-relation-service.ts | 24 +- src/services/issue-service.ts | 100 +++----- src/services/label-service.ts | 15 +- src/services/milestone-service.ts | 36 ++- src/services/project-service.ts | 36 +-- src/services/reaction-service.ts | 34 +-- src/services/team-service.ts | 14 +- src/services/user-service.ts | 4 +- 24 files changed, 307 insertions(+), 533 deletions(-) diff --git a/package-lock.json b/package-lock.json index 10e964fd..3998d6a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2026.6.0-next.7", "license": "MIT", "dependencies": { + "@graphql-typed-document-node/core": "3.2.0", "@linear/sdk": "82.1.0", "commander": "14.0.3", "node-emoji": "2.2.0" diff --git a/package.json b/package.json index 00f8bdaf..7f082aca 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ }, "homepage": "https://github.com/linearis-oss/linearis#readme", "dependencies": { + "@graphql-typed-document-node/core": "3.2.0", "@linear/sdk": "82.1.0", "commander": "14.0.3", "node-emoji": "2.2.0" diff --git a/src/client/graphql-client.ts b/src/client/graphql-client.ts index db75c621..3a942c60 100644 --- a/src/client/graphql-client.ts +++ b/src/client/graphql-client.ts @@ -1,11 +1,22 @@ +import type { TypedDocumentNode } from "@graphql-typed-document-node/core"; import { LinearClient } from "@linear/sdk"; -import { type DocumentNode, print } from "graphql"; +import { print } from "graphql"; import { AuthenticationError, isAuthError } from "../common/errors.js"; import { withRetry } from "../common/retry.js"; /** Default timeout for GraphQL API requests (30 seconds) */ const REQUEST_TIMEOUT_MS = 30_000; +/** + * Variable-less operations generate `Exact<{ [key: string]: never }>` for their + * variables type. Make the variables argument optional in exactly that case and + * required otherwise, so callers infer both types from the document alone. + */ +type RequestVariables<TVariables> = + TVariables extends Record<string, never> + ? [variables?: TVariables] + : [variables: TVariables]; + interface GraphQLErrorResponse { response?: { errors?: Array<{ message: string }>; @@ -34,9 +45,11 @@ export class GraphQLClient { return linearClient.client; } - async request<TResult>( - document: DocumentNode, - variables?: Record<string, unknown>, + async request<TResult, TVariables>( + document: TypedDocumentNode<TResult, TVariables>, + // `NoInfer` pins `TVariables` to the document, so the variables argument is + // type-checked against it rather than widening the inferred type itself. + ...[variables]: RequestVariables<NoInfer<TVariables>> ): Promise<TResult> { try { const response = await withRetry(async () => { @@ -48,7 +61,12 @@ export class GraphQLClient { try { return await this.createRawClient( timeoutController.signal, - ).rawRequest(print(document), variables); + // The public signature stays strongly typed via TypedDocumentNode; + // rawRequest only accepts an untyped variables bag, so cast here. + ).rawRequest( + print(document), + variables as Record<string, unknown> | undefined, + ); } catch (error: unknown) { if ( timeoutController.signal.aborted && diff --git a/src/resolvers/initiative-resolver.ts b/src/resolvers/initiative-resolver.ts index ed1d79f7..14a86b8b 100644 --- a/src/resolvers/initiative-resolver.ts +++ b/src/resolvers/initiative-resolver.ts @@ -4,9 +4,7 @@ import { multipleMatchesError, notFoundError } from "../common/errors.js"; import { isUuid } from "../common/identifier.js"; import { FindInitiativeProjectLinkByPairDocument, - type FindInitiativeProjectLinkByPairQuery, FindInitiativeRelationByPairDocument, - type FindInitiativeRelationByPairQuery, } from "../gql/graphql.js"; export interface InitiativeResolveScope { @@ -70,10 +68,11 @@ export async function resolveInitiativeRelationId( let after: string | undefined; while (true) { - const result = await client.request<FindInitiativeRelationByPairQuery>( - FindInitiativeRelationByPairDocument, - { parentId, childId, after }, - ); + const result = await client.request(FindInitiativeRelationByPairDocument, { + parentId, + childId, + after, + }); const relation = result.initiativeRelations.nodes.find( (node) => @@ -106,7 +105,7 @@ export async function resolveInitiativeProjectLinkId( let after: string | undefined; while (true) { - const result = await client.request<FindInitiativeProjectLinkByPairQuery>( + const result = await client.request( FindInitiativeProjectLinkByPairDocument, { initiativeId, projectId, after }, ); diff --git a/src/resolvers/milestone-resolver.ts b/src/resolvers/milestone-resolver.ts index e9d9367e..f9a6a7e4 100644 --- a/src/resolvers/milestone-resolver.ts +++ b/src/resolvers/milestone-resolver.ts @@ -4,9 +4,7 @@ import { multipleMatchesError, notFoundError } from "../common/errors.js"; import { isUuid } from "../common/identifier.js"; import { FindProjectMilestoneGlobalDocument, - type FindProjectMilestoneGlobalQuery, FindProjectMilestoneScopedDocument, - type FindProjectMilestoneScopedQuery, } from "../gql/graphql.js"; import { resolveProjectId } from "./project-resolver.js"; @@ -46,20 +44,19 @@ export async function resolveMilestoneId( if (projectNameOrId) { const projectId = await resolveProjectId(sdkClient, projectNameOrId); - const result = await gqlClient.request<FindProjectMilestoneScopedQuery>( - FindProjectMilestoneScopedDocument, - { name: nameOrId, projectId }, - ); + const result = await gqlClient.request(FindProjectMilestoneScopedDocument, { + name: nameOrId, + projectId, + }); nodes = (result.project?.projectMilestones?.nodes as MilestoneNode[]) || []; } // Fall back to global search if no project scope or not found if (nodes.length === 0) { - const globalResult = - await gqlClient.request<FindProjectMilestoneGlobalQuery>( - FindProjectMilestoneGlobalDocument, - { name: nameOrId }, - ); + const globalResult = await gqlClient.request( + FindProjectMilestoneGlobalDocument, + { name: nameOrId }, + ); nodes = (globalResult.projectMilestones?.nodes as MilestoneNode[]) || []; } diff --git a/src/resolvers/project-status-resolver.ts b/src/resolvers/project-status-resolver.ts index 030b73ca..0dc0f8e0 100644 --- a/src/resolvers/project-status-resolver.ts +++ b/src/resolvers/project-status-resolver.ts @@ -1,10 +1,7 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import { notFoundError } from "../common/errors.js"; import { isUuid } from "../common/identifier.js"; -import { - GetProjectStatusesDocument, - type GetProjectStatusesQuery, -} from "../gql/graphql.js"; +import { GetProjectStatusesDocument } from "../gql/graphql.js"; /** * Resolves project status name to UUID. @@ -29,9 +26,7 @@ export async function resolveProjectStatusId( ): Promise<string> { if (isUuid(nameOrId)) return nameOrId; - const result = await client.request<GetProjectStatusesQuery>( - GetProjectStatusesDocument, - ); + const result = await client.request(GetProjectStatusesDocument); const match = result.projectStatuses.nodes.find( (s) => s.name.toLowerCase() === nameOrId.toLowerCase(), ); diff --git a/src/services/attachment-service.ts b/src/services/attachment-service.ts index cd47e368..451c299f 100644 --- a/src/services/attachment-service.ts +++ b/src/services/attachment-service.ts @@ -3,22 +3,16 @@ import type { Attachment, CreatedAttachment } from "../common/types.js"; import { AttachmentCreateDocument, type AttachmentCreateInput, - type AttachmentCreateMutation, AttachmentDeleteDocument, - type AttachmentDeleteMutation, type AttachmentFilter, ListAttachmentsDocument, - type ListAttachmentsQuery, } from "../gql/graphql.js"; export async function createAttachment( client: GraphQLClient, input: AttachmentCreateInput, ): Promise<CreatedAttachment> { - const result = await client.request<AttachmentCreateMutation>( - AttachmentCreateDocument, - { input }, - ); + const result = await client.request(AttachmentCreateDocument, { input }); if (!result.attachmentCreate.success || !result.attachmentCreate.attachment) { throw new Error("Failed to create attachment"); @@ -31,10 +25,7 @@ export async function deleteAttachment( client: GraphQLClient, id: string, ): Promise<{ id: string; success: boolean }> { - const result = await client.request<AttachmentDeleteMutation>( - AttachmentDeleteDocument, - { id }, - ); + const result = await client.request(AttachmentDeleteDocument, { id }); if (!result.attachmentDelete.success) { throw new Error("Failed to delete attachment"); @@ -48,10 +39,10 @@ export async function listAttachments( issueId: string, filter?: AttachmentFilter, ): Promise<Attachment[]> { - const result = await client.request<ListAttachmentsQuery>( - ListAttachmentsDocument, - { issueId, ...(filter && { filter }) }, - ); + const result = await client.request(ListAttachmentsDocument, { + issueId, + ...(filter && { filter }), + }); if (!result.issue) { throw new Error(`Issue with ID "${issueId}" not found`); diff --git a/src/services/auth-service.ts b/src/services/auth-service.ts index 1807974e..ae4d5154 100644 --- a/src/services/auth-service.ts +++ b/src/services/auth-service.ts @@ -1,8 +1,8 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import type { Viewer } from "../common/types.js"; -import { GetViewerDocument, type GetViewerQuery } from "../gql/graphql.js"; +import { GetViewerDocument } from "../gql/graphql.js"; export async function validateToken(client: GraphQLClient): Promise<Viewer> { - const result = await client.request<GetViewerQuery>(GetViewerDocument); + const result = await client.request(GetViewerDocument); return result.viewer; } diff --git a/src/services/comment-service.ts b/src/services/comment-service.ts index 2ced7f27..893d603e 100644 --- a/src/services/comment-service.ts +++ b/src/services/comment-service.ts @@ -10,23 +10,16 @@ import { type CommentCreateInput, type CommentUpdateInput, CreateCommentDocument, - type CreateCommentMutation, DeleteCommentDocument, - type DeleteCommentMutation, ListCommentsDocument, - type ListCommentsQuery, UpdateCommentDocument, - type UpdateCommentMutation, } from "../gql/graphql.js"; export async function createComment( client: GraphQLClient, input: CommentCreateInput, ): Promise<CreatedComment> { - const result = await client.request<CreateCommentMutation>( - CreateCommentDocument, - { input }, - ); + const result = await client.request(CreateCommentDocument, { input }); if (!result.commentCreate.success || !result.commentCreate.comment) { throw new Error("Failed to create comment"); @@ -40,10 +33,7 @@ export async function updateComment( id: string, input: CommentUpdateInput, ): Promise<UpdatedComment> { - const result = await client.request<UpdateCommentMutation>( - UpdateCommentDocument, - { id, input }, - ); + const result = await client.request(UpdateCommentDocument, { id, input }); if (!result.commentUpdate.success || !result.commentUpdate.comment) { throw new Error("Failed to update comment"); @@ -59,7 +49,7 @@ export async function listComments( ): Promise<PaginatedResult<CommentListItem>> { const { limit = 25, after } = options; - const result = await client.request<ListCommentsQuery>(ListCommentsDocument, { + const result = await client.request(ListCommentsDocument, { issueId, first: limit, after, @@ -82,10 +72,9 @@ export async function replyToComment( client: GraphQLClient, input: { parentId: string; body: string }, ): Promise<CreatedComment> { - const result = await client.request<CreateCommentMutation>( - CreateCommentDocument, - { input: { parentId: input.parentId, body: input.body } }, - ); + const result = await client.request(CreateCommentDocument, { + input: { parentId: input.parentId, body: input.body }, + }); if (!result.commentCreate.success || !result.commentCreate.comment) { throw new Error("Failed to create reply"); @@ -98,10 +87,7 @@ export async function deleteComment( client: GraphQLClient, id: string, ): Promise<{ id: string; success: boolean }> { - const result = await client.request<DeleteCommentMutation>( - DeleteCommentDocument, - { id }, - ); + const result = await client.request(DeleteCommentDocument, { id }); if (!result.commentDelete.success) { throw new Error("Failed to delete comment"); diff --git a/src/services/cycle-service.ts b/src/services/cycle-service.ts index 90cdd4f8..3e66ba51 100644 --- a/src/services/cycle-service.ts +++ b/src/services/cycle-service.ts @@ -3,9 +3,7 @@ import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { type CycleFilter, GetCycleByIdDocument, - type GetCycleByIdQuery, GetCyclesDocument, - type GetCyclesQuery, } from "../gql/graphql.js"; export interface Cycle { @@ -45,7 +43,7 @@ export async function listCycles( filter.isActive = { eq: true }; } - const result = await client.request<GetCyclesQuery>(GetCyclesDocument, { + const result = await client.request(GetCyclesDocument, { first: limit, after, filter, @@ -71,7 +69,7 @@ export async function getCycle( cycleId: string, issuesLimit: number = 50, ): Promise<CycleDetail> { - const result = await client.request<GetCycleByIdQuery>(GetCycleByIdDocument, { + const result = await client.request(GetCycleByIdDocument, { id: cycleId, first: issuesLimit, }); diff --git a/src/services/discussion-service.ts b/src/services/discussion-service.ts index 1ebcc18c..2f710fb5 100644 --- a/src/services/discussion-service.ts +++ b/src/services/discussion-service.ts @@ -4,7 +4,6 @@ import { type CommentCreateInput, type CommentUpdateInput, DeleteDiscussionReplyDocument, - type DeleteDiscussionReplyMutation, type DiscussionCommentFieldsFragment, type DiscussionCommentFieldsWithReactionsFragment, EditDiscussionReplyDocument, @@ -16,25 +15,19 @@ import { ListInitiativeDiscussionReplyCandidatesWithReactionsDocument, type ListInitiativeDiscussionReplyCandidatesWithReactionsQuery, ListInitiativeDiscussionRootsDocument, - type ListInitiativeDiscussionRootsQuery, ListInitiativeDiscussionRootsWithReactionsDocument, - type ListInitiativeDiscussionRootsWithReactionsQuery, ListIssueDiscussionReplyCandidatesDocument, type ListIssueDiscussionReplyCandidatesQuery, ListIssueDiscussionReplyCandidatesWithReactionsDocument, type ListIssueDiscussionReplyCandidatesWithReactionsQuery, ListIssueDiscussionRootsDocument, - type ListIssueDiscussionRootsQuery, ListIssueDiscussionRootsWithReactionsDocument, - type ListIssueDiscussionRootsWithReactionsQuery, ListProjectDiscussionReplyCandidatesDocument, type ListProjectDiscussionReplyCandidatesQuery, ListProjectDiscussionReplyCandidatesWithReactionsDocument, type ListProjectDiscussionReplyCandidatesWithReactionsQuery, ListProjectDiscussionRootsDocument, - type ListProjectDiscussionRootsQuery, ListProjectDiscussionRootsWithReactionsDocument, - type ListProjectDiscussionRootsWithReactionsQuery, ResolveDiscussionDocument, type ResolveDiscussionMutation, StartDiscussionDocument, @@ -172,10 +165,9 @@ async function assertDiscussionCommentExists( expectedEntityKind?: DiscussionEntityKind, label: "comment" | "reply" = "comment", ): Promise<DiscussionCommentContext> { - const result = await client.request<GetDiscussionCommentContextQuery>( - GetDiscussionCommentContextDocument, - { id }, - ); + const result = await client.request(GetDiscussionCommentContextDocument, { + id, + }); if (!result.comment) { throw new Error(`Discussion comment ID "${id}" not found`); @@ -191,10 +183,9 @@ async function assertRootDiscussionThread( threadId: string, expectedEntityKind?: DiscussionEntityKind, ): Promise<DiscussionThreadContext> { - const result = await client.request<GetDiscussionCommentContextQuery>( - GetDiscussionCommentContextDocument, - { id: threadId }, - ); + const result = await client.request(GetDiscussionCommentContextDocument, { + id: threadId, + }); if (!result.comment) { throw new Error(`Discussion thread ID "${threadId}" not found`); @@ -306,7 +297,7 @@ async function listDiscussionReplyCandidates( let result: DiscussionReplyCandidateQuery; if (entity.kind === "issue") { - result = await client.request<ListIssueDiscussionReplyCandidatesQuery>( + result = await client.request( ListIssueDiscussionReplyCandidatesDocument, { issueId: entity.id, @@ -315,7 +306,7 @@ async function listDiscussionReplyCandidates( }, ); } else if (entity.kind === "project") { - result = await client.request<ListProjectDiscussionReplyCandidatesQuery>( + result = await client.request( ListProjectDiscussionReplyCandidatesDocument, { projectId: entity.id, @@ -324,15 +315,14 @@ async function listDiscussionReplyCandidates( }, ); } else { - result = - await client.request<ListInitiativeDiscussionReplyCandidatesQuery>( - ListInitiativeDiscussionReplyCandidatesDocument, - { - initiativeId: entity.id, - first: DISCUSSION_REPLY_FETCH_LIMIT, - after, - }, - ); + result = await client.request( + ListInitiativeDiscussionReplyCandidatesDocument, + { + initiativeId: entity.id, + first: DISCUSSION_REPLY_FETCH_LIMIT, + after, + }, + ); } nodes.push(...result.comments.nodes); @@ -362,35 +352,32 @@ async function listDiscussionReplyCandidatesWithReactions( let result: DiscussionReplyCandidateWithReactionsQuery; if (entity.kind === "issue") { - result = - await client.request<ListIssueDiscussionReplyCandidatesWithReactionsQuery>( - ListIssueDiscussionReplyCandidatesWithReactionsDocument, - { - issueId: entity.id, - first: DISCUSSION_REPLY_FETCH_LIMIT, - after, - }, - ); + result = await client.request( + ListIssueDiscussionReplyCandidatesWithReactionsDocument, + { + issueId: entity.id, + first: DISCUSSION_REPLY_FETCH_LIMIT, + after, + }, + ); } else if (entity.kind === "project") { - result = - await client.request<ListProjectDiscussionReplyCandidatesWithReactionsQuery>( - ListProjectDiscussionReplyCandidatesWithReactionsDocument, - { - projectId: entity.id, - first: DISCUSSION_REPLY_FETCH_LIMIT, - after, - }, - ); + result = await client.request( + ListProjectDiscussionReplyCandidatesWithReactionsDocument, + { + projectId: entity.id, + first: DISCUSSION_REPLY_FETCH_LIMIT, + after, + }, + ); } else { - result = - await client.request<ListInitiativeDiscussionReplyCandidatesWithReactionsQuery>( - ListInitiativeDiscussionReplyCandidatesWithReactionsDocument, - { - initiativeId: entity.id, - first: DISCUSSION_REPLY_FETCH_LIMIT, - after, - }, - ); + result = await client.request( + ListInitiativeDiscussionReplyCandidatesWithReactionsDocument, + { + initiativeId: entity.id, + first: DISCUSSION_REPLY_FETCH_LIMIT, + after, + }, + ); } nodes.push(...result.comments.nodes); @@ -482,10 +469,7 @@ async function startDiscussion( client: GraphQLClient, input: CommentCreateInput, ): Promise<StartDiscussionMutation["commentCreate"]["comment"]> { - const result = await client.request<StartDiscussionMutation>( - StartDiscussionDocument, - { input }, - ); + const result = await client.request(StartDiscussionDocument, { input }); if (!result.commentCreate.success || !result.commentCreate.comment) { throw new Error("Failed to start discussion"); @@ -576,14 +560,11 @@ export async function listDiscussionsForIssue( options: PaginationOptions = {}, ): Promise<PaginatedResult<DiscussionThread>> { const { limit = DEFAULT_ROOT_LIMIT, after } = options; - const result = await client.request<ListIssueDiscussionRootsQuery>( - ListIssueDiscussionRootsDocument, - { - issueId, - first: limit, - after, - }, - ); + const result = await client.request(ListIssueDiscussionRootsDocument, { + issueId, + first: limit, + after, + }); if (!result.issue) { throw new Error(`Issue with ID "${issueId}" not found`); @@ -601,15 +582,14 @@ export async function listDiscussionsForIssueWithReactions( options: PaginationOptions = {}, ): Promise<PaginatedResult<DiscussionThreadWithReactions>> { const { limit = DEFAULT_ROOT_LIMIT, after } = options; - const result = - await client.request<ListIssueDiscussionRootsWithReactionsQuery>( - ListIssueDiscussionRootsWithReactionsDocument, - { - issueId, - first: limit, - after, - }, - ); + const result = await client.request( + ListIssueDiscussionRootsWithReactionsDocument, + { + issueId, + first: limit, + after, + }, + ); if (!result.issue) { throw new Error(`Issue with ID "${issueId}" not found`); @@ -627,14 +607,11 @@ export async function listDiscussionsForProject( options: PaginationOptions = {}, ): Promise<PaginatedResult<DiscussionThread>> { const { limit = DEFAULT_ROOT_LIMIT, after } = options; - const result = await client.request<ListProjectDiscussionRootsQuery>( - ListProjectDiscussionRootsDocument, - { - projectId, - first: limit, - after, - }, - ); + const result = await client.request(ListProjectDiscussionRootsDocument, { + projectId, + first: limit, + after, + }); if (!result.project) { throw new Error(`Project with ID "${projectId}" not found`); @@ -652,15 +629,14 @@ export async function listDiscussionsForProjectWithReactions( options: PaginationOptions = {}, ): Promise<PaginatedResult<DiscussionThreadWithReactions>> { const { limit = DEFAULT_ROOT_LIMIT, after } = options; - const result = - await client.request<ListProjectDiscussionRootsWithReactionsQuery>( - ListProjectDiscussionRootsWithReactionsDocument, - { - projectId, - first: limit, - after, - }, - ); + const result = await client.request( + ListProjectDiscussionRootsWithReactionsDocument, + { + projectId, + first: limit, + after, + }, + ); if (!result.project) { throw new Error(`Project with ID "${projectId}" not found`); @@ -678,15 +654,12 @@ export async function listDiscussionsForInitiative( options: PaginationOptions = {}, ): Promise<PaginatedResult<DiscussionThread>> { const { limit = DEFAULT_ROOT_LIMIT, after } = options; - const result = await client.request<ListInitiativeDiscussionRootsQuery>( - ListInitiativeDiscussionRootsDocument, - { - initiativeId, - initiativeLookupId: initiativeId, - first: limit, - after, - }, - ); + const result = await client.request(ListInitiativeDiscussionRootsDocument, { + initiativeId, + initiativeLookupId: initiativeId, + first: limit, + after, + }); if (!result.initiative) { throw new Error(`Initiative with ID "${initiativeId}" not found`); @@ -704,16 +677,15 @@ export async function listDiscussionsForInitiativeWithReactions( options: PaginationOptions = {}, ): Promise<PaginatedResult<DiscussionThreadWithReactions>> { const { limit = DEFAULT_ROOT_LIMIT, after } = options; - const result = - await client.request<ListInitiativeDiscussionRootsWithReactionsQuery>( - ListInitiativeDiscussionRootsWithReactionsDocument, - { - initiativeId, - initiativeLookupId: initiativeId, - first: limit, - after, - }, - ); + const result = await client.request( + ListInitiativeDiscussionRootsWithReactionsDocument, + { + initiativeId, + initiativeLookupId: initiativeId, + first: limit, + after, + }, + ); if (!result.initiative) { throw new Error(`Initiative with ID "${initiativeId}" not found`); @@ -808,16 +780,13 @@ export async function replyToDiscussion( ? { projectId: entity.id } : { initiativeId: entity.id }; - const result = await client.request<StartDiscussionMutation>( - StartDiscussionDocument, - { - input: { - parentId: input.threadId, - ...entityField, - body: input.body, - }, + const result = await client.request(StartDiscussionDocument, { + input: { + parentId: input.threadId, + ...entityField, + body: input.body, }, - ); + }); if (!result.commentCreate.success || !result.commentCreate.comment) { throw new Error("Failed to create discussion reply"); @@ -834,10 +803,10 @@ export async function editDiscussionReply( ): Promise<EditDiscussionReplyMutation["commentUpdate"]["comment"]> { await assertReplyComment(client, id, expectedEntityKind); - const result = await client.request<EditDiscussionReplyMutation>( - EditDiscussionReplyDocument, - { id, input }, - ); + const result = await client.request(EditDiscussionReplyDocument, { + id, + input, + }); if (!result.commentUpdate.success || !result.commentUpdate.comment) { throw new Error("Failed to edit discussion reply"); @@ -853,10 +822,7 @@ export async function deleteDiscussionReply( ): Promise<{ id: string; success: true }> { await assertReplyComment(client, id, expectedEntityKind); - const result = await client.request<DeleteDiscussionReplyMutation>( - DeleteDiscussionReplyDocument, - { id }, - ); + const result = await client.request(DeleteDiscussionReplyDocument, { id }); if (!result.commentDelete.success) { throw new Error("Failed to delete discussion reply"); @@ -876,10 +842,10 @@ export async function editDiscussionComment( ): Promise<EditDiscussionReplyMutation["commentUpdate"]["comment"]> { await assertDiscussionCommentExists(client, id, expectedEntityKind); - const result = await client.request<EditDiscussionReplyMutation>( - EditDiscussionReplyDocument, - { id, input }, - ); + const result = await client.request(EditDiscussionReplyDocument, { + id, + input, + }); if (!result.commentUpdate.success || !result.commentUpdate.comment) { throw new Error("Failed to edit discussion comment"); @@ -895,10 +861,7 @@ export async function deleteDiscussionComment( ): Promise<{ id: string; success: true }> { await assertDiscussionCommentExists(client, id, expectedEntityKind); - const result = await client.request<DeleteDiscussionReplyMutation>( - DeleteDiscussionReplyDocument, - { id }, - ); + const result = await client.request(DeleteDiscussionReplyDocument, { id }); if (!result.commentDelete.success) { throw new Error("Failed to delete discussion comment"); @@ -920,13 +883,10 @@ export async function resolveDiscussion( ): Promise<ResolveDiscussionMutation["commentResolve"]["comment"]> { await assertRootDiscussionThread(client, input.threadId, input.entityKind); - const result = await client.request<ResolveDiscussionMutation>( - ResolveDiscussionDocument, - { - id: input.threadId, - resolvingCommentId: input.resolvingCommentId, - }, - ); + 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"); @@ -942,10 +902,9 @@ export async function unresolveDiscussion( ): Promise<UnresolveDiscussionMutation["commentUnresolve"]["comment"]> { await assertRootDiscussionThread(client, threadId, expectedEntityKind); - const result = await client.request<UnresolveDiscussionMutation>( - UnresolveDiscussionDocument, - { id: threadId }, - ); + const result = await client.request(UnresolveDiscussionDocument, { + id: threadId, + }); if (!result.commentUnresolve.success || !result.commentUnresolve.comment) { throw new Error("Failed to unresolve discussion"); diff --git a/src/services/document-service.ts b/src/services/document-service.ts index ad801e85..6ec73d9f 100644 --- a/src/services/document-service.ts +++ b/src/services/document-service.ts @@ -9,24 +9,19 @@ import type { import { DocumentCreateDocument, type DocumentCreateInput, - type DocumentCreateMutation, DocumentDeleteDocument, - type DocumentDeleteMutation, type DocumentFilter, DocumentUpdateDocument, type DocumentUpdateInput, - type DocumentUpdateMutation, GetDocumentDocument, - type GetDocumentQuery, ListDocumentsDocument, - type ListDocumentsQuery, } from "../gql/graphql.js"; export async function getDocument( client: GraphQLClient, id: string, ): Promise<Document> { - const result = await client.request<GetDocumentQuery>(GetDocumentDocument, { + const result = await client.request(GetDocumentDocument, { id, }); @@ -41,10 +36,7 @@ export async function createDocument( client: GraphQLClient, input: DocumentCreateInput, ): Promise<CreatedDocument> { - const result = await client.request<DocumentCreateMutation>( - DocumentCreateDocument, - { input }, - ); + const result = await client.request(DocumentCreateDocument, { input }); if (!result.documentCreate.success || !result.documentCreate.document) { throw new Error("Failed to create document"); @@ -58,10 +50,7 @@ export async function updateDocument( id: string, input: DocumentUpdateInput, ): Promise<UpdatedDocument> { - const result = await client.request<DocumentUpdateMutation>( - DocumentUpdateDocument, - { id, input }, - ); + const result = await client.request(DocumentUpdateDocument, { id, input }); if (!result.documentUpdate.success || !result.documentUpdate.document) { throw new Error("Failed to update document"); @@ -78,14 +67,11 @@ export async function listDocuments( filter?: DocumentFilter; }, ): Promise<PaginatedResult<DocumentListItem>> { - const result = await client.request<ListDocumentsQuery>( - ListDocumentsDocument, - { - first: options?.limit ?? 25, - after: options?.after, - filter: options?.filter, - }, - ); + const result = await client.request(ListDocumentsDocument, { + first: options?.limit ?? 25, + after: options?.after, + filter: options?.filter, + }); return { nodes: result.documents?.nodes ?? [], @@ -100,10 +86,7 @@ export async function deleteDocument( client: GraphQLClient, id: string, ): Promise<{ id: string; success: boolean }> { - const result = await client.request<DocumentDeleteMutation>( - DocumentDeleteDocument, - { id }, - ); + const result = await client.request(DocumentDeleteDocument, { id }); if (!result.documentDelete.success) { throw new Error("Failed to delete document"); diff --git a/src/services/initiative-project-service.ts b/src/services/initiative-project-service.ts index e44e0029..e655e7ff 100644 --- a/src/services/initiative-project-service.ts +++ b/src/services/initiative-project-service.ts @@ -5,21 +5,16 @@ import type { } from "../common/types.js"; import { CreateInitiativeToProjectDocument, - type CreateInitiativeToProjectMutation, DeleteInitiativeToProjectDocument, - type DeleteInitiativeToProjectMutation, } from "../gql/graphql.js"; export async function createInitiativeProjectLink( client: GraphQLClient, input: { initiativeId: string; projectId: string }, ): Promise<InitiativeProjectLink> { - const result = await client.request<CreateInitiativeToProjectMutation>( - CreateInitiativeToProjectDocument, - { - input, - }, - ); + const result = await client.request(CreateInitiativeToProjectDocument, { + input, + }); if ( !result.initiativeToProjectCreate.success || @@ -37,10 +32,9 @@ export async function deleteInitiativeProjectLink( client: GraphQLClient, id: string, ): Promise<DeletedInitiativeProjectLink> { - const result = await client.request<DeleteInitiativeToProjectMutation>( - DeleteInitiativeToProjectDocument, - { id }, - ); + const result = await client.request(DeleteInitiativeToProjectDocument, { + id, + }); if ( !result.initiativeToProjectDelete.success || diff --git a/src/services/initiative-relation-service.ts b/src/services/initiative-relation-service.ts index 21b587e2..1cc04c90 100644 --- a/src/services/initiative-relation-service.ts +++ b/src/services/initiative-relation-service.ts @@ -5,24 +5,19 @@ import type { } from "../common/types.js"; import { CreateInitiativeRelationDocument, - type CreateInitiativeRelationMutation, DeleteInitiativeRelationDocument, - type DeleteInitiativeRelationMutation, } from "../gql/graphql.js"; export async function createInitiativeRelation( client: GraphQLClient, input: { parentId: string; childId: string }, ): Promise<InitiativeRelation> { - const result = await client.request<CreateInitiativeRelationMutation>( - CreateInitiativeRelationDocument, - { - input: { - initiativeId: input.parentId, - relatedInitiativeId: input.childId, - }, + const result = await client.request(CreateInitiativeRelationDocument, { + input: { + initiativeId: input.parentId, + relatedInitiativeId: input.childId, }, - ); + }); if ( !result.initiativeRelationCreate.success || @@ -40,10 +35,7 @@ export async function deleteInitiativeRelation( client: GraphQLClient, id: string, ): Promise<DeletedInitiativeRelation> { - const result = await client.request<DeleteInitiativeRelationMutation>( - DeleteInitiativeRelationDocument, - { id }, - ); + const result = await client.request(DeleteInitiativeRelationDocument, { id }); if ( !result.initiativeRelationDelete.success || diff --git a/src/services/initiative-service.ts b/src/services/initiative-service.ts index 073f5fe9..5c0da521 100644 --- a/src/services/initiative-service.ts +++ b/src/services/initiative-service.ts @@ -12,22 +12,15 @@ import type { } from "../common/types.js"; import { ArchiveInitiativeDocument, - type ArchiveInitiativeMutation, CreateInitiativeDocument, - type CreateInitiativeMutation, DeleteInitiativeDocument, - type DeleteInitiativeMutation, GetInitiativeDocument, - type GetInitiativeQuery, type InitiativeCreateInput, type InitiativeUpdateInput, ListInitiativesDocument, - type ListInitiativesQuery, type ListInitiativesQueryVariables, UnarchiveInitiativeDocument, - type UnarchiveInitiativeMutation, UpdateInitiativeDocument, - type UpdateInitiativeMutation, } from "../gql/graphql.js"; export interface InitiativeListOptions { @@ -52,17 +45,14 @@ export async function listInitiatives( sort, } = options; - const result = await client.request<ListInitiativesQuery>( - ListInitiativesDocument, - { - first: limit, - after, - includeArchived, - filter, - orderBy, - sort, - }, - ); + const result = await client.request(ListInitiativesDocument, { + first: limit, + after, + includeArchived, + filter, + orderBy, + sort, + }); return { nodes: result.initiatives.nodes, @@ -74,12 +64,9 @@ export async function getInitiative( client: GraphQLClient, id: string, ): Promise<InitiativeDetail> { - const result = await client.request<GetInitiativeQuery>( - GetInitiativeDocument, - { - id, - }, - ); + const result = await client.request(GetInitiativeDocument, { + id, + }); if (!result.initiative) { throw new Error(`Initiative with ID "${id}" not found`); @@ -92,12 +79,9 @@ export async function createInitiative( client: GraphQLClient, input: InitiativeCreateInput, ): Promise<CreatedInitiative> { - const result = await client.request<CreateInitiativeMutation>( - CreateInitiativeDocument, - { - input, - }, - ); + const result = await client.request(CreateInitiativeDocument, { + input, + }); if (!result.initiativeCreate.success || !result.initiativeCreate.initiative) { throw new Error(`Failed to create initiative "${input.name}"`); @@ -122,13 +106,10 @@ export async function updateInitiative( ); } - const result = await client.request<UpdateInitiativeMutation>( - UpdateInitiativeDocument, - { - id, - input, - }, - ); + const result = await client.request(UpdateInitiativeDocument, { + id, + input, + }); if (!result.initiativeUpdate.success || !result.initiativeUpdate.initiative) { throw new Error(`Failed to update initiative "${id}"`); @@ -141,10 +122,7 @@ export async function archiveInitiative( client: GraphQLClient, id: string, ): Promise<ArchivedInitiative> { - const result = await client.request<ArchiveInitiativeMutation>( - ArchiveInitiativeDocument, - { id }, - ); + const result = await client.request(ArchiveInitiativeDocument, { id }); if (!result.initiativeArchive.success || !result.initiativeArchive.entity) { throw new Error(`Failed to archive initiative "${id}"`); @@ -157,10 +135,7 @@ export async function unarchiveInitiative( client: GraphQLClient, id: string, ): Promise<UnarchivedInitiative> { - const result = await client.request<UnarchiveInitiativeMutation>( - UnarchiveInitiativeDocument, - { id }, - ); + const result = await client.request(UnarchiveInitiativeDocument, { id }); if ( !result.initiativeUnarchive.success || @@ -176,10 +151,7 @@ export async function deleteInitiative( client: GraphQLClient, id: string, ): Promise<DeletedInitiative> { - const result = await client.request<DeleteInitiativeMutation>( - DeleteInitiativeDocument, - { id }, - ); + const result = await client.request(DeleteInitiativeDocument, { id }); if (!result.initiativeDelete.success || !result.initiativeDelete.entityId) { throw new Error(`Failed to delete initiative "${id}"`); diff --git a/src/services/initiative-update-service.ts b/src/services/initiative-update-service.ts index af552bc7..98d74e90 100644 --- a/src/services/initiative-update-service.ts +++ b/src/services/initiative-update-service.ts @@ -11,19 +11,13 @@ import type { } from "../common/types.js"; import { ArchiveInitiativeUpdateDocument, - type ArchiveInitiativeUpdateMutation, CreateInitiativeUpdateDocument, - type CreateInitiativeUpdateMutation, GetInitiativeUpdateDocument, - type GetInitiativeUpdateQuery, type InitiativeUpdateCreateInput, type InitiativeUpdateUpdateInput, ListInitiativeUpdatesDocument, - type ListInitiativeUpdatesQuery, UnarchiveInitiativeUpdateDocument, - type UnarchiveInitiativeUpdateMutation, UpdateInitiativeUpdateDocument, - type UpdateInitiativeUpdateMutation, } from "../gql/graphql.js"; export interface InitiativeUpdateListOptions { @@ -39,15 +33,12 @@ export async function listInitiativeUpdates( ): Promise<PaginatedResult<InitiativeUpdateListItem>> { const { initiativeId, limit = 50, after, includeArchived = false } = options; - const result = await client.request<ListInitiativeUpdatesQuery>( - ListInitiativeUpdatesDocument, - { - initiativeId, - first: limit, - after, - includeArchived, - }, - ); + const result = await client.request(ListInitiativeUpdatesDocument, { + initiativeId, + first: limit, + after, + includeArchived, + }); return { nodes: result.initiativeUpdates.nodes, @@ -59,10 +50,7 @@ export async function getInitiativeUpdate( client: GraphQLClient, id: string, ): Promise<InitiativeUpdateDetail> { - const result = await client.request<GetInitiativeUpdateQuery>( - GetInitiativeUpdateDocument, - { id }, - ); + const result = await client.request(GetInitiativeUpdateDocument, { id }); if (!result.initiativeUpdate) { throw new Error(`Initiative update with ID "${id}" not found`); @@ -75,10 +63,9 @@ export async function createInitiativeUpdate( client: GraphQLClient, input: InitiativeUpdateCreateInput, ): Promise<CreatedInitiativeUpdate> { - const result = await client.request<CreateInitiativeUpdateMutation>( - CreateInitiativeUpdateDocument, - { input }, - ); + const result = await client.request(CreateInitiativeUpdateDocument, { + input, + }); if ( !result.initiativeUpdateCreate.success || @@ -106,10 +93,10 @@ export async function updateInitiativeUpdate( ); } - const result = await client.request<UpdateInitiativeUpdateMutation>( - UpdateInitiativeUpdateDocument, - { id, input }, - ); + const result = await client.request(UpdateInitiativeUpdateDocument, { + id, + input, + }); if ( !result.initiativeUpdateUpdate.success || @@ -125,10 +112,7 @@ export async function archiveInitiativeUpdate( client: GraphQLClient, id: string, ): Promise<ArchivedInitiativeUpdate> { - const result = await client.request<ArchiveInitiativeUpdateMutation>( - ArchiveInitiativeUpdateDocument, - { id }, - ); + const result = await client.request(ArchiveInitiativeUpdateDocument, { id }); if ( !result.initiativeUpdateArchive.success || @@ -144,10 +128,9 @@ export async function unarchiveInitiativeUpdate( client: GraphQLClient, id: string, ): Promise<UnarchivedInitiativeUpdate> { - const result = await client.request<UnarchiveInitiativeUpdateMutation>( - UnarchiveInitiativeUpdateDocument, - { id }, - ); + const result = await client.request(UnarchiveInitiativeUpdateDocument, { + id, + }); if ( !result.initiativeUpdateUnarchive.success || diff --git a/src/services/issue-relation-service.ts b/src/services/issue-relation-service.ts index 9fec051f..a0317e9e 100644 --- a/src/services/issue-relation-service.ts +++ b/src/services/issue-relation-service.ts @@ -3,9 +3,7 @@ import { notFoundError } from "../common/errors.js"; import type { CreatedIssueRelation } from "../common/types.js"; import { CreateIssueRelationDocument, - type CreateIssueRelationMutation, DeleteIssueRelationDocument, - type DeleteIssueRelationMutation, GetIssueRelationsDocument, type GetIssueRelationsQuery, type IssueRelationType, @@ -21,10 +19,7 @@ export async function createIssueRelation( type: IssueRelationType; }, ): Promise<CreatedIssueRelation> { - const result = await client.request<CreateIssueRelationMutation>( - CreateIssueRelationDocument, - { input }, - ); + const result = await client.request(CreateIssueRelationDocument, { input }); if (!result.issueRelationCreate.success) { throw new Error("Failed to create issue relation"); } @@ -42,10 +37,7 @@ export async function listIssueRelations( | IssueRelationsIssue["inverseRelations"]["nodes"][0] >; }> { - const result = await client.request<GetIssueRelationsQuery>( - GetIssueRelationsDocument, - { issueId }, - ); + const result = await client.request(GetIssueRelationsDocument, { issueId }); if (!result.issue) { throw notFoundError("Issue", issueId); @@ -66,10 +58,7 @@ export async function findIssueRelation( issueId: string, relatedIssueId: string, ): Promise<string> { - const result = await client.request<GetIssueRelationsQuery>( - GetIssueRelationsDocument, - { issueId }, - ); + const result = await client.request(GetIssueRelationsDocument, { issueId }); if (!result.issue) { throw notFoundError("Issue", issueId); @@ -94,10 +83,9 @@ export async function deleteIssueRelation( client: GraphQLClient, relationId: string, ): Promise<{ id: string; success: boolean }> { - const result = await client.request<DeleteIssueRelationMutation>( - DeleteIssueRelationDocument, - { id: relationId }, - ); + const result = await client.request(DeleteIssueRelationDocument, { + id: relationId, + }); if (!result.issueRelationDelete.success) { throw new Error("Failed to delete issue relation"); } diff --git a/src/services/issue-service.ts b/src/services/issue-service.ts index c984bcc2..e0edac96 100644 --- a/src/services/issue-service.ts +++ b/src/services/issue-service.ts @@ -19,41 +19,27 @@ import type { } from "../common/types.js"; import { ArchiveIssueDocument, - type ArchiveIssueMutation, CreateIssueDocument, - type CreateIssueMutation, DeleteIssueDocument, - type DeleteIssueMutation, FilteredSearchIssuesDocument, - type FilteredSearchIssuesQuery, GetIssueByIdDocument, GetIssueByIdentifierDocument, - type GetIssueByIdentifierQuery, GetIssueByIdentifierWithAttachmentsDocument, - type GetIssueByIdentifierWithAttachmentsQuery, GetIssueByIdentifierWithCommentsDocument, - type GetIssueByIdentifierWithCommentsQuery, GetIssueByIdentifierWithReactionsDocument, type GetIssueByIdentifierWithReactionsQuery, - type GetIssueByIdQuery, GetIssueByIdWithAttachmentsDocument, - type GetIssueByIdWithAttachmentsQuery, GetIssueByIdWithCommentsDocument, - type GetIssueByIdWithCommentsQuery, GetIssueByIdWithReactionsDocument, type GetIssueByIdWithReactionsQuery, GetIssuesDocument, - type GetIssuesQuery, type IssueCreateInput, type IssueFilter, type IssueUpdateInput, SearchIssuesDocument, - type SearchIssuesQuery, type SearchIssuesQueryVariables, UnarchiveIssueDocument, - type UnarchiveIssueMutation, UpdateIssueDocument, - type UpdateIssueMutation, } from "../gql/graphql.js"; import { normalizeReactions } from "./reaction-service.js"; @@ -200,22 +186,19 @@ export async function listIssues( const { limit = 25, after } = options; if (filter) { - const result = await client.request<FilteredSearchIssuesQuery>( - FilteredSearchIssuesDocument, - { - first: limit, - after, - filter: buildListIssuesFilter(filter), - orderBy: "updatedAt", - }, - ); + const result = await client.request(FilteredSearchIssuesDocument, { + first: limit, + after, + filter: buildListIssuesFilter(filter), + orderBy: "updatedAt", + }); return { nodes: result.issues?.nodes ?? [], pageInfo: result.issues.pageInfo, }; } - const result = await client.request<GetIssuesQuery>(GetIssuesDocument, { + const result = await client.request(GetIssuesDocument, { first: limit, after, orderBy: "updatedAt", @@ -230,7 +213,7 @@ export async function getIssue( client: GraphQLClient, id: string, ): Promise<IssueDetail> { - const result = await client.request<GetIssueByIdQuery>(GetIssueByIdDocument, { + const result = await client.request(GetIssueByIdDocument, { id, }); if (!result.issue) { @@ -243,10 +226,7 @@ export async function getIssueWithComments( client: GraphQLClient, id: string, ): Promise<IssueDetailWithComments> { - const result = await client.request<GetIssueByIdWithCommentsQuery>( - GetIssueByIdWithCommentsDocument, - { id }, - ); + const result = await client.request(GetIssueByIdWithCommentsDocument, { id }); if (!result.issue) { throw new Error(`Issue with ID "${id}" not found`); } @@ -266,10 +246,10 @@ export async function getIssueByIdentifier( teamKey: string, issueNumber: number, ): Promise<IssueByIdentifier> { - const result = await client.request<GetIssueByIdentifierQuery>( - GetIssueByIdentifierDocument, - { teamKey, number: issueNumber }, - ); + const result = await client.request(GetIssueByIdentifierDocument, { + teamKey, + number: issueNumber, + }); if (!result.issues.nodes.length) { throw new Error( `Issue with identifier "${teamKey}-${issueNumber}" not found`, @@ -283,7 +263,7 @@ export async function getIssueByIdentifierWithComments( teamKey: string, issueNumber: number, ): Promise<IssueByIdentifierWithComments> { - const result = await client.request<GetIssueByIdentifierWithCommentsQuery>( + const result = await client.request( GetIssueByIdentifierWithCommentsDocument, { teamKey, number: issueNumber }, ); @@ -312,10 +292,9 @@ export async function getIssueWithReactions( client: GraphQLClient, id: string, ): Promise<IssueDetailWithReactions> { - const result = await client.request<GetIssueByIdWithReactionsQuery>( - GetIssueByIdWithReactionsDocument, - { id }, - ); + const result = await client.request(GetIssueByIdWithReactionsDocument, { + id, + }); if (!result.issue) { throw new Error(`Issue with ID "${id}" not found`); } @@ -327,7 +306,7 @@ export async function getIssueByIdentifierWithReactions( teamKey: string, issueNumber: number, ): Promise<IssueByIdentifierWithReactions> { - const result = await client.request<GetIssueByIdentifierWithReactionsQuery>( + const result = await client.request( GetIssueByIdentifierWithReactionsDocument, { teamKey, number: issueNumber }, ); @@ -343,10 +322,9 @@ export async function getIssueWithAttachments( client: GraphQLClient, id: string, ): Promise<IssueDetailWithAttachments> { - const result = await client.request<GetIssueByIdWithAttachmentsQuery>( - GetIssueByIdWithAttachmentsDocument, - { id }, - ); + const result = await client.request(GetIssueByIdWithAttachmentsDocument, { + id, + }); if (!result.issue) { throw new Error(`Issue with ID "${id}" not found`); } @@ -358,7 +336,7 @@ export async function getIssueByIdentifierWithAttachments( teamKey: string, issueNumber: number, ): Promise<IssueByIdentifierWithAttachments> { - const result = await client.request<GetIssueByIdentifierWithAttachmentsQuery>( + const result = await client.request( GetIssueByIdentifierWithAttachmentsDocument, { teamKey, number: issueNumber }, ); @@ -383,10 +361,7 @@ export async function searchIssues( after, ...(filter && { filter }), }; - const result = await client.request<SearchIssuesQuery>( - SearchIssuesDocument, - variables, - ); + const result = await client.request(SearchIssuesDocument, variables); return { nodes: result.searchIssues?.nodes ?? [], pageInfo: result.searchIssues.pageInfo, @@ -397,10 +372,7 @@ export async function createIssue( client: GraphQLClient, input: IssueCreateInput, ): Promise<CreatedIssue> { - const result = await client.request<CreateIssueMutation>( - CreateIssueDocument, - { input }, - ); + const result = await client.request(CreateIssueDocument, { input }); if (!result.issueCreate.success || !result.issueCreate.issue) { throw new Error("Failed to create issue"); } @@ -412,10 +384,7 @@ export async function updateIssue( id: string, input: IssueUpdateInput, ): Promise<UpdatedIssue> { - const result = await client.request<UpdateIssueMutation>( - UpdateIssueDocument, - { id, input }, - ); + const result = await client.request(UpdateIssueDocument, { id, input }); if (!result.issueUpdate.success || !result.issueUpdate.issue) { throw new Error("Failed to update issue"); } @@ -426,10 +395,7 @@ export async function archiveIssue( client: GraphQLClient, id: string, ): Promise<IssueDetail> { - const result = await client.request<ArchiveIssueMutation>( - ArchiveIssueDocument, - { id }, - ); + const result = await client.request(ArchiveIssueDocument, { id }); if (!result.issueArchive.success || !result.issueArchive.entity) { throw new Error(`Failed to archive issue "${id}"`); @@ -442,10 +408,7 @@ export async function unarchiveIssue( client: GraphQLClient, id: string, ): Promise<IssueDetail> { - const result = await client.request<UnarchiveIssueMutation>( - UnarchiveIssueDocument, - { id }, - ); + const result = await client.request(UnarchiveIssueDocument, { id }); if (!result.issueUnarchive.success || !result.issueUnarchive.entity) { throw new Error(`Failed to unarchive issue "${id}"`); @@ -458,12 +421,9 @@ export async function deleteIssue( client: GraphQLClient, id: string, ): Promise<{ id: string; success: true }> { - const result = await client.request<DeleteIssueMutation>( - DeleteIssueDocument, - { - id, - }, - ); + const result = await client.request(DeleteIssueDocument, { + id, + }); if (!result.issueDelete.success || !result.issueDelete.entity?.id) { throw new Error(`Failed to delete issue "${id}"`); diff --git a/src/services/label-service.ts b/src/services/label-service.ts index 9d8f485a..195e99b1 100644 --- a/src/services/label-service.ts +++ b/src/services/label-service.ts @@ -2,9 +2,7 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { GetLabelsDocument, - type GetLabelsQuery, GetProjectLabelsDocument, - type GetProjectLabelsQuery, type IssueLabelFilter, } from "../gql/graphql.js"; @@ -50,7 +48,7 @@ export async function listLabels( const { limit = 50, after, scope } = options; const filter = buildIssueLabelFilter(teamId, scope); - const result = await client.request<GetLabelsQuery>(GetLabelsDocument, { + const result = await client.request(GetLabelsDocument, { first: limit, after, filter, @@ -74,13 +72,10 @@ export async function listProjectLabels( ): Promise<PaginatedResult<Label>> { const { limit = 50, after } = options; - const result = await client.request<GetProjectLabelsQuery>( - GetProjectLabelsDocument, - { - first: limit, - after, - }, - ); + const result = await client.request(GetProjectLabelsDocument, { + first: limit, + after, + }); return { nodes: result.projectLabels.nodes.map((label) => ({ diff --git a/src/services/milestone-service.ts b/src/services/milestone-service.ts index 9a9081ad..c1dfb1d2 100644 --- a/src/services/milestone-service.ts +++ b/src/services/milestone-service.ts @@ -9,15 +9,11 @@ import type { } from "../common/types.js"; import { CreateProjectMilestoneDocument, - type CreateProjectMilestoneMutation, GetProjectMilestoneByIdDocument, - type GetProjectMilestoneByIdQuery, ListProjectMilestonesDocument, - type ListProjectMilestonesQuery, type ProjectMilestoneCreateInput, type ProjectMilestoneUpdateInput, UpdateProjectMilestoneDocument, - type UpdateProjectMilestoneMutation, } from "../gql/graphql.js"; export async function listMilestones( @@ -26,10 +22,11 @@ export async function listMilestones( options: PaginationOptions = {}, ): Promise<PaginatedResult<MilestoneListItem>> { const { limit = 50, after } = options; - const result = await client.request<ListProjectMilestonesQuery>( - ListProjectMilestonesDocument, - { projectId, first: limit, after }, - ); + const result = await client.request(ListProjectMilestonesDocument, { + projectId, + first: limit, + after, + }); return { nodes: result.project?.projectMilestones?.nodes ?? [], @@ -45,10 +42,10 @@ export async function getMilestone( id: string, issuesLimit?: number, ): Promise<MilestoneDetail> { - const result = await client.request<GetProjectMilestoneByIdQuery>( - GetProjectMilestoneByIdDocument, - { id, issuesFirst: issuesLimit }, - ); + const result = await client.request(GetProjectMilestoneByIdDocument, { + id, + issuesFirst: issuesLimit, + }); if (!result.projectMilestone) { throw new Error(`Milestone with ID "${id}" not found`); @@ -61,10 +58,9 @@ export async function createMilestone( client: GraphQLClient, input: ProjectMilestoneCreateInput, ): Promise<CreatedMilestone> { - const result = await client.request<CreateProjectMilestoneMutation>( - CreateProjectMilestoneDocument, - { input }, - ); + const result = await client.request(CreateProjectMilestoneDocument, { + input, + }); if ( !result.projectMilestoneCreate.success || @@ -81,10 +77,10 @@ export async function updateMilestone( id: string, input: ProjectMilestoneUpdateInput, ): Promise<UpdatedMilestone> { - const result = await client.request<UpdateProjectMilestoneMutation>( - UpdateProjectMilestoneDocument, - { id, input }, - ); + const result = await client.request(UpdateProjectMilestoneDocument, { + id, + input, + }); if ( !result.projectMilestoneUpdate.success || diff --git a/src/services/project-service.ts b/src/services/project-service.ts index 0907dea7..e2483298 100644 --- a/src/services/project-service.ts +++ b/src/services/project-service.ts @@ -12,21 +12,14 @@ import type { } from "../common/types.js"; import { ArchiveProjectDocument, - type ArchiveProjectMutation, CreateProjectDocument, - type CreateProjectMutation, DeleteProjectDocument, - type DeleteProjectMutation, GetProjectDocument, - type GetProjectQuery, GetProjectsDocument, - type GetProjectsQuery, type ProjectCreateInput, type ProjectUpdateInput, UnarchiveProjectDocument, - type UnarchiveProjectMutation, UpdateProjectDocument, - type UpdateProjectMutation, } from "../gql/graphql.js"; export interface ProjectListOptions extends PaginationOptions { @@ -50,7 +43,7 @@ export async function listProjects( options: ProjectListOptions = {}, ): Promise<PaginatedResult<ProjectListItem>> { const { limit = 50, after, includeArchived } = options; - const result = await client.request<GetProjectsQuery>(GetProjectsDocument, { + const result = await client.request(GetProjectsDocument, { first: limit, after, includeArchived, @@ -71,7 +64,7 @@ export async function getProject( options.milestonesFirst ?? DEFAULT_PROJECT_MILESTONES_FIRST; const issuesFirst = options.issuesFirst ?? DEFAULT_PROJECT_ISSUES_FIRST; - const result = await client.request<GetProjectQuery>(GetProjectDocument, { + const result = await client.request(GetProjectDocument, { id, milestonesFirst: connectionFirstOrOneWhenSkipped(milestonesFirst), skipMilestones: milestonesFirst === 0, @@ -90,10 +83,7 @@ export async function createProject( client: GraphQLClient, input: ProjectCreateInput, ): Promise<CreatedProject> { - const result = await client.request<CreateProjectMutation>( - CreateProjectDocument, - { input }, - ); + const result = await client.request(CreateProjectDocument, { input }); if (!result.projectCreate.success || !result.projectCreate.project) { throw new Error(`Failed to create project "${input.name}"`); @@ -107,10 +97,7 @@ export async function updateProject( id: string, input: ProjectUpdateInput, ): Promise<UpdatedProject> { - const result = await client.request<UpdateProjectMutation>( - UpdateProjectDocument, - { id, input }, - ); + const result = await client.request(UpdateProjectDocument, { id, input }); if (!result.projectUpdate.success || !result.projectUpdate.project) { throw new Error(`Failed to update project "${id}"`); @@ -123,10 +110,7 @@ export async function archiveProject( client: GraphQLClient, id: string, ): Promise<ArchivedProject> { - const result = await client.request<ArchiveProjectMutation>( - ArchiveProjectDocument, - { id }, - ); + const result = await client.request(ArchiveProjectDocument, { id }); if (!result.projectArchive.success || !result.projectArchive.entity) { throw new Error(`Failed to archive project "${id}"`); @@ -139,10 +123,7 @@ export async function unarchiveProject( client: GraphQLClient, id: string, ): Promise<UnarchivedProject> { - const result = await client.request<UnarchiveProjectMutation>( - UnarchiveProjectDocument, - { id }, - ); + const result = await client.request(UnarchiveProjectDocument, { id }); if (!result.projectUnarchive.success || !result.projectUnarchive.entity) { throw new Error(`Failed to unarchive project "${id}"`); @@ -155,10 +136,7 @@ export async function deleteProject( client: GraphQLClient, id: string, ): Promise<DeletedProject> { - const result = await client.request<DeleteProjectMutation>( - DeleteProjectDocument, - { id }, - ); + const result = await client.request(DeleteProjectDocument, { id }); if (!result.projectDelete.success) { throw new Error(`Failed to delete project "${id}"`); diff --git a/src/services/reaction-service.ts b/src/services/reaction-service.ts index c8fe73c1..e39a5fe2 100644 --- a/src/services/reaction-service.ts +++ b/src/services/reaction-service.ts @@ -4,13 +4,9 @@ import { CreateReactionDocument, type CreateReactionMutation, DeleteReactionDocument, - type DeleteReactionMutation, GetCommentReactionsDocument, - type GetCommentReactionsQuery, GetIssueReactionsDocument, - type GetIssueReactionsQuery, GetViewerDocument, - type GetViewerQuery, type ReactionCreateInput, type ReactionReadFieldsFragment, } from "../gql/graphql.js"; @@ -85,7 +81,7 @@ function normalizeReactionUser( } async function getViewerId(client: GraphQLClient): Promise<string> { - const result = await client.request<GetViewerQuery>(GetViewerDocument); + const result = await client.request(GetViewerDocument); return result.viewer.id; } @@ -94,10 +90,9 @@ async function getTargetReactions( input: ReactionLookupInput, ): Promise<ReactionNode[]> { if (input.kind === "issue") { - const result = await client.request<GetIssueReactionsQuery>( - GetIssueReactionsDocument, - { id: input.id }, - ); + const result = await client.request(GetIssueReactionsDocument, { + id: input.id, + }); if (!result.issue) { throw new Error(`Issue with ID "${input.id}" not found`); @@ -106,10 +101,9 @@ async function getTargetReactions( return result.issue.reactions; } - const result = await client.request<GetCommentReactionsQuery>( - GetCommentReactionsDocument, - { id: input.id }, - ); + const result = await client.request(GetCommentReactionsDocument, { + id: input.id, + }); if (!result.comment) { throw new Error(`Discussion comment ID "${input.id}" not found`); @@ -137,10 +131,9 @@ async function createReaction( throw new Error(`Already reacted with emoji ${normalizedEmoji}`); } - const result = await client.request<CreateReactionMutation>( - CreateReactionDocument, - { input: normalizedInput }, - ); + const result = await client.request(CreateReactionDocument, { + input: normalizedInput, + }); if (!result.reactionCreate.success) { throw new Error("Failed to create reaction"); @@ -153,10 +146,9 @@ async function deleteReaction( client: GraphQLClient, reactionId: string, ): Promise<{ id: string; success: boolean }> { - const result = await client.request<DeleteReactionMutation>( - DeleteReactionDocument, - { id: reactionId }, - ); + const result = await client.request(DeleteReactionDocument, { + id: reactionId, + }); if (!result.reactionDelete.success) { throw new Error("Failed to delete reaction"); diff --git a/src/services/team-service.ts b/src/services/team-service.ts index 2a77bdb3..4e7eec3b 100644 --- a/src/services/team-service.ts +++ b/src/services/team-service.ts @@ -10,7 +10,6 @@ import { GetTeamByIdDocument, type GetTeamByIdQuery, GetTeamsDocument, - type GetTeamsQuery, } from "../gql/graphql.js"; export interface Team { @@ -95,12 +94,9 @@ async function resolveEffectiveEstimationConfig( } try { - const parentResult = await client.request<GetTeamByIdQuery>( - GetTeamByIdDocument, - { - id: team.parent.id, - }, - ); + const parentResult = await client.request(GetTeamByIdDocument, { + id: team.parent.id, + }); if (!parentResult.team) { return { config: team, source: "self_fallback" }; @@ -117,7 +113,7 @@ export async function listTeams( options: PaginationOptions = {}, ): Promise<PaginatedResult<Team>> { const { limit = 50, after } = options; - const result = await client.request<GetTeamsQuery>(GetTeamsDocument, { + const result = await client.request(GetTeamsDocument, { first: limit, after, }); @@ -131,7 +127,7 @@ export async function getTeam( client: GraphQLClient, input: GetTeamInput, ): Promise<TeamDetail> { - const result = await client.request<GetTeamByIdQuery>(GetTeamByIdDocument, { + const result = await client.request(GetTeamByIdDocument, { id: input.id, }); diff --git a/src/services/user-service.ts b/src/services/user-service.ts index bb2a5e4f..5c8d478f 100644 --- a/src/services/user-service.ts +++ b/src/services/user-service.ts @@ -1,6 +1,6 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import type { PaginatedResult, PaginationOptions } from "../common/types.js"; -import { GetUsersDocument, type GetUsersQuery } from "../gql/graphql.js"; +import { GetUsersDocument } from "../gql/graphql.js"; export interface User { id: string; @@ -16,7 +16,7 @@ export async function listUsers( ): Promise<PaginatedResult<User>> { const { limit = 50, after } = options; const filter = activeOnly ? { active: { eq: true } } : undefined; - const result = await client.request<GetUsersQuery>(GetUsersDocument, { + const result = await client.request(GetUsersDocument, { first: limit, after, filter, From f61df7b1337450b32acc513c9c3ce9ddea57fc14 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:46:19 +0200 Subject: [PATCH 35/79] chore(deps): move @graphql-typed-document-node/core to devDependencies The TypedDocumentNode import is type-only and fully erased at build, and linearis ships CLI JS only (declaration: false, no published types), so a consumer never needs the package at runtime. Classify it as a dev dependency to match reality; revisit if declarations are ever shipped, since the type surfaces in GraphQLClient.request. Refs #194 --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3998d6a1..a74dfb42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "2026.6.0-next.7", "license": "MIT", "dependencies": { - "@graphql-typed-document-node/core": "3.2.0", "@linear/sdk": "82.1.0", "commander": "14.0.3", "node-emoji": "2.2.0" @@ -24,6 +23,7 @@ "@commitlint/config-conventional": "^21.0.0", "@graphql-codegen/cli": "^7.0.0", "@graphql-codegen/client-preset": "^6.0.0", + "@graphql-typed-document-node/core": "3.2.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/exec": "^7.1.0", diff --git a/package.json b/package.json index 7f082aca..ffc4e542 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,6 @@ }, "homepage": "https://github.com/linearis-oss/linearis#readme", "dependencies": { - "@graphql-typed-document-node/core": "3.2.0", "@linear/sdk": "82.1.0", "commander": "14.0.3", "node-emoji": "2.2.0" @@ -80,6 +79,7 @@ "@commitlint/config-conventional": "^21.0.0", "@graphql-codegen/cli": "^7.0.0", "@graphql-codegen/client-preset": "^6.0.0", + "@graphql-typed-document-node/core": "3.2.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/exec": "^7.1.0", From 73c8c9fc2db70987e0c01694c38e3fb597618d44 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:50:15 +0200 Subject: [PATCH 36/79] refactor(client): tighten request() typing and guard dataless responses Constrain the variables generic to `Record<string, unknown>` so `variables` satisfies rawRequest's own bound directly, removing the variables cast and rejecting untyped documents. Replace the unconditional `data as TResult` assertion with a null guard so a dataless response throws a clear error instead of returning a `TResult`-typed `undefined`. Refs #194 --- src/client/graphql-client.ts | 19 ++++++++++++------- tests/unit/client/graphql-client.test.ts | 13 +++++++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/client/graphql-client.ts b/src/client/graphql-client.ts index 3a942c60..3fd3aca6 100644 --- a/src/client/graphql-client.ts +++ b/src/client/graphql-client.ts @@ -45,7 +45,7 @@ export class GraphQLClient { return linearClient.client; } - async request<TResult, TVariables>( + async request<TResult, TVariables extends Record<string, unknown>>( document: TypedDocumentNode<TResult, TVariables>, // `NoInfer` pins `TVariables` to the document, so the variables argument is // type-checked against it rather than widening the inferred type itself. @@ -59,14 +59,13 @@ export class GraphQLClient { }, REQUEST_TIMEOUT_MS); try { + // Constraining `TVariables extends Record<string, unknown>` lets + // `variables` satisfy rawRequest's own variables bound directly, so no + // cast is needed here. `data` stays untyped (`unknown`) and is checked + // and cast to `TResult` below. return await this.createRawClient( timeoutController.signal, - // The public signature stays strongly typed via TypedDocumentNode; - // rawRequest only accepts an untyped variables bag, so cast here. - ).rawRequest( - print(document), - variables as Record<string, unknown> | undefined, - ); + ).rawRequest(print(document), variables); } catch (error: unknown) { if ( timeoutController.signal.aborted && @@ -80,6 +79,12 @@ export class GraphQLClient { clearTimeout(timeoutHandle); } }); + // rawRequest resolves with `data: unknown | undefined`; guard the absent + // case instead of asserting it away, so a dataless response surfaces as a + // clear error rather than a `TResult`-typed `undefined`. + if (response.data == null) { + throw new Error("GraphQL response contained no data"); + } return response.data as TResult; } catch (error: unknown) { const gqlError = error as GraphQLErrorResponse; diff --git a/tests/unit/client/graphql-client.test.ts b/tests/unit/client/graphql-client.test.ts index a1fcdd5f..72e709c3 100644 --- a/tests/unit/client/graphql-client.test.ts +++ b/tests/unit/client/graphql-client.test.ts @@ -97,6 +97,19 @@ describe("GraphQLClient", () => { } }); + it("throws when the response contains no data", async () => { + mockRawRequest.mockResolvedValueOnce({ data: undefined }); + + const client = new GraphQLClient("good-token"); + const fakeDoc = { kind: "Document", definitions: [] } as Parameters< + typeof client.request + >[0]; + + await expect(client.request(fakeDoc)).rejects.toThrow( + "GraphQL response contained no data", + ); + }); + it("clears timeout timer when request succeeds before timeout", async () => { vi.useFakeTimers(); try { From be01e060c9bfe09cb6cc60a78dd2601b8826d1e1 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:10:31 +0200 Subject: [PATCH 37/79] refactor(services): co-locate projection types with their services Move the entity projection type aliases out of the central src/common/types.ts and into the service files that own them, leaving only the shared PaginatedResult/PaginationOptions in common. Rename Issue -> IssueListItem, Document -> DocumentDetail and Attachment -> AttachmentListItem for clarity, and stop exporting the internal IssueComment/IssueCommentThread helper types. Refs #201 --- src/commands/auth.ts | 3 +- src/common/types.ts | 231 +------------------- src/services/attachment-service.ts | 11 +- src/services/auth-service.ts | 6 +- src/services/comment-service.ts | 21 +- src/services/document-service.ts | 22 +- src/services/initiative-project-service.ts | 17 +- src/services/initiative-relation-service.ts | 17 +- src/services/initiative-service.ts | 39 +++- src/services/initiative-update-service.ts | 35 ++- src/services/issue-relation-service.ts | 6 +- src/services/issue-service.ts | 71 ++++-- src/services/milestone-service.ts | 26 ++- src/services/project-service.ts | 38 +++- src/services/team-service.ts | 21 +- tests/unit/services/team-service.test.ts | 13 +- 16 files changed, 247 insertions(+), 330 deletions(-) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 7e0b9087..8f5c6a90 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -9,9 +9,8 @@ import { import { createGraphQLClient, getRootOpts } from "../common/context.js"; import { handleCommand, outputSuccess } from "../common/output.js"; import { clearToken, saveToken } from "../common/token-storage.js"; -import type { Viewer } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; -import { validateToken } from "../services/auth-service.js"; +import { type Viewer, validateToken } from "../services/auth-service.js"; const LINEAR_API_KEY_URL = "https://linear.app/settings/account/security/api-keys/new"; diff --git a/src/common/types.ts b/src/common/types.ts index e3647e20..d01d6c78 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -1,54 +1,4 @@ -import type { - ArchiveInitiativeMutation, - ArchiveInitiativeUpdateMutation, - ArchiveProjectMutation, - AttachmentCreateMutation, - CreateCommentMutation, - CreateInitiativeMutation, - CreateInitiativeRelationMutation, - CreateInitiativeToProjectMutation, - CreateInitiativeUpdateMutation, - CreateIssueMutation, - CreateIssueRelationMutation, - CreateProjectMilestoneMutation, - CreateProjectMutation, - DeleteInitiativeMutation, - DeleteInitiativeRelationMutation, - DeleteInitiativeToProjectMutation, - DocumentCreateMutation, - DocumentUpdateMutation, - GetDocumentQuery, - GetInitiativeQuery, - GetInitiativeUpdateQuery, - GetIssueByIdentifierQuery, - GetIssueByIdentifierWithAttachmentsQuery, - GetIssueByIdentifierWithCommentsQuery, - GetIssueByIdQuery, - GetIssueByIdWithAttachmentsQuery, - GetIssueByIdWithCommentsQuery, - GetIssuesQuery, - GetProjectMilestoneByIdQuery, - GetProjectQuery, - GetProjectsQuery, - GetTeamByIdQuery, - GetViewerQuery, - ListAttachmentsQuery, - ListCommentsQuery, - ListDocumentsQuery, - ListInitiativesQuery, - ListInitiativeUpdatesQuery, - ListProjectMilestonesQuery, - SearchIssuesQuery, - UnarchiveInitiativeMutation, - UnarchiveInitiativeUpdateMutation, - UnarchiveProjectMutation, - UpdateCommentMutation, - UpdateInitiativeMutation, - UpdateInitiativeUpdateMutation, - UpdateIssueMutation, - UpdateProjectMilestoneMutation, - UpdateProjectMutation, -} from "../gql/graphql.js"; +import type { GetIssuesQuery } from "../gql/graphql.js"; // Pagination types type PageInfo = GetIssuesQuery["issues"]["pageInfo"]; @@ -62,182 +12,3 @@ export interface PaginationOptions { limit?: number; after?: string; } - -// Team types -export type TeamEstimateOption = { - value: number; - label: string; -}; - -export type TeamEstimationSource = "self" | "parent" | "self_fallback"; - -export type TeamDetail = NonNullable<GetTeamByIdQuery["team"]> & { - validEstimates: TeamEstimateOption[]; - estimationSource: TeamEstimationSource; -}; - -// Issue types -export type Issue = GetIssuesQuery["issues"]["nodes"][0]; -export type IssueDetail = NonNullable<GetIssueByIdQuery["issue"]>; -export type IssueByIdentifier = GetIssueByIdentifierQuery["issues"]["nodes"][0]; -export type IssueDetailWithComments = NonNullable< - GetIssueByIdWithCommentsQuery["issue"] ->; -export type IssueByIdentifierWithComments = - GetIssueByIdentifierWithCommentsQuery["issues"]["nodes"][0]; -export type IssueComment = NonNullable< - NonNullable<IssueDetailWithComments["comments"]>["nodes"][0] ->; -export type IssueCommentThread = IssueComment & { - replies: IssueCommentThread[]; -}; -export type IssueDetailWithCommentThreads = Omit< - IssueDetailWithComments, - "comments" -> & { - comments: { nodes: IssueCommentThread[] }; -}; -export type IssueByIdentifierWithCommentThreads = Omit< - IssueByIdentifierWithComments, - "comments" -> & { - comments: { nodes: IssueCommentThread[] }; -}; -export type IssueDetailWithAttachments = NonNullable< - GetIssueByIdWithAttachmentsQuery["issue"] ->; -export type IssueByIdentifierWithAttachments = - GetIssueByIdentifierWithAttachmentsQuery["issues"]["nodes"][0]; -export type IssueSearchResult = SearchIssuesQuery["searchIssues"]["nodes"][0]; -export type CreatedIssue = NonNullable< - CreateIssueMutation["issueCreate"]["issue"] ->; -export type UpdatedIssue = NonNullable< - UpdateIssueMutation["issueUpdate"]["issue"] ->; - -// Issue relation types -export type CreatedIssueRelation = - CreateIssueRelationMutation["issueRelationCreate"]["issueRelation"]; - -// Document types -export type Document = NonNullable<GetDocumentQuery["document"]>; -export type DocumentListItem = ListDocumentsQuery["documents"]["nodes"][0]; -export type CreatedDocument = - DocumentCreateMutation["documentCreate"]["document"]; -export type UpdatedDocument = - DocumentUpdateMutation["documentUpdate"]["document"]; - -// Attachment types -export type Attachment = - ListAttachmentsQuery["issue"]["attachments"]["nodes"][0]; -export type CreatedAttachment = - AttachmentCreateMutation["attachmentCreate"]["attachment"]; - -// Project types -export type ProjectListItem = GetProjectsQuery["projects"]["nodes"][0]; -export type ProjectDetail = NonNullable<GetProjectQuery["project"]>; -export type CreatedProject = NonNullable< - CreateProjectMutation["projectCreate"]["project"] ->; -export type UpdatedProject = NonNullable< - UpdateProjectMutation["projectUpdate"]["project"] ->; -export type ArchivedProject = NonNullable< - ArchiveProjectMutation["projectArchive"]["entity"] ->; -export type UnarchivedProject = NonNullable< - UnarchiveProjectMutation["projectUnarchive"]["entity"] ->; -export type DeletedProject = { - id: string; - success: true; -}; - -// Milestone types -export type MilestoneDetail = NonNullable< - GetProjectMilestoneByIdQuery["projectMilestone"] ->; -export type MilestoneListItem = - ListProjectMilestonesQuery["project"]["projectMilestones"]["nodes"][0]; -export type CreatedMilestone = NonNullable< - CreateProjectMilestoneMutation["projectMilestoneCreate"]["projectMilestone"] ->; -export type UpdatedMilestone = NonNullable< - UpdateProjectMilestoneMutation["projectMilestoneUpdate"]["projectMilestone"] ->; - -// Initiative types -export type InitiativeListItem = - ListInitiativesQuery["initiatives"]["nodes"][0]; -export type InitiativeDetail = NonNullable<GetInitiativeQuery["initiative"]>; -export type CreatedInitiative = NonNullable< - CreateInitiativeMutation["initiativeCreate"]["initiative"] ->; -export type UpdatedInitiative = NonNullable< - UpdateInitiativeMutation["initiativeUpdate"]["initiative"] ->; -export type ArchivedInitiative = NonNullable< - ArchiveInitiativeMutation["initiativeArchive"]["entity"] ->; -export type UnarchivedInitiative = NonNullable< - UnarchiveInitiativeMutation["initiativeUnarchive"]["entity"] ->; - -export type InitiativeRelation = NonNullable< - CreateInitiativeRelationMutation["initiativeRelationCreate"]["initiativeRelation"] ->; - -export type InitiativeProjectLink = NonNullable< - CreateInitiativeToProjectMutation["initiativeToProjectCreate"]["initiativeToProject"] ->; - -export type DeletedInitiative = { - id: NonNullable<DeleteInitiativeMutation["initiativeDelete"]["entityId"]>; - success: true; -}; - -export type DeletedInitiativeRelation = { - id: NonNullable< - DeleteInitiativeRelationMutation["initiativeRelationDelete"]["entityId"] - >; - success: true; -}; - -export type DeletedInitiativeProjectLink = { - id: NonNullable< - DeleteInitiativeToProjectMutation["initiativeToProjectDelete"]["entityId"] - >; - success: true; -}; - -export type InitiativeUpdateListItem = - ListInitiativeUpdatesQuery["initiativeUpdates"]["nodes"][0]; -export type InitiativeUpdateDetail = NonNullable< - GetInitiativeUpdateQuery["initiativeUpdate"] ->; -export type CreatedInitiativeUpdate = NonNullable< - CreateInitiativeUpdateMutation["initiativeUpdateCreate"]["initiativeUpdate"] ->; -export type UpdatedInitiativeUpdate = NonNullable< - UpdateInitiativeUpdateMutation["initiativeUpdateUpdate"]["initiativeUpdate"] ->; -export type ArchivedInitiativeUpdate = NonNullable< - ArchiveInitiativeUpdateMutation["initiativeUpdateArchive"]["entity"] ->; -export type UnarchivedInitiativeUpdate = NonNullable< - UnarchiveInitiativeUpdateMutation["initiativeUpdateUnarchive"]["entity"] ->; - -// Comment types -export type CreatedComment = NonNullable< - CreateCommentMutation["commentCreate"]["comment"] ->; -export type UpdatedComment = NonNullable< - UpdateCommentMutation["commentUpdate"]["comment"] ->; -export type CommentListItem = - ListCommentsQuery["issue"]["comments"]["nodes"][0]; - -// Viewer types -export type Viewer = GetViewerQuery["viewer"]; diff --git a/src/services/attachment-service.ts b/src/services/attachment-service.ts index 451c299f..444fc860 100644 --- a/src/services/attachment-service.ts +++ b/src/services/attachment-service.ts @@ -1,13 +1,20 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { Attachment, CreatedAttachment } from "../common/types.js"; import { AttachmentCreateDocument, type AttachmentCreateInput, + type AttachmentCreateMutation, AttachmentDeleteDocument, type AttachmentFilter, ListAttachmentsDocument, + type ListAttachmentsQuery, } from "../gql/graphql.js"; +// Attachment projection types +export type AttachmentListItem = + ListAttachmentsQuery["issue"]["attachments"]["nodes"][0]; +export type CreatedAttachment = + AttachmentCreateMutation["attachmentCreate"]["attachment"]; + export async function createAttachment( client: GraphQLClient, input: AttachmentCreateInput, @@ -38,7 +45,7 @@ export async function listAttachments( client: GraphQLClient, issueId: string, filter?: AttachmentFilter, -): Promise<Attachment[]> { +): Promise<AttachmentListItem[]> { const result = await client.request(ListAttachmentsDocument, { issueId, ...(filter && { filter }), diff --git a/src/services/auth-service.ts b/src/services/auth-service.ts index ae4d5154..89e3b58a 100644 --- a/src/services/auth-service.ts +++ b/src/services/auth-service.ts @@ -1,6 +1,8 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { Viewer } from "../common/types.js"; -import { GetViewerDocument } from "../gql/graphql.js"; +import { GetViewerDocument, type GetViewerQuery } from "../gql/graphql.js"; + +// Viewer projection types +export type Viewer = GetViewerQuery["viewer"]; export async function validateToken(client: GraphQLClient): Promise<Viewer> { const result = await client.request(GetViewerDocument); diff --git a/src/services/comment-service.ts b/src/services/comment-service.ts index 893d603e..9ae68314 100644 --- a/src/services/comment-service.ts +++ b/src/services/comment-service.ts @@ -1,20 +1,27 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { - CommentListItem, - CreatedComment, - PaginatedResult, - PaginationOptions, - UpdatedComment, -} from "../common/types.js"; +import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { type CommentCreateInput, type CommentUpdateInput, CreateCommentDocument, + type CreateCommentMutation, DeleteCommentDocument, ListCommentsDocument, + type ListCommentsQuery, UpdateCommentDocument, + type UpdateCommentMutation, } from "../gql/graphql.js"; +// Comment projection types +export type CreatedComment = NonNullable< + CreateCommentMutation["commentCreate"]["comment"] +>; +export type UpdatedComment = NonNullable< + UpdateCommentMutation["commentUpdate"]["comment"] +>; +export type CommentListItem = + ListCommentsQuery["issue"]["comments"]["nodes"][0]; + export async function createComment( client: GraphQLClient, input: CommentCreateInput, diff --git a/src/services/document-service.ts b/src/services/document-service.ts index 6ec73d9f..78fd5d6a 100644 --- a/src/services/document-service.ts +++ b/src/services/document-service.ts @@ -1,26 +1,32 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { - CreatedDocument, - Document, - DocumentListItem, - PaginatedResult, - UpdatedDocument, -} from "../common/types.js"; +import type { PaginatedResult } from "../common/types.js"; import { DocumentCreateDocument, type DocumentCreateInput, + type DocumentCreateMutation, DocumentDeleteDocument, type DocumentFilter, DocumentUpdateDocument, type DocumentUpdateInput, + type DocumentUpdateMutation, GetDocumentDocument, + type GetDocumentQuery, ListDocumentsDocument, + type ListDocumentsQuery, } from "../gql/graphql.js"; +// Document projection types +export type DocumentDetail = NonNullable<GetDocumentQuery["document"]>; +export type DocumentListItem = ListDocumentsQuery["documents"]["nodes"][0]; +export type CreatedDocument = + DocumentCreateMutation["documentCreate"]["document"]; +export type UpdatedDocument = + DocumentUpdateMutation["documentUpdate"]["document"]; + export async function getDocument( client: GraphQLClient, id: string, -): Promise<Document> { +): Promise<DocumentDetail> { const result = await client.request(GetDocumentDocument, { id, }); diff --git a/src/services/initiative-project-service.ts b/src/services/initiative-project-service.ts index e655e7ff..a5144fc1 100644 --- a/src/services/initiative-project-service.ts +++ b/src/services/initiative-project-service.ts @@ -1,13 +1,22 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { - DeletedInitiativeProjectLink, - InitiativeProjectLink, -} from "../common/types.js"; import { CreateInitiativeToProjectDocument, + type CreateInitiativeToProjectMutation, DeleteInitiativeToProjectDocument, + type DeleteInitiativeToProjectMutation, } from "../gql/graphql.js"; +// Initiative-project link projection types +export type InitiativeProjectLink = NonNullable< + CreateInitiativeToProjectMutation["initiativeToProjectCreate"]["initiativeToProject"] +>; +export type DeletedInitiativeProjectLink = { + id: NonNullable< + DeleteInitiativeToProjectMutation["initiativeToProjectDelete"]["entityId"] + >; + success: true; +}; + export async function createInitiativeProjectLink( client: GraphQLClient, input: { initiativeId: string; projectId: string }, diff --git a/src/services/initiative-relation-service.ts b/src/services/initiative-relation-service.ts index 1cc04c90..22b0faeb 100644 --- a/src/services/initiative-relation-service.ts +++ b/src/services/initiative-relation-service.ts @@ -1,13 +1,22 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { - DeletedInitiativeRelation, - InitiativeRelation, -} from "../common/types.js"; import { CreateInitiativeRelationDocument, + type CreateInitiativeRelationMutation, DeleteInitiativeRelationDocument, + type DeleteInitiativeRelationMutation, } from "../gql/graphql.js"; +// Initiative relation projection types +export type InitiativeRelation = NonNullable< + CreateInitiativeRelationMutation["initiativeRelationCreate"]["initiativeRelation"] +>; +export type DeletedInitiativeRelation = { + id: NonNullable< + DeleteInitiativeRelationMutation["initiativeRelationDelete"]["entityId"] + >; + success: true; +}; + export async function createInitiativeRelation( client: GraphQLClient, input: { parentId: string; childId: string }, diff --git a/src/services/initiative-service.ts b/src/services/initiative-service.ts index 5c0da521..2c556138 100644 --- a/src/services/initiative-service.ts +++ b/src/services/initiative-service.ts @@ -1,28 +1,47 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import { invalidParameterError } from "../common/errors.js"; -import type { - ArchivedInitiative, - CreatedInitiative, - DeletedInitiative, - InitiativeDetail, - InitiativeListItem, - PaginatedResult, - UnarchivedInitiative, - UpdatedInitiative, -} from "../common/types.js"; +import type { PaginatedResult } from "../common/types.js"; import { ArchiveInitiativeDocument, + type ArchiveInitiativeMutation, CreateInitiativeDocument, + type CreateInitiativeMutation, DeleteInitiativeDocument, + type DeleteInitiativeMutation, GetInitiativeDocument, + type GetInitiativeQuery, type InitiativeCreateInput, type InitiativeUpdateInput, ListInitiativesDocument, + type ListInitiativesQuery, type ListInitiativesQueryVariables, UnarchiveInitiativeDocument, + type UnarchiveInitiativeMutation, UpdateInitiativeDocument, + type UpdateInitiativeMutation, } from "../gql/graphql.js"; +// Initiative projection types +export type InitiativeListItem = + ListInitiativesQuery["initiatives"]["nodes"][0]; +export type InitiativeDetail = NonNullable<GetInitiativeQuery["initiative"]>; +export type CreatedInitiative = NonNullable< + CreateInitiativeMutation["initiativeCreate"]["initiative"] +>; +export type UpdatedInitiative = NonNullable< + UpdateInitiativeMutation["initiativeUpdate"]["initiative"] +>; +export type ArchivedInitiative = NonNullable< + ArchiveInitiativeMutation["initiativeArchive"]["entity"] +>; +export type UnarchivedInitiative = NonNullable< + UnarchiveInitiativeMutation["initiativeUnarchive"]["entity"] +>; +export type DeletedInitiative = { + id: NonNullable<DeleteInitiativeMutation["initiativeDelete"]["entityId"]>; + success: true; +}; + export interface InitiativeListOptions { limit?: number; after?: string; diff --git a/src/services/initiative-update-service.ts b/src/services/initiative-update-service.ts index 98d74e90..f7478ab0 100644 --- a/src/services/initiative-update-service.ts +++ b/src/services/initiative-update-service.ts @@ -1,25 +1,42 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import { invalidParameterError } from "../common/errors.js"; -import type { - ArchivedInitiativeUpdate, - CreatedInitiativeUpdate, - InitiativeUpdateDetail, - InitiativeUpdateListItem, - PaginatedResult, - UnarchivedInitiativeUpdate, - UpdatedInitiativeUpdate, -} from "../common/types.js"; +import type { PaginatedResult } from "../common/types.js"; import { ArchiveInitiativeUpdateDocument, + type ArchiveInitiativeUpdateMutation, CreateInitiativeUpdateDocument, + type CreateInitiativeUpdateMutation, GetInitiativeUpdateDocument, + type GetInitiativeUpdateQuery, type InitiativeUpdateCreateInput, type InitiativeUpdateUpdateInput, ListInitiativeUpdatesDocument, + type ListInitiativeUpdatesQuery, UnarchiveInitiativeUpdateDocument, + type UnarchiveInitiativeUpdateMutation, UpdateInitiativeUpdateDocument, + type UpdateInitiativeUpdateMutation, } from "../gql/graphql.js"; +// Initiative update projection types +export type InitiativeUpdateListItem = + ListInitiativeUpdatesQuery["initiativeUpdates"]["nodes"][0]; +export type InitiativeUpdateDetail = NonNullable< + GetInitiativeUpdateQuery["initiativeUpdate"] +>; +export type CreatedInitiativeUpdate = NonNullable< + CreateInitiativeUpdateMutation["initiativeUpdateCreate"]["initiativeUpdate"] +>; +export type UpdatedInitiativeUpdate = NonNullable< + UpdateInitiativeUpdateMutation["initiativeUpdateUpdate"]["initiativeUpdate"] +>; +export type ArchivedInitiativeUpdate = NonNullable< + ArchiveInitiativeUpdateMutation["initiativeUpdateArchive"]["entity"] +>; +export type UnarchivedInitiativeUpdate = NonNullable< + UnarchiveInitiativeUpdateMutation["initiativeUpdateUnarchive"]["entity"] +>; + export interface InitiativeUpdateListOptions { initiativeId: string; limit?: number; diff --git a/src/services/issue-relation-service.ts b/src/services/issue-relation-service.ts index a0317e9e..7027f0dd 100644 --- a/src/services/issue-relation-service.ts +++ b/src/services/issue-relation-service.ts @@ -1,14 +1,18 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import { notFoundError } from "../common/errors.js"; -import type { CreatedIssueRelation } from "../common/types.js"; import { CreateIssueRelationDocument, + type CreateIssueRelationMutation, DeleteIssueRelationDocument, GetIssueRelationsDocument, type GetIssueRelationsQuery, type IssueRelationType, } from "../gql/graphql.js"; +// Issue relation projection types +export type CreatedIssueRelation = + CreateIssueRelationMutation["issueRelationCreate"]["issueRelation"]; + type IssueRelationsIssue = NonNullable<GetIssueRelationsQuery["issue"]>; export async function createIssueRelation( diff --git a/src/services/issue-service.ts b/src/services/issue-service.ts index e0edac96..f587b74f 100644 --- a/src/services/issue-service.ts +++ b/src/services/issue-service.ts @@ -1,48 +1,81 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { - CreatedIssue, - Issue, - IssueByIdentifier, - IssueByIdentifierWithAttachments, - IssueByIdentifierWithComments, - IssueByIdentifierWithCommentThreads, - IssueComment, - IssueCommentThread, - IssueDetail, - IssueDetailWithAttachments, - IssueDetailWithComments, - IssueDetailWithCommentThreads, - IssueSearchResult, - PaginatedResult, - PaginationOptions, - UpdatedIssue, -} from "../common/types.js"; +import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { ArchiveIssueDocument, CreateIssueDocument, + type CreateIssueMutation, DeleteIssueDocument, FilteredSearchIssuesDocument, GetIssueByIdDocument, GetIssueByIdentifierDocument, + type GetIssueByIdentifierQuery, GetIssueByIdentifierWithAttachmentsDocument, + type GetIssueByIdentifierWithAttachmentsQuery, GetIssueByIdentifierWithCommentsDocument, + type GetIssueByIdentifierWithCommentsQuery, GetIssueByIdentifierWithReactionsDocument, type GetIssueByIdentifierWithReactionsQuery, + type GetIssueByIdQuery, GetIssueByIdWithAttachmentsDocument, + type GetIssueByIdWithAttachmentsQuery, GetIssueByIdWithCommentsDocument, + type GetIssueByIdWithCommentsQuery, GetIssueByIdWithReactionsDocument, type GetIssueByIdWithReactionsQuery, GetIssuesDocument, + type GetIssuesQuery, type IssueCreateInput, type IssueFilter, type IssueUpdateInput, SearchIssuesDocument, + type SearchIssuesQuery, type SearchIssuesQueryVariables, UnarchiveIssueDocument, UpdateIssueDocument, + type UpdateIssueMutation, } from "../gql/graphql.js"; import { normalizeReactions } from "./reaction-service.js"; +// Issue projection types +export type IssueListItem = GetIssuesQuery["issues"]["nodes"][0]; +export type IssueDetail = NonNullable<GetIssueByIdQuery["issue"]>; +export type IssueByIdentifier = GetIssueByIdentifierQuery["issues"]["nodes"][0]; +export type IssueDetailWithComments = NonNullable< + GetIssueByIdWithCommentsQuery["issue"] +>; +export type IssueByIdentifierWithComments = + GetIssueByIdentifierWithCommentsQuery["issues"]["nodes"][0]; +type IssueComment = NonNullable< + NonNullable<IssueDetailWithComments["comments"]>["nodes"][0] +>; +type IssueCommentThread = IssueComment & { + replies: IssueCommentThread[]; +}; +export type IssueDetailWithCommentThreads = Omit< + IssueDetailWithComments, + "comments" +> & { + comments: { nodes: IssueCommentThread[] }; +}; +export type IssueByIdentifierWithCommentThreads = Omit< + IssueByIdentifierWithComments, + "comments" +> & { + comments: { nodes: IssueCommentThread[] }; +}; +export type IssueDetailWithAttachments = NonNullable< + GetIssueByIdWithAttachmentsQuery["issue"] +>; +export type IssueByIdentifierWithAttachments = + GetIssueByIdentifierWithAttachmentsQuery["issues"]["nodes"][0]; +export type IssueSearchResult = SearchIssuesQuery["searchIssues"]["nodes"][0]; +export type CreatedIssue = NonNullable< + CreateIssueMutation["issueCreate"]["issue"] +>; +export type UpdatedIssue = NonNullable< + UpdateIssueMutation["issueUpdate"]["issue"] +>; + const NON_COMPLETED_ISSUES_FILTER: IssueFilter = { state: { type: { neq: "completed" } }, }; @@ -182,7 +215,7 @@ export async function listIssues( client: GraphQLClient, options: PaginationOptions = {}, filter?: IssueFilter, -): Promise<PaginatedResult<Issue>> { +): Promise<PaginatedResult<IssueListItem>> { const { limit = 25, after } = options; if (filter) { diff --git a/src/services/milestone-service.ts b/src/services/milestone-service.ts index c1dfb1d2..8afaa851 100644 --- a/src/services/milestone-service.ts +++ b/src/services/milestone-service.ts @@ -1,21 +1,31 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { - CreatedMilestone, - MilestoneDetail, - MilestoneListItem, - PaginatedResult, - PaginationOptions, - UpdatedMilestone, -} from "../common/types.js"; +import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { CreateProjectMilestoneDocument, + type CreateProjectMilestoneMutation, GetProjectMilestoneByIdDocument, + type GetProjectMilestoneByIdQuery, ListProjectMilestonesDocument, + type ListProjectMilestonesQuery, type ProjectMilestoneCreateInput, type ProjectMilestoneUpdateInput, UpdateProjectMilestoneDocument, + type UpdateProjectMilestoneMutation, } from "../gql/graphql.js"; +// Milestone projection types +export type MilestoneDetail = NonNullable< + GetProjectMilestoneByIdQuery["projectMilestone"] +>; +export type MilestoneListItem = + ListProjectMilestonesQuery["project"]["projectMilestones"]["nodes"][0]; +export type CreatedMilestone = NonNullable< + CreateProjectMilestoneMutation["projectMilestoneCreate"]["projectMilestone"] +>; +export type UpdatedMilestone = NonNullable< + UpdateProjectMilestoneMutation["projectMilestoneUpdate"]["projectMilestone"] +>; + export async function listMilestones( client: GraphQLClient, projectId: string, diff --git a/src/services/project-service.ts b/src/services/project-service.ts index e2483298..27cacdd0 100644 --- a/src/services/project-service.ts +++ b/src/services/project-service.ts @@ -1,27 +1,43 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { - ArchivedProject, - CreatedProject, - DeletedProject, - PaginatedResult, - PaginationOptions, - ProjectDetail, - ProjectListItem, - UnarchivedProject, - UpdatedProject, -} from "../common/types.js"; +import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { ArchiveProjectDocument, + type ArchiveProjectMutation, CreateProjectDocument, + type CreateProjectMutation, DeleteProjectDocument, GetProjectDocument, + type GetProjectQuery, GetProjectsDocument, + type GetProjectsQuery, type ProjectCreateInput, type ProjectUpdateInput, UnarchiveProjectDocument, + type UnarchiveProjectMutation, UpdateProjectDocument, + type UpdateProjectMutation, } from "../gql/graphql.js"; +// Project projection types +export type ProjectListItem = GetProjectsQuery["projects"]["nodes"][0]; +export type ProjectDetail = NonNullable<GetProjectQuery["project"]>; +export type CreatedProject = NonNullable< + CreateProjectMutation["projectCreate"]["project"] +>; +export type UpdatedProject = NonNullable< + UpdateProjectMutation["projectUpdate"]["project"] +>; +export type ArchivedProject = NonNullable< + ArchiveProjectMutation["projectArchive"]["entity"] +>; +export type UnarchivedProject = NonNullable< + UnarchiveProjectMutation["projectUnarchive"]["entity"] +>; +export type DeletedProject = { + id: string; + success: true; +}; + export interface ProjectListOptions extends PaginationOptions { includeArchived?: boolean; } diff --git a/src/services/team-service.ts b/src/services/team-service.ts index 4e7eec3b..5e3ed9c8 100644 --- a/src/services/team-service.ts +++ b/src/services/team-service.ts @@ -1,17 +1,24 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { - PaginatedResult, - PaginationOptions, - TeamDetail, - TeamEstimateOption, - TeamEstimationSource, -} from "../common/types.js"; +import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { GetTeamByIdDocument, type GetTeamByIdQuery, GetTeamsDocument, } from "../gql/graphql.js"; +// Team projection types +export type TeamEstimateOption = { + value: number; + label: string; +}; + +export type TeamEstimationSource = "self" | "parent" | "self_fallback"; + +export type TeamDetail = NonNullable<GetTeamByIdQuery["team"]> & { + validEstimates: TeamEstimateOption[]; + estimationSource: TeamEstimationSource; +}; + export interface Team { id: string; key: string; diff --git a/tests/unit/services/team-service.test.ts b/tests/unit/services/team-service.test.ts index d659919a..1cbe92c4 100644 --- a/tests/unit/services/team-service.test.ts +++ b/tests/unit/services/team-service.test.ts @@ -1,12 +1,13 @@ // tests/unit/services/team-service.test.ts import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; -import type { - TeamDetail, - TeamEstimateOption, - TeamEstimationSource, -} from "../../../src/common/types.js"; -import { getTeam, listTeams } from "../../../src/services/team-service.js"; +import { + getTeam, + listTeams, + type TeamDetail, + type TeamEstimateOption, + type TeamEstimationSource, +} from "../../../src/services/team-service.js"; const assertTeamDetailShape = (value: TeamDetail): TeamDetail => value; const assertEstimateOption = (value: TeamEstimateOption): TeamEstimateOption => From be79f040f84d0d7ed84ba4c260fb2a07c4c07d62 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:23:16 +0200 Subject: [PATCH 38/79] refactor(common): centralize Priority and LabelMode domain values Introduce src/common/domain-values.ts holding the shared Priority (0-4) and LabelMode ("add" | "overwrite") types plus a parseLabelMode helper. Priority parsers and the issues label-mode handling now use these narrowed types instead of ad-hoc number and string validation. Part of #205 --- src/commands/issues.ts | 12 ++++-------- src/commands/projects.ts | 5 +++-- src/common/domain-values.ts | 18 ++++++++++++++++++ src/common/number-options.ts | 5 +++-- tests/unit/common/domain-values.test.ts | 22 ++++++++++++++++++++++ 5 files changed, 50 insertions(+), 12 deletions(-) create mode 100644 src/common/domain-values.ts create mode 100644 tests/unit/common/domain-values.test.ts diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 3bd482b5..9cd02148 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, getRootOpts } from "../common/context.js"; +import { parseLabelMode } from "../common/domain-values.js"; import { resolveReactionEmojiInput } from "../common/emoji.js"; import { invalidParameterError } from "../common/errors.js"; import { validateEstimateAgainstTeamConfig } from "../common/estimate-validation.js"; @@ -1342,12 +1343,7 @@ export function setupIssuesCommands(program: Command): void { throw new Error("--clear-labels cannot be used with --label-mode"); } - if ( - options.labelMode && - !["add", "overwrite"].includes(options.labelMode) - ) { - throw new Error("--label-mode must be either 'add' or 'overwrite'"); - } + const labelMode = parseLabelMode(options.labelMode); const parsedPriority = options.priority !== undefined @@ -1386,7 +1382,7 @@ export function setupIssuesCommands(program: Command): void { options.status || options.projectMilestone || options.cycle || - (options.labels && options.labelMode === "add"); + (options.labels && labelMode === "add"); const issueContext = needsContext ? await getIssue(ctx.gql, resolvedIssueId) : undefined; @@ -1437,7 +1433,7 @@ export function setupIssuesCommands(program: Command): void { const labelNames = options.labels.split(",").map((l) => l.trim()); const labelIds = await resolveLabelIds(ctx.sdk, labelNames); - if (options.labelMode === "add") { + if (labelMode === "add") { const currentLabels = issueContext && "labels" in issueContext && diff --git a/src/commands/projects.ts b/src/commands/projects.ts index c5dc7d5c..965b2aa8 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import { createContext, getRootOpts } from "../common/context.js"; +import type { Priority } from "../common/domain-values.js"; import { resolveReactionEmojiInput } from "../common/emoji.js"; import { invalidParameterError } from "../common/errors.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; @@ -200,12 +201,12 @@ export const PROJECTS_META: DomainMeta = { ], }; -function parsePriority(value: string): number { +function parsePriority(value: string): Priority { const priority = Number.parseInt(value, 10); if (Number.isNaN(priority) || priority < 0 || priority > 4) { throw invalidParameterError("priority", `must be 0-4, got "${value}"`); } - return priority; + return priority as Priority; } function parseNonNegativeIntegerOption(name: string, value: string): number { diff --git a/src/common/domain-values.ts b/src/common/domain-values.ts new file mode 100644 index 00000000..4d33e60d --- /dev/null +++ b/src/common/domain-values.ts @@ -0,0 +1,18 @@ +import { invalidParameterError } from "./errors.js"; + +/** Linear priority scale: 0=none, 1=urgent, 2=high, 3=medium, 4=low. */ +export type Priority = 0 | 1 | 2 | 3 | 4; + +/** How `issues update --labels` combines with existing labels. */ +export type LabelMode = "add" | "overwrite"; + +export function parseLabelMode( + value: string | undefined, +): LabelMode | undefined { + if (value === undefined) return undefined; + if (value === "add" || value === "overwrite") return value; + throw invalidParameterError( + "--label-mode", + "must be either 'add' or 'overwrite'", + ); +} diff --git a/src/common/number-options.ts b/src/common/number-options.ts index 0d72b9d4..176e7012 100644 --- a/src/common/number-options.ts +++ b/src/common/number-options.ts @@ -1,3 +1,4 @@ +import type { Priority } from "./domain-values.js"; import { invalidParameterError } from "./errors.js"; function parseStrictNonNegativeInteger(raw: string): number | null { @@ -8,7 +9,7 @@ function parseStrictNonNegativeInteger(raw: string): number | null { return Number.parseInt(raw, 10); } -export function parsePriorityOption(raw: string): number { +export function parsePriorityOption(raw: string): Priority { const value = parseStrictNonNegativeInteger(raw); if (value === null || value < 1 || value > 4) { throw invalidParameterError( @@ -17,7 +18,7 @@ export function parsePriorityOption(raw: string): number { ); } - return value; + return value as Priority; } export function parseEstimateOption(raw: string): number { diff --git a/tests/unit/common/domain-values.test.ts b/tests/unit/common/domain-values.test.ts new file mode 100644 index 00000000..72010b23 --- /dev/null +++ b/tests/unit/common/domain-values.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { parseLabelMode } from "../../../src/common/domain-values.js"; + +describe("parseLabelMode", () => { + it("returns undefined when value is undefined", () => { + expect(parseLabelMode(undefined)).toBeUndefined(); + }); + + it("returns the narrowed mode for valid values", () => { + expect(parseLabelMode("add")).toBe("add"); + expect(parseLabelMode("overwrite")).toBe("overwrite"); + }); + + it("throws for invalid values", () => { + expect(() => parseLabelMode("replace")).toThrow( + "Invalid --label-mode: must be either 'add' or 'overwrite'", + ); + expect(() => parseLabelMode("")).toThrow( + "Invalid --label-mode: must be either 'add' or 'overwrite'", + ); + }); +}); From 5c61c2bd1b51256f86b3c3a9d1fa5ab9bbc49e6a Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:37:31 +0200 Subject: [PATCH 39/79] refactor(resolvers): replace loose Record types with narrow projections Type the initiative filter clauses with LinearDocument.InitiativeFilter and introduce explicit IssueTeamProjection/TeamLookupProjection interfaces for the issue team lookup, removing untyped Record<string, unknown> access. Closes #204 --- src/resolvers/initiative-resolver.ts | 3 +- src/resolvers/issue-resolver.ts | 63 +++++++++++++++++++++------- 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/src/resolvers/initiative-resolver.ts b/src/resolvers/initiative-resolver.ts index 14a86b8b..5943e411 100644 --- a/src/resolvers/initiative-resolver.ts +++ b/src/resolvers/initiative-resolver.ts @@ -1,3 +1,4 @@ +import type { LinearDocument } from "@linear/sdk"; import type { GraphQLClient } from "../client/graphql-client.js"; import type { LinearSdkClient } from "../client/linear-client.js"; import { multipleMatchesError, notFoundError } from "../common/errors.js"; @@ -21,7 +22,7 @@ export async function resolveInitiativeId( return nameOrId; } - const clauses: Array<Record<string, unknown>> = [ + const clauses: LinearDocument.InitiativeFilter[] = [ { name: { eqIgnoreCase: nameOrId } }, ]; diff --git a/src/resolvers/issue-resolver.ts b/src/resolvers/issue-resolver.ts index 07d6e8c8..650c1006 100644 --- a/src/resolvers/issue-resolver.ts +++ b/src/resolvers/issue-resolver.ts @@ -22,21 +22,56 @@ async function resolveRelationValue(value: unknown): Promise<unknown> { return isPromiseLike(value) ? await value : value; } -function getTeamLookupFromRelation(team: unknown): string | undefined { +/** Narrow projection of the SDK issue node fields the team lookup consumes. */ +interface IssueTeamProjection { + id: string; + teamId?: string; + team?: unknown; // relation; may be a value or PromiseLike (SDK quirk) +} + +/** Narrow projection of a resolved team relation node. */ +interface TeamLookupProjection { + id?: string; + key?: string; +} + +function toIssueTeamProjection( + node: unknown, + ref: string, +): IssueTeamProjection { + if (!isRecord(node) || typeof node.id !== "string") { + throw new Error(`Issue "${ref}" is missing required team context`); + } + + return { + id: node.id, + teamId: typeof node.teamId === "string" ? node.teamId : undefined, + team: node.team, + }; +} + +function toTeamLookupProjection( + team: unknown, +): TeamLookupProjection | undefined { if (!isRecord(team)) return undefined; - if (typeof team.id === "string") return team.id; - if (typeof team.key === "string") return team.key; + return { + id: typeof team.id === "string" ? team.id : undefined, + key: typeof team.key === "string" ? team.key : undefined, + }; +} - return undefined; +function getTeamLookupFromRelation(team: unknown): string | undefined { + const relation = toTeamLookupProjection(team); + return relation?.id ?? relation?.key; } async function getIssueTeamLookup( - node: Record<string, unknown>, + projection: IssueTeamProjection, ): Promise<string | undefined> { - if (typeof node.teamId === "string") return node.teamId; + if (projection.teamId) return projection.teamId; - return getTeamLookupFromRelation(await resolveRelationValue(node.team)); + return getTeamLookupFromRelation(await resolveRelationValue(projection.team)); } export interface IssueEstimateContext { @@ -104,14 +139,12 @@ export async function resolveIssueEstimateContext( throw notFoundError("Issue", issueIdOrIdentifier); } - const issueNode = issues.nodes[0]; - if (!isRecord(issueNode) || typeof issueNode.id !== "string") { - throw new Error( - `Issue "${issueIdOrIdentifier}" is missing required team context`, - ); - } + const projection = toIssueTeamProjection( + issues.nodes[0], + issueIdOrIdentifier, + ); - const teamLookup = await getIssueTeamLookup(issueNode); + const teamLookup = await getIssueTeamLookup(projection); if (!teamLookup) { throw new Error( `Issue "${issueIdOrIdentifier}" is missing required team context`, @@ -119,7 +152,7 @@ export async function resolveIssueEstimateContext( } return { - issueId: issueNode.id, + issueId: projection.id, team: await resolveTeamEstimateContext(client, teamLookup), }; } From b7e29fe0724b55060dde605326221f9f467189b6 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:52:49 +0200 Subject: [PATCH 40/79] docs: add comments-to-discussions migration guide and restructure README Add MIGRATION_2026.4.9.md documenting the comments -> discussions change and rework the README (badges, features, domains table, agent-integration section, documentation links). --- MIGRATION_2026.4.9.md | 46 +++++++++ README.md | 213 +++++++++++++++++++++--------------------- 2 files changed, 153 insertions(+), 106 deletions(-) create mode 100644 MIGRATION_2026.4.9.md diff --git a/MIGRATION_2026.4.9.md b/MIGRATION_2026.4.9.md new file mode 100644 index 00000000..d7c6b06c --- /dev/null +++ b/MIGRATION_2026.4.9.md @@ -0,0 +1,46 @@ +# Migration Guide — v2026.4.9: `comments` → discussions + +**Introduced in:** v2026.4.9 (2026-04-27) · **Status of `comments`:** deprecated compatibility facade + +## What changed + +Before v2026.4.9, Linearis exposed a flat `comments` domain: a single list of comments per issue, with replies that were hard to relate back to their parent. + +v2026.4.9 replaces that with **discussions** — threaded conversations modeled the way Linear itself models them. A discussion is a **root thread** with a body, and each root thread has an ordered list of **replies** (which may themselves be nested). Discussions are available across multiple domains (`issues`, `projects`, `initiatives`), not just issues. + +The old `comments` commands still work — they now route through the discussion service as a **deprecated compatibility facade** — so existing scripts keep running. New automation and agent prompts should use the discussion commands directly. + +## Why the change + +- **Faithful data model.** Linear's API represents conversations as threads with replies. The old flat `comments` view flattened that structure and lost the parent/child relationship. Discussions expose it directly. +- **Nested replies.** Deep reply chains are now represented correctly instead of being collapsed into one level. +- **Consistency across domains.** The same discussion model applies to issues, projects, and initiatives, so agents learn one pattern instead of an issue-only special case. +- **Reactions.** v2026.4.9 also added reaction workflows on discussions, which the flat comment model could not express cleanly. + +## Command mapping + +| Deprecated (`comments`) | Preferred (`issues`) | +|---|---| +| `linearis comments create <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>` | + +Projects and initiatives expose the equivalent discussion subcommands in their own domains — run `linearis projects usage` or `linearis initiatives usage` for the exact commands. + +## What to respect + +- **Root threads vs. replies are distinct.** `issues discussions <issue>` lists **root** threads. Replying with `issues reply <thread>` requires a **root discussion thread ID**, not a reply ID. Passing a reply ID where a root thread ID is expected will fail. +- **Fetch replies explicitly.** `issues discussions <issue>` returns root threads only. Use `issues replies <thread>` to load the replies within one thread, including nested replies. +- **Discussions are domain-scoped.** A thread belongs to the domain it was created in. Operating on a thread through the wrong domain is rejected. +- **Compatibility facade is more lenient.** The deprecated `comments edit`/`delete` commands accept both root thread IDs and reply IDs. The new discussion commands are strict about which ID they expect — do not assume the facade's leniency carries over. +- **Prefer discussions over description edits for progress.** For anything beyond simple checkbox updates, start or continue a discussion thread rather than rewriting an issue's description. + +## Timeline + +- **v2026.4.9 (2026-04-27)** — discussion commands added across `issues`, `projects`, and `initiatives`; `comments` deprecated as a compatibility facade. +- **A future release** — the `comments` facade may be removed. Migrate before then. + +> [!TIP] +> Run `linearis issues usage` for the full, always-current discussion command reference. diff --git a/README.md b/README.md index f1a2b059..ba0f1732 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,44 @@ +<div align="center"> + # Linearis -CLI tool for [Linear.app](https://linear.app) optimized for AI agents. JSON output, smart ID resolution, token-efficient usage commands, and a discover-then-act workflow that keeps agent context small. Works just as well for humans who prefer structured data on the command line. +**A token-efficient [Linear.app](https://linear.app) CLI built for AI agents — and humans who like structured data.** + +[![NPM version](https://img.shields.io/npm/v/linearis.svg)](https://www.npmjs.com/package/linearis) +[![Node version](https://img.shields.io/node/v/linearis.svg)](https://nodejs.org) +[![CI](https://github.com/linearis-oss/linearis/actions/workflows/ci.yml/badge.svg)](https://github.com/linearis-oss/linearis/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.md) + +</div> + +Linearis is a command-line interface for Linear that speaks **JSON only**. It resolves human-friendly IDs (like `ENG-42` or a team name) to UUIDs for you, and exposes a two-tier `usage` system so an agent can discover exactly the commands it needs without loading the whole API surface into context. + +```bash +npm install -g linearis +linearis auth login +linearis issues list --limit 10 +``` + +## Why Linearis? + +The official Linear MCP works well, but it costs ~13k tokens just by being connected — before an agent does anything. Linearis takes a different approach: agents discover capabilities on demand through a two-tier usage system. + +- `linearis usage` — a compact overview of every domain (~200 tokens). +- `linearis <domain> usage` — the full reference for one domain (~300–500 tokens). + +A typical agent interaction costs **~500–700 tokens** of context instead of ~13k. The agent pays only for what it uses, one domain at a time. -## Why? +> [!NOTE] +> The trade-off is coverage. Linearis focuses on the operations that matter for day-to-day work — issues, discussions, cycles, projects, documents, and files. For custom workflows, integrations, or workspace settings, the MCP is the better choice. -The official Linear MCP works fine, but it eats up ~13k tokens just by being connected -- before the agent does anything. Linearis takes a different approach: instead of exposing the full API surface upfront, agents discover what they need through a two-tier usage system. `linearis usage` gives an overview in ~200 tokens, then `linearis <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. +## Features -The trade-off is coverage. An MCP exposes the entire Linear API; Linearis covers the operations that matter for day-to-day work with issues, discussions, cycles, documents, and files. If you need to manage custom workflows, integrations, or workspace settings, the MCP is the better choice. +- **JSON-only output** — pipe into `jq`, no parsing of tables or prose. +- **Smart ID resolution** — pass `ENG-42`, a team name, or a UUID interchangeably. +- **Two-tier discovery** — self-documenting `usage` commands keep agent context small. +- **Discussion threads** — first-class root/reply modeling on issues. +- **File attachments** — upload and download with signed URLs. +- **Broad domain coverage** — issues, projects, cycles, milestones, initiatives, documents, labels, teams, users, and more. ## Installation @@ -14,58 +46,54 @@ The trade-off is coverage. An MCP exposes the entire Linear API; Linearis covers npm install -g linearis ``` -`linearis` is the canonical documented command; `linear` is a fully supported alias that runs the same CLI. - -Requires Node.js >= 22. +Requires **Node.js ≥ 22**. The `linearis` command is canonical; `linear` is a fully supported alias that runs the same CLI. ## Authentication +The interactive flow opens Linear in your browser, walks you through creating an API key, and stores it encrypted in `~/.linearis/token`: + ```bash linearis auth login ``` -This opens Linear in your browser, guides you through creating an API key, and stores the token encrypted in `~/.linearis/token`. - -Alternatively, provide a token directly: +Or provide a token directly: ```bash -# Via CLI flag -linearis --api-token <token> issues list - -# Via environment variable -LINEAR_API_TOKEN=<token> linearis issues list +linearis --api-token <token> issues list # via flag +LINEAR_API_TOKEN=<token> linearis issues list # via environment variable ``` -Token resolution order: `--api-token` flag > `LINEAR_API_TOKEN` env > `~/.linearis/token` > `~/.linear_api_token` (deprecated). +Token resolution order: `--api-token` flag → `LINEAR_API_TOKEN` env → `~/.linearis/token` → `~/.linear_api_token` (deprecated). ## Usage -All output is JSON. Pipe through `jq` or similar for formatting. +All output is JSON. Start with discovery, then act. ```bash -# Discovery -linearis usage # overview of all domains -linearis issues usage # detailed usage for one domain -``` - -### Quick Start - -```bash -# Discover available commands +# Discover what's available (~200 tokens) linearis usage -# Drill into a domain +# Drill into one domain for its full command reference linearis issues usage -# List recent issues +# List and search linearis issues list --limit 10 - -# Search for issues linearis issues search "authentication bug" # Create an issue linearis issues create "Fix login flow" --team Platform --priority 2 +# Read an issue (includes embeds with signed download URLs) +linearis issues read ENG-42 +``` + +For the complete reference of every command and flag, run `linearis <domain> usage`. + +### Discussions + +Discussions are modeled as root threads with replies, rather than a flat comment list: + +```bash # Start a discussion thread on an issue linearis issues discuss ENG-42 --body "Investigating this now" @@ -73,122 +101,95 @@ linearis issues discuss ENG-42 --body "Investigating this now" linearis issues discussions ENG-42 # List replies in one root thread -linearis issues replies 6f4f28cd-4f53-4d76-ae95-80f1b6f6b87e +linearis issues replies <root-thread-id> -# Reply to a thread (use a root discussion thread ID) -linearis issues reply 6f4f28cd-4f53-4d76-ae95-80f1b6f6b87e --body "I found the root cause" +# Reply to a thread (use a root discussion thread ID, not a reply ID) +linearis issues reply <root-thread-id> --body "I found the root cause" ``` -For the full reference of every command and flag, run: - -```bash -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: +### Domains -| Deprecated | Preferred | +| Domain | What it covers | |---|---| -| `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>` | +| `issues` | Work items with status, priority, assignee, labels, and discussions | +| `projects` | Groups of issues working toward a goal | +| `initiatives` | Strategic, multi-project goals | +| `cycles` | Time-boxed iterations (sprints) per team | +| `milestones` | Progress checkpoints within projects | +| `documents` | Long-form markdown docs attached to projects or issues | +| `labels` | Categorization tags for issues and projects | +| `attachments` | Linked external resources on issues (PRs, commits, URLs) | +| `files` | Upload and download file attachments | +| `teams` | Organizational units owning issues and cycles | +| `users` | Workspace members and assignees | +| `auth` | Authenticate with the Linear API | -Notes: -- `issues discussions <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 -## AI Agent Integration +Linearis is structured around a **discover-then-act** pattern that matches how agents work: -### How agents use Linearis +1. **Discover** — `linearis usage` returns a compact overview of all domains. The agent reads it once. +2. **Drill down** — `linearis <domain> usage` gives the full reference for a single domain. The agent loads only what it needs. +3. **Execute** — every command returns structured JSON. No table or prose parsing. -The CLI is structured around a discover-then-act pattern that matches how agents work: +The agent never loads the full API surface into context — it pays for what it uses, one domain at a time. -1. **Discover** -- `linearis usage` returns a compact overview of all domains (~200 tokens). The agent reads this once to understand what's available. -2. **Drill down** -- `linearis <domain> usage` gives the full command reference for one domain (~300-500 tokens). The agent only loads what it needs. -3. **Execute** -- All commands return structured JSON. No parsing of human-readable tables or prose. - -This means the agent never loads the full API surface into context. It pays for what it uses, one domain at a time. - -### Linearis vs. MCP +### Linearis vs. Linear MCP | | Linearis | Linear MCP | |---|---|---| -| Context cost | ~500-700 tokens per interaction | ~13k tokens on connect | +| Context cost | ~500–700 tokens per interaction | ~13k tokens on connect | | Coverage | Common operations (issues, discussions, cycles, docs, files) | Full Linear API | -| Output | JSON via stdout | Tool call responses | -| Setup | `npm install -g linearis` + bash tool | MCP server connection | +| Output | JSON via stdout | Tool-call responses | +| Setup | `npm install -g linearis` + Bash tool | MCP server connection | Use Linearis when token efficiency matters and you work primarily with issues and related data. Use the MCP when you need full API coverage or tight tool-call integration. -### Example prompt +### Example agent prompt + +Add this (or a version adapted to your workflow) to your `AGENTS.md` or `CLAUDE.md` so every session has it in context: ```markdown ## Linear (project management) Tool: `linearis` CLI via Bash. All output is JSON. -Discovery: Run `linearis usage` once to see available domains. Run `linearis <domain> usage` for full command reference of a specific domain. Do NOT guess flags or subcommands -- check usage first. +Discovery: Run `linearis usage` once to see available domains. Run +`linearis <domain> usage` for the full command reference of a specific domain. +Do NOT guess flags or subcommands — check usage first. Ticket format: "ABC-123". Always reference tickets by their identifier. Workflow rules: -- When creating a ticket, ask the user which project to assign it to if unclear. +- When creating a ticket, ask which project to assign it to if unclear. - For subtasks, inherit the parent ticket's project by default. - When a task in a ticket description changes status, update the description. -- For progress beyond simple checkbox changes, start or reply in a discussion thread instead of editing the description. +- For progress beyond checkbox changes, use a discussion thread instead of + editing the description. -File handling: `issues read` returns an `embeds` array with signed download URLs and expiration timestamps. Use `files download` to retrieve them. Use `files upload` to attach new files, then reference the returned URL in discussions or descriptions. +File handling: `issues read` returns an `embeds` array with signed download +URLs and expiration timestamps. Use `files download` to retrieve them, and +`files upload` to attach new files. ``` -Add this (or a version adapted to your workflow) to your `AGENTS.md` or `CLAUDE.md` so every agent session has it in context automatically. - -## Release Automation Policy - -Linearis uses three CI/release workflows: - -- `ci.yml` for required pull request checks -- `ci-post-merge.yml` for post-merge sentinel validation on `main`/`next` pushes -- `release-check.yml` for push-driven and manual releases - -For the authoritative trigger matrix, required checks, and operational verification commands, see [`docs/ci-run-model.md`](docs/ci-run-model.md) (source of truth). +## Documentation -`CHANGELOG.md` is automation-owned and must not be edited in pull requests. If a pull request branch contains `CHANGELOG.md` changes anywhere in `main...HEAD` history, CI fails and posts rebase instructions. - -## Contributing - -Want to contribute? See [CONTRIBUTING.md](CONTRIBUTING.md). - -## Creator - -Carlo Zottmann -- [c.zottmann.dev](https://c.zottmann.dev) | [github.com/czottmann](https://github.com/czottmann) - -Carlo created Linearis and drove its early development. As interest in the project grew, he handed maintenance over to [Fabian Jocks](https://github.com/iamfj) ([in/fabianjocks](https://linkedin.com/in/fabianjocks)). - -This project is neither affiliated with nor endorsed by Linear. - -### Sponsoring Carlo's work - -Carlo doesn't accept sponsoring in the "GitHub sponsorship" sense[^1] but [next to his own apps, he also sells "Tokens of Appreciation"](https://actions.work/store/?ref=github). Any support is appreciated! - -[^1]: Apparently, the German revenue service is still having some fits over "money for nothing??". - -> [!TIP] -> Carlo makes Shortcuts-related macOS & iOS productivity apps like [Actions For Obsidian](https://actions.work/actions-for-obsidian), [Browser Actions](https://actions.work/browser-actions) (which adds Shortcuts support for several major browsers), and [BarCuts](https://actions.work/barcuts) (a surprisingly useful contextual Shortcuts launcher). Check them out! +- [MIGRATION_2026.4.9.md](MIGRATION_2026.4.9.md) — migrating from the deprecated `comments` domain to discussions (v2026.4.9). +- [`docs/`](docs/) — architecture, development, testing, and build-system references. +- [`docs/ci-run-model.md`](docs/ci-run-model.md) — the authoritative CI/release trigger matrix. +- [CONTRIBUTING.md](CONTRIBUTING.md) — contributor guidelines. +- [SECURITY.md](SECURITY.md) — how to report security issues. ## Contributors <a href="https://github.com/linearis-oss/linearis/graphs/contributors"> - <img src="https://contrib.rocks/image?repo=linearis-oss/linearis" /> + <img src="https://contrib.rocks/image?repo=linearis-oss/linearis" alt="Contributors" /> </a> Made with [contrib.rocks](https://contrib.rocks). ## License -MIT. See [LICENSE.md](LICENSE.md). +[MIT](LICENSE.md) + +This project is neither affiliated with nor endorsed by Linear. From 5fca207d96e597ef0e755bb0e1eb0135deeae47e Mon Sep 17 00:00:00 2001 From: Charles Phillips <charles@doublerebel.com> Date: Tue, 16 Jun 2026 20:14:56 -0700 Subject: [PATCH 41/79] feat(labels): add issue label CRUD commands Refs DBL-191 --- graphql/mutations/labels.graphql | 28 ++ graphql/queries/labels.graphql | 6 + src/commands/labels.ts | 221 +++++++++++++++- src/resolvers/label-resolver.ts | 38 ++- src/services/label-service.ts | 108 +++++++- tests/unit/commands/labels.test.ts | 278 +++++++++++++++++++- tests/unit/resolvers/label-resolver.test.ts | 49 ++++ tests/unit/services/label-service.test.ts | 198 ++++++++++++++ 8 files changed, 914 insertions(+), 12 deletions(-) create mode 100644 graphql/mutations/labels.graphql diff --git a/graphql/mutations/labels.graphql b/graphql/mutations/labels.graphql new file mode 100644 index 00000000..460afeed --- /dev/null +++ b/graphql/mutations/labels.graphql @@ -0,0 +1,28 @@ +# ------------------------------------------------------------ +# GraphQL mutations for Linear issue labels +# ------------------------------------------------------------ + +mutation CreateIssueLabel($input: IssueLabelCreateInput!) { + issueLabelCreate(input: $input) { + success + issueLabel { + ...LabelFields + } + } +} + +mutation UpdateIssueLabel($id: String!, $input: IssueLabelUpdateInput!) { + issueLabelUpdate(id: $id, input: $input) { + success + issueLabel { + ...LabelFields + } + } +} + +mutation DeleteIssueLabel($id: String!) { + issueLabelDelete(id: $id) { + success + entityId + } +} diff --git a/graphql/queries/labels.graphql b/graphql/queries/labels.graphql index 5759a84c..e096b757 100644 --- a/graphql/queries/labels.graphql +++ b/graphql/queries/labels.graphql @@ -29,6 +29,12 @@ fragment ProjectLabelFields on ProjectLabel { description } +query GetIssueLabel($id: String!) { + issueLabel(id: $id) { + ...LabelFields + } +} + # List labels in the workspace # # Fetches a list of issue labels with optional team filtering. diff --git a/src/commands/labels.ts b/src/commands/labels.ts index 330d0a09..47677a8f 100644 --- a/src/commands/labels.ts +++ b/src/commands/labels.ts @@ -7,12 +7,24 @@ import { import { invalidParameterError } from "../common/errors.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; +import type { + IssueLabelCreateInput, + IssueLabelUpdateInput, +} from "../gql/graphql.js"; +import { + type LabelResolverScope, + resolveLabelId, +} from "../resolvers/label-resolver.js"; import { resolveTeamId } from "../resolvers/team-resolver.js"; import { + createLabel, + deleteLabel, + getLabel, type LabelScope, type LabelType, listLabels, listProjectLabels, + updateLabel, } from "../services/label-service.js"; interface ListLabelsOptions extends CommandOptions { @@ -23,6 +35,23 @@ interface ListLabelsOptions extends CommandOptions { after?: string; } +interface LabelLookupOptions extends CommandOptions { + team?: string; + scope?: string; +} + +interface CreateLabelOptions extends CommandOptions { + team?: string; + color?: string; + description?: string; +} + +interface UpdateLabelOptions extends LabelLookupOptions { + name?: string; + color?: string; + description?: string; +} + function parseLabelType(value?: string): LabelType { if (value === undefined || value === "issue" || value === "project") { return value ?? "issue"; @@ -42,16 +71,89 @@ function parseLabelScope(value?: string): LabelScope | undefined { ); } +function parseLabelColor(value?: string): string | undefined { + if (value === undefined) { + return undefined; + } + + if (!/^#[0-9a-fA-F]{6}$/.test(value)) { + throw invalidParameterError("--color", "must be a hex color like #B45309"); + } + + return value; +} + +async function resolveIssueLabelLookup( + command: Command, + label: string, + options: LabelLookupOptions, +): Promise<{ ctx: ReturnType<typeof createContext>; labelId: string }> { + const ctx = createContext(getRootOpts(command)); + const scope = parseLabelScope(options.scope); + + if (scope === "team" && !options.team) { + throw invalidParameterError("--scope", "team scope requires --team"); + } + + if (scope === "workspace" && options.team) { + throw invalidParameterError( + "--team", + "cannot be used with --scope workspace", + ); + } + + const teamId = options.team + ? await resolveTeamId(ctx.sdk, options.team) + : undefined; + const labelId = await resolveLabelId(ctx.sdk, label, { + teamId, + scope: scope as LabelResolverScope | undefined, + }); + + return { ctx, labelId }; +} + +function buildUpdateInput(options: UpdateLabelOptions): IssueLabelUpdateInput { + const input: IssueLabelUpdateInput = {}; + const color = parseLabelColor(options.color); + + if (options.name) { + input.name = options.name; + } + + if (color) { + input.color = color; + } + + if (options.description) { + input.description = options.description; + } + + if (Object.keys(input).length === 0) { + throw invalidParameterError( + "label update", + "at least one option must be provided", + ); + } + + return input; +} + export const LABELS_META: DomainMeta = { name: "labels", summary: "categorization tags for issues and projects", context: [ "issue labels can exist at workspace level or be scoped to a specific", - "team. project labels are workspace-level only. use with issues", - "create/update --labels and projects create/update --labels.", + "team. project labels are workspace-level only. use labels list to", + "inspect existing labels, labels create/read/update/delete for issue", + "labels, and issues/projects create/update --labels to apply them.", ].join("\n"), - arguments: {}, + arguments: { name: "label name or UUID" }, seeAlso: [ + "labels create <name>", + "labels read <label>", + "labels update <label>", + "labels delete <label>", "issues create --labels", "issues update --labels", "projects create --labels", @@ -122,6 +224,119 @@ export function setupLabelsCommands(program: Command): void { }), ); + labels + .command("create <name>") + .description("create an issue label") + .option("--team <team>", "create a team-scoped label (key, name, or UUID)") + .option("--color <hex>", "label color as a hex code (for example #B45309)") + .option("--description <text>", "label description") + .action( + handleCommand(async (...args: unknown[]) => { + const [name, options, command] = args as [ + string, + CreateLabelOptions, + Command, + ]; + const ctx = createContext(getRootOpts(command)); + + const input: IssueLabelCreateInput = { name }; + const color = parseLabelColor(options.color); + + if (options.team) { + input.teamId = await resolveTeamId(ctx.sdk, options.team); + } + + if (color) { + input.color = color; + } + + if (options.description) { + input.description = options.description; + } + + outputSuccess(await createLabel(ctx.gql, input)); + }), + ); + + labels + .command("read <label>") + .description("read an issue label") + .option( + "--team <team>", + "resolve a team-scoped label by team (key, name, or UUID)", + ) + .option("--scope <scope>", "resolve within workspace or team scope") + .action( + handleCommand(async (...args: unknown[]) => { + const [label, options, command] = args as [ + string, + LabelLookupOptions, + Command, + ]; + const { ctx, labelId } = await resolveIssueLabelLookup( + command, + label, + options, + ); + + outputSuccess(await getLabel(ctx.gql, labelId)); + }), + ); + + labels + .command("update <label>") + .description("update an issue label") + .option( + "--team <team>", + "resolve a team-scoped label by team (key, name, or UUID)", + ) + .option("--scope <scope>", "resolve within workspace or team scope") + .option("--name <name>", "new label name") + .option("--color <hex>", "new label color as a hex code") + .option("--description <text>", "new label description") + .action( + handleCommand(async (...args: unknown[]) => { + const [label, options, command] = args as [ + string, + UpdateLabelOptions, + Command, + ]; + const input = buildUpdateInput(options); + const { ctx, labelId } = await resolveIssueLabelLookup( + command, + label, + options, + ); + + outputSuccess(await updateLabel(ctx.gql, labelId, input)); + }), + ); + + labels + .command("delete <label>") + .description("delete an issue label") + .option( + "--team <team>", + "resolve a team-scoped label by team (key, name, or UUID)", + ) + .option("--scope <scope>", "resolve within workspace or team scope") + .action( + handleCommand(async (...args: unknown[]) => { + const [label, options, command] = args as [ + string, + LabelLookupOptions, + Command, + ]; + const { ctx, labelId } = await resolveIssueLabelLookup( + command, + label, + options, + ); + + outputSuccess(await deleteLabel(ctx.gql, labelId)); + }), + ); + labels .command("usage") .description("show detailed usage for labels") diff --git a/src/resolvers/label-resolver.ts b/src/resolvers/label-resolver.ts index 5fd3b609..054bea29 100644 --- a/src/resolvers/label-resolver.ts +++ b/src/resolvers/label-resolver.ts @@ -2,14 +2,50 @@ import type { LinearSdkClient } from "../client/linear-client.js"; import { notFoundError } from "../common/errors.js"; import { isUuid } from "../common/identifier.js"; +export type LabelResolverScope = "workspace" | "team"; + +export interface ResolveLabelOptions { + teamId?: string; + scope?: LabelResolverScope; +} + +function buildLabelFilter( + nameOrId: string, + options: ResolveLabelOptions, +): Record<string, unknown> { + if (options.scope === "workspace") { + return { + name: { eqIgnoreCase: nameOrId }, + team: { null: true }, + }; + } + + if (options.scope === "team" && options.teamId) { + return { + name: { eqIgnoreCase: nameOrId }, + team: { id: { eq: options.teamId }, null: false }, + }; + } + + if (options.teamId) { + return { + name: { eqIgnoreCase: nameOrId }, + team: { id: { eq: options.teamId } }, + }; + } + + return { name: { eqIgnoreCase: nameOrId } }; +} + export async function resolveLabelId( client: LinearSdkClient, nameOrId: string, + options: ResolveLabelOptions = {}, ): Promise<string> { if (isUuid(nameOrId)) return nameOrId; const result = await client.sdk.issueLabels({ - filter: { name: { eqIgnoreCase: nameOrId } }, + filter: buildLabelFilter(nameOrId, options), first: 1, }); diff --git a/src/services/label-service.ts b/src/services/label-service.ts index 195e99b1..8829b3c6 100644 --- a/src/services/label-service.ts +++ b/src/services/label-service.ts @@ -1,9 +1,19 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { + CreateIssueLabelDocument, + type CreateIssueLabelMutation, + DeleteIssueLabelDocument, + type DeleteIssueLabelMutation, + GetIssueLabelDocument, + type GetIssueLabelQuery, GetLabelsDocument, GetProjectLabelsDocument, + type IssueLabelCreateInput, type IssueLabelFilter, + type IssueLabelUpdateInput, + UpdateIssueLabelDocument, + type UpdateIssueLabelMutation, } from "../gql/graphql.js"; export type LabelType = "issue" | "project"; @@ -17,10 +27,100 @@ export interface Label { type: LabelType; } +export interface DeleteLabelResult { + id: string; + success: true; +} + export interface ListLabelOptions extends PaginationOptions { scope?: LabelScope; } +function mapIssueLabel(label: { + id: string; + name: string; + color: string; + description?: string | null; +}): Label { + return { + id: label.id, + name: label.name, + color: label.color, + description: label.description ?? undefined, + type: "issue", + }; +} + +export async function getLabel( + client: GraphQLClient, + id: string, +): Promise<Label> { + const result = await client.request<GetIssueLabelQuery>( + GetIssueLabelDocument, + { + id, + }, + ); + + if (!result.issueLabel) { + throw new Error(`Label with ID "${id}" not found`); + } + + return mapIssueLabel(result.issueLabel); +} + +export async function createLabel( + client: GraphQLClient, + input: IssueLabelCreateInput, +): Promise<Label> { + const result = await client.request<CreateIssueLabelMutation>( + CreateIssueLabelDocument, + { input }, + ); + + if (!result.issueLabelCreate.success) { + throw new Error(`Failed to create label "${input.name}"`); + } + + return mapIssueLabel(result.issueLabelCreate.issueLabel); +} + +export async function updateLabel( + client: GraphQLClient, + id: string, + input: IssueLabelUpdateInput, +): Promise<Label> { + const result = await client.request<UpdateIssueLabelMutation>( + UpdateIssueLabelDocument, + { id, input }, + ); + + if (!result.issueLabelUpdate.success) { + throw new Error(`Failed to update label "${id}"`); + } + + return mapIssueLabel(result.issueLabelUpdate.issueLabel); +} + +export async function deleteLabel( + client: GraphQLClient, + id: string, +): Promise<DeleteLabelResult> { + const result = await client.request<DeleteIssueLabelMutation>( + DeleteIssueLabelDocument, + { id }, + ); + + if (!result.issueLabelDelete.success) { + throw new Error(`Failed to delete label "${id}"`); + } + + return { + id: result.issueLabelDelete.entityId, + success: true, + }; +} + function buildIssueLabelFilter( teamId?: string, scope?: LabelScope, @@ -55,13 +155,7 @@ export async function listLabels( }); return { - nodes: result.issueLabels.nodes.map((label) => ({ - id: label.id, - name: label.name, - color: label.color, - description: label.description ?? undefined, - type: "issue", - })), + nodes: result.issueLabels.nodes.map((label) => mapIssueLabel(label)), pageInfo: result.issueLabels.pageInfo, }; } diff --git a/tests/unit/commands/labels.test.ts b/tests/unit/commands/labels.test.ts index 4388da0c..91156681 100644 --- a/tests/unit/commands/labels.test.ts +++ b/tests/unit/commands/labels.test.ts @@ -22,7 +22,33 @@ vi.mock("../../../src/resolvers/team-resolver.js", () => ({ resolveTeamId: vi.fn().mockResolvedValue("resolved-team-uuid"), })); +vi.mock("../../../src/resolvers/label-resolver.js", () => ({ + resolveLabelId: vi.fn().mockResolvedValue("resolved-label-uuid"), +})); + vi.mock("../../../src/services/label-service.js", () => ({ + createLabel: vi.fn().mockResolvedValue({ + id: "lbl-new", + name: "branch:unmerged", + color: "#B45309", + type: "issue", + }), + getLabel: vi.fn().mockResolvedValue({ + id: "resolved-label-uuid", + name: "branch:unmerged", + color: "#B45309", + type: "issue", + }), + updateLabel: vi.fn().mockResolvedValue({ + id: "resolved-label-uuid", + name: "branch:merged", + color: "#1D4ED8", + type: "issue", + }), + deleteLabel: vi.fn().mockResolvedValue({ + id: "resolved-label-uuid", + success: true, + }), listLabels: vi.fn().mockResolvedValue({ nodes: [{ id: "lbl-1", name: "Bug", color: "#ff0000", type: "issue" }], pageInfo: { hasNextPage: false, endCursor: null }, @@ -42,10 +68,15 @@ vi.mock("../../../src/services/label-service.js", () => ({ import { setupLabelsCommands } from "../../../src/commands/labels.js"; import { outputSuccess } from "../../../src/common/output.js"; +import { resolveLabelId } from "../../../src/resolvers/label-resolver.js"; import { resolveTeamId } from "../../../src/resolvers/team-resolver.js"; import { + createLabel, + deleteLabel, + getLabel, listLabels, listProjectLabels, + updateLabel, } from "../../../src/services/label-service.js"; function createProgram(): Command { @@ -216,7 +247,162 @@ describe("labels list", () => { }); }); -describe("labels list validation", () => { +describe("labels create", () => { + it("creates a workspace issue label by default", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "create", + "branch:unmerged", + ]); + + expect(resolveTeamId).not.toHaveBeenCalled(); + expect(createLabel).toHaveBeenCalledWith(expect.anything(), { + name: "branch:unmerged", + }); + expect(outputSuccess).toHaveBeenCalledWith({ + id: "lbl-new", + name: "branch:unmerged", + color: "#B45309", + type: "issue", + }); + }); + + it("creates a team-scoped issue label with optional fields", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "create", + "branch:unmerged", + "--team", + "DBL", + "--color", + "#B45309", + "--description", + "Created from DBL branch workflow", + ]); + + expect(resolveTeamId).toHaveBeenCalledWith(expect.anything(), "DBL"); + expect(createLabel).toHaveBeenCalledWith(expect.anything(), { + name: "branch:unmerged", + teamId: "resolved-team-uuid", + color: "#B45309", + description: "Created from DBL branch workflow", + }); + }); +}); + +describe("labels read", () => { + it("reads a label by resolved id", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "read", + "branch:unmerged", + "--team", + "DBL", + ]); + + expect(resolveTeamId).toHaveBeenCalledWith(expect.anything(), "DBL"); + expect(resolveLabelId).toHaveBeenCalledWith( + expect.anything(), + "branch:unmerged", + { + teamId: "resolved-team-uuid", + scope: undefined, + }, + ); + expect(getLabel).toHaveBeenCalledWith( + expect.anything(), + "resolved-label-uuid", + ); + }); +}); + +describe("labels update", () => { + it("updates a resolved label", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "update", + "branch:unmerged", + "--team", + "DBL", + "--name", + "branch:merged", + "--color", + "#1D4ED8", + "--description", + "Updated from DBL branch workflow", + ]); + + expect(resolveTeamId).toHaveBeenCalledWith(expect.anything(), "DBL"); + expect(resolveLabelId).toHaveBeenCalledWith( + expect.anything(), + "branch:unmerged", + { + teamId: "resolved-team-uuid", + scope: undefined, + }, + ); + expect(updateLabel).toHaveBeenCalledWith( + expect.anything(), + "resolved-label-uuid", + { + name: "branch:merged", + color: "#1D4ED8", + description: "Updated from DBL branch workflow", + }, + ); + }); +}); + +describe("labels delete", () => { + it("deletes a resolved label", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "delete", + "branch:unmerged", + "--scope", + "workspace", + ]); + + expect(resolveLabelId).toHaveBeenCalledWith( + expect.anything(), + "branch:unmerged", + { + teamId: undefined, + scope: "workspace", + }, + ); + expect(deleteLabel).toHaveBeenCalledWith( + expect.anything(), + "resolved-label-uuid", + ); + expect(outputSuccess).toHaveBeenCalledWith({ + id: "resolved-label-uuid", + success: true, + }); + }); +}); + +describe("labels validation", () => { it("rejects unsupported label types", async () => { const program = createProgram(); @@ -366,4 +552,94 @@ describe("labels list validation", () => { expect(listProjectLabels).not.toHaveBeenCalled(); expect(resolveTeamId).not.toHaveBeenCalled(); }); + + it("rejects invalid label colors on create", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "create", + "branch:unmerged", + "--color", + "B45309", + ]); + + const errorOutput = JSON.parse( + vi.mocked(console.error).mock.calls[0][0] as string, + ) as { error: string }; + + expect(errorOutput.error).toBe( + "Invalid --color: must be a hex color like #B45309", + ); + expect(createLabel).not.toHaveBeenCalled(); + }); + + it("rejects invalid label colors on update", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "update", + "branch:unmerged", + "--color", + "B45309", + ]); + + const errorOutput = JSON.parse( + vi.mocked(console.error).mock.calls[0][0] as string, + ) as { error: string }; + + expect(errorOutput.error).toBe( + "Invalid --color: must be a hex color like #B45309", + ); + expect(updateLabel).not.toHaveBeenCalled(); + }); + + it("rejects update with no fields", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "update", + "branch:unmerged", + ]); + + const errorOutput = JSON.parse( + vi.mocked(console.error).mock.calls[0][0] as string, + ) as { error: string }; + + expect(errorOutput.error).toBe( + "Invalid label update: at least one option must be provided", + ); + expect(updateLabel).not.toHaveBeenCalled(); + }); + + it("rejects team scope without a team filter for read", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "read", + "branch:unmerged", + "--scope", + "team", + ]); + + const errorOutput = JSON.parse( + vi.mocked(console.error).mock.calls[0][0] as string, + ) as { error: string }; + + expect(errorOutput.error).toBe( + "Invalid --scope: team scope requires --team", + ); + expect(resolveLabelId).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/resolvers/label-resolver.test.ts b/tests/unit/resolvers/label-resolver.test.ts index a199004e..6f44d3b0 100644 --- a/tests/unit/resolvers/label-resolver.test.ts +++ b/tests/unit/resolvers/label-resolver.test.ts @@ -28,6 +28,55 @@ describe("resolveLabelId", () => { const client = mockSdkClient([{ id: "label-uuid" }]); const result = await resolveLabelId(client, "Bug"); expect(result).toBe("label-uuid"); + expect(client.sdk.issueLabels).toHaveBeenCalledWith({ + filter: { name: { eqIgnoreCase: "Bug" } }, + first: 1, + }); + }); + + it("resolves workspace label by name", async () => { + const client = mockSdkClient([{ id: "label-uuid" }]); + + await resolveLabelId(client, "Bug", { scope: "workspace" }); + + expect(client.sdk.issueLabels).toHaveBeenCalledWith({ + filter: { + name: { eqIgnoreCase: "Bug" }, + team: { null: true }, + }, + first: 1, + }); + }); + + it("resolves team-scoped label by name", async () => { + const client = mockSdkClient([{ id: "label-uuid" }]); + + await resolveLabelId(client, "Bug", { + teamId: "team-uuid", + scope: "team", + }); + + expect(client.sdk.issueLabels).toHaveBeenCalledWith({ + filter: { + name: { eqIgnoreCase: "Bug" }, + team: { id: { eq: "team-uuid" }, null: false }, + }, + first: 1, + }); + }); + + it("filters by team when teamId is provided without explicit scope", async () => { + const client = mockSdkClient([{ id: "label-uuid" }]); + + await resolveLabelId(client, "Bug", { teamId: "team-uuid" }); + + expect(client.sdk.issueLabels).toHaveBeenCalledWith({ + filter: { + name: { eqIgnoreCase: "Bug" }, + team: { id: { eq: "team-uuid" } }, + }, + first: 1, + }); }); it("throws when label not found", async () => { diff --git a/tests/unit/services/label-service.test.ts b/tests/unit/services/label-service.test.ts index 34c32657..760795e1 100644 --- a/tests/unit/services/label-service.test.ts +++ b/tests/unit/services/label-service.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { + createLabel, + deleteLabel, + getLabel, listLabels, listProjectLabels, + updateLabel, } from "../../../src/services/label-service.js"; function mockGqlClient(response: Record<string, unknown>): GraphQLClient { @@ -11,6 +15,200 @@ function mockGqlClient(response: Record<string, unknown>): GraphQLClient { } as unknown as GraphQLClient; } +describe("getLabel", () => { + it("returns a label by id", async () => { + const client = mockGqlClient({ + issueLabel: { + id: "lbl-1", + name: "Bug", + color: "#ff0000", + description: "A bug", + }, + }); + + const result = await getLabel(client, "lbl-1"); + + expect(result).toEqual({ + id: "lbl-1", + name: "Bug", + color: "#ff0000", + description: "A bug", + type: "issue", + }); + }); + + it("throws when label not found", async () => { + const client = mockGqlClient({ issueLabel: null }); + + await expect(getLabel(client, "lbl-1")).rejects.toThrow( + 'Label with ID "lbl-1" not found', + ); + }); +}); + +describe("createLabel", () => { + it("returns created issue label with type", async () => { + const client = mockGqlClient({ + issueLabelCreate: { + success: true, + issueLabel: { + id: "lbl-new", + name: "branch:unmerged", + color: "#B45309", + description: "Created from DBL branch workflow", + }, + }, + }); + + const result = await createLabel(client, { + name: "branch:unmerged", + teamId: "team-1", + color: "#B45309", + description: "Created from DBL branch workflow", + }); + + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + input: { + name: "branch:unmerged", + teamId: "team-1", + color: "#B45309", + description: "Created from DBL branch workflow", + }, + }); + expect(result).toEqual({ + id: "lbl-new", + name: "branch:unmerged", + color: "#B45309", + description: "Created from DBL branch workflow", + type: "issue", + }); + }); + + it("throws on create failure", async () => { + const client = mockGqlClient({ + issueLabelCreate: { + success: false, + issueLabel: { + id: "lbl-new", + name: "branch:unmerged", + color: "#B45309", + description: null, + }, + }, + }); + + await expect( + createLabel(client, { name: "branch:unmerged" }), + ).rejects.toThrow('Failed to create label "branch:unmerged"'); + }); + + it("converts null create description to undefined", async () => { + const client = mockGqlClient({ + issueLabelCreate: { + success: true, + issueLabel: { + id: "lbl-new", + name: "branch:unmerged", + color: "#B45309", + description: null, + }, + }, + }); + + const result = await createLabel(client, { name: "branch:unmerged" }); + + expect(result.description).toBeUndefined(); + expect(result.type).toBe("issue"); + }); +}); + +describe("updateLabel", () => { + it("returns updated issue label", async () => { + const client = mockGqlClient({ + issueLabelUpdate: { + success: true, + issueLabel: { + id: "lbl-1", + name: "branch:merged", + color: "#1D4ED8", + description: "Updated from DBL branch workflow", + }, + }, + }); + + const result = await updateLabel(client, "lbl-1", { + name: "branch:merged", + color: "#1D4ED8", + description: "Updated from DBL branch workflow", + }); + + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + id: "lbl-1", + input: { + name: "branch:merged", + color: "#1D4ED8", + description: "Updated from DBL branch workflow", + }, + }); + expect(result).toEqual({ + id: "lbl-1", + name: "branch:merged", + color: "#1D4ED8", + description: "Updated from DBL branch workflow", + type: "issue", + }); + }); + + it("throws on update failure", async () => { + const client = mockGqlClient({ + issueLabelUpdate: { + success: false, + issueLabel: { + id: "lbl-1", + name: "branch:merged", + color: "#1D4ED8", + description: null, + }, + }, + }); + + await expect( + updateLabel(client, "lbl-1", { name: "branch:merged" }), + ).rejects.toThrow('Failed to update label "lbl-1"'); + }); +}); + +describe("deleteLabel", () => { + it("returns deleted label id", async () => { + const client = mockGqlClient({ + issueLabelDelete: { + success: true, + entityId: "lbl-1", + }, + }); + + const result = await deleteLabel(client, "lbl-1"); + + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + id: "lbl-1", + }); + expect(result).toEqual({ id: "lbl-1", success: true }); + }); + + it("throws on delete failure", async () => { + const client = mockGqlClient({ + issueLabelDelete: { + success: false, + entityId: "lbl-1", + }, + }); + + await expect(deleteLabel(client, "lbl-1")).rejects.toThrow( + 'Failed to delete label "lbl-1"', + ); + }); +}); + describe("listLabels", () => { it("returns issue labels with type", async () => { const client = mockGqlClient({ From c6cfa627972f825868b8868c50057d7c15e906a6 Mon Sep 17 00:00:00 2001 From: Charles Phillips <charles@doublerebel.com> Date: Sat, 20 Jun 2026 17:08:03 -0700 Subject: [PATCH 42/79] feat(labels): support label removal modes --- src/commands/issues.ts | 27 +++++-- src/commands/labels.ts | 3 +- src/commands/projects.ts | 63 +++++++++++++++- tests/unit/commands/issues.test.ts | 60 +++++++++++++++ tests/unit/commands/projects.test.ts | 106 ++++++++++++++++++++++++++- 5 files changed, 250 insertions(+), 9 deletions(-) diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 9cd02148..649f794e 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -1,7 +1,6 @@ import type { Command } from "commander"; import type { CommandContext } from "../common/context.js"; import { createContext, getRootOpts } from "../common/context.js"; -import { parseLabelMode } from "../common/domain-values.js"; import { resolveReactionEmojiInput } from "../common/emoji.js"; import { invalidParameterError } from "../common/errors.js"; import { validateEstimateAgainstTeamConfig } from "../common/estimate-validation.js"; @@ -1278,7 +1277,7 @@ export function setupIssuesCommands(program: Command): void { .option("--assignee <user>", "new assignee") .option("--project <project>", "new project") .option("--labels <labels>", "labels to apply (comma-separated)") - .option("--label-mode <mode>", "add | overwrite") + .option("--label-mode <mode>", "add | remove | overwrite") .option("--clear-labels", "remove all labels") .option("--parent-ticket <issue>", "set parent issue") .option("--clear-parent-ticket", "clear parent") @@ -1343,7 +1342,14 @@ export function setupIssuesCommands(program: Command): void { throw new Error("--clear-labels cannot be used with --label-mode"); } - const labelMode = parseLabelMode(options.labelMode); + if ( + options.labelMode && + !["add", "remove", "overwrite"].includes(options.labelMode) + ) { + throw new Error( + "--label-mode must be one of 'add', 'remove', or 'overwrite'", + ); + } const parsedPriority = options.priority !== undefined @@ -1382,7 +1388,8 @@ export function setupIssuesCommands(program: Command): void { options.status || options.projectMilestone || options.cycle || - (options.labels && labelMode === "add"); + (options.labels && + (options.labelMode === "add" || options.labelMode === "remove")); const issueContext = needsContext ? await getIssue(ctx.gql, resolvedIssueId) : undefined; @@ -1433,7 +1440,7 @@ export function setupIssuesCommands(program: Command): void { const labelNames = options.labels.split(",").map((l) => l.trim()); const labelIds = await resolveLabelIds(ctx.sdk, labelNames); - if (labelMode === "add") { + if (options.labelMode === "add") { const currentLabels = issueContext && "labels" in issueContext && @@ -1441,6 +1448,16 @@ export function setupIssuesCommands(program: Command): void { ? issueContext.labels.nodes.map((l) => l.id) : []; input.labelIds = [...new Set([...currentLabels, ...labelIds])]; + } else if (options.labelMode === "remove") { + const currentLabels = + issueContext && + "labels" in issueContext && + issueContext.labels?.nodes + ? issueContext.labels.nodes.map((l) => l.id) + : []; + input.labelIds = currentLabels.filter( + (id) => !labelIds.includes(id), + ); } else { input.labelIds = labelIds; } diff --git a/src/commands/labels.ts b/src/commands/labels.ts index 47677a8f..5d7d72dc 100644 --- a/src/commands/labels.ts +++ b/src/commands/labels.ts @@ -146,7 +146,8 @@ export const LABELS_META: DomainMeta = { "issue labels can exist at workspace level or be scoped to a specific", "team. project labels are workspace-level only. use labels list to", "inspect existing labels, labels create/read/update/delete for issue", - "labels, and issues/projects create/update --labels to apply them.", + "labels, and issues/projects create/update --labels plus update", + "--label-mode remove or --clear-labels to apply or remove them.", ].join("\n"), arguments: { name: "label name or UUID" }, seeAlso: [ diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 965b2aa8..5fac3f55 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -178,6 +178,8 @@ interface UpdateOptions { teams?: string; team?: string; labels?: string; + labelMode?: string; + clearLabels?: boolean; } export const PROJECTS_META: DomainMeta = { @@ -700,6 +702,8 @@ export function setupProjectsCommands(program: Command): void { .option("--teams <teams>", "comma-separated team names or UUIDs") .option("--team <team>", "team name or UUID (alias for --teams)") .option("--labels <labels>", "comma-separated label names or UUIDs") + .option("--label-mode <mode>", "add | remove | overwrite") + .option("--clear-labels", "remove all labels") .action( handleCommand(async (...args: unknown[]) => { const [project, options, command] = args as [ @@ -730,7 +734,44 @@ export function setupProjectsCommands(program: Command): void { ); } + if (options.labelMode && !options.labels) { + throw invalidParameterError( + "--label-mode", + "requires --labels to be specified", + ); + } + + if (options.clearLabels && options.labels) { + throw invalidParameterError( + "--clear-labels", + "cannot be used with --labels", + ); + } + + if (options.clearLabels && options.labelMode) { + throw invalidParameterError( + "--clear-labels", + "cannot be used with --label-mode", + ); + } + + if ( + options.labelMode && + !["add", "remove", "overwrite"].includes(options.labelMode) + ) { + throw invalidParameterError( + "--label-mode", + "must be one of 'add', 'remove', or 'overwrite'", + ); + } + const projectId = await resolveProjectId(ctx.sdk, project); + const needsLabelContext = + options.labels && + (options.labelMode === "add" || options.labelMode === "remove"); + const projectContext = needsLabelContext + ? await getProject(ctx.gql, projectId) + : undefined; const input: ProjectUpdateInput = {}; @@ -800,12 +841,30 @@ export function setupProjectsCommands(program: Command): void { ); } - if (options.labels) { + if (options.clearLabels) { + input.labelIds = []; + } else if (options.labels) { const labelNames = options.labels .split(",") .map((l) => l.trim()) .filter(Boolean); - input.labelIds = await resolveProjectLabelIds(ctx.sdk, labelNames); + const labelIds = await resolveProjectLabelIds(ctx.sdk, labelNames); + + if (options.labelMode === "add") { + const currentLabels = projectContext?.labels?.nodes + ? projectContext.labels.nodes.map((l) => l.id) + : []; + input.labelIds = [...new Set([...currentLabels, ...labelIds])]; + } else if (options.labelMode === "remove") { + const currentLabels = projectContext?.labels?.nodes + ? projectContext.labels.nodes.map((l) => l.id) + : []; + input.labelIds = currentLabels.filter( + (id) => !labelIds.includes(id), + ); + } else { + input.labelIds = labelIds; + } } if (Object.keys(input).length === 0) { diff --git a/tests/unit/commands/issues.test.ts b/tests/unit/commands/issues.test.ts index 1c0f39ed..bd4b5cae 100644 --- a/tests/unit/commands/issues.test.ts +++ b/tests/unit/commands/issues.test.ts @@ -209,6 +209,7 @@ import { resolveIssueEstimateContext, resolveIssueId, } from "../../../src/resolvers/issue-resolver.js"; +import { resolveLabelIds } from "../../../src/resolvers/label-resolver.js"; import { resolveTeamEstimateContext, resolveTeamId, @@ -1947,6 +1948,65 @@ describe("issues create relations", () => { }); }); +describe("issues update --labels", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + }); + + it("removes selected labels without clearing all labels", async () => { + vi.mocked(getIssue).mockResolvedValueOnce({ + id: "resolved-issue-uuid", + team: { id: "team-uuid", key: "ENG" }, + labels: { + nodes: [{ id: "keep-label-uuid" }, { id: "resolved-label-uuid" }], + }, + } as Awaited<ReturnType<typeof getIssue>>); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "update", + "ENG-123", + "--labels", + "bug", + "--label-mode", + "remove", + ]); + + expect(resolveLabelIds).toHaveBeenCalledWith(expect.anything(), ["bug"]); + expect(updateIssue).toHaveBeenCalledWith( + expect.anything(), + "resolved-issue-uuid", + expect.objectContaining({ labelIds: ["keep-label-uuid"] }), + ); + }); + + it("rejects invalid issue label mode", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "update", + "ENG-123", + "--labels", + "bug", + "--label-mode", + "append", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("must be one of 'add', 'remove', or 'overwrite'"), + ); + expect(updateIssue).not.toHaveBeenCalled(); + }); +}); + describe("issues update relations", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/tests/unit/commands/projects.test.ts b/tests/unit/commands/projects.test.ts index 6f35c93c..da2148b2 100644 --- a/tests/unit/commands/projects.test.ts +++ b/tests/unit/commands/projects.test.ts @@ -113,7 +113,10 @@ vi.mock("../../../src/services/discussion-service.js", () => ({ import { setupProjectsCommands } from "../../../src/commands/projects.js"; import { outputSuccess } from "../../../src/common/output.js"; -import { resolveProjectId } from "../../../src/resolvers/project-resolver.js"; +import { + resolveProjectId, + resolveProjectLabelIds, +} from "../../../src/resolvers/project-resolver.js"; import { resolveTeamId } from "../../../src/resolvers/team-resolver.js"; import { createDiscussionCommentReaction, @@ -1052,4 +1055,105 @@ describe("projects update", () => { expect.objectContaining({ name: "New Name" }), ); }); + + it("adds labels without dropping existing project labels", async () => { + vi.mocked(getProject).mockResolvedValueOnce({ + id: "proj-1", + labels: { nodes: [{ id: "existing-label-uuid" }] }, + } as Awaited<ReturnType<typeof getProject>>); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "update", + "My Project", + "--labels", + "Q3", + "--label-mode", + "add", + ]); + + expect(getProject).toHaveBeenCalledWith( + expect.anything(), + "resolved-project-uuid", + ); + expect(resolveProjectLabelIds).toHaveBeenCalledWith(expect.anything(), [ + "Q3", + ]); + expect(updateProject).toHaveBeenCalledWith( + expect.anything(), + "resolved-project-uuid", + expect.objectContaining({ + labelIds: ["existing-label-uuid", "resolved-label-uuid"], + }), + ); + }); + + it("removes selected project labels without clearing all labels", async () => { + vi.mocked(getProject).mockResolvedValueOnce({ + id: "proj-1", + labels: { + nodes: [{ id: "keep-label-uuid" }, { id: "resolved-label-uuid" }], + }, + } as Awaited<ReturnType<typeof getProject>>); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "update", + "My Project", + "--labels", + "Q3", + "--label-mode", + "remove", + ]); + + expect(updateProject).toHaveBeenCalledWith( + expect.anything(), + "resolved-project-uuid", + expect.objectContaining({ labelIds: ["keep-label-uuid"] }), + ); + }); + + it("clears all project labels", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "update", + "My Project", + "--clear-labels", + ]); + + expect(updateProject).toHaveBeenCalledWith( + expect.anything(), + "resolved-project-uuid", + expect.objectContaining({ labelIds: [] }), + ); + }); + + it("rejects invalid project label mode", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "update", + "My Project", + "--labels", + "Q3", + "--label-mode", + "append", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("must be one of 'add', 'remove', or 'overwrite'"), + ); + expect(updateProject).not.toHaveBeenCalled(); + }); }); From 13098a708e178516d744f560179f9659304b769f Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:04:20 +0200 Subject: [PATCH 43/79] refactor(labels): route label-mode through centralized parseLabelMode The label removal feature landed with inline --label-mode validation, but next centralized label-mode parsing in common/domain-values. Extend the shared LabelMode/parseLabelMode with the 'remove' mode and have the issues and projects update commands consume it instead of duplicating validation. Part of #117 --- src/commands/issues.ts | 17 +++++------------ src/commands/projects.ts | 19 +++++-------------- src/common/domain-values.ts | 7 ++++--- tests/unit/common/domain-values.test.ts | 5 +++-- 4 files changed, 17 insertions(+), 31 deletions(-) diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 649f794e..838f05fa 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, getRootOpts } from "../common/context.js"; +import { parseLabelMode } from "../common/domain-values.js"; import { resolveReactionEmojiInput } from "../common/emoji.js"; import { invalidParameterError } from "../common/errors.js"; import { validateEstimateAgainstTeamConfig } from "../common/estimate-validation.js"; @@ -1342,14 +1343,7 @@ export function setupIssuesCommands(program: Command): void { throw new Error("--clear-labels cannot be used with --label-mode"); } - if ( - options.labelMode && - !["add", "remove", "overwrite"].includes(options.labelMode) - ) { - throw new Error( - "--label-mode must be one of 'add', 'remove', or 'overwrite'", - ); - } + const labelMode = parseLabelMode(options.labelMode); const parsedPriority = options.priority !== undefined @@ -1388,8 +1382,7 @@ export function setupIssuesCommands(program: Command): void { options.status || options.projectMilestone || options.cycle || - (options.labels && - (options.labelMode === "add" || options.labelMode === "remove")); + (options.labels && (labelMode === "add" || labelMode === "remove")); const issueContext = needsContext ? await getIssue(ctx.gql, resolvedIssueId) : undefined; @@ -1440,7 +1433,7 @@ export function setupIssuesCommands(program: Command): void { const labelNames = options.labels.split(",").map((l) => l.trim()); const labelIds = await resolveLabelIds(ctx.sdk, labelNames); - if (options.labelMode === "add") { + if (labelMode === "add") { const currentLabels = issueContext && "labels" in issueContext && @@ -1448,7 +1441,7 @@ export function setupIssuesCommands(program: Command): void { ? issueContext.labels.nodes.map((l) => l.id) : []; input.labelIds = [...new Set([...currentLabels, ...labelIds])]; - } else if (options.labelMode === "remove") { + } else if (labelMode === "remove") { const currentLabels = issueContext && "labels" in issueContext && diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 5fac3f55..dbc3a4b8 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; import { createContext, getRootOpts } from "../common/context.js"; -import type { Priority } from "../common/domain-values.js"; +import { type Priority, parseLabelMode } from "../common/domain-values.js"; import { resolveReactionEmojiInput } from "../common/emoji.js"; import { invalidParameterError } from "../common/errors.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; @@ -755,20 +755,11 @@ export function setupProjectsCommands(program: Command): void { ); } - if ( - options.labelMode && - !["add", "remove", "overwrite"].includes(options.labelMode) - ) { - throw invalidParameterError( - "--label-mode", - "must be one of 'add', 'remove', or 'overwrite'", - ); - } + const labelMode = parseLabelMode(options.labelMode); const projectId = await resolveProjectId(ctx.sdk, project); const needsLabelContext = - options.labels && - (options.labelMode === "add" || options.labelMode === "remove"); + options.labels && (labelMode === "add" || labelMode === "remove"); const projectContext = needsLabelContext ? await getProject(ctx.gql, projectId) : undefined; @@ -850,12 +841,12 @@ export function setupProjectsCommands(program: Command): void { .filter(Boolean); const labelIds = await resolveProjectLabelIds(ctx.sdk, labelNames); - if (options.labelMode === "add") { + if (labelMode === "add") { const currentLabels = projectContext?.labels?.nodes ? projectContext.labels.nodes.map((l) => l.id) : []; input.labelIds = [...new Set([...currentLabels, ...labelIds])]; - } else if (options.labelMode === "remove") { + } else if (labelMode === "remove") { const currentLabels = projectContext?.labels?.nodes ? projectContext.labels.nodes.map((l) => l.id) : []; diff --git a/src/common/domain-values.ts b/src/common/domain-values.ts index 4d33e60d..0bd28bc9 100644 --- a/src/common/domain-values.ts +++ b/src/common/domain-values.ts @@ -4,15 +4,16 @@ import { invalidParameterError } from "./errors.js"; export type Priority = 0 | 1 | 2 | 3 | 4; /** How `issues update --labels` combines with existing labels. */ -export type LabelMode = "add" | "overwrite"; +export type LabelMode = "add" | "remove" | "overwrite"; export function parseLabelMode( value: string | undefined, ): LabelMode | undefined { if (value === undefined) return undefined; - if (value === "add" || value === "overwrite") return value; + if (value === "add" || value === "remove" || value === "overwrite") + return value; throw invalidParameterError( "--label-mode", - "must be either 'add' or 'overwrite'", + "must be one of 'add', 'remove', or 'overwrite'", ); } diff --git a/tests/unit/common/domain-values.test.ts b/tests/unit/common/domain-values.test.ts index 72010b23..c802505f 100644 --- a/tests/unit/common/domain-values.test.ts +++ b/tests/unit/common/domain-values.test.ts @@ -8,15 +8,16 @@ describe("parseLabelMode", () => { it("returns the narrowed mode for valid values", () => { expect(parseLabelMode("add")).toBe("add"); + expect(parseLabelMode("remove")).toBe("remove"); expect(parseLabelMode("overwrite")).toBe("overwrite"); }); it("throws for invalid values", () => { expect(() => parseLabelMode("replace")).toThrow( - "Invalid --label-mode: must be either 'add' or 'overwrite'", + "Invalid --label-mode: must be one of 'add', 'remove', or 'overwrite'", ); expect(() => parseLabelMode("")).toThrow( - "Invalid --label-mode: must be either 'add' or 'overwrite'", + "Invalid --label-mode: must be one of 'add', 'remove', or 'overwrite'", ); }); }); From 5590498b83e10203cb501daa578018367b95abf1 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:04:51 +0200 Subject: [PATCH 44/79] refactor(labels): infer GraphQL result typing for label operations next reworked GraphQLClient.request() to infer the result and variables from the TypedDocumentNode, so explicit single type arguments no longer type-check. Drop them from the issue-label CRUD service functions and let the document drive the types, matching the rest of the services. Part of #117 --- src/services/label-service.ts | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/src/services/label-service.ts b/src/services/label-service.ts index 8829b3c6..73437dbd 100644 --- a/src/services/label-service.ts +++ b/src/services/label-service.ts @@ -2,18 +2,14 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { CreateIssueLabelDocument, - type CreateIssueLabelMutation, DeleteIssueLabelDocument, - type DeleteIssueLabelMutation, GetIssueLabelDocument, - type GetIssueLabelQuery, GetLabelsDocument, GetProjectLabelsDocument, type IssueLabelCreateInput, type IssueLabelFilter, type IssueLabelUpdateInput, UpdateIssueLabelDocument, - type UpdateIssueLabelMutation, } from "../gql/graphql.js"; export type LabelType = "issue" | "project"; @@ -55,12 +51,9 @@ export async function getLabel( client: GraphQLClient, id: string, ): Promise<Label> { - const result = await client.request<GetIssueLabelQuery>( - GetIssueLabelDocument, - { - id, - }, - ); + const result = await client.request(GetIssueLabelDocument, { + id, + }); if (!result.issueLabel) { throw new Error(`Label with ID "${id}" not found`); @@ -73,10 +66,7 @@ export async function createLabel( client: GraphQLClient, input: IssueLabelCreateInput, ): Promise<Label> { - const result = await client.request<CreateIssueLabelMutation>( - CreateIssueLabelDocument, - { input }, - ); + const result = await client.request(CreateIssueLabelDocument, { input }); if (!result.issueLabelCreate.success) { throw new Error(`Failed to create label "${input.name}"`); @@ -90,10 +80,7 @@ export async function updateLabel( id: string, input: IssueLabelUpdateInput, ): Promise<Label> { - const result = await client.request<UpdateIssueLabelMutation>( - UpdateIssueLabelDocument, - { id, input }, - ); + const result = await client.request(UpdateIssueLabelDocument, { id, input }); if (!result.issueLabelUpdate.success) { throw new Error(`Failed to update label "${id}"`); @@ -106,10 +93,7 @@ export async function deleteLabel( client: GraphQLClient, id: string, ): Promise<DeleteLabelResult> { - const result = await client.request<DeleteIssueLabelMutation>( - DeleteIssueLabelDocument, - { id }, - ); + const result = await client.request(DeleteIssueLabelDocument, { id }); if (!result.issueLabelDelete.success) { throw new Error(`Failed to delete label "${id}"`); From 9a2810813753f5f3a3e836f5a02d0ceed2eee433 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:21:30 +0200 Subject: [PATCH 45/79] fix(labels): allow clearing label description with empty string Gate the description field on `!== undefined` so an explicit `labels update <x> --description ""` clears the description instead of being dropped by the truthiness check. --- src/commands/labels.ts | 2 +- tests/unit/commands/labels.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/commands/labels.ts b/src/commands/labels.ts index 5d7d72dc..2099cbb5 100644 --- a/src/commands/labels.ts +++ b/src/commands/labels.ts @@ -125,7 +125,7 @@ function buildUpdateInput(options: UpdateLabelOptions): IssueLabelUpdateInput { input.color = color; } - if (options.description) { + if (options.description !== undefined) { input.description = options.description; } diff --git a/tests/unit/commands/labels.test.ts b/tests/unit/commands/labels.test.ts index 91156681..a3be3aee 100644 --- a/tests/unit/commands/labels.test.ts +++ b/tests/unit/commands/labels.test.ts @@ -367,6 +367,28 @@ describe("labels update", () => { }, ); }); + + it("clears the description when passed an empty string", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "labels", + "update", + "branch:merged", + "--description", + "", + ]); + + expect(updateLabel).toHaveBeenCalledWith( + expect.anything(), + "resolved-label-uuid", + { + description: "", + }, + ); + }); }); describe("labels delete", () => { From df2219ae6f99d009db16acad6909ac5167890490 Mon Sep 17 00:00:00 2001 From: semantic-release-bot <semantic-release-bot@martynus.net> Date: Thu, 2 Jul 2026 22:25:27 +0000 Subject: [PATCH 46/79] chore(release): 2026.6.0-next.8 [skip ci] ## [2026.6.0-next.8](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.7...v2026.6.0-next.8) (2026-07-02) ### Features * **labels:** add issue label CRUD commands ([5fca207](https://github.com/linearis-oss/linearis/commit/5fca207d96e597ef0e755bb0e1eb0135deeae47e)) * **labels:** support label removal modes ([c6cfa62](https://github.com/linearis-oss/linearis/commit/c6cfa627972f825868b8868c50057d7c15e906a6)) ### Bug Fixes * **labels:** allow clearing label description with empty string ([9a28108](https://github.com/linearis-oss/linearis/commit/9a2810813753f5f3a3e836f5a02d0ceed2eee433)) --- CHANGELOG.md | 11 +++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f09fbbb..22bed3fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## [2026.6.0-next.8](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.7...v2026.6.0-next.8) (2026-07-02) + +### Features + +* **labels:** add issue label CRUD commands ([5fca207](https://github.com/linearis-oss/linearis/commit/5fca207d96e597ef0e755bb0e1eb0135deeae47e)) +* **labels:** support label removal modes ([c6cfa62](https://github.com/linearis-oss/linearis/commit/c6cfa627972f825868b8868c50057d7c15e906a6)) + +### Bug Fixes + +* **labels:** allow clearing label description with empty string ([9a28108](https://github.com/linearis-oss/linearis/commit/9a2810813753f5f3a3e836f5a02d0ceed2eee433)) + ## [2026.6.0-next.7](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.6...v2026.6.0-next.7) (2026-07-02) ### Features diff --git a/package-lock.json b/package-lock.json index a74dfb42..975de203 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linearis", - "version": "2026.6.0-next.7", + "version": "2026.6.0-next.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.6.0-next.7", + "version": "2026.6.0-next.8", "license": "MIT", "dependencies": { "@linear/sdk": "82.1.0", diff --git a/package.json b/package.json index ffc4e542..963657c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.6.0-next.7", + "version": "2026.6.0-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 1470fed1d61b6edbbaddf3edf45f6d4f20ec19ca Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 08:23:38 +0200 Subject: [PATCH 47/79] refactor(output): enforce JSON-serializable output at compile time Introduce a JsonSerializable<T> constraint type in src/common/json.ts and apply it to outputSuccess, so non-JSON values (functions, bigint, symbol) are rejected at the output boundary at compile time while still tolerating optional undefined props and opaque Record<string, unknown> JSON blobs. Closes #202 --- src/common/json.ts | 55 ++++++++++++++++++++++++++++++++ src/common/output.ts | 3 +- tests/unit/common/output.test.ts | 21 ++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 src/common/json.ts diff --git a/src/common/json.ts b/src/common/json.ts new file mode 100644 index 00000000..4b75380c --- /dev/null +++ b/src/common/json.ts @@ -0,0 +1,55 @@ +/** + * JSON value types encoding Linearis' "JSON-only output" contract at the type + * level. Anything reaching {@link ./output.outputSuccess} must serialize to + * JSON without data loss or a runtime error. + */ + +/** A JSON scalar: the leaves of any JSON document. */ +type JsonPrimitive = string | number | boolean | null; + +/** + * A value that serializes to JSON losslessly — no functions, `undefined`, + * symbols, `bigint`, or class instances with behaviour. This is the strict + * contract the output boundary ultimately targets. It is not consumed directly + * yet: functions and other non-JSON values vacuously satisfy its index + * signature, so {@link JsonSerializable} does the real enforcement. Kept as the + * documented target for issue #202's path to strict compile-time enforcement. + * + * @public exported as the project-level JSON contract type; not consumed + * internally yet (see above), so it is tagged to document intent. + */ +export type JsonValue = + | JsonPrimitive + | { readonly [key: string]: JsonValue } + | readonly JsonValue[]; + +/** + * Transitional constraint for the output boundary. It is meaningfully stricter + * than `unknown` — it rejects the values that break `JSON.stringify` or lose + * data (functions, `symbol`, `bigint`) at every position — while tolerating the + * shapes today's generated GraphQL result types legitimately produce: + * + * - optional (`field?:`) properties that widen to `undefined` (dropped by + * `JSON.stringify`); + * - opaque `Record<string, unknown>` / `unknown` JSON blobs (e.g. attachment + * `metadata`) that TypeScript cannot prove are pure JSON but which the + * Linear API only ever populates with parsed JSON; + * - named `interface`/type shapes that lack an implicit index signature and so + * are not structurally assignable to {@link JsonValue}. + * + * Each object is validated member-by-member, so nominal result types are + * accepted without per-call-site casts. See issue #202 for the path to + * requiring {@link JsonValue} directly once the generated types are narrowed. + */ +export type JsonSerializable<T> = T extends (...args: never[]) => unknown + ? never + : T extends bigint | symbol + ? never + : T extends JsonPrimitive | undefined + ? T + : T extends readonly (infer U)[] + ? readonly JsonSerializable<U>[] + : T extends object + ? { [K in keyof T]: JsonSerializable<T[K]> } + : // `unknown`/`any` opaque values fall through, tolerated transitionally + T; diff --git a/src/common/output.ts b/src/common/output.ts index 0737ce01..3c061390 100644 --- a/src/common/output.ts +++ b/src/common/output.ts @@ -4,6 +4,7 @@ import { AuthenticationError, invalidParameterError, } from "./errors.js"; +import type { JsonSerializable } from "./json.js"; // Derived from CommandOptions so the two can never drift; `fields` holds raw // dot-paths, e.g. ["identifier", "state.name"]. @@ -64,7 +65,7 @@ export function pickFields(value: unknown, paths: string[][]): unknown { return out; } -export function outputSuccess(data: unknown): void { +export function outputSuccess<T>(data: JsonSerializable<T>): void { const { compact, fields } = currentOutputOptions; const shaped = fields && fields.length > 0 diff --git a/tests/unit/common/output.test.ts b/tests/unit/common/output.test.ts index a5c56d9e..e6710bce 100644 --- a/tests/unit/common/output.test.ts +++ b/tests/unit/common/output.test.ts @@ -50,6 +50,27 @@ describe("outputSuccess", () => { spy.mockRestore(); }); + it("drops undefined properties from optional fields", () => { + // Generated GraphQL results widen optional (`field?:`) props to + // `undefined`; JSON.stringify drops them, so the output stays valid JSON. + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + outputSuccess({ id: "123", editedAt: undefined }); + expect(spy).toHaveBeenCalledWith(JSON.stringify({ id: "123" }, null, 2)); + spy.mockRestore(); + }); + + it("serializes opaque Record<string, unknown> metadata as JSON", () => { + // Attachment metadata is an opaque JSON blob the type layer tolerates; it + // must still round-trip through the output boundary unchanged. + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + const metadata: Record<string, unknown> = { size: 42, nested: { a: [1] } }; + outputSuccess({ id: "123", metadata }); + expect(spy).toHaveBeenCalledWith( + JSON.stringify({ id: "123", metadata }, null, 2), + ); + spy.mockRestore(); + }); + it("combines compact and fields (issue example)", () => { setOutputOptions({ compact: true, From d94b8af378662aba38833c17feb413e62cc948e4 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 08:43:27 +0200 Subject: [PATCH 48/79] refactor(commands): centralize Commander action typing via commandAction Replace the per-command `handleCommand(async (...args: unknown[]) => { const [...] = args as [...] })` boilerplate with a typed `commandAction<TArgs>` wrapper. The argument tuple is declared once as a generic parameter and the handler receives typed positional arguments, while `handleCommand` remains the single error wrapper. No behavior change. Closes #200 --- src/commands/initiatives/entity.ts | 757 ++++++++-------- src/commands/issues.ts | 1314 ++++++++++++++-------------- src/commands/projects.ts | 931 ++++++++++---------- src/common/output.ts | 32 + 4 files changed, 1484 insertions(+), 1550 deletions(-) diff --git a/src/commands/initiatives/entity.ts b/src/commands/initiatives/entity.ts index 00dc3380..4d383e62 100644 --- a/src/commands/initiatives/entity.ts +++ b/src/commands/initiatives/entity.ts @@ -4,7 +4,7 @@ import { createContext, getRootOpts } from "../../common/context.js"; import { resolveReactionEmojiInput } from "../../common/emoji.js"; import { invalidParameterError } from "../../common/errors.js"; import { - handleCommand, + commandAction, outputSuccess, parseLimit, } from "../../common/output.js"; @@ -114,22 +114,18 @@ function addCommentReactionCommands( .description(`add a reaction to a discussion ${noun}`) .option("--shortcode <name>", "emoji shortcode (e.g. thumbs_up)") .action( - handleCommand(async (...args: unknown[]) => { - const [commentId, emoji, options, command] = args as [ - string, - string | undefined, - ReactionOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const result = await createDiscussionCommentReaction(ctx.gql, { - commentId, - target: noun, - expectedEntityKind: "initiative", - emoji: resolveReactionEmojiInput(emoji, options.shortcode), - }); - outputSuccess(result); - }), + commandAction<[string, string | undefined, ReactionOptions, Command]>( + async (commentId, emoji, options, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await createDiscussionCommentReaction(ctx.gql, { + commentId, + target: noun, + expectedEntityKind: "initiative", + emoji: resolveReactionEmojiInput(emoji, options.shortcode), + }); + outputSuccess(result); + }, + ), ); parent @@ -137,22 +133,18 @@ function addCommentReactionCommands( .description(`remove your reaction from a discussion ${noun} by emoji`) .option("--shortcode <name>", "emoji shortcode (e.g. thumbs_up)") .action( - handleCommand(async (...args: unknown[]) => { - const [commentId, emoji, options, command] = args as [ - string, - string | undefined, - ReactionOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { - commentId, - target: noun, - expectedEntityKind: "initiative", - emoji: resolveReactionEmojiInput(emoji, options.shortcode), - }); - outputSuccess(result); - }), + commandAction<[string, string | undefined, ReactionOptions, Command]>( + async (commentId, emoji, options, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { + commentId, + target: noun, + expectedEntityKind: "initiative", + emoji: resolveReactionEmojiInput(emoji, options.shortcode), + }); + outputSuccess(result); + }, + ), ); parent @@ -161,22 +153,18 @@ function addCommentReactionCommands( `remove your reaction from a discussion ${noun} by reaction ID`, ) .action( - handleCommand(async (...args: unknown[]) => { - const [commentId, reactionId, , command] = args as [ - string, - string, - unknown, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionCommentReactionById(ctx.gql, { - commentId, - target: noun, - expectedEntityKind: "initiative", - reactionId, - }); - outputSuccess(result); - }), + commandAction<[string, string, unknown, Command]>( + async (commentId, reactionId, _unused2, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await deleteDiscussionCommentReactionById(ctx.gql, { + commentId, + target: noun, + expectedEntityKind: "initiative", + reactionId, + }); + outputSuccess(result); + }, + ), ); } @@ -484,44 +472,45 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .option("--with-history", "include history in list output") .option("--with-documents", "include documents in list output") .action( - handleCommand(async (...args: unknown[]) => { - const [options, command] = args as [InitiativeListOptions, Command]; - const ctx = createContext(getRootOpts(command)); - - const sortOrder = parseSortOrder(options.sortOrder); - const sortBy = parseSortBy(options.sortBy); - - const expandFlags = getExpandFlags(options); - if (expandFlags.length > 0) { - throw invalidParameterError( - "expand flags", - `${expandFlags.join(", ")} are not supported for initiatives list yet`, - ); - } + commandAction<[InitiativeListOptions, Command]>( + async (options, command) => { + const ctx = createContext(getRootOpts(command)); + + const sortOrder = parseSortOrder(options.sortOrder); + const sortBy = parseSortBy(options.sortBy); + + const expandFlags = getExpandFlags(options); + if (expandFlags.length > 0) { + throw invalidParameterError( + "expand flags", + `${expandFlags.join(", ")} are not supported for initiatives list yet`, + ); + } - if (sortOrder && !sortBy) { - throw invalidParameterError( - "--sort-order", - "requires --sort-by to be specified", - ); - } + if (sortOrder && !sortBy) { + throw invalidParameterError( + "--sort-order", + "requires --sort-by to be specified", + ); + } - const orderBy = mapSortByToPaginationOrderBy(sortBy); - const sort = mapSortByToInitiativeSort(sortBy, sortOrder); + const orderBy = mapSortByToPaginationOrderBy(sortBy); + const sort = mapSortByToInitiativeSort(sortBy, sortOrder); - const filter = await buildInitiativeFilter(ctx.sdk, options); + const filter = await buildInitiativeFilter(ctx.sdk, options); - const result = await listInitiatives(ctx.gql, { - limit: parseLimit(options.limit), - after: options.after, - includeArchived: options.includeArchived ?? false, - filter, - orderBy, - sort, - }); + const result = await listInitiatives(ctx.gql, { + limit: parseLimit(options.limit), + after: options.after, + includeArchived: options.includeArchived ?? false, + filter, + orderBy, + sort, + }); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); initiatives @@ -541,22 +530,19 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .option("--with-history", "include history in read output") .option("--with-documents", "include documents in read output") .action( - handleCommand(async (...args: unknown[]) => { - const [initiative, options, command] = args as [ - string, - InitiativeReadOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); - - // Read query already returns expanded fields. Keep flags accepted for - // CLI contract compatibility until conditional field selection is added. - void getExpandFlags(options); - - const result = await getInitiative(ctx.gql, initiativeId); - outputSuccess(result); - }), + commandAction<[string, InitiativeReadOptions, Command]>( + async (initiative, options, command) => { + const ctx = createContext(getRootOpts(command)); + const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); + + // Read query already returns expanded fields. Keep flags accepted for + // CLI contract compatibility until conditional field selection is added. + void getExpandFlags(options); + + const result = await getInitiative(ctx.gql, initiativeId); + outputSuccess(result); + }, + ), ); initiatives @@ -564,26 +550,23 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .description("start a discussion thread on an initiative") .option("--body <text>", "discussion body (required, markdown supported)") .action( - handleCommand(async (...args: unknown[]) => { - const [initiative, options, command] = args as [ - string, - DiscussionBodyOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } - - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); - const result = await startInitiativeDiscussion(ctx.gql, { - initiativeId, - body: options.body, - }); - - outputSuccess(result); - }), + commandAction<[string, DiscussionBodyOptions, Command]>( + async (initiative, options, command) => { + const ctx = createContext(getRootOpts(command)); + + if (!options.body) { + throw invalidParameterError("--body", "is required"); + } + + const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); + const result = await startInitiativeDiscussion(ctx.gql, { + initiativeId, + body: options.body, + }); + + outputSuccess(result); + }, + ), ); initiatives @@ -593,33 +576,30 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .option("--after <cursor>", "cursor for next page") .option("--with-reactions", "include normalized discussion reactions") .action( - handleCommand(async (...args: unknown[]) => { - const [initiative, options, command] = args as [ - string, - DiscussionsOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); - const paginationOptions = { - limit: parseLimit(options.limit || "25"), - after: options.after, - }; - const result = options.withReactions - ? await listDiscussionsForInitiativeWithReactions( - ctx.gql, - initiativeId, - paginationOptions, - ) - : await listDiscussionsForInitiative( - ctx.gql, - initiativeId, - paginationOptions, - ); - - outputSuccess(result); - }), + commandAction<[string, DiscussionsOptions, Command]>( + async (initiative, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); + const paginationOptions = { + limit: parseLimit(options.limit || "25"), + after: options.after, + }; + const result = options.withReactions + ? await listDiscussionsForInitiativeWithReactions( + ctx.gql, + initiativeId, + paginationOptions, + ) + : await listDiscussionsForInitiative( + ctx.gql, + initiativeId, + paginationOptions, + ); + + outputSuccess(result); + }, + ), ); const initiativeThreads = initiatives @@ -634,34 +614,31 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .option("--after <cursor>", "cursor for next page") .option("--with-reactions", "include normalized discussion reactions") .action( - handleCommand(async (...args: unknown[]) => { - const [thread, options, command] = args as [ - string, - DiscussionsOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - const paginationOptions = { - limit: parseLimit(options.limit || "50"), - after: options.after, - }; - const result = options.withReactions - ? await listDiscussionRepliesWithReactions( - ctx.gql, - thread, - paginationOptions, - "initiative", - ) - : await listDiscussionReplies( - ctx.gql, - thread, - paginationOptions, - "initiative", - ); - - outputSuccess(result); - }), + commandAction<[string, DiscussionsOptions, Command]>( + async (thread, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const paginationOptions = { + limit: parseLimit(options.limit || "50"), + after: options.after, + }; + const result = options.withReactions + ? await listDiscussionRepliesWithReactions( + ctx.gql, + thread, + paginationOptions, + "initiative", + ) + : await listDiscussionReplies( + ctx.gql, + thread, + paginationOptions, + "initiative", + ); + + outputSuccess(result); + }, + ), ); addCommentReactionCommands(initiativeReplies, "reply"); @@ -674,26 +651,23 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { ) .option("--body <text>", "reply body (required, markdown supported)") .action( - handleCommand(async (...args: unknown[]) => { - const [thread, options, command] = args as [ - string, - DiscussionBodyOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } - - const result = await replyToDiscussion(ctx.gql, { - threadId: thread, - body: options.body, - entityKind: "initiative", - }); - - outputSuccess(result); - }), + commandAction<[string, DiscussionBodyOptions, Command]>( + async (thread, options, command) => { + const ctx = createContext(getRootOpts(command)); + + if (!options.body) { + throw invalidParameterError("--body", "is required"); + } + + const result = await replyToDiscussion(ctx.gql, { + threadId: thread, + body: options.body, + entityKind: "initiative", + }); + + outputSuccess(result); + }, + ), ); initiatives @@ -701,29 +675,26 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .description("edit a root discussion or reply comment") .option("--body <text>", "new comment body (required, markdown supported)") .action( - handleCommand(async (...args: unknown[]) => { - const [comment, options, command] = args as [ - string, - DiscussionBodyOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } - - const result = await editDiscussionComment( - ctx.gql, - comment, - { - body: options.body, - }, - "initiative", - ); + commandAction<[string, DiscussionBodyOptions, Command]>( + async (comment, options, command) => { + const ctx = createContext(getRootOpts(command)); + + if (!options.body) { + throw invalidParameterError("--body", "is required"); + } + + const result = await editDiscussionComment( + ctx.gql, + comment, + { + body: options.body, + }, + "initiative", + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); initiatives @@ -731,65 +702,64 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .description("edit a discussion reply") .option("--body <text>", "new reply body (required, markdown supported)") .action( - handleCommand(async (...args: unknown[]) => { - const [reply, options, command] = args as [ - string, - DiscussionBodyOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } - - const result = await editDiscussionReply( - ctx.gql, - reply, - { - body: options.body, - }, - "initiative", - ); + commandAction<[string, DiscussionBodyOptions, Command]>( + async (reply, options, command) => { + const ctx = createContext(getRootOpts(command)); + + if (!options.body) { + throw invalidParameterError("--body", "is required"); + } + + const result = await editDiscussionReply( + ctx.gql, + reply, + { + body: options.body, + }, + "initiative", + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); initiatives .command("delete-comment <comment>") .description("delete a root discussion or reply comment") .action( - handleCommand(async (...args: unknown[]) => { - const [comment, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - - const result = await deleteDiscussionComment( - ctx.gql, - comment, - "initiative", - ); - - outputSuccess(result); - }), + commandAction<[string, unknown, Command]>( + async (comment, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + + const result = await deleteDiscussionComment( + ctx.gql, + comment, + "initiative", + ); + + outputSuccess(result); + }, + ), ); initiatives .command("delete-reply <reply>") .description("delete a discussion reply") .action( - handleCommand(async (...args: unknown[]) => { - const [reply, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - - const result = await deleteDiscussionReply( - ctx.gql, - reply, - "initiative", - ); - - outputSuccess(result); - }), + commandAction<[string, unknown, Command]>( + async (reply, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + + const result = await deleteDiscussionReply( + ctx.gql, + reply, + "initiative", + ); + + outputSuccess(result); + }, + ), ); initiatives @@ -797,36 +767,38 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .description("resolve a discussion thread") .option("--with-comment <comment>", "comment to mark as resolving comment") .action( - handleCommand(async (...args: unknown[]) => { - const [thread, options, command] = args as [ - string, - ResolveDiscussionOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - const result = await resolveDiscussion(ctx.gql, { - threadId: thread, - resolvingCommentId: options.withComment, - entityKind: "initiative", - }); - - outputSuccess(result); - }), + commandAction<[string, ResolveDiscussionOptions, Command]>( + async (thread, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const result = await resolveDiscussion(ctx.gql, { + threadId: 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(getRootOpts(command)); - - const result = await unresolveDiscussion(ctx.gql, thread, "initiative"); + commandAction<[string, unknown, Command]>( + async (thread, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + + const result = await unresolveDiscussion( + ctx.gql, + thread, + "initiative", + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); initiatives @@ -839,45 +811,42 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .option("--target-date <date>", "target date (YYYY-MM-DD)") .option("--sort-order <n>", "display sort order") .action( - handleCommand(async (...args: unknown[]) => { - const [name, options, command] = args as [ - string, - InitiativeCreateOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - const input: InitiativeCreateInput = { name }; - - if (options.description !== undefined) { - input.description = options.description; - } - - if (options.content !== undefined) { - input.content = options.content; - } - - if (options.owner) { - input.ownerId = await resolveUserId(ctx.sdk, options.owner); - } - - const status = parseInitiativeStatus(options.status); - if (status) { - input.status = status; - } - - if (options.targetDate !== undefined) { - input.targetDate = options.targetDate; - } - - const sortOrder = parseSortOrderNumber(options.sortOrder); - if (sortOrder !== undefined) { - input.sortOrder = sortOrder; - } - - const result = await createInitiative(ctx.gql, input); - outputSuccess(result); - }), + commandAction<[string, InitiativeCreateOptions, Command]>( + async (name, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const input: InitiativeCreateInput = { name }; + + if (options.description !== undefined) { + input.description = options.description; + } + + if (options.content !== undefined) { + input.content = options.content; + } + + if (options.owner) { + input.ownerId = await resolveUserId(ctx.sdk, options.owner); + } + + const status = parseInitiativeStatus(options.status); + if (status) { + input.status = status; + } + + if (options.targetDate !== undefined) { + input.targetDate = options.targetDate; + } + + const sortOrder = parseSortOrderNumber(options.sortOrder); + if (sortOrder !== undefined) { + input.sortOrder = sortOrder; + } + + const result = await createInitiative(ctx.gql, input); + outputSuccess(result); + }, + ), ); initiatives @@ -891,95 +860,95 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .option("--target-date <date>", "new target date (YYYY-MM-DD)") .option("--sort-order <n>", "new display sort order") .action( - handleCommand(async (...args: unknown[]) => { - const [initiative, options, command] = args as [ - string, - InitiativeUpdateOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); - - const input: InitiativeUpdateInput = {}; - - if (options.name !== undefined) { - input.name = options.name; - } - - if (options.description !== undefined) { - input.description = options.description; - } - - if (options.content !== undefined) { - input.content = options.content; - } - - if (options.owner) { - input.ownerId = await resolveUserId(ctx.sdk, options.owner); - } - - const status = parseInitiativeStatus(options.status); - if (status) { - input.status = status; - } - - if (options.targetDate !== undefined) { - input.targetDate = options.targetDate; - } - - const sortOrder = parseSortOrderNumber(options.sortOrder); - if (sortOrder !== undefined) { - input.sortOrder = sortOrder; - } - - if (Object.keys(input).length === 0) { - throw invalidParameterError( - "update options", - "at least one option must be provided", - ); - } + commandAction<[string, InitiativeUpdateOptions, Command]>( + async (initiative, options, command) => { + const ctx = createContext(getRootOpts(command)); + const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); + + const input: InitiativeUpdateInput = {}; + + if (options.name !== undefined) { + input.name = options.name; + } + + if (options.description !== undefined) { + input.description = options.description; + } + + if (options.content !== undefined) { + input.content = options.content; + } + + if (options.owner) { + input.ownerId = await resolveUserId(ctx.sdk, options.owner); + } + + const status = parseInitiativeStatus(options.status); + if (status) { + input.status = status; + } + + if (options.targetDate !== undefined) { + input.targetDate = options.targetDate; + } + + const sortOrder = parseSortOrderNumber(options.sortOrder); + if (sortOrder !== undefined) { + input.sortOrder = sortOrder; + } + + if (Object.keys(input).length === 0) { + throw invalidParameterError( + "update options", + "at least one option must be provided", + ); + } - const result = await updateInitiative(ctx.gql, initiativeId, input); - outputSuccess(result); - }), + const result = await updateInitiative(ctx.gql, initiativeId, input); + outputSuccess(result); + }, + ), ); initiatives .command("archive <initiative>") .description("archive an initiative") .action( - handleCommand(async (...args: unknown[]) => { - const [initiative, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); - const result = await archiveInitiative(ctx.gql, initiativeId); - outputSuccess(result); - }), + commandAction<[string, unknown, Command]>( + async (initiative, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); + const result = await archiveInitiative(ctx.gql, initiativeId); + outputSuccess(result); + }, + ), ); initiatives .command("unarchive <initiative>") .description("unarchive an initiative") .action( - handleCommand(async (...args: unknown[]) => { - const [initiative, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); - const result = await unarchiveInitiative(ctx.gql, initiativeId); - outputSuccess(result); - }), + commandAction<[string, unknown, Command]>( + async (initiative, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); + const result = await unarchiveInitiative(ctx.gql, initiativeId); + outputSuccess(result); + }, + ), ); initiatives .command("delete <initiative>") .description("delete an initiative") .action( - handleCommand(async (...args: unknown[]) => { - const [initiative, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); - const result = await deleteInitiative(ctx.gql, initiativeId); - outputSuccess(result); - }), + commandAction<[string, unknown, Command]>( + async (initiative, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); + const result = await deleteInitiative(ctx.gql, initiativeId); + outputSuccess(result); + }, + ), ); } diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 838f05fa..a5028d00 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -15,7 +15,7 @@ import { parseEstimateOption, parsePriorityOption, } from "../common/number-options.js"; -import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; +import { commandAction, outputSuccess, parseLimit } from "../common/output.js"; import { resolveFilterOptions } from "../common/resolve-filters.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import type { @@ -188,23 +188,19 @@ function addCommentReactionCommands( .description(`add a reaction to a discussion ${noun}`) .option("--shortcode <name>", "emoji shortcode (e.g. thumbs_up)") .action( - handleCommand(async (...args: unknown[]) => { - const [commentId, emoji, options, command] = args as [ - string, - string | undefined, - ReactionOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const result = await createDiscussionCommentReaction(ctx.gql, { - commentId, - target: noun, - expectedEntityKind: "issue", - emoji: resolveReactionEmojiInput(emoji, options.shortcode), - }); + commandAction<[string, string | undefined, ReactionOptions, Command]>( + async (commentId, emoji, options, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await createDiscussionCommentReaction(ctx.gql, { + commentId, + target: noun, + expectedEntityKind: "issue", + emoji: resolveReactionEmojiInput(emoji, options.shortcode), + }); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); parent @@ -212,23 +208,19 @@ function addCommentReactionCommands( .description(`remove your reaction from a discussion ${noun} by emoji`) .option("--shortcode <name>", "emoji shortcode (e.g. thumbs_up)") .action( - handleCommand(async (...args: unknown[]) => { - const [commentId, emoji, options, command] = args as [ - string, - string | undefined, - ReactionOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { - commentId, - target: noun, - expectedEntityKind: "issue", - emoji: resolveReactionEmojiInput(emoji, options.shortcode), - }); + commandAction<[string, string | undefined, ReactionOptions, Command]>( + async (commentId, emoji, options, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { + commentId, + target: noun, + expectedEntityKind: "issue", + emoji: resolveReactionEmojiInput(emoji, options.shortcode), + }); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); parent @@ -237,23 +229,19 @@ function addCommentReactionCommands( `remove your reaction from a discussion ${noun} by reaction ID`, ) .action( - handleCommand(async (...args: unknown[]) => { - const [commentId, reactionId, , command] = args as [ - string, - string, - unknown, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionCommentReactionById(ctx.gql, { - commentId, - target: noun, - expectedEntityKind: "issue", - reactionId, - }); + commandAction<[string, string, unknown, Command]>( + async (commentId, reactionId, _unused2, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await deleteDiscussionCommentReactionById(ctx.gql, { + commentId, + target: noun, + expectedEntityKind: "issue", + reactionId, + }); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); } @@ -542,14 +530,15 @@ export function setupIssuesCommands(program: Command): void { .command("list <issue>") .description("list relations for an issue") .action( - handleCommand(async (...args: unknown[]) => { - const [issue, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); - const result = await listIssueRelations(ctx.gql, issueId); + commandAction<[string, unknown, Command]>( + async (issue, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const issueId = await resolveIssueId(ctx.sdk, issue); + const result = await listIssueRelations(ctx.gql, issueId); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); relations @@ -563,44 +552,42 @@ export function setupIssuesCommands(program: Command): void { ) .option("--similar <issues>", "similar issues (comma-separated)") .action( - handleCommand(async (...args: unknown[]) => { - const [issue, options, command] = args as [ - string, - RelationAddOptions, - Command, - ]; - const relation = parseRelationAddOptions(options); - const ctx = createContext(getRootOpts(command)); - const sourceIssueId = await resolveIssueId(ctx.sdk, issue); - const targetIds = await Promise.all( - relation.targets.map((target) => resolveIssueId(ctx.sdk, target)), - ); + commandAction<[string, RelationAddOptions, Command]>( + async (issue, options, command) => { + const relation = parseRelationAddOptions(options); + const ctx = createContext(getRootOpts(command)); + const sourceIssueId = await resolveIssueId(ctx.sdk, issue); + const targetIds = await Promise.all( + relation.targets.map((target) => resolveIssueId(ctx.sdk, target)), + ); - const created = await Promise.all( - targetIds.map((targetId) => - createIssueRelation(ctx.gql, { - issueId: sourceIssueId, - relatedIssueId: targetId, - type: relation.type, - }), - ), - ); + const created = await Promise.all( + targetIds.map((targetId) => + createIssueRelation(ctx.gql, { + issueId: sourceIssueId, + relatedIssueId: targetId, + type: relation.type, + }), + ), + ); - outputSuccess(created); - }), + outputSuccess(created); + }, + ), ); relations .command("remove <relation>") .description("remove a relation by UUID") .action( - handleCommand(async (...args: unknown[]) => { - const [relation, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - const result = await deleteIssueRelation(ctx.gql, relation); + commandAction<[string, unknown, Command]>( + async (relation, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await deleteIssueRelation(ctx.gql, relation); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); addFilterOptions( @@ -611,8 +598,7 @@ export function setupIssuesCommands(program: Command): void { .option("-l, --limit <n>", "max results", "50") .option("--after <cursor>", "cursor for next page"), ).action( - handleCommand(async (...args: unknown[]) => { - const [options, command] = args as [FilterOptions, Command]; + commandAction<[FilterOptions, Command]>(async (options, command) => { const ctx = createContext(getRootOpts(command)); const paginationOptions = { @@ -646,29 +632,26 @@ export function setupIssuesCommands(program: Command): void { .option("-l, --limit <n>", "max results", "50") .option("--after <cursor>", "cursor for next page"), ).action( - handleCommand(async (...args: unknown[]) => { - const [query, options, command] = args as [ - string, - FilterOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, FilterOptions, Command]>( + async (query, options, command) => { + const ctx = createContext(getRootOpts(command)); - const paginationOptions = { - limit: parseLimit(options.limit), - after: options.after, - }; + const paginationOptions = { + limit: parseLimit(options.limit), + after: options.after, + }; - const filterOptions = await resolveFilterOptions(ctx, options); - const filter = buildIssueFilter(filterOptions); - const result = await searchIssues( - ctx.gql, - query, - paginationOptions, - filter, - ); - outputSuccess(result); - }), + const filterOptions = await resolveFilterOptions(ctx, options); + const filter = buildIssueFilter(filterOptions); + const result = await searchIssues( + ctx.gql, + query, + paginationOptions, + filter, + ); + outputSuccess(result); + }, + ), ); issues @@ -686,92 +669,89 @@ export function setupIssuesCommands(program: Command): void { `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, ) .action( - handleCommand(async (...args: unknown[]) => { - const [issue, options, command] = args as [ - string, - ReadOptions, - Command, - ]; - validateReadOptions(options); - const ctx = createContext(getRootOpts(command)); + commandAction<[string, ReadOptions, Command]>( + async (issue, options, command) => { + validateReadOptions(options); + const ctx = createContext(getRootOpts(command)); + + if (options.withAttachments) { + if (isUuid(issue)) { + const result = await getIssueWithAttachments(ctx.gql, issue); + outputSuccess(result); + } else { + const { teamKey, issueNumber } = parseIssueIdentifier(issue); + const result = await getIssueByIdentifierWithAttachments( + ctx.gql, + teamKey, + issueNumber, + ); + outputSuccess(result); + } + return; + } - if (options.withAttachments) { - if (isUuid(issue)) { - const result = await getIssueWithAttachments(ctx.gql, issue); - outputSuccess(result); - } else { - const { teamKey, issueNumber } = parseIssueIdentifier(issue); - const result = await getIssueByIdentifierWithAttachments( - ctx.gql, - teamKey, - issueNumber, - ); - outputSuccess(result); + if (options.withCommentThreads) { + if (isUuid(issue)) { + const result = await getIssueWithCommentThreads(ctx.gql, issue); + outputSuccess(result); + } else { + const { teamKey, issueNumber } = parseIssueIdentifier(issue); + const result = await getIssueByIdentifierWithCommentThreads( + ctx.gql, + teamKey, + issueNumber, + ); + outputSuccess(result); + } + return; } - return; - } - if (options.withCommentThreads) { - if (isUuid(issue)) { - const result = await getIssueWithCommentThreads(ctx.gql, issue); - outputSuccess(result); - } else { - const { teamKey, issueNumber } = parseIssueIdentifier(issue); - const result = await getIssueByIdentifierWithCommentThreads( - ctx.gql, - teamKey, - issueNumber, - ); - outputSuccess(result); + if (options.withComments) { + if (isUuid(issue)) { + const result = await getIssueWithComments(ctx.gql, issue); + outputSuccess(result); + } else { + const { teamKey, issueNumber } = parseIssueIdentifier(issue); + const result = await getIssueByIdentifierWithComments( + ctx.gql, + teamKey, + issueNumber, + ); + outputSuccess(result); + } + return; } - return; - } - if (options.withComments) { - if (isUuid(issue)) { - const result = await getIssueWithComments(ctx.gql, issue); - outputSuccess(result); - } else { - const { teamKey, issueNumber } = parseIssueIdentifier(issue); - const result = await getIssueByIdentifierWithComments( - ctx.gql, - teamKey, - issueNumber, - ); - outputSuccess(result); + if (options.withReactions) { + if (isUuid(issue)) { + const result = await getIssueWithReactions(ctx.gql, issue); + outputSuccess(result); + } else { + const { teamKey, issueNumber } = parseIssueIdentifier(issue); + const result = await getIssueByIdentifierWithReactions( + ctx.gql, + teamKey, + issueNumber, + ); + outputSuccess(result); + } + return; } - return; - } - if (options.withReactions) { if (isUuid(issue)) { - const result = await getIssueWithReactions(ctx.gql, issue); + const result = await getIssue(ctx.gql, issue); outputSuccess(result); } else { const { teamKey, issueNumber } = parseIssueIdentifier(issue); - const result = await getIssueByIdentifierWithReactions( + const result = await getIssueByIdentifier( ctx.gql, teamKey, issueNumber, ); outputSuccess(result); } - return; - } - - if (isUuid(issue)) { - const result = await getIssue(ctx.gql, issue); - outputSuccess(result); - } else { - const { teamKey, issueNumber } = parseIssueIdentifier(issue); - const result = await getIssueByIdentifier( - ctx.gql, - teamKey, - issueNumber, - ); - outputSuccess(result); - } - }), + }, + ), ); issues @@ -783,22 +763,18 @@ export function setupIssuesCommands(program: Command): void { `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, ) .action( - handleCommand(async (...args: unknown[]) => { - const [issue, emoji, options, command] = args as [ - string, - string | undefined, - ReactionOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); - const result = await createReactionForIssue(ctx.gql, { - issueId, - emoji: resolveReactionEmojiInput(emoji, options.shortcode), - }); + commandAction<[string, string | undefined, ReactionOptions, Command]>( + async (issue, emoji, options, command) => { + const ctx = createContext(getRootOpts(command)); + const issueId = await resolveIssueId(ctx.sdk, issue); + const result = await createReactionForIssue(ctx.gql, { + issueId, + emoji: resolveReactionEmojiInput(emoji, options.shortcode), + }); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues @@ -810,23 +786,19 @@ export function setupIssuesCommands(program: Command): void { `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, ) .action( - handleCommand(async (...args: unknown[]) => { - const [issue, emoji, options, command] = args as [ - string, - string | undefined, - ReactionOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); - const result = await deleteOwnReactionByEmoji(ctx.gql, { - kind: "issue", - id: issueId, - emoji: resolveReactionEmojiInput(emoji, options.shortcode), - }); + commandAction<[string, string | undefined, ReactionOptions, Command]>( + async (issue, emoji, options, command) => { + const ctx = createContext(getRootOpts(command)); + const issueId = await resolveIssueId(ctx.sdk, issue); + const result = await deleteOwnReactionByEmoji(ctx.gql, { + kind: "issue", + id: issueId, + emoji: resolveReactionEmojiInput(emoji, options.shortcode), + }); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues @@ -837,23 +809,19 @@ export function setupIssuesCommands(program: Command): void { `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, ) .action( - handleCommand(async (...args: unknown[]) => { - const [issue, reactionId, , command] = args as [ - string, - string, - unknown, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); - const result = await deleteOwnReactionById(ctx.gql, { - kind: "issue", - id: issueId, - reactionId, - }); + commandAction<[string, string, unknown, Command]>( + async (issue, reactionId, _unused2, command) => { + const ctx = createContext(getRootOpts(command)); + const issueId = await resolveIssueId(ctx.sdk, issue); + const result = await deleteOwnReactionById(ctx.gql, { + kind: "issue", + id: issueId, + reactionId, + }); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues @@ -865,26 +833,23 @@ export function setupIssuesCommands(program: Command): void { ) .option("--body <text>", "discussion body (required, markdown supported)") .action( - handleCommand(async (...args: unknown[]) => { - const [issue, options, command] = args as [ - string, - DiscussionBodyOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, DiscussionBodyOptions, Command]>( + async (issue, options, command) => { + const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + if (!options.body) { + throw invalidParameterError("--body", "is required"); + } - const issueId = await resolveIssueId(ctx.sdk, issue); - const result = await startIssueDiscussion(ctx.gql, { - issueId, - body: options.body, - }); + const issueId = await resolveIssueId(ctx.sdk, issue); + const result = await startIssueDiscussion(ctx.gql, { + issueId, + body: options.body, + }); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues @@ -898,29 +863,30 @@ export function setupIssuesCommands(program: Command): void { .option("--after <cursor>", "cursor for next page") .option("--with-reactions", "include normalized discussion reactions") .action( - handleCommand(async (...args: unknown[]) => { - const [issue, options, command] = args as [ - string, - DiscussionsOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - const issueId = await resolveIssueId(ctx.sdk, issue); - const paginationOptions = { - limit: parseLimit(options.limit || "25"), - after: options.after, - }; - const result = options.withReactions - ? await listDiscussionsForIssueWithReactions( - ctx.gql, - issueId, - paginationOptions, - ) - : await listDiscussionsForIssue(ctx.gql, issueId, paginationOptions); + commandAction<[string, DiscussionsOptions, Command]>( + async (issue, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const issueId = await resolveIssueId(ctx.sdk, issue); + const paginationOptions = { + limit: parseLimit(options.limit || "25"), + after: options.after, + }; + const result = options.withReactions + ? await listDiscussionsForIssueWithReactions( + ctx.gql, + issueId, + paginationOptions, + ) + : await listDiscussionsForIssue( + ctx.gql, + issueId, + paginationOptions, + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); const issueThreads = issues @@ -935,34 +901,31 @@ export function setupIssuesCommands(program: Command): void { .option("--after <cursor>", "cursor for next page") .option("--with-reactions", "include normalized discussion reactions") .action( - handleCommand(async (...args: unknown[]) => { - const [thread, options, command] = args as [ - string, - DiscussionsOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, DiscussionsOptions, Command]>( + async (thread, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const paginationOptions = { + limit: parseLimit(options.limit || "50"), + after: options.after, + }; + const result = options.withReactions + ? await listDiscussionRepliesWithReactions( + ctx.gql, + thread, + paginationOptions, + "issue", + ) + : await listDiscussionReplies( + ctx.gql, + thread, + paginationOptions, + "issue", + ); - const paginationOptions = { - 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); - }), + outputSuccess(result); + }, + ), ); addCommentReactionCommands(issueReplies, "reply"); @@ -975,26 +938,23 @@ export function setupIssuesCommands(program: Command): void { ) .option("--body <text>", "reply body (required, markdown supported)") .action( - handleCommand(async (...args: unknown[]) => { - const [thread, options, command] = args as [ - string, - DiscussionBodyOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, DiscussionBodyOptions, Command]>( + async (thread, options, command) => { + const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + if (!options.body) { + throw invalidParameterError("--body", "is required"); + } - const result = await replyToDiscussion(ctx.gql, { - threadId: thread, - body: options.body, - entityKind: "issue", - }); + const result = await replyToDiscussion(ctx.gql, { + threadId: thread, + body: options.body, + entityKind: "issue", + }); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues @@ -1002,29 +962,26 @@ export function setupIssuesCommands(program: Command): void { .description("edit a root discussion or reply comment") .option("--body <text>", "new comment body (required, markdown supported)") .action( - handleCommand(async (...args: unknown[]) => { - const [comment, options, command] = args as [ - string, - DiscussionBodyOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, DiscussionBodyOptions, Command]>( + async (comment, options, command) => { + const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + if (!options.body) { + throw invalidParameterError("--body", "is required"); + } - const result = await editDiscussionComment( - ctx.gql, - comment, - { - body: options.body, - }, - "issue", - ); + const result = await editDiscussionComment( + ctx.gql, + comment, + { + body: options.body, + }, + "issue", + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues @@ -1032,57 +989,60 @@ export function setupIssuesCommands(program: Command): void { .description("edit a discussion reply") .option("--body <text>", "new reply body (required, markdown supported)") .action( - handleCommand(async (...args: unknown[]) => { - const [reply, options, command] = args as [ - string, - DiscussionBodyOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, DiscussionBodyOptions, Command]>( + async (reply, options, command) => { + const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + if (!options.body) { + throw invalidParameterError("--body", "is required"); + } - const result = await editDiscussionReply( - ctx.gql, - reply, - { - body: options.body, - }, - "issue", - ); + const result = await editDiscussionReply( + ctx.gql, + reply, + { + body: options.body, + }, + "issue", + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues .command("delete-comment <comment>") .description("delete a root discussion or reply comment") .action( - handleCommand(async (...args: unknown[]) => { - const [comment, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, unknown, Command]>( + async (comment, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionComment(ctx.gql, comment, "issue"); + const result = await deleteDiscussionComment( + ctx.gql, + comment, + "issue", + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues .command("delete-reply <reply>") .description("delete a discussion reply") .action( - handleCommand(async (...args: unknown[]) => { - const [reply, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, unknown, Command]>( + async (reply, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionReply(ctx.gql, reply, "issue"); + const result = await deleteDiscussionReply(ctx.gql, reply, "issue"); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues @@ -1090,36 +1050,34 @@ export function setupIssuesCommands(program: Command): void { .description("resolve a discussion thread") .option("--with-comment <comment>", "comment to mark as resolving comment") .action( - handleCommand(async (...args: unknown[]) => { - const [thread, options, command] = args as [ - string, - ResolveDiscussionOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - const result = await resolveDiscussion(ctx.gql, { - threadId: thread, - resolvingCommentId: options.withComment, - entityKind: "issue", - }); + commandAction<[string, ResolveDiscussionOptions, Command]>( + async (thread, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const result = await resolveDiscussion(ctx.gql, { + threadId: thread, + resolvingCommentId: options.withComment, + entityKind: "issue", + }); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues .command("unresolve <thread>") .description("unresolve a discussion thread") .action( - handleCommand(async (...args: unknown[]) => { - const [thread, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, unknown, Command]>( + async (thread, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); - const result = await unresolveDiscussion(ctx.gql, thread, "issue"); + const result = await unresolveDiscussion(ctx.gql, thread, "issue"); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues @@ -1143,125 +1101,125 @@ export function setupIssuesCommands(program: Command): void { .option("--duplicate-of <issue>", "this issue duplicates <issue>") .option("--similar-to <issue>", "this issue is similar to <issue>") .action( - handleCommand(async (...args: unknown[]) => { - const [title, options, command] = args as [ - string, - CreateOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, CreateOptions, Command]>( + async (title, options, command) => { + const ctx = createContext(getRootOpts(command)); - const relationActions = parseRelationFlags(options); + const relationActions = parseRelationFlags(options); - const parsedPriority = - options.priority !== undefined - ? parsePriorityOption(options.priority) - : undefined; - const parsedEstimate = - options.estimate !== undefined - ? parseEstimateOption(options.estimate) - : undefined; + const parsedPriority = + options.priority !== undefined + ? parsePriorityOption(options.priority) + : undefined; + const parsedEstimate = + options.estimate !== undefined + ? parseEstimateOption(options.estimate) + : undefined; - if (!options.team) { - throw new Error("--team is required"); - } + if (!options.team) { + throw new Error("--team is required"); + } - const teamEstimateContext = - parsedEstimate !== undefined - ? await resolveTeamEstimateContext(ctx.sdk, options.team) - : undefined; + const teamEstimateContext = + parsedEstimate !== undefined + ? await resolveTeamEstimateContext(ctx.sdk, options.team) + : undefined; - const teamId = teamEstimateContext - ? teamEstimateContext.teamId - : await resolveTeamId(ctx.sdk, options.team); - - if (parsedEstimate !== undefined && teamEstimateContext) { - validateEstimateAgainstTeamConfig(parsedEstimate, { - teamKey: teamEstimateContext.teamKey, - issueEstimationType: teamEstimateContext.issueEstimationType, - issueEstimationExtended: - teamEstimateContext.issueEstimationExtended, - issueEstimationAllowZero: - teamEstimateContext.issueEstimationAllowZero, - }); - } + const teamId = teamEstimateContext + ? teamEstimateContext.teamId + : await resolveTeamId(ctx.sdk, options.team); + + if (parsedEstimate !== undefined && teamEstimateContext) { + validateEstimateAgainstTeamConfig(parsedEstimate, { + teamKey: teamEstimateContext.teamKey, + issueEstimationType: teamEstimateContext.issueEstimationType, + issueEstimationExtended: + teamEstimateContext.issueEstimationExtended, + issueEstimationAllowZero: + teamEstimateContext.issueEstimationAllowZero, + }); + } - const input: IssueCreateInput = { - title, - teamId, - }; + const input: IssueCreateInput = { + title, + teamId, + }; - if (options.description) { - input.description = options.description; - } + if (options.description) { + input.description = options.description; + } - if (options.assignee) { - input.assigneeId = await resolveUserId(ctx.sdk, options.assignee); - } + if (options.assignee) { + input.assigneeId = await resolveUserId(ctx.sdk, options.assignee); + } - if (parsedPriority !== undefined) { - input.priority = parsedPriority; - } + if (parsedPriority !== undefined) { + input.priority = parsedPriority; + } - if (parsedEstimate !== undefined) { - input.estimate = parsedEstimate; - } + if (parsedEstimate !== undefined) { + input.estimate = parsedEstimate; + } - if (options.project) { - input.projectId = await resolveProjectId(ctx.sdk, options.project); - } + if (options.project) { + input.projectId = await resolveProjectId(ctx.sdk, options.project); + } - if (options.labels) { - const labelNames = options.labels.split(",").map((l) => l.trim()); - input.labelIds = await resolveLabelIds(ctx.sdk, labelNames); - } + if (options.labels) { + const labelNames = options.labels.split(",").map((l) => l.trim()); + input.labelIds = await resolveLabelIds(ctx.sdk, labelNames); + } - if (options.projectMilestone) { - if (!options.project) { - throw new Error( - "--project-milestone requires --project to be specified", + if (options.projectMilestone) { + if (!options.project) { + throw new Error( + "--project-milestone requires --project to be specified", + ); + } + input.projectMilestoneId = await resolveMilestoneId( + ctx.gql, + ctx.sdk, + options.projectMilestone, + options.project, ); } - input.projectMilestoneId = await resolveMilestoneId( - ctx.gql, - ctx.sdk, - options.projectMilestone, - options.project, - ); - } - if (options.cycle) { - input.cycleId = await resolveCycleId( - ctx.sdk, - options.cycle, - options.team, - ); - } + if (options.cycle) { + input.cycleId = await resolveCycleId( + ctx.sdk, + options.cycle, + options.team, + ); + } - if (options.status) { - input.stateId = await resolveStatusId( - ctx.sdk, - options.status, - teamId, - ); - } + if (options.status) { + input.stateId = await resolveStatusId( + ctx.sdk, + options.status, + teamId, + ); + } - if (options.parentTicket) { - input.parentId = await resolveIssueId(ctx.sdk, options.parentTicket); - } + if (options.parentTicket) { + input.parentId = await resolveIssueId( + ctx.sdk, + options.parentTicket, + ); + } - if (options.dueDate) { - input.dueDate = parseDueDate(options.dueDate); - } + if (options.dueDate) { + input.dueDate = parseDueDate(options.dueDate); + } - const result = await createIssue(ctx.gql, input); + const result = await createIssue(ctx.gql, input); - if (relationActions.length > 0) { - await resolveAndApplyRelations(ctx, result.id, relationActions); - } + if (relationActions.length > 0) { + await resolveAndApplyRelations(ctx, result.id, relationActions); + } - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues @@ -1297,251 +1255,263 @@ export function setupIssuesCommands(program: Command): void { .option("--similar-to <issue>", "add similar relation") .option("--remove-relation <issue>", "remove relation with <issue>") .action( - handleCommand(async (...args: unknown[]) => { - const [issue, options, command] = args as [ - string, - UpdateOptions, - Command, - ]; - if (options.parentTicket && options.clearParentTicket) { - throw new Error( - "Cannot use --parent-ticket and --clear-parent-ticket together", - ); - } + commandAction<[string, UpdateOptions, Command]>( + async (issue, options, command) => { + if (options.parentTicket && options.clearParentTicket) { + throw new Error( + "Cannot use --parent-ticket and --clear-parent-ticket together", + ); + } - if (options.projectMilestone && options.clearProjectMilestone) { - throw new Error( - "Cannot use --project-milestone and --clear-project-milestone together", - ); - } + if (options.projectMilestone && options.clearProjectMilestone) { + throw new Error( + "Cannot use --project-milestone and --clear-project-milestone together", + ); + } - if (options.estimate !== undefined && options.clearEstimate) { - throw new Error( - "Cannot use --estimate and --clear-estimate together", - ); - } + if (options.estimate !== undefined && options.clearEstimate) { + throw new Error( + "Cannot use --estimate and --clear-estimate together", + ); + } - if (options.cycle && options.clearCycle) { - throw new Error("Cannot use --cycle and --clear-cycle together"); - } + if (options.cycle && options.clearCycle) { + throw new Error("Cannot use --cycle and --clear-cycle together"); + } - if (options.dueDate && options.clearDueDate) { - throw new Error( - "Cannot use --due-date and --clear-due-date together", - ); - } + if (options.dueDate && options.clearDueDate) { + throw new Error( + "Cannot use --due-date and --clear-due-date together", + ); + } - if (options.labelMode && !options.labels) { - throw new Error("--label-mode requires --labels to be specified"); - } + if (options.labelMode && !options.labels) { + throw new Error("--label-mode requires --labels to be specified"); + } - if (options.clearLabels && options.labels) { - throw new Error("--clear-labels cannot be used with --labels"); - } + if (options.clearLabels && options.labels) { + throw new Error("--clear-labels cannot be used with --labels"); + } - if (options.clearLabels && options.labelMode) { - throw new Error("--clear-labels cannot be used with --label-mode"); - } + if (options.clearLabels && options.labelMode) { + throw new Error("--clear-labels cannot be used with --label-mode"); + } - const labelMode = parseLabelMode(options.labelMode); + const labelMode = parseLabelMode(options.labelMode); - const parsedPriority = - options.priority !== undefined - ? parsePriorityOption(options.priority) - : undefined; - const parsedEstimate = - options.estimate !== undefined - ? parseEstimateOption(options.estimate) - : undefined; + const parsedPriority = + options.priority !== undefined + ? parsePriorityOption(options.priority) + : undefined; + const parsedEstimate = + options.estimate !== undefined + ? parseEstimateOption(options.estimate) + : undefined; - const relationActions = parseRelationFlags(options); + const relationActions = parseRelationFlags(options); - const ctx = createContext(getRootOpts(command)); + const ctx = createContext(getRootOpts(command)); - const issueEstimateContext = - parsedEstimate !== undefined - ? await resolveIssueEstimateContext(ctx.sdk, issue) - : undefined; + const issueEstimateContext = + parsedEstimate !== undefined + ? await resolveIssueEstimateContext(ctx.sdk, issue) + : undefined; - const resolvedIssueId = issueEstimateContext - ? issueEstimateContext.issueId - : await resolveIssueId(ctx.sdk, issue); - - if (parsedEstimate !== undefined && issueEstimateContext) { - validateEstimateAgainstTeamConfig(parsedEstimate, { - teamKey: issueEstimateContext.team.teamKey, - issueEstimationType: issueEstimateContext.team.issueEstimationType, - issueEstimationExtended: - issueEstimateContext.team.issueEstimationExtended, - issueEstimationAllowZero: - issueEstimateContext.team.issueEstimationAllowZero, - }); - } + const resolvedIssueId = issueEstimateContext + ? issueEstimateContext.issueId + : await resolveIssueId(ctx.sdk, issue); + + if (parsedEstimate !== undefined && issueEstimateContext) { + validateEstimateAgainstTeamConfig(parsedEstimate, { + teamKey: issueEstimateContext.team.teamKey, + issueEstimationType: + issueEstimateContext.team.issueEstimationType, + issueEstimationExtended: + issueEstimateContext.team.issueEstimationExtended, + issueEstimationAllowZero: + issueEstimateContext.team.issueEstimationAllowZero, + }); + } - const needsContext = - options.status || - options.projectMilestone || - options.cycle || - (options.labels && (labelMode === "add" || labelMode === "remove")); - const issueContext = needsContext - ? await getIssue(ctx.gql, resolvedIssueId) - : undefined; + const needsContext = + options.status || + options.projectMilestone || + options.cycle || + (options.labels && (labelMode === "add" || labelMode === "remove")); + const issueContext = needsContext + ? await getIssue(ctx.gql, resolvedIssueId) + : undefined; - const input: IssueUpdateInput = {}; + const input: IssueUpdateInput = {}; - if (options.title) { - input.title = options.title; - } + if (options.title) { + input.title = options.title; + } - if (options.description) { - input.description = options.description; - } + if (options.description) { + input.description = options.description; + } - if (options.status) { - const teamId = - issueContext && "team" in issueContext && issueContext.team - ? issueContext.team.id - : undefined; - input.stateId = await resolveStatusId( - ctx.sdk, - options.status, - teamId, - ); - } + if (options.status) { + const teamId = + issueContext && "team" in issueContext && issueContext.team + ? issueContext.team.id + : undefined; + input.stateId = await resolveStatusId( + ctx.sdk, + options.status, + teamId, + ); + } - if (parsedPriority !== undefined) { - input.priority = parsedPriority; - } + if (parsedPriority !== undefined) { + input.priority = parsedPriority; + } - if (options.clearEstimate) { - input.estimate = null; - } else if (parsedEstimate !== undefined) { - input.estimate = parsedEstimate; - } + if (options.clearEstimate) { + input.estimate = null; + } else if (parsedEstimate !== undefined) { + input.estimate = parsedEstimate; + } - if (options.assignee) { - input.assigneeId = await resolveUserId(ctx.sdk, options.assignee); - } + if (options.assignee) { + input.assigneeId = await resolveUserId(ctx.sdk, options.assignee); + } - if (options.project) { - input.projectId = await resolveProjectId(ctx.sdk, options.project); - } + if (options.project) { + input.projectId = await resolveProjectId(ctx.sdk, options.project); + } - if (options.clearLabels) { - input.labelIds = []; - } else if (options.labels) { - const labelNames = options.labels.split(",").map((l) => l.trim()); - const labelIds = await resolveLabelIds(ctx.sdk, labelNames); + if (options.clearLabels) { + input.labelIds = []; + } else if (options.labels) { + const labelNames = options.labels.split(",").map((l) => l.trim()); + const labelIds = await resolveLabelIds(ctx.sdk, labelNames); + + if (labelMode === "add") { + const currentLabels = + issueContext && + "labels" in issueContext && + issueContext.labels?.nodes + ? issueContext.labels.nodes.map((l) => l.id) + : []; + input.labelIds = [...new Set([...currentLabels, ...labelIds])]; + } else if (labelMode === "remove") { + const currentLabels = + issueContext && + "labels" in issueContext && + issueContext.labels?.nodes + ? issueContext.labels.nodes.map((l) => l.id) + : []; + input.labelIds = currentLabels.filter( + (id) => !labelIds.includes(id), + ); + } else { + input.labelIds = labelIds; + } + } - if (labelMode === "add") { - const currentLabels = - issueContext && - "labels" in issueContext && - issueContext.labels?.nodes - ? issueContext.labels.nodes.map((l) => l.id) - : []; - input.labelIds = [...new Set([...currentLabels, ...labelIds])]; - } else if (labelMode === "remove") { - const currentLabels = - issueContext && - "labels" in issueContext && - issueContext.labels?.nodes - ? issueContext.labels.nodes.map((l) => l.id) - : []; - input.labelIds = currentLabels.filter( - (id) => !labelIds.includes(id), + if (options.clearParentTicket) { + input.parentId = null; + } else if (options.parentTicket) { + input.parentId = await resolveIssueId( + ctx.sdk, + options.parentTicket, ); - } else { - input.labelIds = labelIds; } - } - if (options.clearParentTicket) { - input.parentId = null; - } else if (options.parentTicket) { - input.parentId = await resolveIssueId(ctx.sdk, options.parentTicket); - } - - if (options.clearProjectMilestone) { - input.projectMilestoneId = null; - } else if (options.projectMilestone) { - const projectName = - issueContext && - "project" in issueContext && - issueContext.project?.name - ? issueContext.project.name - : undefined; - input.projectMilestoneId = await resolveMilestoneId( - ctx.gql, - ctx.sdk, - options.projectMilestone, - projectName, - ); - } + if (options.clearProjectMilestone) { + input.projectMilestoneId = null; + } else if (options.projectMilestone) { + const projectName = + issueContext && + "project" in issueContext && + issueContext.project?.name + ? issueContext.project.name + : undefined; + input.projectMilestoneId = await resolveMilestoneId( + ctx.gql, + ctx.sdk, + options.projectMilestone, + projectName, + ); + } - if (options.clearCycle) { - input.cycleId = null; - } else if (options.cycle) { - const teamKey = - issueContext && "team" in issueContext && issueContext.team?.key - ? issueContext.team.key - : undefined; - input.cycleId = await resolveCycleId(ctx.sdk, options.cycle, teamKey); - } + if (options.clearCycle) { + input.cycleId = null; + } else if (options.cycle) { + const teamKey = + issueContext && "team" in issueContext && issueContext.team?.key + ? issueContext.team.key + : undefined; + input.cycleId = await resolveCycleId( + ctx.sdk, + options.cycle, + teamKey, + ); + } - if (options.clearDueDate) { - input.dueDate = null; - } else if (options.dueDate) { - input.dueDate = parseDueDate(options.dueDate); - } + if (options.clearDueDate) { + input.dueDate = null; + } else if (options.dueDate) { + input.dueDate = parseDueDate(options.dueDate); + } - const result = await updateIssue(ctx.gql, resolvedIssueId, input); + const result = await updateIssue(ctx.gql, resolvedIssueId, input); - if (relationActions.length > 0) { - await resolveAndApplyRelations(ctx, resolvedIssueId, relationActions); - } + if (relationActions.length > 0) { + await resolveAndApplyRelations( + ctx, + resolvedIssueId, + relationActions, + ); + } - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); issues .command("archive <issue>") .description("archive an issue") .action( - handleCommand(async (...args: unknown[]) => { - const [issue, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); - const result = await archiveIssue(ctx.gql, issueId); - outputSuccess(result); - }), + commandAction<[string, unknown, Command]>( + async (issue, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const issueId = await resolveIssueId(ctx.sdk, issue); + const result = await archiveIssue(ctx.gql, issueId); + outputSuccess(result); + }, + ), ); issues .command("unarchive <issue>") .description("unarchive an issue") .action( - handleCommand(async (...args: unknown[]) => { - const [issue, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); - const result = await unarchiveIssue(ctx.gql, issueId); - outputSuccess(result); - }), + commandAction<[string, unknown, Command]>( + async (issue, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const issueId = await resolveIssueId(ctx.sdk, issue); + const result = await unarchiveIssue(ctx.gql, issueId); + outputSuccess(result); + }, + ), ); issues .command("delete <issue>") .description("delete an issue") .action( - handleCommand(async (...args: unknown[]) => { - const [issue, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); - const result = await deleteIssue(ctx.gql, issueId); - outputSuccess(result); - }), + commandAction<[string, unknown, Command]>( + async (issue, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const issueId = await resolveIssueId(ctx.sdk, issue); + const result = await deleteIssue(ctx.gql, issueId); + outputSuccess(result); + }, + ), ); issues diff --git a/src/commands/projects.ts b/src/commands/projects.ts index dbc3a4b8..9adca03a 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -3,7 +3,7 @@ import { createContext, getRootOpts } from "../common/context.js"; import { type Priority, parseLabelMode } from "../common/domain-values.js"; import { resolveReactionEmojiInput } from "../common/emoji.js"; import { invalidParameterError } from "../common/errors.js"; -import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; +import { commandAction, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import type { ProjectCreateInput, ProjectUpdateInput } from "../gql/graphql.js"; import { @@ -78,22 +78,18 @@ function addCommentReactionCommands( .description(`add a reaction to a discussion ${noun}`) .option("--shortcode <name>", "emoji shortcode (e.g. thumbs_up)") .action( - handleCommand(async (...args: unknown[]) => { - const [commentId, emoji, options, command] = args as [ - string, - string | undefined, - ReactionOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const result = await createDiscussionCommentReaction(ctx.gql, { - commentId, - target: noun, - expectedEntityKind: "project", - emoji: resolveReactionEmojiInput(emoji, options.shortcode), - }); - outputSuccess(result); - }), + commandAction<[string, string | undefined, ReactionOptions, Command]>( + async (commentId, emoji, options, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await createDiscussionCommentReaction(ctx.gql, { + commentId, + target: noun, + expectedEntityKind: "project", + emoji: resolveReactionEmojiInput(emoji, options.shortcode), + }); + outputSuccess(result); + }, + ), ); parent @@ -101,22 +97,18 @@ function addCommentReactionCommands( .description(`remove your reaction from a discussion ${noun} by emoji`) .option("--shortcode <name>", "emoji shortcode (e.g. thumbs_up)") .action( - handleCommand(async (...args: unknown[]) => { - const [commentId, emoji, options, command] = args as [ - string, - string | undefined, - ReactionOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { - commentId, - target: noun, - expectedEntityKind: "project", - emoji: resolveReactionEmojiInput(emoji, options.shortcode), - }); - outputSuccess(result); - }), + commandAction<[string, string | undefined, ReactionOptions, Command]>( + async (commentId, emoji, options, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { + commentId, + target: noun, + expectedEntityKind: "project", + emoji: resolveReactionEmojiInput(emoji, options.shortcode), + }); + outputSuccess(result); + }, + ), ); parent @@ -125,22 +117,18 @@ function addCommentReactionCommands( `remove your reaction from a discussion ${noun} by reaction ID`, ) .action( - handleCommand(async (...args: unknown[]) => { - const [commentId, reactionId, , command] = args as [ - string, - string, - unknown, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionCommentReactionById(ctx.gql, { - commentId, - target: noun, - expectedEntityKind: "project", - reactionId, - }); - outputSuccess(result); - }), + commandAction<[string, string, unknown, Command]>( + async (commentId, reactionId, _unused2, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await deleteDiscussionCommentReactionById(ctx.gql, { + commentId, + target: noun, + expectedEntityKind: "project", + reactionId, + }); + outputSuccess(result); + }, + ), ); } @@ -271,8 +259,7 @@ export function setupProjectsCommands(program: Command): void { .option("--after <cursor>", "cursor for next page") .option("--include-archived", "include archived projects") .action( - handleCommand(async (...args: unknown[]) => { - const [options, command] = args as [ListOptions, Command]; + commandAction<[ListOptions, Command]>(async (options, command) => { const ctx = createContext(getRootOpts(command)); const result = await listProjects(ctx.gql, { limit: parseLimit(options.limit), @@ -297,26 +284,23 @@ export function setupProjectsCommands(program: Command): void { "50", ) .action( - handleCommand(async (...args: unknown[]) => { - const [project, options, command] = args as [ - string, - ReadOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - const projectId = await resolveProjectId(ctx.sdk, project); - const result = await getProject(ctx.gql, projectId, { - milestonesFirst: parseNonNegativeIntegerOption( - "--milestones-first", - options.milestonesFirst, - ), - issuesFirst: parseNonNegativeIntegerOption( - "--issues-first", - options.issuesFirst, - ), - }); - outputSuccess(result); - }), + commandAction<[string, ReadOptions, Command]>( + async (project, options, command) => { + const ctx = createContext(getRootOpts(command)); + const projectId = await resolveProjectId(ctx.sdk, project); + const result = await getProject(ctx.gql, projectId, { + milestonesFirst: parseNonNegativeIntegerOption( + "--milestones-first", + options.milestonesFirst, + ), + issuesFirst: parseNonNegativeIntegerOption( + "--issues-first", + options.issuesFirst, + ), + }); + outputSuccess(result); + }, + ), ); projects @@ -324,26 +308,23 @@ export function setupProjectsCommands(program: Command): void { .description("start a discussion thread on a project") .option("--body <text>", "discussion body (required, markdown supported)") .action( - handleCommand(async (...args: unknown[]) => { - const [project, options, command] = args as [ - string, - DiscussionBodyOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, DiscussionBodyOptions, Command]>( + async (project, options, command) => { + const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + if (!options.body) { + throw invalidParameterError("--body", "is required"); + } - const projectId = await resolveProjectId(ctx.sdk, project); - const result = await startProjectDiscussion(ctx.gql, { - projectId, - body: options.body, - }); + const projectId = await resolveProjectId(ctx.sdk, project); + const result = await startProjectDiscussion(ctx.gql, { + projectId, + body: options.body, + }); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); projects @@ -353,33 +334,30 @@ export function setupProjectsCommands(program: Command): void { .option("--after <cursor>", "cursor for next page") .option("--with-reactions", "include normalized discussion reactions") .action( - handleCommand(async (...args: unknown[]) => { - const [project, options, command] = args as [ - string, - DiscussionsOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - const projectId = await resolveProjectId(ctx.sdk, project); - const paginationOptions = { - limit: parseLimit(options.limit || "25"), - after: options.after, - }; - const result = options.withReactions - ? await listDiscussionsForProjectWithReactions( - ctx.gql, - projectId, - paginationOptions, - ) - : await listDiscussionsForProject( - ctx.gql, - projectId, - paginationOptions, - ); - - outputSuccess(result); - }), + commandAction<[string, DiscussionsOptions, Command]>( + async (project, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const projectId = await resolveProjectId(ctx.sdk, project); + const paginationOptions = { + limit: parseLimit(options.limit || "25"), + after: options.after, + }; + const result = options.withReactions + ? await listDiscussionsForProjectWithReactions( + ctx.gql, + projectId, + paginationOptions, + ) + : await listDiscussionsForProject( + ctx.gql, + projectId, + paginationOptions, + ); + + outputSuccess(result); + }, + ), ); const projectThreads = projects @@ -394,34 +372,31 @@ export function setupProjectsCommands(program: Command): void { .option("--after <cursor>", "cursor for next page") .option("--with-reactions", "include normalized discussion reactions") .action( - handleCommand(async (...args: unknown[]) => { - const [thread, options, command] = args as [ - string, - DiscussionsOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - const paginationOptions = { - limit: parseLimit(options.limit || "50"), - after: options.after, - }; - const result = options.withReactions - ? await listDiscussionRepliesWithReactions( - ctx.gql, - thread, - paginationOptions, - "project", - ) - : await listDiscussionReplies( - ctx.gql, - thread, - paginationOptions, - "project", - ); - - outputSuccess(result); - }), + commandAction<[string, DiscussionsOptions, Command]>( + async (thread, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const paginationOptions = { + limit: parseLimit(options.limit || "50"), + after: options.after, + }; + const result = options.withReactions + ? await listDiscussionRepliesWithReactions( + ctx.gql, + thread, + paginationOptions, + "project", + ) + : await listDiscussionReplies( + ctx.gql, + thread, + paginationOptions, + "project", + ); + + outputSuccess(result); + }, + ), ); addCommentReactionCommands(projectReplies, "reply"); @@ -434,26 +409,23 @@ export function setupProjectsCommands(program: Command): void { ) .option("--body <text>", "reply body (required, markdown supported)") .action( - handleCommand(async (...args: unknown[]) => { - const [thread, options, command] = args as [ - string, - DiscussionBodyOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, DiscussionBodyOptions, Command]>( + async (thread, options, command) => { + const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + if (!options.body) { + throw invalidParameterError("--body", "is required"); + } - const result = await replyToDiscussion(ctx.gql, { - threadId: thread, - body: options.body, - entityKind: "project", - }); + const result = await replyToDiscussion(ctx.gql, { + threadId: thread, + body: options.body, + entityKind: "project", + }); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); projects @@ -461,29 +433,26 @@ export function setupProjectsCommands(program: Command): void { .description("edit a root discussion or reply comment") .option("--body <text>", "new comment body (required, markdown supported)") .action( - handleCommand(async (...args: unknown[]) => { - const [comment, options, command] = args as [ - string, - DiscussionBodyOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, DiscussionBodyOptions, Command]>( + async (comment, options, command) => { + const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + if (!options.body) { + throw invalidParameterError("--body", "is required"); + } - const result = await editDiscussionComment( - ctx.gql, - comment, - { - body: options.body, - }, - "project", - ); + const result = await editDiscussionComment( + ctx.gql, + comment, + { + body: options.body, + }, + "project", + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); projects @@ -491,61 +460,60 @@ export function setupProjectsCommands(program: Command): void { .description("edit a discussion reply") .option("--body <text>", "new reply body (required, markdown supported)") .action( - handleCommand(async (...args: unknown[]) => { - const [reply, options, command] = args as [ - string, - DiscussionBodyOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, DiscussionBodyOptions, Command]>( + async (reply, options, command) => { + const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + if (!options.body) { + throw invalidParameterError("--body", "is required"); + } - const result = await editDiscussionReply( - ctx.gql, - reply, - { - body: options.body, - }, - "project", - ); + const result = await editDiscussionReply( + ctx.gql, + reply, + { + body: options.body, + }, + "project", + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); projects .command("delete-comment <comment>") .description("delete a root discussion or reply comment") .action( - handleCommand(async (...args: unknown[]) => { - const [comment, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, unknown, Command]>( + async (comment, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionComment( - ctx.gql, - comment, - "project", - ); + const result = await deleteDiscussionComment( + ctx.gql, + comment, + "project", + ); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); projects .command("delete-reply <reply>") .description("delete a discussion reply") .action( - handleCommand(async (...args: unknown[]) => { - const [reply, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, unknown, Command]>( + async (reply, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionReply(ctx.gql, reply, "project"); + const result = await deleteDiscussionReply(ctx.gql, reply, "project"); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); projects @@ -553,36 +521,34 @@ export function setupProjectsCommands(program: Command): void { .description("resolve a discussion thread") .option("--with-comment <comment>", "comment to mark as resolving comment") .action( - handleCommand(async (...args: unknown[]) => { - const [thread, options, command] = args as [ - string, - ResolveDiscussionOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); - - const result = await resolveDiscussion(ctx.gql, { - threadId: thread, - resolvingCommentId: options.withComment, - entityKind: "project", - }); - - outputSuccess(result); - }), + commandAction<[string, ResolveDiscussionOptions, Command]>( + async (thread, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const result = await resolveDiscussion(ctx.gql, { + threadId: thread, + resolvingCommentId: options.withComment, + entityKind: "project", + }); + + outputSuccess(result); + }, + ), ); projects .command("unresolve <thread>") .description("unresolve a discussion thread") .action( - handleCommand(async (...args: unknown[]) => { - const [thread, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, unknown, Command]>( + async (thread, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); - const result = await unresolveDiscussion(ctx.gql, thread, "project"); + const result = await unresolveDiscussion(ctx.gql, thread, "project"); - outputSuccess(result); - }), + outputSuccess(result); + }, + ), ); projects @@ -602,84 +568,81 @@ export function setupProjectsCommands(program: Command): void { .option("--target-date <date>", "target date (YYYY-MM-DD)") .option("--labels <labels>", "comma-separated label names or UUIDs") .action( - handleCommand(async (...args: unknown[]) => { - const [name, options, command] = args as [ - string, - CreateOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, CreateOptions, Command]>( + async (name, options, command) => { + const ctx = createContext(getRootOpts(command)); - const teamNames = getCreateTeamNames(options); - const teamIds = await Promise.all( - teamNames.map((t) => resolveTeamId(ctx.sdk, t)), - ); - - const input: ProjectCreateInput = { - name, - teamIds, - }; - - if (options.description) { - input.description = options.description; - } - - if (options.content) { - input.content = options.content; - } - - if (options.icon !== undefined) { - input.icon = options.icon; - } - - if (options.color !== undefined) { - input.color = options.color; - } - - if (options.lead) { - input.leadId = await resolveUserId(ctx.sdk, options.lead); - } - - if (options.members) { - const memberNames = options.members - .split(",") - .map((m) => m.trim()) - .filter(Boolean); - input.memberIds = await Promise.all( - memberNames.map((m) => resolveUserId(ctx.sdk, m)), + const teamNames = getCreateTeamNames(options); + const teamIds = await Promise.all( + teamNames.map((t) => resolveTeamId(ctx.sdk, t)), ); - } - if (options.priority) { - input.priority = parsePriority(options.priority); - } + const input: ProjectCreateInput = { + name, + teamIds, + }; - if (options.status) { - input.statusId = await resolveProjectStatusId( - ctx.gql, - options.status, - ); - } + if (options.description) { + input.description = options.description; + } - if (options.startDate) { - input.startDate = options.startDate; - } + if (options.content) { + input.content = options.content; + } - if (options.targetDate) { - input.targetDate = options.targetDate; - } + if (options.icon !== undefined) { + input.icon = options.icon; + } - if (options.labels) { - const labelNames = options.labels - .split(",") - .map((l) => l.trim()) - .filter(Boolean); - input.labelIds = await resolveProjectLabelIds(ctx.sdk, labelNames); - } + if (options.color !== undefined) { + input.color = options.color; + } - const result = await createProject(ctx.gql, input); - outputSuccess(result); - }), + if (options.lead) { + input.leadId = await resolveUserId(ctx.sdk, options.lead); + } + + if (options.members) { + const memberNames = options.members + .split(",") + .map((m) => m.trim()) + .filter(Boolean); + input.memberIds = await Promise.all( + memberNames.map((m) => resolveUserId(ctx.sdk, m)), + ); + } + + if (options.priority) { + input.priority = parsePriority(options.priority); + } + + if (options.status) { + input.statusId = await resolveProjectStatusId( + ctx.gql, + options.status, + ); + } + + if (options.startDate) { + input.startDate = options.startDate; + } + + if (options.targetDate) { + input.targetDate = options.targetDate; + } + + if (options.labels) { + const labelNames = options.labels + .split(",") + .map((l) => l.trim()) + .filter(Boolean); + input.labelIds = await resolveProjectLabelIds(ctx.sdk, labelNames); + } + + const result = await createProject(ctx.gql, input); + outputSuccess(result); + }, + ), ); projects @@ -705,212 +668,212 @@ export function setupProjectsCommands(program: Command): void { .option("--label-mode <mode>", "add | remove | overwrite") .option("--clear-labels", "remove all labels") .action( - handleCommand(async (...args: unknown[]) => { - const [project, options, command] = args as [ - string, - UpdateOptions, - Command, - ]; - const ctx = createContext(getRootOpts(command)); + commandAction<[string, UpdateOptions, Command]>( + async (project, options, command) => { + const ctx = createContext(getRootOpts(command)); + + if (options.lead && options.clearLead) { + throw invalidParameterError( + "--lead", + "cannot be combined with --clear-lead", + ); + } - if (options.lead && options.clearLead) { - throw invalidParameterError( - "--lead", - "cannot be combined with --clear-lead", - ); - } + if (options.startDate && options.clearStartDate) { + throw invalidParameterError( + "--start-date", + "cannot be combined with --clear-start-date", + ); + } - if (options.startDate && options.clearStartDate) { - throw invalidParameterError( - "--start-date", - "cannot be combined with --clear-start-date", - ); - } + if (options.targetDate && options.clearTargetDate) { + throw invalidParameterError( + "--target-date", + "cannot be combined with --clear-target-date", + ); + } - if (options.targetDate && options.clearTargetDate) { - throw invalidParameterError( - "--target-date", - "cannot be combined with --clear-target-date", - ); - } + if (options.labelMode && !options.labels) { + throw invalidParameterError( + "--label-mode", + "requires --labels to be specified", + ); + } - if (options.labelMode && !options.labels) { - throw invalidParameterError( - "--label-mode", - "requires --labels to be specified", - ); - } + if (options.clearLabels && options.labels) { + throw invalidParameterError( + "--clear-labels", + "cannot be used with --labels", + ); + } - if (options.clearLabels && options.labels) { - throw invalidParameterError( - "--clear-labels", - "cannot be used with --labels", - ); - } + if (options.clearLabels && options.labelMode) { + throw invalidParameterError( + "--clear-labels", + "cannot be used with --label-mode", + ); + } - if (options.clearLabels && options.labelMode) { - throw invalidParameterError( - "--clear-labels", - "cannot be used with --label-mode", - ); - } - - const labelMode = parseLabelMode(options.labelMode); - - const projectId = await resolveProjectId(ctx.sdk, project); - const needsLabelContext = - options.labels && (labelMode === "add" || labelMode === "remove"); - const projectContext = needsLabelContext - ? await getProject(ctx.gql, projectId) - : undefined; - - const input: ProjectUpdateInput = {}; - - if (options.name) { - input.name = options.name; - } - - if (options.description) { - input.description = options.description; - } - - if (options.content) { - input.content = options.content; - } - - if (options.icon !== undefined) { - input.icon = options.icon; - } - - if (options.color !== undefined) { - input.color = options.color; - } - - if (options.clearLead) { - input.leadId = null; - } else if (options.lead) { - input.leadId = await resolveUserId(ctx.sdk, options.lead); - } - - if (options.members) { - const memberNames = options.members - .split(",") - .map((m) => m.trim()) - .filter(Boolean); - input.memberIds = await Promise.all( - memberNames.map((m) => resolveUserId(ctx.sdk, m)), - ); - } + const labelMode = parseLabelMode(options.labelMode); - if (options.priority) { - input.priority = parsePriority(options.priority); - } + const projectId = await resolveProjectId(ctx.sdk, project); + const needsLabelContext = + options.labels && (labelMode === "add" || labelMode === "remove"); + const projectContext = needsLabelContext + ? await getProject(ctx.gql, projectId) + : undefined; - if (options.status) { - input.statusId = await resolveProjectStatusId( - ctx.gql, - options.status, - ); - } - - if (options.clearStartDate) { - input.startDate = null; - } else if (options.startDate) { - input.startDate = options.startDate; - } - - if (options.clearTargetDate) { - input.targetDate = null; - } else if (options.targetDate) { - input.targetDate = options.targetDate; - } - - const teamNames = getUpdateTeamNames(options); - if (teamNames) { - input.teamIds = await Promise.all( - teamNames.map((t) => resolveTeamId(ctx.sdk, t)), - ); - } - - if (options.clearLabels) { - input.labelIds = []; - } else if (options.labels) { - const labelNames = options.labels - .split(",") - .map((l) => l.trim()) - .filter(Boolean); - const labelIds = await resolveProjectLabelIds(ctx.sdk, labelNames); - - if (labelMode === "add") { - const currentLabels = projectContext?.labels?.nodes - ? projectContext.labels.nodes.map((l) => l.id) - : []; - input.labelIds = [...new Set([...currentLabels, ...labelIds])]; - } else if (labelMode === "remove") { - const currentLabels = projectContext?.labels?.nodes - ? projectContext.labels.nodes.map((l) => l.id) - : []; - input.labelIds = currentLabels.filter( - (id) => !labelIds.includes(id), + const input: ProjectUpdateInput = {}; + + if (options.name) { + input.name = options.name; + } + + if (options.description) { + input.description = options.description; + } + + if (options.content) { + input.content = options.content; + } + + if (options.icon !== undefined) { + input.icon = options.icon; + } + + if (options.color !== undefined) { + input.color = options.color; + } + + if (options.clearLead) { + input.leadId = null; + } else if (options.lead) { + input.leadId = await resolveUserId(ctx.sdk, options.lead); + } + + if (options.members) { + const memberNames = options.members + .split(",") + .map((m) => m.trim()) + .filter(Boolean); + input.memberIds = await Promise.all( + memberNames.map((m) => resolveUserId(ctx.sdk, m)), ); - } else { - input.labelIds = labelIds; } - } - if (Object.keys(input).length === 0) { - throw invalidParameterError( - "update options", - "at least one option must be provided", - ); - } + if (options.priority) { + input.priority = parsePriority(options.priority); + } - const result = await updateProject(ctx.gql, projectId, input); - outputSuccess(result); - }), + if (options.status) { + input.statusId = await resolveProjectStatusId( + ctx.gql, + options.status, + ); + } + + if (options.clearStartDate) { + input.startDate = null; + } else if (options.startDate) { + input.startDate = options.startDate; + } + + if (options.clearTargetDate) { + input.targetDate = null; + } else if (options.targetDate) { + input.targetDate = options.targetDate; + } + + const teamNames = getUpdateTeamNames(options); + if (teamNames) { + input.teamIds = await Promise.all( + teamNames.map((t) => resolveTeamId(ctx.sdk, t)), + ); + } + + if (options.clearLabels) { + input.labelIds = []; + } else if (options.labels) { + const labelNames = options.labels + .split(",") + .map((l) => l.trim()) + .filter(Boolean); + const labelIds = await resolveProjectLabelIds(ctx.sdk, labelNames); + + if (labelMode === "add") { + const currentLabels = projectContext?.labels?.nodes + ? projectContext.labels.nodes.map((l) => l.id) + : []; + input.labelIds = [...new Set([...currentLabels, ...labelIds])]; + } else if (labelMode === "remove") { + const currentLabels = projectContext?.labels?.nodes + ? projectContext.labels.nodes.map((l) => l.id) + : []; + input.labelIds = currentLabels.filter( + (id) => !labelIds.includes(id), + ); + } else { + input.labelIds = labelIds; + } + } + + if (Object.keys(input).length === 0) { + throw invalidParameterError( + "update options", + "at least one option must be provided", + ); + } + + const result = await updateProject(ctx.gql, projectId, input); + outputSuccess(result); + }, + ), ); projects .command("archive <project>") .description("archive a project") .action( - handleCommand(async (...args: unknown[]) => { - const [project, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - const projectId = await resolveProjectId(ctx.sdk, project); - const result = await archiveProject(ctx.gql, projectId); - outputSuccess(result); - }), + commandAction<[string, unknown, Command]>( + async (project, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const projectId = await resolveProjectId(ctx.sdk, project); + const result = await archiveProject(ctx.gql, projectId); + outputSuccess(result); + }, + ), ); projects .command("unarchive <project>") .description("unarchive a project") .action( - handleCommand(async (...args: unknown[]) => { - const [project, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - const projectId = await resolveProjectId(ctx.sdk, project, { - includeArchived: true, - }); - const result = await unarchiveProject(ctx.gql, projectId); - outputSuccess(result); - }), + commandAction<[string, unknown, Command]>( + async (project, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const projectId = await resolveProjectId(ctx.sdk, project, { + includeArchived: true, + }); + const result = await unarchiveProject(ctx.gql, projectId); + outputSuccess(result); + }, + ), ); projects .command("delete <project>") .description("delete a project") .action( - handleCommand(async (...args: unknown[]) => { - const [project, , command] = args as [string, unknown, Command]; - const ctx = createContext(getRootOpts(command)); - const projectId = await resolveProjectId(ctx.sdk, project, { - includeArchived: true, - }); - const result = await deleteProject(ctx.gql, projectId); - outputSuccess(result); - }), + commandAction<[string, unknown, Command]>( + async (project, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const projectId = await resolveProjectId(ctx.sdk, project, { + includeArchived: true, + }); + const result = await deleteProject(ctx.gql, projectId); + outputSuccess(result); + }, + ), ); projects diff --git a/src/common/output.ts b/src/common/output.ts index 3c061390..40790b28 100644 --- a/src/common/output.ts +++ b/src/common/output.ts @@ -124,3 +124,35 @@ export function handleCommand( } }; } + +/** + * Typed wrapper around {@link handleCommand} for Commander action handlers. + * + * Commander invokes an action with the positional arguments first, followed by + * the parsed options object and the `Command` instance. That boundary is + * inherently `unknown[]`, so command bodies used to open with a hand-written + * tuple cast (`const [issue, options, command] = args as [...]`). Those casts + * are invisible to the compiler: if a command signature changes, TypeScript + * cannot flag the now-wrong destructuring. + * + * `commandAction` centralizes the cast in one place. Declare the expected + * argument tuple once via the generic parameter and the handler receives fully + * typed arguments, while `handleCommand` remains the single error wrapper. + * + * @example + * .action( + * commandAction<[string, ReadOptions, Command]>( + * async (issue, options, command) => { + * const ctx = createContext(getRootOpts(command)); + * // ... + * }, + * ), + * ) + */ +export function commandAction<TArgs extends readonly unknown[]>( + fn: (...args: TArgs) => Promise<void>, +): (...args: unknown[]) => Promise<void> { + return handleCommand(async (...args: unknown[]) => { + await fn(...(args as unknown as TArgs)); + }); +} From 9a283ea5221491079a38e2af37292fa700a807f1 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:20:53 +0200 Subject: [PATCH 49/79] ci: type-check test files in a dedicated tsc pass The build tsconfig excludes tests to keep them out of dist/, which left test files entirely unchecked by tsc. Add tsconfig.test.json (extends the base, noEmit, allowJs, includes src + tests) plus a `typecheck:test` npm script, and run it as a new CI step so test type errors fail the build. Enabling the check surfaced pre-existing type errors in tests that had drifted from the code they exercise; fix them: - graphql-client: adapt to request()'s current variables-argument signature - initiative-service: orderBy is a PaginationOrderBy string, not an object - issue-service: comment.user is nullable, use optional chaining Closes #198 --- .github/workflows/ci-validate.yml | 3 ++ package.json | 1 + tests/unit/client/graphql-client.test.ts | 52 +++++++++---------- .../unit/services/initiative-service.test.ts | 4 +- tests/unit/services/issue-service.test.ts | 2 +- tsconfig.json | 6 +-- tsconfig.test.json | 21 ++++++++ 7 files changed, 55 insertions(+), 34 deletions(-) create mode 100644 tsconfig.test.json diff --git a/.github/workflows/ci-validate.yml b/.github/workflows/ci-validate.yml index f82d8ae7..e9a07004 100644 --- a/.github/workflows/ci-validate.yml +++ b/.github/workflows/ci-validate.yml @@ -60,6 +60,9 @@ jobs: - name: TypeScript type check run: npx tsc --noEmit + - name: TypeScript type check (tests) + run: npm run typecheck:test + actionlint: name: Lint Workflows runs-on: ubuntu-latest diff --git a/package.json b/package.json index 963657c9..9b61f736 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", "test:commands": "tsx tests/command-coverage.ts", + "typecheck:test": "tsc --noEmit -p tsconfig.test.json", "generate": "graphql-codegen --config codegen.config.ts", "generate:usage": "tsx src/main.ts usage --all > USAGE.md", "format": "biome format --write .", diff --git a/tests/unit/client/graphql-client.test.ts b/tests/unit/client/graphql-client.test.ts index 72e709c3..1912091e 100644 --- a/tests/unit/client/graphql-client.test.ts +++ b/tests/unit/client/graphql-client.test.ts @@ -1,7 +1,21 @@ +import type { TypedDocumentNode } from "@graphql-typed-document-node/core"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { GraphQLClient } from "../../../src/client/graphql-client.js"; import { AuthenticationError } from "../../../src/common/errors.js"; +// A stand-in document for the error-path tests. Typing its variables as +// `Record<string, never>` makes `request`'s variables argument optional, so +// these calls can invoke it without variables. +function fakeDocument<TResult = unknown>(): TypedDocumentNode< + TResult, + Record<string, never> +> { + return { kind: "Document", definitions: [] } as unknown as TypedDocumentNode< + TResult, + Record<string, never> + >; +} + // We test the error handling logic by mocking the underlying rawRequest // The constructor creates a real LinearClient, so we mock at module level vi.mock("@linear/sdk", () => { @@ -49,9 +63,7 @@ describe("GraphQLClient", () => { }); const client = new GraphQLClient("bad-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; + const fakeDoc = fakeDocument(); await expect(client.request(fakeDoc)).rejects.toThrow( AuthenticationError, @@ -66,9 +78,7 @@ describe("GraphQLClient", () => { }); const client = new GraphQLClient("bad-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; + const fakeDoc = fakeDocument(); await expect(client.request(fakeDoc)).rejects.toThrow( AuthenticationError, @@ -83,9 +93,7 @@ describe("GraphQLClient", () => { }); const client = new GraphQLClient("good-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; + const fakeDoc = fakeDocument(); try { await client.request(fakeDoc); @@ -101,9 +109,7 @@ describe("GraphQLClient", () => { mockRawRequest.mockResolvedValueOnce({ data: undefined }); const client = new GraphQLClient("good-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; + const fakeDoc = fakeDocument(); await expect(client.request(fakeDoc)).rejects.toThrow( "GraphQL response contained no data", @@ -116,11 +122,9 @@ describe("GraphQLClient", () => { mockRawRequest.mockResolvedValueOnce({ data: { ok: true } }); const client = new GraphQLClient("good-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; + const fakeDoc = fakeDocument<{ ok: boolean }>(); - const result = await client.request<{ ok: boolean }>(fakeDoc); + const result = await client.request(fakeDoc); expect(result).toEqual({ ok: true }); expect(vi.getTimerCount()).toBe(0); @@ -139,9 +143,7 @@ describe("GraphQLClient", () => { }); const client = new GraphQLClient("good-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; + const fakeDoc = fakeDocument(); await expect(client.request(fakeDoc)).rejects.toThrow( "Entity not found", @@ -165,9 +167,7 @@ describe("GraphQLClient", () => { }); const client = new GraphQLClient("good-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; + const fakeDoc = fakeDocument(); const promise = client.request(fakeDoc); const rejection = expect(promise).rejects.toThrow("Request timed out"); @@ -188,9 +188,7 @@ describe("GraphQLClient", () => { .mockResolvedValueOnce({ data: { foo: "bar" } }); const client = new GraphQLClient("good-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; + const fakeDoc = fakeDocument(); vi.useFakeTimers(); try { @@ -215,9 +213,7 @@ describe("GraphQLClient", () => { .mockResolvedValueOnce({ data: { foo: "bar" } }); const client = new GraphQLClient("good-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; + const fakeDoc = fakeDocument(); const promise = client.request(fakeDoc); diff --git a/tests/unit/services/initiative-service.test.ts b/tests/unit/services/initiative-service.test.ts index d30bafd3..59031882 100644 --- a/tests/unit/services/initiative-service.test.ts +++ b/tests/unit/services/initiative-service.test.ts @@ -72,7 +72,7 @@ describe("listInitiatives", () => { after: "cursor-1", includeArchived: true, filter: { name: { eqIgnoreCase: "Growth" } }, - orderBy: { createdAt: "Asc" }, + orderBy: "createdAt", }); expect(client.request).toHaveBeenCalledWith(expect.anything(), { @@ -80,7 +80,7 @@ describe("listInitiatives", () => { after: "cursor-1", includeArchived: true, filter: { name: { eqIgnoreCase: "Growth" } }, - orderBy: { createdAt: "Asc" }, + orderBy: "createdAt", sort: undefined, }); }); diff --git a/tests/unit/services/issue-service.test.ts b/tests/unit/services/issue-service.test.ts index 9728d604..c9b0f39d 100644 --- a/tests/unit/services/issue-service.test.ts +++ b/tests/unit/services/issue-service.test.ts @@ -367,7 +367,7 @@ describe("getIssueByIdentifierWithComments", () => { }); const result = await getIssueByIdentifierWithComments(client, "ENG", 42); - expect(result.comments.nodes[0].user.displayName).toBe("Ada"); + expect(result.comments.nodes[0].user?.displayName).toBe("Ada"); expect(client.request).toHaveBeenCalledWith( GetIssueByIdentifierWithCommentsDocument, { diff --git a/tsconfig.json b/tsconfig.json index a4033c54..0fa2753b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,9 +25,9 @@ "exclude": [ "node_modules", "dist", - // Tests excluded from TypeScript compilation to prevent them from - // being compiled into dist/. Tests are type-checked and validated - // by Vitest at runtime, which provides sufficient type safety. + // Tests are excluded from the *build* so they are never emitted into + // dist/. They are NOT unchecked: tsconfig.test.json runs a dedicated + // `tsc --noEmit` over src + tests (see the "typecheck:test" script and CI). "tests", "**/*.test.ts", "**/*.spec.ts", diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 00000000..c4149329 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + // Allow importing the plain-CommonJS release scripts (scripts/release/*.cjs) + // from their tests without a declaration file. + "allowJs": true, + "types": ["node"] + }, + "include": [ + "src/**/*", + "tests/**/*", + "vitest.config.ts", + "vitest.base.config.ts", + "vitest.integration.config.ts" + ], + // Override the base config's exclude (which drops tests/ and *.test.ts) so + // the test files listed in "include" are actually type-checked here. + "exclude": ["node_modules", "dist"] +} From b7d53a58e6bc3d8195e722ef6724c5f360491ccf Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:22:32 +0200 Subject: [PATCH 50/79] refactor(services): move filter/input construction into service layer Push filter building, sort/status/health parsing, and CRUD input typing out of the command layer into the services. Each service now owns its Pick-based CreateX/UpdateX input types and exposes pure builder/parser helpers (buildInitiativeFilter, buildAttachmentFilter, document filters, parseInitiativeStatus, parseHealth, sort mappers) that operate on pre-resolved UUIDs. Commands keep ID resolution and delegate shaping to the services, tightening layer separation with no behavior change. Closes #195 --- src/commands/attachments.ts | 31 +-- src/commands/documents.ts | 29 +-- src/commands/initiatives/entity.ts | 220 +++++--------------- src/commands/initiatives/updates.ts | 26 +-- src/commands/issues.ts | 12 +- src/commands/labels.ts | 12 +- src/commands/milestones.ts | 4 +- src/commands/projects.ts | 7 +- src/services/attachment-service.ts | 43 +++- src/services/document-service.ts | 48 ++++- src/services/initiative-service.ts | 232 +++++++++++++++++++++- src/services/initiative-update-service.ts | 37 +++- src/services/issue-service.ts | 46 ++++- src/services/label-service.ts | 25 ++- src/services/milestone-service.ts | 20 +- src/services/project-service.ts | 49 ++++- tests/unit/commands/attachments.test.ts | 42 ++-- tests/unit/commands/documents.test.ts | 37 ++-- tests/unit/commands/initiatives.test.ts | 108 ++++++---- 19 files changed, 659 insertions(+), 369 deletions(-) diff --git a/src/commands/attachments.ts b/src/commands/attachments.ts index f1be1d42..c07c7982 100644 --- a/src/commands/attachments.ts +++ b/src/commands/attachments.ts @@ -3,12 +3,10 @@ import { createContext, getRootOpts } from "../common/context.js"; import { invalidParameterError } from "../common/errors.js"; import { handleCommand, outputSuccess } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; -import type { - AttachmentCreateInput, - AttachmentFilter, -} from "../gql/graphql.js"; import { resolveIssueId } from "../resolvers/issue-resolver.js"; import { + buildAttachmentFilter, + type CreateAttachmentInput, createAttachment, deleteAttachment, listAttachments, @@ -67,29 +65,6 @@ function resolveIssueArgument( return issue; } -function buildAttachmentFilter( - options: ListOptions, -): AttachmentFilter | undefined { - const filters: AttachmentFilter[] = []; - - if (options.sourceType) { - filters.push({ sourceType: { eq: options.sourceType } }); - } - if (options.title) { - filters.push({ title: { eqIgnoreCase: options.title } }); - } - if (options.createdAfter) { - filters.push({ createdAt: { gte: options.createdAfter } }); - } - if (options.createdBefore) { - filters.push({ createdAt: { lt: options.createdBefore } }); - } - - if (filters.length === 0) return undefined; - if (filters.length === 1) return filters[0]; - return { and: filters }; -} - export function setupAttachmentsCommands(program: Command): void { const attachments = program .command("attachments") @@ -143,7 +118,7 @@ export function setupAttachmentsCommands(program: Command): void { const issueIdentifier = resolveIssueArgument(issue, options.issue); const ctx = createContext(getRootOpts(command)); const issueId = await resolveIssueId(ctx.sdk, issueIdentifier); - const input: AttachmentCreateInput = { + const input: CreateAttachmentInput = { issueId, title: options.title, url: options.url, diff --git a/src/commands/documents.ts b/src/commands/documents.ts index f73f32da..6097b55e 100644 --- a/src/commands/documents.ts +++ b/src/commands/documents.ts @@ -3,16 +3,18 @@ import { createContext, getRootOpts } from "../common/context.js"; import { invalidParameterError } from "../common/errors.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; -import type { DocumentFilter, DocumentUpdateInput } from "../gql/graphql.js"; import { resolveIssueId } from "../resolvers/issue-resolver.js"; import { resolveProjectId } from "../resolvers/project-resolver.js"; import { resolveTeamId } from "../resolvers/team-resolver.js"; import { listAttachments } from "../services/attachment-service.js"; import { + buildIssueDocumentFilter, + buildProjectDocumentFilter, createDocument, deleteDocument, getDocument, listDocuments, + type UpdateDocumentInput, updateDocument, } from "../services/document-service.js"; @@ -69,25 +71,6 @@ function extractDocumentIdFromUrl(url: string): string | null { } } -function buildIssueDocumentFilter( - issueId: string, - legacyDocumentSlugIds: string[], -): DocumentFilter { - const issueFilter: DocumentFilter = { issue: { id: { eq: issueId } } }; - if (legacyDocumentSlugIds.length === 0) { - return issueFilter; - } - - return { - or: [ - issueFilter, - ...legacyDocumentSlugIds.map((slugId) => ({ - slugId: { eq: slugId }, - })), - ], - }; -} - export const DOCUMENTS_META: DomainMeta = { name: "documents", summary: "long-form markdown docs attached to projects or issues", @@ -142,9 +125,9 @@ export function setupDocumentsCommands(program: Command): void { issueId = await resolveIssueId(ctx.sdk, options.issue); } - let filter: DocumentFilter | undefined; + let filter: ReturnType<typeof buildIssueDocumentFilter> | undefined; if (projectId) { - filter = { project: { id: { eq: projectId } } }; + filter = buildProjectDocumentFilter(projectId); } else if (issueId) { const attachments = await listAttachments(ctx.gql, issueId); const legacyDocumentSlugIds = [ @@ -248,7 +231,7 @@ export function setupDocumentsCommands(program: Command): void { const rootOpts = getRootOpts(command); const ctx = createContext(rootOpts); - const input: DocumentUpdateInput = {}; + const input: UpdateDocumentInput = {}; if (options.title) input.title = options.title; if (options.content) input.content = options.content; if (options.project) { diff --git a/src/commands/initiatives/entity.ts b/src/commands/initiatives/entity.ts index 4d383e62..8670fa8a 100644 --- a/src/commands/initiatives/entity.ts +++ b/src/commands/initiatives/entity.ts @@ -8,14 +8,6 @@ import { outputSuccess, parseLimit, } from "../../common/output.js"; -import type { - InitiativeCreateInput, - InitiativeSortInput, - InitiativeStatus, - InitiativeUpdateInput, - ListInitiativesQueryVariables, - PaginationOrderBy, -} from "../../gql/graphql.js"; import { resolveInitiativeId } from "../../resolvers/initiative-resolver.js"; import { resolveTeamId } from "../../resolvers/team-resolver.js"; import { resolveUserId } from "../../resolvers/user-resolver.js"; @@ -38,10 +30,18 @@ import { } from "../../services/discussion-service.js"; import { archiveInitiative, + buildInitiativeFilter, + type CreateInitiativeInput, createInitiative, deleteInitiative, getInitiative, + type InitiativeFilterInput, + type InitiativeSortBy, listInitiatives, + mapSortByToInitiativeSort, + mapSortByToPaginationOrderBy, + parseInitiativeStatus, + type UpdateInitiativeInput, unarchiveInitiative, updateInitiative, } from "../../services/initiative-service.js"; @@ -187,16 +187,6 @@ interface InitiativeUpdateOptions { sortOrder?: string; } -type InitiativeSortBy = - | "name" - | "createdAt" - | "updatedAt" - | "targetDate" - | "health" - | "healthUpdatedAt" - | "manual" - | "owner"; - function parseSortOrder(value?: string): "asc" | "desc" | undefined { if (!value) return undefined; const normalized = value.toLowerCase(); @@ -226,60 +216,6 @@ function parseSortBy(value?: string): InitiativeSortBy | undefined { ); } -function mapSortByToPaginationOrderBy( - sortBy?: InitiativeSortBy, -): PaginationOrderBy | undefined { - return sortBy === "createdAt" || sortBy === "updatedAt" ? sortBy : undefined; -} - -function mapSortByToInitiativeSort( - sortBy?: InitiativeSortBy, - sortOrder?: "asc" | "desc", -): ListInitiativesQueryVariables["sort"] | undefined { - if (!sortBy) return undefined; - - const withNulls = { - order: sortOrder === "desc" ? "Descending" : "Ascending", - nulls: "last", - } as const; - - const sortEntry: InitiativeSortInput = - sortBy === "manual" - ? { manual: withNulls } - : sortBy === "name" - ? { name: withNulls } - : sortBy === "createdAt" - ? { createdAt: withNulls } - : sortBy === "updatedAt" - ? { updatedAt: withNulls } - : sortBy === "targetDate" - ? { targetDate: withNulls } - : sortBy === "health" - ? { health: withNulls } - : sortBy === "healthUpdatedAt" - ? { healthUpdatedAt: withNulls } - : { owner: withNulls }; - - return [sortEntry]; -} - -const INITIATIVE_STATUS_VALUES = ["Planned", "Active", "Completed"] as const; - -function parseInitiativeStatus(value?: string): InitiativeStatus | undefined { - if (!value) return undefined; - - const normalized = value.toLowerCase(); - const match = INITIATIVE_STATUS_VALUES.find( - (status) => status.toLowerCase() === normalized, - ); - if (match) return match; - - throw invalidParameterError( - "--status", - 'must be one of: "Planned", "Active", "Completed"', - ); -} - function parseSortOrderNumber(value?: string): number | undefined { if (value === undefined) return undefined; const parsed = Number.parseFloat(value); @@ -292,19 +228,6 @@ function parseSortOrderNumber(value?: string): number | undefined { return parsed; } -function applyNullableDateRange( - target: { gte?: string | null; lte?: string | null }, - after?: string, - before?: string, -): void { - if (after !== undefined) { - target.gte = after; - } - if (before !== undefined) { - target.lte = before; - } -} - function getExpandFlags(options: InitiativeExpandOptions): string[] { const map: Array<[boolean | undefined, string]> = [ [options.withProjects, "--with-projects"], @@ -319,110 +242,53 @@ function getExpandFlags(options: InitiativeExpandOptions): string[] { return map.filter(([enabled]) => enabled).map(([, flag]) => flag); } -async function buildInitiativeFilter( +async function resolveInitiativeFilterInput( sdk: LinearSdkClient, options: InitiativeListOptions, -): Promise<ListInitiativesQueryVariables["filter"] | undefined> { - const filter: NonNullable<ListInitiativesQueryVariables["filter"]> = {}; - - if (options.id) { - filter.id = { eq: options.id }; - } - - if (options.slug) { - filter.slugId = { eqIgnoreCase: options.slug }; - } - - if (options.name) { - filter.name = { eqIgnoreCase: options.name }; - } - - const status = parseInitiativeStatus(options.status); - if (status) { - filter.status = { eq: status }; - } - - if (options.health) { - filter.health = { eq: options.health }; +): Promise<InitiativeFilterInput> { + if (options.parent) { + throw invalidParameterError( + "--parent", + "is not supported by current Linear initiatives filter API", + ); } - if (options.healthWithAge) { - filter.healthWithAge = { eq: options.healthWithAge }; - } + const input: InitiativeFilterInput = { + id: options.id, + slug: options.slug, + name: options.name, + status: parseInitiativeStatus(options.status), + health: options.health, + healthWithAge: options.healthWithAge, + targetAfter: options.targetAfter, + targetBefore: options.targetBefore, + startedAfter: options.startedAfter, + startedBefore: options.startedBefore, + completedAfter: options.completedAfter, + completedBefore: options.completedBefore, + createdAfter: options.createdAfter, + createdBefore: options.createdBefore, + updatedAfter: options.updatedAfter, + updatedBefore: options.updatedBefore, + }; if (options.owner) { - const ownerId = await resolveUserId(sdk, options.owner); - filter.owner = { id: { eq: ownerId } }; + input.ownerId = await resolveUserId(sdk, options.owner); } if (options.creator) { - const creatorId = await resolveUserId(sdk, options.creator); - filter.creator = { id: { eq: creatorId } }; + input.creatorId = await resolveUserId(sdk, options.creator); } if (options.team) { - const teamId = await resolveTeamId(sdk, options.team); - filter.teams = { some: { id: { eq: teamId } } }; - } - - if (options.targetAfter || options.targetBefore) { - filter.targetDate = {}; - applyNullableDateRange( - filter.targetDate, - options.targetAfter, - options.targetBefore, - ); - } - - if (options.startedAfter || options.startedBefore) { - filter.startedAt = {}; - applyNullableDateRange( - filter.startedAt, - options.startedAfter, - options.startedBefore, - ); - } - - if (options.completedAfter || options.completedBefore) { - filter.completedAt = {}; - applyNullableDateRange( - filter.completedAt, - options.completedAfter, - options.completedBefore, - ); - } - - if (options.createdAfter || options.createdBefore) { - filter.createdAt = {}; - applyNullableDateRange( - filter.createdAt, - options.createdAfter, - options.createdBefore, - ); - } - - if (options.updatedAfter || options.updatedBefore) { - filter.updatedAt = {}; - applyNullableDateRange( - filter.updatedAt, - options.updatedAfter, - options.updatedBefore, - ); + input.teamId = await resolveTeamId(sdk, options.team); } if (options.ancestor) { - const ancestorId = await resolveInitiativeId(sdk, options.ancestor); - filter.ancestors = { some: { id: { eq: ancestorId } } }; - } - - if (options.parent) { - throw invalidParameterError( - "--parent", - "is not supported by current Linear initiatives filter API", - ); + input.ancestorId = await resolveInitiativeId(sdk, options.ancestor); } - return Object.keys(filter).length > 0 ? filter : undefined; + return input; } export function setupInitiativeEntityCommands(initiatives: Command): void { @@ -497,7 +363,11 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { const orderBy = mapSortByToPaginationOrderBy(sortBy); const sort = mapSortByToInitiativeSort(sortBy, sortOrder); - const filter = await buildInitiativeFilter(ctx.sdk, options); + const filterInput = await resolveInitiativeFilterInput( + ctx.sdk, + options, + ); + const filter = buildInitiativeFilter(filterInput); const result = await listInitiatives(ctx.gql, { limit: parseLimit(options.limit), @@ -815,7 +685,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { async (name, options, command) => { const ctx = createContext(getRootOpts(command)); - const input: InitiativeCreateInput = { name }; + const input: CreateInitiativeInput = { name }; if (options.description !== undefined) { input.description = options.description; @@ -865,7 +735,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { const ctx = createContext(getRootOpts(command)); const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); - const input: InitiativeUpdateInput = {}; + const input: UpdateInitiativeInput = {}; if (options.name !== undefined) { input.name = options.name; diff --git a/src/commands/initiatives/updates.ts b/src/commands/initiatives/updates.ts index 988786fd..b9e664fb 100644 --- a/src/commands/initiatives/updates.ts +++ b/src/commands/initiatives/updates.ts @@ -6,17 +6,15 @@ import { outputSuccess, parseLimit, } from "../../common/output.js"; -import type { - InitiativeUpdateCreateInput, - InitiativeUpdateHealthType, - InitiativeUpdateUpdateInput, -} from "../../gql/graphql.js"; import { resolveInitiativeId } from "../../resolvers/initiative-resolver.js"; import { archiveInitiativeUpdate, + type CreateInitiativeUpdateInput, createInitiativeUpdate, getInitiativeUpdate, listInitiativeUpdates, + parseHealth, + type UpdateInitiativeUpdateInput, unarchiveInitiativeUpdate, updateInitiativeUpdate, } from "../../services/initiative-update-service.js"; @@ -39,20 +37,6 @@ interface InitiativeUpdatesUpdateOptions { health?: string; } -function parseHealth(value?: string): InitiativeUpdateHealthType | undefined { - if (!value) return undefined; - - const normalized = value.trim().toLowerCase(); - if (normalized === "ontrack") return "onTrack"; - if (normalized === "atrisk") return "atRisk"; - if (normalized === "offtrack") return "offTrack"; - - throw invalidParameterError( - "--health", - 'must be one of: "onTrack", "atRisk", "offTrack"', - ); -} - export function setupInitiativeUpdateCommands(initiatives: Command): void { const updates = initiatives .command("updates") @@ -122,7 +106,7 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { options.initiative, ); - const input: InitiativeUpdateCreateInput = { initiativeId }; + const input: CreateInitiativeUpdateInput = { initiativeId }; if (options.body !== undefined) { input.body = options.body; @@ -152,7 +136,7 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { ]; const ctx = createContext(getRootOpts(command)); - const input: InitiativeUpdateUpdateInput = {}; + const input: UpdateInitiativeUpdateInput = {}; if (options.body !== undefined) { input.body = options.body; diff --git a/src/commands/issues.ts b/src/commands/issues.ts index a5028d00..d488606e 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -18,11 +18,7 @@ import { import { commandAction, outputSuccess, parseLimit } from "../common/output.js"; import { resolveFilterOptions } from "../common/resolve-filters.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; -import type { - IssueCreateInput, - IssueRelationType, - IssueUpdateInput, -} from "../gql/graphql.js"; +import type { IssueRelationType } from "../gql/graphql.js"; import { resolveCycleId } from "../resolvers/cycle-resolver.js"; import { resolveIssueEstimateContext, @@ -63,6 +59,7 @@ import { } from "../services/issue-relation-service.js"; import { archiveIssue, + type CreateIssueInput, createIssue, deleteIssue, getIssue, @@ -77,6 +74,7 @@ import { getIssueWithReactions, listIssues, searchIssues, + type UpdateIssueInput, unarchiveIssue, updateIssue, } from "../services/issue-service.js"; @@ -1140,7 +1138,7 @@ export function setupIssuesCommands(program: Command): void { }); } - const input: IssueCreateInput = { + const input: CreateIssueInput = { title, teamId, }; @@ -1342,7 +1340,7 @@ export function setupIssuesCommands(program: Command): void { ? await getIssue(ctx.gql, resolvedIssueId) : undefined; - const input: IssueUpdateInput = {}; + const input: UpdateIssueInput = {}; if (options.title) { input.title = options.title; diff --git a/src/commands/labels.ts b/src/commands/labels.ts index 2099cbb5..c36b0f4c 100644 --- a/src/commands/labels.ts +++ b/src/commands/labels.ts @@ -7,16 +7,13 @@ import { import { invalidParameterError } from "../common/errors.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; -import type { - IssueLabelCreateInput, - IssueLabelUpdateInput, -} from "../gql/graphql.js"; import { type LabelResolverScope, resolveLabelId, } from "../resolvers/label-resolver.js"; import { resolveTeamId } from "../resolvers/team-resolver.js"; import { + type CreateLabelInput, createLabel, deleteLabel, getLabel, @@ -24,6 +21,7 @@ import { type LabelType, listLabels, listProjectLabels, + type UpdateLabelInput, updateLabel, } from "../services/label-service.js"; @@ -113,8 +111,8 @@ async function resolveIssueLabelLookup( return { ctx, labelId }; } -function buildUpdateInput(options: UpdateLabelOptions): IssueLabelUpdateInput { - const input: IssueLabelUpdateInput = {}; +function buildUpdateInput(options: UpdateLabelOptions): UpdateLabelInput { + const input: UpdateLabelInput = {}; const color = parseLabelColor(options.color); if (options.name) { @@ -240,7 +238,7 @@ export function setupLabelsCommands(program: Command): void { ]; const ctx = createContext(getRootOpts(command)); - const input: IssueLabelCreateInput = { name }; + const input: CreateLabelInput = { name }; const color = parseLabelColor(options.color); if (options.team) { diff --git a/src/commands/milestones.ts b/src/commands/milestones.ts index 03715188..58f3cbf0 100644 --- a/src/commands/milestones.ts +++ b/src/commands/milestones.ts @@ -2,13 +2,13 @@ import type { Command } from "commander"; 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"; import { resolveMilestoneId } from "../resolvers/milestone-resolver.js"; import { resolveProjectId } from "../resolvers/project-resolver.js"; import { createMilestone, getMilestone, listMilestones, + type UpdateMilestoneInput, updateMilestone, } from "../services/milestone-service.js"; @@ -177,7 +177,7 @@ export function setupMilestonesCommands(program: Command): void { ); // Build update input (only include provided fields) - const updateInput: ProjectMilestoneUpdateInput = {}; + const updateInput: UpdateMilestoneInput = {}; if (options.name !== undefined) updateInput.name = options.name; if (options.description !== undefined) { updateInput.description = options.description; diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 9adca03a..a79d7bac 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -5,7 +5,6 @@ import { resolveReactionEmojiInput } from "../common/emoji.js"; import { invalidParameterError } from "../common/errors.js"; import { commandAction, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; -import type { ProjectCreateInput, ProjectUpdateInput } from "../gql/graphql.js"; import { resolveProjectId, resolveProjectLabelIds, @@ -32,10 +31,12 @@ import { } from "../services/discussion-service.js"; import { archiveProject, + type CreateProjectInput, createProject, deleteProject, getProject, listProjects, + type UpdateProjectInput, unarchiveProject, updateProject, } from "../services/project-service.js"; @@ -577,7 +578,7 @@ export function setupProjectsCommands(program: Command): void { teamNames.map((t) => resolveTeamId(ctx.sdk, t)), ); - const input: ProjectCreateInput = { + const input: CreateProjectInput = { name, teamIds, }; @@ -723,7 +724,7 @@ export function setupProjectsCommands(program: Command): void { ? await getProject(ctx.gql, projectId) : undefined; - const input: ProjectUpdateInput = {}; + const input: UpdateProjectInput = {}; if (options.name) { input.name = options.name; diff --git a/src/services/attachment-service.ts b/src/services/attachment-service.ts index 444fc860..4e64383b 100644 --- a/src/services/attachment-service.ts +++ b/src/services/attachment-service.ts @@ -15,11 +15,50 @@ export type AttachmentListItem = export type CreatedAttachment = AttachmentCreateMutation["attachmentCreate"]["attachment"]; +// Service-owned input type (UUIDs pre-resolved by the command). +export type CreateAttachmentInput = Pick< + AttachmentCreateInput, + "issueId" | "title" | "url" | "subtitle" | "commentBody" | "iconUrl" +>; + +export interface AttachmentFilterOptions { + sourceType?: string; + title?: string; + createdAfter?: string; + createdBefore?: string; +} + +export function buildAttachmentFilter( + options: AttachmentFilterOptions, +): AttachmentFilter | undefined { + const filters: AttachmentFilter[] = []; + + if (options.sourceType) { + filters.push({ sourceType: { eq: options.sourceType } }); + } + if (options.title) { + filters.push({ title: { eqIgnoreCase: options.title } }); + } + if (options.createdAfter) { + filters.push({ createdAt: { gte: options.createdAfter } }); + } + if (options.createdBefore) { + filters.push({ createdAt: { lt: options.createdBefore } }); + } + + if (filters.length === 0) return undefined; + if (filters.length === 1) return filters[0]; + return { and: filters }; +} + export async function createAttachment( client: GraphQLClient, - input: AttachmentCreateInput, + input: CreateAttachmentInput, ): Promise<CreatedAttachment> { - const result = await client.request(AttachmentCreateDocument, { input }); + const gqlInput: AttachmentCreateInput = input; + const result = await client.request(AttachmentCreateDocument, { + input: gqlInput, + }); if (!result.attachmentCreate.success || !result.attachmentCreate.attachment) { throw new Error("Failed to create attachment"); diff --git a/src/services/document-service.ts b/src/services/document-service.ts index 78fd5d6a..2a6798d1 100644 --- a/src/services/document-service.ts +++ b/src/services/document-service.ts @@ -23,6 +23,39 @@ export type CreatedDocument = export type UpdatedDocument = DocumentUpdateMutation["documentUpdate"]["document"]; +// Service-owned input types (UUIDs pre-resolved by the command). +export type CreateDocumentInput = Pick< + DocumentCreateInput, + "title" | "content" | "projectId" | "teamId" | "issueId" | "icon" | "color" +>; +export type UpdateDocumentInput = Pick< + DocumentUpdateInput, + "title" | "content" | "projectId" | "icon" | "color" +>; + +export function buildProjectDocumentFilter(projectId: string): DocumentFilter { + return { project: { id: { eq: projectId } } }; +} + +export function buildIssueDocumentFilter( + issueId: string, + legacyDocumentSlugIds: string[], +): DocumentFilter { + const issueFilter: DocumentFilter = { issue: { id: { eq: issueId } } }; + if (legacyDocumentSlugIds.length === 0) { + return issueFilter; + } + + return { + or: [ + issueFilter, + ...legacyDocumentSlugIds.map((slugId) => ({ + slugId: { eq: slugId }, + })), + ], + }; +} + export async function getDocument( client: GraphQLClient, id: string, @@ -40,9 +73,12 @@ export async function getDocument( export async function createDocument( client: GraphQLClient, - input: DocumentCreateInput, + input: CreateDocumentInput, ): Promise<CreatedDocument> { - const result = await client.request(DocumentCreateDocument, { input }); + const gqlInput: DocumentCreateInput = input; + const result = await client.request(DocumentCreateDocument, { + input: gqlInput, + }); if (!result.documentCreate.success || !result.documentCreate.document) { throw new Error("Failed to create document"); @@ -54,9 +90,13 @@ export async function createDocument( export async function updateDocument( client: GraphQLClient, id: string, - input: DocumentUpdateInput, + input: UpdateDocumentInput, ): Promise<UpdatedDocument> { - const result = await client.request(DocumentUpdateDocument, { id, input }); + const gqlInput: DocumentUpdateInput = input; + const result = await client.request(DocumentUpdateDocument, { + id, + input: gqlInput, + }); if (!result.documentUpdate.success || !result.documentUpdate.document) { throw new Error("Failed to update document"); diff --git a/src/services/initiative-service.ts b/src/services/initiative-service.ts index 2c556138..38dfb955 100644 --- a/src/services/initiative-service.ts +++ b/src/services/initiative-service.ts @@ -11,10 +11,13 @@ import { GetInitiativeDocument, type GetInitiativeQuery, type InitiativeCreateInput, + type InitiativeSortInput, + type InitiativeStatus, type InitiativeUpdateInput, ListInitiativesDocument, type ListInitiativesQuery, type ListInitiativesQueryVariables, + type PaginationOrderBy, UnarchiveInitiativeDocument, type UnarchiveInitiativeMutation, UpdateInitiativeDocument, @@ -42,6 +45,225 @@ export type DeletedInitiative = { success: true; }; +// Service-owned input types (UUIDs pre-resolved by the command). +export type CreateInitiativeInput = Pick< + InitiativeCreateInput, + | "name" + | "description" + | "content" + | "ownerId" + | "status" + | "targetDate" + | "sortOrder" +>; +export type UpdateInitiativeInput = Pick< + InitiativeUpdateInput, + | "name" + | "description" + | "content" + | "ownerId" + | "status" + | "targetDate" + | "sortOrder" +>; + +export type InitiativeSortBy = + | "name" + | "createdAt" + | "updatedAt" + | "targetDate" + | "health" + | "healthUpdatedAt" + | "manual" + | "owner"; + +const INITIATIVE_STATUS_VALUES = ["Planned", "Active", "Completed"] as const; + +export function parseInitiativeStatus( + value?: string, +): InitiativeStatus | undefined { + if (!value) return undefined; + + const normalized = value.toLowerCase(); + const match = INITIATIVE_STATUS_VALUES.find( + (status) => status.toLowerCase() === normalized, + ); + if (match) return match; + + throw invalidParameterError( + "--status", + 'must be one of: "Planned", "Active", "Completed"', + ); +} + +export function mapSortByToPaginationOrderBy( + sortBy?: InitiativeSortBy, +): PaginationOrderBy | undefined { + return sortBy === "createdAt" || sortBy === "updatedAt" ? sortBy : undefined; +} + +export function mapSortByToInitiativeSort( + sortBy?: InitiativeSortBy, + sortOrder?: "asc" | "desc", +): ListInitiativesQueryVariables["sort"] | undefined { + if (!sortBy) return undefined; + + const withNulls = { + order: sortOrder === "desc" ? "Descending" : "Ascending", + nulls: "last", + } as const; + + const sortEntry: InitiativeSortInput = + sortBy === "manual" + ? { manual: withNulls } + : sortBy === "name" + ? { name: withNulls } + : sortBy === "createdAt" + ? { createdAt: withNulls } + : sortBy === "updatedAt" + ? { updatedAt: withNulls } + : sortBy === "targetDate" + ? { targetDate: withNulls } + : sortBy === "health" + ? { health: withNulls } + : sortBy === "healthUpdatedAt" + ? { healthUpdatedAt: withNulls } + : { owner: withNulls }; + + return [sortEntry]; +} + +// Filter input carrying pre-resolved UUIDs and a parsed status; the command +// resolves human-friendly IDs before calling buildInitiativeFilter. +export interface InitiativeFilterInput { + id?: string; + slug?: string; + name?: string; + status?: InitiativeStatus; + health?: string; + healthWithAge?: string; + ownerId?: string; + creatorId?: string; + teamId?: string; + targetAfter?: string; + targetBefore?: string; + startedAfter?: string; + startedBefore?: string; + completedAfter?: string; + completedBefore?: string; + createdAfter?: string; + createdBefore?: string; + updatedAfter?: string; + updatedBefore?: string; + ancestorId?: string; +} + +function applyNullableDateRange( + target: { gte?: string | null; lte?: string | null }, + after?: string, + before?: string, +): void { + if (after !== undefined) { + target.gte = after; + } + if (before !== undefined) { + target.lte = before; + } +} + +export function buildInitiativeFilter( + input: InitiativeFilterInput, +): ListInitiativesQueryVariables["filter"] | undefined { + const filter: NonNullable<ListInitiativesQueryVariables["filter"]> = {}; + + if (input.id) { + filter.id = { eq: input.id }; + } + + if (input.slug) { + filter.slugId = { eqIgnoreCase: input.slug }; + } + + if (input.name) { + filter.name = { eqIgnoreCase: input.name }; + } + + if (input.status) { + filter.status = { eq: input.status }; + } + + if (input.health) { + filter.health = { eq: input.health }; + } + + if (input.healthWithAge) { + filter.healthWithAge = { eq: input.healthWithAge }; + } + + if (input.ownerId) { + filter.owner = { id: { eq: input.ownerId } }; + } + + if (input.creatorId) { + filter.creator = { id: { eq: input.creatorId } }; + } + + if (input.teamId) { + filter.teams = { some: { id: { eq: input.teamId } } }; + } + + if (input.targetAfter || input.targetBefore) { + filter.targetDate = {}; + applyNullableDateRange( + filter.targetDate, + input.targetAfter, + input.targetBefore, + ); + } + + if (input.startedAfter || input.startedBefore) { + filter.startedAt = {}; + applyNullableDateRange( + filter.startedAt, + input.startedAfter, + input.startedBefore, + ); + } + + if (input.completedAfter || input.completedBefore) { + filter.completedAt = {}; + applyNullableDateRange( + filter.completedAt, + input.completedAfter, + input.completedBefore, + ); + } + + if (input.createdAfter || input.createdBefore) { + filter.createdAt = {}; + applyNullableDateRange( + filter.createdAt, + input.createdAfter, + input.createdBefore, + ); + } + + if (input.updatedAfter || input.updatedBefore) { + filter.updatedAt = {}; + applyNullableDateRange( + filter.updatedAt, + input.updatedAfter, + input.updatedBefore, + ); + } + + if (input.ancestorId) { + filter.ancestors = { some: { id: { eq: input.ancestorId } } }; + } + + return Object.keys(filter).length > 0 ? filter : undefined; +} + export interface InitiativeListOptions { limit?: number; after?: string; @@ -96,10 +318,11 @@ export async function getInitiative( export async function createInitiative( client: GraphQLClient, - input: InitiativeCreateInput, + input: CreateInitiativeInput, ): Promise<CreatedInitiative> { + const gqlInput: InitiativeCreateInput = input; const result = await client.request(CreateInitiativeDocument, { - input, + input: gqlInput, }); if (!result.initiativeCreate.success || !result.initiativeCreate.initiative) { @@ -112,7 +335,7 @@ export async function createInitiative( export async function updateInitiative( client: GraphQLClient, id: string, - input: InitiativeUpdateInput, + input: UpdateInitiativeInput, ): Promise<UpdatedInitiative> { const hasAtLeastOneField = Object.values(input).some( (value) => value !== undefined, @@ -125,9 +348,10 @@ export async function updateInitiative( ); } + const gqlInput: InitiativeUpdateInput = input; const result = await client.request(UpdateInitiativeDocument, { id, - input, + input: gqlInput, }); if (!result.initiativeUpdate.success || !result.initiativeUpdate.initiative) { diff --git a/src/services/initiative-update-service.ts b/src/services/initiative-update-service.ts index f7478ab0..edc6fcf3 100644 --- a/src/services/initiative-update-service.ts +++ b/src/services/initiative-update-service.ts @@ -9,6 +9,7 @@ import { GetInitiativeUpdateDocument, type GetInitiativeUpdateQuery, type InitiativeUpdateCreateInput, + type InitiativeUpdateHealthType, type InitiativeUpdateUpdateInput, ListInitiativeUpdatesDocument, type ListInitiativeUpdatesQuery, @@ -44,6 +45,32 @@ export interface InitiativeUpdateListOptions { includeArchived?: boolean; } +// Service-owned input types (UUIDs pre-resolved by the command). +export type CreateInitiativeUpdateInput = Pick< + InitiativeUpdateCreateInput, + "initiativeId" | "body" | "health" +>; +export type UpdateInitiativeUpdateInput = Pick< + InitiativeUpdateUpdateInput, + "body" | "health" +>; + +export function parseHealth( + value?: string, +): InitiativeUpdateHealthType | undefined { + if (!value) return undefined; + + const normalized = value.trim().toLowerCase(); + if (normalized === "ontrack") return "onTrack"; + if (normalized === "atrisk") return "atRisk"; + if (normalized === "offtrack") return "offTrack"; + + throw invalidParameterError( + "--health", + 'must be one of: "onTrack", "atRisk", "offTrack"', + ); +} + export async function listInitiativeUpdates( client: GraphQLClient, options: InitiativeUpdateListOptions, @@ -78,10 +105,11 @@ export async function getInitiativeUpdate( export async function createInitiativeUpdate( client: GraphQLClient, - input: InitiativeUpdateCreateInput, + input: CreateInitiativeUpdateInput, ): Promise<CreatedInitiativeUpdate> { + const gqlInput: InitiativeUpdateCreateInput = input; const result = await client.request(CreateInitiativeUpdateDocument, { - input, + input: gqlInput, }); if ( @@ -97,7 +125,7 @@ export async function createInitiativeUpdate( export async function updateInitiativeUpdate( client: GraphQLClient, id: string, - input: InitiativeUpdateUpdateInput, + input: UpdateInitiativeUpdateInput, ): Promise<UpdatedInitiativeUpdate> { const hasAtLeastOneField = Object.values(input).some( (value) => value !== undefined, @@ -110,9 +138,10 @@ export async function updateInitiativeUpdate( ); } + const gqlInput: InitiativeUpdateUpdateInput = input; const result = await client.request(UpdateInitiativeUpdateDocument, { id, - input, + input: gqlInput, }); if ( diff --git a/src/services/issue-service.ts b/src/services/issue-service.ts index f587b74f..b62087f3 100644 --- a/src/services/issue-service.ts +++ b/src/services/issue-service.ts @@ -76,6 +76,39 @@ export type UpdatedIssue = NonNullable< UpdateIssueMutation["issueUpdate"]["issue"] >; +// Service-owned input types (UUIDs pre-resolved by the command). +export type CreateIssueInput = Pick< + IssueCreateInput, + | "title" + | "teamId" + | "description" + | "assigneeId" + | "priority" + | "estimate" + | "projectId" + | "labelIds" + | "projectMilestoneId" + | "cycleId" + | "stateId" + | "parentId" + | "dueDate" +>; +export type UpdateIssueInput = Pick< + IssueUpdateInput, + | "title" + | "description" + | "stateId" + | "priority" + | "estimate" + | "assigneeId" + | "projectId" + | "labelIds" + | "parentId" + | "projectMilestoneId" + | "cycleId" + | "dueDate" +>; + const NON_COMPLETED_ISSUES_FILTER: IssueFilter = { state: { type: { neq: "completed" } }, }; @@ -403,9 +436,10 @@ export async function searchIssues( export async function createIssue( client: GraphQLClient, - input: IssueCreateInput, + input: CreateIssueInput, ): Promise<CreatedIssue> { - const result = await client.request(CreateIssueDocument, { input }); + const gqlInput: IssueCreateInput = input; + const result = await client.request(CreateIssueDocument, { input: gqlInput }); if (!result.issueCreate.success || !result.issueCreate.issue) { throw new Error("Failed to create issue"); } @@ -415,9 +449,13 @@ export async function createIssue( export async function updateIssue( client: GraphQLClient, id: string, - input: IssueUpdateInput, + input: UpdateIssueInput, ): Promise<UpdatedIssue> { - const result = await client.request(UpdateIssueDocument, { id, input }); + const gqlInput: IssueUpdateInput = input; + const result = await client.request(UpdateIssueDocument, { + id, + input: gqlInput, + }); if (!result.issueUpdate.success || !result.issueUpdate.issue) { throw new Error("Failed to update issue"); } diff --git a/src/services/label-service.ts b/src/services/label-service.ts index 73437dbd..8bc2a6d6 100644 --- a/src/services/label-service.ts +++ b/src/services/label-service.ts @@ -32,6 +32,16 @@ export interface ListLabelOptions extends PaginationOptions { scope?: LabelScope; } +// Service-owned input types (UUIDs pre-resolved by the command). +export type CreateLabelInput = Pick< + IssueLabelCreateInput, + "name" | "teamId" | "color" | "description" +>; +export type UpdateLabelInput = Pick< + IssueLabelUpdateInput, + "name" | "color" | "description" +>; + function mapIssueLabel(label: { id: string; name: string; @@ -64,9 +74,12 @@ export async function getLabel( export async function createLabel( client: GraphQLClient, - input: IssueLabelCreateInput, + input: CreateLabelInput, ): Promise<Label> { - const result = await client.request(CreateIssueLabelDocument, { input }); + const gqlInput: IssueLabelCreateInput = input; + const result = await client.request(CreateIssueLabelDocument, { + input: gqlInput, + }); if (!result.issueLabelCreate.success) { throw new Error(`Failed to create label "${input.name}"`); @@ -78,9 +91,13 @@ export async function createLabel( export async function updateLabel( client: GraphQLClient, id: string, - input: IssueLabelUpdateInput, + input: UpdateLabelInput, ): Promise<Label> { - const result = await client.request(UpdateIssueLabelDocument, { id, input }); + const gqlInput: IssueLabelUpdateInput = input; + const result = await client.request(UpdateIssueLabelDocument, { + id, + input: gqlInput, + }); if (!result.issueLabelUpdate.success) { throw new Error(`Failed to update label "${id}"`); diff --git a/src/services/milestone-service.ts b/src/services/milestone-service.ts index 8afaa851..0ff00487 100644 --- a/src/services/milestone-service.ts +++ b/src/services/milestone-service.ts @@ -26,6 +26,16 @@ export type UpdatedMilestone = NonNullable< UpdateProjectMilestoneMutation["projectMilestoneUpdate"]["projectMilestone"] >; +// Service-owned input types (UUIDs pre-resolved by the command). +export type CreateMilestoneInput = Pick< + ProjectMilestoneCreateInput, + "projectId" | "name" | "description" | "targetDate" +>; +export type UpdateMilestoneInput = Pick< + ProjectMilestoneUpdateInput, + "name" | "description" | "targetDate" | "sortOrder" +>; + export async function listMilestones( client: GraphQLClient, projectId: string, @@ -66,10 +76,11 @@ export async function getMilestone( export async function createMilestone( client: GraphQLClient, - input: ProjectMilestoneCreateInput, + input: CreateMilestoneInput, ): Promise<CreatedMilestone> { + const gqlInput: ProjectMilestoneCreateInput = input; const result = await client.request(CreateProjectMilestoneDocument, { - input, + input: gqlInput, }); if ( @@ -85,11 +96,12 @@ export async function createMilestone( export async function updateMilestone( client: GraphQLClient, id: string, - input: ProjectMilestoneUpdateInput, + input: UpdateMilestoneInput, ): Promise<UpdatedMilestone> { + const gqlInput: ProjectMilestoneUpdateInput = input; const result = await client.request(UpdateProjectMilestoneDocument, { id, - input, + input: gqlInput, }); if ( diff --git a/src/services/project-service.ts b/src/services/project-service.ts index 27cacdd0..fa0f649e 100644 --- a/src/services/project-service.ts +++ b/src/services/project-service.ts @@ -38,6 +38,40 @@ export type DeletedProject = { success: true; }; +// Service-owned input types (UUIDs pre-resolved by the command). +export type CreateProjectInput = Pick< + ProjectCreateInput, + | "name" + | "teamIds" + | "description" + | "content" + | "icon" + | "color" + | "leadId" + | "memberIds" + | "priority" + | "statusId" + | "startDate" + | "targetDate" + | "labelIds" +>; +export type UpdateProjectInput = Pick< + ProjectUpdateInput, + | "name" + | "description" + | "content" + | "icon" + | "color" + | "leadId" + | "memberIds" + | "priority" + | "statusId" + | "startDate" + | "targetDate" + | "teamIds" + | "labelIds" +>; + export interface ProjectListOptions extends PaginationOptions { includeArchived?: boolean; } @@ -97,9 +131,12 @@ export async function getProject( export async function createProject( client: GraphQLClient, - input: ProjectCreateInput, + input: CreateProjectInput, ): Promise<CreatedProject> { - const result = await client.request(CreateProjectDocument, { input }); + const gqlInput: ProjectCreateInput = input; + const result = await client.request(CreateProjectDocument, { + input: gqlInput, + }); if (!result.projectCreate.success || !result.projectCreate.project) { throw new Error(`Failed to create project "${input.name}"`); @@ -111,9 +148,13 @@ export async function createProject( export async function updateProject( client: GraphQLClient, id: string, - input: ProjectUpdateInput, + input: UpdateProjectInput, ): Promise<UpdatedProject> { - const result = await client.request(UpdateProjectDocument, { id, input }); + const gqlInput: ProjectUpdateInput = input; + const result = await client.request(UpdateProjectDocument, { + id, + input: gqlInput, + }); if (!result.projectUpdate.success || !result.projectUpdate.project) { throw new Error(`Failed to update project "${id}"`); diff --git a/tests/unit/commands/attachments.test.ts b/tests/unit/commands/attachments.test.ts index 4d9d702d..0345bd81 100644 --- a/tests/unit/commands/attachments.test.ts +++ b/tests/unit/commands/attachments.test.ts @@ -22,22 +22,32 @@ vi.mock("../../../src/resolvers/issue-resolver.js", () => ({ resolveIssueId: vi.fn().mockResolvedValue("resolved-issue-uuid"), })); -vi.mock("../../../src/services/attachment-service.js", () => ({ - createAttachment: vi.fn().mockResolvedValue({ - id: "att-1", - title: "Test", - url: "https://example.com", - }), - deleteAttachment: vi.fn().mockResolvedValue({ - id: "att-1", - success: true, - }), - listAttachments: vi - .fn() - .mockResolvedValue([ - { id: "att-1", title: "PR #42", sourceType: "github" }, - ]), -})); +vi.mock( + "../../../src/services/attachment-service.js", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/services/attachment-service.js") + >(); + return { + ...actual, + createAttachment: vi.fn().mockResolvedValue({ + id: "att-1", + title: "Test", + url: "https://example.com", + }), + deleteAttachment: vi.fn().mockResolvedValue({ + id: "att-1", + success: true, + }), + listAttachments: vi + .fn() + .mockResolvedValue([ + { id: "att-1", title: "PR #42", sourceType: "github" }, + ]), + }; + }, +); import { setupAttachmentsCommands } from "../../../src/commands/attachments.js"; import { resolveIssueId } from "../../../src/resolvers/issue-resolver.js"; diff --git a/tests/unit/commands/documents.test.ts b/tests/unit/commands/documents.test.ts index 8f779619..5ef2eb2a 100644 --- a/tests/unit/commands/documents.test.ts +++ b/tests/unit/commands/documents.test.ts @@ -34,20 +34,29 @@ vi.mock("../../../src/services/attachment-service.js", () => ({ listAttachments: vi.fn().mockResolvedValue([]), })); -vi.mock("../../../src/services/document-service.js", () => ({ - createDocument: vi.fn().mockResolvedValue({ - id: "doc-1", - title: "Runbook", - url: "https://linear.app/example/document/runbook-abc123", - }), - deleteDocument: vi.fn().mockResolvedValue({ id: "doc-1", success: true }), - getDocument: vi.fn().mockResolvedValue({ id: "doc-1", title: "Runbook" }), - listDocuments: vi.fn().mockResolvedValue({ - nodes: [{ id: "doc-1", title: "Runbook" }], - pageInfo: { hasNextPage: false, endCursor: null }, - }), - updateDocument: vi.fn().mockResolvedValue({ id: "doc-1", title: "Runbook" }), -})); +vi.mock("../../../src/services/document-service.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/services/document-service.js") + >(); + return { + ...actual, + createDocument: vi.fn().mockResolvedValue({ + id: "doc-1", + title: "Runbook", + url: "https://linear.app/example/document/runbook-abc123", + }), + deleteDocument: vi.fn().mockResolvedValue({ id: "doc-1", success: true }), + getDocument: vi.fn().mockResolvedValue({ id: "doc-1", title: "Runbook" }), + listDocuments: vi.fn().mockResolvedValue({ + nodes: [{ id: "doc-1", title: "Runbook" }], + pageInfo: { hasNextPage: false, endCursor: null }, + }), + updateDocument: vi + .fn() + .mockResolvedValue({ id: "doc-1", title: "Runbook" }), + }; +}); import { setupDocumentsCommands } from "../../../src/commands/documents.js"; import { resolveIssueId } from "../../../src/resolvers/issue-resolver.js"; diff --git a/tests/unit/commands/initiatives.test.ts b/tests/unit/commands/initiatives.test.ts index 36ce0afb..21158db9 100644 --- a/tests/unit/commands/initiatives.test.ts +++ b/tests/unit/commands/initiatives.test.ts @@ -40,28 +40,40 @@ vi.mock("../../../src/resolvers/user-resolver.js", () => ({ resolveUserId: vi.fn().mockResolvedValue("resolved-user-uuid"), })); -vi.mock("../../../src/services/initiative-service.js", () => ({ - listInitiatives: vi.fn().mockResolvedValue({ - nodes: [], - pageInfo: { hasNextPage: false, endCursor: null }, - }), - getInitiative: vi.fn().mockResolvedValue({ id: "resolved-initiative-uuid" }), - createInitiative: vi - .fn() - .mockResolvedValue({ id: "resolved-initiative-uuid" }), - updateInitiative: vi - .fn() - .mockResolvedValue({ id: "resolved-initiative-uuid" }), - archiveInitiative: vi - .fn() - .mockResolvedValue({ id: "resolved-initiative-uuid" }), - unarchiveInitiative: vi - .fn() - .mockResolvedValue({ id: "resolved-initiative-uuid" }), - deleteInitiative: vi - .fn() - .mockResolvedValue({ id: "resolved-initiative-uuid", success: true }), -})); +vi.mock( + "../../../src/services/initiative-service.js", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/services/initiative-service.js") + >(); + return { + ...actual, + listInitiatives: vi.fn().mockResolvedValue({ + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }), + getInitiative: vi + .fn() + .mockResolvedValue({ id: "resolved-initiative-uuid" }), + createInitiative: vi + .fn() + .mockResolvedValue({ id: "resolved-initiative-uuid" }), + updateInitiative: vi + .fn() + .mockResolvedValue({ id: "resolved-initiative-uuid" }), + archiveInitiative: vi + .fn() + .mockResolvedValue({ id: "resolved-initiative-uuid" }), + unarchiveInitiative: vi + .fn() + .mockResolvedValue({ id: "resolved-initiative-uuid" }), + deleteInitiative: vi + .fn() + .mockResolvedValue({ id: "resolved-initiative-uuid", success: true }), + }; + }, +); vi.mock("../../../src/services/initiative-relation-service.js", () => ({ createInitiativeRelation: vi @@ -81,27 +93,37 @@ vi.mock("../../../src/services/initiative-project-service.js", () => ({ .mockResolvedValue({ id: "resolved-link-uuid", success: true }), })); -vi.mock("../../../src/services/initiative-update-service.js", () => ({ - listInitiativeUpdates: vi.fn().mockResolvedValue({ - nodes: [], - pageInfo: { hasNextPage: false, endCursor: null }, - }), - getInitiativeUpdate: vi - .fn() - .mockResolvedValue({ id: "resolved-update-uuid" }), - createInitiativeUpdate: vi - .fn() - .mockResolvedValue({ id: "resolved-update-uuid" }), - updateInitiativeUpdate: vi - .fn() - .mockResolvedValue({ id: "resolved-update-uuid" }), - archiveInitiativeUpdate: vi - .fn() - .mockResolvedValue({ id: "resolved-update-uuid" }), - unarchiveInitiativeUpdate: vi - .fn() - .mockResolvedValue({ id: "resolved-update-uuid" }), -})); +vi.mock( + "../../../src/services/initiative-update-service.js", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/services/initiative-update-service.js") + >(); + return { + ...actual, + listInitiativeUpdates: vi.fn().mockResolvedValue({ + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }), + getInitiativeUpdate: vi + .fn() + .mockResolvedValue({ id: "resolved-update-uuid" }), + createInitiativeUpdate: vi + .fn() + .mockResolvedValue({ id: "resolved-update-uuid" }), + updateInitiativeUpdate: vi + .fn() + .mockResolvedValue({ id: "resolved-update-uuid" }), + archiveInitiativeUpdate: vi + .fn() + .mockResolvedValue({ id: "resolved-update-uuid" }), + unarchiveInitiativeUpdate: vi + .fn() + .mockResolvedValue({ id: "resolved-update-uuid" }), + }; + }, +); vi.mock("../../../src/services/discussion-service.js", () => ({ startInitiativeDiscussion: vi From 3230cb8b784c88ed79422720f45ea208dd43063a Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:54:38 +0200 Subject: [PATCH 51/79] refactor(services): extract shared mutation-payload assertion helpers Replace repeated success/entity payload checks across services with requireMutationEntity and requireMutationSuccess helpers in src/common/mutation-payload.ts. Closes #203 --- src/common/mutation-payload.ts | 28 ++++++++ src/services/attachment-service.ts | 21 +++--- src/services/comment-service.ts | 38 +++++----- src/services/discussion-service.ts | 78 +++++++++++---------- src/services/document-service.ts | 28 ++++---- src/services/initiative-project-service.ts | 29 ++++---- src/services/initiative-relation-service.ts | 29 ++++---- src/services/initiative-service.ts | 54 +++++++------- src/services/initiative-update-service.ts | 53 ++++++-------- src/services/issue-relation-service.ts | 15 ++-- src/services/issue-service.ts | 39 ++++++----- src/services/label-service.ts | 22 +++--- src/services/milestone-service.ts | 27 +++---- src/services/project-service.ts | 51 ++++++++------ src/services/reaction-service.ts | 9 +-- tests/unit/common/mutation-payload.test.ts | 62 ++++++++++++++++ 16 files changed, 337 insertions(+), 246 deletions(-) create mode 100644 src/common/mutation-payload.ts create mode 100644 tests/unit/common/mutation-payload.test.ts diff --git a/src/common/mutation-payload.ts b/src/common/mutation-payload.ts new file mode 100644 index 00000000..593156cc --- /dev/null +++ b/src/common/mutation-payload.ts @@ -0,0 +1,28 @@ +/** + * Assert a Linear mutation payload succeeded and return its entity field. + * + * The entity field name varies per mutation (`issue`, `project`, `entity`, + * `comment`, …), so the caller supplies the key. Typing stays exact via + * `keyof` + `NonNullable`, so no `any` is needed and the returned value is + * narrowed to the non-null entity type. + */ +export function requireMutationEntity< + P extends { success: boolean }, + K extends keyof P, +>(payload: P, key: K, message: string): NonNullable<P[K]> { + const entity = payload[key]; + if (!payload.success || entity == null) { + throw new Error(message); + } + return entity as NonNullable<P[K]>; +} + +/** Assert a mutation payload succeeded when there is no entity to return. */ +export function requireMutationSuccess( + payload: { success: boolean }, + message: string, +): void { + if (!payload.success) { + throw new Error(message); + } +} diff --git a/src/services/attachment-service.ts b/src/services/attachment-service.ts index 4e64383b..e5d69e26 100644 --- a/src/services/attachment-service.ts +++ b/src/services/attachment-service.ts @@ -1,4 +1,8 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import { + requireMutationEntity, + requireMutationSuccess, +} from "../common/mutation-payload.js"; import { AttachmentCreateDocument, type AttachmentCreateInput, @@ -60,11 +64,11 @@ export async function createAttachment( input: gqlInput, }); - if (!result.attachmentCreate.success || !result.attachmentCreate.attachment) { - throw new Error("Failed to create attachment"); - } - - return result.attachmentCreate.attachment; + return requireMutationEntity( + result.attachmentCreate, + "attachment", + "Failed to create attachment", + ); } export async function deleteAttachment( @@ -73,9 +77,10 @@ export async function deleteAttachment( ): Promise<{ id: string; success: boolean }> { const result = await client.request(AttachmentDeleteDocument, { id }); - if (!result.attachmentDelete.success) { - throw new Error("Failed to delete attachment"); - } + requireMutationSuccess( + result.attachmentDelete, + "Failed to delete attachment", + ); return { id: result.attachmentDelete.entityId, success: true }; } diff --git a/src/services/comment-service.ts b/src/services/comment-service.ts index 9ae68314..6da212ce 100644 --- a/src/services/comment-service.ts +++ b/src/services/comment-service.ts @@ -1,4 +1,8 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import { + requireMutationEntity, + requireMutationSuccess, +} from "../common/mutation-payload.js"; import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { type CommentCreateInput, @@ -28,11 +32,11 @@ export async function createComment( ): Promise<CreatedComment> { const result = await client.request(CreateCommentDocument, { input }); - if (!result.commentCreate.success || !result.commentCreate.comment) { - throw new Error("Failed to create comment"); - } - - return result.commentCreate.comment; + return requireMutationEntity( + result.commentCreate, + "comment", + "Failed to create comment", + ); } export async function updateComment( @@ -42,11 +46,11 @@ export async function updateComment( ): Promise<UpdatedComment> { const result = await client.request(UpdateCommentDocument, { id, input }); - if (!result.commentUpdate.success || !result.commentUpdate.comment) { - throw new Error("Failed to update comment"); - } - - return result.commentUpdate.comment; + return requireMutationEntity( + result.commentUpdate, + "comment", + "Failed to update comment", + ); } export async function listComments( @@ -83,11 +87,11 @@ export async function replyToComment( input: { parentId: input.parentId, body: input.body }, }); - if (!result.commentCreate.success || !result.commentCreate.comment) { - throw new Error("Failed to create reply"); - } - - return result.commentCreate.comment; + return requireMutationEntity( + result.commentCreate, + "comment", + "Failed to create reply", + ); } export async function deleteComment( @@ -96,9 +100,7 @@ export async function deleteComment( ): Promise<{ id: string; success: boolean }> { const result = await client.request(DeleteCommentDocument, { id }); - if (!result.commentDelete.success) { - throw new Error("Failed to delete comment"); - } + requireMutationSuccess(result.commentDelete, "Failed to delete comment"); return { id: result.commentDelete.entityId, diff --git a/src/services/discussion-service.ts b/src/services/discussion-service.ts index 2f710fb5..8c48791e 100644 --- a/src/services/discussion-service.ts +++ b/src/services/discussion-service.ts @@ -1,4 +1,8 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import { + requireMutationEntity, + requireMutationSuccess, +} from "../common/mutation-payload.js"; import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { type CommentCreateInput, @@ -471,11 +475,11 @@ async function startDiscussion( ): Promise<StartDiscussionMutation["commentCreate"]["comment"]> { const result = await client.request(StartDiscussionDocument, { input }); - if (!result.commentCreate.success || !result.commentCreate.comment) { - throw new Error("Failed to start discussion"); - } - - return result.commentCreate.comment; + return requireMutationEntity( + result.commentCreate, + "comment", + "Failed to start discussion", + ); } export async function createDiscussionCommentReaction( @@ -788,11 +792,11 @@ export async function replyToDiscussion( }, }); - if (!result.commentCreate.success || !result.commentCreate.comment) { - throw new Error("Failed to create discussion reply"); - } - - return result.commentCreate.comment; + return requireMutationEntity( + result.commentCreate, + "comment", + "Failed to create discussion reply", + ); } export async function editDiscussionReply( @@ -808,11 +812,11 @@ export async function editDiscussionReply( input, }); - if (!result.commentUpdate.success || !result.commentUpdate.comment) { - throw new Error("Failed to edit discussion reply"); - } - - return result.commentUpdate.comment; + return requireMutationEntity( + result.commentUpdate, + "comment", + "Failed to edit discussion reply", + ); } export async function deleteDiscussionReply( @@ -824,9 +828,10 @@ export async function deleteDiscussionReply( const result = await client.request(DeleteDiscussionReplyDocument, { id }); - if (!result.commentDelete.success) { - throw new Error("Failed to delete discussion reply"); - } + requireMutationSuccess( + result.commentDelete, + "Failed to delete discussion reply", + ); return { id: result.commentDelete.entityId, @@ -847,11 +852,11 @@ export async function editDiscussionComment( input, }); - if (!result.commentUpdate.success || !result.commentUpdate.comment) { - throw new Error("Failed to edit discussion comment"); - } - - return result.commentUpdate.comment; + return requireMutationEntity( + result.commentUpdate, + "comment", + "Failed to edit discussion comment", + ); } export async function deleteDiscussionComment( @@ -863,9 +868,10 @@ export async function deleteDiscussionComment( const result = await client.request(DeleteDiscussionReplyDocument, { id }); - if (!result.commentDelete.success) { - throw new Error("Failed to delete discussion comment"); - } + requireMutationSuccess( + result.commentDelete, + "Failed to delete discussion comment", + ); return { id: result.commentDelete.entityId, @@ -888,11 +894,11 @@ export async function resolveDiscussion( resolvingCommentId: input.resolvingCommentId, }); - if (!result.commentResolve.success || !result.commentResolve.comment) { - throw new Error("Failed to resolve discussion"); - } - - return result.commentResolve.comment; + return requireMutationEntity( + result.commentResolve, + "comment", + "Failed to resolve discussion", + ); } export async function unresolveDiscussion( @@ -906,9 +912,9 @@ export async function unresolveDiscussion( id: threadId, }); - if (!result.commentUnresolve.success || !result.commentUnresolve.comment) { - throw new Error("Failed to unresolve discussion"); - } - - return result.commentUnresolve.comment; + return requireMutationEntity( + result.commentUnresolve, + "comment", + "Failed to unresolve discussion", + ); } diff --git a/src/services/document-service.ts b/src/services/document-service.ts index 2a6798d1..61b0658a 100644 --- a/src/services/document-service.ts +++ b/src/services/document-service.ts @@ -1,4 +1,8 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import { + requireMutationEntity, + requireMutationSuccess, +} from "../common/mutation-payload.js"; import type { PaginatedResult } from "../common/types.js"; import { DocumentCreateDocument, @@ -80,11 +84,11 @@ export async function createDocument( input: gqlInput, }); - if (!result.documentCreate.success || !result.documentCreate.document) { - throw new Error("Failed to create document"); - } - - return result.documentCreate.document; + return requireMutationEntity( + result.documentCreate, + "document", + "Failed to create document", + ); } export async function updateDocument( @@ -98,11 +102,11 @@ export async function updateDocument( input: gqlInput, }); - if (!result.documentUpdate.success || !result.documentUpdate.document) { - throw new Error("Failed to update document"); - } - - return result.documentUpdate.document; + return requireMutationEntity( + result.documentUpdate, + "document", + "Failed to update document", + ); } export async function listDocuments( @@ -134,9 +138,7 @@ export async function deleteDocument( ): Promise<{ id: string; success: boolean }> { const result = await client.request(DocumentDeleteDocument, { id }); - if (!result.documentDelete.success) { - throw new Error("Failed to delete document"); - } + requireMutationSuccess(result.documentDelete, "Failed to delete document"); return { id: result.documentDelete.entity?.id ?? id, success: true }; } diff --git a/src/services/initiative-project-service.ts b/src/services/initiative-project-service.ts index a5144fc1..f3492426 100644 --- a/src/services/initiative-project-service.ts +++ b/src/services/initiative-project-service.ts @@ -1,4 +1,5 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import { requireMutationEntity } from "../common/mutation-payload.js"; import { CreateInitiativeToProjectDocument, type CreateInitiativeToProjectMutation, @@ -25,16 +26,11 @@ export async function createInitiativeProjectLink( input, }); - if ( - !result.initiativeToProjectCreate.success || - !result.initiativeToProjectCreate.initiativeToProject - ) { - throw new Error( - `Failed to create initiative-project link for initiative "${input.initiativeId}" and project "${input.projectId}"`, - ); - } - - return result.initiativeToProjectCreate.initiativeToProject; + return requireMutationEntity( + result.initiativeToProjectCreate, + "initiativeToProject", + `Failed to create initiative-project link for initiative "${input.initiativeId}" and project "${input.projectId}"`, + ); } export async function deleteInitiativeProjectLink( @@ -45,15 +41,14 @@ export async function deleteInitiativeProjectLink( id, }); - if ( - !result.initiativeToProjectDelete.success || - !result.initiativeToProjectDelete.entityId - ) { - throw new Error(`Failed to delete initiative-project link "${id}"`); - } + const entityId = requireMutationEntity( + result.initiativeToProjectDelete, + "entityId", + `Failed to delete initiative-project link "${id}"`, + ); return { - id: result.initiativeToProjectDelete.entityId, + id: entityId, success: true, }; } diff --git a/src/services/initiative-relation-service.ts b/src/services/initiative-relation-service.ts index 22b0faeb..02612fb1 100644 --- a/src/services/initiative-relation-service.ts +++ b/src/services/initiative-relation-service.ts @@ -1,4 +1,5 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import { requireMutationEntity } from "../common/mutation-payload.js"; import { CreateInitiativeRelationDocument, type CreateInitiativeRelationMutation, @@ -28,16 +29,11 @@ export async function createInitiativeRelation( }, }); - if ( - !result.initiativeRelationCreate.success || - !result.initiativeRelationCreate.initiativeRelation - ) { - throw new Error( - `Failed to create initiative relation from "${input.parentId}" to "${input.childId}"`, - ); - } - - return result.initiativeRelationCreate.initiativeRelation; + return requireMutationEntity( + result.initiativeRelationCreate, + "initiativeRelation", + `Failed to create initiative relation from "${input.parentId}" to "${input.childId}"`, + ); } export async function deleteInitiativeRelation( @@ -46,15 +42,14 @@ export async function deleteInitiativeRelation( ): Promise<DeletedInitiativeRelation> { const result = await client.request(DeleteInitiativeRelationDocument, { id }); - if ( - !result.initiativeRelationDelete.success || - !result.initiativeRelationDelete.entityId - ) { - throw new Error(`Failed to delete initiative relation "${id}"`); - } + const entityId = requireMutationEntity( + result.initiativeRelationDelete, + "entityId", + `Failed to delete initiative relation "${id}"`, + ); return { - id: result.initiativeRelationDelete.entityId, + id: entityId, success: true, }; } diff --git a/src/services/initiative-service.ts b/src/services/initiative-service.ts index 38dfb955..e3965307 100644 --- a/src/services/initiative-service.ts +++ b/src/services/initiative-service.ts @@ -1,5 +1,6 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import { invalidParameterError } from "../common/errors.js"; +import { requireMutationEntity } from "../common/mutation-payload.js"; import type { PaginatedResult } from "../common/types.js"; import { ArchiveInitiativeDocument, @@ -325,11 +326,11 @@ export async function createInitiative( input: gqlInput, }); - if (!result.initiativeCreate.success || !result.initiativeCreate.initiative) { - throw new Error(`Failed to create initiative "${input.name}"`); - } - - return result.initiativeCreate.initiative; + return requireMutationEntity( + result.initiativeCreate, + "initiative", + `Failed to create initiative "${input.name}"`, + ); } export async function updateInitiative( @@ -354,11 +355,11 @@ export async function updateInitiative( input: gqlInput, }); - if (!result.initiativeUpdate.success || !result.initiativeUpdate.initiative) { - throw new Error(`Failed to update initiative "${id}"`); - } - - return result.initiativeUpdate.initiative; + return requireMutationEntity( + result.initiativeUpdate, + "initiative", + `Failed to update initiative "${id}"`, + ); } export async function archiveInitiative( @@ -367,11 +368,11 @@ export async function archiveInitiative( ): Promise<ArchivedInitiative> { const result = await client.request(ArchiveInitiativeDocument, { id }); - if (!result.initiativeArchive.success || !result.initiativeArchive.entity) { - throw new Error(`Failed to archive initiative "${id}"`); - } - - return result.initiativeArchive.entity; + return requireMutationEntity( + result.initiativeArchive, + "entity", + `Failed to archive initiative "${id}"`, + ); } export async function unarchiveInitiative( @@ -380,14 +381,11 @@ export async function unarchiveInitiative( ): Promise<UnarchivedInitiative> { const result = await client.request(UnarchiveInitiativeDocument, { id }); - if ( - !result.initiativeUnarchive.success || - !result.initiativeUnarchive.entity - ) { - throw new Error(`Failed to unarchive initiative "${id}"`); - } - - return result.initiativeUnarchive.entity; + return requireMutationEntity( + result.initiativeUnarchive, + "entity", + `Failed to unarchive initiative "${id}"`, + ); } export async function deleteInitiative( @@ -396,12 +394,14 @@ export async function deleteInitiative( ): Promise<DeletedInitiative> { const result = await client.request(DeleteInitiativeDocument, { id }); - if (!result.initiativeDelete.success || !result.initiativeDelete.entityId) { - throw new Error(`Failed to delete initiative "${id}"`); - } + const entityId = requireMutationEntity( + result.initiativeDelete, + "entityId", + `Failed to delete initiative "${id}"`, + ); return { - id: result.initiativeDelete.entityId, + id: entityId, success: true, }; } diff --git a/src/services/initiative-update-service.ts b/src/services/initiative-update-service.ts index edc6fcf3..943befe6 100644 --- a/src/services/initiative-update-service.ts +++ b/src/services/initiative-update-service.ts @@ -1,5 +1,6 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import { invalidParameterError } from "../common/errors.js"; +import { requireMutationEntity } from "../common/mutation-payload.js"; import type { PaginatedResult } from "../common/types.js"; import { ArchiveInitiativeUpdateDocument, @@ -112,14 +113,11 @@ export async function createInitiativeUpdate( input: gqlInput, }); - if ( - !result.initiativeUpdateCreate.success || - !result.initiativeUpdateCreate.initiativeUpdate - ) { - throw new Error("Failed to create initiative update"); - } - - return result.initiativeUpdateCreate.initiativeUpdate; + return requireMutationEntity( + result.initiativeUpdateCreate, + "initiativeUpdate", + "Failed to create initiative update", + ); } export async function updateInitiativeUpdate( @@ -144,14 +142,11 @@ export async function updateInitiativeUpdate( input: gqlInput, }); - if ( - !result.initiativeUpdateUpdate.success || - !result.initiativeUpdateUpdate.initiativeUpdate - ) { - throw new Error(`Failed to update initiative update "${id}"`); - } - - return result.initiativeUpdateUpdate.initiativeUpdate; + return requireMutationEntity( + result.initiativeUpdateUpdate, + "initiativeUpdate", + `Failed to update initiative update "${id}"`, + ); } export async function archiveInitiativeUpdate( @@ -160,14 +155,11 @@ export async function archiveInitiativeUpdate( ): Promise<ArchivedInitiativeUpdate> { const result = await client.request(ArchiveInitiativeUpdateDocument, { id }); - if ( - !result.initiativeUpdateArchive.success || - !result.initiativeUpdateArchive.entity - ) { - throw new Error(`Failed to archive initiative update "${id}"`); - } - - return result.initiativeUpdateArchive.entity; + return requireMutationEntity( + result.initiativeUpdateArchive, + "entity", + `Failed to archive initiative update "${id}"`, + ); } export async function unarchiveInitiativeUpdate( @@ -178,12 +170,9 @@ export async function unarchiveInitiativeUpdate( id, }); - if ( - !result.initiativeUpdateUnarchive.success || - !result.initiativeUpdateUnarchive.entity - ) { - throw new Error(`Failed to unarchive initiative update "${id}"`); - } - - return result.initiativeUpdateUnarchive.entity; + return requireMutationEntity( + result.initiativeUpdateUnarchive, + "entity", + `Failed to unarchive initiative update "${id}"`, + ); } diff --git a/src/services/issue-relation-service.ts b/src/services/issue-relation-service.ts index 7027f0dd..737a37c0 100644 --- a/src/services/issue-relation-service.ts +++ b/src/services/issue-relation-service.ts @@ -1,5 +1,6 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import { notFoundError } from "../common/errors.js"; +import { requireMutationSuccess } from "../common/mutation-payload.js"; import { CreateIssueRelationDocument, type CreateIssueRelationMutation, @@ -24,9 +25,10 @@ export async function createIssueRelation( }, ): Promise<CreatedIssueRelation> { const result = await client.request(CreateIssueRelationDocument, { input }); - if (!result.issueRelationCreate.success) { - throw new Error("Failed to create issue relation"); - } + requireMutationSuccess( + result.issueRelationCreate, + "Failed to create issue relation", + ); return result.issueRelationCreate.issueRelation; } @@ -90,8 +92,9 @@ export async function deleteIssueRelation( const result = await client.request(DeleteIssueRelationDocument, { id: relationId, }); - if (!result.issueRelationDelete.success) { - throw new Error("Failed to delete issue relation"); - } + requireMutationSuccess( + result.issueRelationDelete, + "Failed to delete issue relation", + ); return { id: result.issueRelationDelete.entityId, success: true }; } diff --git a/src/services/issue-service.ts b/src/services/issue-service.ts index b62087f3..0556dd70 100644 --- a/src/services/issue-service.ts +++ b/src/services/issue-service.ts @@ -1,4 +1,5 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import { requireMutationEntity } from "../common/mutation-payload.js"; import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { ArchiveIssueDocument, @@ -440,10 +441,11 @@ export async function createIssue( ): Promise<CreatedIssue> { const gqlInput: IssueCreateInput = input; const result = await client.request(CreateIssueDocument, { input: gqlInput }); - if (!result.issueCreate.success || !result.issueCreate.issue) { - throw new Error("Failed to create issue"); - } - return result.issueCreate.issue; + return requireMutationEntity( + result.issueCreate, + "issue", + "Failed to create issue", + ); } export async function updateIssue( @@ -456,10 +458,11 @@ export async function updateIssue( id, input: gqlInput, }); - if (!result.issueUpdate.success || !result.issueUpdate.issue) { - throw new Error("Failed to update issue"); - } - return result.issueUpdate.issue; + return requireMutationEntity( + result.issueUpdate, + "issue", + "Failed to update issue", + ); } export async function archiveIssue( @@ -468,11 +471,11 @@ export async function archiveIssue( ): Promise<IssueDetail> { const result = await client.request(ArchiveIssueDocument, { id }); - if (!result.issueArchive.success || !result.issueArchive.entity) { - throw new Error(`Failed to archive issue "${id}"`); - } - - return result.issueArchive.entity; + return requireMutationEntity( + result.issueArchive, + "entity", + `Failed to archive issue "${id}"`, + ); } export async function unarchiveIssue( @@ -481,11 +484,11 @@ export async function unarchiveIssue( ): Promise<IssueDetail> { const result = await client.request(UnarchiveIssueDocument, { id }); - if (!result.issueUnarchive.success || !result.issueUnarchive.entity) { - throw new Error(`Failed to unarchive issue "${id}"`); - } - - return result.issueUnarchive.entity; + return requireMutationEntity( + result.issueUnarchive, + "entity", + `Failed to unarchive issue "${id}"`, + ); } export async function deleteIssue( diff --git a/src/services/label-service.ts b/src/services/label-service.ts index 8bc2a6d6..d95846a5 100644 --- a/src/services/label-service.ts +++ b/src/services/label-service.ts @@ -1,4 +1,5 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import { requireMutationSuccess } from "../common/mutation-payload.js"; import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { CreateIssueLabelDocument, @@ -81,9 +82,10 @@ export async function createLabel( input: gqlInput, }); - if (!result.issueLabelCreate.success) { - throw new Error(`Failed to create label "${input.name}"`); - } + requireMutationSuccess( + result.issueLabelCreate, + `Failed to create label "${input.name}"`, + ); return mapIssueLabel(result.issueLabelCreate.issueLabel); } @@ -99,9 +101,10 @@ export async function updateLabel( input: gqlInput, }); - if (!result.issueLabelUpdate.success) { - throw new Error(`Failed to update label "${id}"`); - } + requireMutationSuccess( + result.issueLabelUpdate, + `Failed to update label "${id}"`, + ); return mapIssueLabel(result.issueLabelUpdate.issueLabel); } @@ -112,9 +115,10 @@ export async function deleteLabel( ): Promise<DeleteLabelResult> { const result = await client.request(DeleteIssueLabelDocument, { id }); - if (!result.issueLabelDelete.success) { - throw new Error(`Failed to delete label "${id}"`); - } + requireMutationSuccess( + result.issueLabelDelete, + `Failed to delete label "${id}"`, + ); return { id: result.issueLabelDelete.entityId, diff --git a/src/services/milestone-service.ts b/src/services/milestone-service.ts index 0ff00487..dd00b851 100644 --- a/src/services/milestone-service.ts +++ b/src/services/milestone-service.ts @@ -1,4 +1,5 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import { requireMutationEntity } from "../common/mutation-payload.js"; import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { CreateProjectMilestoneDocument, @@ -83,14 +84,11 @@ export async function createMilestone( input: gqlInput, }); - if ( - !result.projectMilestoneCreate.success || - !result.projectMilestoneCreate.projectMilestone - ) { - throw new Error("Failed to create milestone"); - } - - return result.projectMilestoneCreate.projectMilestone; + return requireMutationEntity( + result.projectMilestoneCreate, + "projectMilestone", + "Failed to create milestone", + ); } export async function updateMilestone( @@ -104,12 +102,9 @@ export async function updateMilestone( input: gqlInput, }); - if ( - !result.projectMilestoneUpdate.success || - !result.projectMilestoneUpdate.projectMilestone - ) { - throw new Error("Failed to update milestone"); - } - - return result.projectMilestoneUpdate.projectMilestone; + return requireMutationEntity( + result.projectMilestoneUpdate, + "projectMilestone", + "Failed to update milestone", + ); } diff --git a/src/services/project-service.ts b/src/services/project-service.ts index fa0f649e..10a46aa7 100644 --- a/src/services/project-service.ts +++ b/src/services/project-service.ts @@ -1,4 +1,8 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import { + requireMutationEntity, + requireMutationSuccess, +} from "../common/mutation-payload.js"; import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { ArchiveProjectDocument, @@ -138,11 +142,11 @@ export async function createProject( input: gqlInput, }); - if (!result.projectCreate.success || !result.projectCreate.project) { - throw new Error(`Failed to create project "${input.name}"`); - } - - return result.projectCreate.project; + return requireMutationEntity( + result.projectCreate, + "project", + `Failed to create project "${input.name}"`, + ); } export async function updateProject( @@ -156,11 +160,11 @@ export async function updateProject( input: gqlInput, }); - if (!result.projectUpdate.success || !result.projectUpdate.project) { - throw new Error(`Failed to update project "${id}"`); - } - - return result.projectUpdate.project; + return requireMutationEntity( + result.projectUpdate, + "project", + `Failed to update project "${id}"`, + ); } export async function archiveProject( @@ -169,11 +173,11 @@ export async function archiveProject( ): Promise<ArchivedProject> { const result = await client.request(ArchiveProjectDocument, { id }); - if (!result.projectArchive.success || !result.projectArchive.entity) { - throw new Error(`Failed to archive project "${id}"`); - } - - return result.projectArchive.entity; + return requireMutationEntity( + result.projectArchive, + "entity", + `Failed to archive project "${id}"`, + ); } export async function unarchiveProject( @@ -182,11 +186,11 @@ export async function unarchiveProject( ): Promise<UnarchivedProject> { const result = await client.request(UnarchiveProjectDocument, { id }); - if (!result.projectUnarchive.success || !result.projectUnarchive.entity) { - throw new Error(`Failed to unarchive project "${id}"`); - } - - return result.projectUnarchive.entity; + return requireMutationEntity( + result.projectUnarchive, + "entity", + `Failed to unarchive project "${id}"`, + ); } export async function deleteProject( @@ -195,9 +199,10 @@ export async function deleteProject( ): Promise<DeletedProject> { const result = await client.request(DeleteProjectDocument, { id }); - if (!result.projectDelete.success) { - throw new Error(`Failed to delete project "${id}"`); - } + requireMutationSuccess( + result.projectDelete, + `Failed to delete project "${id}"`, + ); return { id: result.projectDelete.entity?.id ?? id, diff --git a/src/services/reaction-service.ts b/src/services/reaction-service.ts index e39a5fe2..6864a156 100644 --- a/src/services/reaction-service.ts +++ b/src/services/reaction-service.ts @@ -1,5 +1,6 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import { normalizeReactionEmojiInput } from "../common/emoji.js"; +import { requireMutationSuccess } from "../common/mutation-payload.js"; import { CreateReactionDocument, type CreateReactionMutation, @@ -135,9 +136,7 @@ async function createReaction( input: normalizedInput, }); - if (!result.reactionCreate.success) { - throw new Error("Failed to create reaction"); - } + requireMutationSuccess(result.reactionCreate, "Failed to create reaction"); return result.reactionCreate.reaction; } @@ -150,9 +149,7 @@ async function deleteReaction( id: reactionId, }); - if (!result.reactionDelete.success) { - throw new Error("Failed to delete reaction"); - } + requireMutationSuccess(result.reactionDelete, "Failed to delete reaction"); return { id: result.reactionDelete.entityId, success: true }; } diff --git a/tests/unit/common/mutation-payload.test.ts b/tests/unit/common/mutation-payload.test.ts new file mode 100644 index 00000000..51acd4bd --- /dev/null +++ b/tests/unit/common/mutation-payload.test.ts @@ -0,0 +1,62 @@ +// tests/unit/common/mutation-payload.test.ts +import { describe, expect, it } from "vitest"; +import { + requireMutationEntity, + requireMutationSuccess, +} from "../../../src/common/mutation-payload.js"; + +describe("requireMutationEntity", () => { + it("returns the entity on success", () => { + const payload = { success: true, issue: { id: "iss-1" } }; + expect(requireMutationEntity(payload, "issue", "boom")).toEqual({ + id: "iss-1", + }); + }); + + it("supports arbitrary entity field names", () => { + const payload = { success: true, entity: { id: "ent-1" } }; + expect(requireMutationEntity(payload, "entity", "boom")).toEqual({ + id: "ent-1", + }); + }); + + it("returns a string entity field (e.g. entityId)", () => { + const payload = { success: true, entityId: "del-1" }; + expect(requireMutationEntity(payload, "entityId", "boom")).toBe("del-1"); + }); + + it("throws the given message when success is false", () => { + const payload = { success: false, issue: { id: "iss-1" } }; + expect(() => requireMutationEntity(payload, "issue", "boom")).toThrow( + "boom", + ); + }); + + it("throws when the entity field is null", () => { + const payload = { success: true, issue: null }; + expect(() => requireMutationEntity(payload, "issue", "boom")).toThrow( + "boom", + ); + }); + + it("throws when the entity field is undefined", () => { + const payload = { success: true, issue: undefined }; + expect(() => requireMutationEntity(payload, "issue", "boom")).toThrow( + "boom", + ); + }); +}); + +describe("requireMutationSuccess", () => { + it("does not throw on success", () => { + expect(() => + requireMutationSuccess({ success: true }, "boom"), + ).not.toThrow(); + }); + + it("throws the given message on failure", () => { + expect(() => requireMutationSuccess({ success: false }, "boom")).toThrow( + "boom", + ); + }); +}); From bc3e1d7a779a5c8ff5904809e531a41df531e737 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:11:34 +0200 Subject: [PATCH 52/79] docs(readme): refine agent prompt for Linear CLI usage --- README.md | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index ba0f1732..bfd7bfc7 100644 --- a/README.md +++ b/README.md @@ -152,24 +152,25 @@ Add this (or a version adapted to your workflow) to your `AGENTS.md` or `CLAUDE. ```markdown ## Linear (project management) -Tool: `linearis` CLI via Bash. All output is JSON. +Tool: `linearis` CLI, invoked via Bash. All output is JSON. -Discovery: Run `linearis usage` once to see available domains. Run -`linearis <domain> usage` for the full command reference of a specific domain. -Do NOT guess flags or subcommands — check usage first. +Discovery (do this before acting): run `linearis usage` once for the list of +domains, then `linearis <domain> usage` for a domain's full command reference. +Never guess flags or subcommands — check usage first. -Ticket format: "ABC-123". Always reference tickets by their identifier. +Tickets: always reference by identifier, e.g. `ABC-123`. Workflow rules: -- When creating a ticket, ask 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 checkbox changes, use 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, and -`files upload` to attach new files. +- Ask which project a new ticket belongs to when it's unclear; subtasks inherit + the parent's project by default. +- Keep the ticket description in sync when a task in it changes status. +- Record progress that isn't a simple checkbox change in a discussion thread + (`issues discuss`), not in the description. + +Files: `issues read --with-attachments` includes an `attachments` array whose +entries carry a `url`. Fetch those with `files download <url>`. Upload new +files with `files upload <file>`; it returns an `assetUrl` you can embed in +descriptions or comments. ``` ## Documentation From cb86222344b66d4664164cfcd607cb63067640ce Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:23:49 +0200 Subject: [PATCH 53/79] docs(readme): clarify file download vs attachment references in agent prompt --- README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index bfd7bfc7..0555c53d 100644 --- a/README.md +++ b/README.md @@ -167,10 +167,13 @@ Workflow rules: - Record progress that isn't a simple checkbox change in a discussion thread (`issues discuss`), not in the description. -Files: `issues read --with-attachments` includes an `attachments` array whose -entries carry a `url`. Fetch those with `files download <url>`. Upload new -files with `files upload <file>`; it returns an `assetUrl` you can embed in -descriptions or comments. +Files: `files download <url>` only fetches Linear storage URLs +(`uploads.linear.app`), such as images embedded in descriptions or comments. +Upload new files with `files upload <file>`; it returns an `assetUrl` you can +embed in descriptions or comments. `issues read --with-attachments` lists +resources linked to an issue (PRs, docs, external URLs) under an +`attachments.nodes` array whose entries carry a `url` — these are references, +not necessarily downloadable files. ``` ## Documentation From 7ff625b81308b6d31021c88a7e4a2c5172759c99 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:40:39 +0200 Subject: [PATCH 54/79] refactor: enable stricter TypeScript compiler flags Turn on exactOptionalPropertyTypes, noUncheckedIndexedAccess, noImplicitOverride and noPropertyAccessFromIndexSignature, and update the codebase and tests to comply. Add shared helpers (firstOrThrow, omitUndefined, buildPaginationOptions) and disable Biome's useLiteralKeys rule, which conflicts with noPropertyAccessFromIndexSignature. Closes #197 --- biome.json | 3 ++ src/client/graphql-client.ts | 2 +- src/commands/comments.ts | 10 ++-- src/commands/cycles.ts | 3 +- src/commands/documents.ts | 17 +++++-- src/commands/files.ts | 12 +++-- src/commands/initiatives/entity.ts | 45 ++++++++++------- src/commands/initiatives/updates.ts | 4 +- src/commands/issues.ts | 49 ++++++++++--------- src/commands/labels.ts | 17 ++++--- src/commands/milestones.ts | 13 +++-- src/commands/projects.ts | 28 ++++++----- src/commands/teams.ts | 9 ++-- src/commands/users.ts | 10 ++-- src/common/array.ts | 23 +++++++++ src/common/auth.ts | 4 +- src/common/identifier.ts | 14 ++++-- src/common/object.ts | 16 ++++++ src/common/resolve-filters.ts | 28 ++++++----- src/common/token-storage.ts | 2 +- src/common/types.ts | 13 +++++ src/common/update-notifier.ts | 6 +-- src/common/usage.ts | 31 ++++++------ src/resolvers/cycle-resolver.ts | 10 ++-- src/resolvers/initiative-resolver.ts | 16 ++++-- src/resolvers/issue-resolver.ts | 26 +++++----- src/resolvers/label-resolver.ts | 7 +-- src/resolvers/milestone-resolver.ts | 3 +- src/resolvers/project-resolver.ts | 25 +++++----- src/resolvers/status-resolver.ts | 14 +++--- src/resolvers/team-resolver.ts | 18 ++++--- src/resolvers/user-resolver.ts | 6 ++- src/services/discussion-service.ts | 5 +- src/services/file-service.ts | 2 +- src/services/initiative-service.ts | 7 ++- src/services/issue-service.ts | 41 +++++++--------- src/services/label-service.ts | 20 +++++--- src/services/reaction-service.ts | 9 +++- tests/command-coverage.ts | 7 ++- tests/integration/cycles-cli.test.ts | 2 +- tests/integration/documents-cli.test.ts | 2 +- tests/integration/issues-cli.test.ts | 4 +- tests/integration/milestones-cli.test.ts | 2 +- tests/integration/teams-cli.test.ts | 2 +- tests/integration/users-cli.test.ts | 2 +- tests/unit/commands/issues.test.ts | 6 +-- tests/unit/commands/labels.test.ts | 20 ++++---- tests/unit/common/array.test.ts | 35 +++++++++++++ tests/unit/common/auth.test.ts | 10 ++-- tests/unit/common/object.test.ts | 25 ++++++++++ tests/unit/common/output.test.ts | 6 +-- tests/unit/common/token-storage.test.ts | 6 +-- tests/unit/services/comment-service.test.ts | 2 +- tests/unit/services/cycle-service.test.ts | 18 +++---- .../unit/services/discussion-service.test.ts | 8 +-- tests/unit/services/issue-service.test.ts | 16 +++--- tests/unit/services/label-service.test.ts | 8 +-- tests/unit/services/project-service.test.ts | 12 ++--- tests/unit/services/team-service.test.ts | 6 +-- tests/unit/services/user-service.test.ts | 4 +- tsconfig.json | 4 ++ 61 files changed, 489 insertions(+), 286 deletions(-) create mode 100644 src/common/array.ts create mode 100644 src/common/object.ts create mode 100644 tests/unit/common/array.test.ts create mode 100644 tests/unit/common/object.test.ts diff --git a/biome.json b/biome.json index 83fda0d9..236ec8c7 100644 --- a/biome.json +++ b/biome.json @@ -24,6 +24,9 @@ "linter": { "rules": { "recommended": true, + "complexity": { + "useLiteralKeys": "off" + }, "style": { "noNonNullAssertion": "off" } diff --git a/src/client/graphql-client.ts b/src/client/graphql-client.ts index 3fd3aca6..f810ac2e 100644 --- a/src/client/graphql-client.ts +++ b/src/client/graphql-client.ts @@ -36,7 +36,7 @@ export class GraphQLClient { ): InstanceType<typeof LinearClient>["client"] { const linearClient = new LinearClient({ apiKey: this.apiToken, - signal, + ...(signal ? { signal } : {}), headers: { // Request 1-hour signed URLs for file downloads (see file-service.ts) "public-file-urls-expire-in": "3600", diff --git a/src/commands/comments.ts b/src/commands/comments.ts index 094e926c..fd661ec8 100644 --- a/src/commands/comments.ts +++ b/src/commands/comments.ts @@ -7,6 +7,7 @@ import { import { resolveReactionEmojiInput } from "../common/emoji.js"; import { invalidParameterError } from "../common/errors.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; +import { buildPaginationOptions } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { resolveIssueId } from "../resolvers/issue-resolver.js"; import { @@ -97,10 +98,11 @@ export function setupCommentsCommands(program: Command): void { const limit = parseLimit(options.limit || "25"); const resolvedIssueId = await resolveIssueId(ctx.sdk, issue); - const result = await listDiscussionsForIssue(ctx.gql, resolvedIssueId, { - limit, - after: options.after, - }); + const result = await listDiscussionsForIssue( + ctx.gql, + resolvedIssueId, + buildPaginationOptions(limit, options.after), + ); outputSuccess(result); }), diff --git a/src/commands/cycles.ts b/src/commands/cycles.ts index c13a9d46..be3569d9 100644 --- a/src/commands/cycles.ts +++ b/src/commands/cycles.ts @@ -10,6 +10,7 @@ import { requiresParameterError, } from "../common/errors.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; +import { buildPaginationOptions } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { resolveCycleId } from "../resolvers/cycle-resolver.js"; import { resolveTeamId } from "../resolvers/team-resolver.js"; @@ -79,7 +80,7 @@ export function setupCyclesCommands(program: Command): void { ctx.gql, teamId, options.active || false, - { limit: parseLimit(options.limit), after: options.after }, + buildPaginationOptions(parseLimit(options.limit), options.after), ); if (options.window) { diff --git a/src/commands/documents.ts b/src/commands/documents.ts index 6097b55e..143a56eb 100644 --- a/src/commands/documents.ts +++ b/src/commands/documents.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { createContext, getRootOpts } from "../common/context.js"; import { invalidParameterError } from "../common/errors.js"; +import { omitUndefined } from "../common/object.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { resolveIssueId } from "../resolvers/issue-resolver.js"; @@ -59,6 +60,9 @@ function extractDocumentIdFromUrl(url: string): string | null { } const docSlug = pathParts[docIndex + 1]; + if (docSlug === undefined) { + return null; + } const lastHyphenIndex = docSlug.lastIndexOf("-"); if (lastHyphenIndex === -1) { return docSlug || null; @@ -140,11 +144,14 @@ export function setupDocumentsCommands(program: Command): void { filter = buildIssueDocumentFilter(issueId, legacyDocumentSlugIds); } - const documents = await listDocuments(ctx.gql, { - limit, - after: options.after, - filter, - }); + const documents = await listDocuments( + ctx.gql, + omitUndefined({ + limit, + after: options.after, + filter, + }), + ); outputSuccess(documents); }), diff --git a/src/commands/files.ts b/src/commands/files.ts index 4230f9d5..261f0bfa 100644 --- a/src/commands/files.ts +++ b/src/commands/files.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { type CommandOptions, getApiToken } from "../common/auth.js"; import { getRootOpts } from "../common/context.js"; +import { omitUndefined } from "../common/object.js"; import { handleCommand, outputSuccess } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { FileService } from "../services/file-service.js"; @@ -40,10 +41,13 @@ export function setupFilesCommands(program: Command): void { ]; const apiToken = getApiToken(getRootOpts(command)); const fileService = new FileService(apiToken); - const result = await fileService.downloadFile(url, { - output: options.output, - overwrite: options.overwrite, - }); + const result = await fileService.downloadFile( + url, + omitUndefined({ + output: options.output, + overwrite: options.overwrite, + }), + ); if (!result.success) { throw new Error(result.error || "Download failed"); diff --git a/src/commands/initiatives/entity.ts b/src/commands/initiatives/entity.ts index 8670fa8a..fa62f3b0 100644 --- a/src/commands/initiatives/entity.ts +++ b/src/commands/initiatives/entity.ts @@ -3,11 +3,13 @@ import type { LinearSdkClient } from "../../client/linear-client.js"; import { createContext, getRootOpts } from "../../common/context.js"; import { resolveReactionEmojiInput } from "../../common/emoji.js"; import { invalidParameterError } from "../../common/errors.js"; +import { omitUndefined } from "../../common/object.js"; import { commandAction, outputSuccess, parseLimit, } from "../../common/output.js"; +import { buildPaginationOptions } from "../../common/types.js"; import { resolveInitiativeId } from "../../resolvers/initiative-resolver.js"; import { resolveTeamId } from "../../resolvers/team-resolver.js"; import { resolveUserId } from "../../resolvers/user-resolver.js"; @@ -253,7 +255,7 @@ async function resolveInitiativeFilterInput( ); } - const input: InitiativeFilterInput = { + const input: InitiativeFilterInput = omitUndefined({ id: options.id, slug: options.slug, name: options.name, @@ -270,7 +272,7 @@ async function resolveInitiativeFilterInput( createdBefore: options.createdBefore, updatedAfter: options.updatedAfter, updatedBefore: options.updatedBefore, - }; + }); if (options.owner) { input.ownerId = await resolveUserId(sdk, options.owner); @@ -369,14 +371,17 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { ); const filter = buildInitiativeFilter(filterInput); - const result = await listInitiatives(ctx.gql, { - limit: parseLimit(options.limit), - after: options.after, - includeArchived: options.includeArchived ?? false, - filter, - orderBy, - sort, - }); + const result = await listInitiatives( + ctx.gql, + omitUndefined({ + limit: parseLimit(options.limit), + after: options.after, + includeArchived: options.includeArchived ?? false, + filter, + orderBy, + sort, + }), + ); outputSuccess(result); }, @@ -451,10 +456,10 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { const ctx = createContext(getRootOpts(command)); const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); - const paginationOptions = { - limit: parseLimit(options.limit || "25"), - after: options.after, - }; + const paginationOptions = buildPaginationOptions( + parseLimit(options.limit || "25"), + options.after, + ); const result = options.withReactions ? await listDiscussionsForInitiativeWithReactions( ctx.gql, @@ -488,10 +493,10 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { async (thread, options, command) => { const ctx = createContext(getRootOpts(command)); - const paginationOptions = { - limit: parseLimit(options.limit || "50"), - after: options.after, - }; + const paginationOptions = buildPaginationOptions( + parseLimit(options.limit || "50"), + options.after, + ); const result = options.withReactions ? await listDiscussionRepliesWithReactions( ctx.gql, @@ -643,7 +648,9 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { const result = await resolveDiscussion(ctx.gql, { threadId: thread, - resolvingCommentId: options.withComment, + ...(options.withComment !== undefined + ? { resolvingCommentId: options.withComment } + : {}), entityKind: "initiative", }); diff --git a/src/commands/initiatives/updates.ts b/src/commands/initiatives/updates.ts index b9e664fb..253ac7b3 100644 --- a/src/commands/initiatives/updates.ts +++ b/src/commands/initiatives/updates.ts @@ -6,6 +6,7 @@ import { outputSuccess, parseLimit, } from "../../common/output.js"; +import { buildPaginationOptions } from "../../common/types.js"; import { resolveInitiativeId } from "../../resolvers/initiative-resolver.js"; import { archiveInitiativeUpdate, @@ -66,8 +67,7 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { const result = await listInitiativeUpdates(ctx.gql, { initiativeId, - limit: parseLimit(options.limit), - after: options.after, + ...buildPaginationOptions(parseLimit(options.limit), options.after), includeArchived: options.includeArchived ?? false, }); diff --git a/src/commands/issues.ts b/src/commands/issues.ts index d488606e..a0b34fa6 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -1,4 +1,5 @@ import type { Command } from "commander"; +import { firstOrThrow } from "../common/array.js"; import type { CommandContext } from "../common/context.js"; import { createContext, getRootOpts } from "../common/context.js"; import { parseLabelMode } from "../common/domain-values.js"; @@ -17,6 +18,7 @@ import { } from "../common/number-options.js"; import { commandAction, outputSuccess, parseLimit } from "../common/output.js"; import { resolveFilterOptions } from "../common/resolve-filters.js"; +import { buildPaginationOptions } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import type { IssueRelationType } from "../gql/graphql.js"; import { resolveCycleId } from "../resolvers/cycle-resolver.js"; @@ -383,17 +385,14 @@ function parseRelationAddOptions(options: RelationAddOptions): { options.similar ? "similar" : null, ].filter((type): type is keyof RelationAddOptions => type !== null); - if (typeFlags.length === 0) { - throw new Error( - "Must specify one of --blocks, --related, --duplicate, or --similar", - ); - } - if (typeFlags.length > 1) { throw new Error("Cannot specify multiple relation types"); } - const type = typeFlags[0]; + const type = firstOrThrow( + typeFlags, + "Must specify one of --blocks, --related, --duplicate, or --similar", + ); const rawTargets = options[type] ?? ""; const targets = [ ...new Set( @@ -599,10 +598,10 @@ export function setupIssuesCommands(program: Command): void { commandAction<[FilterOptions, Command]>(async (options, command) => { const ctx = createContext(getRootOpts(command)); - const paginationOptions = { - limit: parseLimit(options.limit), - after: options.after, - }; + const paginationOptions = buildPaginationOptions( + parseLimit(options.limit), + options.after, + ); const filterOptions = await resolveFilterOptions(ctx, options); const filter = buildIssueFilter(filterOptions); @@ -634,10 +633,10 @@ export function setupIssuesCommands(program: Command): void { async (query, options, command) => { const ctx = createContext(getRootOpts(command)); - const paginationOptions = { - limit: parseLimit(options.limit), - after: options.after, - }; + const paginationOptions = buildPaginationOptions( + parseLimit(options.limit), + options.after, + ); const filterOptions = await resolveFilterOptions(ctx, options); const filter = buildIssueFilter(filterOptions); @@ -866,10 +865,10 @@ export function setupIssuesCommands(program: Command): void { const ctx = createContext(getRootOpts(command)); const issueId = await resolveIssueId(ctx.sdk, issue); - const paginationOptions = { - limit: parseLimit(options.limit || "25"), - after: options.after, - }; + const paginationOptions = buildPaginationOptions( + parseLimit(options.limit || "25"), + options.after, + ); const result = options.withReactions ? await listDiscussionsForIssueWithReactions( ctx.gql, @@ -903,10 +902,10 @@ export function setupIssuesCommands(program: Command): void { async (thread, options, command) => { const ctx = createContext(getRootOpts(command)); - const paginationOptions = { - limit: parseLimit(options.limit || "50"), - after: options.after, - }; + const paginationOptions = buildPaginationOptions( + parseLimit(options.limit || "50"), + options.after, + ); const result = options.withReactions ? await listDiscussionRepliesWithReactions( ctx.gql, @@ -1054,7 +1053,9 @@ export function setupIssuesCommands(program: Command): void { const result = await resolveDiscussion(ctx.gql, { threadId: thread, - resolvingCommentId: options.withComment, + ...(options.withComment !== undefined + ? { resolvingCommentId: options.withComment } + : {}), entityKind: "issue", }); diff --git a/src/commands/labels.ts b/src/commands/labels.ts index c36b0f4c..8bf4899a 100644 --- a/src/commands/labels.ts +++ b/src/commands/labels.ts @@ -5,6 +5,7 @@ import { getRootOpts, } from "../common/context.js"; import { invalidParameterError } from "../common/errors.js"; +import { omitUndefined } from "../common/object.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { @@ -103,10 +104,14 @@ async function resolveIssueLabelLookup( const teamId = options.team ? await resolveTeamId(ctx.sdk, options.team) : undefined; - const labelId = await resolveLabelId(ctx.sdk, label, { - teamId, - scope: scope as LabelResolverScope | undefined, - }); + const labelId = await resolveLabelId( + ctx.sdk, + label, + omitUndefined({ + teamId, + scope: scope as LabelResolverScope | undefined, + }), + ); return { ctx, labelId }; } @@ -179,11 +184,11 @@ export function setupLabelsCommands(program: Command): void { const ctx = createContext(getRootOpts(command)); const type = parseLabelType(options.type); const scope = parseLabelScope(options.scope); - const pagination = { + const pagination = omitUndefined({ limit: parseLimit(options.limit), after: options.after, scope, - }; + }); if (type === "project") { if (options.team) { diff --git a/src/commands/milestones.ts b/src/commands/milestones.ts index 58f3cbf0..3028050f 100644 --- a/src/commands/milestones.ts +++ b/src/commands/milestones.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { createContext, getRootOpts } from "../common/context.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; +import { buildPaginationOptions } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { resolveMilestoneId } from "../resolvers/milestone-resolver.js"; import { resolveProjectId } from "../resolvers/project-resolver.js"; @@ -77,10 +78,14 @@ export function setupMilestonesCommands(program: Command): void { // Resolve project ID const projectId = await resolveProjectId(ctx.sdk, options.project); - const milestones = await listMilestones(ctx.gql, projectId, { - limit: parseLimit(options.limit || "50"), - after: options.after, - }); + const milestones = await listMilestones( + ctx.gql, + projectId, + buildPaginationOptions( + parseLimit(options.limit || "50"), + options.after, + ), + ); outputSuccess(milestones); }), diff --git a/src/commands/projects.ts b/src/commands/projects.ts index a79d7bac..0b7f9cb0 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -4,6 +4,7 @@ import { type Priority, parseLabelMode } from "../common/domain-values.js"; import { resolveReactionEmojiInput } from "../common/emoji.js"; import { invalidParameterError } from "../common/errors.js"; import { commandAction, outputSuccess, parseLimit } from "../common/output.js"; +import { buildPaginationOptions } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { resolveProjectId, @@ -263,9 +264,10 @@ export function setupProjectsCommands(program: Command): void { commandAction<[ListOptions, Command]>(async (options, command) => { const ctx = createContext(getRootOpts(command)); const result = await listProjects(ctx.gql, { - limit: parseLimit(options.limit), - after: options.after, - includeArchived: options.includeArchived, + ...buildPaginationOptions(parseLimit(options.limit), options.after), + ...(options.includeArchived !== undefined + ? { includeArchived: options.includeArchived } + : {}), }); outputSuccess(result); }), @@ -340,10 +342,10 @@ export function setupProjectsCommands(program: Command): void { const ctx = createContext(getRootOpts(command)); const projectId = await resolveProjectId(ctx.sdk, project); - const paginationOptions = { - limit: parseLimit(options.limit || "25"), - after: options.after, - }; + const paginationOptions = buildPaginationOptions( + parseLimit(options.limit || "25"), + options.after, + ); const result = options.withReactions ? await listDiscussionsForProjectWithReactions( ctx.gql, @@ -377,10 +379,10 @@ export function setupProjectsCommands(program: Command): void { async (thread, options, command) => { const ctx = createContext(getRootOpts(command)); - const paginationOptions = { - limit: parseLimit(options.limit || "50"), - after: options.after, - }; + const paginationOptions = buildPaginationOptions( + parseLimit(options.limit || "50"), + options.after, + ); const result = options.withReactions ? await listDiscussionRepliesWithReactions( ctx.gql, @@ -528,7 +530,9 @@ export function setupProjectsCommands(program: Command): void { const result = await resolveDiscussion(ctx.gql, { threadId: thread, - resolvingCommentId: options.withComment, + ...(options.withComment !== undefined + ? { resolvingCommentId: options.withComment } + : {}), entityKind: "project", }); diff --git a/src/commands/teams.ts b/src/commands/teams.ts index 09b471ef..6a5c53a4 100644 --- a/src/commands/teams.ts +++ b/src/commands/teams.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { createContext, getRootOpts } from "../common/context.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; +import { buildPaginationOptions } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { resolveTeamId } from "../resolvers/team-resolver.js"; import { getTeam, listTeams } from "../services/team-service.js"; @@ -33,10 +34,10 @@ export function setupTeamsCommands(program: Command): void { Command, ]; const ctx = createContext(getRootOpts(command)); - const result = await listTeams(ctx.gql, { - limit: parseLimit(options.limit), - after: options.after, - }); + const result = await listTeams( + ctx.gql, + buildPaginationOptions(parseLimit(options.limit), options.after), + ); outputSuccess(result); }), ); diff --git a/src/commands/users.ts b/src/commands/users.ts index bb68a54e..15cac95c 100644 --- a/src/commands/users.ts +++ b/src/commands/users.ts @@ -5,6 +5,7 @@ import { getRootOpts, } from "../common/context.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; +import { buildPaginationOptions } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { listUsers } from "../services/user-service.js"; @@ -40,10 +41,11 @@ export function setupUsersCommands(program: Command): void { handleCommand(async (...args: unknown[]) => { const [options, command] = args as [ListUsersOptions, Command]; const ctx = createContext(getRootOpts(command)); - const result = await listUsers(ctx.gql, options.active || false, { - limit: parseLimit(options.limit), - after: options.after, - }); + const result = await listUsers( + ctx.gql, + options.active || false, + buildPaginationOptions(parseLimit(options.limit), options.after), + ); outputSuccess(result); }), ); diff --git a/src/common/array.ts b/src/common/array.ts new file mode 100644 index 00000000..6ccc0289 --- /dev/null +++ b/src/common/array.ts @@ -0,0 +1,23 @@ +/** + * Return the first element of `items`, or throw when the array is empty. + * + * Preferred over a non-null assertion (`items[0]!`) at call sites where the + * array is expected to be non-empty: it keeps the narrowing explicit and yields + * a meaningful error instead of a downstream `undefined` access under + * `noUncheckedIndexedAccess`. Pass a string for an ad-hoc message, a ready-made + * `Error` (e.g. `notFoundError(...)`) to preserve domain-specific messaging, or + * a factory returning either — the factory form defers constructing the error + * (and capturing its stack) to the empty path, avoiding wasted work on the + * common non-empty case. + */ +export function firstOrThrow<T>( + items: readonly T[], + error: string | Error | (() => string | Error), +): T { + const first = items[0]; + if (first === undefined) { + const resolved = typeof error === "function" ? error() : error; + throw typeof resolved === "string" ? new Error(resolved) : resolved; + } + return first; +} diff --git a/src/common/auth.ts b/src/common/auth.ts index 33b66378..17cfb2b1 100644 --- a/src/common/auth.ts +++ b/src/common/auth.ts @@ -24,8 +24,8 @@ export function resolveApiToken(options: CommandOptions): ResolvedToken { } // 2. Environment variable - if (process.env.LINEAR_API_TOKEN) { - return { token: process.env.LINEAR_API_TOKEN, source: "env" }; + if (process.env["LINEAR_API_TOKEN"]) { + return { token: process.env["LINEAR_API_TOKEN"], source: "env" }; } // 3. Encrypted stored token (~/.linearis/token) diff --git a/src/common/identifier.ts b/src/common/identifier.ts index 03478048..f7269b7f 100644 --- a/src/common/identifier.ts +++ b/src/common/identifier.ts @@ -12,16 +12,19 @@ export interface IssueIdentifier { /** @throws Error if identifier format is invalid */ export function parseIssueIdentifier(identifier: string): IssueIdentifier { - const parts = identifier.split("-"); + const [teamKey, issueNumberRaw, ...rest] = identifier.split("-"); - if (parts.length !== 2) { + if ( + teamKey === undefined || + issueNumberRaw === undefined || + rest.length > 0 + ) { throw new Error( `Invalid issue identifier format: "${identifier}". Expected format: TEAM-123`, ); } - const teamKey = parts[0]; - const issueNumber = parseInt(parts[1], 10); + const issueNumber = parseInt(issueNumberRaw, 10); if (Number.isNaN(issueNumber)) { throw new Error(`Invalid issue number in identifier: "${identifier}"`); @@ -52,6 +55,9 @@ export function parseDueDate(value: string): string { } const [year, month, day] = value.split("-").map(Number); + if (year === undefined || month === undefined || day === undefined) { + throw new Error(`Invalid due date: "${value}". The date does not exist.`); + } const date = new Date(year, month - 1, day); if ( diff --git a/src/common/object.ts b/src/common/object.ts new file mode 100644 index 00000000..751a0f21 --- /dev/null +++ b/src/common/object.ts @@ -0,0 +1,16 @@ +/** + * Return a shallow copy of `obj` with every `undefined`-valued key removed. + * + * The result type marks each key optional and strips `undefined` from its value + * type, so the object satisfies interfaces declared under + * `exactOptionalPropertyTypes` (where an explicit `key: undefined` is not + * assignable to `key?: T`). Preferred over a wall of conditional spreads when + * building filter/option objects whose fields are all individually optional. + */ +export function omitUndefined<T extends object>( + obj: T, +): { [K in keyof T]?: Exclude<T[K], undefined> } { + return Object.fromEntries( + Object.entries(obj).filter(([, value]) => value !== undefined), + ) as { [K in keyof T]?: Exclude<T[K], undefined> }; +} diff --git a/src/common/resolve-filters.ts b/src/common/resolve-filters.ts index ccd3fcac..3488ca8f 100644 --- a/src/common/resolve-filters.ts +++ b/src/common/resolve-filters.ts @@ -12,6 +12,7 @@ import { validateFilterDependencies, validatePriority, } from "./issue-filter.js"; +import { omitUndefined } from "./object.js"; /** * Resolves raw CLI filter flags into validated IssueFilterOptions with UUIDs. @@ -106,23 +107,26 @@ export async function resolveFilterOptions( opts.parent !== undefined; const batchResolved = hasResolvableFilters - ? await resolveSearchFilterIds(ctx.sdk, { - team: opts.team, - assignee: opts.assignee, - creator: opts.creator, - project: opts.project, - statusNames: parsedStatusNames, - labelNames: parsedLabelNames, - cycle: opts.cycle, - parent: opts.parent, - }) + ? await resolveSearchFilterIds( + ctx.sdk, + omitUndefined({ + team: opts.team, + assignee: opts.assignee, + creator: opts.creator, + project: opts.project, + statusNames: parsedStatusNames, + labelNames: parsedLabelNames, + cycle: opts.cycle, + parent: opts.parent, + }), + ) : {}; const milestoneId = opts.milestone ? await resolveMilestoneId(ctx.gql, ctx.sdk, opts.milestone, opts.project) : undefined; - const resolved: IssueFilterOptions = { + const resolved: IssueFilterOptions = omitUndefined({ ...batchResolved, milestoneId, priority: parsedPriority, @@ -137,7 +141,7 @@ export async function resolveFilterOptions( updatedBefore: opts.updatedBefore, hasBlockers: opts.hasBlockers, isBlocking: opts.isBlocking, - }; + }); return resolved; } diff --git a/src/common/token-storage.ts b/src/common/token-storage.ts index ae2117a3..2b966f45 100644 --- a/src/common/token-storage.ts +++ b/src/common/token-storage.ts @@ -9,7 +9,7 @@ const TOKEN_FILE = "token"; export function getTokenDir(): string { if (process.platform === "linux") { - const xdgConfig = process.env.XDG_CONFIG_HOME; + const xdgConfig = process.env["XDG_CONFIG_HOME"]; if (xdgConfig && path.isAbsolute(xdgConfig)) { return path.join(xdgConfig, DIR_NAME); } diff --git a/src/common/types.ts b/src/common/types.ts index d01d6c78..86101bdc 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -12,3 +12,16 @@ export interface PaginationOptions { limit?: number; after?: string; } + +/** + * Build a {@link PaginationOptions} object from raw CLI values, omitting `after` + * when it is undefined. Keeping the key absent (rather than set to `undefined`) + * is required under `exactOptionalPropertyTypes` and avoids repeating the + * conditional spread at every list command. + */ +export function buildPaginationOptions( + limit: number, + after: string | undefined, +): PaginationOptions { + return after === undefined ? { limit } : { limit, after }; +} diff --git a/src/common/update-notifier.ts b/src/common/update-notifier.ts index f13dfbea..ac89cff1 100644 --- a/src/common/update-notifier.ts +++ b/src/common/update-notifier.ts @@ -70,8 +70,8 @@ export function writeCache(data: UpdateCacheData): void { * A release (no prerelease) outranks a prerelease sharing the same core. */ export function compareVersions(a: string, b: string): number { - const [coreA, preA = ""] = a.split("-"); - const [coreB, preB = ""] = b.split("-"); + const [coreA = "", preA = ""] = a.split("-"); + const [coreB = "", preB = ""] = b.split("-"); const numsA = coreA.split(".").map((n) => Number.parseInt(n, 10) || 0); const numsB = coreB.split(".").map((n) => Number.parseInt(n, 10) || 0); const len = Math.max(numsA.length, numsB.length); @@ -115,7 +115,7 @@ export function updateChecksDisabled( env: NodeJS.ProcessEnv = process.env, ): boolean { return Boolean( - env.NO_UPDATE_NOTIFIER || env.LINEARIS_NO_UPDATE_CHECK || env.CI, + env["NO_UPDATE_NOTIFIER"] || env["LINEARIS_NO_UPDATE_CHECK"] || env["CI"], ); } diff --git a/src/common/usage.ts b/src/common/usage.ts index 7a446152..eba67bc5 100644 --- a/src/common/usage.ts +++ b/src/common/usage.ts @@ -61,12 +61,13 @@ export function formatDomainUsage(command: Command, meta: DomainMeta): string { const subcommands = command.commands.filter((c) => c.name() !== "usage"); lines.push("commands:"); - const signatures = subcommands.map((c) => formatCommandSignature(c)); - const maxSigLen = Math.max(...signatures.map((s) => s.length)); + const subcommandEntries = subcommands.map((c) => ({ + sig: formatCommandSignature(c), + desc: c.description(), + })); + const maxSigLen = Math.max(...subcommandEntries.map((e) => e.sig.length)); - for (let i = 0; i < subcommands.length; i++) { - const sig = signatures[i]; - const desc = subcommands[i].description(); + for (const { sig, desc } of subcommandEntries) { lines.push(` ${sig.padEnd(maxSigLen + 2)}${desc}`); } @@ -89,15 +90,17 @@ export function formatDomainUsage(command: Command, meta: DomainMeta): string { lines.push(""); lines.push(`${cmd.name()} options:`); - const flags = opts.map((o) => extractLongFlag(o.flags)); - const maxFlagLen = Math.max(...flags.map((f) => f.length)); - - for (let j = 0; j < opts.length; j++) { - const flag = flags[j]; - let desc = opts[j].description; - const defaultVal = opts[j].defaultValue; - if (defaultVal !== undefined && defaultVal !== false) { - desc += ` (default: ${defaultVal})`; + const optionEntries = opts.map((o) => ({ + flag: extractLongFlag(o.flags), + description: o.description, + defaultValue: o.defaultValue, + })); + const maxFlagLen = Math.max(...optionEntries.map((e) => e.flag.length)); + + for (const { flag, description, defaultValue } of optionEntries) { + let desc = description; + if (defaultValue !== undefined && defaultValue !== false) { + desc += ` (default: ${defaultValue})`; } lines.push(` ${flag.padEnd(maxFlagLen + 2)}${desc}`); } diff --git a/src/resolvers/cycle-resolver.ts b/src/resolvers/cycle-resolver.ts index 9f8048cb..a5ce89c8 100644 --- a/src/resolvers/cycle-resolver.ts +++ b/src/resolvers/cycle-resolver.ts @@ -54,13 +54,15 @@ export async function resolveCycleId( id: cycle.id, name: cycle.name ?? "", number: cycle.number, - startsAt: cycle.startsAt - ? new Date(cycle.startsAt).toISOString() - : undefined, isActive: cycle.isActive, isNext: cycle.isNext, isPrevious: cycle.isPrevious, - team: team ? { id: team.id, key: team.key, name: team.name } : undefined, + ...(cycle.startsAt + ? { startsAt: new Date(cycle.startsAt).toISOString() } + : {}), + ...(team + ? { team: { id: team.id, key: team.key, name: team.name } } + : {}), }); } diff --git a/src/resolvers/initiative-resolver.ts b/src/resolvers/initiative-resolver.ts index 5943e411..9379d864 100644 --- a/src/resolvers/initiative-resolver.ts +++ b/src/resolvers/initiative-resolver.ts @@ -1,8 +1,10 @@ import type { LinearDocument } from "@linear/sdk"; import type { GraphQLClient } from "../client/graphql-client.js"; import type { LinearSdkClient } from "../client/linear-client.js"; +import { firstOrThrow } from "../common/array.js"; import { multipleMatchesError, notFoundError } from "../common/errors.js"; import { isUuid } from "../common/identifier.js"; +import { omitUndefined } from "../common/object.js"; import { FindInitiativeProjectLinkByPairDocument, FindInitiativeRelationByPairDocument, @@ -36,17 +38,21 @@ export async function resolveInitiativeId( const filter = clauses.length === 1 ? clauses[0] : { and: clauses }; - const result = await client.sdk.initiatives({ - filter, - first: 20, - }); + const result = await client.sdk.initiatives( + omitUndefined({ + filter, + first: 20, + }), + ); if (result.nodes.length === 0) { throw notFoundError("Initiative", nameOrId); } if (result.nodes.length === 1) { - return result.nodes[0].id; + return firstOrThrow(result.nodes, () => + notFoundError("Initiative", nameOrId), + ).id; } const candidates = result.nodes.map((node) => `${node.name} (${node.id})`); diff --git a/src/resolvers/issue-resolver.ts b/src/resolvers/issue-resolver.ts index 650c1006..317804bb 100644 --- a/src/resolvers/issue-resolver.ts +++ b/src/resolvers/issue-resolver.ts @@ -1,6 +1,8 @@ import type { LinearSdkClient } from "../client/linear-client.js"; +import { firstOrThrow } from "../common/array.js"; import { notFoundError } from "../common/errors.js"; import { isUuid, parseIssueIdentifier } from "../common/identifier.js"; +import { omitUndefined } from "../common/object.js"; import { resolveTeamEstimateContext, type TeamEstimateContext, @@ -39,14 +41,14 @@ function toIssueTeamProjection( node: unknown, ref: string, ): IssueTeamProjection { - if (!isRecord(node) || typeof node.id !== "string") { + if (!isRecord(node) || typeof node["id"] !== "string") { throw new Error(`Issue "${ref}" is missing required team context`); } return { - id: node.id, - teamId: typeof node.teamId === "string" ? node.teamId : undefined, - team: node.team, + id: node["id"], + team: node["team"], + ...(typeof node["teamId"] === "string" ? { teamId: node["teamId"] } : {}), }; } @@ -55,10 +57,10 @@ function toTeamLookupProjection( ): TeamLookupProjection | undefined { if (!isRecord(team)) return undefined; - return { - id: typeof team.id === "string" ? team.id : undefined, - key: typeof team.key === "string" ? team.key : undefined, - }; + return omitUndefined({ + id: typeof team["id"] === "string" ? team["id"] : undefined, + key: typeof team["key"] === "string" ? team["key"] : undefined, + }); } function getTeamLookupFromRelation(team: unknown): string | undefined { @@ -105,11 +107,9 @@ export async function resolveIssueId( first: 1, }); - if (issues.nodes.length === 0) { - throw notFoundError("Issue", issueIdOrIdentifier); - } - - return issues.nodes[0].id; + return firstOrThrow(issues.nodes, () => + notFoundError("Issue", issueIdOrIdentifier), + ).id; } export async function resolveIssueEstimateContext( diff --git a/src/resolvers/label-resolver.ts b/src/resolvers/label-resolver.ts index 054bea29..6ac56818 100644 --- a/src/resolvers/label-resolver.ts +++ b/src/resolvers/label-resolver.ts @@ -1,4 +1,5 @@ import type { LinearSdkClient } from "../client/linear-client.js"; +import { firstOrThrow } from "../common/array.js"; import { notFoundError } from "../common/errors.js"; import { isUuid } from "../common/identifier.js"; @@ -49,11 +50,7 @@ export async function resolveLabelId( first: 1, }); - if (result.nodes.length === 0) { - throw notFoundError("Label", nameOrId); - } - - return result.nodes[0].id; + return firstOrThrow(result.nodes, () => notFoundError("Label", nameOrId)).id; } export async function resolveLabelIds( diff --git a/src/resolvers/milestone-resolver.ts b/src/resolvers/milestone-resolver.ts index f9a6a7e4..d98c77e2 100644 --- a/src/resolvers/milestone-resolver.ts +++ b/src/resolvers/milestone-resolver.ts @@ -1,5 +1,6 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import type { LinearSdkClient } from "../client/linear-client.js"; +import { firstOrThrow } from "../common/array.js"; import { multipleMatchesError, notFoundError } from "../common/errors.js"; import { isUuid } from "../common/identifier.js"; import { @@ -76,5 +77,5 @@ export async function resolveMilestoneId( ); } - return nodes[0].id; + return firstOrThrow(nodes, () => notFoundError("Milestone", nameOrId)).id; } diff --git a/src/resolvers/project-resolver.ts b/src/resolvers/project-resolver.ts index c88caa76..f8ffd2f5 100644 --- a/src/resolvers/project-resolver.ts +++ b/src/resolvers/project-resolver.ts @@ -1,6 +1,8 @@ import type { LinearSdkClient } from "../client/linear-client.js"; +import { firstOrThrow } from "../common/array.js"; import { multipleMatchesError, notFoundError } from "../common/errors.js"; import { isUuid } from "../common/identifier.js"; +import { omitUndefined } from "../common/object.js"; export interface ResolveProjectIdOptions { includeArchived?: boolean; @@ -13,11 +15,13 @@ export async function resolveProjectId( ): Promise<string> { if (isUuid(nameOrId)) return nameOrId; - const result = await client.sdk.projects({ - filter: { name: { eqIgnoreCase: nameOrId } }, - first: 2, - includeArchived: options.includeArchived, - }); + const result = await client.sdk.projects( + omitUndefined({ + filter: { name: { eqIgnoreCase: nameOrId } }, + first: 2, + includeArchived: options.includeArchived, + }), + ); if (result.nodes.length === 0) { throw notFoundError("Project", nameOrId); @@ -32,7 +36,8 @@ export async function resolveProjectId( ); } - return result.nodes[0].id; + return firstOrThrow(result.nodes, () => notFoundError("Project", nameOrId)) + .id; } export async function resolveProjectLabelId( @@ -46,11 +51,9 @@ export async function resolveProjectLabelId( first: 1, }); - if (result.nodes.length === 0) { - throw notFoundError("Project label", nameOrId); - } - - return result.nodes[0].id; + return firstOrThrow(result.nodes, () => + notFoundError("Project label", nameOrId), + ).id; } export async function resolveProjectLabelIds( diff --git a/src/resolvers/status-resolver.ts b/src/resolvers/status-resolver.ts index 9dc92545..272f3008 100644 --- a/src/resolvers/status-resolver.ts +++ b/src/resolvers/status-resolver.ts @@ -1,5 +1,6 @@ import type { LinearDocument } from "@linear/sdk"; import type { LinearSdkClient } from "../client/linear-client.js"; +import { firstOrThrow } from "../common/array.js"; import { notFoundError } from "../common/errors.js"; import { isUuid } from "../common/identifier.js"; @@ -23,10 +24,11 @@ export async function resolveStatusId( first: 1, }); - if (result.nodes.length === 0) { - const context = teamId ? `for team ${teamId}` : undefined; - throw notFoundError("Status", nameOrId, context); - } - - return result.nodes[0].id; + return firstOrThrow(result.nodes, () => + notFoundError( + "Status", + nameOrId, + teamId ? `for team ${teamId}` : undefined, + ), + ).id; } diff --git a/src/resolvers/team-resolver.ts b/src/resolvers/team-resolver.ts index f24adc87..ba00227f 100644 --- a/src/resolvers/team-resolver.ts +++ b/src/resolvers/team-resolver.ts @@ -51,12 +51,12 @@ function toTeamEstimateNode( ); } - const id = node.id; - const key = node.key; - const name = node.name; - const issueEstimationType = node.issueEstimationType; - const issueEstimationExtended = node.issueEstimationExtended; - const issueEstimationAllowZero = node.issueEstimationAllowZero; + const id = node["id"]; + const key = node["key"]; + const name = node["name"]; + const issueEstimationType = node["issueEstimationType"]; + const issueEstimationExtended = node["issueEstimationExtended"]; + const issueEstimationAllowZero = node["issueEstimationAllowZero"]; if ( typeof id !== "string" || @@ -145,14 +145,16 @@ export async function resolveTeamId( filter: { key: { eq: keyOrNameOrId } }, first: 1, }); - if (byKey.nodes.length > 0) return byKey.nodes[0].id; + const [byKeyMatch] = byKey.nodes; + if (byKeyMatch) return byKeyMatch.id; // Fall back to name const byName = await client.sdk.teams({ filter: { name: { eq: keyOrNameOrId } }, first: 1, }); - if (byName.nodes.length > 0) return byName.nodes[0].id; + const [byNameMatch] = byName.nodes; + if (byNameMatch) return byNameMatch.id; throw notFoundError("Team", keyOrNameOrId); } diff --git a/src/resolvers/user-resolver.ts b/src/resolvers/user-resolver.ts index 98b847a9..77350150 100644 --- a/src/resolvers/user-resolver.ts +++ b/src/resolvers/user-resolver.ts @@ -14,7 +14,8 @@ export async function resolveUserId( first: 10, }); - if (byName.nodes.length === 1) return byName.nodes[0].id; + const [byNameMatch] = byName.nodes; + if (byName.nodes.length === 1 && byNameMatch) return byNameMatch.id; if (byName.nodes.length > 1) { throw multipleMatchesError( @@ -31,7 +32,8 @@ export async function resolveUserId( first: 1, }); - if (byEmail.nodes.length > 0) return byEmail.nodes[0].id; + const [byEmailMatch] = byEmail.nodes; + if (byEmailMatch) return byEmailMatch.id; throw notFoundError("User", nameOrEmailOrId); } diff --git a/src/services/discussion-service.ts b/src/services/discussion-service.ts index 8c48791e..98da8482 100644 --- a/src/services/discussion-service.ts +++ b/src/services/discussion-service.ts @@ -437,7 +437,10 @@ function filterThreadReplies<T extends DiscussionCommentFieldsFragment>( } for (let i = children.length - 1; i >= 0; i -= 1) { - stack.push(children[i]); + const child = children[i]; + if (child !== undefined) { + stack.push(child); + } } } diff --git a/src/services/file-service.ts b/src/services/file-service.ts index c23e3d8a..b246cb9a 100644 --- a/src/services/file-service.ts +++ b/src/services/file-service.ts @@ -195,7 +195,7 @@ export class FileService { // Make HTTP request (with Bearer token only if not a signed URL) const headers: Record<string, string> = {}; if (!isSignedUrl) { - headers.Authorization = `Bearer ${this.apiToken}`; + headers["Authorization"] = `Bearer ${this.apiToken}`; } const response = await fetch(url, { diff --git a/src/services/initiative-service.ts b/src/services/initiative-service.ts index e3965307..c3364c0b 100644 --- a/src/services/initiative-service.ts +++ b/src/services/initiative-service.ts @@ -160,7 +160,12 @@ export interface InitiativeFilterInput { } function applyNullableDateRange( - target: { gte?: string | null; lte?: string | null }, + // Accepts a GraphQL date comparator whose optional fields are `InputMaybe` + // (i.e. include `undefined`); the function only ever writes to gte/lte. + target: { + gte?: string | null | undefined; + lte?: string | null | undefined; + }, after?: string, before?: string, ): void { diff --git a/src/services/issue-service.ts b/src/services/issue-service.ts index 0556dd70..94a1b37c 100644 --- a/src/services/issue-service.ts +++ b/src/services/issue-service.ts @@ -1,4 +1,5 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import { firstOrThrow } from "../common/array.js"; import { requireMutationEntity } from "../common/mutation-payload.js"; import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { @@ -317,12 +318,10 @@ export async function getIssueByIdentifier( teamKey, number: issueNumber, }); - if (!result.issues.nodes.length) { - throw new Error( - `Issue with identifier "${teamKey}-${issueNumber}" not found`, - ); - } - return result.issues.nodes[0]; + return firstOrThrow( + result.issues.nodes, + `Issue with identifier "${teamKey}-${issueNumber}" not found`, + ); } export async function getIssueByIdentifierWithComments( @@ -334,12 +333,10 @@ export async function getIssueByIdentifierWithComments( GetIssueByIdentifierWithCommentsDocument, { teamKey, number: issueNumber }, ); - if (!result.issues.nodes.length) { - throw new Error( - `Issue with identifier "${teamKey}-${issueNumber}" not found`, - ); - } - return result.issues.nodes[0]; + return firstOrThrow( + result.issues.nodes, + `Issue with identifier "${teamKey}-${issueNumber}" not found`, + ); } export async function getIssueByIdentifierWithCommentThreads( @@ -377,12 +374,12 @@ export async function getIssueByIdentifierWithReactions( GetIssueByIdentifierWithReactionsDocument, { teamKey, number: issueNumber }, ); - if (!result.issues.nodes.length) { - throw new Error( + return normalizeIssueReactions( + firstOrThrow( + result.issues.nodes, `Issue with identifier "${teamKey}-${issueNumber}" not found`, - ); - } - return normalizeIssueReactions(result.issues.nodes[0]); + ), + ); } export async function getIssueWithAttachments( @@ -407,12 +404,10 @@ export async function getIssueByIdentifierWithAttachments( GetIssueByIdentifierWithAttachmentsDocument, { teamKey, number: issueNumber }, ); - if (!result.issues.nodes.length) { - throw new Error( - `Issue with identifier "${teamKey}-${issueNumber}" not found`, - ); - } - return result.issues.nodes[0]; + return firstOrThrow( + result.issues.nodes, + `Issue with identifier "${teamKey}-${issueNumber}" not found`, + ); } export async function searchIssues( diff --git a/src/services/label-service.ts b/src/services/label-service.ts index d95846a5..da430ec7 100644 --- a/src/services/label-service.ts +++ b/src/services/label-service.ts @@ -53,8 +53,8 @@ function mapIssueLabel(label: { id: label.id, name: label.name, color: label.color, - description: label.description ?? undefined, type: "issue", + ...(label.description != null ? { description: label.description } : {}), }; } @@ -177,13 +177,17 @@ export async function listProjectLabels( }); return { - nodes: result.projectLabels.nodes.map((label) => ({ - id: label.id, - name: label.name, - color: label.color, - description: label.description ?? undefined, - type: "project", - })), + nodes: result.projectLabels.nodes.map( + (label): Label => ({ + id: label.id, + name: label.name, + color: label.color, + type: "project", + ...(label.description != null + ? { description: label.description } + : {}), + }), + ), pageInfo: result.projectLabels.pageInfo, }; } diff --git a/src/services/reaction-service.ts b/src/services/reaction-service.ts index 6864a156..e1a38be4 100644 --- a/src/services/reaction-service.ts +++ b/src/services/reaction-service.ts @@ -1,4 +1,5 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import { firstOrThrow } from "../common/array.js"; import { normalizeReactionEmojiInput } from "../common/emoji.js"; import { requireMutationSuccess } from "../common/mutation-payload.js"; import { @@ -252,7 +253,13 @@ export async function deleteOwnReactionByEmoji( ); } - return deleteReaction(client, matchingReactions[0].id); + return deleteReaction( + client, + firstOrThrow( + matchingReactions, + `No own reaction found with emoji ${normalizedEmoji}`, + ).id, + ); } export async function deleteOwnReactionById( diff --git a/tests/command-coverage.ts b/tests/command-coverage.ts index 84c0109c..45c8558d 100644 --- a/tests/command-coverage.ts +++ b/tests/command-coverage.ts @@ -32,6 +32,7 @@ function extractCommands(commandsDir: string): Command[] { if (!mainCommandMatch) continue; const commandName = mainCommandMatch[1]; + if (commandName === undefined) continue; const subcommands: string[] = []; // Extract subcommands @@ -41,10 +42,12 @@ function extractCommands(commandsDir: string): Command[] { for (const match of subcommandMatches) { const sub = match[1]; // Skip the main command name - if (sub !== commandName) { + if (sub !== undefined && sub !== commandName) { // Extract just the command word, remove parameters like <id> const subName = sub.split(" ")[0]; - subcommands.push(subName); + if (subName !== undefined) { + subcommands.push(subName); + } } } diff --git a/tests/integration/cycles-cli.test.ts b/tests/integration/cycles-cli.test.ts index d3cde547..3d252512 100644 --- a/tests/integration/cycles-cli.test.ts +++ b/tests/integration/cycles-cli.test.ts @@ -18,7 +18,7 @@ const execAsync = promisify(exec); */ const CLI_PATH = "./dist/main.js"; -const hasApiToken = !!process.env.LINEAR_API_TOKEN; +const hasApiToken = !!process.env["LINEAR_API_TOKEN"]; describe("Cycles CLI Commands", () => { beforeAll(async () => { diff --git a/tests/integration/documents-cli.test.ts b/tests/integration/documents-cli.test.ts index c9d32cd2..0ea80155 100644 --- a/tests/integration/documents-cli.test.ts +++ b/tests/integration/documents-cli.test.ts @@ -14,7 +14,7 @@ const execAsync = promisify(exec); */ const CLI_PATH = "./dist/main.js"; -const hasApiToken = !!process.env.LINEAR_API_TOKEN; +const hasApiToken = !!process.env["LINEAR_API_TOKEN"]; describe("Documents CLI Commands", () => { beforeAll(async () => { diff --git a/tests/integration/issues-cli.test.ts b/tests/integration/issues-cli.test.ts index a67d290e..16ef67c2 100644 --- a/tests/integration/issues-cli.test.ts +++ b/tests/integration/issues-cli.test.ts @@ -4,7 +4,7 @@ import { beforeAll, describe, expect, it } from "vitest"; const execAsync = promisify(exec); const CLI_PATH = "./dist/main.js"; -const hasApiToken = !!process.env.LINEAR_API_TOKEN; +const hasApiToken = !!process.env["LINEAR_API_TOKEN"]; interface CliResult { stdout: string; @@ -47,7 +47,7 @@ describe("Issues CLI lifecycle", () => { ); expect(teams.nodes.length).toBeGreaterThan(0); - const teamKey = teams.nodes[0].key; + const teamKey = teams.nodes[0]?.key; const title = `issue-lifecycle-e2e-${Date.now()}`; const createdResult = await runCli( diff --git a/tests/integration/milestones-cli.test.ts b/tests/integration/milestones-cli.test.ts index 813e5e71..1bfae021 100644 --- a/tests/integration/milestones-cli.test.ts +++ b/tests/integration/milestones-cli.test.ts @@ -20,7 +20,7 @@ const execAsync = promisify(exec); */ const CLI_PATH = "./dist/main.js"; -const hasApiToken = !!process.env.LINEAR_API_TOKEN; +const hasApiToken = !!process.env["LINEAR_API_TOKEN"]; describe("Milestones CLI Commands", () => { beforeAll(async () => { diff --git a/tests/integration/teams-cli.test.ts b/tests/integration/teams-cli.test.ts index 240ad404..be3ce10e 100644 --- a/tests/integration/teams-cli.test.ts +++ b/tests/integration/teams-cli.test.ts @@ -14,7 +14,7 @@ const execAsync = promisify(exec); */ const CLI_PATH = "./dist/main.js"; -const hasApiToken = !!process.env.LINEAR_API_TOKEN; +const hasApiToken = !!process.env["LINEAR_API_TOKEN"]; describe("Teams CLI Commands", () => { beforeAll(async () => { diff --git a/tests/integration/users-cli.test.ts b/tests/integration/users-cli.test.ts index 13da6a79..cf9f7d73 100644 --- a/tests/integration/users-cli.test.ts +++ b/tests/integration/users-cli.test.ts @@ -14,7 +14,7 @@ const execAsync = promisify(exec); */ const CLI_PATH = "./dist/main.js"; -const hasApiToken = !!process.env.LINEAR_API_TOKEN; +const hasApiToken = !!process.env["LINEAR_API_TOKEN"]; describe("Users CLI Commands", () => { beforeAll(async () => { diff --git a/tests/unit/commands/issues.test.ts b/tests/unit/commands/issues.test.ts index bd4b5cae..bd59e353 100644 --- a/tests/unit/commands/issues.test.ts +++ b/tests/unit/commands/issues.test.ts @@ -427,7 +427,7 @@ describe("issues create --estimate", () => { ]); const outOfScaleCreateError = JSON.parse( - vi.mocked(console.error).mock.calls[0][0] as string, + vi.mocked(console.error).mock.calls[0]?.[0] as string, ) as { error: string }; expect(outOfScaleCreateError.error).toBe( 'Invalid --estimate: must be one of [1, 2, 3, 5, 8] for team "ENG" (fibonacci)', @@ -459,7 +459,7 @@ describe("issues create --estimate", () => { ]); const disabledEstimationCreateError = JSON.parse( - vi.mocked(console.error).mock.calls[0][0] as string, + vi.mocked(console.error).mock.calls[0]?.[0] as string, ) as { error: string }; expect(disabledEstimationCreateError.error).toBe( 'Invalid --estimate: team "ENG" has estimates disabled (issueEstimationType=notUsed)', @@ -669,7 +669,7 @@ describe("issues update --estimate", () => { ]); const outOfScaleUpdateError = JSON.parse( - vi.mocked(console.error).mock.calls[0][0] as string, + vi.mocked(console.error).mock.calls[0]?.[0] as string, ) as { error: string }; expect(outOfScaleUpdateError.error).toBe( 'Invalid --estimate: must be one of [1, 2, 3, 4, 5] for team "ENG" (linear)', diff --git a/tests/unit/commands/labels.test.ts b/tests/unit/commands/labels.test.ts index a3be3aee..fd771854 100644 --- a/tests/unit/commands/labels.test.ts +++ b/tests/unit/commands/labels.test.ts @@ -438,7 +438,7 @@ describe("labels validation", () => { ]); const errorOutput = JSON.parse( - vi.mocked(console.error).mock.calls[0][0] as string, + vi.mocked(console.error).mock.calls[0]?.[0] as string, ) as { error: string }; expect(errorOutput.error).toBe( @@ -462,7 +462,7 @@ describe("labels validation", () => { ]); const errorOutput = JSON.parse( - vi.mocked(console.error).mock.calls[0][0] as string, + vi.mocked(console.error).mock.calls[0]?.[0] as string, ) as { error: string }; expect(errorOutput.error).toBe( @@ -486,7 +486,7 @@ describe("labels validation", () => { ]); const errorOutput = JSON.parse( - vi.mocked(console.error).mock.calls[0][0] as string, + vi.mocked(console.error).mock.calls[0]?.[0] as string, ) as { error: string }; expect(errorOutput.error).toBe( @@ -512,7 +512,7 @@ describe("labels validation", () => { ]); const errorOutput = JSON.parse( - vi.mocked(console.error).mock.calls[0][0] as string, + vi.mocked(console.error).mock.calls[0]?.[0] as string, ) as { error: string }; expect(errorOutput.error).toBe( @@ -538,7 +538,7 @@ describe("labels validation", () => { ]); const errorOutput = JSON.parse( - vi.mocked(console.error).mock.calls[0][0] as string, + vi.mocked(console.error).mock.calls[0]?.[0] as string, ) as { error: string }; expect(errorOutput.error).toBe( @@ -564,7 +564,7 @@ describe("labels validation", () => { ]); const errorOutput = JSON.parse( - vi.mocked(console.error).mock.calls[0][0] as string, + vi.mocked(console.error).mock.calls[0]?.[0] as string, ) as { error: string }; expect(errorOutput.error).toBe( @@ -589,7 +589,7 @@ describe("labels validation", () => { ]); const errorOutput = JSON.parse( - vi.mocked(console.error).mock.calls[0][0] as string, + vi.mocked(console.error).mock.calls[0]?.[0] as string, ) as { error: string }; expect(errorOutput.error).toBe( @@ -612,7 +612,7 @@ describe("labels validation", () => { ]); const errorOutput = JSON.parse( - vi.mocked(console.error).mock.calls[0][0] as string, + vi.mocked(console.error).mock.calls[0]?.[0] as string, ) as { error: string }; expect(errorOutput.error).toBe( @@ -633,7 +633,7 @@ describe("labels validation", () => { ]); const errorOutput = JSON.parse( - vi.mocked(console.error).mock.calls[0][0] as string, + vi.mocked(console.error).mock.calls[0]?.[0] as string, ) as { error: string }; expect(errorOutput.error).toBe( @@ -656,7 +656,7 @@ describe("labels validation", () => { ]); const errorOutput = JSON.parse( - vi.mocked(console.error).mock.calls[0][0] as string, + vi.mocked(console.error).mock.calls[0]?.[0] as string, ) as { error: string }; expect(errorOutput.error).toBe( diff --git a/tests/unit/common/array.test.ts b/tests/unit/common/array.test.ts new file mode 100644 index 00000000..543a5d2a --- /dev/null +++ b/tests/unit/common/array.test.ts @@ -0,0 +1,35 @@ +// tests/unit/common/array.test.ts +import { describe, expect, it, vi } from "vitest"; +import { firstOrThrow } from "../../../src/common/array.js"; + +describe("firstOrThrow", () => { + it("returns the first element of a non-empty array", () => { + expect(firstOrThrow([1, 2, 3], "empty")).toBe(1); + expect(firstOrThrow(["a"], "empty")).toBe("a"); + }); + + it("throws with the given message when the array is empty", () => { + expect(() => firstOrThrow([], "no items found")).toThrow("no items found"); + }); + + it("throws the provided Error instance when the array is empty", () => { + const err = new Error("custom"); + expect(() => firstOrThrow([], err)).toThrow(err); + }); + + it("invokes the error factory only when the array is empty", () => { + const factory = vi.fn(() => new Error("lazy")); + + expect(firstOrThrow([1], factory)).toBe(1); + expect(factory).not.toHaveBeenCalled(); + + expect(() => firstOrThrow([], factory)).toThrow("lazy"); + expect(factory).toHaveBeenCalledTimes(1); + }); + + it("wraps a string returned by the factory in an Error", () => { + expect(() => firstOrThrow([], () => "lazy message")).toThrow( + "lazy message", + ); + }); +}); diff --git a/tests/unit/common/auth.test.ts b/tests/unit/common/auth.test.ts index aa73dd9f..07f96272 100644 --- a/tests/unit/common/auth.test.ts +++ b/tests/unit/common/auth.test.ts @@ -14,19 +14,19 @@ import { getApiToken } from "../../../src/common/auth.js"; import { getStoredToken } from "../../../src/common/token-storage.js"; describe("getApiToken", () => { - const originalEnv = process.env.LINEAR_API_TOKEN; + const originalEnv = process.env["LINEAR_API_TOKEN"]; beforeEach(() => { vi.clearAllMocks(); - delete process.env.LINEAR_API_TOKEN; + delete process.env["LINEAR_API_TOKEN"]; vi.mocked(os.homedir).mockReturnValue("/home/testuser"); }); afterEach(() => { if (originalEnv !== undefined) { - process.env.LINEAR_API_TOKEN = originalEnv; + process.env["LINEAR_API_TOKEN"] = originalEnv; } else { - delete process.env.LINEAR_API_TOKEN; + delete process.env["LINEAR_API_TOKEN"]; } }); @@ -36,7 +36,7 @@ describe("getApiToken", () => { }); it("returns LINEAR_API_TOKEN env var as second priority", () => { - process.env.LINEAR_API_TOKEN = "env-token"; + process.env["LINEAR_API_TOKEN"] = "env-token"; const token = getApiToken({}); expect(token).toBe("env-token"); }); diff --git a/tests/unit/common/object.test.ts b/tests/unit/common/object.test.ts new file mode 100644 index 00000000..f7771cd7 --- /dev/null +++ b/tests/unit/common/object.test.ts @@ -0,0 +1,25 @@ +// tests/unit/common/object.test.ts +import { describe, expect, it } from "vitest"; +import { omitUndefined } from "../../../src/common/object.js"; + +describe("omitUndefined", () => { + it("removes keys whose value is undefined", () => { + expect(omitUndefined({ a: 1, b: undefined, c: "x" })).toEqual({ + a: 1, + c: "x", + }); + }); + + it("keeps falsy values that are not undefined", () => { + expect(omitUndefined({ a: 0, b: "", c: false, d: null })).toEqual({ + a: 0, + b: "", + c: false, + d: null, + }); + }); + + it("returns an empty object when every value is undefined", () => { + expect(omitUndefined({ a: undefined, b: undefined })).toEqual({}); + }); +}); diff --git a/tests/unit/common/output.test.ts b/tests/unit/common/output.test.ts index e6710bce..2dfde646 100644 --- a/tests/unit/common/output.test.ts +++ b/tests/unit/common/output.test.ts @@ -180,7 +180,7 @@ describe("pickFields", () => { const result = pickFields(input, [["__proto__", "x"], ["a"]]); expect(Object.getPrototypeOf(result)).toBe(Object.prototype); expect((result as { a: number }).a).toBe(1); - expect((result as Record<string, { x: number }>).__proto__.x).toBe(9); + expect((result as Record<string, { x: number }>)["__proto__"]?.x).toBe(9); }); }); @@ -243,7 +243,7 @@ describe("handleCommand with AuthenticationError", () => { await handler(); - const output = JSON.parse(consoleSpy.mock.calls[0][0] as string); + const output = JSON.parse(consoleSpy.mock.calls[0]?.[0] as string); expect(output.error).toBe("AUTHENTICATION_REQUIRED"); expect(exitSpy).toHaveBeenCalledWith(42); @@ -284,7 +284,7 @@ describe("outputAuthError", () => { const err = new AuthenticationError("Token expired"); outputAuthError(err); - const output = JSON.parse(consoleSpy.mock.calls[0][0] as string); + const output = JSON.parse(consoleSpy.mock.calls[0]?.[0] as string); expect(output.error).toBe("AUTHENTICATION_REQUIRED"); expect(output.message).toBe("Linear API authentication failed."); expect(output.details).toBe("Token expired"); diff --git a/tests/unit/common/token-storage.test.ts b/tests/unit/common/token-storage.test.ts index fa46a06b..2c73d67b 100644 --- a/tests/unit/common/token-storage.test.ts +++ b/tests/unit/common/token-storage.test.ts @@ -27,7 +27,7 @@ const originalPlatform = process.platform; beforeEach(() => { vi.clearAllMocks(); - delete process.env.XDG_CONFIG_HOME; + delete process.env["XDG_CONFIG_HOME"]; vi.mocked(os.homedir).mockReturnValue(HOME); }); @@ -62,13 +62,13 @@ describe("getTokenDir", () => { it("uses XDG_CONFIG_HOME on Linux when set", () => { setPlatform("linux"); - process.env.XDG_CONFIG_HOME = "/custom/config"; + process.env["XDG_CONFIG_HOME"] = "/custom/config"; expect(getTokenDir()).toBe(path.join("/custom/config", "linearis")); }); it("ignores relative XDG_CONFIG_HOME", () => { setPlatform("linux"); - process.env.XDG_CONFIG_HOME = "relative/path"; + process.env["XDG_CONFIG_HOME"] = "relative/path"; expect(getTokenDir()).toBe(xdgDir); }); }); diff --git a/tests/unit/services/comment-service.test.ts b/tests/unit/services/comment-service.test.ts index 7f1fae0d..713eeb06 100644 --- a/tests/unit/services/comment-service.test.ts +++ b/tests/unit/services/comment-service.test.ts @@ -119,7 +119,7 @@ describe("listComments", () => { parentId: null, user: MOCK_USER, }); - expect(result.nodes[1].parentId).toBe("comment-1"); + expect(result.nodes[1]?.parentId).toBe("comment-1"); expect(result.pageInfo).toEqual({ hasNextPage: true, endCursor: "cursor-abc", diff --git a/tests/unit/services/cycle-service.test.ts b/tests/unit/services/cycle-service.test.ts index 3f0f83b3..334714ab 100644 --- a/tests/unit/services/cycle-service.test.ts +++ b/tests/unit/services/cycle-service.test.ts @@ -30,12 +30,12 @@ describe("listCycles", () => { }); const result = await listCycles(client); expect(result.nodes).toHaveLength(1); - expect(result.nodes[0].id).toBe("cyc-1"); - expect(result.nodes[0].number).toBe(1); - expect(result.nodes[0].name).toBe("Sprint 1"); - expect(result.nodes[0].startsAt).toBe("2025-01-01"); - expect(result.nodes[0].endsAt).toBe("2025-01-14"); - expect(result.nodes[0].isActive).toBe(true); + expect(result.nodes[0]?.id).toBe("cyc-1"); + expect(result.nodes[0]?.number).toBe(1); + expect(result.nodes[0]?.name).toBe("Sprint 1"); + expect(result.nodes[0]?.startsAt).toBe("2025-01-01"); + expect(result.nodes[0]?.endsAt).toBe("2025-01-14"); + expect(result.nodes[0]?.isActive).toBe(true); expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: "c1" }); }); @@ -130,7 +130,7 @@ describe("listCycles", () => { }, }); const result = await listCycles(client); - expect(result.nodes[0].name).toBe("Cycle 3"); + expect(result.nodes[0]?.name).toBe("Cycle 3"); }); }); @@ -162,8 +162,8 @@ describe("getCycle", () => { expect(result.id).toBe("cyc-1"); expect(result.name).toBe("Sprint 1"); expect(result.issues).toHaveLength(1); - expect(result.issues[0].identifier).toBe("ENG-1"); - expect(result.issues[0].state.name).toBe("In Progress"); + expect(result.issues[0]?.identifier).toBe("ENG-1"); + expect(result.issues[0]?.state.name).toBe("In Progress"); }); it("throws when cycle not found", async () => { diff --git a/tests/unit/services/discussion-service.test.ts b/tests/unit/services/discussion-service.test.ts index 045c64b2..83946d94 100644 --- a/tests/unit/services/discussion-service.test.ts +++ b/tests/unit/services/discussion-service.test.ts @@ -346,7 +346,7 @@ describe("listDiscussionsForIssue", () => { { limit: 10 }, ); - expect(result.nodes[0].reactions).toEqual([ + expect(result.nodes[0]?.reactions).toEqual([ { emoji: "👍", count: 1, @@ -409,7 +409,7 @@ describe("listDiscussionsForProject", () => { { limit: 10 }, ); - expect(result.nodes[0].reactions).toEqual([ + expect(result.nodes[0]?.reactions).toEqual([ { emoji: "👍", count: 1, @@ -467,7 +467,7 @@ describe("listDiscussionsForInitiative", () => { { limit: 10 }, ); - expect(result.nodes[0].reactions).toEqual([ + expect(result.nodes[0]?.reactions).toEqual([ { emoji: "👍", count: 1, @@ -635,7 +635,7 @@ describe("listDiscussionReplies", () => { "issue", ); - expect(result.nodes[0].reactions).toEqual([ + expect(result.nodes[0]?.reactions).toEqual([ { emoji: "👍", count: 1, diff --git a/tests/unit/services/issue-service.test.ts b/tests/unit/services/issue-service.test.ts index c9b0f39d..d0ba6f5e 100644 --- a/tests/unit/services/issue-service.test.ts +++ b/tests/unit/services/issue-service.test.ts @@ -114,7 +114,7 @@ describe("listIssues", () => { }); const result = await listIssues(client, { limit: 10 }); expect(result.nodes).toHaveLength(1); - expect(result.nodes[0].id).toBe("1"); + expect(result.nodes[0]?.id).toBe("1"); expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: "cursor1", @@ -367,7 +367,7 @@ describe("getIssueByIdentifierWithComments", () => { }); const result = await getIssueByIdentifierWithComments(client, "ENG", 42); - expect(result.comments.nodes[0].user?.displayName).toBe("Ada"); + expect(result.comments.nodes[0]?.user?.displayName).toBe("Ada"); expect(client.request).toHaveBeenCalledWith( GetIssueByIdentifierWithCommentsDocument, { @@ -434,15 +434,15 @@ describe("getIssueWithCommentThreads", () => { const result = await getIssueWithCommentThreads(client, "issue-1"); expect(result.comments.nodes).toHaveLength(2); - expect(result.comments.nodes[0].id).toBe("comment-1"); - expect(result.comments.nodes[0].replies.map((reply) => reply.id)).toEqual([ + expect(result.comments.nodes[0]?.id).toBe("comment-1"); + expect(result.comments.nodes[0]?.replies.map((reply) => reply.id)).toEqual([ "comment-2", "comment-5", ]); expect( - result.comments.nodes[0].replies[0].replies.map((reply) => reply.id), + result.comments.nodes[0]?.replies[0]?.replies.map((reply) => reply.id), ).toEqual(["comment-4"]); - expect(result.comments.nodes[1].id).toBe("comment-3"); + expect(result.comments.nodes[1]?.id).toBe("comment-3"); }); }); @@ -485,7 +485,7 @@ describe("getIssueByIdentifierWithCommentThreads", () => { 42, ); - expect(result.comments.nodes[0].replies[0].id).toBe("comment-2"); + expect(result.comments.nodes[0]?.replies[0]?.id).toBe("comment-2"); expect(client.request).toHaveBeenCalledWith( GetIssueByIdentifierWithCommentsDocument, { @@ -762,7 +762,7 @@ describe("searchIssues", () => { }); const result = await searchIssues(client, "test", { limit: 10 }); expect(result.nodes).toHaveLength(1); - expect(result.nodes[0].id).toBe("1"); + expect(result.nodes[0]?.id).toBe("1"); expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: "cursor1", diff --git a/tests/unit/services/label-service.test.ts b/tests/unit/services/label-service.test.ts index 760795e1..d33e0827 100644 --- a/tests/unit/services/label-service.test.ts +++ b/tests/unit/services/label-service.test.ts @@ -345,8 +345,8 @@ describe("listLabels", () => { const result = await listLabels(client); - expect(result.nodes[0].description).toBeUndefined(); - expect(result.nodes[0].type).toBe("issue"); + expect(result.nodes[0]?.description).toBeUndefined(); + expect(result.nodes[0]?.type).toBe("issue"); }); }); @@ -429,7 +429,7 @@ describe("listProjectLabels", () => { const result = await listProjectLabels(client); - expect(result.nodes[0].description).toBeUndefined(); - expect(result.nodes[0].type).toBe("project"); + expect(result.nodes[0]?.description).toBeUndefined(); + expect(result.nodes[0]?.type).toBe("project"); }); }); diff --git a/tests/unit/services/project-service.test.ts b/tests/unit/services/project-service.test.ts index 25dfd8d6..ae1e5b3a 100644 --- a/tests/unit/services/project-service.test.ts +++ b/tests/unit/services/project-service.test.ts @@ -146,11 +146,11 @@ describe("listProjects", () => { }); const result = await listProjects(client); expect(result.nodes).toHaveLength(1); - expect(result.nodes[0].id).toBe("proj-1"); - expect(result.nodes[0].name).toBe("Project Alpha"); - expect(result.nodes[0].state).toBe("started"); - expect(result.nodes[0].status.name).toBe("Started"); - expect(result.nodes[0].slugId).toBe("alpha"); + expect(result.nodes[0]?.id).toBe("proj-1"); + expect(result.nodes[0]?.name).toBe("Project Alpha"); + expect(result.nodes[0]?.state).toBe("started"); + expect(result.nodes[0]?.status.name).toBe("Started"); + expect(result.nodes[0]?.slugId).toBe("alpha"); expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: "c1" }); }); @@ -238,7 +238,7 @@ describe("listProjects", () => { }, }); const result = await listProjects(client); - expect(result.nodes[0].targetDate).toBeNull(); + expect(result.nodes[0]?.targetDate).toBeNull(); }); }); diff --git a/tests/unit/services/team-service.test.ts b/tests/unit/services/team-service.test.ts index 1cbe92c4..e6646b82 100644 --- a/tests/unit/services/team-service.test.ts +++ b/tests/unit/services/team-service.test.ts @@ -46,9 +46,9 @@ describe("listTeams", () => { }); const result = await listTeams(client); expect(result.nodes).toHaveLength(1); - expect(result.nodes[0].id).toBe("team-1"); - expect(result.nodes[0].key).toBe("ENG"); - expect(result.nodes[0].name).toBe("Engineering"); + expect(result.nodes[0]?.id).toBe("team-1"); + expect(result.nodes[0]?.key).toBe("ENG"); + expect(result.nodes[0]?.name).toBe("Engineering"); expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: "c1" }); }); diff --git a/tests/unit/services/user-service.test.ts b/tests/unit/services/user-service.test.ts index 0063b18b..f5d7fb63 100644 --- a/tests/unit/services/user-service.test.ts +++ b/tests/unit/services/user-service.test.ts @@ -21,8 +21,8 @@ describe("listUsers", () => { }, }); const result = await listUsers(client); - expect(result.nodes[0].name).toBe("Alice"); - expect(result.nodes[1].name).toBe("Zoe"); + expect(result.nodes[0]?.name).toBe("Alice"); + expect(result.nodes[1]?.name).toBe("Zoe"); }); it("returns empty result", async () => { diff --git a/tsconfig.json b/tsconfig.json index 0fa2753b..425e39b1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,12 +4,16 @@ "declaration": false, "declarationMap": false, "esModuleInterop": true, + "exactOptionalPropertyTypes": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true, "lib": ["ES2022", "DOM"], "module": "ESNext", "moduleResolution": "Bundler", "noEmitOnError": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noUncheckedIndexedAccess": true, "outDir": "./dist", "pretty": true, "removeComments": true, From 8fb59c2c635d23e15dde06ec74d33be5c537a94d Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:50:44 +0200 Subject: [PATCH 55/79] refactor(common): harden firstOrThrow and drop dead type guards firstOrThrow now keys off array length instead of the first element being undefined, so a non-empty array with an undefined first element no longer throws. Rebuild the initiative resolver filter so it is provably defined instead of laundering a possibly-undefined value through omitUndefined, and extract parseDueDate components via regex capture groups to remove the unreachable, misleadingly-messaged undefined guard. Also read the own __proto__ key via getOwnPropertyDescriptor to clear a noProto lint warning. --- src/common/array.ts | 5 ++--- src/common/identifier.ts | 14 ++++++++------ src/resolvers/initiative-resolver.ts | 27 ++++++++++++++------------- tests/unit/common/array.test.ts | 4 ++++ tests/unit/common/output.test.ts | 3 ++- 5 files changed, 30 insertions(+), 23 deletions(-) diff --git a/src/common/array.ts b/src/common/array.ts index 6ccc0289..f9777997 100644 --- a/src/common/array.ts +++ b/src/common/array.ts @@ -14,10 +14,9 @@ export function firstOrThrow<T>( items: readonly T[], error: string | Error | (() => string | Error), ): T { - const first = items[0]; - if (first === undefined) { + if (items.length === 0) { const resolved = typeof error === "function" ? error() : error; throw typeof resolved === "string" ? new Error(resolved) : resolved; } - return first; + return items[0] as T; } diff --git a/src/common/identifier.ts b/src/common/identifier.ts index f7269b7f..d1c04aab 100644 --- a/src/common/identifier.ts +++ b/src/common/identifier.ts @@ -44,20 +44,22 @@ export function tryParseIssueIdentifier( } } -const DUE_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; +const DUE_DATE_REGEX = /^(\d{4})-(\d{2})-(\d{2})$/; /** @throws Error if date format is invalid or date doesn't exist */ export function parseDueDate(value: string): string { - if (!DUE_DATE_REGEX.test(value)) { + const match = DUE_DATE_REGEX.exec(value); + if (!match) { throw new Error( `Invalid due date format: "${value}". Expected format: YYYY-MM-DD`, ); } - const [year, month, day] = value.split("-").map(Number); - if (year === undefined || month === undefined || day === undefined) { - throw new Error(`Invalid due date: "${value}". The date does not exist.`); - } + // The three capture groups are guaranteed present when the regex matches. + const [, yearStr, monthStr, dayStr] = match; + const year = Number(yearStr); + const month = Number(monthStr); + const day = Number(dayStr); const date = new Date(year, month - 1, day); if ( diff --git a/src/resolvers/initiative-resolver.ts b/src/resolvers/initiative-resolver.ts index 9379d864..5f81ef9a 100644 --- a/src/resolvers/initiative-resolver.ts +++ b/src/resolvers/initiative-resolver.ts @@ -4,7 +4,6 @@ import type { LinearSdkClient } from "../client/linear-client.js"; import { firstOrThrow } from "../common/array.js"; import { multipleMatchesError, notFoundError } from "../common/errors.js"; import { isUuid } from "../common/identifier.js"; -import { omitUndefined } from "../common/object.js"; import { FindInitiativeProjectLinkByPairDocument, FindInitiativeRelationByPairDocument, @@ -24,26 +23,28 @@ export async function resolveInitiativeId( return nameOrId; } - const clauses: LinearDocument.InitiativeFilter[] = [ - { name: { eqIgnoreCase: nameOrId } }, - ]; + const nameClause: LinearDocument.InitiativeFilter = { + name: { eqIgnoreCase: nameOrId }, + }; + const scopeClauses: LinearDocument.InitiativeFilter[] = []; if (scope.teamId) { - clauses.push({ teams: { some: { id: { eq: scope.teamId } } } }); + scopeClauses.push({ teams: { some: { id: { eq: scope.teamId } } } }); } if (scope.ownerId) { - clauses.push({ owner: { id: { eq: scope.ownerId } } }); + scopeClauses.push({ owner: { id: { eq: scope.ownerId } } }); } - const filter = clauses.length === 1 ? clauses[0] : { and: clauses }; + const filter: LinearDocument.InitiativeFilter = + scopeClauses.length === 0 + ? nameClause + : { and: [nameClause, ...scopeClauses] }; - const result = await client.sdk.initiatives( - omitUndefined({ - filter, - first: 20, - }), - ); + const result = await client.sdk.initiatives({ + filter, + first: 20, + }); if (result.nodes.length === 0) { throw notFoundError("Initiative", nameOrId); diff --git a/tests/unit/common/array.test.ts b/tests/unit/common/array.test.ts index 543a5d2a..5f45bce0 100644 --- a/tests/unit/common/array.test.ts +++ b/tests/unit/common/array.test.ts @@ -12,6 +12,10 @@ describe("firstOrThrow", () => { expect(() => firstOrThrow([], "no items found")).toThrow("no items found"); }); + it("returns an undefined first element without throwing", () => { + expect(firstOrThrow([undefined, 2], "empty")).toBeUndefined(); + }); + it("throws the provided Error instance when the array is empty", () => { const err = new Error("custom"); expect(() => firstOrThrow([], err)).toThrow(err); diff --git a/tests/unit/common/output.test.ts b/tests/unit/common/output.test.ts index 2dfde646..17c38a00 100644 --- a/tests/unit/common/output.test.ts +++ b/tests/unit/common/output.test.ts @@ -180,7 +180,8 @@ describe("pickFields", () => { const result = pickFields(input, [["__proto__", "x"], ["a"]]); expect(Object.getPrototypeOf(result)).toBe(Object.prototype); expect((result as { a: number }).a).toBe(1); - expect((result as Record<string, { x: number }>)["__proto__"]?.x).toBe(9); + const ownProto = Object.getOwnPropertyDescriptor(result, "__proto__"); + expect((ownProto?.value as { x: number }).x).toBe(9); }); }); From 5921828d89d038737ea1b2437c97a3f6763bf227 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:55:35 +0200 Subject: [PATCH 56/79] refactor(common): brand resolved IDs with a compile-time UUID type Introduce a branded `UUID` type in `src/common/identifier.ts` (`UUID`, `asUuid`, `BrandUuidFields`) and thread it through resolver return types, service parameters and input types, and the command call sites that feed them. The compiler now enforces the architecture invariant that services receive already-resolved IDs: a plain identifier string (e.g. `ENG-123`) is no longer assignable to a service's UUID slot. The brand is erased at runtime, so there is no behavior change. Closes #196 --- src/commands/attachments.ts | 3 +- src/commands/comments.ts | 15 +- src/commands/documents.ts | 15 +- src/commands/initiatives/entity.ts | 29 ++-- src/commands/initiatives/updates.ts | 16 +- src/commands/issues.ts | 58 ++++--- src/commands/labels.ts | 3 +- src/commands/projects.ts | 41 +++-- src/common/identifier.ts | 39 ++++- src/resolvers/cycle-resolver.ts | 8 +- src/resolvers/initiative-resolver.ts | 33 ++-- src/resolvers/issue-filter-resolver.ts | 17 +- src/resolvers/issue-resolver.ts | 23 ++- src/resolvers/label-resolver.ts | 12 +- src/resolvers/milestone-resolver.ts | 10 +- src/resolvers/project-resolver.ts | 24 +-- src/resolvers/project-status-resolver.ts | 8 +- src/resolvers/status-resolver.ts | 24 +-- src/resolvers/team-resolver.ts | 14 +- src/resolvers/user-resolver.ts | 10 +- src/services/attachment-service.ts | 14 +- src/services/comment-service.ts | 11 +- src/services/cycle-service.ts | 5 +- src/services/discussion-service.ts | 57 +++---- src/services/document-service.ts | 29 ++-- src/services/initiative-project-service.ts | 5 +- src/services/initiative-relation-service.ts | 5 +- src/services/initiative-service.ts | 61 ++++---- src/services/initiative-update-service.ts | 17 +- src/services/issue-relation-service.ts | 19 +-- src/services/issue-service.ts | 66 +++++--- src/services/label-service.ts | 17 +- src/services/milestone-service.ts | 16 +- src/services/project-service.ts | 77 +++++----- src/services/reaction-service.ts | 9 +- src/services/team-service.ts | 3 +- tests/unit/commands/issues.test.ts | 6 +- .../resolvers/initiative-resolver.test.ts | 37 +++-- tests/unit/resolvers/status-resolver.test.ts | 4 +- .../unit/services/attachment-service.test.ts | 18 ++- tests/unit/services/comment-service.test.ts | 34 ++-- tests/unit/services/cycle-service.test.ts | 10 +- .../unit/services/discussion-service.test.ts | 145 +++++++++++------- tests/unit/services/document-service.test.ts | 18 ++- .../initiative-project-service.test.ts | 27 ++-- .../initiative-relation-service.test.ts | 29 ++-- .../unit/services/initiative-service.test.ts | 27 ++-- .../initiative-update-service.test.ts | 67 ++++---- .../services/issue-relation-service.test.ts | 33 ++-- tests/unit/services/issue-service.test.ts | 51 +++--- tests/unit/services/label-service.test.ts | 19 +-- tests/unit/services/milestone-service.test.ts | 28 ++-- .../milestone-service.variables.test.ts | 5 +- tests/unit/services/project-service.test.ts | 32 ++-- tests/unit/services/reaction-service.test.ts | 45 ++++-- tests/unit/services/team-service.test.ts | 14 +- 56 files changed, 871 insertions(+), 591 deletions(-) diff --git a/src/commands/attachments.ts b/src/commands/attachments.ts index c07c7982..1ffcb2f7 100644 --- a/src/commands/attachments.ts +++ b/src/commands/attachments.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { createContext, getRootOpts } from "../common/context.js"; import { invalidParameterError } from "../common/errors.js"; +import { asUuid } from "../common/identifier.js"; import { handleCommand, outputSuccess } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { resolveIssueId } from "../resolvers/issue-resolver.js"; @@ -138,7 +139,7 @@ export function setupAttachmentsCommands(program: Command): void { handleCommand(async (...args: unknown[]) => { const [id, , command] = args as [string, unknown, Command]; const ctx = createContext(getRootOpts(command)); - const result = await deleteAttachment(ctx.gql, id); + const result = await deleteAttachment(ctx.gql, asUuid(id)); outputSuccess(result); }), ); diff --git a/src/commands/comments.ts b/src/commands/comments.ts index fd661ec8..cd3355d5 100644 --- a/src/commands/comments.ts +++ b/src/commands/comments.ts @@ -6,6 +6,7 @@ import { } from "../common/context.js"; import { resolveReactionEmojiInput } from "../common/emoji.js"; import { invalidParameterError } from "../common/errors.js"; +import { asUuid } from "../common/identifier.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { buildPaginationOptions } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; @@ -171,7 +172,7 @@ export function setupCommentsCommands(program: Command): void { } const result = await replyToDiscussion(ctx.gql, { - threadId: thread, + threadId: asUuid(thread), body: options.body, entityKind: "issue", }); @@ -200,7 +201,7 @@ export function setupCommentsCommands(program: Command): void { throw invalidParameterError("--body", "is required"); } - const result = await editDiscussionComment(ctx.gql, comment, { + const result = await editDiscussionComment(ctx.gql, asUuid(comment), { body: options.body, }); @@ -219,7 +220,7 @@ export function setupCommentsCommands(program: Command): void { const [comment, , command] = args as [string, unknown, Command]; const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionComment(ctx.gql, comment); + const result = await deleteDiscussionComment(ctx.gql, asUuid(comment)); outputSuccess(result); }), @@ -246,7 +247,7 @@ export function setupCommentsCommands(program: Command): void { const ctx = createContext(getRootOpts(command)); const result = await createIssueDiscussionCommentReaction(ctx.gql, { - commentId: comment, + commentId: asUuid(comment), emoji: resolveReactionEmojiInput(emoji, options.shortcode), }); @@ -277,7 +278,7 @@ export function setupCommentsCommands(program: Command): void { const result = await deleteIssueDiscussionCommentReactionByEmoji( ctx.gql, { - commentId: comment, + commentId: asUuid(comment), emoji: resolveReactionEmojiInput(emoji, options.shortcode), }, ); @@ -306,8 +307,8 @@ export function setupCommentsCommands(program: Command): void { const ctx = createContext(getRootOpts(command)); const result = await deleteIssueDiscussionCommentReactionById(ctx.gql, { - commentId: comment, - reactionId, + commentId: asUuid(comment), + reactionId: asUuid(reactionId), }); outputSuccess(result); diff --git a/src/commands/documents.ts b/src/commands/documents.ts index 143a56eb..fb312981 100644 --- a/src/commands/documents.ts +++ b/src/commands/documents.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { createContext, getRootOpts } from "../common/context.js"; import { invalidParameterError } from "../common/errors.js"; +import { asUuid, type UUID } from "../common/identifier.js"; import { omitUndefined } from "../common/object.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; @@ -119,12 +120,12 @@ export function setupDocumentsCommands(program: Command): void { const limit = parseLimit(options.limit || "50"); - let projectId: string | undefined; + let projectId: UUID | undefined; if (options.project) { projectId = await resolveProjectId(ctx.sdk, options.project); } - let issueId: string | undefined; + let issueId: UUID | undefined; if (options.issue) { issueId = await resolveIssueId(ctx.sdk, options.issue); } @@ -166,7 +167,7 @@ export function setupDocumentsCommands(program: Command): void { const rootOpts = getRootOpts(command); const ctx = createContext(rootOpts); - const documentResult = await getDocument(ctx.gql, document); + const documentResult = await getDocument(ctx.gql, asUuid(document)); outputSuccess(documentResult); }), ); @@ -247,7 +248,11 @@ export function setupDocumentsCommands(program: Command): void { if (options.icon) input.icon = options.icon; if (options.color) input.color = options.color; - const updatedDocument = await updateDocument(ctx.gql, document, input); + const updatedDocument = await updateDocument( + ctx.gql, + asUuid(document), + input, + ); outputSuccess(updatedDocument); }), ); @@ -261,7 +266,7 @@ export function setupDocumentsCommands(program: Command): void { const rootOpts = getRootOpts(command); const ctx = createContext(rootOpts); - const result = await deleteDocument(ctx.gql, document); + const result = await deleteDocument(ctx.gql, asUuid(document)); outputSuccess(result); }), ); diff --git a/src/commands/initiatives/entity.ts b/src/commands/initiatives/entity.ts index fa62f3b0..0a2cadca 100644 --- a/src/commands/initiatives/entity.ts +++ b/src/commands/initiatives/entity.ts @@ -3,6 +3,7 @@ import type { LinearSdkClient } from "../../client/linear-client.js"; import { createContext, getRootOpts } from "../../common/context.js"; import { resolveReactionEmojiInput } from "../../common/emoji.js"; import { invalidParameterError } from "../../common/errors.js"; +import { asUuid } from "../../common/identifier.js"; import { omitUndefined } from "../../common/object.js"; import { commandAction, @@ -120,7 +121,7 @@ function addCommentReactionCommands( async (commentId, emoji, options, command) => { const ctx = createContext(getRootOpts(command)); const result = await createDiscussionCommentReaction(ctx.gql, { - commentId, + commentId: asUuid(commentId), target: noun, expectedEntityKind: "initiative", emoji: resolveReactionEmojiInput(emoji, options.shortcode), @@ -139,7 +140,7 @@ function addCommentReactionCommands( async (commentId, emoji, options, command) => { const ctx = createContext(getRootOpts(command)); const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { - commentId, + commentId: asUuid(commentId), target: noun, expectedEntityKind: "initiative", emoji: resolveReactionEmojiInput(emoji, options.shortcode), @@ -159,10 +160,10 @@ function addCommentReactionCommands( async (commentId, reactionId, _unused2, command) => { const ctx = createContext(getRootOpts(command)); const result = await deleteDiscussionCommentReactionById(ctx.gql, { - commentId, + commentId: asUuid(commentId), target: noun, expectedEntityKind: "initiative", - reactionId, + reactionId: asUuid(reactionId), }); outputSuccess(result); }, @@ -500,13 +501,13 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { const result = options.withReactions ? await listDiscussionRepliesWithReactions( ctx.gql, - thread, + asUuid(thread), paginationOptions, "initiative", ) : await listDiscussionReplies( ctx.gql, - thread, + asUuid(thread), paginationOptions, "initiative", ); @@ -535,7 +536,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { } const result = await replyToDiscussion(ctx.gql, { - threadId: thread, + threadId: asUuid(thread), body: options.body, entityKind: "initiative", }); @@ -560,7 +561,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { const result = await editDiscussionComment( ctx.gql, - comment, + asUuid(comment), { body: options.body, }, @@ -587,7 +588,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { const result = await editDiscussionReply( ctx.gql, - reply, + asUuid(reply), { body: options.body, }, @@ -609,7 +610,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { const result = await deleteDiscussionComment( ctx.gql, - comment, + asUuid(comment), "initiative", ); @@ -628,7 +629,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { const result = await deleteDiscussionReply( ctx.gql, - reply, + asUuid(reply), "initiative", ); @@ -647,9 +648,9 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { const ctx = createContext(getRootOpts(command)); const result = await resolveDiscussion(ctx.gql, { - threadId: thread, + threadId: asUuid(thread), ...(options.withComment !== undefined - ? { resolvingCommentId: options.withComment } + ? { resolvingCommentId: asUuid(options.withComment) } : {}), entityKind: "initiative", }); @@ -669,7 +670,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { const result = await unresolveDiscussion( ctx.gql, - thread, + asUuid(thread), "initiative", ); diff --git a/src/commands/initiatives/updates.ts b/src/commands/initiatives/updates.ts index 253ac7b3..a611ab4b 100644 --- a/src/commands/initiatives/updates.ts +++ b/src/commands/initiatives/updates.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { createContext, getRootOpts } from "../../common/context.js"; import { invalidParameterError } from "../../common/errors.js"; +import { asUuid } from "../../common/identifier.js"; import { handleCommand, outputSuccess, @@ -82,7 +83,7 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { handleCommand(async (...args: unknown[]) => { const [updateId, , command] = args as [string, unknown, Command]; const ctx = createContext(getRootOpts(command)); - const result = await getInitiativeUpdate(ctx.gql, updateId); + const result = await getInitiativeUpdate(ctx.gql, asUuid(updateId)); outputSuccess(result); }), ); @@ -154,7 +155,11 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { ); } - const result = await updateInitiativeUpdate(ctx.gql, updateId, input); + const result = await updateInitiativeUpdate( + ctx.gql, + asUuid(updateId), + input, + ); outputSuccess(result); }), ); @@ -166,7 +171,7 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { handleCommand(async (...args: unknown[]) => { const [updateId, , command] = args as [string, unknown, Command]; const ctx = createContext(getRootOpts(command)); - const result = await archiveInitiativeUpdate(ctx.gql, updateId); + const result = await archiveInitiativeUpdate(ctx.gql, asUuid(updateId)); outputSuccess(result); }), ); @@ -178,7 +183,10 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { handleCommand(async (...args: unknown[]) => { const [updateId, , command] = args as [string, unknown, Command]; const ctx = createContext(getRootOpts(command)); - const result = await unarchiveInitiativeUpdate(ctx.gql, updateId); + const result = await unarchiveInitiativeUpdate( + ctx.gql, + asUuid(updateId), + ); outputSuccess(result); }), ); diff --git a/src/commands/issues.ts b/src/commands/issues.ts index a0b34fa6..c000dbbc 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -7,9 +7,11 @@ import { resolveReactionEmojiInput } from "../common/emoji.js"; import { invalidParameterError } from "../common/errors.js"; import { validateEstimateAgainstTeamConfig } from "../common/estimate-validation.js"; import { + asUuid, isUuid, parseDueDate, parseIssueIdentifier, + type UUID, } from "../common/identifier.js"; import type { RawFilterFlags } from "../common/issue-filter.js"; import { @@ -192,7 +194,7 @@ function addCommentReactionCommands( async (commentId, emoji, options, command) => { const ctx = createContext(getRootOpts(command)); const result = await createDiscussionCommentReaction(ctx.gql, { - commentId, + commentId: asUuid(commentId), target: noun, expectedEntityKind: "issue", emoji: resolveReactionEmojiInput(emoji, options.shortcode), @@ -212,7 +214,7 @@ function addCommentReactionCommands( async (commentId, emoji, options, command) => { const ctx = createContext(getRootOpts(command)); const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { - commentId, + commentId: asUuid(commentId), target: noun, expectedEntityKind: "issue", emoji: resolveReactionEmojiInput(emoji, options.shortcode), @@ -233,10 +235,10 @@ function addCommentReactionCommands( async (commentId, reactionId, _unused2, command) => { const ctx = createContext(getRootOpts(command)); const result = await deleteDiscussionCommentReactionById(ctx.gql, { - commentId, + commentId: asUuid(commentId), target: noun, expectedEntityKind: "issue", - reactionId, + reactionId: asUuid(reactionId), }); outputSuccess(result); @@ -415,12 +417,12 @@ function parseRelationAddOptions(options: RelationAddOptions): { async function resolveAndApplyRelations( ctx: CommandContext, - issueId: string, + issueId: UUID, actions: RelationAction[], ): Promise<void> { // Resolve all unique targets to UUIDs const uniqueTargets = new Set(actions.flatMap((a) => a.targets)); - const resolved = new Map<string, string>(); + const resolved = new Map<string, UUID>(); await Promise.all( [...uniqueTargets].map(async (target) => { resolved.set(target, await resolveIssueId(ctx.sdk, target)); @@ -580,7 +582,7 @@ export function setupIssuesCommands(program: Command): void { commandAction<[string, unknown, Command]>( async (relation, _unused1, command) => { const ctx = createContext(getRootOpts(command)); - const result = await deleteIssueRelation(ctx.gql, relation); + const result = await deleteIssueRelation(ctx.gql, asUuid(relation)); outputSuccess(result); }, @@ -813,7 +815,7 @@ export function setupIssuesCommands(program: Command): void { const result = await deleteOwnReactionById(ctx.gql, { kind: "issue", id: issueId, - reactionId, + reactionId: asUuid(reactionId), }); outputSuccess(result); @@ -909,13 +911,13 @@ export function setupIssuesCommands(program: Command): void { const result = options.withReactions ? await listDiscussionRepliesWithReactions( ctx.gql, - thread, + asUuid(thread), paginationOptions, "issue", ) : await listDiscussionReplies( ctx.gql, - thread, + asUuid(thread), paginationOptions, "issue", ); @@ -944,7 +946,7 @@ export function setupIssuesCommands(program: Command): void { } const result = await replyToDiscussion(ctx.gql, { - threadId: thread, + threadId: asUuid(thread), body: options.body, entityKind: "issue", }); @@ -969,7 +971,7 @@ export function setupIssuesCommands(program: Command): void { const result = await editDiscussionComment( ctx.gql, - comment, + asUuid(comment), { body: options.body, }, @@ -996,7 +998,7 @@ export function setupIssuesCommands(program: Command): void { const result = await editDiscussionReply( ctx.gql, - reply, + asUuid(reply), { body: options.body, }, @@ -1018,7 +1020,7 @@ export function setupIssuesCommands(program: Command): void { const result = await deleteDiscussionComment( ctx.gql, - comment, + asUuid(comment), "issue", ); @@ -1035,7 +1037,11 @@ export function setupIssuesCommands(program: Command): void { async (reply, _unused1, command) => { const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionReply(ctx.gql, reply, "issue"); + const result = await deleteDiscussionReply( + ctx.gql, + asUuid(reply), + "issue", + ); outputSuccess(result); }, @@ -1052,9 +1058,9 @@ export function setupIssuesCommands(program: Command): void { const ctx = createContext(getRootOpts(command)); const result = await resolveDiscussion(ctx.gql, { - threadId: thread, + threadId: asUuid(thread), ...(options.withComment !== undefined - ? { resolvingCommentId: options.withComment } + ? { resolvingCommentId: asUuid(options.withComment) } : {}), entityKind: "issue", }); @@ -1072,7 +1078,11 @@ export function setupIssuesCommands(program: Command): void { async (thread, _unused1, command) => { const ctx = createContext(getRootOpts(command)); - const result = await unresolveDiscussion(ctx.gql, thread, "issue"); + const result = await unresolveDiscussion( + ctx.gql, + asUuid(thread), + "issue", + ); outputSuccess(result); }, @@ -1213,7 +1223,11 @@ export function setupIssuesCommands(program: Command): void { const result = await createIssue(ctx.gql, input); if (relationActions.length > 0) { - await resolveAndApplyRelations(ctx, result.id, relationActions); + await resolveAndApplyRelations( + ctx, + asUuid(result.id), + relationActions, + ); } outputSuccess(result); @@ -1354,7 +1368,7 @@ export function setupIssuesCommands(program: Command): void { if (options.status) { const teamId = issueContext && "team" in issueContext && issueContext.team - ? issueContext.team.id + ? asUuid(issueContext.team.id) : undefined; input.stateId = await resolveStatusId( ctx.sdk, @@ -1392,7 +1406,7 @@ export function setupIssuesCommands(program: Command): void { issueContext && "labels" in issueContext && issueContext.labels?.nodes - ? issueContext.labels.nodes.map((l) => l.id) + ? issueContext.labels.nodes.map((l) => asUuid(l.id)) : []; input.labelIds = [...new Set([...currentLabels, ...labelIds])]; } else if (labelMode === "remove") { @@ -1400,7 +1414,7 @@ export function setupIssuesCommands(program: Command): void { issueContext && "labels" in issueContext && issueContext.labels?.nodes - ? issueContext.labels.nodes.map((l) => l.id) + ? issueContext.labels.nodes.map((l) => asUuid(l.id)) : []; input.labelIds = currentLabels.filter( (id) => !labelIds.includes(id), diff --git a/src/commands/labels.ts b/src/commands/labels.ts index 8bf4899a..33ba1828 100644 --- a/src/commands/labels.ts +++ b/src/commands/labels.ts @@ -5,6 +5,7 @@ import { getRootOpts, } from "../common/context.js"; import { invalidParameterError } from "../common/errors.js"; +import type { UUID } from "../common/identifier.js"; import { omitUndefined } from "../common/object.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; @@ -86,7 +87,7 @@ async function resolveIssueLabelLookup( command: Command, label: string, options: LabelLookupOptions, -): Promise<{ ctx: ReturnType<typeof createContext>; labelId: string }> { +): Promise<{ ctx: ReturnType<typeof createContext>; labelId: UUID }> { const ctx = createContext(getRootOpts(command)); const scope = parseLabelScope(options.scope); diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 0b7f9cb0..4772f30b 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -3,6 +3,7 @@ import { createContext, getRootOpts } from "../common/context.js"; import { type Priority, parseLabelMode } from "../common/domain-values.js"; import { resolveReactionEmojiInput } from "../common/emoji.js"; import { invalidParameterError } from "../common/errors.js"; +import { asUuid } from "../common/identifier.js"; import { commandAction, outputSuccess, parseLimit } from "../common/output.js"; import { buildPaginationOptions } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; @@ -84,7 +85,7 @@ function addCommentReactionCommands( async (commentId, emoji, options, command) => { const ctx = createContext(getRootOpts(command)); const result = await createDiscussionCommentReaction(ctx.gql, { - commentId, + commentId: asUuid(commentId), target: noun, expectedEntityKind: "project", emoji: resolveReactionEmojiInput(emoji, options.shortcode), @@ -103,7 +104,7 @@ function addCommentReactionCommands( async (commentId, emoji, options, command) => { const ctx = createContext(getRootOpts(command)); const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { - commentId, + commentId: asUuid(commentId), target: noun, expectedEntityKind: "project", emoji: resolveReactionEmojiInput(emoji, options.shortcode), @@ -123,10 +124,10 @@ function addCommentReactionCommands( async (commentId, reactionId, _unused2, command) => { const ctx = createContext(getRootOpts(command)); const result = await deleteDiscussionCommentReactionById(ctx.gql, { - commentId, + commentId: asUuid(commentId), target: noun, expectedEntityKind: "project", - reactionId, + reactionId: asUuid(reactionId), }); outputSuccess(result); }, @@ -386,13 +387,13 @@ export function setupProjectsCommands(program: Command): void { const result = options.withReactions ? await listDiscussionRepliesWithReactions( ctx.gql, - thread, + asUuid(thread), paginationOptions, "project", ) : await listDiscussionReplies( ctx.gql, - thread, + asUuid(thread), paginationOptions, "project", ); @@ -421,7 +422,7 @@ export function setupProjectsCommands(program: Command): void { } const result = await replyToDiscussion(ctx.gql, { - threadId: thread, + threadId: asUuid(thread), body: options.body, entityKind: "project", }); @@ -446,7 +447,7 @@ export function setupProjectsCommands(program: Command): void { const result = await editDiscussionComment( ctx.gql, - comment, + asUuid(comment), { body: options.body, }, @@ -473,7 +474,7 @@ export function setupProjectsCommands(program: Command): void { const result = await editDiscussionReply( ctx.gql, - reply, + asUuid(reply), { body: options.body, }, @@ -495,7 +496,7 @@ export function setupProjectsCommands(program: Command): void { const result = await deleteDiscussionComment( ctx.gql, - comment, + asUuid(comment), "project", ); @@ -512,7 +513,11 @@ export function setupProjectsCommands(program: Command): void { async (reply, _unused1, command) => { const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionReply(ctx.gql, reply, "project"); + const result = await deleteDiscussionReply( + ctx.gql, + asUuid(reply), + "project", + ); outputSuccess(result); }, @@ -529,9 +534,9 @@ export function setupProjectsCommands(program: Command): void { const ctx = createContext(getRootOpts(command)); const result = await resolveDiscussion(ctx.gql, { - threadId: thread, + threadId: asUuid(thread), ...(options.withComment !== undefined - ? { resolvingCommentId: options.withComment } + ? { resolvingCommentId: asUuid(options.withComment) } : {}), entityKind: "project", }); @@ -549,7 +554,11 @@ export function setupProjectsCommands(program: Command): void { async (thread, _unused1, command) => { const ctx = createContext(getRootOpts(command)); - const result = await unresolveDiscussion(ctx.gql, thread, "project"); + const result = await unresolveDiscussion( + ctx.gql, + asUuid(thread), + "project", + ); outputSuccess(result); }, @@ -807,12 +816,12 @@ export function setupProjectsCommands(program: Command): void { if (labelMode === "add") { const currentLabels = projectContext?.labels?.nodes - ? projectContext.labels.nodes.map((l) => l.id) + ? projectContext.labels.nodes.map((l) => asUuid(l.id)) : []; input.labelIds = [...new Set([...currentLabels, ...labelIds])]; } else if (labelMode === "remove") { const currentLabels = projectContext?.labels?.nodes - ? projectContext.labels.nodes.map((l) => l.id) + ? projectContext.labels.nodes.map((l) => asUuid(l.id)) : []; input.labelIds = currentLabels.filter( (id) => !labelIds.includes(id), diff --git a/src/common/identifier.ts b/src/common/identifier.ts index d1c04aab..56d50881 100644 --- a/src/common/identifier.ts +++ b/src/common/identifier.ts @@ -1,10 +1,47 @@ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -export function isUuid(value: string): boolean { +type Brand<T, TBrand extends string> = T & { readonly __brand: TBrand }; + +export function isUuid(value: string): value is UUID { return UUID_REGEX.test(value); } +/** + * A resolved Linear entity UUID. + * + * Branded so the compiler enforces the architecture invariant that services + * receive already-resolved IDs: a plain `string` (e.g. a human identifier like + * `ENG-123`) is not assignable to `UUID`, but a `UUID` flows into `string` + * slots (such as codegen GraphQL inputs) untouched. The brand is erased at + * runtime, so a `UUID` behaves exactly like the underlying string. + */ +export type UUID = Brand<string, "UUID">; + +/** + * Brand a string as a resolved UUID at a trust boundary — the output of a + * resolver, or a UUID the user supplied directly on the CLI. Performs no + * runtime validation. + */ +export function asUuid(value: string): UUID { + return value as UUID; +} + +/** Replace a `string`/`string[]` core with `UUID`/`UUID[]`, preserving null/undefined. */ +type ReplaceStringWithUuid<V> = V extends string + ? UUID + : V extends string[] + ? UUID[] + : V; + +/** + * Brand selected keys of an input type as UUID, preserving each field's + * optional and readonly modifiers (homomorphic over `keyof T`). + */ +export type BrandUuidFields<T, K extends keyof T> = { + [P in keyof T]: P extends K ? ReplaceStringWithUuid<T[P]> : T[P]; +}; + export interface IssueIdentifier { teamKey: string; issueNumber: number; diff --git a/src/resolvers/cycle-resolver.ts b/src/resolvers/cycle-resolver.ts index a5ce89c8..336b03bd 100644 --- a/src/resolvers/cycle-resolver.ts +++ b/src/resolvers/cycle-resolver.ts @@ -1,7 +1,7 @@ import type { LinearDocument } from "@linear/sdk"; import type { LinearSdkClient } from "../client/linear-client.js"; import { multipleMatchesError, notFoundError } from "../common/errors.js"; -import { isUuid } from "../common/identifier.js"; +import { asUuid, isUuid, type UUID } from "../common/identifier.js"; import { resolveTeamId } from "./team-resolver.js"; /** @@ -20,8 +20,8 @@ export async function resolveCycleId( client: LinearSdkClient, nameOrId: string, teamFilter?: string, -): Promise<string> { - if (isUuid(nameOrId)) return nameOrId; +): Promise<UUID> { + if (isUuid(nameOrId)) return asUuid(nameOrId); const filter: LinearDocument.CycleFilter = { name: { eq: nameOrId }, @@ -92,5 +92,5 @@ export async function resolveCycleId( ); } - return chosen.id; + return asUuid(chosen.id); } diff --git a/src/resolvers/initiative-resolver.ts b/src/resolvers/initiative-resolver.ts index 5f81ef9a..3f3aa03b 100644 --- a/src/resolvers/initiative-resolver.ts +++ b/src/resolvers/initiative-resolver.ts @@ -3,24 +3,24 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import type { LinearSdkClient } from "../client/linear-client.js"; import { firstOrThrow } from "../common/array.js"; import { multipleMatchesError, notFoundError } from "../common/errors.js"; -import { isUuid } from "../common/identifier.js"; +import { asUuid, isUuid, type UUID } from "../common/identifier.js"; import { FindInitiativeProjectLinkByPairDocument, FindInitiativeRelationByPairDocument, } from "../gql/graphql.js"; export interface InitiativeResolveScope { - teamId?: string; - ownerId?: string; + teamId?: UUID; + ownerId?: UUID; } export async function resolveInitiativeId( client: LinearSdkClient, nameOrId: string, scope: InitiativeResolveScope = {}, -): Promise<string> { +): Promise<UUID> { if (isUuid(nameOrId)) { - return nameOrId; + return asUuid(nameOrId); } const nameClause: LinearDocument.InitiativeFilter = { @@ -51,9 +51,10 @@ export async function resolveInitiativeId( } if (result.nodes.length === 1) { - return firstOrThrow(result.nodes, () => - notFoundError("Initiative", nameOrId), - ).id; + return asUuid( + firstOrThrow(result.nodes, () => notFoundError("Initiative", nameOrId)) + .id, + ); } const candidates = result.nodes.map((node) => `${node.name} (${node.id})`); @@ -70,9 +71,9 @@ export async function resolveInitiativeId( export async function resolveInitiativeRelationId( client: GraphQLClient, - parentId: string, - childId: string, -): Promise<string> { + parentId: UUID, + childId: UUID, +): Promise<UUID> { let after: string | undefined; while (true) { @@ -89,7 +90,7 @@ export async function resolveInitiativeRelationId( ); if (relation) { - return relation.id; + return asUuid(relation.id); } if (!result.initiativeRelations.pageInfo.hasNextPage) { @@ -107,9 +108,9 @@ export async function resolveInitiativeRelationId( export async function resolveInitiativeProjectLinkId( client: GraphQLClient, - initiativeId: string, - projectId: string, -): Promise<string> { + initiativeId: UUID, + projectId: UUID, +): Promise<UUID> { let after: string | undefined; while (true) { @@ -124,7 +125,7 @@ export async function resolveInitiativeProjectLinkId( ); if (link) { - return link.id; + return asUuid(link.id); } if (!result.initiativeToProjects.pageInfo.hasNextPage) { diff --git a/src/resolvers/issue-filter-resolver.ts b/src/resolvers/issue-filter-resolver.ts index 8fdf3164..9729797d 100644 --- a/src/resolvers/issue-filter-resolver.ts +++ b/src/resolvers/issue-filter-resolver.ts @@ -1,4 +1,5 @@ import type { LinearSdkClient } from "../client/linear-client.js"; +import type { UUID } from "../common/identifier.js"; import { resolveCycleId } from "./cycle-resolver.js"; import { resolveIssueId } from "./issue-resolver.js"; import { resolveLabelIds } from "./label-resolver.js"; @@ -19,14 +20,14 @@ export interface SearchFilterResolutionInput { } export interface SearchFilterResolution { - teamId?: string; - assigneeId?: string; - creatorId?: string; - projectId?: string; - stateIds?: string[]; - labelIds?: string[]; - cycleId?: string; - parentId?: string; + teamId?: UUID; + assigneeId?: UUID; + creatorId?: UUID; + projectId?: UUID; + stateIds?: UUID[]; + labelIds?: UUID[]; + cycleId?: UUID; + parentId?: UUID; } export async function resolveSearchFilterIds( diff --git a/src/resolvers/issue-resolver.ts b/src/resolvers/issue-resolver.ts index 317804bb..e3eab997 100644 --- a/src/resolvers/issue-resolver.ts +++ b/src/resolvers/issue-resolver.ts @@ -1,7 +1,12 @@ import type { LinearSdkClient } from "../client/linear-client.js"; import { firstOrThrow } from "../common/array.js"; import { notFoundError } from "../common/errors.js"; -import { isUuid, parseIssueIdentifier } from "../common/identifier.js"; +import { + asUuid, + isUuid, + parseIssueIdentifier, + type UUID, +} from "../common/identifier.js"; import { omitUndefined } from "../common/object.js"; import { resolveTeamEstimateContext, @@ -77,7 +82,7 @@ async function getIssueTeamLookup( } export interface IssueEstimateContext { - issueId: string; + issueId: UUID; team: TeamEstimateContext; } @@ -94,8 +99,8 @@ export interface IssueEstimateContext { export async function resolveIssueId( client: LinearSdkClient, issueIdOrIdentifier: string, -): Promise<string> { - if (isUuid(issueIdOrIdentifier)) return issueIdOrIdentifier; +): Promise<UUID> { + if (isUuid(issueIdOrIdentifier)) return asUuid(issueIdOrIdentifier); const { teamKey, issueNumber } = parseIssueIdentifier(issueIdOrIdentifier); @@ -107,9 +112,11 @@ export async function resolveIssueId( first: 1, }); - return firstOrThrow(issues.nodes, () => - notFoundError("Issue", issueIdOrIdentifier), - ).id; + return asUuid( + firstOrThrow(issues.nodes, () => + notFoundError("Issue", issueIdOrIdentifier), + ).id, + ); } export async function resolveIssueEstimateContext( @@ -152,7 +159,7 @@ export async function resolveIssueEstimateContext( } return { - issueId: projection.id, + issueId: asUuid(projection.id), team: await resolveTeamEstimateContext(client, teamLookup), }; } diff --git a/src/resolvers/label-resolver.ts b/src/resolvers/label-resolver.ts index 6ac56818..75af59e7 100644 --- a/src/resolvers/label-resolver.ts +++ b/src/resolvers/label-resolver.ts @@ -1,7 +1,7 @@ import type { LinearSdkClient } from "../client/linear-client.js"; import { firstOrThrow } from "../common/array.js"; import { notFoundError } from "../common/errors.js"; -import { isUuid } from "../common/identifier.js"; +import { asUuid, isUuid, type UUID } from "../common/identifier.js"; export type LabelResolverScope = "workspace" | "team"; @@ -42,20 +42,22 @@ export async function resolveLabelId( client: LinearSdkClient, nameOrId: string, options: ResolveLabelOptions = {}, -): Promise<string> { - if (isUuid(nameOrId)) return nameOrId; +): Promise<UUID> { + if (isUuid(nameOrId)) return asUuid(nameOrId); const result = await client.sdk.issueLabels({ filter: buildLabelFilter(nameOrId, options), first: 1, }); - return firstOrThrow(result.nodes, () => notFoundError("Label", nameOrId)).id; + return asUuid( + firstOrThrow(result.nodes, () => notFoundError("Label", nameOrId)).id, + ); } export async function resolveLabelIds( client: LinearSdkClient, namesOrIds: string[], -): Promise<string[]> { +): Promise<UUID[]> { return Promise.all(namesOrIds.map((id) => resolveLabelId(client, id))); } diff --git a/src/resolvers/milestone-resolver.ts b/src/resolvers/milestone-resolver.ts index d98c77e2..17caead4 100644 --- a/src/resolvers/milestone-resolver.ts +++ b/src/resolvers/milestone-resolver.ts @@ -2,7 +2,7 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import type { LinearSdkClient } from "../client/linear-client.js"; import { firstOrThrow } from "../common/array.js"; import { multipleMatchesError, notFoundError } from "../common/errors.js"; -import { isUuid } from "../common/identifier.js"; +import { asUuid, isUuid, type UUID } from "../common/identifier.js"; import { FindProjectMilestoneGlobalDocument, FindProjectMilestoneScopedDocument, @@ -33,8 +33,8 @@ export async function resolveMilestoneId( sdkClient: LinearSdkClient, nameOrId: string, projectNameOrId?: string, -): Promise<string> { - if (isUuid(nameOrId)) return nameOrId; +): Promise<UUID> { + if (isUuid(nameOrId)) return asUuid(nameOrId); type MilestoneNode = { id: string; @@ -77,5 +77,7 @@ export async function resolveMilestoneId( ); } - return firstOrThrow(nodes, () => notFoundError("Milestone", nameOrId)).id; + return asUuid( + firstOrThrow(nodes, () => notFoundError("Milestone", nameOrId)).id, + ); } diff --git a/src/resolvers/project-resolver.ts b/src/resolvers/project-resolver.ts index f8ffd2f5..e11b79cf 100644 --- a/src/resolvers/project-resolver.ts +++ b/src/resolvers/project-resolver.ts @@ -1,7 +1,7 @@ import type { LinearSdkClient } from "../client/linear-client.js"; import { firstOrThrow } from "../common/array.js"; import { multipleMatchesError, notFoundError } from "../common/errors.js"; -import { isUuid } from "../common/identifier.js"; +import { asUuid, isUuid, type UUID } from "../common/identifier.js"; import { omitUndefined } from "../common/object.js"; export interface ResolveProjectIdOptions { @@ -12,8 +12,8 @@ export async function resolveProjectId( client: LinearSdkClient, nameOrId: string, options: ResolveProjectIdOptions = {}, -): Promise<string> { - if (isUuid(nameOrId)) return nameOrId; +): Promise<UUID> { + if (isUuid(nameOrId)) return asUuid(nameOrId); const result = await client.sdk.projects( omitUndefined({ @@ -36,30 +36,32 @@ export async function resolveProjectId( ); } - return firstOrThrow(result.nodes, () => notFoundError("Project", nameOrId)) - .id; + return asUuid( + firstOrThrow(result.nodes, () => notFoundError("Project", nameOrId)).id, + ); } export async function resolveProjectLabelId( client: LinearSdkClient, nameOrId: string, -): Promise<string> { - if (isUuid(nameOrId)) return nameOrId; +): Promise<UUID> { + if (isUuid(nameOrId)) return asUuid(nameOrId); const result = await client.sdk.projectLabels({ filter: { name: { eqIgnoreCase: nameOrId } }, first: 1, }); - return firstOrThrow(result.nodes, () => - notFoundError("Project label", nameOrId), - ).id; + return asUuid( + firstOrThrow(result.nodes, () => notFoundError("Project label", nameOrId)) + .id, + ); } export async function resolveProjectLabelIds( client: LinearSdkClient, namesOrIds: string[], -): Promise<string[]> { +): Promise<UUID[]> { return Promise.all( namesOrIds.map((nameOrId) => resolveProjectLabelId(client, nameOrId)), ); diff --git a/src/resolvers/project-status-resolver.ts b/src/resolvers/project-status-resolver.ts index 0dc0f8e0..d9cf289f 100644 --- a/src/resolvers/project-status-resolver.ts +++ b/src/resolvers/project-status-resolver.ts @@ -1,6 +1,6 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import { notFoundError } from "../common/errors.js"; -import { isUuid } from "../common/identifier.js"; +import { asUuid, isUuid, type UUID } from "../common/identifier.js"; import { GetProjectStatusesDocument } from "../gql/graphql.js"; /** @@ -23,8 +23,8 @@ import { GetProjectStatusesDocument } from "../gql/graphql.js"; export async function resolveProjectStatusId( client: GraphQLClient, nameOrId: string, -): Promise<string> { - if (isUuid(nameOrId)) return nameOrId; +): Promise<UUID> { + if (isUuid(nameOrId)) return asUuid(nameOrId); const result = await client.request(GetProjectStatusesDocument); const match = result.projectStatuses.nodes.find( @@ -35,5 +35,5 @@ export async function resolveProjectStatusId( throw notFoundError("Project status", nameOrId); } - return match.id; + return asUuid(match.id); } diff --git a/src/resolvers/status-resolver.ts b/src/resolvers/status-resolver.ts index 272f3008..c4369df9 100644 --- a/src/resolvers/status-resolver.ts +++ b/src/resolvers/status-resolver.ts @@ -2,14 +2,14 @@ import type { LinearDocument } from "@linear/sdk"; import type { LinearSdkClient } from "../client/linear-client.js"; import { firstOrThrow } from "../common/array.js"; import { notFoundError } from "../common/errors.js"; -import { isUuid } from "../common/identifier.js"; +import { asUuid, isUuid, type UUID } from "../common/identifier.js"; export async function resolveStatusId( client: LinearSdkClient, nameOrId: string, - teamId?: string, -): Promise<string> { - if (isUuid(nameOrId)) return nameOrId; + teamId?: UUID, +): Promise<UUID> { + if (isUuid(nameOrId)) return asUuid(nameOrId); const filter: LinearDocument.WorkflowStateFilter = { name: { eqIgnoreCase: nameOrId }, @@ -24,11 +24,13 @@ export async function resolveStatusId( first: 1, }); - return firstOrThrow(result.nodes, () => - notFoundError( - "Status", - nameOrId, - teamId ? `for team ${teamId}` : undefined, - ), - ).id; + return asUuid( + firstOrThrow(result.nodes, () => + notFoundError( + "Status", + nameOrId, + teamId ? `for team ${teamId}` : undefined, + ), + ).id, + ); } diff --git a/src/resolvers/team-resolver.ts b/src/resolvers/team-resolver.ts index ba00227f..33e29a73 100644 --- a/src/resolvers/team-resolver.ts +++ b/src/resolvers/team-resolver.ts @@ -1,6 +1,6 @@ import type { LinearSdkClient } from "../client/linear-client.js"; import { notFoundError } from "../common/errors.js"; -import { isUuid } from "../common/identifier.js"; +import { asUuid, isUuid, type UUID } from "../common/identifier.js"; type TeamEstimationType = | "notUsed" @@ -10,7 +10,7 @@ type TeamEstimationType = | "tShirt"; export interface TeamEstimateContext { - teamId: string; + teamId: UUID; teamKey: string; teamName: string; issueEstimationType: TeamEstimationType; @@ -85,7 +85,7 @@ function mapTeamNodeToEstimateContext( node: TeamEstimateNode, ): TeamEstimateContext { return { - teamId: node.id, + teamId: asUuid(node.id), teamKey: node.key, teamName: node.name, issueEstimationType: node.issueEstimationType, @@ -137,8 +137,8 @@ export async function resolveTeamEstimateContext( export async function resolveTeamId( client: LinearSdkClient, keyOrNameOrId: string, -): Promise<string> { - if (isUuid(keyOrNameOrId)) return keyOrNameOrId; +): Promise<UUID> { + if (isUuid(keyOrNameOrId)) return asUuid(keyOrNameOrId); // Try by key first const byKey = await client.sdk.teams({ @@ -146,7 +146,7 @@ export async function resolveTeamId( first: 1, }); const [byKeyMatch] = byKey.nodes; - if (byKeyMatch) return byKeyMatch.id; + if (byKeyMatch) return asUuid(byKeyMatch.id); // Fall back to name const byName = await client.sdk.teams({ @@ -154,7 +154,7 @@ export async function resolveTeamId( first: 1, }); const [byNameMatch] = byName.nodes; - if (byNameMatch) return byNameMatch.id; + if (byNameMatch) return asUuid(byNameMatch.id); throw notFoundError("Team", keyOrNameOrId); } diff --git a/src/resolvers/user-resolver.ts b/src/resolvers/user-resolver.ts index 77350150..3725f667 100644 --- a/src/resolvers/user-resolver.ts +++ b/src/resolvers/user-resolver.ts @@ -1,12 +1,12 @@ import type { LinearSdkClient } from "../client/linear-client.js"; import { multipleMatchesError, notFoundError } from "../common/errors.js"; -import { isUuid } from "../common/identifier.js"; +import { asUuid, isUuid, type UUID } from "../common/identifier.js"; export async function resolveUserId( client: LinearSdkClient, nameOrEmailOrId: string, -): Promise<string> { - if (isUuid(nameOrEmailOrId)) return nameOrEmailOrId; +): Promise<UUID> { + if (isUuid(nameOrEmailOrId)) return asUuid(nameOrEmailOrId); // Try by display name first (case-insensitive) const byName = await client.sdk.users({ @@ -15,7 +15,7 @@ export async function resolveUserId( }); const [byNameMatch] = byName.nodes; - if (byName.nodes.length === 1 && byNameMatch) return byNameMatch.id; + if (byName.nodes.length === 1 && byNameMatch) return asUuid(byNameMatch.id); if (byName.nodes.length > 1) { throw multipleMatchesError( @@ -33,7 +33,7 @@ export async function resolveUserId( }); const [byEmailMatch] = byEmail.nodes; - if (byEmailMatch) return byEmailMatch.id; + if (byEmailMatch) return asUuid(byEmailMatch.id); throw notFoundError("User", nameOrEmailOrId); } diff --git a/src/services/attachment-service.ts b/src/services/attachment-service.ts index e5d69e26..435f03cb 100644 --- a/src/services/attachment-service.ts +++ b/src/services/attachment-service.ts @@ -1,4 +1,5 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import type { BrandUuidFields, UUID } from "../common/identifier.js"; import { requireMutationEntity, requireMutationSuccess, @@ -20,9 +21,12 @@ export type CreatedAttachment = AttachmentCreateMutation["attachmentCreate"]["attachment"]; // Service-owned input type (UUIDs pre-resolved by the command). -export type CreateAttachmentInput = Pick< - AttachmentCreateInput, - "issueId" | "title" | "url" | "subtitle" | "commentBody" | "iconUrl" +export type CreateAttachmentInput = BrandUuidFields< + Pick< + AttachmentCreateInput, + "issueId" | "title" | "url" | "subtitle" | "commentBody" | "iconUrl" + >, + "issueId" >; export interface AttachmentFilterOptions { @@ -73,7 +77,7 @@ export async function createAttachment( export async function deleteAttachment( client: GraphQLClient, - id: string, + id: UUID, ): Promise<{ id: string; success: boolean }> { const result = await client.request(AttachmentDeleteDocument, { id }); @@ -87,7 +91,7 @@ export async function deleteAttachment( export async function listAttachments( client: GraphQLClient, - issueId: string, + issueId: UUID, filter?: AttachmentFilter, ): Promise<AttachmentListItem[]> { const result = await client.request(ListAttachmentsDocument, { diff --git a/src/services/comment-service.ts b/src/services/comment-service.ts index 6da212ce..05aba485 100644 --- a/src/services/comment-service.ts +++ b/src/services/comment-service.ts @@ -1,4 +1,5 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import type { BrandUuidFields, UUID } from "../common/identifier.js"; import { requireMutationEntity, requireMutationSuccess, @@ -28,7 +29,7 @@ export type CommentListItem = export async function createComment( client: GraphQLClient, - input: CommentCreateInput, + input: BrandUuidFields<CommentCreateInput, "issueId" | "parentId">, ): Promise<CreatedComment> { const result = await client.request(CreateCommentDocument, { input }); @@ -41,7 +42,7 @@ export async function createComment( export async function updateComment( client: GraphQLClient, - id: string, + id: UUID, input: CommentUpdateInput, ): Promise<UpdatedComment> { const result = await client.request(UpdateCommentDocument, { id, input }); @@ -55,7 +56,7 @@ export async function updateComment( export async function listComments( client: GraphQLClient, - issueId: string, + issueId: UUID, options: PaginationOptions = {}, ): Promise<PaginatedResult<CommentListItem>> { const { limit = 25, after } = options; @@ -81,7 +82,7 @@ export async function listComments( export async function replyToComment( client: GraphQLClient, - input: { parentId: string; body: string }, + input: { parentId: UUID; body: string }, ): Promise<CreatedComment> { const result = await client.request(CreateCommentDocument, { input: { parentId: input.parentId, body: input.body }, @@ -96,7 +97,7 @@ export async function replyToComment( export async function deleteComment( client: GraphQLClient, - id: string, + id: UUID, ): Promise<{ id: string; success: boolean }> { const result = await client.request(DeleteCommentDocument, { id }); diff --git a/src/services/cycle-service.ts b/src/services/cycle-service.ts index 3e66ba51..7b787b02 100644 --- a/src/services/cycle-service.ts +++ b/src/services/cycle-service.ts @@ -1,4 +1,5 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import type { UUID } from "../common/identifier.js"; import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { type CycleFilter, @@ -28,7 +29,7 @@ export interface CycleDetail extends Cycle { export async function listCycles( client: GraphQLClient, - teamId?: string, + teamId?: UUID, activeOnly: boolean = false, options: PaginationOptions = {}, ): Promise<PaginatedResult<Cycle>> { @@ -66,7 +67,7 @@ export async function listCycles( export async function getCycle( client: GraphQLClient, - cycleId: string, + cycleId: UUID, issuesLimit: number = 50, ): Promise<CycleDetail> { const result = await client.request(GetCycleByIdDocument, { diff --git a/src/services/discussion-service.ts b/src/services/discussion-service.ts index 98da8482..ad4c5841 100644 --- a/src/services/discussion-service.ts +++ b/src/services/discussion-service.ts @@ -1,4 +1,5 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import type { UUID } from "../common/identifier.js"; import { requireMutationEntity, requireMutationSuccess, @@ -82,7 +83,7 @@ type DeleteDiscussionReactionResult = Awaited< >; interface DiscussionReactionTargetInput { - commentId: string; + commentId: UUID; target: DiscussionReactionTarget; expectedEntityKind?: DiscussionEntityKind; } @@ -98,7 +99,7 @@ interface DeleteDiscussionReactionByEmojiInput interface DeleteDiscussionReactionByIdInput extends DiscussionReactionTargetInput { - reactionId: string; + reactionId: UUID; } function normalizeDiscussionCommentReactions< @@ -165,7 +166,7 @@ function assertExpectedDiscussionEntityKind( async function assertDiscussionCommentExists( client: GraphQLClient, - id: string, + id: UUID, expectedEntityKind?: DiscussionEntityKind, label: "comment" | "reply" = "comment", ): Promise<DiscussionCommentContext> { @@ -184,7 +185,7 @@ async function assertDiscussionCommentExists( async function assertRootDiscussionThread( client: GraphQLClient, - threadId: string, + threadId: UUID, expectedEntityKind?: DiscussionEntityKind, ): Promise<DiscussionThreadContext> { const result = await client.request(GetDiscussionCommentContextDocument, { @@ -212,7 +213,7 @@ async function assertRootDiscussionThread( async function assertReplyComment( client: GraphQLClient, - commentId: string, + commentId: UUID, expectedEntityKind?: DiscussionEntityKind, ): Promise<DiscussionCommentContext> { const comment = await assertDiscussionCommentExists( @@ -403,7 +404,7 @@ async function listDiscussionReplyCandidatesWithReactions( function filterThreadReplies<T extends DiscussionCommentFieldsFragment>( comments: readonly T[], - threadId: string, + threadId: UUID, ): T[] { const childrenByParentId = new Map<string, T[]>(); @@ -499,7 +500,7 @@ export async function createDiscussionCommentReaction( export async function createIssueDiscussionCommentReaction( client: GraphQLClient, - input: { commentId: string; emoji: string }, + input: { commentId: UUID; emoji: string }, ): Promise<CreateDiscussionReactionResult> { await assertDiscussionCommentExists(client, input.commentId, "issue"); @@ -524,7 +525,7 @@ export async function deleteDiscussionCommentReactionByEmoji( export async function deleteIssueDiscussionCommentReactionByEmoji( client: GraphQLClient, - input: { commentId: string; emoji: string }, + input: { commentId: UUID; emoji: string }, ): Promise<DeleteDiscussionReactionResult> { await assertDiscussionCommentExists(client, input.commentId, "issue"); @@ -550,7 +551,7 @@ export async function deleteDiscussionCommentReactionById( export async function deleteIssueDiscussionCommentReactionById( client: GraphQLClient, - input: { commentId: string; reactionId: string }, + input: { commentId: UUID; reactionId: UUID }, ): Promise<DeleteDiscussionReactionResult> { await assertDiscussionCommentExists(client, input.commentId, "issue"); @@ -563,7 +564,7 @@ export async function deleteIssueDiscussionCommentReactionById( export async function listDiscussionsForIssue( client: GraphQLClient, - issueId: string, + issueId: UUID, options: PaginationOptions = {}, ): Promise<PaginatedResult<DiscussionThread>> { const { limit = DEFAULT_ROOT_LIMIT, after } = options; @@ -585,7 +586,7 @@ export async function listDiscussionsForIssue( export async function listDiscussionsForIssueWithReactions( client: GraphQLClient, - issueId: string, + issueId: UUID, options: PaginationOptions = {}, ): Promise<PaginatedResult<DiscussionThreadWithReactions>> { const { limit = DEFAULT_ROOT_LIMIT, after } = options; @@ -610,7 +611,7 @@ export async function listDiscussionsForIssueWithReactions( export async function listDiscussionsForProject( client: GraphQLClient, - projectId: string, + projectId: UUID, options: PaginationOptions = {}, ): Promise<PaginatedResult<DiscussionThread>> { const { limit = DEFAULT_ROOT_LIMIT, after } = options; @@ -632,7 +633,7 @@ export async function listDiscussionsForProject( export async function listDiscussionsForProjectWithReactions( client: GraphQLClient, - projectId: string, + projectId: UUID, options: PaginationOptions = {}, ): Promise<PaginatedResult<DiscussionThreadWithReactions>> { const { limit = DEFAULT_ROOT_LIMIT, after } = options; @@ -657,7 +658,7 @@ export async function listDiscussionsForProjectWithReactions( export async function listDiscussionsForInitiative( client: GraphQLClient, - initiativeId: string, + initiativeId: UUID, options: PaginationOptions = {}, ): Promise<PaginatedResult<DiscussionThread>> { const { limit = DEFAULT_ROOT_LIMIT, after } = options; @@ -680,7 +681,7 @@ export async function listDiscussionsForInitiative( export async function listDiscussionsForInitiativeWithReactions( client: GraphQLClient, - initiativeId: string, + initiativeId: UUID, options: PaginationOptions = {}, ): Promise<PaginatedResult<DiscussionThreadWithReactions>> { const { limit = DEFAULT_ROOT_LIMIT, after } = options; @@ -706,7 +707,7 @@ export async function listDiscussionsForInitiativeWithReactions( export async function listDiscussionReplies( client: GraphQLClient, - threadId: string, + threadId: UUID, options: PaginationOptions = {}, expectedEntityKind?: DiscussionEntityKind, ): Promise<PaginatedResult<DiscussionCommentFieldsFragment>> { @@ -724,7 +725,7 @@ export async function listDiscussionReplies( export async function listDiscussionRepliesWithReactions( client: GraphQLClient, - threadId: string, + threadId: UUID, options: PaginationOptions = {}, expectedEntityKind?: DiscussionEntityKind, ): Promise<PaginatedResult<DiscussionThreadWithReactions>> { @@ -745,14 +746,14 @@ export async function listDiscussionRepliesWithReactions( export async function startIssueDiscussion( client: GraphQLClient, - input: { issueId: string; body: string }, + input: { issueId: UUID; body: string }, ): Promise<StartDiscussionMutation["commentCreate"]["comment"]> { return startDiscussion(client, { issueId: input.issueId, body: input.body }); } export async function startProjectDiscussion( client: GraphQLClient, - input: { projectId: string; body: string }, + input: { projectId: UUID; body: string }, ): Promise<StartDiscussionMutation["commentCreate"]["comment"]> { return startDiscussion(client, { projectId: input.projectId, @@ -762,7 +763,7 @@ export async function startProjectDiscussion( export async function startInitiativeDiscussion( client: GraphQLClient, - input: { initiativeId: string; body: string }, + input: { initiativeId: UUID; body: string }, ): Promise<StartDiscussionMutation["commentCreate"]["comment"]> { return startDiscussion(client, { initiativeId: input.initiativeId, @@ -772,7 +773,7 @@ export async function startInitiativeDiscussion( export async function replyToDiscussion( client: GraphQLClient, - input: { threadId: string; body: string; entityKind?: DiscussionEntityKind }, + input: { threadId: UUID; body: string; entityKind?: DiscussionEntityKind }, ): Promise<StartDiscussionMutation["commentCreate"]["comment"]> { const thread = await assertRootDiscussionThread( client, @@ -804,7 +805,7 @@ export async function replyToDiscussion( export async function editDiscussionReply( client: GraphQLClient, - id: string, + id: UUID, input: CommentUpdateInput, expectedEntityKind?: DiscussionEntityKind, ): Promise<EditDiscussionReplyMutation["commentUpdate"]["comment"]> { @@ -824,7 +825,7 @@ export async function editDiscussionReply( export async function deleteDiscussionReply( client: GraphQLClient, - id: string, + id: UUID, expectedEntityKind?: DiscussionEntityKind, ): Promise<{ id: string; success: true }> { await assertReplyComment(client, id, expectedEntityKind); @@ -844,7 +845,7 @@ export async function deleteDiscussionReply( export async function editDiscussionComment( client: GraphQLClient, - id: string, + id: UUID, input: CommentUpdateInput, expectedEntityKind?: DiscussionEntityKind, ): Promise<EditDiscussionReplyMutation["commentUpdate"]["comment"]> { @@ -864,7 +865,7 @@ export async function editDiscussionComment( export async function deleteDiscussionComment( client: GraphQLClient, - id: string, + id: UUID, expectedEntityKind?: DiscussionEntityKind, ): Promise<{ id: string; success: true }> { await assertDiscussionCommentExists(client, id, expectedEntityKind); @@ -885,8 +886,8 @@ export async function deleteDiscussionComment( export async function resolveDiscussion( client: GraphQLClient, input: { - threadId: string; - resolvingCommentId?: string; + threadId: UUID; + resolvingCommentId?: UUID; entityKind?: DiscussionEntityKind; }, ): Promise<ResolveDiscussionMutation["commentResolve"]["comment"]> { @@ -906,7 +907,7 @@ export async function resolveDiscussion( export async function unresolveDiscussion( client: GraphQLClient, - threadId: string, + threadId: UUID, expectedEntityKind?: DiscussionEntityKind, ): Promise<UnresolveDiscussionMutation["commentUnresolve"]["comment"]> { await assertRootDiscussionThread(client, threadId, expectedEntityKind); diff --git a/src/services/document-service.ts b/src/services/document-service.ts index 61b0658a..95119e19 100644 --- a/src/services/document-service.ts +++ b/src/services/document-service.ts @@ -1,4 +1,5 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import type { BrandUuidFields, UUID } from "../common/identifier.js"; import { requireMutationEntity, requireMutationSuccess, @@ -28,21 +29,27 @@ export type UpdatedDocument = DocumentUpdateMutation["documentUpdate"]["document"]; // Service-owned input types (UUIDs pre-resolved by the command). -export type CreateDocumentInput = Pick< - DocumentCreateInput, - "title" | "content" | "projectId" | "teamId" | "issueId" | "icon" | "color" +export type CreateDocumentInput = BrandUuidFields< + Pick< + DocumentCreateInput, + "title" | "content" | "projectId" | "teamId" | "issueId" | "icon" | "color" + >, + "projectId" | "teamId" | "issueId" >; -export type UpdateDocumentInput = Pick< - DocumentUpdateInput, - "title" | "content" | "projectId" | "icon" | "color" +export type UpdateDocumentInput = BrandUuidFields< + Pick< + DocumentUpdateInput, + "title" | "content" | "projectId" | "icon" | "color" + >, + "projectId" >; -export function buildProjectDocumentFilter(projectId: string): DocumentFilter { +export function buildProjectDocumentFilter(projectId: UUID): DocumentFilter { return { project: { id: { eq: projectId } } }; } export function buildIssueDocumentFilter( - issueId: string, + issueId: UUID, legacyDocumentSlugIds: string[], ): DocumentFilter { const issueFilter: DocumentFilter = { issue: { id: { eq: issueId } } }; @@ -62,7 +69,7 @@ export function buildIssueDocumentFilter( export async function getDocument( client: GraphQLClient, - id: string, + id: UUID, ): Promise<DocumentDetail> { const result = await client.request(GetDocumentDocument, { id, @@ -93,7 +100,7 @@ export async function createDocument( export async function updateDocument( client: GraphQLClient, - id: string, + id: UUID, input: UpdateDocumentInput, ): Promise<UpdatedDocument> { const gqlInput: DocumentUpdateInput = input; @@ -134,7 +141,7 @@ export async function listDocuments( export async function deleteDocument( client: GraphQLClient, - id: string, + id: UUID, ): Promise<{ id: string; success: boolean }> { const result = await client.request(DocumentDeleteDocument, { id }); diff --git a/src/services/initiative-project-service.ts b/src/services/initiative-project-service.ts index f3492426..d4d01095 100644 --- a/src/services/initiative-project-service.ts +++ b/src/services/initiative-project-service.ts @@ -1,4 +1,5 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import type { UUID } from "../common/identifier.js"; import { requireMutationEntity } from "../common/mutation-payload.js"; import { CreateInitiativeToProjectDocument, @@ -20,7 +21,7 @@ export type DeletedInitiativeProjectLink = { export async function createInitiativeProjectLink( client: GraphQLClient, - input: { initiativeId: string; projectId: string }, + input: { initiativeId: UUID; projectId: UUID }, ): Promise<InitiativeProjectLink> { const result = await client.request(CreateInitiativeToProjectDocument, { input, @@ -35,7 +36,7 @@ export async function createInitiativeProjectLink( export async function deleteInitiativeProjectLink( client: GraphQLClient, - id: string, + id: UUID, ): Promise<DeletedInitiativeProjectLink> { const result = await client.request(DeleteInitiativeToProjectDocument, { id, diff --git a/src/services/initiative-relation-service.ts b/src/services/initiative-relation-service.ts index 02612fb1..254ab5b3 100644 --- a/src/services/initiative-relation-service.ts +++ b/src/services/initiative-relation-service.ts @@ -1,4 +1,5 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import type { UUID } from "../common/identifier.js"; import { requireMutationEntity } from "../common/mutation-payload.js"; import { CreateInitiativeRelationDocument, @@ -20,7 +21,7 @@ export type DeletedInitiativeRelation = { export async function createInitiativeRelation( client: GraphQLClient, - input: { parentId: string; childId: string }, + input: { parentId: UUID; childId: UUID }, ): Promise<InitiativeRelation> { const result = await client.request(CreateInitiativeRelationDocument, { input: { @@ -38,7 +39,7 @@ export async function createInitiativeRelation( export async function deleteInitiativeRelation( client: GraphQLClient, - id: string, + id: UUID, ): Promise<DeletedInitiativeRelation> { const result = await client.request(DeleteInitiativeRelationDocument, { id }); diff --git a/src/services/initiative-service.ts b/src/services/initiative-service.ts index c3364c0b..43afef61 100644 --- a/src/services/initiative-service.ts +++ b/src/services/initiative-service.ts @@ -1,5 +1,6 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import { invalidParameterError } from "../common/errors.js"; +import type { BrandUuidFields, UUID } from "../common/identifier.js"; import { requireMutationEntity } from "../common/mutation-payload.js"; import type { PaginatedResult } from "../common/types.js"; import { @@ -47,25 +48,31 @@ export type DeletedInitiative = { }; // Service-owned input types (UUIDs pre-resolved by the command). -export type CreateInitiativeInput = Pick< - InitiativeCreateInput, - | "name" - | "description" - | "content" - | "ownerId" - | "status" - | "targetDate" - | "sortOrder" +export type CreateInitiativeInput = BrandUuidFields< + Pick< + InitiativeCreateInput, + | "name" + | "description" + | "content" + | "ownerId" + | "status" + | "targetDate" + | "sortOrder" + >, + "ownerId" >; -export type UpdateInitiativeInput = Pick< - InitiativeUpdateInput, - | "name" - | "description" - | "content" - | "ownerId" - | "status" - | "targetDate" - | "sortOrder" +export type UpdateInitiativeInput = BrandUuidFields< + Pick< + InitiativeUpdateInput, + | "name" + | "description" + | "content" + | "ownerId" + | "status" + | "targetDate" + | "sortOrder" + >, + "ownerId" >; export type InitiativeSortBy = @@ -143,9 +150,9 @@ export interface InitiativeFilterInput { status?: InitiativeStatus; health?: string; healthWithAge?: string; - ownerId?: string; - creatorId?: string; - teamId?: string; + ownerId?: UUID; + creatorId?: UUID; + teamId?: UUID; targetAfter?: string; targetBefore?: string; startedAfter?: string; @@ -156,7 +163,7 @@ export interface InitiativeFilterInput { createdBefore?: string; updatedAfter?: string; updatedBefore?: string; - ancestorId?: string; + ancestorId?: UUID; } function applyNullableDateRange( @@ -309,7 +316,7 @@ export async function listInitiatives( export async function getInitiative( client: GraphQLClient, - id: string, + id: UUID, ): Promise<InitiativeDetail> { const result = await client.request(GetInitiativeDocument, { id, @@ -340,7 +347,7 @@ export async function createInitiative( export async function updateInitiative( client: GraphQLClient, - id: string, + id: UUID, input: UpdateInitiativeInput, ): Promise<UpdatedInitiative> { const hasAtLeastOneField = Object.values(input).some( @@ -369,7 +376,7 @@ export async function updateInitiative( export async function archiveInitiative( client: GraphQLClient, - id: string, + id: UUID, ): Promise<ArchivedInitiative> { const result = await client.request(ArchiveInitiativeDocument, { id }); @@ -382,7 +389,7 @@ export async function archiveInitiative( export async function unarchiveInitiative( client: GraphQLClient, - id: string, + id: UUID, ): Promise<UnarchivedInitiative> { const result = await client.request(UnarchiveInitiativeDocument, { id }); @@ -395,7 +402,7 @@ export async function unarchiveInitiative( export async function deleteInitiative( client: GraphQLClient, - id: string, + id: UUID, ): Promise<DeletedInitiative> { const result = await client.request(DeleteInitiativeDocument, { id }); diff --git a/src/services/initiative-update-service.ts b/src/services/initiative-update-service.ts index 943befe6..fd2aa4f4 100644 --- a/src/services/initiative-update-service.ts +++ b/src/services/initiative-update-service.ts @@ -1,5 +1,6 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import { invalidParameterError } from "../common/errors.js"; +import type { BrandUuidFields, UUID } from "../common/identifier.js"; import { requireMutationEntity } from "../common/mutation-payload.js"; import type { PaginatedResult } from "../common/types.js"; import { @@ -40,16 +41,16 @@ export type UnarchivedInitiativeUpdate = NonNullable< >; export interface InitiativeUpdateListOptions { - initiativeId: string; + initiativeId: UUID; limit?: number; after?: string; includeArchived?: boolean; } // Service-owned input types (UUIDs pre-resolved by the command). -export type CreateInitiativeUpdateInput = Pick< - InitiativeUpdateCreateInput, - "initiativeId" | "body" | "health" +export type CreateInitiativeUpdateInput = BrandUuidFields< + Pick<InitiativeUpdateCreateInput, "initiativeId" | "body" | "health">, + "initiativeId" >; export type UpdateInitiativeUpdateInput = Pick< InitiativeUpdateUpdateInput, @@ -93,7 +94,7 @@ export async function listInitiativeUpdates( export async function getInitiativeUpdate( client: GraphQLClient, - id: string, + id: UUID, ): Promise<InitiativeUpdateDetail> { const result = await client.request(GetInitiativeUpdateDocument, { id }); @@ -122,7 +123,7 @@ export async function createInitiativeUpdate( export async function updateInitiativeUpdate( client: GraphQLClient, - id: string, + id: UUID, input: UpdateInitiativeUpdateInput, ): Promise<UpdatedInitiativeUpdate> { const hasAtLeastOneField = Object.values(input).some( @@ -151,7 +152,7 @@ export async function updateInitiativeUpdate( export async function archiveInitiativeUpdate( client: GraphQLClient, - id: string, + id: UUID, ): Promise<ArchivedInitiativeUpdate> { const result = await client.request(ArchiveInitiativeUpdateDocument, { id }); @@ -164,7 +165,7 @@ export async function archiveInitiativeUpdate( export async function unarchiveInitiativeUpdate( client: GraphQLClient, - id: string, + id: UUID, ): Promise<UnarchivedInitiativeUpdate> { const result = await client.request(UnarchiveInitiativeUpdateDocument, { id, diff --git a/src/services/issue-relation-service.ts b/src/services/issue-relation-service.ts index 737a37c0..62fedfef 100644 --- a/src/services/issue-relation-service.ts +++ b/src/services/issue-relation-service.ts @@ -1,5 +1,6 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import { notFoundError } from "../common/errors.js"; +import { asUuid, type UUID } from "../common/identifier.js"; import { requireMutationSuccess } from "../common/mutation-payload.js"; import { CreateIssueRelationDocument, @@ -19,8 +20,8 @@ type IssueRelationsIssue = NonNullable<GetIssueRelationsQuery["issue"]>; export async function createIssueRelation( client: GraphQLClient, input: { - issueId: string; - relatedIssueId: string; + issueId: UUID; + relatedIssueId: UUID; type: IssueRelationType; }, ): Promise<CreatedIssueRelation> { @@ -34,7 +35,7 @@ export async function createIssueRelation( export async function listIssueRelations( client: GraphQLClient, - issueId: string, + issueId: UUID, ): Promise<{ issueId: string; identifier: string; @@ -61,9 +62,9 @@ export async function listIssueRelations( export async function findIssueRelation( client: GraphQLClient, - issueId: string, - relatedIssueId: string, -): Promise<string> { + issueId: UUID, + relatedIssueId: UUID, +): Promise<UUID> { const result = await client.request(GetIssueRelationsDocument, { issueId }); if (!result.issue) { @@ -74,20 +75,20 @@ export async function findIssueRelation( const forwardMatch = result.issue.relations.nodes.find( (r) => r.relatedIssue.id === relatedIssueId, ); - if (forwardMatch) return forwardMatch.id; + if (forwardMatch) return asUuid(forwardMatch.id); // Check inverse relations const inverseMatch = result.issue.inverseRelations.nodes.find( (r) => r.issue.id === relatedIssueId, ); - if (inverseMatch) return inverseMatch.id; + if (inverseMatch) return asUuid(inverseMatch.id); throw notFoundError("Relation", `between ${issueId} and ${relatedIssueId}`); } export async function deleteIssueRelation( client: GraphQLClient, - relationId: string, + relationId: UUID, ): Promise<{ id: string; success: boolean }> { const result = await client.request(DeleteIssueRelationDocument, { id: relationId, diff --git a/src/services/issue-service.ts b/src/services/issue-service.ts index 94a1b37c..11dca0f4 100644 --- a/src/services/issue-service.ts +++ b/src/services/issue-service.ts @@ -1,5 +1,6 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import { firstOrThrow } from "../common/array.js"; +import type { BrandUuidFields, UUID } from "../common/identifier.js"; import { requireMutationEntity } from "../common/mutation-payload.js"; import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { @@ -79,36 +80,55 @@ export type UpdatedIssue = NonNullable< >; // Service-owned input types (UUIDs pre-resolved by the command). -export type CreateIssueInput = Pick< - IssueCreateInput, - | "title" +export type CreateIssueInput = BrandUuidFields< + Pick< + IssueCreateInput, + | "title" + | "teamId" + | "description" + | "assigneeId" + | "priority" + | "estimate" + | "projectId" + | "labelIds" + | "projectMilestoneId" + | "cycleId" + | "stateId" + | "parentId" + | "dueDate" + >, | "teamId" - | "description" | "assigneeId" - | "priority" - | "estimate" | "projectId" | "labelIds" | "projectMilestoneId" | "cycleId" | "stateId" | "parentId" - | "dueDate" >; -export type UpdateIssueInput = Pick< - IssueUpdateInput, - | "title" - | "description" +export type UpdateIssueInput = BrandUuidFields< + Pick< + IssueUpdateInput, + | "title" + | "description" + | "stateId" + | "priority" + | "estimate" + | "assigneeId" + | "projectId" + | "labelIds" + | "parentId" + | "projectMilestoneId" + | "cycleId" + | "dueDate" + >, | "stateId" - | "priority" - | "estimate" | "assigneeId" | "projectId" | "labelIds" | "parentId" | "projectMilestoneId" | "cycleId" - | "dueDate" >; const NON_COMPLETED_ISSUES_FILTER: IssueFilter = { @@ -279,7 +299,7 @@ export async function listIssues( export async function getIssue( client: GraphQLClient, - id: string, + id: UUID, ): Promise<IssueDetail> { const result = await client.request(GetIssueByIdDocument, { id, @@ -292,7 +312,7 @@ export async function getIssue( export async function getIssueWithComments( client: GraphQLClient, - id: string, + id: UUID, ): Promise<IssueDetailWithComments> { const result = await client.request(GetIssueByIdWithCommentsDocument, { id }); if (!result.issue) { @@ -303,7 +323,7 @@ export async function getIssueWithComments( export async function getIssueWithCommentThreads( client: GraphQLClient, - id: string, + id: UUID, ): Promise<IssueDetailWithCommentThreads> { const issue = await getIssueWithComments(client, id); return threadIssueComments(issue); @@ -354,7 +374,7 @@ export async function getIssueByIdentifierWithCommentThreads( export async function getIssueWithReactions( client: GraphQLClient, - id: string, + id: UUID, ): Promise<IssueDetailWithReactions> { const result = await client.request(GetIssueByIdWithReactionsDocument, { id, @@ -384,7 +404,7 @@ export async function getIssueByIdentifierWithReactions( export async function getIssueWithAttachments( client: GraphQLClient, - id: string, + id: UUID, ): Promise<IssueDetailWithAttachments> { const result = await client.request(GetIssueByIdWithAttachmentsDocument, { id, @@ -445,7 +465,7 @@ export async function createIssue( export async function updateIssue( client: GraphQLClient, - id: string, + id: UUID, input: UpdateIssueInput, ): Promise<UpdatedIssue> { const gqlInput: IssueUpdateInput = input; @@ -462,7 +482,7 @@ export async function updateIssue( export async function archiveIssue( client: GraphQLClient, - id: string, + id: UUID, ): Promise<IssueDetail> { const result = await client.request(ArchiveIssueDocument, { id }); @@ -475,7 +495,7 @@ export async function archiveIssue( export async function unarchiveIssue( client: GraphQLClient, - id: string, + id: UUID, ): Promise<IssueDetail> { const result = await client.request(UnarchiveIssueDocument, { id }); @@ -488,7 +508,7 @@ export async function unarchiveIssue( export async function deleteIssue( client: GraphQLClient, - id: string, + id: UUID, ): Promise<{ id: string; success: true }> { const result = await client.request(DeleteIssueDocument, { id, diff --git a/src/services/label-service.ts b/src/services/label-service.ts index da430ec7..b1a5c251 100644 --- a/src/services/label-service.ts +++ b/src/services/label-service.ts @@ -1,4 +1,5 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import type { BrandUuidFields, UUID } from "../common/identifier.js"; import { requireMutationSuccess } from "../common/mutation-payload.js"; import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { @@ -34,9 +35,9 @@ export interface ListLabelOptions extends PaginationOptions { } // Service-owned input types (UUIDs pre-resolved by the command). -export type CreateLabelInput = Pick< - IssueLabelCreateInput, - "name" | "teamId" | "color" | "description" +export type CreateLabelInput = BrandUuidFields< + Pick<IssueLabelCreateInput, "name" | "teamId" | "color" | "description">, + "teamId" >; export type UpdateLabelInput = Pick< IssueLabelUpdateInput, @@ -60,7 +61,7 @@ function mapIssueLabel(label: { export async function getLabel( client: GraphQLClient, - id: string, + id: UUID, ): Promise<Label> { const result = await client.request(GetIssueLabelDocument, { id, @@ -92,7 +93,7 @@ export async function createLabel( export async function updateLabel( client: GraphQLClient, - id: string, + id: UUID, input: UpdateLabelInput, ): Promise<Label> { const gqlInput: IssueLabelUpdateInput = input; @@ -111,7 +112,7 @@ export async function updateLabel( export async function deleteLabel( client: GraphQLClient, - id: string, + id: UUID, ): Promise<DeleteLabelResult> { const result = await client.request(DeleteIssueLabelDocument, { id }); @@ -127,7 +128,7 @@ export async function deleteLabel( } function buildIssueLabelFilter( - teamId?: string, + teamId?: UUID, scope?: LabelScope, ): IssueLabelFilter | undefined { if (scope === "workspace") { @@ -147,7 +148,7 @@ function buildIssueLabelFilter( export async function listLabels( client: GraphQLClient, - teamId?: string, + teamId?: UUID, options: ListLabelOptions = {}, ): Promise<PaginatedResult<Label>> { const { limit = 50, after, scope } = options; diff --git a/src/services/milestone-service.ts b/src/services/milestone-service.ts index dd00b851..dadc6629 100644 --- a/src/services/milestone-service.ts +++ b/src/services/milestone-service.ts @@ -1,4 +1,5 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import type { BrandUuidFields, UUID } from "../common/identifier.js"; import { requireMutationEntity } from "../common/mutation-payload.js"; import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { @@ -28,9 +29,12 @@ export type UpdatedMilestone = NonNullable< >; // Service-owned input types (UUIDs pre-resolved by the command). -export type CreateMilestoneInput = Pick< - ProjectMilestoneCreateInput, - "projectId" | "name" | "description" | "targetDate" +export type CreateMilestoneInput = BrandUuidFields< + Pick< + ProjectMilestoneCreateInput, + "projectId" | "name" | "description" | "targetDate" + >, + "projectId" >; export type UpdateMilestoneInput = Pick< ProjectMilestoneUpdateInput, @@ -39,7 +43,7 @@ export type UpdateMilestoneInput = Pick< export async function listMilestones( client: GraphQLClient, - projectId: string, + projectId: UUID, options: PaginationOptions = {}, ): Promise<PaginatedResult<MilestoneListItem>> { const { limit = 50, after } = options; @@ -60,7 +64,7 @@ export async function listMilestones( export async function getMilestone( client: GraphQLClient, - id: string, + id: UUID, issuesLimit?: number, ): Promise<MilestoneDetail> { const result = await client.request(GetProjectMilestoneByIdDocument, { @@ -93,7 +97,7 @@ export async function createMilestone( export async function updateMilestone( client: GraphQLClient, - id: string, + id: UUID, input: UpdateMilestoneInput, ): Promise<UpdatedMilestone> { const gqlInput: ProjectMilestoneUpdateInput = input; diff --git a/src/services/project-service.ts b/src/services/project-service.ts index 10a46aa7..6c8c8297 100644 --- a/src/services/project-service.ts +++ b/src/services/project-service.ts @@ -1,4 +1,5 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import type { BrandUuidFields, UUID } from "../common/identifier.js"; import { requireMutationEntity, requireMutationSuccess, @@ -43,37 +44,43 @@ export type DeletedProject = { }; // Service-owned input types (UUIDs pre-resolved by the command). -export type CreateProjectInput = Pick< - ProjectCreateInput, - | "name" - | "teamIds" - | "description" - | "content" - | "icon" - | "color" - | "leadId" - | "memberIds" - | "priority" - | "statusId" - | "startDate" - | "targetDate" - | "labelIds" +export type CreateProjectInput = BrandUuidFields< + Pick< + ProjectCreateInput, + | "name" + | "teamIds" + | "description" + | "content" + | "icon" + | "color" + | "leadId" + | "memberIds" + | "priority" + | "statusId" + | "startDate" + | "targetDate" + | "labelIds" + >, + "teamIds" | "leadId" | "memberIds" | "statusId" | "labelIds" >; -export type UpdateProjectInput = Pick< - ProjectUpdateInput, - | "name" - | "description" - | "content" - | "icon" - | "color" - | "leadId" - | "memberIds" - | "priority" - | "statusId" - | "startDate" - | "targetDate" - | "teamIds" - | "labelIds" +export type UpdateProjectInput = BrandUuidFields< + Pick< + ProjectUpdateInput, + | "name" + | "description" + | "content" + | "icon" + | "color" + | "leadId" + | "memberIds" + | "priority" + | "statusId" + | "startDate" + | "targetDate" + | "teamIds" + | "labelIds" + >, + "teamIds" | "leadId" | "memberIds" | "statusId" | "labelIds" >; export interface ProjectListOptions extends PaginationOptions { @@ -111,7 +118,7 @@ export async function listProjects( export async function getProject( client: GraphQLClient, - id: string, + id: UUID, options: ProjectDetailOptions = {}, ): Promise<ProjectDetail> { const milestonesFirst = @@ -151,7 +158,7 @@ export async function createProject( export async function updateProject( client: GraphQLClient, - id: string, + id: UUID, input: UpdateProjectInput, ): Promise<UpdatedProject> { const gqlInput: ProjectUpdateInput = input; @@ -169,7 +176,7 @@ export async function updateProject( export async function archiveProject( client: GraphQLClient, - id: string, + id: UUID, ): Promise<ArchivedProject> { const result = await client.request(ArchiveProjectDocument, { id }); @@ -182,7 +189,7 @@ export async function archiveProject( export async function unarchiveProject( client: GraphQLClient, - id: string, + id: UUID, ): Promise<UnarchivedProject> { const result = await client.request(UnarchiveProjectDocument, { id }); @@ -195,7 +202,7 @@ export async function unarchiveProject( export async function deleteProject( client: GraphQLClient, - id: string, + id: UUID, ): Promise<DeletedProject> { const result = await client.request(DeleteProjectDocument, { id }); diff --git a/src/services/reaction-service.ts b/src/services/reaction-service.ts index e1a38be4..e3313d7a 100644 --- a/src/services/reaction-service.ts +++ b/src/services/reaction-service.ts @@ -1,6 +1,7 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import { firstOrThrow } from "../common/array.js"; import { normalizeReactionEmojiInput } from "../common/emoji.js"; +import type { UUID } from "../common/identifier.js"; import { requireMutationSuccess } from "../common/mutation-payload.js"; import { CreateReactionDocument, @@ -30,7 +31,7 @@ interface NormalizedReactionGroup { interface ReactionLookupInput { kind: "issue" | "comment"; - id: string; + id: UUID; } interface DeleteOwnReactionByEmojiInput extends ReactionLookupInput { @@ -38,7 +39,7 @@ interface DeleteOwnReactionByEmojiInput extends ReactionLookupInput { } interface DeleteOwnReactionByIdInput extends ReactionLookupInput { - reactionId: string; + reactionId: UUID; } function compareNormalizedUsers( @@ -205,7 +206,7 @@ export function normalizeReactions( export async function createReactionForIssue( client: GraphQLClient, input: { - issueId: string; + issueId: UUID; emoji: string; }, ): Promise<CreateReactionMutation["reactionCreate"]["reaction"]> { @@ -219,7 +220,7 @@ export async function createReactionForIssue( export async function createReactionForComment( client: GraphQLClient, input: { - commentId: string; + commentId: UUID; emoji: string; }, ): Promise<CreateReactionMutation["reactionCreate"]["reaction"]> { diff --git a/src/services/team-service.ts b/src/services/team-service.ts index 5e3ed9c8..653281d9 100644 --- a/src/services/team-service.ts +++ b/src/services/team-service.ts @@ -1,4 +1,5 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import type { UUID } from "../common/identifier.js"; import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { GetTeamByIdDocument, @@ -26,7 +27,7 @@ export interface Team { } interface GetTeamInput { - id: string; + id: UUID; } type TeamConfigSource = Pick< diff --git a/tests/unit/commands/issues.test.ts b/tests/unit/commands/issues.test.ts index bd59e353..2367b210 100644 --- a/tests/unit/commands/issues.test.ts +++ b/tests/unit/commands/issues.test.ts @@ -1,6 +1,8 @@ // tests/unit/commands/issues.test.ts + import { Command } from "commander"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { asUuid } from "../../../src/common/identifier.js"; // Mock all external dependencies before importing the module under test vi.mock("../../../src/common/context.js", () => ({ @@ -366,7 +368,7 @@ describe("issues create --estimate", () => { it("passes estimate 0 through to createIssue when team allows zero", async () => { vi.mocked(resolveTeamEstimateContext).mockResolvedValueOnce({ - teamId: "resolved-team-uuid", + teamId: asUuid("resolved-team-uuid"), teamKey: "ENG", teamName: "Engineering", issueEstimationType: "fibonacci", @@ -437,7 +439,7 @@ describe("issues create --estimate", () => { it("rejects create estimate when team estimation disabled", async () => { vi.mocked(resolveTeamEstimateContext).mockResolvedValueOnce({ - teamId: "resolved-team-uuid", + teamId: asUuid("resolved-team-uuid"), teamKey: "ENG", teamName: "Engineering", issueEstimationType: "notUsed", diff --git a/tests/unit/resolvers/initiative-resolver.test.ts b/tests/unit/resolvers/initiative-resolver.test.ts index fe28ed27..ea6156a1 100644 --- a/tests/unit/resolvers/initiative-resolver.test.ts +++ b/tests/unit/resolvers/initiative-resolver.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import type { LinearSdkClient } from "../../../src/client/linear-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { resolveInitiativeId, resolveInitiativeProjectLinkId, @@ -87,8 +88,8 @@ describe("resolveInitiativeId", () => { await expect( resolveInitiativeId(sdk, "Growth", { - teamId: "team-1", - ownerId: "user-1", + teamId: asUuid("team-1"), + ownerId: asUuid("user-1"), }), ).resolves.toBe("init-2"); @@ -128,7 +129,7 @@ describe("resolveInitiativeRelationId", () => { }); await expect( - resolveInitiativeRelationId(gql, "parent-id", "child-id"), + resolveInitiativeRelationId(gql, asUuid("parent-id"), asUuid("child-id")), ).resolves.toBe("rel-1"); }); @@ -143,7 +144,7 @@ describe("resolveInitiativeRelationId", () => { }); await expect( - resolveInitiativeRelationId(gql, "parent-id", "child-id"), + resolveInitiativeRelationId(gql, asUuid("parent-id"), asUuid("child-id")), ).rejects.toThrow( 'Initiative relation "between parent-id and child-id" not found', ); @@ -182,7 +183,7 @@ describe("resolveInitiativeRelationId", () => { ]); await expect( - resolveInitiativeRelationId(gql, "parent-id", "child-id"), + resolveInitiativeRelationId(gql, asUuid("parent-id"), asUuid("child-id")), ).resolves.toBe("rel-2"); expect(gql.request).toHaveBeenNthCalledWith(1, expect.anything(), { @@ -218,7 +219,7 @@ describe("resolveInitiativeRelationId", () => { ]); await expect( - resolveInitiativeRelationId(gql, "parent-id", "child-id"), + resolveInitiativeRelationId(gql, asUuid("parent-id"), asUuid("child-id")), ).rejects.toThrow( 'Initiative relation "between parent-id and child-id" not found', ); @@ -248,7 +249,11 @@ describe("resolveInitiativeProjectLinkId", () => { }); await expect( - resolveInitiativeProjectLinkId(gql, "init-id", "project-id"), + resolveInitiativeProjectLinkId( + gql, + asUuid("init-id"), + asUuid("project-id"), + ), ).resolves.toBe("link-1"); }); @@ -263,7 +268,11 @@ describe("resolveInitiativeProjectLinkId", () => { }); await expect( - resolveInitiativeProjectLinkId(gql, "init-id", "project-id"), + resolveInitiativeProjectLinkId( + gql, + asUuid("init-id"), + asUuid("project-id"), + ), ).rejects.toThrow( 'Initiative project link "between init-id and project-id" not found', ); @@ -302,7 +311,11 @@ describe("resolveInitiativeProjectLinkId", () => { ]); await expect( - resolveInitiativeProjectLinkId(gql, "init-id", "project-id"), + resolveInitiativeProjectLinkId( + gql, + asUuid("init-id"), + asUuid("project-id"), + ), ).resolves.toBe("link-2"); expect(gql.request).toHaveBeenNthCalledWith(1, expect.anything(), { @@ -338,7 +351,11 @@ describe("resolveInitiativeProjectLinkId", () => { ]); await expect( - resolveInitiativeProjectLinkId(gql, "init-id", "project-id"), + resolveInitiativeProjectLinkId( + gql, + asUuid("init-id"), + asUuid("project-id"), + ), ).rejects.toThrow( 'Initiative project link "between init-id and project-id" not found', ); diff --git a/tests/unit/resolvers/status-resolver.test.ts b/tests/unit/resolvers/status-resolver.test.ts index 0d3f193f..63f88839 100644 --- a/tests/unit/resolvers/status-resolver.test.ts +++ b/tests/unit/resolvers/status-resolver.test.ts @@ -1,6 +1,8 @@ // tests/unit/resolvers/status-resolver.test.ts + import { describe, expect, it, vi } from "vitest"; import type { LinearSdkClient } from "../../../src/client/linear-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { resolveStatusId } from "../../../src/resolvers/status-resolver.js"; function mockSdkClient(nodes: Array<{ id: string }>) { @@ -29,7 +31,7 @@ describe("resolveStatusId", () => { it("resolves status by name with team context", async () => { const client = mockSdkClient([{ id: "status-uuid" }]); - await resolveStatusId(client, "In Progress", "team-uuid"); + await resolveStatusId(client, "In Progress", asUuid("team-uuid")); expect(client.sdk.workflowStates).toHaveBeenCalledWith({ filter: { name: { eqIgnoreCase: "In Progress" }, diff --git a/tests/unit/services/attachment-service.test.ts b/tests/unit/services/attachment-service.test.ts index 3956ff96..386af314 100644 --- a/tests/unit/services/attachment-service.test.ts +++ b/tests/unit/services/attachment-service.test.ts @@ -1,6 +1,8 @@ // tests/unit/services/attachment-service.test.ts + import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { createAttachment, deleteAttachment, @@ -26,7 +28,7 @@ describe("createAttachment", () => { }, }); const result = await createAttachment(client, { - issueId: "issue-1", + issueId: asUuid("issue-1"), title: "Test.pdf", url: "https://example.com/test.pdf", }); @@ -39,7 +41,7 @@ describe("createAttachment", () => { }); await expect( createAttachment(client, { - issueId: "issue-1", + issueId: asUuid("issue-1"), title: "Test.pdf", url: "https://example.com/test.pdf", }), @@ -52,13 +54,13 @@ describe("deleteAttachment", () => { const client = mockGqlClient({ attachmentDelete: { success: true, entityId: "att-1" }, }); - const result = await deleteAttachment(client, "att-1"); + const result = await deleteAttachment(client, asUuid("att-1")); expect(result).toEqual({ id: "att-1", success: true }); }); it("throws when delete fails", async () => { const client = mockGqlClient({ attachmentDelete: { success: false } }); - await expect(deleteAttachment(client, "att-1")).rejects.toThrow( + await expect(deleteAttachment(client, asUuid("att-1"))).rejects.toThrow( "Failed to delete attachment", ); }); @@ -76,7 +78,7 @@ describe("listAttachments", () => { }, }, }); - const result = await listAttachments(client, "issue-1"); + const result = await listAttachments(client, asUuid("issue-1")); expect(result).toHaveLength(2); }); @@ -84,13 +86,13 @@ describe("listAttachments", () => { const client = mockGqlClient({ issue: { attachments: { nodes: [] } }, }); - const result = await listAttachments(client, "issue-1"); + const result = await listAttachments(client, asUuid("issue-1")); expect(result).toEqual([]); }); it("throws when issue not found", async () => { const client = mockGqlClient({ issue: null }); - await expect(listAttachments(client, "missing")).rejects.toThrow( + await expect(listAttachments(client, asUuid("missing"))).rejects.toThrow( "not found", ); }); @@ -104,7 +106,7 @@ describe("listAttachments", () => { }, }); const filter = { sourceType: { eq: "github" } }; - const result = await listAttachments(client, "issue-1", filter); + const result = await listAttachments(client, asUuid("issue-1"), filter); expect(result).toHaveLength(1); expect(client.request).toHaveBeenCalledWith( expect.anything(), diff --git a/tests/unit/services/comment-service.test.ts b/tests/unit/services/comment-service.test.ts index 713eeb06..6c09515e 100644 --- a/tests/unit/services/comment-service.test.ts +++ b/tests/unit/services/comment-service.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { createComment, deleteComment, @@ -33,7 +34,7 @@ describe("createComment", () => { }); const result = await createComment(client, { - issueId: "issue-1", + issueId: asUuid("issue-1"), body: "This is a comment", }); @@ -59,7 +60,7 @@ describe("createComment", () => { }); await expect( - createComment(client, { issueId: "issue-1", body: "test" }), + createComment(client, { issueId: asUuid("issue-1"), body: "test" }), ).rejects.toThrow("Failed to create comment"); }); @@ -72,7 +73,7 @@ describe("createComment", () => { }); await expect( - createComment(client, { issueId: "issue-1", body: "test" }), + createComment(client, { issueId: asUuid("issue-1"), body: "test" }), ).rejects.toThrow("Failed to create comment"); }); }); @@ -108,7 +109,7 @@ describe("listComments", () => { }, }); - const result = await listComments(client, "issue-1"); + const result = await listComments(client, asUuid("issue-1")); expect(result.nodes).toHaveLength(2); expect(result.nodes[0]).toEqual({ @@ -141,7 +142,7 @@ describe("listComments", () => { }, }); - const result = await listComments(client, "issue-1"); + const result = await listComments(client, asUuid("issue-1")); expect(result.nodes).toEqual([]); expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: null }); @@ -150,9 +151,9 @@ describe("listComments", () => { it("throws when issue does not exist", async () => { const client = mockGqlClient({ issue: null }); - await expect(listComments(client, "nonexistent-id")).rejects.toThrow( - 'Issue with ID "nonexistent-id" not found', - ); + await expect( + listComments(client, asUuid("nonexistent-id")), + ).rejects.toThrow('Issue with ID "nonexistent-id" not found'); }); it("passes pagination options to request", async () => { @@ -165,7 +166,10 @@ describe("listComments", () => { }, }); - await listComments(client, "issue-1", { limit: 10, after: "cursor-xyz" }); + await listComments(client, asUuid("issue-1"), { + limit: 10, + after: "cursor-xyz", + }); expect(client.request).toHaveBeenCalledWith(expect.anything(), { issueId: "issue-1", @@ -192,7 +196,7 @@ describe("replyToComment", () => { }); const result = await replyToComment(client, { - parentId: "comment-1", + parentId: asUuid("comment-1"), body: "This is a reply", }); @@ -218,7 +222,7 @@ describe("replyToComment", () => { }); await expect( - replyToComment(client, { parentId: "comment-1", body: "reply" }), + replyToComment(client, { parentId: asUuid("comment-1"), body: "reply" }), ).rejects.toThrow("Failed to create reply"); }); }); @@ -239,7 +243,7 @@ describe("updateComment", () => { }, }); - const result = await updateComment(client, "comment-1", { + const result = await updateComment(client, asUuid("comment-1"), { body: "Updated body", }); @@ -266,7 +270,7 @@ describe("updateComment", () => { }); await expect( - updateComment(client, "comment-1", { body: "new" }), + updateComment(client, asUuid("comment-1"), { body: "new" }), ).rejects.toThrow("Failed to update comment"); }); }); @@ -280,7 +284,7 @@ describe("deleteComment", () => { }, }); - const result = await deleteComment(client, "comment-1"); + const result = await deleteComment(client, asUuid("comment-1")); expect(result).toEqual({ id: "comment-1", success: true }); expect(client.request).toHaveBeenCalledWith(expect.anything(), { @@ -296,7 +300,7 @@ describe("deleteComment", () => { }, }); - await expect(deleteComment(client, "comment-1")).rejects.toThrow( + await expect(deleteComment(client, asUuid("comment-1"))).rejects.toThrow( "Failed to delete comment", ); }); diff --git a/tests/unit/services/cycle-service.test.ts b/tests/unit/services/cycle-service.test.ts index 334714ab..4896b173 100644 --- a/tests/unit/services/cycle-service.test.ts +++ b/tests/unit/services/cycle-service.test.ts @@ -1,6 +1,8 @@ // tests/unit/services/cycle-service.test.ts + import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { getCycle, listCycles } from "../../../src/services/cycle-service.js"; function mockGqlClient(response: Record<string, unknown>): GraphQLClient { @@ -88,7 +90,7 @@ describe("listCycles", () => { pageInfo: { hasNextPage: false, endCursor: null }, }, }); - await listCycles(client, "team-1"); + await listCycles(client, asUuid("team-1")); expect(client.request).toHaveBeenCalledWith(expect.anything(), { first: 50, after: undefined, @@ -158,7 +160,7 @@ describe("getCycle", () => { }, }, }); - const result = await getCycle(client, "cyc-1"); + const result = await getCycle(client, asUuid("cyc-1")); expect(result.id).toBe("cyc-1"); expect(result.name).toBe("Sprint 1"); expect(result.issues).toHaveLength(1); @@ -168,6 +170,8 @@ describe("getCycle", () => { it("throws when cycle not found", async () => { const client = mockGqlClient({ cycle: null }); - await expect(getCycle(client, "missing-id")).rejects.toThrow("not found"); + await expect(getCycle(client, asUuid("missing-id"))).rejects.toThrow( + "not found", + ); }); }); diff --git a/tests/unit/services/discussion-service.test.ts b/tests/unit/services/discussion-service.test.ts index 83946d94..8c55301d 100644 --- a/tests/unit/services/discussion-service.test.ts +++ b/tests/unit/services/discussion-service.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { GetDiscussionCommentContextDocument, type ListIssueDiscussionRootsQuery, @@ -110,7 +111,7 @@ describe("discussion comment reactions", () => { await expect( createDiscussionCommentReaction(client, { - commentId: "thread-1", + commentId: asUuid("thread-1"), target: "thread", expectedEntityKind: "issue", emoji: "👍", @@ -140,7 +141,7 @@ describe("discussion comment reactions", () => { await expect( createDiscussionCommentReaction(client, { - commentId: "reply-1", + commentId: asUuid("reply-1"), target: "thread", expectedEntityKind: "issue", emoji: "👍", @@ -165,7 +166,7 @@ describe("discussion comment reactions", () => { await expect( deleteDiscussionCommentReactionByEmoji(client, { - commentId: "reply-1", + commentId: asUuid("reply-1"), target: "reply", expectedEntityKind: "issue", emoji: "👍", @@ -190,10 +191,10 @@ describe("discussion comment reactions", () => { await expect( deleteDiscussionCommentReactionById(client, { - commentId: "reply-1", + commentId: asUuid("reply-1"), target: "reply", expectedEntityKind: "initiative", - reactionId: "reaction-1", + reactionId: asUuid("reaction-1"), }), ).resolves.toEqual({ id: "reaction-1", success: true }); @@ -217,7 +218,7 @@ describe("discussion comment reactions", () => { await expect( createIssueDiscussionCommentReaction(client, { - commentId: "comment-1", + commentId: asUuid("comment-1"), emoji: "👍", }), ).resolves.toEqual({ id: "reaction-1" }); @@ -241,7 +242,7 @@ describe("discussion comment reactions", () => { await expect( createIssueDiscussionCommentReaction(client, { - commentId: "comment-1", + commentId: asUuid("comment-1"), emoji: "👍", }), ).rejects.toThrow( @@ -257,7 +258,7 @@ describe("discussion comment reactions", () => { await expect( deleteIssueDiscussionCommentReactionByEmoji(client, { - commentId: "comment-1", + commentId: asUuid("comment-1"), emoji: "👍", }), ).rejects.toThrow('Discussion comment ID "comment-1" not found'); @@ -278,8 +279,8 @@ describe("discussion comment reactions", () => { await expect( deleteIssueDiscussionCommentReactionById(client, { - commentId: "comment-1", - reactionId: "reaction-1", + commentId: asUuid("comment-1"), + reactionId: asUuid("reaction-1"), }), ).resolves.toEqual({ id: "reaction-1", success: true }); @@ -303,7 +304,7 @@ describe("listDiscussionsForIssue", () => { }, } satisfies ListIssueDiscussionRootsQuery); - const result = await listDiscussionsForIssue(client, "issue-1", { + const result = await listDiscussionsForIssue(client, asUuid("issue-1"), { limit: 2, after: "root-cursor-0", }); @@ -325,7 +326,7 @@ describe("listDiscussionsForIssue", () => { vi.mocked(client.request).mockResolvedValue({ issue: null }); await expect( - listDiscussionsForIssue(client, "issue-missing"), + listDiscussionsForIssue(client, asUuid("issue-missing")), ).rejects.toThrow('Issue with ID "issue-missing" not found'); }); @@ -342,7 +343,7 @@ describe("listDiscussionsForIssue", () => { const result = await listDiscussionsForIssueWithReactions( client, - "issue-1", + asUuid("issue-1"), { limit: 10 }, ); @@ -369,10 +370,14 @@ describe("listDiscussionsForProject", () => { }, }); - const result = await listDiscussionsForProject(client, "project-1", { - limit: 10, - after: "cur-0", - }); + const result = await listDiscussionsForProject( + client, + asUuid("project-1"), + { + limit: 10, + after: "cur-0", + }, + ); expect(result.nodes).toHaveLength(1); expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: null }); @@ -388,7 +393,7 @@ describe("listDiscussionsForProject", () => { vi.mocked(client.request).mockResolvedValue({ project: null }); await expect( - listDiscussionsForProject(client, "project-missing"), + listDiscussionsForProject(client, asUuid("project-missing")), ).rejects.toThrow('Project with ID "project-missing" not found'); }); @@ -405,7 +410,7 @@ describe("listDiscussionsForProject", () => { const result = await listDiscussionsForProjectWithReactions( client, - "project-1", + asUuid("project-1"), { limit: 10 }, ); @@ -431,7 +436,10 @@ describe("listDiscussionsForInitiative", () => { }, }); - const result = await listDiscussionsForInitiative(client, "initiative-1"); + const result = await listDiscussionsForInitiative( + client, + asUuid("initiative-1"), + ); expect(result.nodes).toHaveLength(1); expect(client.request).toHaveBeenCalledWith(expect.anything(), { @@ -447,7 +455,7 @@ describe("listDiscussionsForInitiative", () => { vi.mocked(client.request).mockResolvedValue({ initiative: null }); await expect( - listDiscussionsForInitiative(client, "initiative-missing"), + listDiscussionsForInitiative(client, asUuid("initiative-missing")), ).rejects.toThrow('Initiative with ID "initiative-missing" not found'); }); @@ -463,7 +471,7 @@ describe("listDiscussionsForInitiative", () => { const result = await listDiscussionsForInitiativeWithReactions( client, - "initiative-1", + asUuid("initiative-1"), { limit: 10 }, ); @@ -503,7 +511,7 @@ describe("listDiscussionReplies", () => { }, }); - const result = await listDiscussionReplies(client, "root-1", { + const result = await listDiscussionReplies(client, asUuid("root-1"), { limit: 5, }); @@ -543,7 +551,7 @@ describe("listDiscussionReplies", () => { }, }); - const result = await listDiscussionReplies(client, "root-1", { + const result = await listDiscussionReplies(client, asUuid("root-1"), { limit: 1, after: "reply-1", }); @@ -582,7 +590,9 @@ describe("listDiscussionReplies", () => { }, }); - const result = await listDiscussionReplies(client, "root-1", { limit: 10 }); + const result = await listDiscussionReplies(client, asUuid("root-1"), { + limit: 10, + }); expect(result.nodes.map((node) => node.id)).toEqual([ "z-parent", @@ -595,7 +605,7 @@ describe("listDiscussionReplies", () => { vi.mocked(client.request).mockResolvedValueOnce({ comment: null }); await expect( - listDiscussionReplies(client, "missing-thread"), + listDiscussionReplies(client, asUuid("missing-thread")), ).rejects.toThrow('Discussion thread ID "missing-thread" not found'); }); @@ -605,7 +615,9 @@ describe("listDiscussionReplies", () => { comment: comment("reply-1", "root-1"), }); - await expect(listDiscussionReplies(client, "reply-1")).rejects.toThrow( + await expect( + listDiscussionReplies(client, asUuid("reply-1")), + ).rejects.toThrow( 'Discussion thread ID "reply-1" must reference a root comment', ); }); @@ -630,7 +642,7 @@ describe("listDiscussionReplies", () => { const result = await listDiscussionRepliesWithReactions( client, - "root-1", + asUuid("root-1"), { limit: 10 }, "issue", ); @@ -652,7 +664,10 @@ describe("replyToDiscussion", () => { vi.mocked(client.request).mockResolvedValueOnce({ comment: null }); await expect( - replyToDiscussion(client, { threadId: "missing-thread", body: "nested" }), + replyToDiscussion(client, { + threadId: asUuid("missing-thread"), + body: "nested", + }), ).rejects.toThrow('Discussion thread ID "missing-thread" not found'); }); @@ -668,7 +683,10 @@ describe("replyToDiscussion", () => { }); await expect( - replyToDiscussion(client, { threadId: "reply-2", body: "nested reply" }), + replyToDiscussion(client, { + threadId: asUuid("reply-2"), + body: "nested reply", + }), ).rejects.toThrow( 'Discussion thread ID "reply-2" must reference a root comment', ); @@ -695,7 +713,7 @@ describe("replyToDiscussion", () => { await expect( replyToDiscussion(client, { - threadId: "root-1", + threadId: asUuid("root-1"), body: "nested reply", entityKind: "issue", }), @@ -723,7 +741,7 @@ describe("replyToDiscussion", () => { }); const result = await replyToDiscussion(client, { - threadId: "root-1", + threadId: asUuid("root-1"), body: "hello", }); @@ -754,17 +772,20 @@ describe("discussion mutation flows", () => { }); await expect( - startIssueDiscussion(client, { issueId: "issue-1", body: "issue body" }), + startIssueDiscussion(client, { + issueId: asUuid("issue-1"), + body: "issue body", + }), ).resolves.toMatchObject({ id: "c-issue" }); await expect( startProjectDiscussion(client, { - projectId: "project-1", + projectId: asUuid("project-1"), body: "project body", }), ).resolves.toMatchObject({ id: "c-project" }); await expect( startInitiativeDiscussion(client, { - initiativeId: "initiative-1", + initiativeId: asUuid("initiative-1"), body: "initiative body", }), ).resolves.toMatchObject({ id: "c-initiative" }); @@ -777,7 +798,10 @@ describe("discussion mutation flows", () => { }); await expect( - startIssueDiscussion(client, { issueId: "issue-1", body: "issue body" }), + startIssueDiscussion(client, { + issueId: asUuid("issue-1"), + body: "issue body", + }), ).rejects.toThrow("Failed to start discussion"); }); @@ -797,9 +821,11 @@ describe("discussion mutation flows", () => { }); await expect( - editDiscussionReply(client, "reply-1", { body: "updated" }), + editDiscussionReply(client, asUuid("reply-1"), { body: "updated" }), ).resolves.toMatchObject({ id: "reply-1", body: "updated" }); - await expect(deleteDiscussionReply(client, "reply-1")).resolves.toEqual({ + await expect( + deleteDiscussionReply(client, asUuid("reply-1")), + ).resolves.toEqual({ id: "reply-1", success: true, }); @@ -812,7 +838,7 @@ describe("discussion mutation flows", () => { }); await expect( - editDiscussionReply(client, "root-1", { body: "updated" }), + editDiscussionReply(client, asUuid("root-1"), { body: "updated" }), ).rejects.toThrow( 'Discussion reply ID "root-1" must reference a reply comment', ); @@ -824,7 +850,9 @@ describe("discussion mutation flows", () => { comment: comment("root-1"), }); - await expect(deleteDiscussionReply(client, "root-1")).rejects.toThrow( + await expect( + deleteDiscussionReply(client, asUuid("root-1")), + ).rejects.toThrow( 'Discussion reply ID "root-1" must reference a reply comment', ); }); @@ -845,9 +873,11 @@ describe("discussion mutation flows", () => { }); await expect( - editDiscussionComment(client, "root-1", { body: "updated" }), + editDiscussionComment(client, asUuid("root-1"), { body: "updated" }), ).resolves.toMatchObject({ id: "root-1", body: "updated" }); - await expect(deleteDiscussionComment(client, "root-1")).resolves.toEqual({ + await expect( + deleteDiscussionComment(client, asUuid("root-1")), + ).resolves.toEqual({ id: "root-1", success: true, }); @@ -865,7 +895,12 @@ describe("discussion mutation flows", () => { }); await expect( - editDiscussionReply(client, "reply-1", { body: "updated" }, "issue"), + editDiscussionReply( + client, + asUuid("reply-1"), + { body: "updated" }, + "issue", + ), ).rejects.toThrow( 'Discussion reply ID "reply-1" belongs to project, not issue', ); @@ -887,9 +922,11 @@ describe("discussion mutation flows", () => { }); await expect( - editDiscussionComment(client, "reply-1", { body: "updated" }), + editDiscussionComment(client, asUuid("reply-1"), { body: "updated" }), ).resolves.toMatchObject({ id: "reply-1", body: "updated" }); - await expect(deleteDiscussionComment(client, "reply-1")).resolves.toEqual({ + await expect( + deleteDiscussionComment(client, asUuid("reply-1")), + ).resolves.toEqual({ id: "reply-1", success: true, }); @@ -900,7 +937,7 @@ describe("discussion mutation flows", () => { vi.mocked(client.request).mockResolvedValueOnce({ comment: null }); await expect( - editDiscussionComment(client, "missing", { body: "updated" }), + editDiscussionComment(client, asUuid("missing"), { body: "updated" }), ).rejects.toThrow('Discussion comment ID "missing" not found'); }); @@ -913,7 +950,7 @@ describe("discussion mutation flows", () => { }); await expect( - editDiscussionComment(client, "root-1", { body: "updated" }), + editDiscussionComment(client, asUuid("root-1"), { body: "updated" }), ).rejects.toThrow("Failed to edit discussion comment"); }); @@ -925,9 +962,9 @@ describe("discussion mutation flows", () => { commentDelete: { success: false, entityId: "root-1" }, }); - await expect(deleteDiscussionComment(client, "root-1")).rejects.toThrow( - "Failed to delete discussion comment", - ); + await expect( + deleteDiscussionComment(client, asUuid("root-1")), + ).rejects.toThrow("Failed to delete discussion comment"); }); it("resolves and unresolves root discussion", async () => { @@ -953,11 +990,13 @@ describe("discussion mutation flows", () => { await expect( resolveDiscussion(client, { - threadId: "root-1", - resolvingCommentId: "reply-1", + threadId: asUuid("root-1"), + resolvingCommentId: asUuid("reply-1"), }), ).resolves.toMatchObject({ id: "root-1" }); - await expect(unresolveDiscussion(client, "root-1")).resolves.toMatchObject({ + await expect( + unresolveDiscussion(client, asUuid("root-1")), + ).resolves.toMatchObject({ id: "root-1", }); }); diff --git a/tests/unit/services/document-service.test.ts b/tests/unit/services/document-service.test.ts index 899aeafa..063c83ce 100644 --- a/tests/unit/services/document-service.test.ts +++ b/tests/unit/services/document-service.test.ts @@ -1,6 +1,8 @@ // tests/unit/services/document-service.test.ts + import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { createDocument, deleteDocument, @@ -18,13 +20,15 @@ function mockGqlClient(response: Record<string, unknown>) { describe("getDocument", () => { it("returns document by ID", async () => { const client = mockGqlClient({ document: { id: "doc-1", title: "Test" } }); - const result = await getDocument(client, "doc-1"); + const result = await getDocument(client, asUuid("doc-1")); expect(result.id).toBe("doc-1"); }); it("throws when not found", async () => { const client = mockGqlClient({ document: null }); - await expect(getDocument(client, "missing")).rejects.toThrow("not found"); + await expect(getDocument(client, asUuid("missing"))).rejects.toThrow( + "not found", + ); }); }); @@ -58,7 +62,9 @@ describe("updateDocument", () => { document: { id: "doc-1", title: "Updated" }, }, }); - const result = await updateDocument(client, "doc-1", { title: "Updated" }); + const result = await updateDocument(client, asUuid("doc-1"), { + title: "Updated", + }); expect(result.title).toBe("Updated"); }); @@ -67,7 +73,7 @@ describe("updateDocument", () => { documentUpdate: { success: false }, }); await expect( - updateDocument(client, "doc-1", { title: "Updated" }), + updateDocument(client, asUuid("doc-1"), { title: "Updated" }), ).rejects.toThrow("Failed to update document"); }); }); @@ -135,13 +141,13 @@ describe("deleteDocument", () => { const client = mockGqlClient({ documentDelete: { success: true, entity: { id: "doc-1" } }, }); - const result = await deleteDocument(client, "doc-1"); + const result = await deleteDocument(client, asUuid("doc-1")); expect(result).toEqual({ id: "doc-1", success: true }); }); it("throws when delete fails", async () => { const client = mockGqlClient({ documentDelete: { success: false } }); - await expect(deleteDocument(client, "doc-1")).rejects.toThrow( + await expect(deleteDocument(client, asUuid("doc-1"))).rejects.toThrow( "Failed to delete document", ); }); diff --git a/tests/unit/services/initiative-project-service.test.ts b/tests/unit/services/initiative-project-service.test.ts index 7112a0ce..648046e0 100644 --- a/tests/unit/services/initiative-project-service.test.ts +++ b/tests/unit/services/initiative-project-service.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { CreateInitiativeToProjectDocument, DeleteInitiativeToProjectDocument, @@ -38,8 +39,8 @@ describe("createInitiativeProjectLink", () => { await expect( createInitiativeProjectLink(client, { - initiativeId: "init-1", - projectId: "proj-1", + initiativeId: asUuid("init-1"), + projectId: asUuid("proj-1"), }), ).resolves.toEqual(link); @@ -63,8 +64,8 @@ describe("createInitiativeProjectLink", () => { await expect( createInitiativeProjectLink(client, { - initiativeId: "init-1", - projectId: "proj-1", + initiativeId: asUuid("init-1"), + projectId: asUuid("proj-1"), }), ).rejects.toThrow( 'Failed to create initiative-project link for initiative "init-1" and project "proj-1"', @@ -81,8 +82,8 @@ describe("createInitiativeProjectLink", () => { await expect( createInitiativeProjectLink(client, { - initiativeId: "init-1", - projectId: "proj-1", + initiativeId: asUuid("init-1"), + projectId: asUuid("proj-1"), }), ).rejects.toThrow( 'Failed to create initiative-project link for initiative "init-1" and project "proj-1"', @@ -100,7 +101,7 @@ describe("deleteInitiativeProjectLink", () => { }); await expect( - deleteInitiativeProjectLink(client, "link-1"), + deleteInitiativeProjectLink(client, asUuid("link-1")), ).resolves.toEqual({ id: "link-1", success: true, @@ -119,9 +120,9 @@ describe("deleteInitiativeProjectLink", () => { }, }); - await expect(deleteInitiativeProjectLink(client, "link-1")).rejects.toThrow( - 'Failed to delete initiative-project link "link-1"', - ); + await expect( + deleteInitiativeProjectLink(client, asUuid("link-1")), + ).rejects.toThrow('Failed to delete initiative-project link "link-1"'); }); it("throws when payload is missing", async () => { @@ -132,8 +133,8 @@ describe("deleteInitiativeProjectLink", () => { }, }); - await expect(deleteInitiativeProjectLink(client, "link-1")).rejects.toThrow( - 'Failed to delete initiative-project link "link-1"', - ); + await expect( + deleteInitiativeProjectLink(client, asUuid("link-1")), + ).rejects.toThrow('Failed to delete initiative-project link "link-1"'); }); }); diff --git a/tests/unit/services/initiative-relation-service.test.ts b/tests/unit/services/initiative-relation-service.test.ts index dc167caf..b57a93cf 100644 --- a/tests/unit/services/initiative-relation-service.test.ts +++ b/tests/unit/services/initiative-relation-service.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { CreateInitiativeRelationDocument, DeleteInitiativeRelationDocument, @@ -38,8 +39,8 @@ describe("createInitiativeRelation", () => { await expect( createInitiativeRelation(client, { - parentId: "init-parent", - childId: "init-child", + parentId: asUuid("init-parent"), + childId: asUuid("init-child"), }), ).resolves.toEqual(relation); @@ -63,8 +64,8 @@ describe("createInitiativeRelation", () => { await expect( createInitiativeRelation(client, { - parentId: "init-parent", - childId: "init-child", + parentId: asUuid("init-parent"), + childId: asUuid("init-child"), }), ).rejects.toThrow( 'Failed to create initiative relation from "init-parent" to "init-child"', @@ -81,8 +82,8 @@ describe("createInitiativeRelation", () => { await expect( createInitiativeRelation(client, { - parentId: "init-parent", - childId: "init-child", + parentId: asUuid("init-parent"), + childId: asUuid("init-child"), }), ).rejects.toThrow( 'Failed to create initiative relation from "init-parent" to "init-child"', @@ -99,7 +100,9 @@ describe("deleteInitiativeRelation", () => { }, }); - await expect(deleteInitiativeRelation(client, "rel-1")).resolves.toEqual({ + await expect( + deleteInitiativeRelation(client, asUuid("rel-1")), + ).resolves.toEqual({ id: "rel-1", success: true, }); @@ -117,9 +120,9 @@ describe("deleteInitiativeRelation", () => { }, }); - await expect(deleteInitiativeRelation(client, "rel-1")).rejects.toThrow( - 'Failed to delete initiative relation "rel-1"', - ); + await expect( + deleteInitiativeRelation(client, asUuid("rel-1")), + ).rejects.toThrow('Failed to delete initiative relation "rel-1"'); }); it("throws when payload is missing", async () => { @@ -130,8 +133,8 @@ describe("deleteInitiativeRelation", () => { }, }); - await expect(deleteInitiativeRelation(client, "rel-1")).rejects.toThrow( - 'Failed to delete initiative relation "rel-1"', - ); + await expect( + deleteInitiativeRelation(client, asUuid("rel-1")), + ).rejects.toThrow('Failed to delete initiative relation "rel-1"'); }); }); diff --git a/tests/unit/services/initiative-service.test.ts b/tests/unit/services/initiative-service.test.ts index 59031882..88ff01e8 100644 --- a/tests/unit/services/initiative-service.test.ts +++ b/tests/unit/services/initiative-service.test.ts @@ -1,6 +1,7 @@ import { type DocumentNode, type FragmentDefinitionNode, Kind } from "graphql"; import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { GetInitiativeDocument } from "../../../src/gql/graphql.js"; import { archiveInitiative, @@ -104,7 +105,7 @@ describe("getInitiative", () => { }, }); - await expect(getInitiative(client, "init-1")).resolves.toEqual({ + await expect(getInitiative(client, asUuid("init-1"))).resolves.toEqual({ id: "init-1", name: "Growth", }); @@ -113,7 +114,7 @@ describe("getInitiative", () => { it("throws when initiative is not found", async () => { const client = mockGqlClient({ initiative: null }); - await expect(getInitiative(client, "missing")).rejects.toThrow( + await expect(getInitiative(client, asUuid("missing"))).rejects.toThrow( 'Initiative with ID "missing" not found', ); }); @@ -151,7 +152,9 @@ describe("updateInitiative", () => { it("rejects empty update input", async () => { const client = mockGqlClient({}); - await expect(updateInitiative(client, "init-1", {})).rejects.toThrow( + await expect( + updateInitiative(client, asUuid("init-1"), {}), + ).rejects.toThrow( "Invalid update options: at least one update field must be provided", ); }); @@ -165,7 +168,7 @@ describe("updateInitiative", () => { }); await expect( - updateInitiative(client, "init-1", { name: "Updated" }), + updateInitiative(client, asUuid("init-1"), { name: "Updated" }), ).resolves.toEqual({ id: "init-1", name: "Updated", @@ -178,7 +181,7 @@ describe("updateInitiative", () => { }); await expect( - updateInitiative(client, "init-1", { name: "Updated" }), + updateInitiative(client, asUuid("init-1"), { name: "Updated" }), ).rejects.toThrow('Failed to update initiative "init-1"'); }); }); @@ -192,7 +195,7 @@ describe("archiveInitiative", () => { }, }); - await expect(archiveInitiative(client, "init-1")).resolves.toEqual({ + await expect(archiveInitiative(client, asUuid("init-1"))).resolves.toEqual({ id: "init-1", name: "Growth", }); @@ -203,7 +206,7 @@ describe("archiveInitiative", () => { initiativeArchive: { success: false, entity: null }, }); - await expect(archiveInitiative(client, "init-1")).rejects.toThrow( + await expect(archiveInitiative(client, asUuid("init-1"))).rejects.toThrow( 'Failed to archive initiative "init-1"', ); }); @@ -218,7 +221,9 @@ describe("unarchiveInitiative", () => { }, }); - await expect(unarchiveInitiative(client, "init-1")).resolves.toEqual({ + await expect( + unarchiveInitiative(client, asUuid("init-1")), + ).resolves.toEqual({ id: "init-1", name: "Growth", }); @@ -229,7 +234,7 @@ describe("unarchiveInitiative", () => { initiativeUnarchive: { success: false, entity: null }, }); - await expect(unarchiveInitiative(client, "init-1")).rejects.toThrow( + await expect(unarchiveInitiative(client, asUuid("init-1"))).rejects.toThrow( 'Failed to unarchive initiative "init-1"', ); }); @@ -241,7 +246,7 @@ describe("deleteInitiative", () => { initiativeDelete: { success: true, entityId: "init-1" }, }); - await expect(deleteInitiative(client, "init-1")).resolves.toEqual({ + await expect(deleteInitiative(client, asUuid("init-1"))).resolves.toEqual({ id: "init-1", success: true, }); @@ -252,7 +257,7 @@ describe("deleteInitiative", () => { initiativeDelete: { success: false, entityId: null }, }); - await expect(deleteInitiative(client, "init-1")).rejects.toThrow( + await expect(deleteInitiative(client, asUuid("init-1"))).rejects.toThrow( 'Failed to delete initiative "init-1"', ); }); diff --git a/tests/unit/services/initiative-update-service.test.ts b/tests/unit/services/initiative-update-service.test.ts index 2f21fabb..da0426f5 100644 --- a/tests/unit/services/initiative-update-service.test.ts +++ b/tests/unit/services/initiative-update-service.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { ArchiveInitiativeUpdateDocument, CreateInitiativeUpdateDocument, @@ -40,7 +41,7 @@ describe("listInitiativeUpdates", () => { }); await listInitiativeUpdates(client, { - initiativeId: "init-1", + initiativeId: asUuid("init-1"), limit: 5, after: "cursor-1", includeArchived: true, @@ -62,7 +63,7 @@ describe("listInitiativeUpdates", () => { } as unknown as GraphQLClient; await expect( - listInitiativeUpdates(client, { initiativeId: "init-1" }), + listInitiativeUpdates(client, { initiativeId: asUuid("init-1") }), ).rejects.toThrow(requestError); expect(request).toHaveBeenCalledWith(ListInitiativeUpdatesDocument, { @@ -88,7 +89,9 @@ describe("getInitiativeUpdate", () => { }; const { client, request } = mockGqlClient({ initiativeUpdate: update }); - await expect(getInitiativeUpdate(client, "upd-1")).resolves.toEqual(update); + await expect(getInitiativeUpdate(client, asUuid("upd-1"))).resolves.toEqual( + update, + ); expect(request).toHaveBeenCalledWith(GetInitiativeUpdateDocument, { id: "upd-1", @@ -98,9 +101,9 @@ describe("getInitiativeUpdate", () => { it("throws when update is not found", async () => { const { client } = mockGqlClient({ initiativeUpdate: null }); - await expect(getInitiativeUpdate(client, "upd-missing")).rejects.toThrow( - 'Initiative update with ID "upd-missing" not found', - ); + await expect( + getInitiativeUpdate(client, asUuid("upd-missing")), + ).rejects.toThrow('Initiative update with ID "upd-missing" not found'); }); }); @@ -122,7 +125,7 @@ describe("createInitiativeUpdate", () => { await expect( createInitiativeUpdate(client, { - initiativeId: "init-1", + initiativeId: asUuid("init-1"), body: "Week 1", }), ).resolves.toEqual(update); @@ -142,7 +145,7 @@ describe("createInitiativeUpdate", () => { await expect( createInitiativeUpdate(client, { - initiativeId: "init-1", + initiativeId: asUuid("init-1"), body: "Week 1", }), ).rejects.toThrow("Failed to create initiative update"); @@ -155,7 +158,7 @@ describe("createInitiativeUpdate", () => { await expect( createInitiativeUpdate(client, { - initiativeId: "init-1", + initiativeId: asUuid("init-1"), body: "Week 1", }), ).rejects.toThrow("Failed to create initiative update"); @@ -166,7 +169,9 @@ describe("updateInitiativeUpdate", () => { it("rejects no-op update input", async () => { const { client } = mockGqlClient({}); - await expect(updateInitiativeUpdate(client, "upd-1", {})).rejects.toThrow( + await expect( + updateInitiativeUpdate(client, asUuid("upd-1"), {}), + ).rejects.toThrow( "Invalid update options: at least one update field must be provided", ); }); @@ -187,7 +192,7 @@ describe("updateInitiativeUpdate", () => { }); await expect( - updateInitiativeUpdate(client, "upd-1", { body: "Week 2" }), + updateInitiativeUpdate(client, asUuid("upd-1"), { body: "Week 2" }), ).resolves.toEqual(update); expect(request).toHaveBeenCalledWith(UpdateInitiativeUpdateDocument, { @@ -205,7 +210,7 @@ describe("updateInitiativeUpdate", () => { }); await expect( - updateInitiativeUpdate(client, "upd-1", { body: "Week 2" }), + updateInitiativeUpdate(client, asUuid("upd-1"), { body: "Week 2" }), ).rejects.toThrow('Failed to update initiative update "upd-1"'); }); @@ -215,7 +220,7 @@ describe("updateInitiativeUpdate", () => { }); await expect( - updateInitiativeUpdate(client, "upd-1", { body: "Week 2" }), + updateInitiativeUpdate(client, asUuid("upd-1"), { body: "Week 2" }), ).rejects.toThrow('Failed to update initiative update "upd-1"'); }); }); @@ -236,9 +241,9 @@ describe("archiveInitiativeUpdate", () => { initiativeUpdateArchive: { success: true, entity: archived }, }); - await expect(archiveInitiativeUpdate(client, "upd-1")).resolves.toEqual( - archived, - ); + await expect( + archiveInitiativeUpdate(client, asUuid("upd-1")), + ).resolves.toEqual(archived); expect(request).toHaveBeenCalledWith(ArchiveInitiativeUpdateDocument, { id: "upd-1", @@ -250,9 +255,9 @@ describe("archiveInitiativeUpdate", () => { initiativeUpdateArchive: { success: false, entity: { id: "upd-1" } }, }); - await expect(archiveInitiativeUpdate(client, "upd-1")).rejects.toThrow( - 'Failed to archive initiative update "upd-1"', - ); + await expect( + archiveInitiativeUpdate(client, asUuid("upd-1")), + ).rejects.toThrow('Failed to archive initiative update "upd-1"'); }); it("throws when mutation payload is missing", async () => { @@ -260,9 +265,9 @@ describe("archiveInitiativeUpdate", () => { initiativeUpdateArchive: { success: true, entity: null }, }); - await expect(archiveInitiativeUpdate(client, "upd-1")).rejects.toThrow( - 'Failed to archive initiative update "upd-1"', - ); + await expect( + archiveInitiativeUpdate(client, asUuid("upd-1")), + ).rejects.toThrow('Failed to archive initiative update "upd-1"'); }); }); @@ -282,9 +287,9 @@ describe("unarchiveInitiativeUpdate", () => { initiativeUpdateUnarchive: { success: true, entity: unarchived }, }); - await expect(unarchiveInitiativeUpdate(client, "upd-1")).resolves.toEqual( - unarchived, - ); + await expect( + unarchiveInitiativeUpdate(client, asUuid("upd-1")), + ).resolves.toEqual(unarchived); expect(request).toHaveBeenCalledWith(UnarchiveInitiativeUpdateDocument, { id: "upd-1", @@ -296,9 +301,9 @@ describe("unarchiveInitiativeUpdate", () => { initiativeUpdateUnarchive: { success: false, entity: { id: "upd-1" } }, }); - await expect(unarchiveInitiativeUpdate(client, "upd-1")).rejects.toThrow( - 'Failed to unarchive initiative update "upd-1"', - ); + await expect( + unarchiveInitiativeUpdate(client, asUuid("upd-1")), + ).rejects.toThrow('Failed to unarchive initiative update "upd-1"'); }); it("throws when mutation payload is missing", async () => { @@ -306,8 +311,8 @@ describe("unarchiveInitiativeUpdate", () => { initiativeUpdateUnarchive: { success: true, entity: null }, }); - await expect(unarchiveInitiativeUpdate(client, "upd-1")).rejects.toThrow( - 'Failed to unarchive initiative update "upd-1"', - ); + await expect( + unarchiveInitiativeUpdate(client, asUuid("upd-1")), + ).rejects.toThrow('Failed to unarchive initiative update "upd-1"'); }); }); diff --git a/tests/unit/services/issue-relation-service.test.ts b/tests/unit/services/issue-relation-service.test.ts index e8585e30..b3bb642d 100644 --- a/tests/unit/services/issue-relation-service.test.ts +++ b/tests/unit/services/issue-relation-service.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { createIssueRelation, deleteIssueRelation, @@ -25,8 +26,8 @@ describe("createIssueRelation", () => { }); const result = await createIssueRelation(client, { - issueId: "issue-1", - relatedIssueId: "issue-2", + issueId: asUuid("issue-1"), + relatedIssueId: asUuid("issue-2"), type: "blocks", }); @@ -41,8 +42,8 @@ describe("createIssueRelation", () => { await expect( createIssueRelation(client, { - issueId: "issue-1", - relatedIssueId: "issue-2", + issueId: asUuid("issue-1"), + relatedIssueId: asUuid("issue-2"), type: "blocks", }), ).rejects.toThrow("Failed to create issue relation"); @@ -66,7 +67,11 @@ describe("findIssueRelation", () => { }, }); - const result = await findIssueRelation(client, "source-id", "target-id"); + const result = await findIssueRelation( + client, + asUuid("source-id"), + asUuid("target-id"), + ); expect(result).toBe("rel-1"); }); @@ -86,7 +91,11 @@ describe("findIssueRelation", () => { }, }); - const result = await findIssueRelation(client, "source-id", "target-id"); + const result = await findIssueRelation( + client, + asUuid("source-id"), + asUuid("target-id"), + ); expect(result).toBe("rel-2"); }); @@ -94,7 +103,7 @@ describe("findIssueRelation", () => { const client = mockGqlClient({ issue: null }); await expect( - findIssueRelation(client, "non-existent-id", "target-id"), + findIssueRelation(client, asUuid("non-existent-id"), asUuid("target-id")), ).rejects.toThrow("not found"); }); @@ -107,7 +116,7 @@ describe("findIssueRelation", () => { }); await expect( - findIssueRelation(client, "source-id", "target-id"), + findIssueRelation(client, asUuid("source-id"), asUuid("target-id")), ).rejects.toThrow("not found"); }); }); @@ -139,7 +148,7 @@ describe("listIssueRelations", () => { }, }); - const result = await listIssueRelations(client, "source-id"); + const result = await listIssueRelations(client, asUuid("source-id")); expect(result).toEqual({ issueId: "source-id", @@ -162,7 +171,7 @@ describe("listIssueRelations", () => { it("throws when issue is not found", async () => { const client = mockGqlClient({ issue: null }); - await expect(listIssueRelations(client, "missing")).rejects.toThrow( + await expect(listIssueRelations(client, asUuid("missing"))).rejects.toThrow( "not found", ); }); @@ -174,7 +183,7 @@ describe("deleteIssueRelation", () => { issueRelationDelete: { success: true, entityId: "rel-1" }, }); - const result = await deleteIssueRelation(client, "rel-1"); + const result = await deleteIssueRelation(client, asUuid("rel-1")); expect(result).toEqual({ id: "rel-1", success: true }); }); @@ -183,7 +192,7 @@ describe("deleteIssueRelation", () => { issueRelationDelete: { success: false }, }); - await expect(deleteIssueRelation(client, "rel-1")).rejects.toThrow( + await expect(deleteIssueRelation(client, asUuid("rel-1"))).rejects.toThrow( "Failed to delete issue relation", ); }); diff --git a/tests/unit/services/issue-service.test.ts b/tests/unit/services/issue-service.test.ts index d0ba6f5e..9ef206e5 100644 --- a/tests/unit/services/issue-service.test.ts +++ b/tests/unit/services/issue-service.test.ts @@ -1,6 +1,7 @@ import { type DocumentNode, type FragmentDefinitionNode, Kind } from "graphql"; import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { ArchiveIssueDocument, DeleteIssueDocument, @@ -253,7 +254,7 @@ describe("getIssue", () => { }); const result = await getIssue( client, - "550e8400-e29b-41d4-a716-446655440000", + asUuid("550e8400-e29b-41d4-a716-446655440000"), ); expect(result.id).toBe("550e8400-e29b-41d4-a716-446655440000"); expect(result.comments.nodes).toEqual([{ id: "comment-1", body: "First" }]); @@ -265,7 +266,7 @@ describe("getIssue", () => { it("throws when issue not found by UUID", async () => { const client = mockGqlClient({ issue: null }); await expect( - getIssue(client, "550e8400-e29b-41d4-a716-446655440000"), + getIssue(client, asUuid("550e8400-e29b-41d4-a716-446655440000")), ).rejects.toThrow("not found"); }); }); @@ -322,7 +323,7 @@ describe("getIssueWithComments", () => { }, }, }); - const result = await getIssueWithComments(client, "issue-1"); + const result = await getIssueWithComments(client, asUuid("issue-1")); expect(result.comments.nodes[0]).toEqual({ id: "comment-1", @@ -431,7 +432,7 @@ describe("getIssueWithCommentThreads", () => { }, }); - const result = await getIssueWithCommentThreads(client, "issue-1"); + const result = await getIssueWithCommentThreads(client, asUuid("issue-1")); expect(result.comments.nodes).toHaveLength(2); expect(result.comments.nodes[0]?.id).toBe("comment-1"); @@ -506,7 +507,7 @@ describe("createIssue", () => { }); const result = await createIssue(client, { title: "New", - teamId: "team-uuid", + teamId: asUuid("team-uuid"), estimate: 5, }); expect(result.id).toBe("new-id"); @@ -520,7 +521,7 @@ describe("createIssue", () => { issueCreate: { success: false, issue: null }, }); await expect( - createIssue(client, { title: "Fail", teamId: "team-uuid" }), + createIssue(client, { title: "Fail", teamId: asUuid("team-uuid") }), ).rejects.toThrow("Failed to create issue"); }); }); @@ -538,7 +539,9 @@ describe("updateIssue", () => { }, }, }); - const result = await updateIssue(client, "issue-id", { estimate: 8 }); + const result = await updateIssue(client, asUuid("issue-id"), { + estimate: 8, + }); expect(result.id).toBe("issue-id"); expect(client.request).toHaveBeenCalledWith(expect.anything(), { id: "issue-id", @@ -553,7 +556,9 @@ describe("updateIssue", () => { issue: { id: "issue-id", identifier: "ENG-1", title: "Cleared" }, }, }); - const result = await updateIssue(client, "issue-id", { estimate: null }); + const result = await updateIssue(client, asUuid("issue-id"), { + estimate: null, + }); expect(result.id).toBe("issue-id"); expect(client.request).toHaveBeenCalledWith(expect.anything(), { id: "issue-id", @@ -566,7 +571,7 @@ describe("updateIssue", () => { issueUpdate: { success: false, issue: null }, }); await expect( - updateIssue(client, "issue-id", { title: "Fail" }), + updateIssue(client, asUuid("issue-id"), { title: "Fail" }), ).rejects.toThrow("Failed to update issue"); }); }); @@ -601,7 +606,7 @@ describe("getIssueWithReactions", () => { }, }); - const result = await getIssueWithReactions(client, "issue-1"); + const result = await getIssueWithReactions(client, asUuid("issue-1")); expect(result.reactions).toEqual([ { @@ -629,9 +634,9 @@ describe("getIssueWithReactions", () => { it("throws when issue not found by UUID", async () => { const client = mockGqlClient({ issue: null }); - await expect(getIssueWithReactions(client, "missing")).rejects.toThrow( - 'Issue with ID "missing" not found', - ); + await expect( + getIssueWithReactions(client, asUuid("missing")), + ).rejects.toThrow('Issue with ID "missing" not found'); }); }); @@ -705,7 +710,7 @@ describe("getIssueWithAttachments", () => { }, }, }); - const result = await getIssueWithAttachments(client, "issue-1"); + const result = await getIssueWithAttachments(client, asUuid("issue-1")); expect(result.id).toBe("issue-1"); expect(client.request).toHaveBeenCalledWith( GetIssueByIdWithAttachmentsDocument, @@ -715,9 +720,9 @@ describe("getIssueWithAttachments", () => { it("throws when issue not found", async () => { const client = mockGqlClient({ issue: null }); - await expect(getIssueWithAttachments(client, "missing")).rejects.toThrow( - "not found", - ); + await expect( + getIssueWithAttachments(client, asUuid("missing")), + ).rejects.toThrow("not found"); }); }); @@ -827,7 +832,7 @@ describe("archiveIssue", () => { }, }); - const result = await archiveIssue(client, "issue-1"); + const result = await archiveIssue(client, asUuid("issue-1")); expect(result.id).toBe("issue-1"); expect(client.request).toHaveBeenCalledWith(ArchiveIssueDocument, { @@ -840,7 +845,7 @@ describe("archiveIssue", () => { issueArchive: { success: false, entity: null }, }); - await expect(archiveIssue(client, "issue-1")).rejects.toThrow( + await expect(archiveIssue(client, asUuid("issue-1"))).rejects.toThrow( 'Failed to archive issue "issue-1"', ); }); @@ -855,7 +860,7 @@ describe("unarchiveIssue", () => { }, }); - const result = await unarchiveIssue(client, "issue-1"); + const result = await unarchiveIssue(client, asUuid("issue-1")); expect(result.id).toBe("issue-1"); expect(client.request).toHaveBeenCalledWith(UnarchiveIssueDocument, { @@ -868,7 +873,7 @@ describe("unarchiveIssue", () => { issueUnarchive: { success: false, entity: null }, }); - await expect(unarchiveIssue(client, "issue-1")).rejects.toThrow( + await expect(unarchiveIssue(client, asUuid("issue-1"))).rejects.toThrow( 'Failed to unarchive issue "issue-1"', ); }); @@ -880,7 +885,7 @@ describe("deleteIssue", () => { issueDelete: { success: true, entity: { id: "issue-1" } }, }); - await expect(deleteIssue(client, "issue-1")).resolves.toEqual({ + await expect(deleteIssue(client, asUuid("issue-1"))).resolves.toEqual({ id: "issue-1", success: true, }); @@ -895,7 +900,7 @@ describe("deleteIssue", () => { issueDelete: { success: false, entity: null }, }); - await expect(deleteIssue(client, "issue-1")).rejects.toThrow( + await expect(deleteIssue(client, asUuid("issue-1"))).rejects.toThrow( 'Failed to delete issue "issue-1"', ); }); diff --git a/tests/unit/services/label-service.test.ts b/tests/unit/services/label-service.test.ts index d33e0827..58bb1a99 100644 --- a/tests/unit/services/label-service.test.ts +++ b/tests/unit/services/label-service.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { createLabel, deleteLabel, @@ -26,7 +27,7 @@ describe("getLabel", () => { }, }); - const result = await getLabel(client, "lbl-1"); + const result = await getLabel(client, asUuid("lbl-1")); expect(result).toEqual({ id: "lbl-1", @@ -40,7 +41,7 @@ describe("getLabel", () => { it("throws when label not found", async () => { const client = mockGqlClient({ issueLabel: null }); - await expect(getLabel(client, "lbl-1")).rejects.toThrow( + await expect(getLabel(client, asUuid("lbl-1"))).rejects.toThrow( 'Label with ID "lbl-1" not found', ); }); @@ -62,7 +63,7 @@ describe("createLabel", () => { const result = await createLabel(client, { name: "branch:unmerged", - teamId: "team-1", + teamId: asUuid("team-1"), color: "#B45309", description: "Created from DBL branch workflow", }); @@ -136,7 +137,7 @@ describe("updateLabel", () => { }, }); - const result = await updateLabel(client, "lbl-1", { + const result = await updateLabel(client, asUuid("lbl-1"), { name: "branch:merged", color: "#1D4ED8", description: "Updated from DBL branch workflow", @@ -173,7 +174,7 @@ describe("updateLabel", () => { }); await expect( - updateLabel(client, "lbl-1", { name: "branch:merged" }), + updateLabel(client, asUuid("lbl-1"), { name: "branch:merged" }), ).rejects.toThrow('Failed to update label "lbl-1"'); }); }); @@ -187,7 +188,7 @@ describe("deleteLabel", () => { }, }); - const result = await deleteLabel(client, "lbl-1"); + const result = await deleteLabel(client, asUuid("lbl-1")); expect(client.request).toHaveBeenCalledWith(expect.anything(), { id: "lbl-1", @@ -203,7 +204,7 @@ describe("deleteLabel", () => { }, }); - await expect(deleteLabel(client, "lbl-1")).rejects.toThrow( + await expect(deleteLabel(client, asUuid("lbl-1"))).rejects.toThrow( 'Failed to delete label "lbl-1"', ); }); @@ -290,7 +291,7 @@ describe("listLabels", () => { }, }); - await listLabels(client, "team-1"); + await listLabels(client, asUuid("team-1")); expect(client.request).toHaveBeenCalledWith(expect.anything(), { first: 50, @@ -324,7 +325,7 @@ describe("listLabels", () => { }, }); - await listLabels(client, "team-1", { scope: "team" }); + await listLabels(client, asUuid("team-1"), { scope: "team" }); expect(client.request).toHaveBeenCalledWith(expect.anything(), { first: 50, diff --git a/tests/unit/services/milestone-service.test.ts b/tests/unit/services/milestone-service.test.ts index 6618d6cc..4c50b797 100644 --- a/tests/unit/services/milestone-service.test.ts +++ b/tests/unit/services/milestone-service.test.ts @@ -1,6 +1,8 @@ // tests/unit/services/milestone-service.test.ts + import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { createMilestone, getMilestone, @@ -32,7 +34,7 @@ describe("listMilestones", () => { }, }, }); - const result = await listMilestones(client, "proj-1"); + const result = await listMilestones(client, asUuid("proj-1")); expect(result.nodes).toHaveLength(1); expect(result.nodes[0]).toEqual({ id: "ms-1", @@ -46,7 +48,7 @@ describe("listMilestones", () => { it("returns empty when project is null", async () => { const client = mockGqlClient({ project: null }); - const result = await listMilestones(client, "missing-proj"); + const result = await listMilestones(client, asUuid("missing-proj")); expect(result.nodes).toEqual([]); expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: null }); }); @@ -60,7 +62,7 @@ describe("listMilestones", () => { }, }, }); - await listMilestones(client, "proj-1", { after: "cur1" }); + await listMilestones(client, asUuid("proj-1"), { after: "cur1" }); expect(client.request).toHaveBeenCalledWith(expect.anything(), { projectId: "proj-1", first: 50, @@ -77,7 +79,7 @@ describe("listMilestones", () => { }, }, }); - await listMilestones(client, "proj-1"); + await listMilestones(client, asUuid("proj-1")); expect(client.request).toHaveBeenCalledWith(expect.anything(), { projectId: "proj-1", first: 50, @@ -99,14 +101,14 @@ describe("getMilestone", () => { issues: { nodes: [] }, }, }); - const result = await getMilestone(client, "ms-1"); + const result = await getMilestone(client, asUuid("ms-1")); expect(result.id).toBe("ms-1"); expect(result.name).toBe("v1.0"); }); it("throws when not found", async () => { const client = mockGqlClient({ projectMilestone: null }); - await expect(getMilestone(client, "missing-id")).rejects.toThrow( + await expect(getMilestone(client, asUuid("missing-id"))).rejects.toThrow( "not found", ); }); @@ -127,7 +129,7 @@ describe("createMilestone", () => { }, }); const result = await createMilestone(client, { - projectId: "proj-1", + projectId: asUuid("proj-1"), name: "v2.0", }); expect(result.id).toBe("ms-new"); @@ -148,7 +150,7 @@ describe("createMilestone", () => { }, }); const input = { - projectId: "proj-1", + projectId: asUuid("proj-1"), name: "v2.0", description: "desc", targetDate: "2025-12-01", @@ -165,7 +167,7 @@ describe("createMilestone", () => { }, }); await expect( - createMilestone(client, { projectId: "proj-1", name: "Bad" }), + createMilestone(client, { projectId: asUuid("proj-1"), name: "Bad" }), ).rejects.toThrow("Failed to create milestone"); }); }); @@ -184,7 +186,9 @@ describe("updateMilestone", () => { }, }, }); - const result = await updateMilestone(client, "ms-1", { name: "v1.1" }); + const result = await updateMilestone(client, asUuid("ms-1"), { + name: "v1.1", + }); expect(result.id).toBe("ms-1"); expect(result.name).toBe("v1.1"); }); @@ -203,7 +207,7 @@ describe("updateMilestone", () => { }, }); const input = { name: "v1.1", description: "updated" }; - await updateMilestone(client, "ms-1", input); + await updateMilestone(client, asUuid("ms-1"), input); expect(client.request).toHaveBeenCalledWith(expect.anything(), { id: "ms-1", input, @@ -218,7 +222,7 @@ describe("updateMilestone", () => { }, }); await expect( - updateMilestone(client, "ms-1", { name: "Bad" }), + updateMilestone(client, asUuid("ms-1"), { name: "Bad" }), ).rejects.toThrow("Failed to update milestone"); }); }); diff --git a/tests/unit/services/milestone-service.variables.test.ts b/tests/unit/services/milestone-service.variables.test.ts index 95d58018..fa61f937 100644 --- a/tests/unit/services/milestone-service.variables.test.ts +++ b/tests/unit/services/milestone-service.variables.test.ts @@ -1,6 +1,7 @@ import type { DocumentNode } from "graphql"; import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { createMilestone, updateMilestone, @@ -42,7 +43,7 @@ describe("milestone service variable shapes (issues #223 / #228)", () => { }); await createMilestone(client, { - projectId: "proj-1", + projectId: asUuid("proj-1"), name: "v2.0", description: "Second release", targetDate: "2025-12-01", @@ -61,7 +62,7 @@ describe("milestone service variable shapes (issues #223 / #228)", () => { }, }); - await updateMilestone(client, "ms-1", { + await updateMilestone(client, asUuid("ms-1"), { name: "v1.1", description: "Updated", targetDate: "2026-01-01", diff --git a/tests/unit/services/project-service.test.ts b/tests/unit/services/project-service.test.ts index ae1e5b3a..147befe3 100644 --- a/tests/unit/services/project-service.test.ts +++ b/tests/unit/services/project-service.test.ts @@ -1,7 +1,9 @@ // tests/unit/services/project-service.test.ts + import { type DocumentNode, type FragmentDefinitionNode, Kind } from "graphql"; import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { ArchiveProjectDocument, GetProjectDocument, @@ -278,7 +280,7 @@ describe("getProject", () => { initiatives: { nodes: [{ id: "init-1", name: "Growth" }] }, }, }); - const result = await getProject(client, "proj-1"); + const result = await getProject(client, asUuid("proj-1")); expect(result.id).toBe("proj-1"); expect(result.name).toBe("Project Alpha"); expect(result.status.name).toBe("Started"); @@ -301,7 +303,7 @@ describe("getProject", () => { }, }); - await getProject(client, "proj-1", { + await getProject(client, asUuid("proj-1"), { milestonesFirst: 0, issuesFirst: 0, }); @@ -323,7 +325,7 @@ describe("getProject", () => { }, }); - await getProject(client, "proj-1", { + await getProject(client, asUuid("proj-1"), { milestonesFirst: 5, issuesFirst: 10, }); @@ -339,7 +341,7 @@ describe("getProject", () => { it("throws when project not found", async () => { const client = mockGqlClient({ project: null }); - await expect(getProject(client, "nonexistent")).rejects.toThrow( + await expect(getProject(client, asUuid("nonexistent"))).rejects.toThrow( 'Project with ID "nonexistent" not found', ); }); @@ -384,7 +386,7 @@ describe("createProject", () => { }); const result = await createProject(client, { name: "New Project", - teamIds: ["team-1"], + teamIds: [asUuid("team-1")], }); expect(result.id).toBe("proj-new"); expect(result.name).toBe("New Project"); @@ -395,7 +397,7 @@ describe("createProject", () => { projectCreate: { success: false, project: null }, }); await expect( - createProject(client, { name: "Fail", teamIds: ["team-1"] }), + createProject(client, { name: "Fail", teamIds: [asUuid("team-1")] }), ).rejects.toThrow('Failed to create project "Fail"'); }); }); @@ -437,7 +439,7 @@ describe("updateProject", () => { }, }, }); - const result = await updateProject(client, "proj-1", { + const result = await updateProject(client, asUuid("proj-1"), { name: "Updated Name", }); expect(result.id).toBe("proj-1"); @@ -450,7 +452,7 @@ describe("updateProject", () => { projectUpdate: { success: false, project: null }, }); await expect( - updateProject(client, "proj-1", { name: "Fail" }), + updateProject(client, asUuid("proj-1"), { name: "Fail" }), ).rejects.toThrow('Failed to update project "proj-1"'); }); }); @@ -464,7 +466,7 @@ describe("archiveProject", () => { }, }); - await expect(archiveProject(client, "proj-1")).resolves.toEqual({ + await expect(archiveProject(client, asUuid("proj-1"))).resolves.toEqual({ id: "proj-1", name: "Archived Project", }); @@ -479,7 +481,7 @@ describe("archiveProject", () => { projectArchive: { success: false, entity: null }, }); - await expect(archiveProject(client, "proj-1")).rejects.toThrow( + await expect(archiveProject(client, asUuid("proj-1"))).rejects.toThrow( 'Failed to archive project "proj-1"', ); }); @@ -494,7 +496,7 @@ describe("unarchiveProject", () => { }, }); - await expect(unarchiveProject(client, "proj-1")).resolves.toEqual({ + await expect(unarchiveProject(client, asUuid("proj-1"))).resolves.toEqual({ id: "proj-1", name: "Active Project", }); @@ -509,7 +511,7 @@ describe("unarchiveProject", () => { projectUnarchive: { success: false, entity: null }, }); - await expect(unarchiveProject(client, "proj-1")).rejects.toThrow( + await expect(unarchiveProject(client, asUuid("proj-1"))).rejects.toThrow( 'Failed to unarchive project "proj-1"', ); }); @@ -521,7 +523,7 @@ describe("deleteProject", () => { projectDelete: { success: true, entity: { id: "proj-1" } }, }); - await expect(deleteProject(client, "proj-1")).resolves.toEqual({ + await expect(deleteProject(client, asUuid("proj-1"))).resolves.toEqual({ id: "proj-1", success: true, }); @@ -536,7 +538,7 @@ describe("deleteProject", () => { projectDelete: { success: true, entity: null }, }); - await expect(deleteProject(client, "proj-1")).resolves.toEqual({ + await expect(deleteProject(client, asUuid("proj-1"))).resolves.toEqual({ id: "proj-1", success: true, }); @@ -547,7 +549,7 @@ describe("deleteProject", () => { projectDelete: { success: false, entity: null }, }); - await expect(deleteProject(client, "proj-1")).rejects.toThrow( + await expect(deleteProject(client, asUuid("proj-1"))).rejects.toThrow( 'Failed to delete project "proj-1"', ); }); diff --git a/tests/unit/services/reaction-service.test.ts b/tests/unit/services/reaction-service.test.ts index 3e3243f2..f91b4741 100644 --- a/tests/unit/services/reaction-service.test.ts +++ b/tests/unit/services/reaction-service.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { createReactionForComment, createReactionForIssue, @@ -45,7 +46,10 @@ describe("createReactionForIssue", () => { }); await expect( - createReactionForIssue(client, { issueId: "issue-1", emoji: "👍" }), + createReactionForIssue(client, { + issueId: asUuid("issue-1"), + emoji: "👍", + }), ).resolves.toEqual({ id: "r-2", emoji: "👍", @@ -78,7 +82,10 @@ describe("createReactionForIssue", () => { }); await expect( - createReactionForIssue(client, { issueId: "issue-1", emoji: "👍" }), + createReactionForIssue(client, { + issueId: asUuid("issue-1"), + emoji: "👍", + }), ).rejects.toThrow("Already reacted with emoji 👍"); }); @@ -103,7 +110,10 @@ describe("createReactionForIssue", () => { }); await expect( - createReactionForIssue(client, { issueId: "issue-1", emoji: " 👍 " }), + createReactionForIssue(client, { + issueId: asUuid("issue-1"), + emoji: " 👍 ", + }), ).rejects.toThrow("Already reacted with emoji 👍"); expect(client.request).toHaveBeenCalledTimes(2); }); @@ -118,7 +128,7 @@ describe("createReactionForIssue", () => { await expect( createReactionForIssue(client, { - issueId: "issue-missing", + issueId: asUuid("issue-missing"), emoji: "👍", }), ).rejects.toThrow('Issue with ID "issue-missing" not found'); @@ -159,7 +169,10 @@ describe("createReactionForComment", () => { }); await expect( - createReactionForComment(client, { commentId: "comment-1", emoji: "👍" }), + createReactionForComment(client, { + commentId: asUuid("comment-1"), + emoji: "👍", + }), ).resolves.toEqual({ id: "r-2", emoji: "👍", @@ -178,7 +191,7 @@ describe("createReactionForComment", () => { await expect( createReactionForComment(client, { - commentId: "comment-missing", + commentId: asUuid("comment-missing"), emoji: "👍", }), ).rejects.toThrow('Discussion comment ID "comment-missing" not found'); @@ -213,7 +226,7 @@ describe("deleteOwnReactionByEmoji", () => { await expect( deleteOwnReactionByEmoji(client, { kind: "comment", - id: "comment-1", + id: asUuid("comment-1"), emoji: "👍", }), ).resolves.toEqual({ id: "r-1", success: true }); @@ -246,7 +259,7 @@ describe("deleteOwnReactionByEmoji", () => { await expect( deleteOwnReactionByEmoji(client, { kind: "comment", - id: "comment-1", + id: asUuid("comment-1"), emoji: " 👍 ", }), ).resolves.toEqual({ id: "r-1", success: true }); @@ -276,7 +289,7 @@ describe("deleteOwnReactionByEmoji", () => { await expect( deleteOwnReactionByEmoji(client, { kind: "comment", - id: "comment-1", + id: asUuid("comment-1"), emoji: "👍", }), ).rejects.toThrow("No own reaction found with emoji 👍"); @@ -312,7 +325,7 @@ describe("deleteOwnReactionByEmoji", () => { await expect( deleteOwnReactionByEmoji(client, { kind: "comment", - id: "comment-1", + id: asUuid("comment-1"), emoji: "👍", }), ).rejects.toThrow("Multiple own reactions found with emoji 👍"); @@ -346,8 +359,8 @@ describe("deleteOwnReactionById", () => { await expect( deleteOwnReactionById(client, { kind: "issue", - id: "issue-1", - reactionId: "r-1", + id: asUuid("issue-1"), + reactionId: asUuid("r-1"), }), ).resolves.toEqual({ id: "r-1", success: true }); }); @@ -375,8 +388,8 @@ describe("deleteOwnReactionById", () => { await expect( deleteOwnReactionById(client, { kind: "issue", - id: "issue-1", - reactionId: "missing-reaction", + id: asUuid("issue-1"), + reactionId: asUuid("missing-reaction"), }), ).rejects.toThrow('Reaction "missing-reaction" not found'); }); @@ -404,8 +417,8 @@ describe("deleteOwnReactionById", () => { await expect( deleteOwnReactionById(client, { kind: "issue", - id: "issue-1", - reactionId: "r-1", + id: asUuid("issue-1"), + reactionId: asUuid("r-1"), }), ).rejects.toThrow('Reaction "r-1" is not owned by viewer'); }); diff --git a/tests/unit/services/team-service.test.ts b/tests/unit/services/team-service.test.ts index e6646b82..9c266d25 100644 --- a/tests/unit/services/team-service.test.ts +++ b/tests/unit/services/team-service.test.ts @@ -1,6 +1,8 @@ // tests/unit/services/team-service.test.ts + import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; import { getTeam, listTeams, @@ -129,7 +131,7 @@ describe("getTeam", () => { ]); const result = assertTeamDetailShape( - await getTeam(client, { id: "team-1" }), + await getTeam(client, { id: asUuid("team-1") }), ); expect(assertEstimationSource(result.estimationSource)).toBe("self"); @@ -207,7 +209,7 @@ describe("getTeam", () => { ]); const result = assertTeamDetailShape( - await getTeam(client, { id: "team-child" }), + await getTeam(client, { id: asUuid("team-child") }), ); expect(assertEstimationSource(result.estimationSource)).toBe("parent"); @@ -257,7 +259,7 @@ describe("getTeam", () => { }, ]); - const result = await getTeam(client, { id: "team-2" }); + const result = await getTeam(client, { id: asUuid("team-2") }); expect(result.validEstimates).toEqual([]); expect(result.estimationSource).toBe("self"); }); @@ -296,7 +298,7 @@ describe("getTeam", () => { }, ]); - const result = await getTeam(client, { id: "team-unknown" }); + const result = await getTeam(client, { id: asUuid("team-unknown") }); expect(result.validEstimates).toEqual([]); expect(result.estimationSource).toBe("self"); }); @@ -335,7 +337,7 @@ describe("getTeam", () => { }, ]); - const result = await getTeam(client, { id: "team-3" }); + const result = await getTeam(client, { id: asUuid("team-3") }); expect(result.validEstimates).toEqual([ { value: 1, label: "XS" }, { value: 2, label: "S" }, @@ -384,7 +386,7 @@ describe("getTeam", () => { .mockRejectedValueOnce(new Error("parent lookup failed")), } as unknown as GraphQLClient; - const result = await getTeam(client, { id: "team-child" }); + const result = await getTeam(client, { id: asUuid("team-child") }); expect(result.estimationSource).toBe("self_fallback"); expect(result.validEstimates).toEqual([ From 7d5c1b3489dab1c75f211bd202e6d5f2b30761f4 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:13:04 +0200 Subject: [PATCH 57/79] refactor(client): replace SDK rawRequest with native fetch transport Drop the Linear SDK dependency from GraphQLClient and issue GraphQL requests directly over native fetch, pinning graphql as a direct dependency. Retry, timeout, and error-mapping behavior are preserved. Closes #207 --- package-lock.json | 2 +- package.json | 1 + src/client/graphql-client.ts | 119 +++++++++++--- tests/unit/client/graphql-client.test.ts | 189 ++++++++++++++--------- 4 files changed, 215 insertions(+), 96 deletions(-) diff --git a/package-lock.json b/package-lock.json index 975de203..95433238 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@linear/sdk": "82.1.0", "commander": "14.0.3", + "graphql": "16.12.0", "node-emoji": "2.2.0" }, "bin": { @@ -6724,7 +6725,6 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", "license": "MIT", - "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } diff --git a/package.json b/package.json index 9b61f736..413bf590 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "dependencies": { "@linear/sdk": "82.1.0", "commander": "14.0.3", + "graphql": "16.12.0", "node-emoji": "2.2.0" }, "devDependencies": { diff --git a/src/client/graphql-client.ts b/src/client/graphql-client.ts index f810ac2e..dcc65503 100644 --- a/src/client/graphql-client.ts +++ b/src/client/graphql-client.ts @@ -1,9 +1,11 @@ import type { TypedDocumentNode } from "@graphql-typed-document-node/core"; -import { LinearClient } from "@linear/sdk"; import { print } from "graphql"; import { AuthenticationError, isAuthError } from "../common/errors.js"; import { withRetry } from "../common/retry.js"; +/** Linear's GraphQL API endpoint. */ +const LINEAR_GRAPHQL_ENDPOINT = "https://api.linear.app/graphql"; + /** Default timeout for GraphQL API requests (30 seconds) */ const REQUEST_TIMEOUT_MS = 30_000; @@ -17,13 +19,43 @@ type RequestVariables<TVariables> = ? [variables?: TVariables] : [variables: TVariables]; +interface GraphQLError { + message: string; +} + +interface GraphQLResponseBody { + data?: unknown; + errors?: GraphQLError[]; +} + interface GraphQLErrorResponse { response?: { - errors?: Array<{ message: string }>; + errors?: GraphQLError[]; }; message?: string; } +/** + * Transport-level error carrying an HTTP status and any GraphQL errors. The + * `response` shape mirrors what `withRetry`/`isRetryable` and the `request()` + * catch block expect, so retry and error-mapping behavior stay unchanged after + * dropping the SDK's `rawRequest`. + */ +interface TransportErrorResponse { + status: number; + errors?: GraphQLError[] | undefined; +} + +class GraphQLTransportError extends Error { + readonly response: TransportErrorResponse; + + constructor(message: string, response: TransportErrorResponse) { + super(message); + this.name = "GraphQLTransportError"; + this.response = response; + } +} + export class GraphQLClient { private readonly apiToken: string; @@ -31,18 +63,61 @@ export class GraphQLClient { this.apiToken = apiToken; } - private createRawClient( - signal?: AbortSignal, - ): InstanceType<typeof LinearClient>["client"] { - const linearClient = new LinearClient({ - apiKey: this.apiToken, - ...(signal ? { signal } : {}), + /** + * Perform a single GraphQL request over native `fetch`. Returns the raw + * `data` payload (typed `unknown`, validated by the caller) or throws a + * `GraphQLTransportError` for HTTP failures and GraphQL-level errors. + */ + private async execute( + document: TypedDocumentNode<unknown, Record<string, unknown>>, + variables: Record<string, unknown> | undefined, + signal: AbortSignal, + ): Promise<unknown> { + const response = await fetch(LINEAR_GRAPHQL_ENDPOINT, { + method: "POST", + signal, headers: { + "Content-Type": "application/json", + // Personal API keys are sent as the raw Authorization value (matching + // the SDK, which forwards `apiKey` verbatim). + Authorization: this.apiToken, // Request 1-hour signed URLs for file downloads (see file-service.ts) "public-file-urls-expire-in": "3600", }, + body: JSON.stringify({ query: print(document), variables }), }); - return linearClient.client; + + // Parse defensively: a non-JSON body (e.g. an HTML error page) should not + // mask the underlying HTTP status. + let body: GraphQLResponseBody | undefined; + try { + body = (await response.json()) as GraphQLResponseBody; + } catch { + body = undefined; + } + + const errors = body?.errors; + + if (!response.ok) { + // Surface HTTP failures with their status so `isRetryable` can retry 429 + // and 5xx responses. + const message = + errors?.[0]?.message ?? `Request failed with status ${response.status}`; + throw new GraphQLTransportError(message, { + status: response.status, + errors, + }); + } + + if (errors && errors.length > 0) { + // GraphQL errors are returned with HTTP 200; propagate them the same way. + throw new GraphQLTransportError(errors[0]?.message ?? "", { + status: response.status, + errors, + }); + } + + return body?.data; } async request<TResult, TVariables extends Record<string, unknown>>( @@ -52,25 +127,26 @@ export class GraphQLClient { ...[variables]: RequestVariables<NoInfer<TVariables>> ): Promise<TResult> { try { - const response = await withRetry(async () => { + const data = await withRetry(async () => { const timeoutController = new AbortController(); const timeoutHandle = setTimeout(() => { timeoutController.abort(); }, REQUEST_TIMEOUT_MS); try { - // Constraining `TVariables extends Record<string, unknown>` lets - // `variables` satisfy rawRequest's own variables bound directly, so no - // cast is needed here. `data` stays untyped (`unknown`) and is checked - // and cast to `TResult` below. - return await this.createRawClient( + // `TVariables extends Record<string, unknown>` lets `variables` widen + // to the `execute` bound directly. `data` stays untyped (`unknown`) + // and is checked and cast to `TResult` below. + return await this.execute( + document as TypedDocumentNode<unknown, Record<string, unknown>>, + variables, timeoutController.signal, - ).rawRequest(print(document), variables); + ); } catch (error: unknown) { if ( timeoutController.signal.aborted && error instanceof Error && - error.message.toLowerCase().includes("aborted") + error.message.toLowerCase().includes("abort") ) { throw new Error("Request timed out"); } @@ -79,13 +155,12 @@ export class GraphQLClient { clearTimeout(timeoutHandle); } }); - // rawRequest resolves with `data: unknown | undefined`; guard the absent - // case instead of asserting it away, so a dataless response surfaces as a - // clear error rather than a `TResult`-typed `undefined`. - if (response.data == null) { + // A successful response with no `data` (and no errors) is unexpected; + // guard it instead of returning a `TResult`-typed `undefined`. + if (data == null) { throw new Error("GraphQL response contained no data"); } - return response.data as TResult; + return data as TResult; } catch (error: unknown) { const gqlError = error as GraphQLErrorResponse; const errorMessage = gqlError.response?.errors?.[0]?.message ?? ""; diff --git a/tests/unit/client/graphql-client.test.ts b/tests/unit/client/graphql-client.test.ts index 1912091e..820d69be 100644 --- a/tests/unit/client/graphql-client.test.ts +++ b/tests/unit/client/graphql-client.test.ts @@ -1,5 +1,5 @@ import type { TypedDocumentNode } from "@graphql-typed-document-node/core"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GraphQLClient } from "../../../src/client/graphql-client.js"; import { AuthenticationError } from "../../../src/common/errors.js"; @@ -10,29 +10,24 @@ function fakeDocument<TResult = unknown>(): TypedDocumentNode< TResult, Record<string, never> > { - return { kind: "Document", definitions: [] } as unknown as TypedDocumentNode< - TResult, - Record<string, never> - >; + return { + kind: "Document", + definitions: [], + } as unknown as TypedDocumentNode<TResult, Record<string, never>>; } -// We test the error handling logic by mocking the underlying rawRequest -// The constructor creates a real LinearClient, so we mock at module level -vi.mock("@linear/sdk", () => { - const mockRawRequest = vi.fn(); - const mockConstructorCalls: Array<{ signal?: AbortSignal }> = []; +// Build a minimal `fetch` Response stand-in. Only the fields the transport +// touches (`ok`, `status`, `json`) are populated. +function fakeResponse( + init: { ok: boolean; status: number }, + body: unknown, +): Response { return { - // biome-ignore lint/complexity/useArrowFunction: vitest v4 requires regular function for constructor mocks - LinearClient: vi.fn().mockImplementation(function (options?: { - signal?: AbortSignal; - }) { - mockConstructorCalls.push(options ?? {}); - return { client: { rawRequest: mockRawRequest } }; - }), - __mockRawRequest: mockRawRequest, - __mockConstructorCalls: mockConstructorCalls, - }; -}); + ok: init.ok, + status: init.status, + json: async () => body, + } as unknown as Response; +} describe("GraphQLClient", () => { it("can be constructed with an API token", () => { @@ -41,26 +36,48 @@ describe("GraphQLClient", () => { }); describe("request", () => { - let mockRawRequest: ReturnType<typeof vi.fn>; - let mockConstructorCalls: Array<{ signal?: AbortSignal }>; - - beforeEach(async () => { - const sdk = (await import("@linear/sdk")) as unknown as { - __mockRawRequest: ReturnType<typeof vi.fn>; - __mockConstructorCalls: Array<{ signal?: AbortSignal }>; - }; - mockRawRequest = sdk.__mockRawRequest; - mockConstructorCalls = sdk.__mockConstructorCalls; - mockRawRequest.mockReset(); - mockConstructorCalls.length = 0; + let mockFetch: ReturnType<typeof vi.fn>; + + beforeEach(() => { + mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("sends the expected request to the Linear GraphQL endpoint", async () => { + mockFetch.mockResolvedValueOnce( + fakeResponse({ ok: true, status: 200 }, { data: { ok: true } }), + ); + + const client = new GraphQLClient("test-token"); + const fakeDoc = fakeDocument<{ ok: boolean }>(); + + await client.request(fakeDoc); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0] as [ + string, + RequestInit & { headers: Record<string, string> }, + ]; + expect(url).toBe("https://api.linear.app/graphql"); + expect(options.method).toBe("POST"); + expect(options.headers.Authorization).toBe("test-token"); + expect(options.headers["Content-Type"]).toBe("application/json"); + expect(options.headers["public-file-urls-expire-in"]).toBe("3600"); + const body = JSON.parse(options.body as string); + expect(body).toHaveProperty("query"); }); it("throws AuthenticationError on 'Authentication required' error", async () => { - mockRawRequest.mockRejectedValueOnce({ - response: { - errors: [{ message: "Authentication required" }], - }, - }); + mockFetch.mockResolvedValueOnce( + fakeResponse( + { ok: false, status: 400 }, + { errors: [{ message: "Authentication required" }] }, + ), + ); const client = new GraphQLClient("bad-token"); const fakeDoc = fakeDocument(); @@ -71,11 +88,12 @@ describe("GraphQLClient", () => { }); it("throws AuthenticationError on 'Unauthorized' error message", async () => { - mockRawRequest.mockRejectedValueOnce({ - response: { - errors: [{ message: "Unauthorized" }], - }, - }); + mockFetch.mockResolvedValueOnce( + fakeResponse( + { ok: false, status: 401 }, + { errors: [{ message: "Unauthorized" }] }, + ), + ); const client = new GraphQLClient("bad-token"); const fakeDoc = fakeDocument(); @@ -86,11 +104,12 @@ describe("GraphQLClient", () => { }); it("throws regular Error on non-auth errors", async () => { - mockRawRequest.mockRejectedValueOnce({ - response: { - errors: [{ message: "Entity not found" }], - }, - }); + mockFetch.mockResolvedValueOnce( + fakeResponse( + { ok: false, status: 400 }, + { errors: [{ message: "Entity not found" }] }, + ), + ); const client = new GraphQLClient("good-token"); const fakeDoc = fakeDocument(); @@ -105,8 +124,24 @@ describe("GraphQLClient", () => { } }); + it("throws regular Error on GraphQL errors returned with HTTP 200", async () => { + mockFetch.mockResolvedValueOnce( + fakeResponse( + { ok: true, status: 200 }, + { errors: [{ message: "Entity not found" }] }, + ), + ); + + const client = new GraphQLClient("good-token"); + const fakeDoc = fakeDocument(); + + await expect(client.request(fakeDoc)).rejects.toThrow("Entity not found"); + }); + it("throws when the response contains no data", async () => { - mockRawRequest.mockResolvedValueOnce({ data: undefined }); + mockFetch.mockResolvedValueOnce( + fakeResponse({ ok: true, status: 200 }, { data: undefined }), + ); const client = new GraphQLClient("good-token"); const fakeDoc = fakeDocument(); @@ -119,7 +154,9 @@ describe("GraphQLClient", () => { it("clears timeout timer when request succeeds before timeout", async () => { vi.useFakeTimers(); try { - mockRawRequest.mockResolvedValueOnce({ data: { ok: true } }); + mockFetch.mockResolvedValueOnce( + fakeResponse({ ok: true, status: 200 }, { data: { ok: true } }), + ); const client = new GraphQLClient("good-token"); const fakeDoc = fakeDocument<{ ok: boolean }>(); @@ -136,11 +173,12 @@ describe("GraphQLClient", () => { it("clears timeout timer on non-retryable GraphQL error", async () => { vi.useFakeTimers(); try { - mockRawRequest.mockRejectedValueOnce({ - response: { - errors: [{ message: "Entity not found" }], - }, - }); + mockFetch.mockResolvedValueOnce( + fakeResponse( + { ok: false, status: 400 }, + { errors: [{ message: "Entity not found" }] }, + ), + ); const client = new GraphQLClient("good-token"); const fakeDoc = fakeDocument(); @@ -157,14 +195,17 @@ describe("GraphQLClient", () => { it("aborts in-flight request when timeout elapses", async () => { vi.useFakeTimers(); try { - mockRawRequest.mockImplementation(() => { - const call = mockConstructorCalls.at(-1); - return new Promise((_, reject) => { - call?.signal?.addEventListener("abort", () => { - reject(new Error("aborted-by-signal")); + let capturedSignal: AbortSignal | undefined; + mockFetch.mockImplementation( + (_url: string, options: { signal?: AbortSignal }) => { + capturedSignal = options.signal; + return new Promise((_, reject) => { + options.signal?.addEventListener("abort", () => { + reject(new Error("This operation was aborted")); + }); }); - }); - }); + }, + ); const client = new GraphQLClient("good-token"); const fakeDoc = fakeDocument(); @@ -174,7 +215,7 @@ describe("GraphQLClient", () => { await vi.runAllTimersAsync(); await rejection; - expect(mockConstructorCalls.at(-1)?.signal?.aborted).toBe(true); + expect(capturedSignal?.aborted).toBe(true); expect(vi.getTimerCount()).toBe(0); } finally { vi.useRealTimers(); @@ -182,10 +223,11 @@ describe("GraphQLClient", () => { }); it("retries on 429 and succeeds on next attempt", async () => { - const rateLimitError = { response: { status: 429 } }; - mockRawRequest - .mockRejectedValueOnce(rateLimitError) - .mockResolvedValueOnce({ data: { foo: "bar" } }); + mockFetch + .mockResolvedValueOnce(fakeResponse({ ok: false, status: 429 }, {})) + .mockResolvedValueOnce( + fakeResponse({ ok: true, status: 200 }, { data: { foo: "bar" } }), + ); const client = new GraphQLClient("good-token"); const fakeDoc = fakeDocument(); @@ -197,7 +239,7 @@ describe("GraphQLClient", () => { const result = await promise; expect(result).toEqual({ foo: "bar" }); - expect(mockRawRequest).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledTimes(2); expect(vi.getTimerCount()).toBe(0); } finally { vi.useRealTimers(); @@ -207,10 +249,11 @@ describe("GraphQLClient", () => { it("clears timeout timers across retry attempts", async () => { vi.useFakeTimers(); try { - const rateLimitError = { response: { status: 429 } }; - mockRawRequest - .mockRejectedValueOnce(rateLimitError) - .mockResolvedValueOnce({ data: { foo: "bar" } }); + mockFetch + .mockResolvedValueOnce(fakeResponse({ ok: false, status: 429 }, {})) + .mockResolvedValueOnce( + fakeResponse({ ok: true, status: 200 }, { data: { foo: "bar" } }), + ); const client = new GraphQLClient("good-token"); const fakeDoc = fakeDocument(); @@ -221,7 +264,7 @@ describe("GraphQLClient", () => { await vi.advanceTimersByTimeAsync(500); await expect(promise).resolves.toEqual({ foo: "bar" }); - expect(mockRawRequest).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledTimes(2); expect(vi.getTimerCount()).toBe(0); } finally { vi.useRealTimers(); From 6fc8e64ea83b45ee316307dd14701cb3df696300 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:17:52 +0200 Subject: [PATCH 58/79] fix(retry): retry native fetch transport failures Native fetch (undici) rejects transport failures as `TypeError: fetch failed` with the real code (e.g. ECONNRESET) on `error.cause`, so isRetryable's top-level message check no longer matched them after the SDK-to-fetch switch. Walk the cause chain and recognize the generic `fetch failed` wrapper so transient network errors stay retryable. Refs #207 --- src/common/retry.ts | 38 ++++++++++++++++++++++++--------- tests/unit/common/retry.test.ts | 9 ++++++++ 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/common/retry.ts b/src/common/retry.ts index 6c535023..456f046a 100644 --- a/src/common/retry.ts +++ b/src/common/retry.ts @@ -9,22 +9,40 @@ interface RetryableError { }; } +/** + * Collect the lowercased messages of an error and its `cause` chain. Native + * `fetch` (undici) rejects transport failures as `TypeError: fetch failed` and + * carries the real error (e.g. `ECONNRESET`) on `cause`, so the top-level + * message alone is not enough to classify the failure. + */ +function collectErrorMessages(error: unknown): string { + const messages: string[] = []; + const seen = new Set<unknown>(); + let current: unknown = error; + while (current instanceof Error && !seen.has(current)) { + seen.add(current); + messages.push(current.message); + current = current.cause; + } + return messages.join(" ").toLowerCase(); +} + export function isRetryable(error: unknown): boolean { const err = error as RetryableError; const status = err?.response?.status; if (typeof status === "number") { return status === 429 || (status >= 500 && status < 600); } - // network-level errors (ECONNRESET, ETIMEDOUT, etc.) - if (error instanceof Error) { - const msg = error.message.toLowerCase(); - return ( - msg.includes("timed out") || - msg.includes("econnreset") || - msg.includes("network") - ); - } - return false; + // network-level errors (ECONNRESET, ETIMEDOUT, etc.). `fetch failed` is + // undici's generic wrapper for transport failures with no HTTP status. + const msg = collectErrorMessages(error); + return ( + msg.includes("timed out") || + msg.includes("etimedout") || + msg.includes("econnreset") || + msg.includes("network") || + msg.includes("fetch failed") + ); } export async function withRetry<T>( diff --git a/tests/unit/common/retry.test.ts b/tests/unit/common/retry.test.ts index 88fd5722..e578b8ce 100644 --- a/tests/unit/common/retry.test.ts +++ b/tests/unit/common/retry.test.ts @@ -33,6 +33,15 @@ describe("isRetryable", () => { it("returns false for generic errors", () => { expect(isRetryable(new Error("Entity not found"))).toBe(false); }); + + it("returns true for native fetch transport failures", () => { + expect(isRetryable(new TypeError("fetch failed"))).toBe(true); + }); + + it("returns true when a retryable code is only on the cause chain", () => { + const cause = new Error("read ECONNRESET"); + expect(isRetryable(new TypeError("fetch failed", { cause }))).toBe(true); + }); }); describe("withRetry", () => { From 785093f6e203e2d2a7c0534095122cc571caa45f Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:19:24 +0200 Subject: [PATCH 59/79] test(client): access Authorization header via index signature tsconfig.test.json enables noPropertyAccessFromIndexSignature, so the `Record<string, string>` header must be read with bracket notation. Refs #207 --- tests/unit/client/graphql-client.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/client/graphql-client.test.ts b/tests/unit/client/graphql-client.test.ts index 820d69be..58f23820 100644 --- a/tests/unit/client/graphql-client.test.ts +++ b/tests/unit/client/graphql-client.test.ts @@ -64,7 +64,7 @@ describe("GraphQLClient", () => { ]; expect(url).toBe("https://api.linear.app/graphql"); expect(options.method).toBe("POST"); - expect(options.headers.Authorization).toBe("test-token"); + expect(options.headers["Authorization"]).toBe("test-token"); expect(options.headers["Content-Type"]).toBe("application/json"); expect(options.headers["public-file-urls-expire-in"]).toBe("3600"); const body = JSON.parse(options.body as string); From 2e8fdc6ea0380eb669aaa12e4ef1a878a1053e42 Mon Sep 17 00:00:00 2001 From: semantic-release-bot <semantic-release-bot@martynus.net> Date: Fri, 3 Jul 2026 11:22:17 +0000 Subject: [PATCH 60/79] chore(release): 2026.6.0-next.9 [skip ci] ## [2026.6.0-next.9](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.8...v2026.6.0-next.9) (2026-07-03) ### Bug Fixes * **retry:** retry native fetch transport failures ([6fc8e64](https://github.com/linearis-oss/linearis/commit/6fc8e64ea83b45ee316307dd14701cb3df696300)), closes [#207](https://github.com/linearis-oss/linearis/issues/207) --- 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 22bed3fb..7e75234b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [2026.6.0-next.9](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.8...v2026.6.0-next.9) (2026-07-03) + +### Bug Fixes + +* **retry:** retry native fetch transport failures ([6fc8e64](https://github.com/linearis-oss/linearis/commit/6fc8e64ea83b45ee316307dd14701cb3df696300)), closes [#207](https://github.com/linearis-oss/linearis/issues/207) + ## [2026.6.0-next.8](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.7...v2026.6.0-next.8) (2026-07-02) ### Features diff --git a/package-lock.json b/package-lock.json index 95433238..a489f8f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linearis", - "version": "2026.6.0-next.8", + "version": "2026.6.0-next.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.6.0-next.8", + "version": "2026.6.0-next.9", "license": "MIT", "dependencies": { "@linear/sdk": "82.1.0", diff --git a/package.json b/package.json index 413bf590..84fbbf83 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.6.0-next.8", + "version": "2026.6.0-next.9", "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 7ce12539336b5c357874ad6ddfffd37814d5324d Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:13:36 +0200 Subject: [PATCH 61/79] refactor(graphql): add lean filter-based lookup queries for resolvers Add dynamic filter-based lookup queries and lean fragments for ID resolution across teams, projects, project labels, initiatives, and workflow states, avoiding over-fetching heavy detail fragments and nullable-scope filtering. Part of #208 --- graphql/queries/initiatives.graphql | 15 +++++++++++++++ graphql/queries/issues.graphql | 13 +++++++++++++ graphql/queries/labels.graphql | 12 ++++++++++++ graphql/queries/projects.graphql | 16 ++++++++++++++++ graphql/queries/teams.graphql | 27 +++++++++++++++++++++++++++ 5 files changed, 83 insertions(+) diff --git a/graphql/queries/initiatives.graphql b/graphql/queries/initiatives.graphql index e3095af8..dcd23885 100644 --- a/graphql/queries/initiatives.graphql +++ b/graphql/queries/initiatives.graphql @@ -182,6 +182,21 @@ query FindInitiativesByName($name: String!, $teamId: ID, $ownerId: ID) { } } +# Find initiatives by a dynamic filter for ID resolution +# +# The filter is supplied by the caller (name plus optional team/owner +# scope), preserving the resolver's dynamic filter construction and +# avoiding the nullable-scope behavior of FindInitiativesByName. +# Emits the InitiativeFilter input type. first: 20 matches the resolver's +# candidate limit for ambiguity reporting. +query FindInitiatives($filter: InitiativeFilter, $first: Int = 20) { + initiatives(filter: $filter, first: $first) { + nodes { + ...InitiativeNameLookupFields + } + } +} + query FindInitiativeRelationByPair( $parentId: String! $childId: String! diff --git a/graphql/queries/issues.graphql b/graphql/queries/issues.graphql index 2afbf99e..066a9683 100644 --- a/graphql/queries/issues.graphql +++ b/graphql/queries/issues.graphql @@ -835,6 +835,19 @@ query BatchResolveWorkflowStatesPage($first: Int!, $after: String) { } } +# Find workflow states by a dynamic filter for status ID resolution +# +# The filter is supplied by the caller (name plus optional team scope) +# so the unscoped case omits the team clause entirely rather than +# filtering on a null team id. Emits the WorkflowStateFilter input type. +query FindWorkflowStates($filter: WorkflowStateFilter, $first: Int = 1) { + workflowStates(filter: $filter, first: $first) { + nodes { + id + } + } +} + # Complete issue fragment with attachments fragment CompleteIssueWithAttachmentsFields on Issue { ...CompleteIssueWithDefaultCommentsFields diff --git a/graphql/queries/labels.graphql b/graphql/queries/labels.graphql index e096b757..5ff98e3f 100644 --- a/graphql/queries/labels.graphql +++ b/graphql/queries/labels.graphql @@ -67,3 +67,15 @@ query GetProjectLabels($first: Int = 50, $after: String) { } } } + +# Find a project label by case-insensitive name for ID resolution +# +# Returns the first match; project label names are treated as unique +# for resolution purposes (no ambiguity error). +query FindProjectLabelByName($name: String!) { + projectLabels(filter: { name: { eqIgnoreCase: $name } }, first: 1) { + nodes { + id + } + } +} diff --git a/graphql/queries/projects.graphql b/graphql/queries/projects.graphql index ae57f23f..81889fac 100644 --- a/graphql/queries/projects.graphql +++ b/graphql/queries/projects.graphql @@ -195,3 +195,19 @@ query GetProjectStatuses { } } } + +# Find projects by case-insensitive name for ID resolution +# +# Returns up to two matches so the resolver can throw on ambiguity. +# includeArchived is passed through to preserve archive-aware lookups. +query FindProjectsByName($name: String!, $includeArchived: Boolean) { + projects( + filter: { name: { eqIgnoreCase: $name } } + first: 2 + includeArchived: $includeArchived + ) { + nodes { + id + } + } +} diff --git a/graphql/queries/teams.graphql b/graphql/queries/teams.graphql index f941bd33..cc1241af 100644 --- a/graphql/queries/teams.graphql +++ b/graphql/queries/teams.graphql @@ -78,3 +78,30 @@ query GetTeamById($id: String!) { ...TeamDetailFields } } + +# Lean fields for team ID/estimate resolution +# +# Covers both resolveTeamId (id only) and resolveTeamEstimateContext +# (id, key, name plus the estimation fields) without over-fetching the +# heavy TeamDetailFields fragment. +fragment TeamLookupFields on Team { + id + key + name + issueEstimationType + issueEstimationExtended + issueEstimationAllowZero +} + +# Find teams by an arbitrary filter for resolver lookups +# +# The filter is supplied dynamically so callers can preserve the +# key-first-then-name lookup order (two calls) and the id/key/name +# estimate lookups with a single operation. +query FindTeams($filter: TeamFilter, $first: Int = 1) { + teams(filter: $filter, first: $first) { + nodes { + ...TeamLookupFields + } + } +} From c2185bcc7036d9ed0ecdf088ddcc5591cce3bb11 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:55:23 +0200 Subject: [PATCH 62/79] refactor(resolvers): migrate ID resolvers to GraphQLClient and drop Linear SDK Move every ID resolver off LinearSdkClient onto GraphQLClient and generated GraphQL operations/types, keeping all lookup, disambiguation, and error behavior. Add lean FindUsers/FindIssues/FindIssueLabels lookup queries; reuse the existing Find* lookups for teams, projects, labels, statuses, cycles, and initiatives. Update all command callers (ctx.sdk -> ctx.gql), remove the sdk client from CommandContext, delete LinearSdkClient, and drop the now-unused @linear/sdk dependency. Rewrite resolver tests to mock GraphQLClient.request one layer deep. Closes #209 --- graphql/queries/issues.graphql | 19 ++ graphql/queries/labels.graphql | 13 ++ graphql/queries/users.graphql | 15 ++ package-lock.json | 14 +- package.json | 1 - src/client/linear-client.ts | 9 - src/commands/attachments.ts | 4 +- src/commands/comments.ts | 4 +- src/commands/cycles.ts | 4 +- src/commands/documents.ts | 12 +- src/commands/initiatives/entity.ts | 32 ++-- src/commands/initiatives/projects.ts | 8 +- src/commands/initiatives/relations.ts | 8 +- src/commands/initiatives/updates.ts | 4 +- src/commands/issues.ts | 58 +++--- src/commands/labels.ts | 8 +- src/commands/milestones.ts | 6 +- src/commands/projects.ts | 30 ++-- src/commands/teams.ts | 2 +- src/common/context.ts | 3 - src/common/resolve-filters.ts | 4 +- src/resolvers/cycle-resolver.ts | 72 +++----- src/resolvers/initiative-resolver.ts | 27 +-- src/resolvers/issue-filter-resolver.ts | 20 +-- src/resolvers/issue-resolver.ts | 130 +++----------- src/resolvers/label-resolver.ts | 16 +- src/resolvers/milestone-resolver.ts | 16 +- src/resolvers/project-resolver.ts | 45 ++--- src/resolvers/project-status-resolver.ts | 8 +- src/resolvers/status-resolver.ts | 15 +- src/resolvers/team-resolver.ts | 33 ++-- src/resolvers/user-resolver.ts | 9 +- tests/unit/common/resolve-filters.test.ts | 4 - tests/unit/resolvers/cycle-resolver.test.ts | 102 ++++++++--- .../resolvers/initiative-resolver.test.ts | 37 ++-- .../resolvers/issue-filter-resolver.test.ts | 18 +- tests/unit/resolvers/issue-resolver.test.ts | 167 ++++-------------- tests/unit/resolvers/label-resolver.test.ts | 32 ++-- .../unit/resolvers/milestone-resolver.test.ts | 29 +-- tests/unit/resolvers/project-resolver.test.ts | 45 +++-- tests/unit/resolvers/status-resolver.test.ts | 20 +-- tests/unit/resolvers/team-resolver.test.ts | 82 +++++---- tests/unit/resolvers/user-resolver.test.ts | 30 ++-- 43 files changed, 534 insertions(+), 681 deletions(-) delete mode 100644 src/client/linear-client.ts diff --git a/graphql/queries/issues.graphql b/graphql/queries/issues.graphql index 066a9683..2598015a 100644 --- a/graphql/queries/issues.graphql +++ b/graphql/queries/issues.graphql @@ -848,6 +848,25 @@ query FindWorkflowStates($filter: WorkflowStateFilter, $first: Int = 1) { } } +# Find issues by a dynamic filter for ID resolution +# +# The filter is supplied by the caller so the resolver can preserve both the +# UUID lookup ({ id: { eq } }) and the identifier lookup +# ({ number: { eq }, team: { key: { eq } } }). team { id key } is selected so +# the estimate-context resolver can derive the owning team without a second +# round-trip. +query FindIssues($filter: IssueFilter, $first: Int = 1) { + issues(filter: $filter, first: $first) { + nodes { + id + team { + id + key + } + } + } +} + # Complete issue fragment with attachments fragment CompleteIssueWithAttachmentsFields on Issue { ...CompleteIssueWithDefaultCommentsFields diff --git a/graphql/queries/labels.graphql b/graphql/queries/labels.graphql index 5ff98e3f..98b16d12 100644 --- a/graphql/queries/labels.graphql +++ b/graphql/queries/labels.graphql @@ -79,3 +79,16 @@ query FindProjectLabelByName($name: String!) { } } } + +# Find issue labels by a dynamic filter for ID resolution +# +# The filter is supplied by the caller so the resolver can preserve its +# scope-aware filter construction (workspace/team/team-scoped) rather than +# baking the scope clauses into the query. +query FindIssueLabels($filter: IssueLabelFilter, $first: Int = 1) { + issueLabels(filter: $filter, first: $first) { + nodes { + id + } + } +} diff --git a/graphql/queries/users.graphql b/graphql/queries/users.graphql index 253c6cac..214d5321 100644 --- a/graphql/queries/users.graphql +++ b/graphql/queries/users.graphql @@ -42,3 +42,18 @@ query GetUsers($first: Int = 50, $after: String, $filter: UserFilter) { } } } + +# Find users by a dynamic filter for ID resolution +# +# The filter is supplied by the caller so the resolver can preserve its +# display-name-first (first: 10) then email-fallback (first: 1) lookup order. +# name/email are selected so the resolver can report ambiguous matches. +query FindUsers($filter: UserFilter, $first: Int = 1) { + users(filter: $filter, first: $first) { + nodes { + id + name + email + } + } +} diff --git a/package-lock.json b/package-lock.json index a489f8f5..2b049248 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "2026.6.0-next.9", "license": "MIT", "dependencies": { - "@linear/sdk": "82.1.0", "commander": "14.0.3", "graphql": "16.12.0", "node-emoji": "2.2.0" @@ -2205,6 +2204,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "dev": true, "license": "MIT", "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" @@ -2604,18 +2604,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@linear/sdk": { - "version": "82.1.0", - "resolved": "https://registry.npmjs.org/@linear/sdk/-/sdk-82.1.0.tgz", - "integrity": "sha512-Ok7o+LqXaenx6Um58NQqjQoQanDsCgAIe9yNgpVbqRSh5APz3Ds1kZUz2vWmSNTNATFZm1zDQtEktTMga2X7UQ==", - "license": "MIT", - "dependencies": { - "@graphql-typed-document-node/core": "^3.2.0" - }, - "engines": { - "node": ">=18.x" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", diff --git a/package.json b/package.json index 84fbbf83..2f4d5508 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,6 @@ }, "homepage": "https://github.com/linearis-oss/linearis#readme", "dependencies": { - "@linear/sdk": "82.1.0", "commander": "14.0.3", "graphql": "16.12.0", "node-emoji": "2.2.0" diff --git a/src/client/linear-client.ts b/src/client/linear-client.ts deleted file mode 100644 index 96b936f6..00000000 --- a/src/client/linear-client.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { LinearClient } from "@linear/sdk"; - -export class LinearSdkClient { - readonly sdk: LinearClient; - - constructor(apiToken: string) { - this.sdk = new LinearClient({ apiKey: apiToken }); - } -} diff --git a/src/commands/attachments.ts b/src/commands/attachments.ts index 1ffcb2f7..90003f6d 100644 --- a/src/commands/attachments.ts +++ b/src/commands/attachments.ts @@ -93,7 +93,7 @@ export function setupAttachmentsCommands(program: Command): void { ]; const issueIdentifier = resolveIssueArgument(issue, options.issue); const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issueIdentifier); + const issueId = await resolveIssueId(ctx.gql, issueIdentifier); const filter = buildAttachmentFilter(options); const result = await listAttachments(ctx.gql, issueId, filter); outputSuccess(result); @@ -118,7 +118,7 @@ export function setupAttachmentsCommands(program: Command): void { ]; const issueIdentifier = resolveIssueArgument(issue, options.issue); const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issueIdentifier); + const issueId = await resolveIssueId(ctx.gql, issueIdentifier); const input: CreateAttachmentInput = { issueId, title: options.title, diff --git a/src/commands/comments.ts b/src/commands/comments.ts index cd3355d5..3a233279 100644 --- a/src/commands/comments.ts +++ b/src/commands/comments.ts @@ -98,7 +98,7 @@ export function setupCommentsCommands(program: Command): void { const ctx = createContext(getRootOpts(command)); const limit = parseLimit(options.limit || "25"); - const resolvedIssueId = await resolveIssueId(ctx.sdk, issue); + const resolvedIssueId = await resolveIssueId(ctx.gql, issue); const result = await listDiscussionsForIssue( ctx.gql, resolvedIssueId, @@ -133,7 +133,7 @@ export function setupCommentsCommands(program: Command): void { throw invalidParameterError("--body", "is required"); } - const resolvedIssueId = await resolveIssueId(ctx.sdk, issue); + const resolvedIssueId = await resolveIssueId(ctx.gql, issue); const result = await startIssueDiscussion(ctx.gql, { issueId: resolvedIssueId, body: options.body, diff --git a/src/commands/cycles.ts b/src/commands/cycles.ts index be3569d9..7efbd0f7 100644 --- a/src/commands/cycles.ts +++ b/src/commands/cycles.ts @@ -72,7 +72,7 @@ export function setupCyclesCommands(program: Command): void { // Resolve team filter if provided const teamId = options.team - ? await resolveTeamId(ctx.sdk, options.team) + ? await resolveTeamId(ctx.gql, options.team) : undefined; // Fetch cycles @@ -130,7 +130,7 @@ export function setupCyclesCommands(program: Command): void { ]; const ctx = createContext(getRootOpts(command)); - const cycleId = await resolveCycleId(ctx.sdk, cycle, options.team); + const cycleId = await resolveCycleId(ctx.gql, cycle, options.team); const cycleResult = await getCycle( ctx.gql, diff --git a/src/commands/documents.ts b/src/commands/documents.ts index fb312981..e9392eba 100644 --- a/src/commands/documents.ts +++ b/src/commands/documents.ts @@ -122,12 +122,12 @@ export function setupDocumentsCommands(program: Command): void { let projectId: UUID | undefined; if (options.project) { - projectId = await resolveProjectId(ctx.sdk, options.project); + projectId = await resolveProjectId(ctx.gql, options.project); } let issueId: UUID | undefined; if (options.issue) { - issueId = await resolveIssueId(ctx.sdk, options.issue); + issueId = await resolveIssueId(ctx.gql, options.issue); } let filter: ReturnType<typeof buildIssueDocumentFilter> | undefined; @@ -198,13 +198,13 @@ export function setupDocumentsCommands(program: Command): void { const ctx = createContext(rootOpts); const projectId = options.project - ? await resolveProjectId(ctx.sdk, options.project) + ? await resolveProjectId(ctx.gql, options.project) : undefined; const teamId = options.team - ? await resolveTeamId(ctx.sdk, options.team) + ? await resolveTeamId(ctx.gql, options.team) : undefined; const issueId = issueIdentifier - ? await resolveIssueId(ctx.sdk, issueIdentifier) + ? await resolveIssueId(ctx.gql, issueIdentifier) : undefined; const document = await createDocument(ctx.gql, { @@ -243,7 +243,7 @@ export function setupDocumentsCommands(program: Command): void { if (options.title) input.title = options.title; if (options.content) input.content = options.content; if (options.project) { - input.projectId = await resolveProjectId(ctx.sdk, options.project); + input.projectId = await resolveProjectId(ctx.gql, options.project); } if (options.icon) input.icon = options.icon; if (options.color) input.color = options.color; diff --git a/src/commands/initiatives/entity.ts b/src/commands/initiatives/entity.ts index 0a2cadca..8466ef57 100644 --- a/src/commands/initiatives/entity.ts +++ b/src/commands/initiatives/entity.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import type { LinearSdkClient } from "../../client/linear-client.js"; +import type { GraphQLClient } from "../../client/graphql-client.js"; import { createContext, getRootOpts } from "../../common/context.js"; import { resolveReactionEmojiInput } from "../../common/emoji.js"; import { invalidParameterError } from "../../common/errors.js"; @@ -246,7 +246,7 @@ function getExpandFlags(options: InitiativeExpandOptions): string[] { } async function resolveInitiativeFilterInput( - sdk: LinearSdkClient, + gql: GraphQLClient, options: InitiativeListOptions, ): Promise<InitiativeFilterInput> { if (options.parent) { @@ -276,19 +276,19 @@ async function resolveInitiativeFilterInput( }); if (options.owner) { - input.ownerId = await resolveUserId(sdk, options.owner); + input.ownerId = await resolveUserId(gql, options.owner); } if (options.creator) { - input.creatorId = await resolveUserId(sdk, options.creator); + input.creatorId = await resolveUserId(gql, options.creator); } if (options.team) { - input.teamId = await resolveTeamId(sdk, options.team); + input.teamId = await resolveTeamId(gql, options.team); } if (options.ancestor) { - input.ancestorId = await resolveInitiativeId(sdk, options.ancestor); + input.ancestorId = await resolveInitiativeId(gql, options.ancestor); } return input; @@ -367,7 +367,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { const sort = mapSortByToInitiativeSort(sortBy, sortOrder); const filterInput = await resolveInitiativeFilterInput( - ctx.sdk, + ctx.gql, options, ); const filter = buildInitiativeFilter(filterInput); @@ -409,7 +409,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { commandAction<[string, InitiativeReadOptions, Command]>( async (initiative, options, command) => { const ctx = createContext(getRootOpts(command)); - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); + const initiativeId = await resolveInitiativeId(ctx.gql, initiative); // Read query already returns expanded fields. Keep flags accepted for // CLI contract compatibility until conditional field selection is added. @@ -434,7 +434,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { throw invalidParameterError("--body", "is required"); } - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); + const initiativeId = await resolveInitiativeId(ctx.gql, initiative); const result = await startInitiativeDiscussion(ctx.gql, { initiativeId, body: options.body, @@ -456,7 +456,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { async (initiative, options, command) => { const ctx = createContext(getRootOpts(command)); - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); + const initiativeId = await resolveInitiativeId(ctx.gql, initiative); const paginationOptions = buildPaginationOptions( parseLimit(options.limit || "25"), options.after, @@ -704,7 +704,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { } if (options.owner) { - input.ownerId = await resolveUserId(ctx.sdk, options.owner); + input.ownerId = await resolveUserId(ctx.gql, options.owner); } const status = parseInitiativeStatus(options.status); @@ -741,7 +741,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { commandAction<[string, InitiativeUpdateOptions, Command]>( async (initiative, options, command) => { const ctx = createContext(getRootOpts(command)); - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); + const initiativeId = await resolveInitiativeId(ctx.gql, initiative); const input: UpdateInitiativeInput = {}; @@ -758,7 +758,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { } if (options.owner) { - input.ownerId = await resolveUserId(ctx.sdk, options.owner); + input.ownerId = await resolveUserId(ctx.gql, options.owner); } const status = parseInitiativeStatus(options.status); @@ -795,7 +795,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { commandAction<[string, unknown, Command]>( async (initiative, _unused1, command) => { const ctx = createContext(getRootOpts(command)); - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); + const initiativeId = await resolveInitiativeId(ctx.gql, initiative); const result = await archiveInitiative(ctx.gql, initiativeId); outputSuccess(result); }, @@ -809,7 +809,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { commandAction<[string, unknown, Command]>( async (initiative, _unused1, command) => { const ctx = createContext(getRootOpts(command)); - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); + const initiativeId = await resolveInitiativeId(ctx.gql, initiative); const result = await unarchiveInitiative(ctx.gql, initiativeId); outputSuccess(result); }, @@ -823,7 +823,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { commandAction<[string, unknown, Command]>( async (initiative, _unused1, command) => { const ctx = createContext(getRootOpts(command)); - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); + const initiativeId = await resolveInitiativeId(ctx.gql, initiative); const result = await deleteInitiative(ctx.gql, initiativeId); outputSuccess(result); }, diff --git a/src/commands/initiatives/projects.ts b/src/commands/initiatives/projects.ts index 6b1d17e0..13b35fc8 100644 --- a/src/commands/initiatives/projects.ts +++ b/src/commands/initiatives/projects.ts @@ -25,8 +25,8 @@ export function setupInitiativeProjectCommands(initiatives: Command): void { ]; const ctx = createContext(getRootOpts(command)); - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); - const projectId = await resolveProjectId(ctx.sdk, project); + const initiativeId = await resolveInitiativeId(ctx.gql, initiative); + const projectId = await resolveProjectId(ctx.gql, project); const result = await createInitiativeProjectLink(ctx.gql, { initiativeId, @@ -50,8 +50,8 @@ export function setupInitiativeProjectCommands(initiatives: Command): void { ]; const ctx = createContext(getRootOpts(command)); - const initiativeId = await resolveInitiativeId(ctx.sdk, initiative); - const projectId = await resolveProjectId(ctx.sdk, project); + const initiativeId = await resolveInitiativeId(ctx.gql, initiative); + const projectId = await resolveProjectId(ctx.gql, project); const linkId = await resolveInitiativeProjectLinkId( ctx.gql, diff --git a/src/commands/initiatives/relations.ts b/src/commands/initiatives/relations.ts index 58520a37..9ecaf746 100644 --- a/src/commands/initiatives/relations.ts +++ b/src/commands/initiatives/relations.ts @@ -24,8 +24,8 @@ export function setupInitiativeRelationCommands(initiatives: Command): void { ]; const ctx = createContext(getRootOpts(command)); - const parentId = await resolveInitiativeId(ctx.sdk, parent); - const childId = await resolveInitiativeId(ctx.sdk, child); + const parentId = await resolveInitiativeId(ctx.gql, parent); + const childId = await resolveInitiativeId(ctx.gql, child); const result = await createInitiativeRelation(ctx.gql, { parentId, @@ -49,8 +49,8 @@ export function setupInitiativeRelationCommands(initiatives: Command): void { ]; const ctx = createContext(getRootOpts(command)); - const parentId = await resolveInitiativeId(ctx.sdk, parent); - const childId = await resolveInitiativeId(ctx.sdk, child); + const parentId = await resolveInitiativeId(ctx.gql, parent); + const childId = await resolveInitiativeId(ctx.gql, child); const relationId = await resolveInitiativeRelationId( ctx.gql, diff --git a/src/commands/initiatives/updates.ts b/src/commands/initiatives/updates.ts index a611ab4b..ddc4159c 100644 --- a/src/commands/initiatives/updates.ts +++ b/src/commands/initiatives/updates.ts @@ -62,7 +62,7 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { const ctx = createContext(getRootOpts(command)); const initiativeId = await resolveInitiativeId( - ctx.sdk, + ctx.gql, options.initiative, ); @@ -103,7 +103,7 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { const ctx = createContext(getRootOpts(command)); const initiativeId = await resolveInitiativeId( - ctx.sdk, + ctx.gql, options.initiative, ); diff --git a/src/commands/issues.ts b/src/commands/issues.ts index c000dbbc..5fc5d725 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -425,7 +425,7 @@ async function resolveAndApplyRelations( const resolved = new Map<string, UUID>(); await Promise.all( [...uniqueTargets].map(async (target) => { - resolved.set(target, await resolveIssueId(ctx.sdk, target)); + resolved.set(target, await resolveIssueId(ctx.gql, target)); }), ); @@ -532,7 +532,7 @@ export function setupIssuesCommands(program: Command): void { commandAction<[string, unknown, Command]>( async (issue, _unused1, command) => { const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); + const issueId = await resolveIssueId(ctx.gql, issue); const result = await listIssueRelations(ctx.gql, issueId); outputSuccess(result); @@ -555,9 +555,9 @@ export function setupIssuesCommands(program: Command): void { async (issue, options, command) => { const relation = parseRelationAddOptions(options); const ctx = createContext(getRootOpts(command)); - const sourceIssueId = await resolveIssueId(ctx.sdk, issue); + const sourceIssueId = await resolveIssueId(ctx.gql, issue); const targetIds = await Promise.all( - relation.targets.map((target) => resolveIssueId(ctx.sdk, target)), + relation.targets.map((target) => resolveIssueId(ctx.gql, target)), ); const created = await Promise.all( @@ -765,7 +765,7 @@ export function setupIssuesCommands(program: Command): void { commandAction<[string, string | undefined, ReactionOptions, Command]>( async (issue, emoji, options, command) => { const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); + const issueId = await resolveIssueId(ctx.gql, issue); const result = await createReactionForIssue(ctx.gql, { issueId, emoji: resolveReactionEmojiInput(emoji, options.shortcode), @@ -788,7 +788,7 @@ export function setupIssuesCommands(program: Command): void { commandAction<[string, string | undefined, ReactionOptions, Command]>( async (issue, emoji, options, command) => { const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); + const issueId = await resolveIssueId(ctx.gql, issue); const result = await deleteOwnReactionByEmoji(ctx.gql, { kind: "issue", id: issueId, @@ -811,7 +811,7 @@ export function setupIssuesCommands(program: Command): void { commandAction<[string, string, unknown, Command]>( async (issue, reactionId, _unused2, command) => { const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); + const issueId = await resolveIssueId(ctx.gql, issue); const result = await deleteOwnReactionById(ctx.gql, { kind: "issue", id: issueId, @@ -840,7 +840,7 @@ export function setupIssuesCommands(program: Command): void { throw invalidParameterError("--body", "is required"); } - const issueId = await resolveIssueId(ctx.sdk, issue); + const issueId = await resolveIssueId(ctx.gql, issue); const result = await startIssueDiscussion(ctx.gql, { issueId, body: options.body, @@ -866,7 +866,7 @@ export function setupIssuesCommands(program: Command): void { async (issue, options, command) => { const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); + const issueId = await resolveIssueId(ctx.gql, issue); const paginationOptions = buildPaginationOptions( parseLimit(options.limit || "25"), options.after, @@ -1131,12 +1131,12 @@ export function setupIssuesCommands(program: Command): void { const teamEstimateContext = parsedEstimate !== undefined - ? await resolveTeamEstimateContext(ctx.sdk, options.team) + ? await resolveTeamEstimateContext(ctx.gql, options.team) : undefined; const teamId = teamEstimateContext ? teamEstimateContext.teamId - : await resolveTeamId(ctx.sdk, options.team); + : await resolveTeamId(ctx.gql, options.team); if (parsedEstimate !== undefined && teamEstimateContext) { validateEstimateAgainstTeamConfig(parsedEstimate, { @@ -1159,7 +1159,7 @@ export function setupIssuesCommands(program: Command): void { } if (options.assignee) { - input.assigneeId = await resolveUserId(ctx.sdk, options.assignee); + input.assigneeId = await resolveUserId(ctx.gql, options.assignee); } if (parsedPriority !== undefined) { @@ -1171,12 +1171,12 @@ export function setupIssuesCommands(program: Command): void { } if (options.project) { - input.projectId = await resolveProjectId(ctx.sdk, options.project); + input.projectId = await resolveProjectId(ctx.gql, options.project); } if (options.labels) { const labelNames = options.labels.split(",").map((l) => l.trim()); - input.labelIds = await resolveLabelIds(ctx.sdk, labelNames); + input.labelIds = await resolveLabelIds(ctx.gql, labelNames); } if (options.projectMilestone) { @@ -1187,7 +1187,6 @@ export function setupIssuesCommands(program: Command): void { } input.projectMilestoneId = await resolveMilestoneId( ctx.gql, - ctx.sdk, options.projectMilestone, options.project, ); @@ -1195,7 +1194,7 @@ export function setupIssuesCommands(program: Command): void { if (options.cycle) { input.cycleId = await resolveCycleId( - ctx.sdk, + ctx.gql, options.cycle, options.team, ); @@ -1203,7 +1202,7 @@ export function setupIssuesCommands(program: Command): void { if (options.status) { input.stateId = await resolveStatusId( - ctx.sdk, + ctx.gql, options.status, teamId, ); @@ -1211,7 +1210,7 @@ export function setupIssuesCommands(program: Command): void { if (options.parentTicket) { input.parentId = await resolveIssueId( - ctx.sdk, + ctx.gql, options.parentTicket, ); } @@ -1327,12 +1326,12 @@ export function setupIssuesCommands(program: Command): void { const issueEstimateContext = parsedEstimate !== undefined - ? await resolveIssueEstimateContext(ctx.sdk, issue) + ? await resolveIssueEstimateContext(ctx.gql, issue) : undefined; const resolvedIssueId = issueEstimateContext ? issueEstimateContext.issueId - : await resolveIssueId(ctx.sdk, issue); + : await resolveIssueId(ctx.gql, issue); if (parsedEstimate !== undefined && issueEstimateContext) { validateEstimateAgainstTeamConfig(parsedEstimate, { @@ -1371,7 +1370,7 @@ export function setupIssuesCommands(program: Command): void { ? asUuid(issueContext.team.id) : undefined; input.stateId = await resolveStatusId( - ctx.sdk, + ctx.gql, options.status, teamId, ); @@ -1388,18 +1387,18 @@ export function setupIssuesCommands(program: Command): void { } if (options.assignee) { - input.assigneeId = await resolveUserId(ctx.sdk, options.assignee); + input.assigneeId = await resolveUserId(ctx.gql, options.assignee); } if (options.project) { - input.projectId = await resolveProjectId(ctx.sdk, options.project); + input.projectId = await resolveProjectId(ctx.gql, options.project); } if (options.clearLabels) { input.labelIds = []; } else if (options.labels) { const labelNames = options.labels.split(",").map((l) => l.trim()); - const labelIds = await resolveLabelIds(ctx.sdk, labelNames); + const labelIds = await resolveLabelIds(ctx.gql, labelNames); if (labelMode === "add") { const currentLabels = @@ -1428,7 +1427,7 @@ export function setupIssuesCommands(program: Command): void { input.parentId = null; } else if (options.parentTicket) { input.parentId = await resolveIssueId( - ctx.sdk, + ctx.gql, options.parentTicket, ); } @@ -1444,7 +1443,6 @@ export function setupIssuesCommands(program: Command): void { : undefined; input.projectMilestoneId = await resolveMilestoneId( ctx.gql, - ctx.sdk, options.projectMilestone, projectName, ); @@ -1458,7 +1456,7 @@ export function setupIssuesCommands(program: Command): void { ? issueContext.team.key : undefined; input.cycleId = await resolveCycleId( - ctx.sdk, + ctx.gql, options.cycle, teamKey, ); @@ -1492,7 +1490,7 @@ export function setupIssuesCommands(program: Command): void { commandAction<[string, unknown, Command]>( async (issue, _unused1, command) => { const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); + const issueId = await resolveIssueId(ctx.gql, issue); const result = await archiveIssue(ctx.gql, issueId); outputSuccess(result); }, @@ -1506,7 +1504,7 @@ export function setupIssuesCommands(program: Command): void { commandAction<[string, unknown, Command]>( async (issue, _unused1, command) => { const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); + const issueId = await resolveIssueId(ctx.gql, issue); const result = await unarchiveIssue(ctx.gql, issueId); outputSuccess(result); }, @@ -1520,7 +1518,7 @@ export function setupIssuesCommands(program: Command): void { commandAction<[string, unknown, Command]>( async (issue, _unused1, command) => { const ctx = createContext(getRootOpts(command)); - const issueId = await resolveIssueId(ctx.sdk, issue); + const issueId = await resolveIssueId(ctx.gql, issue); const result = await deleteIssue(ctx.gql, issueId); outputSuccess(result); }, diff --git a/src/commands/labels.ts b/src/commands/labels.ts index 33ba1828..62f8b24d 100644 --- a/src/commands/labels.ts +++ b/src/commands/labels.ts @@ -103,10 +103,10 @@ async function resolveIssueLabelLookup( } const teamId = options.team - ? await resolveTeamId(ctx.sdk, options.team) + ? await resolveTeamId(ctx.gql, options.team) : undefined; const labelId = await resolveLabelId( - ctx.sdk, + ctx.gql, label, omitUndefined({ teamId, @@ -222,7 +222,7 @@ export function setupLabelsCommands(program: Command): void { } const teamId = options.team - ? await resolveTeamId(ctx.sdk, options.team) + ? await resolveTeamId(ctx.gql, options.team) : undefined; outputSuccess(await listLabels(ctx.gql, teamId, pagination)); @@ -248,7 +248,7 @@ export function setupLabelsCommands(program: Command): void { const color = parseLabelColor(options.color); if (options.team) { - input.teamId = await resolveTeamId(ctx.sdk, options.team); + input.teamId = await resolveTeamId(ctx.gql, options.team); } if (color) { diff --git a/src/commands/milestones.ts b/src/commands/milestones.ts index 3028050f..029b1f61 100644 --- a/src/commands/milestones.ts +++ b/src/commands/milestones.ts @@ -76,7 +76,7 @@ export function setupMilestonesCommands(program: Command): void { const ctx = createContext(getRootOpts(command)); // Resolve project ID - const projectId = await resolveProjectId(ctx.sdk, options.project); + const projectId = await resolveProjectId(ctx.gql, options.project); const milestones = await listMilestones( ctx.gql, @@ -108,7 +108,6 @@ export function setupMilestonesCommands(program: Command): void { const milestoneId = await resolveMilestoneId( ctx.gql, - ctx.sdk, milestone, options.project, ); @@ -140,7 +139,7 @@ export function setupMilestonesCommands(program: Command): void { const ctx = createContext(getRootOpts(command)); // Resolve project ID - const projectId = await resolveProjectId(ctx.sdk, options.project); + const projectId = await resolveProjectId(ctx.gql, options.project); const milestone = await createMilestone(ctx.gql, { projectId, @@ -176,7 +175,6 @@ export function setupMilestonesCommands(program: Command): void { const milestoneId = await resolveMilestoneId( ctx.gql, - ctx.sdk, milestone, options.project, ); diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 4772f30b..e9e9fc20 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -291,7 +291,7 @@ export function setupProjectsCommands(program: Command): void { commandAction<[string, ReadOptions, Command]>( async (project, options, command) => { const ctx = createContext(getRootOpts(command)); - const projectId = await resolveProjectId(ctx.sdk, project); + const projectId = await resolveProjectId(ctx.gql, project); const result = await getProject(ctx.gql, projectId, { milestonesFirst: parseNonNegativeIntegerOption( "--milestones-first", @@ -320,7 +320,7 @@ export function setupProjectsCommands(program: Command): void { throw invalidParameterError("--body", "is required"); } - const projectId = await resolveProjectId(ctx.sdk, project); + const projectId = await resolveProjectId(ctx.gql, project); const result = await startProjectDiscussion(ctx.gql, { projectId, body: options.body, @@ -342,7 +342,7 @@ export function setupProjectsCommands(program: Command): void { async (project, options, command) => { const ctx = createContext(getRootOpts(command)); - const projectId = await resolveProjectId(ctx.sdk, project); + const projectId = await resolveProjectId(ctx.gql, project); const paginationOptions = buildPaginationOptions( parseLimit(options.limit || "25"), options.after, @@ -588,7 +588,7 @@ export function setupProjectsCommands(program: Command): void { const teamNames = getCreateTeamNames(options); const teamIds = await Promise.all( - teamNames.map((t) => resolveTeamId(ctx.sdk, t)), + teamNames.map((t) => resolveTeamId(ctx.gql, t)), ); const input: CreateProjectInput = { @@ -613,7 +613,7 @@ export function setupProjectsCommands(program: Command): void { } if (options.lead) { - input.leadId = await resolveUserId(ctx.sdk, options.lead); + input.leadId = await resolveUserId(ctx.gql, options.lead); } if (options.members) { @@ -622,7 +622,7 @@ export function setupProjectsCommands(program: Command): void { .map((m) => m.trim()) .filter(Boolean); input.memberIds = await Promise.all( - memberNames.map((m) => resolveUserId(ctx.sdk, m)), + memberNames.map((m) => resolveUserId(ctx.gql, m)), ); } @@ -650,7 +650,7 @@ export function setupProjectsCommands(program: Command): void { .split(",") .map((l) => l.trim()) .filter(Boolean); - input.labelIds = await resolveProjectLabelIds(ctx.sdk, labelNames); + input.labelIds = await resolveProjectLabelIds(ctx.gql, labelNames); } const result = await createProject(ctx.gql, input); @@ -730,7 +730,7 @@ export function setupProjectsCommands(program: Command): void { const labelMode = parseLabelMode(options.labelMode); - const projectId = await resolveProjectId(ctx.sdk, project); + const projectId = await resolveProjectId(ctx.gql, project); const needsLabelContext = options.labels && (labelMode === "add" || labelMode === "remove"); const projectContext = needsLabelContext @@ -762,7 +762,7 @@ export function setupProjectsCommands(program: Command): void { if (options.clearLead) { input.leadId = null; } else if (options.lead) { - input.leadId = await resolveUserId(ctx.sdk, options.lead); + input.leadId = await resolveUserId(ctx.gql, options.lead); } if (options.members) { @@ -771,7 +771,7 @@ export function setupProjectsCommands(program: Command): void { .map((m) => m.trim()) .filter(Boolean); input.memberIds = await Promise.all( - memberNames.map((m) => resolveUserId(ctx.sdk, m)), + memberNames.map((m) => resolveUserId(ctx.gql, m)), ); } @@ -801,7 +801,7 @@ export function setupProjectsCommands(program: Command): void { const teamNames = getUpdateTeamNames(options); if (teamNames) { input.teamIds = await Promise.all( - teamNames.map((t) => resolveTeamId(ctx.sdk, t)), + teamNames.map((t) => resolveTeamId(ctx.gql, t)), ); } @@ -812,7 +812,7 @@ export function setupProjectsCommands(program: Command): void { .split(",") .map((l) => l.trim()) .filter(Boolean); - const labelIds = await resolveProjectLabelIds(ctx.sdk, labelNames); + const labelIds = await resolveProjectLabelIds(ctx.gql, labelNames); if (labelMode === "add") { const currentLabels = projectContext?.labels?.nodes @@ -851,7 +851,7 @@ export function setupProjectsCommands(program: Command): void { commandAction<[string, unknown, Command]>( async (project, _unused1, command) => { const ctx = createContext(getRootOpts(command)); - const projectId = await resolveProjectId(ctx.sdk, project); + const projectId = await resolveProjectId(ctx.gql, project); const result = await archiveProject(ctx.gql, projectId); outputSuccess(result); }, @@ -865,7 +865,7 @@ export function setupProjectsCommands(program: Command): void { commandAction<[string, unknown, Command]>( async (project, _unused1, command) => { const ctx = createContext(getRootOpts(command)); - const projectId = await resolveProjectId(ctx.sdk, project, { + const projectId = await resolveProjectId(ctx.gql, project, { includeArchived: true, }); const result = await unarchiveProject(ctx.gql, projectId); @@ -881,7 +881,7 @@ export function setupProjectsCommands(program: Command): void { commandAction<[string, unknown, Command]>( async (project, _unused1, command) => { const ctx = createContext(getRootOpts(command)); - const projectId = await resolveProjectId(ctx.sdk, project, { + const projectId = await resolveProjectId(ctx.gql, project, { includeArchived: true, }); const result = await deleteProject(ctx.gql, projectId); diff --git a/src/commands/teams.ts b/src/commands/teams.ts index 6a5c53a4..859e7752 100644 --- a/src/commands/teams.ts +++ b/src/commands/teams.ts @@ -50,7 +50,7 @@ export function setupTeamsCommands(program: Command): void { const team = args[0] as string; const command = args.at(-1) as Command; const ctx = createContext(getRootOpts(command)); - const teamId = await resolveTeamId(ctx.sdk, team); + const teamId = await resolveTeamId(ctx.gql, team); const result = await getTeam(ctx.gql, { id: teamId }); outputSuccess(result); }), diff --git a/src/common/context.ts b/src/common/context.ts index 4baa1d62..082dc52a 100644 --- a/src/common/context.ts +++ b/src/common/context.ts @@ -1,20 +1,17 @@ import type { Command } from "commander"; import { GraphQLClient } from "../client/graphql-client.js"; -import { LinearSdkClient } from "../client/linear-client.js"; import { type CommandOptions, getApiToken } from "./auth.js"; export type { CommandOptions }; export interface CommandContext { gql: GraphQLClient; - sdk: LinearSdkClient; } export function createContext(options: CommandOptions): CommandContext { const token = getApiToken(options); return { gql: new GraphQLClient(token), - sdk: new LinearSdkClient(token), }; } diff --git a/src/common/resolve-filters.ts b/src/common/resolve-filters.ts index 3488ca8f..7a6e15cf 100644 --- a/src/common/resolve-filters.ts +++ b/src/common/resolve-filters.ts @@ -108,7 +108,7 @@ export async function resolveFilterOptions( const batchResolved = hasResolvableFilters ? await resolveSearchFilterIds( - ctx.sdk, + ctx.gql, omitUndefined({ team: opts.team, assignee: opts.assignee, @@ -123,7 +123,7 @@ export async function resolveFilterOptions( : {}; const milestoneId = opts.milestone - ? await resolveMilestoneId(ctx.gql, ctx.sdk, opts.milestone, opts.project) + ? await resolveMilestoneId(ctx.gql, opts.milestone, opts.project) : undefined; const resolved: IssueFilterOptions = omitUndefined({ diff --git a/src/resolvers/cycle-resolver.ts b/src/resolvers/cycle-resolver.ts index 336b03bd..7a51a254 100644 --- a/src/resolvers/cycle-resolver.ts +++ b/src/resolvers/cycle-resolver.ts @@ -1,7 +1,10 @@ -import type { LinearDocument } from "@linear/sdk"; -import type { LinearSdkClient } from "../client/linear-client.js"; +import type { GraphQLClient } from "../client/graphql-client.js"; import { multipleMatchesError, notFoundError } from "../common/errors.js"; import { asUuid, isUuid, type UUID } from "../common/identifier.js"; +import { + FindCycleGlobalDocument, + FindCycleScopedDocument, +} from "../gql/graphql.js"; import { resolveTeamId } from "./team-resolver.js"; /** @@ -10,61 +13,40 @@ import { resolveTeamId } from "./team-resolver.js"; * Accepts UUID or cycle name. When multiple cycles match a name, * prefers active > next > previous. Use teamFilter to disambiguate. * - * @param client - Linear SDK client + * @param client - GraphQL client * @param nameOrId - Cycle name or UUID * @param teamFilter - Optional team key/name/ID to scope search * @returns Cycle UUID * @throws Error if not found or multiple matches without clear preference */ export async function resolveCycleId( - client: LinearSdkClient, + client: GraphQLClient, nameOrId: string, teamFilter?: string, ): Promise<UUID> { if (isUuid(nameOrId)) return asUuid(nameOrId); - const filter: LinearDocument.CycleFilter = { - name: { eq: nameOrId }, - }; + const matched = teamFilter + ? ( + await client.request(FindCycleScopedDocument, { + name: nameOrId, + teamId: await resolveTeamId(client, teamFilter), + }) + ).cycles.nodes + : (await client.request(FindCycleGlobalDocument, { name: nameOrId })).cycles + .nodes; - if (teamFilter) { - const teamId = await resolveTeamId(client, teamFilter); - filter.team = { id: { eq: teamId } }; - } - - const cyclesConnection = await client.sdk.cycles({ - filter, - first: 10, - }); - - const nodes: Array<{ - id: string; - name: string; - number: number; - startsAt?: string; - isActive: boolean; - isNext: boolean; - isPrevious: boolean; - team?: { id: string; key: string; name: string }; - }> = []; - - for (const cycle of cyclesConnection.nodes) { - const team = await cycle.team; - nodes.push({ - id: cycle.id, - name: cycle.name ?? "", - number: cycle.number, - isActive: cycle.isActive, - isNext: cycle.isNext, - isPrevious: cycle.isPrevious, - ...(cycle.startsAt - ? { startsAt: new Date(cycle.startsAt).toISOString() } - : {}), - ...(team - ? { team: { id: team.id, key: team.key, name: team.name } } - : {}), - }); - } + const nodes = matched.map((cycle) => ({ + id: cycle.id, + number: cycle.number, + isActive: cycle.isActive, + isNext: cycle.isNext, + isPrevious: cycle.isPrevious, + ...(cycle.startsAt + ? { startsAt: new Date(cycle.startsAt).toISOString() } + : {}), + ...(cycle.team ? { team: { key: cycle.team.key } } : {}), + })); if (nodes.length === 0) { throw notFoundError( diff --git a/src/resolvers/initiative-resolver.ts b/src/resolvers/initiative-resolver.ts index 3f3aa03b..f0c4a241 100644 --- a/src/resolvers/initiative-resolver.ts +++ b/src/resolvers/initiative-resolver.ts @@ -1,12 +1,12 @@ -import type { LinearDocument } from "@linear/sdk"; import type { GraphQLClient } from "../client/graphql-client.js"; -import type { LinearSdkClient } from "../client/linear-client.js"; import { firstOrThrow } from "../common/array.js"; import { multipleMatchesError, notFoundError } from "../common/errors.js"; import { asUuid, isUuid, type UUID } from "../common/identifier.js"; import { FindInitiativeProjectLinkByPairDocument, FindInitiativeRelationByPairDocument, + FindInitiativesDocument, + type InitiativeFilter, } from "../gql/graphql.js"; export interface InitiativeResolveScope { @@ -15,7 +15,7 @@ export interface InitiativeResolveScope { } export async function resolveInitiativeId( - client: LinearSdkClient, + client: GraphQLClient, nameOrId: string, scope: InitiativeResolveScope = {}, ): Promise<UUID> { @@ -23,10 +23,10 @@ export async function resolveInitiativeId( return asUuid(nameOrId); } - const nameClause: LinearDocument.InitiativeFilter = { + const nameClause: InitiativeFilter = { name: { eqIgnoreCase: nameOrId }, }; - const scopeClauses: LinearDocument.InitiativeFilter[] = []; + const scopeClauses: InitiativeFilter[] = []; if (scope.teamId) { scopeClauses.push({ teams: { some: { id: { eq: scope.teamId } } } }); @@ -36,28 +36,31 @@ export async function resolveInitiativeId( scopeClauses.push({ owner: { id: { eq: scope.ownerId } } }); } - const filter: LinearDocument.InitiativeFilter = + const filter: InitiativeFilter = scopeClauses.length === 0 ? nameClause : { and: [nameClause, ...scopeClauses] }; - const result = await client.sdk.initiatives({ + const { initiatives } = await client.request(FindInitiativesDocument, { filter, first: 20, }); - if (result.nodes.length === 0) { + if (initiatives.nodes.length === 0) { throw notFoundError("Initiative", nameOrId); } - if (result.nodes.length === 1) { + if (initiatives.nodes.length === 1) { return asUuid( - firstOrThrow(result.nodes, () => notFoundError("Initiative", nameOrId)) - .id, + firstOrThrow(initiatives.nodes, () => + notFoundError("Initiative", nameOrId), + ).id, ); } - const candidates = result.nodes.map((node) => `${node.name} (${node.id})`); + const candidates = initiatives.nodes.map( + (node) => `${node.name} (${node.id})`, + ); throw multipleMatchesError( "initiative", diff --git a/src/resolvers/issue-filter-resolver.ts b/src/resolvers/issue-filter-resolver.ts index 9729797d..34e11bae 100644 --- a/src/resolvers/issue-filter-resolver.ts +++ b/src/resolvers/issue-filter-resolver.ts @@ -1,4 +1,4 @@ -import type { LinearSdkClient } from "../client/linear-client.js"; +import type { GraphQLClient } from "../client/graphql-client.js"; import type { UUID } from "../common/identifier.js"; import { resolveCycleId } from "./cycle-resolver.js"; import { resolveIssueId } from "./issue-resolver.js"; @@ -31,49 +31,49 @@ export interface SearchFilterResolution { } export async function resolveSearchFilterIds( - sdkClient: LinearSdkClient, + gqlClient: GraphQLClient, input: SearchFilterResolutionInput, ): Promise<SearchFilterResolution> { const resolved: SearchFilterResolution = {}; if (input.team) { - resolved.teamId = await resolveTeamId(sdkClient, input.team); + resolved.teamId = await resolveTeamId(gqlClient, input.team); } if (input.assignee) { - resolved.assigneeId = await resolveUserId(sdkClient, input.assignee); + resolved.assigneeId = await resolveUserId(gqlClient, input.assignee); } if (input.creator) { - resolved.creatorId = await resolveUserId(sdkClient, input.creator); + resolved.creatorId = await resolveUserId(gqlClient, input.creator); } if (input.project) { - resolved.projectId = await resolveProjectId(sdkClient, input.project); + resolved.projectId = await resolveProjectId(gqlClient, input.project); } if (input.statusNames && input.statusNames.length > 0) { resolved.stateIds = await Promise.all( input.statusNames.map((status) => - resolveStatusId(sdkClient, status, resolved.teamId), + resolveStatusId(gqlClient, status, resolved.teamId), ), ); } if (input.labelNames && input.labelNames.length > 0) { - resolved.labelIds = await resolveLabelIds(sdkClient, input.labelNames); + resolved.labelIds = await resolveLabelIds(gqlClient, input.labelNames); } if (input.cycle) { resolved.cycleId = await resolveCycleId( - sdkClient, + gqlClient, input.cycle, resolved.teamId ?? input.team, ); } if (input.parent) { - resolved.parentId = await resolveIssueId(sdkClient, input.parent); + resolved.parentId = await resolveIssueId(gqlClient, input.parent); } return resolved; diff --git a/src/resolvers/issue-resolver.ts b/src/resolvers/issue-resolver.ts index e3eab997..aceacb1b 100644 --- a/src/resolvers/issue-resolver.ts +++ b/src/resolvers/issue-resolver.ts @@ -1,4 +1,4 @@ -import type { LinearSdkClient } from "../client/linear-client.js"; +import type { GraphQLClient } from "../client/graphql-client.js"; import { firstOrThrow } from "../common/array.js"; import { notFoundError } from "../common/errors.js"; import { @@ -7,80 +7,25 @@ import { parseIssueIdentifier, type UUID, } from "../common/identifier.js"; -import { omitUndefined } from "../common/object.js"; +import { FindIssuesDocument, type IssueFilter } from "../gql/graphql.js"; import { resolveTeamEstimateContext, type TeamEstimateContext, } from "./team-resolver.js"; -function isRecord(value: unknown): value is Record<string, unknown> { - return typeof value === "object" && value !== null; -} - -function isPromiseLike(value: unknown): value is PromiseLike<unknown> { - if ((typeof value !== "object" && typeof value !== "function") || !value) { - return false; - } - - return typeof (value as { then?: unknown }).then === "function"; -} - -async function resolveRelationValue(value: unknown): Promise<unknown> { - return isPromiseLike(value) ? await value : value; -} - -/** Narrow projection of the SDK issue node fields the team lookup consumes. */ -interface IssueTeamProjection { - id: string; - teamId?: string; - team?: unknown; // relation; may be a value or PromiseLike (SDK quirk) -} - -/** Narrow projection of a resolved team relation node. */ -interface TeamLookupProjection { - id?: string; - key?: string; -} - -function toIssueTeamProjection( - node: unknown, - ref: string, -): IssueTeamProjection { - if (!isRecord(node) || typeof node["id"] !== "string") { - throw new Error(`Issue "${ref}" is missing required team context`); +/** Builds the FindIssues filter for a UUID or "TEAM-123" identifier. */ +function issueLookupFilter(issueIdOrIdentifier: string): IssueFilter { + if (isUuid(issueIdOrIdentifier)) { + return { id: { eq: issueIdOrIdentifier } }; } + const { teamKey, issueNumber } = parseIssueIdentifier(issueIdOrIdentifier); return { - id: node["id"], - team: node["team"], - ...(typeof node["teamId"] === "string" ? { teamId: node["teamId"] } : {}), + number: { eq: issueNumber }, + team: { key: { eq: teamKey } }, }; } -function toTeamLookupProjection( - team: unknown, -): TeamLookupProjection | undefined { - if (!isRecord(team)) return undefined; - - return omitUndefined({ - id: typeof team["id"] === "string" ? team["id"] : undefined, - key: typeof team["key"] === "string" ? team["key"] : undefined, - }); -} - -function getTeamLookupFromRelation(team: unknown): string | undefined { - const relation = toTeamLookupProjection(team); - return relation?.id ?? relation?.key; -} - -async function getIssueTeamLookup( - projection: IssueTeamProjection, -): Promise<string | undefined> { - if (projection.teamId) return projection.teamId; - - return getTeamLookupFromRelation(await resolveRelationValue(projection.team)); -} - export interface IssueEstimateContext { issueId: UUID; team: TeamEstimateContext; @@ -91,24 +36,19 @@ export interface IssueEstimateContext { * * Accepts UUID or issue identifier (e.g., "ENG-123"). * - * @param client - Linear SDK client + * @param client - GraphQL client * @param issueIdOrIdentifier - Issue UUID or identifier * @returns Issue UUID * @throws Error if issue not found */ export async function resolveIssueId( - client: LinearSdkClient, + client: GraphQLClient, issueIdOrIdentifier: string, ): Promise<UUID> { if (isUuid(issueIdOrIdentifier)) return asUuid(issueIdOrIdentifier); - const { teamKey, issueNumber } = parseIssueIdentifier(issueIdOrIdentifier); - - const issues = await client.sdk.issues({ - filter: { - number: { eq: issueNumber }, - team: { key: { eq: teamKey } }, - }, + const { issues } = await client.request(FindIssuesDocument, { + filter: issueLookupFilter(issueIdOrIdentifier), first: 1, }); @@ -120,46 +60,20 @@ export async function resolveIssueId( } export async function resolveIssueEstimateContext( - client: LinearSdkClient, + client: GraphQLClient, issueIdOrIdentifier: string, ): Promise<IssueEstimateContext> { - const issueIsUuid = isUuid(issueIdOrIdentifier); - const issues = await (issueIsUuid - ? client.sdk.issues({ - filter: { id: { eq: issueIdOrIdentifier } }, - first: 1, - }) - : (() => { - const { teamKey, issueNumber } = - parseIssueIdentifier(issueIdOrIdentifier); - - return client.sdk.issues({ - filter: { - number: { eq: issueNumber }, - team: { key: { eq: teamKey } }, - }, - first: 1, - }); - })()); - - if (issues.nodes.length === 0) { - throw notFoundError("Issue", issueIdOrIdentifier); - } + const { issues } = await client.request(FindIssuesDocument, { + filter: issueLookupFilter(issueIdOrIdentifier), + first: 1, + }); - const projection = toIssueTeamProjection( - issues.nodes[0], - issueIdOrIdentifier, + const node = firstOrThrow(issues.nodes, () => + notFoundError("Issue", issueIdOrIdentifier), ); - const teamLookup = await getIssueTeamLookup(projection); - if (!teamLookup) { - throw new Error( - `Issue "${issueIdOrIdentifier}" is missing required team context`, - ); - } - return { - issueId: asUuid(projection.id), - team: await resolveTeamEstimateContext(client, teamLookup), + issueId: asUuid(node.id), + team: await resolveTeamEstimateContext(client, node.team.id), }; } diff --git a/src/resolvers/label-resolver.ts b/src/resolvers/label-resolver.ts index 75af59e7..33cd9b1e 100644 --- a/src/resolvers/label-resolver.ts +++ b/src/resolvers/label-resolver.ts @@ -1,7 +1,11 @@ -import type { LinearSdkClient } from "../client/linear-client.js"; +import type { GraphQLClient } from "../client/graphql-client.js"; import { firstOrThrow } from "../common/array.js"; import { notFoundError } from "../common/errors.js"; import { asUuid, isUuid, type UUID } from "../common/identifier.js"; +import { + FindIssueLabelsDocument, + type IssueLabelFilter, +} from "../gql/graphql.js"; export type LabelResolverScope = "workspace" | "team"; @@ -13,7 +17,7 @@ export interface ResolveLabelOptions { function buildLabelFilter( nameOrId: string, options: ResolveLabelOptions, -): Record<string, unknown> { +): IssueLabelFilter { if (options.scope === "workspace") { return { name: { eqIgnoreCase: nameOrId }, @@ -39,24 +43,24 @@ function buildLabelFilter( } export async function resolveLabelId( - client: LinearSdkClient, + client: GraphQLClient, nameOrId: string, options: ResolveLabelOptions = {}, ): Promise<UUID> { if (isUuid(nameOrId)) return asUuid(nameOrId); - const result = await client.sdk.issueLabels({ + const { issueLabels } = await client.request(FindIssueLabelsDocument, { filter: buildLabelFilter(nameOrId, options), first: 1, }); return asUuid( - firstOrThrow(result.nodes, () => notFoundError("Label", nameOrId)).id, + firstOrThrow(issueLabels.nodes, () => notFoundError("Label", nameOrId)).id, ); } export async function resolveLabelIds( - client: LinearSdkClient, + client: GraphQLClient, namesOrIds: string[], ): Promise<UUID[]> { return Promise.all(namesOrIds.map((id) => resolveLabelId(client, id))); diff --git a/src/resolvers/milestone-resolver.ts b/src/resolvers/milestone-resolver.ts index 17caead4..686d98c9 100644 --- a/src/resolvers/milestone-resolver.ts +++ b/src/resolvers/milestone-resolver.ts @@ -1,5 +1,4 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { LinearSdkClient } from "../client/linear-client.js"; import { firstOrThrow } from "../common/array.js"; import { multipleMatchesError, notFoundError } from "../common/errors.js"; import { asUuid, isUuid, type UUID } from "../common/identifier.js"; @@ -15,14 +14,12 @@ import { resolveProjectId } from "./project-resolver.js"; * Accepts UUID or milestone name. When multiple milestones match a name, * use projectNameOrId to scope the search to a specific project. * - * ARCHITECTURAL EXCEPTION: This resolver uses GraphQLClient in addition to - * LinearSdkClient because the Linear SDK does not expose milestone lookup - * by name. The GraphQL client is needed for the FindProjectMilestoneScoped - * and FindProjectMilestoneGlobal queries. This is a documented deviation - * from the standard resolver contract (resolvers normally use SDK only). + * ARCHITECTURAL EXCEPTION: This resolver queries milestones directly via + * GraphQL (FindProjectMilestoneScoped / FindProjectMilestoneGlobal) because + * the Linear API exposes no lean lookup fragment for milestones by name. All + * lookups go through the single GraphQL client. * - * @param gqlClient - GraphQL client for querying milestones - * @param sdkClient - SDK client for project resolution + * @param gqlClient - GraphQL client for querying milestones and projects * @param nameOrId - Milestone name or UUID * @param projectNameOrId - Optional project name/ID to scope search * @returns Milestone UUID @@ -30,7 +27,6 @@ import { resolveProjectId } from "./project-resolver.js"; */ export async function resolveMilestoneId( gqlClient: GraphQLClient, - sdkClient: LinearSdkClient, nameOrId: string, projectNameOrId?: string, ): Promise<UUID> { @@ -44,7 +40,7 @@ export async function resolveMilestoneId( let nodes: MilestoneNode[] = []; if (projectNameOrId) { - const projectId = await resolveProjectId(sdkClient, projectNameOrId); + const projectId = await resolveProjectId(gqlClient, projectNameOrId); const result = await gqlClient.request(FindProjectMilestoneScopedDocument, { name: nameOrId, projectId, diff --git a/src/resolvers/project-resolver.ts b/src/resolvers/project-resolver.ts index e11b79cf..5e67d096 100644 --- a/src/resolvers/project-resolver.ts +++ b/src/resolvers/project-resolver.ts @@ -1,65 +1,66 @@ -import type { LinearSdkClient } from "../client/linear-client.js"; +import type { GraphQLClient } from "../client/graphql-client.js"; import { firstOrThrow } from "../common/array.js"; import { multipleMatchesError, notFoundError } from "../common/errors.js"; import { asUuid, isUuid, type UUID } from "../common/identifier.js"; -import { omitUndefined } from "../common/object.js"; +import { + FindProjectLabelByNameDocument, + FindProjectsByNameDocument, +} from "../gql/graphql.js"; export interface ResolveProjectIdOptions { includeArchived?: boolean; } export async function resolveProjectId( - client: LinearSdkClient, + client: GraphQLClient, nameOrId: string, options: ResolveProjectIdOptions = {}, ): Promise<UUID> { if (isUuid(nameOrId)) return asUuid(nameOrId); - const result = await client.sdk.projects( - omitUndefined({ - filter: { name: { eqIgnoreCase: nameOrId } }, - first: 2, - includeArchived: options.includeArchived, - }), - ); + const { projects } = await client.request(FindProjectsByNameDocument, { + name: nameOrId, + includeArchived: options.includeArchived, + }); - if (result.nodes.length === 0) { + if (projects.nodes.length === 0) { throw notFoundError("Project", nameOrId); } - if (result.nodes.length > 1) { + if (projects.nodes.length > 1) { throw multipleMatchesError( "Project", nameOrId, - result.nodes.map((project) => project.id), + projects.nodes.map((project) => project.id), "provide project UUID", ); } return asUuid( - firstOrThrow(result.nodes, () => notFoundError("Project", nameOrId)).id, + firstOrThrow(projects.nodes, () => notFoundError("Project", nameOrId)).id, ); } export async function resolveProjectLabelId( - client: LinearSdkClient, + client: GraphQLClient, nameOrId: string, ): Promise<UUID> { if (isUuid(nameOrId)) return asUuid(nameOrId); - const result = await client.sdk.projectLabels({ - filter: { name: { eqIgnoreCase: nameOrId } }, - first: 1, - }); + const { projectLabels } = await client.request( + FindProjectLabelByNameDocument, + { name: nameOrId }, + ); return asUuid( - firstOrThrow(result.nodes, () => notFoundError("Project label", nameOrId)) - .id, + firstOrThrow(projectLabels.nodes, () => + notFoundError("Project label", nameOrId), + ).id, ); } export async function resolveProjectLabelIds( - client: LinearSdkClient, + client: GraphQLClient, namesOrIds: string[], ): Promise<UUID[]> { return Promise.all( diff --git a/src/resolvers/project-status-resolver.ts b/src/resolvers/project-status-resolver.ts index d9cf289f..a998280b 100644 --- a/src/resolvers/project-status-resolver.ts +++ b/src/resolvers/project-status-resolver.ts @@ -8,12 +8,8 @@ import { GetProjectStatusesDocument } from "../gql/graphql.js"; * * Accepts UUID (returned as-is) or a status name (case-insensitive match). * - * ARCHITECTURAL EXCEPTION: This resolver uses GraphQLClient instead of - * LinearSdkClient because the Linear SDK's projectStatuses() method does - * not support server-side filtering. A GraphQL query fetches all statuses - * (a small fixed set) and filters client-side. This is a documented - * deviation from the standard resolver contract (resolvers normally use - * SDK only). + * projectStatuses has no server-side name filter, so this fetches the full + * (small, fixed) set and matches client-side. * * @param client - GraphQL client for querying project statuses * @param nameOrId - Status name or UUID diff --git a/src/resolvers/status-resolver.ts b/src/resolvers/status-resolver.ts index c4369df9..f4f6b14c 100644 --- a/src/resolvers/status-resolver.ts +++ b/src/resolvers/status-resolver.ts @@ -1,17 +1,20 @@ -import type { LinearDocument } from "@linear/sdk"; -import type { LinearSdkClient } from "../client/linear-client.js"; +import type { GraphQLClient } from "../client/graphql-client.js"; import { firstOrThrow } from "../common/array.js"; import { notFoundError } from "../common/errors.js"; import { asUuid, isUuid, type UUID } from "../common/identifier.js"; +import { + FindWorkflowStatesDocument, + type WorkflowStateFilter, +} from "../gql/graphql.js"; export async function resolveStatusId( - client: LinearSdkClient, + client: GraphQLClient, nameOrId: string, teamId?: UUID, ): Promise<UUID> { if (isUuid(nameOrId)) return asUuid(nameOrId); - const filter: LinearDocument.WorkflowStateFilter = { + const filter: WorkflowStateFilter = { name: { eqIgnoreCase: nameOrId }, }; @@ -19,13 +22,13 @@ export async function resolveStatusId( filter.team = { id: { eq: teamId } }; } - const result = await client.sdk.workflowStates({ + const { workflowStates } = await client.request(FindWorkflowStatesDocument, { filter, first: 1, }); return asUuid( - firstOrThrow(result.nodes, () => + firstOrThrow(workflowStates.nodes, () => notFoundError( "Status", nameOrId, diff --git a/src/resolvers/team-resolver.ts b/src/resolvers/team-resolver.ts index 33e29a73..b410b22e 100644 --- a/src/resolvers/team-resolver.ts +++ b/src/resolvers/team-resolver.ts @@ -1,6 +1,7 @@ -import type { LinearSdkClient } from "../client/linear-client.js"; +import type { GraphQLClient } from "../client/graphql-client.js"; import { notFoundError } from "../common/errors.js"; import { asUuid, isUuid, type UUID } from "../common/identifier.js"; +import { FindTeamsDocument } from "../gql/graphql.js"; type TeamEstimationType = | "notUsed" @@ -95,39 +96,39 @@ function mapTeamNodeToEstimateContext( } export async function resolveTeamEstimateContext( - client: LinearSdkClient, + client: GraphQLClient, keyOrNameOrId: string, ): Promise<TeamEstimateContext> { if (isUuid(keyOrNameOrId)) { - const byId = await client.sdk.teams({ + const { teams } = await client.request(FindTeamsDocument, { filter: { id: { eq: keyOrNameOrId } }, first: 1, }); - if (byId.nodes.length > 0) { + if (teams.nodes.length > 0) { return mapTeamNodeToEstimateContext( - toTeamEstimateNode(byId.nodes[0], keyOrNameOrId), + toTeamEstimateNode(teams.nodes[0], keyOrNameOrId), ); } throw notFoundError("Team", keyOrNameOrId); } - const byKey = await client.sdk.teams({ + const byKey = await client.request(FindTeamsDocument, { filter: { key: { eq: keyOrNameOrId } }, first: 1, }); - if (byKey.nodes.length > 0) { + if (byKey.teams.nodes.length > 0) { return mapTeamNodeToEstimateContext( - toTeamEstimateNode(byKey.nodes[0], keyOrNameOrId), + toTeamEstimateNode(byKey.teams.nodes[0], keyOrNameOrId), ); } - const byName = await client.sdk.teams({ + const byName = await client.request(FindTeamsDocument, { filter: { name: { eq: keyOrNameOrId } }, first: 1, }); - if (byName.nodes.length > 0) { + if (byName.teams.nodes.length > 0) { return mapTeamNodeToEstimateContext( - toTeamEstimateNode(byName.nodes[0], keyOrNameOrId), + toTeamEstimateNode(byName.teams.nodes[0], keyOrNameOrId), ); } @@ -135,25 +136,25 @@ export async function resolveTeamEstimateContext( } export async function resolveTeamId( - client: LinearSdkClient, + client: GraphQLClient, keyOrNameOrId: string, ): Promise<UUID> { if (isUuid(keyOrNameOrId)) return asUuid(keyOrNameOrId); // Try by key first - const byKey = await client.sdk.teams({ + const byKey = await client.request(FindTeamsDocument, { filter: { key: { eq: keyOrNameOrId } }, first: 1, }); - const [byKeyMatch] = byKey.nodes; + const [byKeyMatch] = byKey.teams.nodes; if (byKeyMatch) return asUuid(byKeyMatch.id); // Fall back to name - const byName = await client.sdk.teams({ + const byName = await client.request(FindTeamsDocument, { filter: { name: { eq: keyOrNameOrId } }, first: 1, }); - const [byNameMatch] = byName.nodes; + const [byNameMatch] = byName.teams.nodes; if (byNameMatch) return asUuid(byNameMatch.id); throw notFoundError("Team", keyOrNameOrId); diff --git a/src/resolvers/user-resolver.ts b/src/resolvers/user-resolver.ts index 3725f667..d051099d 100644 --- a/src/resolvers/user-resolver.ts +++ b/src/resolvers/user-resolver.ts @@ -1,15 +1,16 @@ -import type { LinearSdkClient } from "../client/linear-client.js"; +import type { GraphQLClient } from "../client/graphql-client.js"; import { multipleMatchesError, notFoundError } from "../common/errors.js"; import { asUuid, isUuid, type UUID } from "../common/identifier.js"; +import { FindUsersDocument } from "../gql/graphql.js"; export async function resolveUserId( - client: LinearSdkClient, + client: GraphQLClient, nameOrEmailOrId: string, ): Promise<UUID> { if (isUuid(nameOrEmailOrId)) return asUuid(nameOrEmailOrId); // Try by display name first (case-insensitive) - const byName = await client.sdk.users({ + const { users: byName } = await client.request(FindUsersDocument, { filter: { displayName: { eqIgnoreCase: nameOrEmailOrId } }, first: 10, }); @@ -27,7 +28,7 @@ export async function resolveUserId( } // Fall back to email (case-insensitive) - const byEmail = await client.sdk.users({ + const { users: byEmail } = await client.request(FindUsersDocument, { filter: { email: { eqIgnoreCase: nameOrEmailOrId } }, first: 1, }); diff --git a/tests/unit/common/resolve-filters.test.ts b/tests/unit/common/resolve-filters.test.ts index 827ab20d..cfa372e1 100644 --- a/tests/unit/common/resolve-filters.test.ts +++ b/tests/unit/common/resolve-filters.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; -import type { LinearSdkClient } from "../../../src/client/linear-client.js"; import type { CommandContext } from "../../../src/common/context.js"; import { resolveFilterOptions } from "../../../src/common/resolve-filters.js"; import { resolveSearchFilterIds } from "../../../src/resolvers/issue-filter-resolver.js"; @@ -26,7 +25,6 @@ vi.mock("../../../src/resolvers/milestone-resolver.js", () => ({ function mockContext(): CommandContext { return { gql: {} as unknown as GraphQLClient, - sdk: {} as unknown as LinearSdkClient, }; } @@ -83,7 +81,6 @@ describe("resolveFilterOptions", () => { parent: "ENG-123", }); expect(resolveMilestoneId).toHaveBeenCalledWith( - expect.anything(), expect.anything(), "v1.0", "Backend", @@ -187,7 +184,6 @@ describe("resolveFilterOptions", () => { expect(resolveSearchFilterIds).not.toHaveBeenCalled(); expect(resolveMilestoneId).toHaveBeenCalledWith( - expect.anything(), expect.anything(), "550e8400-e29b-41d4-a716-446655440002", undefined, diff --git a/tests/unit/resolvers/cycle-resolver.test.ts b/tests/unit/resolvers/cycle-resolver.test.ts index 1efb9751..000a21a5 100644 --- a/tests/unit/resolvers/cycle-resolver.test.ts +++ b/tests/unit/resolvers/cycle-resolver.test.ts @@ -1,53 +1,97 @@ // tests/unit/resolvers/cycle-resolver.test.ts import { describe, expect, it, vi } from "vitest"; -import type { LinearSdkClient } from "../../../src/client/linear-client.js"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { resolveCycleId } from "../../../src/resolvers/cycle-resolver.js"; -function mockSdkClient( - cycleNodes: Array<{ - id: string; - name?: string; - isActive?: boolean; - isNext?: boolean; - isPrevious?: boolean; - number?: number; - startsAt?: string; - }>, -) { - const teams = vi.fn().mockResolvedValue({ nodes: [{ id: "team-uuid" }] }); - const cycles = vi.fn().mockResolvedValue({ nodes: cycleNodes }); - // Mock cycle.team as a resolved property - cycleNodes.forEach((node) => { - Object.defineProperty(node, "team", { - value: Promise.resolve({ - id: "team-uuid", - key: "ENG", - name: "Engineering", - }), - enumerable: false, - }); - }); - return { sdk: { teams, cycles } } as unknown as LinearSdkClient; +type CycleNode = { + id: string; + name?: string; + number?: number; + startsAt?: string; + isActive?: boolean; + isNext?: boolean; + isPrevious?: boolean; + team?: { id: string; key: string }; +}; + +function cycle(node: CycleNode): CycleNode { + return { + name: "Sprint", + number: 1, + isActive: false, + isNext: false, + isPrevious: false, + team: { id: "team-uuid", key: "ENG" }, + ...node, + }; +} + +// Unscoped lookups issue a single FindCycleGlobal request. +function mockGlobalClient(cycleNodes: CycleNode[]) { + return { + request: vi.fn().mockResolvedValue({ cycles: { nodes: cycleNodes } }), + } as unknown as GraphQLClient; +} + +// Team-scoped lookups first resolve the team (FindTeams), then FindCycleScoped. +function mockScopedClient(cycleNodes: CycleNode[]) { + const request = vi + .fn() + .mockResolvedValueOnce({ teams: { nodes: [{ id: "team-uuid" }] } }) + .mockResolvedValueOnce({ cycles: { nodes: cycleNodes } }); + return { request } as unknown as GraphQLClient; } describe("resolveCycleId", () => { it("returns UUID as-is", async () => { - const client = mockSdkClient([]); + const client = mockGlobalClient([]); const result = await resolveCycleId( client, "550e8400-e29b-41d4-a716-446655440000", ); expect(result).toBe("550e8400-e29b-41d4-a716-446655440000"); + expect(client.request).not.toHaveBeenCalled(); }); it("resolves single matching cycle by name", async () => { - const client = mockSdkClient([{ id: "cycle-uuid", name: "Sprint 1" }]); + const client = mockGlobalClient([cycle({ id: "cycle-uuid" })]); const result = await resolveCycleId(client, "Sprint 1"); expect(result).toBe("cycle-uuid"); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + name: "Sprint 1", + }); + }); + + it("prefers the active cycle when multiple match", async () => { + const client = mockGlobalClient([ + cycle({ id: "prev", isPrevious: true }), + cycle({ id: "active", isActive: true }), + cycle({ id: "next", isNext: true }), + ]); + const result = await resolveCycleId(client, "Sprint"); + expect(result).toBe("active"); + }); + + it("scopes to a team by resolving the team first", async () => { + const client = mockScopedClient([cycle({ id: "cycle-uuid" })]); + const result = await resolveCycleId(client, "Sprint 1", "ENG"); + expect(result).toBe("cycle-uuid"); + expect(client.request).toHaveBeenNthCalledWith(2, expect.anything(), { + name: "Sprint 1", + teamId: "team-uuid", + }); }); it("throws when cycle not found", async () => { - const client = mockSdkClient([]); + const client = mockGlobalClient([]); await expect(resolveCycleId(client, "Nonexistent")).rejects.toThrow(); }); + + it("throws when multiple cycles match without a clear preference", async () => { + const client = mockGlobalClient([ + cycle({ id: "a", team: { id: "t1", key: "ENG" } }), + cycle({ id: "b", team: { id: "t2", key: "OPS" } }), + ]); + await expect(resolveCycleId(client, "Sprint")).rejects.toThrow(/Multiple/i); + }); }); diff --git a/tests/unit/resolvers/initiative-resolver.test.ts b/tests/unit/resolvers/initiative-resolver.test.ts index ea6156a1..bd790d66 100644 --- a/tests/unit/resolvers/initiative-resolver.test.ts +++ b/tests/unit/resolvers/initiative-resolver.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; -import type { LinearSdkClient } from "../../../src/client/linear-client.js"; import { asUuid } from "../../../src/common/identifier.js"; import { resolveInitiativeId, @@ -13,12 +12,10 @@ type InitiativeLookupNode = { name: string; }; -function mockSdkClient(nodes: InitiativeLookupNode[]) { +function mockInitiativesClient(nodes: InitiativeLookupNode[]) { return { - sdk: { - initiatives: vi.fn().mockResolvedValue({ nodes }), - }, - } as unknown as LinearSdkClient; + request: vi.fn().mockResolvedValue({ initiatives: { nodes } }), + } as unknown as GraphQLClient; } function mockGqlClient(response: Record<string, unknown>) { @@ -41,59 +38,59 @@ function mockPagedGqlClient(responses: Array<Record<string, unknown>>) { describe("resolveInitiativeId", () => { it("returns UUID as-is", async () => { - const sdk = mockSdkClient([]); + const client = mockInitiativesClient([]); const result = await resolveInitiativeId( - sdk, + client, "550e8400-e29b-41d4-a716-446655440000", ); expect(result).toBe("550e8400-e29b-41d4-a716-446655440000"); - expect(sdk.sdk.initiatives).not.toHaveBeenCalled(); + expect(client.request).not.toHaveBeenCalled(); }); it("resolves initiative name", async () => { - const sdk = mockSdkClient([{ id: "init-1", name: "Growth" }]); + const client = mockInitiativesClient([{ id: "init-1", name: "Growth" }]); - await expect(resolveInitiativeId(sdk, "growth")).resolves.toBe("init-1"); - expect(sdk.sdk.initiatives).toHaveBeenCalledWith({ + await expect(resolveInitiativeId(client, "growth")).resolves.toBe("init-1"); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { filter: { name: { eqIgnoreCase: "growth" } }, first: 20, }); }); it("throws not found", async () => { - const sdk = mockSdkClient([]); + const client = mockInitiativesClient([]); - await expect(resolveInitiativeId(sdk, "Missing")).rejects.toThrow( + await expect(resolveInitiativeId(client, "Missing")).rejects.toThrow( 'Initiative "Missing" not found', ); }); it("throws ambiguity without scope", async () => { - const sdk = mockSdkClient([ + const client = mockInitiativesClient([ { id: "init-1", name: "Growth" }, { id: "init-2", name: "Growth" }, ]); - await expect(resolveInitiativeId(sdk, "Growth")).rejects.toThrow( + await expect(resolveInitiativeId(client, "Growth")).rejects.toThrow( "Multiple initiatives found matching", ); - await expect(resolveInitiativeId(sdk, "Growth")).rejects.toThrow( + await expect(resolveInitiativeId(client, "Growth")).rejects.toThrow( "provide --team or --owner, or use UUID", ); }); it("resolves scoped disambiguation", async () => { - const sdk = mockSdkClient([{ id: "init-2", name: "Growth" }]); + const client = mockInitiativesClient([{ id: "init-2", name: "Growth" }]); await expect( - resolveInitiativeId(sdk, "Growth", { + resolveInitiativeId(client, "Growth", { teamId: asUuid("team-1"), ownerId: asUuid("user-1"), }), ).resolves.toBe("init-2"); - expect(sdk.sdk.initiatives).toHaveBeenCalledWith({ + expect(client.request).toHaveBeenCalledWith(expect.anything(), { filter: { and: [ { name: { eqIgnoreCase: "Growth" } }, diff --git a/tests/unit/resolvers/issue-filter-resolver.test.ts b/tests/unit/resolvers/issue-filter-resolver.test.ts index ef1b6a13..4cf00c00 100644 --- a/tests/unit/resolvers/issue-filter-resolver.test.ts +++ b/tests/unit/resolvers/issue-filter-resolver.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { LinearSdkClient } from "../../../src/client/linear-client.js"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { resolveSearchFilterIds } from "../../../src/resolvers/issue-filter-resolver.js"; const { @@ -54,22 +54,22 @@ describe("resolveSearchFilterIds", () => { }); it("passes resolved team UUID to status/cycle lookups", async () => { - const sdk = {} as unknown as LinearSdkClient; + const gql = {} as unknown as GraphQLClient; resolveTeamIdMock.mockResolvedValue("team-uuid"); resolveStatusIdMock.mockResolvedValue("state-uuid"); resolveCycleIdMock.mockResolvedValue("cycle-uuid"); - const result = await resolveSearchFilterIds(sdk, { + const result = await resolveSearchFilterIds(gql, { team: "ENG", statusNames: ["Todo"], cycle: "Sprint 1", }); - expect(resolveTeamIdMock).toHaveBeenCalledWith(sdk, "ENG"); - expect(resolveStatusIdMock).toHaveBeenCalledWith(sdk, "Todo", "team-uuid"); + expect(resolveTeamIdMock).toHaveBeenCalledWith(gql, "ENG"); + expect(resolveStatusIdMock).toHaveBeenCalledWith(gql, "Todo", "team-uuid"); expect(resolveCycleIdMock).toHaveBeenCalledWith( - sdk, + gql, "Sprint 1", "team-uuid", ); @@ -81,17 +81,17 @@ describe("resolveSearchFilterIds", () => { }); it("falls back to raw team input for cycle lookup when team not pre-resolved", async () => { - const sdk = {} as unknown as LinearSdkClient; + const gql = {} as unknown as GraphQLClient; resolveCycleIdMock.mockResolvedValue("cycle-uuid"); - const result = await resolveSearchFilterIds(sdk, { + const result = await resolveSearchFilterIds(gql, { cycle: "Sprint 2", team: "Engineering", }); expect(resolveCycleIdMock).toHaveBeenCalledWith( - sdk, + gql, "Sprint 2", "Engineering", ); diff --git a/tests/unit/resolvers/issue-resolver.test.ts b/tests/unit/resolvers/issue-resolver.test.ts index a7342fd1..eab3159b 100644 --- a/tests/unit/resolvers/issue-resolver.test.ts +++ b/tests/unit/resolvers/issue-resolver.test.ts @@ -1,6 +1,6 @@ // tests/unit/resolvers/issue-resolver.test.ts import { describe, expect, it, vi } from "vitest"; -import type { LinearSdkClient } from "../../../src/client/linear-client.js"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { resolveIssueEstimateContext, resolveIssueId, @@ -8,16 +8,7 @@ import { type IssueNode = { id: string; - teamId?: string; - team?: - | { - id?: string; - key?: string; - } - | Promise<{ - id?: string; - key?: string; - }>; + team: { id: string; key: string }; }; type TeamNode = { @@ -34,13 +25,14 @@ type TeamNode = { issueEstimationAllowZero: boolean; }; -function mockSdkClient(issueNodes: IssueNode[], teamNodes: TeamNode[] = []) { - return { - sdk: { - issues: vi.fn().mockResolvedValue({ nodes: issueNodes }), - teams: vi.fn().mockResolvedValue({ nodes: teamNodes }), - }, - } as unknown as LinearSdkClient; +// The estimate-context resolver issues two requests: FindIssues, then FindTeams +// (via resolveTeamEstimateContext). resolveIssueId only issues the first. +function mockGqlClient(issueNodes: IssueNode[], teamNodes: TeamNode[] = []) { + const request = vi + .fn() + .mockResolvedValueOnce({ issues: { nodes: issueNodes } }) + .mockResolvedValueOnce({ teams: { nodes: teamNodes } }); + return { request } as unknown as GraphQLClient; } const teamId = "550e8400-e29b-41d4-a716-446655440001"; @@ -54,24 +46,34 @@ const exponentialTeam: TeamNode = { issueEstimationAllowZero: false, }; +const engIssue: IssueNode = { + id: "issue-uuid", + team: { id: teamId, key: "ENG" }, +}; + describe("resolveIssueId", () => { it("returns UUID as-is", async () => { - const client = mockSdkClient([]); + const client = mockGqlClient([]); const result = await resolveIssueId( client, "550e8400-e29b-41d4-a716-446655440000", ); expect(result).toBe("550e8400-e29b-41d4-a716-446655440000"); + expect(client.request).not.toHaveBeenCalled(); }); it("resolves ABC-123 identifier", async () => { - const client = mockSdkClient([{ id: "issue-uuid" }]); + const client = mockGqlClient([engIssue]); const result = await resolveIssueId(client, "ENG-42"); expect(result).toBe("issue-uuid"); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + filter: { number: { eq: 42 }, team: { key: { eq: "ENG" } } }, + first: 1, + }); }); it("throws when issue not found", async () => { - const client = mockSdkClient([]); + const client = mockGqlClient([]); await expect(resolveIssueId(client, "ENG-999")).rejects.toThrow( 'Issue "ENG-999" not found', ); @@ -79,11 +81,8 @@ describe("resolveIssueId", () => { }); describe("resolveIssueEstimateContext", () => { - it("resolves identifier, extracts teamId, delegates to team estimate resolver, and returns issueId plus team context", async () => { - const client = mockSdkClient( - [{ id: "issue-uuid", teamId }], - [exponentialTeam], - ); + it("resolves identifier, derives team from the issue, and returns issueId plus team context", async () => { + const client = mockGqlClient([engIssue], [exponentialTeam]); await expect( resolveIssueEstimateContext(client, "ENG-42"), @@ -99,137 +98,35 @@ describe("resolveIssueEstimateContext", () => { }, }); - expect(client.sdk.issues).toHaveBeenCalledWith({ - filter: { - number: { eq: 42 }, - team: { key: { eq: "ENG" } }, - }, + expect(client.request).toHaveBeenNthCalledWith(1, expect.anything(), { + filter: { number: { eq: 42 }, team: { key: { eq: "ENG" } } }, first: 1, }); - expect(client.sdk.teams).toHaveBeenCalledWith({ + expect(client.request).toHaveBeenNthCalledWith(2, expect.anything(), { filter: { id: { eq: teamId } }, first: 1, }); }); - it("resolves by UUID and uses sdk issues filter id eq", async () => { - const client = mockSdkClient( - [{ id: "issue-uuid", teamId }], - [exponentialTeam], - ); + it("resolves by UUID using an id eq filter", async () => { + const client = mockGqlClient([engIssue], [exponentialTeam]); await resolveIssueEstimateContext( client, "550e8400-e29b-41d4-a716-446655440000", ); - expect(client.sdk.issues).toHaveBeenCalledWith({ + expect(client.request).toHaveBeenNthCalledWith(1, expect.anything(), { filter: { id: { eq: "550e8400-e29b-41d4-a716-446655440000" } }, first: 1, }); }); - it("resolves identifier and uses sdk issues filter number plus team key", async () => { - const client = mockSdkClient( - [{ id: "issue-uuid", teamId }], - [exponentialTeam], - ); - - await resolveIssueEstimateContext(client, "ENG-42"); - - expect(client.sdk.issues).toHaveBeenCalledWith({ - filter: { - number: { eq: 42 }, - team: { key: { eq: "ENG" } }, - }, - first: 1, - }); - }); - - it("succeeds when issue node has no nested team estimation fields", async () => { - const client = mockSdkClient( - [ - { - id: "issue-uuid", - team: { - id: teamId, - key: "ENG", - }, - }, - ], - [exponentialTeam], - ); - - await expect( - resolveIssueEstimateContext(client, "ENG-42"), - ).resolves.toMatchObject({ - issueId: "issue-uuid", - team: { - teamId, - teamKey: "ENG", - }, - }); - }); - - it("falls back to async team relation id when teamId is absent", async () => { - const client = mockSdkClient( - [ - { - id: "issue-uuid", - team: Promise.resolve({ id: teamId, key: "ENG" }), - }, - ], - [exponentialTeam], - ); - - await expect( - resolveIssueEstimateContext(client, "ENG-42"), - ).resolves.toMatchObject({ - issueId: "issue-uuid", - team: { - teamId, - teamKey: "ENG", - }, - }); - - expect(client.sdk.teams).toHaveBeenCalledWith({ - filter: { id: { eq: teamId } }, - first: 1, - }); - }); - - it("falls back to async team relation key when relation id is absent", async () => { - const client = mockSdkClient( - [ - { - id: "issue-uuid", - team: Promise.resolve({ key: "ENG" }), - }, - ], - [exponentialTeam], - ); - - await resolveIssueEstimateContext(client, "ENG-42"); - - expect(client.sdk.teams).toHaveBeenCalledWith({ - filter: { key: { eq: "ENG" } }, - first: 1, - }); - }); - it("throws Issue not found", async () => { - const client = mockSdkClient([]); + const client = mockGqlClient([]); await expect( resolveIssueEstimateContext(client, "ENG-999"), ).rejects.toThrow('Issue "ENG-999" not found'); }); - - it("throws when issue team context is missing", async () => { - const client = mockSdkClient([{ id: "issue-uuid" }]); - - await expect(resolveIssueEstimateContext(client, "ENG-42")).rejects.toThrow( - 'Issue "ENG-42" is missing required team context', - ); - }); }); diff --git a/tests/unit/resolvers/label-resolver.test.ts b/tests/unit/resolvers/label-resolver.test.ts index 6f44d3b0..50720bfa 100644 --- a/tests/unit/resolvers/label-resolver.test.ts +++ b/tests/unit/resolvers/label-resolver.test.ts @@ -1,22 +1,20 @@ // tests/unit/resolvers/label-resolver.test.ts import { describe, expect, it, vi } from "vitest"; -import type { LinearSdkClient } from "../../../src/client/linear-client.js"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { resolveLabelId, resolveLabelIds, } from "../../../src/resolvers/label-resolver.js"; -function mockSdkClient(nodes: Array<{ id: string; name?: string }>) { +function mockGqlClient(nodes: Array<{ id: string; name?: string }>) { return { - sdk: { - issueLabels: vi.fn().mockResolvedValue({ nodes }), - }, - } as unknown as LinearSdkClient; + request: vi.fn().mockResolvedValue({ issueLabels: { nodes } }), + } as unknown as GraphQLClient; } describe("resolveLabelId", () => { it("returns UUID as-is", async () => { - const client = mockSdkClient([]); + const client = mockGqlClient([]); const result = await resolveLabelId( client, "550e8400-e29b-41d4-a716-446655440000", @@ -25,21 +23,21 @@ describe("resolveLabelId", () => { }); it("resolves label by name", async () => { - const client = mockSdkClient([{ id: "label-uuid" }]); + const client = mockGqlClient([{ id: "label-uuid" }]); const result = await resolveLabelId(client, "Bug"); expect(result).toBe("label-uuid"); - expect(client.sdk.issueLabels).toHaveBeenCalledWith({ + expect(client.request).toHaveBeenCalledWith(expect.anything(), { filter: { name: { eqIgnoreCase: "Bug" } }, first: 1, }); }); it("resolves workspace label by name", async () => { - const client = mockSdkClient([{ id: "label-uuid" }]); + const client = mockGqlClient([{ id: "label-uuid" }]); await resolveLabelId(client, "Bug", { scope: "workspace" }); - expect(client.sdk.issueLabels).toHaveBeenCalledWith({ + expect(client.request).toHaveBeenCalledWith(expect.anything(), { filter: { name: { eqIgnoreCase: "Bug" }, team: { null: true }, @@ -49,14 +47,14 @@ describe("resolveLabelId", () => { }); it("resolves team-scoped label by name", async () => { - const client = mockSdkClient([{ id: "label-uuid" }]); + const client = mockGqlClient([{ id: "label-uuid" }]); await resolveLabelId(client, "Bug", { teamId: "team-uuid", scope: "team", }); - expect(client.sdk.issueLabels).toHaveBeenCalledWith({ + expect(client.request).toHaveBeenCalledWith(expect.anything(), { filter: { name: { eqIgnoreCase: "Bug" }, team: { id: { eq: "team-uuid" }, null: false }, @@ -66,11 +64,11 @@ describe("resolveLabelId", () => { }); it("filters by team when teamId is provided without explicit scope", async () => { - const client = mockSdkClient([{ id: "label-uuid" }]); + const client = mockGqlClient([{ id: "label-uuid" }]); await resolveLabelId(client, "Bug", { teamId: "team-uuid" }); - expect(client.sdk.issueLabels).toHaveBeenCalledWith({ + expect(client.request).toHaveBeenCalledWith(expect.anything(), { filter: { name: { eqIgnoreCase: "Bug" }, team: { id: { eq: "team-uuid" } }, @@ -80,7 +78,7 @@ describe("resolveLabelId", () => { }); it("throws when label not found", async () => { - const client = mockSdkClient([]); + const client = mockGqlClient([]); await expect(resolveLabelId(client, "Nonexistent")).rejects.toThrow( 'Label "Nonexistent" not found', ); @@ -89,7 +87,7 @@ describe("resolveLabelId", () => { describe("resolveLabelIds", () => { it("resolves mixed UUIDs and names", async () => { - const client = mockSdkClient([{ id: "label-uuid" }]); + const client = mockGqlClient([{ id: "label-uuid" }]); const result = await resolveLabelIds(client, [ "550e8400-e29b-41d4-a716-446655440000", "Bug", diff --git a/tests/unit/resolvers/milestone-resolver.test.ts b/tests/unit/resolvers/milestone-resolver.test.ts index b1485c14..12a1d394 100644 --- a/tests/unit/resolvers/milestone-resolver.test.ts +++ b/tests/unit/resolvers/milestone-resolver.test.ts @@ -1,7 +1,6 @@ // tests/unit/resolvers/milestone-resolver.test.ts import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; -import type { LinearSdkClient } from "../../../src/client/linear-client.js"; import { resolveMilestoneId } from "../../../src/resolvers/milestone-resolver.js"; function mockGqlClient(...responses: Array<Record<string, unknown>>) { @@ -12,34 +11,38 @@ function mockGqlClient(...responses: Array<Record<string, unknown>>) { return { request } as unknown as GraphQLClient; } -function mockSdkClient() { - return { - sdk: { - projects: vi.fn().mockResolvedValue({ nodes: [{ id: "proj-uuid" }] }), - }, - } as unknown as LinearSdkClient; -} - describe("resolveMilestoneId", () => { it("returns UUID as-is", async () => { const gql = mockGqlClient(); - const sdk = mockSdkClient(); const result = await resolveMilestoneId( gql, - sdk, "550e8400-e29b-41d4-a716-446655440000", ); expect(result).toBe("550e8400-e29b-41d4-a716-446655440000"); + expect(gql.request).not.toHaveBeenCalled(); + }); + + it("resolves a project-scoped milestone by name", async () => { + const gql = mockGqlClient( + { projects: { nodes: [{ id: "proj-uuid" }] } }, + { + project: { + projectMilestones: { nodes: [{ id: "ms-uuid", name: "M1" }] }, + }, + }, + ); + const result = await resolveMilestoneId(gql, "M1", "My Project"); + expect(result).toBe("ms-uuid"); }); it("throws when milestone not found", async () => { const gql = mockGqlClient( + { projects: { nodes: [{ id: "proj-uuid" }] } }, { project: { projectMilestones: { nodes: [] } } }, { projectMilestones: { nodes: [] } }, ); - const sdk = mockSdkClient(); await expect( - resolveMilestoneId(gql, sdk, "Nonexistent", "My Project"), + resolveMilestoneId(gql, "Nonexistent", "My Project"), ).rejects.toThrow('Milestone "Nonexistent" not found'); }); }); diff --git a/tests/unit/resolvers/project-resolver.test.ts b/tests/unit/resolvers/project-resolver.test.ts index 0c0785cc..185a7d61 100644 --- a/tests/unit/resolvers/project-resolver.test.ts +++ b/tests/unit/resolvers/project-resolver.test.ts @@ -1,69 +1,64 @@ // tests/unit/resolvers/project-resolver.test.ts import { describe, expect, it, vi } from "vitest"; -import type { LinearSdkClient } from "../../../src/client/linear-client.js"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { resolveProjectId, resolveProjectLabelId, resolveProjectLabelIds, } from "../../../src/resolvers/project-resolver.js"; -function mockSdkClient(nodes: Array<{ id: string }>) { +function mockGqlClient(nodes: Array<{ id: string }>) { return { - sdk: { - projects: vi.fn().mockResolvedValue({ nodes }), - }, - } as unknown as LinearSdkClient; + request: vi.fn().mockResolvedValue({ projects: { nodes } }), + } as unknown as GraphQLClient; } -function mockSdkClientWithLabels(nodes: Array<{ id: string }>) { +function mockGqlClientWithLabels(nodes: Array<{ id: string }>) { return { - sdk: { - projectLabels: vi.fn().mockResolvedValue({ nodes }), - }, - } as unknown as LinearSdkClient; + request: vi.fn().mockResolvedValue({ projectLabels: { nodes } }), + } as unknown as GraphQLClient; } describe("resolveProjectId", () => { it("returns UUID as-is", async () => { - const client = mockSdkClient([]); + const client = mockGqlClient([]); const result = await resolveProjectId( client, "550e8400-e29b-41d4-a716-446655440000", ); expect(result).toBe("550e8400-e29b-41d4-a716-446655440000"); - expect(client.sdk.projects).not.toHaveBeenCalled(); + expect(client.request).not.toHaveBeenCalled(); }); it("resolves project by name", async () => { - const client = mockSdkClient([{ id: "proj-uuid" }]); + const client = mockGqlClient([{ id: "proj-uuid" }]); const result = await resolveProjectId(client, "Mobile App"); expect(result).toBe("proj-uuid"); }); it("includes archived projects when requested", async () => { - const client = mockSdkClient([{ id: "archived-proj-uuid" }]); + const client = mockGqlClient([{ id: "archived-proj-uuid" }]); const result = await resolveProjectId(client, "Archived Project", { includeArchived: true, }); expect(result).toBe("archived-proj-uuid"); - expect(client.sdk.projects).toHaveBeenCalledWith({ - filter: { name: { eqIgnoreCase: "Archived Project" } }, - first: 2, + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + name: "Archived Project", includeArchived: true, }); }); it("throws when project not found", async () => { - const client = mockSdkClient([]); + const client = mockGqlClient([]); await expect(resolveProjectId(client, "Nonexistent")).rejects.toThrow( 'Project "Nonexistent" not found', ); }); it("throws when multiple projects match same name", async () => { - const client = mockSdkClient([ + const client = mockGqlClient([ { id: "proj-uuid-1" }, { id: "proj-uuid-2" }, ]); @@ -78,23 +73,23 @@ describe("resolveProjectId", () => { describe("resolveProjectLabelId", () => { it("returns UUID as-is", async () => { - const client = mockSdkClientWithLabels([]); + const client = mockGqlClientWithLabels([]); const result = await resolveProjectLabelId( client, "550e8400-e29b-41d4-a716-446655440000", ); expect(result).toBe("550e8400-e29b-41d4-a716-446655440000"); - expect(client.sdk.projectLabels).not.toHaveBeenCalled(); + expect(client.request).not.toHaveBeenCalled(); }); it("resolves label by name", async () => { - const client = mockSdkClientWithLabels([{ id: "label-uuid" }]); + const client = mockGqlClientWithLabels([{ id: "label-uuid" }]); const result = await resolveProjectLabelId(client, "Q1-2025"); expect(result).toBe("label-uuid"); }); it("throws when label not found", async () => { - const client = mockSdkClientWithLabels([]); + const client = mockGqlClientWithLabels([]); await expect(resolveProjectLabelId(client, "Nonexistent")).rejects.toThrow( 'Project label "Nonexistent" not found', ); @@ -103,7 +98,7 @@ describe("resolveProjectLabelId", () => { describe("resolveProjectLabelIds", () => { it("resolves mixed UUIDs and names", async () => { - const client = mockSdkClientWithLabels([{ id: "label-uuid" }]); + const client = mockGqlClientWithLabels([{ id: "label-uuid" }]); const result = await resolveProjectLabelIds(client, [ "550e8400-e29b-41d4-a716-446655440000", "Q1-2025", diff --git a/tests/unit/resolvers/status-resolver.test.ts b/tests/unit/resolvers/status-resolver.test.ts index 63f88839..18b96038 100644 --- a/tests/unit/resolvers/status-resolver.test.ts +++ b/tests/unit/resolvers/status-resolver.test.ts @@ -1,21 +1,19 @@ // tests/unit/resolvers/status-resolver.test.ts import { describe, expect, it, vi } from "vitest"; -import type { LinearSdkClient } from "../../../src/client/linear-client.js"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { asUuid } from "../../../src/common/identifier.js"; import { resolveStatusId } from "../../../src/resolvers/status-resolver.js"; -function mockSdkClient(nodes: Array<{ id: string }>) { +function mockGqlClient(nodes: Array<{ id: string }>) { return { - sdk: { - workflowStates: vi.fn().mockResolvedValue({ nodes }), - }, - } as unknown as LinearSdkClient; + request: vi.fn().mockResolvedValue({ workflowStates: { nodes } }), + } as unknown as GraphQLClient; } describe("resolveStatusId", () => { it("returns UUID as-is", async () => { - const client = mockSdkClient([]); + const client = mockGqlClient([]); const result = await resolveStatusId( client, "550e8400-e29b-41d4-a716-446655440000", @@ -24,15 +22,15 @@ describe("resolveStatusId", () => { }); it("resolves status by name", async () => { - const client = mockSdkClient([{ id: "status-uuid" }]); + const client = mockGqlClient([{ id: "status-uuid" }]); const result = await resolveStatusId(client, "In Progress"); expect(result).toBe("status-uuid"); }); it("resolves status by name with team context", async () => { - const client = mockSdkClient([{ id: "status-uuid" }]); + const client = mockGqlClient([{ id: "status-uuid" }]); await resolveStatusId(client, "In Progress", asUuid("team-uuid")); - expect(client.sdk.workflowStates).toHaveBeenCalledWith({ + expect(client.request).toHaveBeenCalledWith(expect.anything(), { filter: { name: { eqIgnoreCase: "In Progress" }, team: { id: { eq: "team-uuid" } }, @@ -42,7 +40,7 @@ describe("resolveStatusId", () => { }); it("throws when status not found", async () => { - const client = mockSdkClient([]); + const client = mockGqlClient([]); await expect(resolveStatusId(client, "Nonexistent")).rejects.toThrow( 'Status "Nonexistent" not found', ); diff --git a/tests/unit/resolvers/team-resolver.test.ts b/tests/unit/resolvers/team-resolver.test.ts index f5ef4d6b..714d37a0 100644 --- a/tests/unit/resolvers/team-resolver.test.ts +++ b/tests/unit/resolvers/team-resolver.test.ts @@ -1,63 +1,69 @@ // tests/unit/resolvers/team-resolver.test.ts import { describe, expect, it, vi } from "vitest"; -import type { LinearSdkClient } from "../../../src/client/linear-client.js"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { resolveTeamEstimateContext, resolveTeamId, } from "../../../src/resolvers/team-resolver.js"; -function mockSdkClient( - ...callResults: Array<{ - nodes: Array<{ - id: string; - key?: string; - name?: string; - issueEstimationType?: - | "notUsed" - | "exponential" - | "fibonacci" - | "linear" - | "tShirt"; - issueEstimationExtended?: boolean; - issueEstimationAllowZero?: boolean; - }>; - }> -) { - const teams = vi.fn(); +type TeamLookupNode = { + id: string; + key?: string; + name?: string; + issueEstimationType?: + | "notUsed" + | "exponential" + | "fibonacci" + | "linear" + | "tShirt"; + issueEstimationExtended?: boolean; + issueEstimationAllowZero?: boolean; +}; + +function mockGqlClient(...callResults: Array<{ nodes: TeamLookupNode[] }>) { + const request = vi.fn(); for (const result of callResults) { - teams.mockResolvedValueOnce(result); + request.mockResolvedValueOnce({ teams: result }); } - return { sdk: { teams } } as unknown as LinearSdkClient; + return { request } as unknown as GraphQLClient; } describe("resolveTeamId", () => { - it("returns UUID as-is without calling SDK", async () => { - const client = mockSdkClient(); + it("returns UUID as-is without querying", async () => { + const client = mockGqlClient(); const result = await resolveTeamId( client, "550e8400-e29b-41d4-a716-446655440000", ); expect(result).toBe("550e8400-e29b-41d4-a716-446655440000"); - expect(client.sdk.teams).not.toHaveBeenCalled(); + expect(client.request).not.toHaveBeenCalled(); }); it("resolves team by key", async () => { - const client = mockSdkClient({ nodes: [{ id: "uuid-1", key: "ENG" }] }); + const client = mockGqlClient({ nodes: [{ id: "uuid-1", key: "ENG" }] }); const result = await resolveTeamId(client, "ENG"); expect(result).toBe("uuid-1"); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + filter: { key: { eq: "ENG" } }, + first: 1, + }); }); it("falls back to name when key not found", async () => { - const client = mockSdkClient( + const client = mockGqlClient( { nodes: [] }, { nodes: [{ id: "uuid-2", name: "Engineering" }] }, ); const result = await resolveTeamId(client, "Engineering"); expect(result).toBe("uuid-2"); + expect(client.request).toHaveBeenNthCalledWith(2, expect.anything(), { + filter: { name: { eq: "Engineering" } }, + first: 1, + }); }); it("throws when team not found by key or name", async () => { - const client = mockSdkClient({ nodes: [] }, { nodes: [] }); + const client = mockGqlClient({ nodes: [] }, { nodes: [] }); await expect(resolveTeamId(client, "NOPE")).rejects.toThrow( 'Team "NOPE" not found', ); @@ -66,7 +72,7 @@ describe("resolveTeamId", () => { describe("resolveTeamEstimateContext", () => { it("resolves by key with full context fields", async () => { - const client = mockSdkClient({ + const client = mockGqlClient({ nodes: [ { id: "uuid-1", @@ -89,8 +95,8 @@ describe("resolveTeamEstimateContext", () => { }); }); - it("resolves by UUID and queries sdk with id eq filter", async () => { - const client = mockSdkClient({ + it("resolves by UUID and queries with id eq filter", async () => { + const client = mockGqlClient({ nodes: [ { id: "team-uuid", @@ -109,14 +115,14 @@ describe("resolveTeamEstimateContext", () => { ); expect(result.teamId).toBe("team-uuid"); - expect(client.sdk.teams).toHaveBeenCalledWith({ + expect(client.request).toHaveBeenCalledWith(expect.anything(), { filter: { id: { eq: "550e8400-e29b-41d4-a716-446655440000" } }, first: 1, }); }); it("falls back to name when key lookup misses", async () => { - const client = mockSdkClient( + const client = mockGqlClient( { nodes: [] }, { nodes: [ @@ -143,11 +149,11 @@ describe("resolveTeamEstimateContext", () => { issueEstimationAllowZero: true, }); - expect(client.sdk.teams).toHaveBeenNthCalledWith(1, { + expect(client.request).toHaveBeenNthCalledWith(1, expect.anything(), { filter: { key: { eq: "Engineering" } }, first: 1, }); - expect(client.sdk.teams).toHaveBeenNthCalledWith(2, { + expect(client.request).toHaveBeenNthCalledWith(2, expect.anything(), { filter: { name: { eq: "Engineering" } }, first: 1, }); @@ -155,21 +161,21 @@ describe("resolveTeamEstimateContext", () => { it("throws not found for UUID when id lookup has no nodes and does not fallback", async () => { const teamId = "550e8400-e29b-41d4-a716-446655440000"; - const client = mockSdkClient({ nodes: [] }); + const client = mockGqlClient({ nodes: [] }); await expect(resolveTeamEstimateContext(client, teamId)).rejects.toThrow( `Team "${teamId}" not found`, ); - expect(client.sdk.teams).toHaveBeenCalledTimes(1); - expect(client.sdk.teams).toHaveBeenCalledWith({ + expect(client.request).toHaveBeenCalledTimes(1); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { filter: { id: { eq: teamId } }, first: 1, }); }); it("throws not found when no nodes", async () => { - const client = mockSdkClient({ nodes: [] }, { nodes: [] }); + const client = mockGqlClient({ nodes: [] }, { nodes: [] }); await expect(resolveTeamEstimateContext(client, "NOPE")).rejects.toThrow( 'Team "NOPE" not found', ); diff --git a/tests/unit/resolvers/user-resolver.test.ts b/tests/unit/resolvers/user-resolver.test.ts index e66ab16a..d794ec7c 100644 --- a/tests/unit/resolvers/user-resolver.test.ts +++ b/tests/unit/resolvers/user-resolver.test.ts @@ -1,6 +1,6 @@ // tests/unit/resolvers/user-resolver.test.ts import { describe, expect, it, vi } from "vitest"; -import type { LinearSdkClient } from "../../../src/client/linear-client.js"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { resolveUserId } from "../../../src/resolvers/user-resolver.js"; interface MockUser { @@ -9,41 +9,41 @@ interface MockUser { email?: string; } -function mockSdkClient(...callResults: Array<{ nodes: MockUser[] }>) { - const users = vi.fn(); +function mockGqlClient(...callResults: Array<{ nodes: MockUser[] }>) { + const request = vi.fn(); for (const result of callResults) { - users.mockResolvedValueOnce(result); + request.mockResolvedValueOnce({ users: result }); } - return { sdk: { users } } as unknown as LinearSdkClient; + return { request } as unknown as GraphQLClient; } describe("resolveUserId", () => { - it("returns UUID as-is without calling SDK", async () => { - const client = mockSdkClient(); + it("returns UUID as-is without querying", async () => { + const client = mockGqlClient(); const result = await resolveUserId( client, "550e8400-e29b-41d4-a716-446655440000", ); expect(result).toBe("550e8400-e29b-41d4-a716-446655440000"); - expect(client.sdk.users).not.toHaveBeenCalled(); + expect(client.request).not.toHaveBeenCalled(); }); it("resolves user by display name", async () => { - const client = mockSdkClient({ + const client = mockGqlClient({ nodes: [ { id: "user-uuid-1", name: "John Doe", email: "john@example.com" }, ], }); const result = await resolveUserId(client, "John Doe"); expect(result).toBe("user-uuid-1"); - expect(client.sdk.users).toHaveBeenCalledWith({ + expect(client.request).toHaveBeenCalledWith(expect.anything(), { filter: { displayName: { eqIgnoreCase: "John Doe" } }, first: 10, }); }); it("falls back to email when name not found", async () => { - const client = mockSdkClient( + const client = mockGqlClient( { nodes: [] }, { nodes: [{ id: "user-uuid-2", name: "Jane", email: "jane@example.com" }], @@ -51,22 +51,22 @@ describe("resolveUserId", () => { ); const result = await resolveUserId(client, "jane@example.com"); expect(result).toBe("user-uuid-2"); - expect(client.sdk.users).toHaveBeenCalledTimes(2); - expect(client.sdk.users).toHaveBeenNthCalledWith(2, { + expect(client.request).toHaveBeenCalledTimes(2); + expect(client.request).toHaveBeenNthCalledWith(2, expect.anything(), { filter: { email: { eqIgnoreCase: "jane@example.com" } }, first: 1, }); }); it("throws when user not found by name or email", async () => { - const client = mockSdkClient({ nodes: [] }, { nodes: [] }); + const client = mockGqlClient({ nodes: [] }, { nodes: [] }); await expect(resolveUserId(client, "Nobody")).rejects.toThrow( 'User "Nobody" not found', ); }); it("throws when multiple users match by name", async () => { - const client = mockSdkClient({ + const client = mockGqlClient({ nodes: [ { id: "user-1", name: "Alex Smith", email: "alex1@example.com" }, { id: "user-2", name: "Alex Smith", email: "alex2@example.com" }, From baa7e9232585ebee4d82e13e7c89a0fb7a3401bc Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:23:49 +0200 Subject: [PATCH 63/79] docs: reflect Linear SDK removal in docs and test mocks Update architecture docs, dependency lists, and command test context mocks to describe the GraphQLClient-only design after the Linear SDK was dropped from resolvers. Closes #210 --- AGENTS.md | 40 ++++++++++--------- docs/architecture.md | 27 +++++-------- docs/build-system.md | 5 ++- docs/development.md | 52 ++++++++++++++----------- docs/files.md | 11 +++--- docs/project-overview.md | 8 ++-- docs/testing.md | 46 +++++++++++----------- src/common/resolve-filters.ts | 2 +- tests/unit/commands/attachments.test.ts | 1 - tests/unit/commands/comments.test.ts | 1 - tests/unit/commands/documents.test.ts | 1 - tests/unit/commands/initiatives.test.ts | 1 - 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 - 16 files changed, 95 insertions(+), 104 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index eb504b6b..4605c8b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,16 +42,16 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full commit types table and examp ``` CLI Input → Command → Resolver → Service → JSON Output │ │ │ - createContext SDK GraphQL + createContext GraphQL GraphQL (UUID) (data) ``` | Layer | Directory | Client | Responsibility | |-----------|-----------------|---------------------|--------------------------------------| | Client | `src/client/` | — | Thin API wrappers, no logic | -| Resolver | `src/resolvers/` | `LinearSdkClient` | Human ID → UUID conversion | +| Resolver | `src/resolvers/` | `GraphQLClient` | Human ID → UUID conversion | | Service | `src/services/` | `GraphQLClient` | Business logic, CRUD via GraphQL | -| Command | `src/commands/` | Both via `createContext()` | CLI orchestration only | +| Command | `src/commands/` | `GraphQLClient` via `createContext()` | CLI orchestration only | | Common | `src/common/` | — | Shared types, errors, output, auth | ### Invariants (P0 — violations fail CI/review) @@ -59,12 +59,12 @@ CLI Input → Command → Resolver → Service → JSON Output 1. **No `any` types.** Use `unknown`, codegen types, or explicit interfaces. 2. **Strict layer separation.** No cross-layer imports: - Resolvers must not import services (or vice versa). - - Commands must not import `GraphQLClient` directly. + - Commands must not construct `GraphQLClient` directly — use `createContext()`. 3. **Client-layer contract:** - - Resolvers → `LinearSdkClient` by default. - - Services → `GraphQLClient` only. - - Commands → both, via `createContext()`. - - **Narrow exceptions allowed only when SDK lacks required capability**, with explicit `ARCHITECTURAL EXCEPTION` docstring in code (current examples: milestone/project-status lookups, initiative relation/link ID lookup helpers). + - Resolvers → `GraphQLClient` (via lean filter-based lookup queries). + - Services → `GraphQLClient`. + - Commands → `GraphQLClient` via `createContext()` (`ctx.gql`). + - Resolvers should prefer the lean lookup fragments; when the Linear API exposes no lean lookup for an entity, a resolver may query it directly with an explicit `ARCHITECTURAL EXCEPTION` docstring (current examples: milestone/project-status lookups, initiative relation/link ID lookup helpers). 4. **ID resolution happens once**, in resolvers only. Services accept UUIDs. 5. **All commands** use `handleCommand()` wrapper and `outputSuccess()` for output. 6. **Explicit return types** on all exported functions. @@ -84,9 +84,9 @@ Need a new GraphQL operation? Need to resolve a human-friendly ID? → Add/edit src/resolvers/*-resolver.ts - → Prefer LinearSdkClient, return UUID string - → Pattern: UUID passthrough → SDK lookup → notFoundError() - → If SDK cannot express lookup, use GraphQL as documented ARCHITECTURAL EXCEPTION (include rationale in resolver docstring) + → Use GraphQLClient with a lean lookup query, return UUID string + → Pattern: UUID passthrough → GraphQL filter lookup → notFoundError() + → If no lean lookup fragment exists, query directly as a documented ARCHITECTURAL EXCEPTION (include rationale in resolver docstring) Need business logic / CRUD? → Add/edit src/services/*-service.ts @@ -110,7 +110,7 @@ Tests mirror `src/` structure under `tests/unit/`. Mock the dependency one layer | Test target | Mock | Example | |-------------|------|---------| -| Resolver | `LinearSdkClient` (mock `sdk.*`) | `{ sdk: { teams: vi.fn() } } as unknown as LinearSdkClient` | +| Resolver | `GraphQLClient` (mock `request`) | `{ request: vi.fn() } as unknown as GraphQLClient` | | Service | `GraphQLClient` (mock `request`) | `{ request: vi.fn() } as unknown as GraphQLClient` | | Common | No mocks (pure functions) | Direct import + assert | @@ -136,12 +136,14 @@ async function createIssue(client: GraphQLClient, teamName: string) { async function createIssue(client: GraphQLClient, input: { teamId: string }) { ... } ``` -**Wrong client in layer:** +**ID resolution in command:** ```typescript -// WRONG: resolver uses GraphQLClient -async function resolveTeamId(client: GraphQLClient) { ... } -// RIGHT: resolver uses LinearSdkClient -async function resolveTeamId(client: LinearSdkClient) { ... } +// WRONG: command builds a raw mutation instead of delegating +async function resolveTeamId(client: GraphQLClient, teamName: string) { + const teamId = /* inline lookup in the command */; +} +// RIGHT: resolvers own ID resolution, services own CRUD +async function resolveTeamId(client: GraphQLClient, teamName: string): Promise<UUID> { ... } ``` **Business logic in command:** @@ -152,7 +154,7 @@ async function resolveTeamId(client: LinearSdkClient) { ... } })) // RIGHT: command delegates .action(handleCommand(async (title, opts) => { - const teamId = await resolveTeamId(ctx.sdk, opts.team); + const teamId = await resolveTeamId(ctx.gql, opts.team); const result = await createIssue(ctx.gql, { title, teamId }); outputSuccess(result); })) @@ -195,7 +197,7 @@ Registration checklist: ``` src/ main.ts # entry point, command registration - client/ # GraphQLClient, LinearSdkClient + client/ # GraphQLClient resolvers/ # ID resolution (human → UUID) services/ # business logic (GraphQL CRUD) commands/ # CLI definitions (Commander.js) diff --git a/docs/architecture.md b/docs/architecture.md index 918af886..d8c9477e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,13 +2,13 @@ Linearis follows a modular, five-layer architecture with clear separation of concerns. The application uses a command-based structure with Commander.js, typed GraphQL operations, standalone resolver functions, and service functions that eliminate code duplication. -The architecture emphasizes performance through GraphQL batch operations, single-query optimizations, and smart ID resolution for user convenience. All components are fully typed with TypeScript - no `any` types in the new architecture. The system uses both direct GraphQL queries (via typed client) and Linear SDK (for ID resolution). +The architecture emphasizes performance through GraphQL batch operations, single-query optimizations, and smart ID resolution for user convenience. All components are fully typed with TypeScript - no `any` types in the new architecture. Every layer talks to the Linear API through a single typed GraphQL client — both ID resolution and data operations. ## Five-Layer Architecture ### 1. Client Layer (`src/client/`) -Thin wrappers around GraphQL and Linear SDK with no business logic. +Thin wrapper around the Linear GraphQL API with no business logic. - **graphql-client.ts** - Typed GraphQL client - Takes `DocumentNode` from codegen @@ -16,11 +16,6 @@ Thin wrappers around GraphQL and Linear SDK with no business logic. - Handles error transformation - No ID resolution or business logic -- **linear-client.ts** - Linear SDK wrapper - - Simple wrapper exposing `sdk` property - - Used by resolvers for lookups - - No business logic - ### 2. Resolver Layer (`src/resolvers/`) Pure functions that convert human-friendly identifiers to UUIDs. @@ -34,10 +29,10 @@ Pure functions that convert human-friendly identifiers to UUIDs. - **issue-resolver.ts** - `resolveIssueId(client, issueIdOrIdentifier)` - Parses ABC-123 format - **status-resolver.ts** - `resolveStatusId(client, nameOrId, teamId?)` - **cycle-resolver.ts** - `resolveCycleId(client, nameOrId, teamFilter?)` - Complex disambiguation -- **milestone-resolver.ts** - `resolveMilestoneId(gqlClient, sdkClient, nameOrId, projectNameOrId?)` +- **milestone-resolver.ts** - `resolveMilestoneId(gqlClient, nameOrId, projectNameOrId?)` **Pattern:** -- Accept SDK or GraphQL client +- Accept the `GraphQLClient` - Check if input is UUID (early return) - Query Linear API for name/key match - Throw descriptive error if not found @@ -60,7 +55,7 @@ Pure, typed functions for CRUD operations. Receive pre-resolved UUIDs. - **file-service.ts** - File upload/download operations **Pattern:** -- Accept `GraphQLClient` or `LinearSdkClient` +- Accept `GraphQLClient` - Take pre-resolved UUIDs in inputs - Use codegen `DocumentNode` types - Return typed results @@ -91,8 +86,8 @@ Thin orchestration layer that composes resolvers and services. const ctx = await createContext(command.parent!.parent!.opts()); // Resolve IDs - const teamId = await resolveTeamId(ctx.sdk, options.team); - const labelIds = await resolveLabelIds(ctx.sdk, options.labels.split(',')); + const teamId = await resolveTeamId(ctx.gql, options.team); + const labelIds = await resolveLabelIds(ctx.gql, options.labels.split(',')); // Call service const result = await createIssue(ctx.gql, { @@ -111,7 +106,7 @@ Thin orchestration layer that composes resolvers and services. Shared utilities used across layers. -- **context.ts** - `createContext(options)` - Creates `{ gql, sdk }` from auth +- **context.ts** - `createContext(options)` - Creates `{ gql }` from auth - **auth.ts** - `resolveApiToken(options)` - Multi-source authentication (flag, env, encrypted storage, legacy file) - **output.ts** - `outputSuccess(data)`, `outputError(error)`, `handleCommand(fn)` - **errors.ts** - `notFoundError()`, `multipleMatchesError()`, `invalidParameterError()` @@ -140,7 +135,6 @@ Shared utilities used across layers. ### Client Layer - API Wrappers - **src/client/graphql-client.ts** - Typed GraphQL client with error handling -- **src/client/linear-client.ts** - Linear SDK wrapper ### Resolver Layer - ID Resolution @@ -170,7 +164,6 @@ Shared utilities used across layers. **Client Layer** - src/client/graphql-client.ts - GraphQLClient class with typed request method -- src/client/linear-client.ts - LinearSdkClient wrapper **Resolver Layer** @@ -202,9 +195,9 @@ Shared utilities used across layers. ### Command Execution Flow 1. **Command Parsing** - src/main.ts parses CLI arguments via Commander.js -2. **Context Creation** - src/common/context.ts creates `{ gql, sdk }` from auth options +2. **Context Creation** - src/common/context.ts creates `{ gql }` from auth options 3. **Authentication** - src/common/auth.ts resolves API token from multiple sources -4. **ID Resolution** - src/resolvers/* convert human inputs to UUIDs via SDK +4. **ID Resolution** - src/resolvers/* convert human inputs to UUIDs via GraphQL 5. **Service Operations** - src/services/* execute typed GraphQL operations 6. **Response Formatting** - src/common/output.ts outputs structured JSON diff --git a/docs/build-system.md b/docs/build-system.md index 092cb10f..ccc89b1d 100644 --- a/docs/build-system.md +++ b/docs/build-system.md @@ -135,8 +135,9 @@ npm run test:commands # Run command coverage analysis | Package | Version | Purpose | |---|---|---| -| `@linear/sdk` | ^58.1.0 | Linear API SDK for ID resolution | -| `commander` | ^14.0.0 | CLI argument parsing | +| `commander` | 14.0.3 | CLI argument parsing | +| `graphql` | 16.12.0 | GraphQL document parsing/types for the typed client | +| `node-emoji` | 2.2.0 | Emoji shortcode handling | ### Development diff --git a/docs/development.md b/docs/development.md index b733ca62..631c6c82 100644 --- a/docs/development.md +++ b/docs/development.md @@ -33,19 +33,19 @@ The codebase is organized into five layers, each with a single responsibility: ``` CLI Input --> Command --> Resolver --> Service --> JSON Output | | - SDK client GraphQL client + GraphQL client GraphQL client (ID lookup) (data operations) ``` | Layer | Directory | Client | Responsibility | |-------|-----------|--------|----------------| -| Client | `src/client/` | -- | API client wrappers | -| Resolver | `src/resolvers/` | `LinearSdkClient` | Convert human IDs to UUIDs | +| Client | `src/client/` | -- | API client wrapper | +| Resolver | `src/resolvers/` | `GraphQLClient` | Convert human IDs to UUIDs | | Service | `src/services/` | `GraphQLClient` | Business logic and CRUD | -| Command | `src/commands/` | Both (via `createContext()`) | CLI orchestration | +| Command | `src/commands/` | `GraphQLClient` (via `createContext()`) | CLI orchestration | | Common | `src/common/` | -- | Shared utilities and types | -Two separate clients exist because the Linear SDK is convenient for ID lookups (resolvers), while direct GraphQL queries are more efficient for data operations (services). Commands get both clients through `createContext()`. +A single typed GraphQL client backs every layer: resolvers use lean filter-based lookup queries for ID resolution, while services use richer queries for data operations. Commands get the client through `createContext()` as `ctx.gql`. ## Code Style @@ -90,7 +90,7 @@ export function setupIssuesCommands(program: Command): void { .action(handleCommand(async (title, options, command) => { const ctx = await createContext(command.parent!.parent!.opts()); const teamId = options.team - ? await resolveTeamId(ctx.sdk, options.team) + ? await resolveTeamId(ctx.gql, options.team) : undefined; const result = await createIssue(ctx.gql, { title, teamId }); outputSuccess(result); @@ -109,38 +109,44 @@ setupEntityCommands(program); ### Resolver Pattern -Resolvers convert human-friendly identifiers (team keys, names, issue identifiers like `ENG-123`) into UUIDs. They use the `LinearSdkClient` and live in `src/resolvers/`. +Resolvers convert human-friendly identifiers (team keys, names, issue identifiers like `ENG-123`) into UUIDs. They use the `GraphQLClient` with lean filter-based lookup queries and live in `src/resolvers/`. ```typescript -import type { LinearSdkClient } from "../client/linear-client.js"; -import { isUuid } from "../common/identifier.js"; +import type { GraphQLClient } from "../client/graphql-client.js"; +import { notFoundError } from "../common/errors.js"; +import { asUuid, isUuid, type UUID } from "../common/identifier.js"; +import { FindTeamsDocument } from "../gql/graphql.js"; export async function resolveTeamId( - client: LinearSdkClient, + client: GraphQLClient, keyOrNameOrId: string, -): Promise<string> { - if (isUuid(keyOrNameOrId)) return keyOrNameOrId; +): Promise<UUID> { + if (isUuid(keyOrNameOrId)) return asUuid(keyOrNameOrId); - const byKey = await client.sdk.teams({ + // Try by key first + const byKey = await client.request(FindTeamsDocument, { filter: { key: { eq: keyOrNameOrId } }, first: 1, }); - if (byKey.nodes.length > 0) return byKey.nodes[0].id; + const [byKeyMatch] = byKey.teams.nodes; + if (byKeyMatch) return asUuid(byKeyMatch.id); - const byName = await client.sdk.teams({ + // Fall back to name + const byName = await client.request(FindTeamsDocument, { filter: { name: { eq: keyOrNameOrId } }, first: 1, }); - if (byName.nodes.length > 0) return byName.nodes[0].id; + const [byNameMatch] = byName.teams.nodes; + if (byNameMatch) return asUuid(byNameMatch.id); - throw new Error(`Team "${keyOrNameOrId}" not found`); + throw notFoundError("Team", keyOrNameOrId); } ``` Rules for resolvers: - Always accept a UUID passthrough as the first check. - Return a UUID string, never an object. -- Use `LinearSdkClient` only (not `GraphQLClient`). +- Use `GraphQLClient` with a lean lookup query; do not import services. - No CRUD operations or data transformations. ### Service Pattern @@ -180,7 +186,7 @@ export async function createIssue( ``` Rules for services: -- Use `GraphQLClient` only (not `LinearSdkClient`). +- Use `GraphQLClient` (the only client). - Accept UUIDs, not human-friendly identifiers. - Import `DocumentNode` constants and types from `src/gql/graphql.js`. - Always type the `client.request<T>()` call. @@ -299,7 +305,7 @@ A typical feature addition touches four layers. Here is the sequence: 1. **GraphQL operations** -- Define queries and mutations in `graphql/queries/` or `graphql/mutations/`, then run `npm run generate`. -2. **Resolver** (if new entity types need ID resolution) -- Add a `resolve*Id()` function in `src/resolvers/`. Use `LinearSdkClient`, return a UUID string. +2. **Resolver** (if new entity types need ID resolution) -- Add a `resolve*Id()` function in `src/resolvers/`. Use `GraphQLClient` with a lean lookup query, return a UUID string. 3. **Service** -- Add functions in `src/services/`. Use `GraphQLClient`, accept UUIDs, import codegen types. @@ -353,7 +359,6 @@ src/ main.ts # Entry point, registers all command groups client/ graphql-client.ts # GraphQLClient - direct GraphQL execution - linear-client.ts # LinearSdkClient - SDK wrapper for resolvers resolvers/ # Human ID to UUID resolution team-resolver.ts project-resolver.ts @@ -403,7 +408,7 @@ graphql/ mutations/ # GraphQL mutation definitions tests/ unit/ - resolvers/ # Resolver tests (mock SDK) + resolvers/ # Resolver tests (mock GraphQLClient) services/ # Service tests (mock GraphQL) common/ # Pure function tests ``` @@ -411,8 +416,9 @@ tests/ ## Dependencies **Runtime:** -- `@linear/sdk` -- Linear SDK, used by resolvers for ID lookups - `commander` -- CLI framework +- `graphql` -- GraphQL document parsing/types for the typed client +- `node-emoji` -- emoji shortcode handling **Development:** - `typescript` -- Compiler diff --git a/docs/files.md b/docs/files.md index 9b0f5922..788e6edf 100644 --- a/docs/files.md +++ b/docs/files.md @@ -8,14 +8,13 @@ A reference of every file in the Linearis codebase, organized by architectural l ## Client Layer (`src/client/`) -Thin wrappers around the Linear API. No business logic. +Thin wrapper around the Linear API. No business logic. - **graphql-client.ts** -- `GraphQLClient` class with a typed `request<TResult>(document: DocumentNode, variables?: Record<string, unknown>)` method for direct GraphQL execution. -- **linear-client.ts** -- `LinearSdkClient` wrapper exposing a readonly `sdk: LinearClient` property for SDK-based lookups. ## Resolver Layer (`src/resolvers/`) -Each resolver converts a human-friendly identifier (name, key, or slug) into a UUID. Resolvers use `LinearSdkClient` exclusively. +Each resolver converts a human-friendly identifier (name, key, or slug) into a UUID. Resolvers use `GraphQLClient` with lean filter-based lookup queries. - **team-resolver.ts** -- `resolveTeamId(client, keyOrNameOrId)` - **project-resolver.ts** -- `resolveProjectId(client, nameOrId)` @@ -61,7 +60,7 @@ CLI orchestration. Each file registers a command group via a `setup*Commands(pro Shared utilities used across all layers. -- **context.ts** -- `CommandContext` interface and `createContext()` factory that produces both `GraphQLClient` and `LinearSdkClient`. +- **context.ts** -- `CommandContext` interface and `createContext()` factory that produces the `GraphQLClient` (`ctx.gql`). - **auth.ts** -- `resolveApiToken()` with multi-source lookup: `--api-token` flag, `LINEAR_API_TOKEN` env var, `~/.linearis/token` (encrypted), `~/.linear_api_token` (deprecated). - **token-storage.ts** -- `saveToken()`, `getStoredToken()`, `clearToken()` for encrypted token storage in `~/.linearis/token`. - **encryption.ts** -- AES-256-CBC encryption for token storage. @@ -103,7 +102,7 @@ Source `.graphql` files that feed into code generation. ## Tests (`tests/`) -Unit tests mirror the source structure. Resolver tests mock the SDK client; service tests mock the GraphQL client; common tests require no mocks. +Unit tests mirror the source structure. Resolver and service tests both mock the `GraphQLClient` (`request`); common tests require no mocks. ``` tests/unit/ @@ -138,7 +137,7 @@ tests/unit/ ``` CLI Input --> Command --> Resolver --> Service --> JSON Output | | | - createContext() SDK GraphQL + createContext() GraphQL GraphQL (name->UUID) (CRUD) ``` diff --git a/docs/project-overview.md b/docs/project-overview.md index 027cebf9..02f2ba56 100644 --- a/docs/project-overview.md +++ b/docs/project-overview.md @@ -10,10 +10,10 @@ The codebase follows a five-layer architecture. Each layer has a specific respon | Layer | Directory | Responsibility | Client | |-------|-----------|---------------|--------| -| Client | `src/client/` | Low-level API wrappers | -- | -| Resolver | `src/resolvers/` | Human ID to UUID conversion | LinearSdkClient | +| Client | `src/client/` | Low-level API wrapper | -- | +| Resolver | `src/resolvers/` | Human ID to UUID conversion | GraphQLClient | | Service | `src/services/` | Business logic and CRUD operations | GraphQLClient | -| Command | `src/commands/` | CLI orchestration via Commander.js | Both (via `createContext()`) | +| Command | `src/commands/` | CLI orchestration via Commander.js | GraphQLClient (via `createContext()`) | | Common | `src/common/` | Shared utilities, types, error handling | -- | Data flows in one direction: @@ -29,7 +29,7 @@ Commands receive user input, resolve any identifiers to UUIDs through the resolv - **TypeScript** with strict mode enabled and no `any` types - **Node.js** >= 22.0.0, ES modules throughout - **Commander.js** v14.0.0 for CLI structure -- **Linear SDK** v58.1.0 for the SDK client used in resolvers +- **GraphQL** for the typed client backing every layer (resolvers and services) - **GraphQL Codegen** for type-safe query and mutation documents - **Vitest** for unit testing - **tsx** for development execution diff --git a/docs/testing.md b/docs/testing.md index bb2b8b5a..0eca7b09 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -61,17 +61,16 @@ Each architectural layer uses a different mock target. The rule is simple: mock ### Resolver Tests -Resolvers depend on `LinearSdkClient`. Mock the SDK methods it calls: +Resolvers depend on `GraphQLClient`. Mock the `request` method it calls: ```typescript -import type { LinearSdkClient } from "../../src/client/linear-client.js"; +import type { GraphQLClient } from "../../src/client/graphql-client.js"; -const mockSdk = { - teams: vi.fn().mockResolvedValue({ - nodes: [{ id: "uuid-123", key: "ABC" }], +const client = { + request: vi.fn().mockResolvedValue({ + teams: { nodes: [{ id: "uuid-123", key: "ABC" }] }, }), -}; -const client = { sdk: mockSdk } as unknown as LinearSdkClient; +} as unknown as GraphQLClient; ``` ### Service Tests @@ -100,10 +99,11 @@ expect(isUuid("ABC-123")).toBe(false); ### Client Tests -Client tests mock the underlying network layer: +Client tests mock the underlying network layer by stubbing global `fetch`: ```typescript -const mockClient = { rawRequest: vi.fn() }; +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); ``` ## Writing a New Test @@ -116,34 +116,32 @@ Example resolver test: ```typescript import { describe, expect, it, vi } from "vitest"; -import type { LinearSdkClient } from "../../../src/client/linear-client.js"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { resolveTeamId } from "../../../src/resolvers/team-resolver.js"; +// Queue one `{ teams: { nodes } }` response per expected request. +function mockGqlClient(...results: Array<{ nodes: Array<{ id: string; key?: string; name?: string }> }>) { + const request = vi.fn(); + for (const teams of results) request.mockResolvedValueOnce({ teams }); + return { request } as unknown as GraphQLClient; +} + describe("resolveTeamId", () => { - it("should return UUID as-is", async () => { - const client = { sdk: {} } as unknown as LinearSdkClient; + it("should return UUID as-is without querying", async () => { + const client = mockGqlClient(); const result = await resolveTeamId(client, "550e8400-e29b-41d4-a716-446655440000"); expect(result).toBe("550e8400-e29b-41d4-a716-446655440000"); + expect(client.request).not.toHaveBeenCalled(); }); it("should resolve team by key", async () => { - const mockSdk = { - teams: vi.fn().mockResolvedValue({ - nodes: [{ id: "uuid-456", key: "ENG" }], - }), - }; - const client = { sdk: mockSdk } as unknown as LinearSdkClient; - + const client = mockGqlClient({ nodes: [{ id: "uuid-456", key: "ENG" }] }); const result = await resolveTeamId(client, "ENG"); expect(result).toBe("uuid-456"); }); it("should throw when team is not found", async () => { - const mockSdk = { - teams: vi.fn().mockResolvedValue({ nodes: [] }), - }; - const client = { sdk: mockSdk } as unknown as LinearSdkClient; - + const client = mockGqlClient({ nodes: [] }, { nodes: [] }); await expect(resolveTeamId(client, "NOPE")).rejects.toThrow(); }); }); diff --git a/src/common/resolve-filters.ts b/src/common/resolve-filters.ts index 7a6e15cf..02bf5b40 100644 --- a/src/common/resolve-filters.ts +++ b/src/common/resolve-filters.ts @@ -20,7 +20,7 @@ import { omitUndefined } from "./object.js"; * Validation order: format → dependency → date ranges → ID resolution. * Fails fast before making any API calls when input is invalid. * - * @param ctx - Command context with SDK and GraphQL clients + * @param ctx - Command context with the GraphQL client * @param opts - Raw filter flags from CLI options * @returns Resolved filter options with UUIDs ready for buildIssueFilter() */ diff --git a/tests/unit/commands/attachments.test.ts b/tests/unit/commands/attachments.test.ts index 0345bd81..83fd1ccb 100644 --- a/tests/unit/commands/attachments.test.ts +++ b/tests/unit/commands/attachments.test.ts @@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../../../src/common/context.js", () => ({ createContext: vi.fn(() => ({ gql: { request: vi.fn() }, - sdk: {}, })), getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), })); diff --git a/tests/unit/commands/comments.test.ts b/tests/unit/commands/comments.test.ts index f16ee3c6..58c34ac7 100644 --- a/tests/unit/commands/comments.test.ts +++ b/tests/unit/commands/comments.test.ts @@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../../../src/common/context.js", () => ({ createContext: vi.fn(() => ({ gql: { request: vi.fn() }, - sdk: { sdk: {} }, })), getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), })); diff --git a/tests/unit/commands/documents.test.ts b/tests/unit/commands/documents.test.ts index 5ef2eb2a..06508f32 100644 --- a/tests/unit/commands/documents.test.ts +++ b/tests/unit/commands/documents.test.ts @@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../../../src/common/context.js", () => ({ createContext: vi.fn(() => ({ gql: { request: vi.fn() }, - sdk: { sdk: {} }, })), getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), })); diff --git a/tests/unit/commands/initiatives.test.ts b/tests/unit/commands/initiatives.test.ts index 21158db9..bd0b7d6e 100644 --- a/tests/unit/commands/initiatives.test.ts +++ b/tests/unit/commands/initiatives.test.ts @@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../../../src/common/context.js", () => ({ createContext: vi.fn(() => ({ gql: { request: vi.fn() }, - sdk: { sdk: {} }, })), getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), })); diff --git a/tests/unit/commands/issues.test.ts b/tests/unit/commands/issues.test.ts index 2367b210..fe489b64 100644 --- a/tests/unit/commands/issues.test.ts +++ b/tests/unit/commands/issues.test.ts @@ -8,7 +8,6 @@ import { asUuid } from "../../../src/common/identifier.js"; vi.mock("../../../src/common/context.js", () => ({ createContext: vi.fn(() => ({ gql: { request: vi.fn() }, - sdk: { sdk: {} }, })), getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), })); diff --git a/tests/unit/commands/labels.test.ts b/tests/unit/commands/labels.test.ts index fd771854..d383fcdc 100644 --- a/tests/unit/commands/labels.test.ts +++ b/tests/unit/commands/labels.test.ts @@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../../../src/common/context.js", () => ({ createContext: vi.fn(() => ({ gql: { request: vi.fn() }, - sdk: { sdk: {} }, })), getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), })); diff --git a/tests/unit/commands/projects.test.ts b/tests/unit/commands/projects.test.ts index da2148b2..7bb9a6f4 100644 --- a/tests/unit/commands/projects.test.ts +++ b/tests/unit/commands/projects.test.ts @@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../../../src/common/context.js", () => ({ createContext: vi.fn(() => ({ gql: { request: vi.fn() }, - sdk: { sdk: {} }, })), getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), })); diff --git a/tests/unit/commands/teams.test.ts b/tests/unit/commands/teams.test.ts index febd052b..f1652b91 100644 --- a/tests/unit/commands/teams.test.ts +++ b/tests/unit/commands/teams.test.ts @@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../../../src/common/context.js", () => ({ createContext: vi.fn(() => ({ gql: { request: vi.fn() }, - sdk: { sdk: {} }, })), getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), })); From 819c0451c909bbe2c5d297b6b584ce4da1358391 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:53:24 +0200 Subject: [PATCH 64/79] perf(issues): batch-resolve assignee and IDs in create/update Replace the per-field sequential resolver calls in issue create/update with a single BatchResolveForCreate/Update request via a new issue-mutation-resolver, and extract the disambiguation/not-found mapping logic into shared pure mappers (batch-resolve-mappers). Migrate the search filter resolver onto BatchResolveForSearch as well. This removes the standalone user lookup for --assignee and other N+1 ID lookups, keeping every mutation within one batched request while preserving the exact resolver semantics. Closes #126 --- graphql/queries/issues.graphql | 33 +- src/commands/issues.ts | 230 +++++----- src/resolvers/batch-resolve-mappers.ts | 173 ++++++++ src/resolvers/issue-filter-resolver.ts | 127 +++++- src/resolvers/issue-mutation-resolver.ts | 370 ++++++++++++++++ tests/unit/commands/issues.test.ts | 177 +++++--- .../resolvers/issue-filter-resolver.test.ts | 199 ++++++--- .../resolvers/issue-mutation-resolver.test.ts | 414 ++++++++++++++++++ 8 files changed, 1447 insertions(+), 276 deletions(-) create mode 100644 src/resolvers/batch-resolve-mappers.ts create mode 100644 src/resolvers/issue-mutation-resolver.ts create mode 100644 tests/unit/resolvers/issue-mutation-resolver.test.ts diff --git a/graphql/queries/issues.graphql b/graphql/queries/issues.graphql index 2598015a..32d936d2 100644 --- a/graphql/queries/issues.graphql +++ b/graphql/queries/issues.graphql @@ -385,7 +385,7 @@ query BatchResolveForUpdate( $assigneeQuery: String $projectName: String $projectId: ID - $labelNames: [String!] + $labelFilter: IssueLabelFilter $statusName: String $cycleName: String $teamKey: String @@ -420,10 +420,7 @@ query BatchResolveForUpdate( nodes { id name - projectMilestones( - filter: { name: { eqIgnoreCase: $milestoneName } } - first: 10 - ) { + projectMilestones(filter: { name: { eq: $milestoneName } }, first: 10) { nodes { id name @@ -432,7 +429,7 @@ query BatchResolveForUpdate( } } - labels: issueLabels(filter: { name: { in: $labelNames } }) { + labels: issueLabels(filter: $labelFilter) { nodes { id name @@ -519,7 +516,7 @@ query BatchResolveForCreate( $assigneeQuery: String $projectName: String $projectId: ID - $labelNames: [String!] + $labelFilter: IssueLabelFilter $statusName: String $cycleName: String $milestoneName: String @@ -527,13 +524,22 @@ query BatchResolveForCreate( $parentIssueNumber: Float ) { teams( - filter: { or: [{ key: { eq: $teamKey } }, { name: { eq: $teamName } }] } + filter: { + or: [ + { key: { eq: $teamKey } } + { name: { eq: $teamName } } + { id: { eq: $teamId } } + ] + } first: 10 ) { nodes { id key name + issueEstimationType + issueEstimationExtended + issueEstimationAllowZero } } @@ -563,10 +569,7 @@ query BatchResolveForCreate( nodes { id name - projectMilestones( - filter: { name: { eqIgnoreCase: $milestoneName } } - first: 10 - ) { + projectMilestones(filter: { name: { eq: $milestoneName } }, first: 10) { nodes { id name @@ -575,7 +578,7 @@ query BatchResolveForCreate( } } - labels: issueLabels(filter: { name: { in: $labelNames } }) { + labels: issueLabels(filter: $labelFilter) { nodes { id name @@ -661,7 +664,7 @@ query BatchResolveForSearch( $creatorQuery: String $projectName: String $projectId: ID - $labelNames: [String!] + $labelFilter: IssueLabelFilter $cycleName: String $parentTeamKey: String $parentIssueNumber: Float @@ -733,7 +736,7 @@ query BatchResolveForSearch( } } - labels: issueLabels(filter: { name: { in: $labelNames } }) { + labels: issueLabels(filter: $labelFilter) { nodes { id name diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 5fc5d725..55dc47f0 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -23,20 +23,18 @@ import { resolveFilterOptions } from "../common/resolve-filters.js"; import { buildPaginationOptions } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import type { IssueRelationType } from "../gql/graphql.js"; -import { resolveCycleId } from "../resolvers/cycle-resolver.js"; +import { + type ResolveCreateIssueIdsInput, + type ResolvedUpdateIssueIds, + type ResolveUpdateIssueIdsInput, + resolveCreateIssueIds, + resolveUpdateIssueIds, + type UpdateIssueContext, +} from "../resolvers/issue-mutation-resolver.js"; import { resolveIssueEstimateContext, resolveIssueId, } from "../resolvers/issue-resolver.js"; -import { resolveLabelIds } from "../resolvers/label-resolver.js"; -import { resolveMilestoneId } from "../resolvers/milestone-resolver.js"; -import { resolveProjectId } from "../resolvers/project-resolver.js"; -import { resolveStatusId } from "../resolvers/status-resolver.js"; -import { - resolveTeamEstimateContext, - resolveTeamId, -} from "../resolvers/team-resolver.js"; -import { resolveUserId } from "../resolvers/user-resolver.js"; import { createDiscussionCommentReaction, deleteDiscussionComment, @@ -1129,37 +1127,53 @@ export function setupIssuesCommands(program: Command): void { throw new Error("--team is required"); } - const teamEstimateContext = - parsedEstimate !== undefined - ? await resolveTeamEstimateContext(ctx.gql, options.team) - : undefined; + if (options.projectMilestone && !options.project) { + throw new Error( + "--project-milestone requires --project to be specified", + ); + } + + const idsInput: ResolveCreateIssueIdsInput = { + team: options.team, + withEstimateContext: parsedEstimate !== undefined, + }; + if (options.assignee) idsInput.assignee = options.assignee; + if (options.project) idsInput.project = options.project; + if (options.labels) { + idsInput.labels = options.labels.split(",").map((l) => l.trim()); + } + if (options.projectMilestone) { + idsInput.projectMilestone = options.projectMilestone; + } + if (options.cycle) idsInput.cycle = options.cycle; + if (options.status) idsInput.status = options.status; + if (options.parentTicket) + idsInput.parentTicket = options.parentTicket; - const teamId = teamEstimateContext - ? teamEstimateContext.teamId - : await resolveTeamId(ctx.gql, options.team); + const ids = await resolveCreateIssueIds(ctx.gql, idsInput); - if (parsedEstimate !== undefined && teamEstimateContext) { + if (parsedEstimate !== undefined && ids.estimateContext) { validateEstimateAgainstTeamConfig(parsedEstimate, { - teamKey: teamEstimateContext.teamKey, - issueEstimationType: teamEstimateContext.issueEstimationType, + teamKey: ids.estimateContext.teamKey, + issueEstimationType: ids.estimateContext.issueEstimationType, issueEstimationExtended: - teamEstimateContext.issueEstimationExtended, + ids.estimateContext.issueEstimationExtended, issueEstimationAllowZero: - teamEstimateContext.issueEstimationAllowZero, + ids.estimateContext.issueEstimationAllowZero, }); } const input: CreateIssueInput = { title, - teamId, + teamId: ids.teamId, }; if (options.description) { input.description = options.description; } - if (options.assignee) { - input.assigneeId = await resolveUserId(ctx.gql, options.assignee); + if (ids.assigneeId) { + input.assigneeId = ids.assigneeId; } if (parsedPriority !== undefined) { @@ -1170,49 +1184,28 @@ export function setupIssuesCommands(program: Command): void { input.estimate = parsedEstimate; } - if (options.project) { - input.projectId = await resolveProjectId(ctx.gql, options.project); + if (ids.projectId) { + input.projectId = ids.projectId; } - if (options.labels) { - const labelNames = options.labels.split(",").map((l) => l.trim()); - input.labelIds = await resolveLabelIds(ctx.gql, labelNames); + if (ids.labelIds) { + input.labelIds = ids.labelIds; } - if (options.projectMilestone) { - if (!options.project) { - throw new Error( - "--project-milestone requires --project to be specified", - ); - } - input.projectMilestoneId = await resolveMilestoneId( - ctx.gql, - options.projectMilestone, - options.project, - ); + if (ids.projectMilestoneId) { + input.projectMilestoneId = ids.projectMilestoneId; } - if (options.cycle) { - input.cycleId = await resolveCycleId( - ctx.gql, - options.cycle, - options.team, - ); + if (ids.cycleId) { + input.cycleId = ids.cycleId; } - if (options.status) { - input.stateId = await resolveStatusId( - ctx.gql, - options.status, - teamId, - ); + if (ids.stateId) { + input.stateId = ids.stateId; } - if (options.parentTicket) { - input.parentId = await resolveIssueId( - ctx.gql, - options.parentTicket, - ); + if (ids.parentId) { + input.parentId = ids.parentId; } if (options.dueDate) { @@ -1354,6 +1347,51 @@ export function setupIssuesCommands(program: Command): void { ? await getIssue(ctx.gql, resolvedIssueId) : undefined; + const updContext: UpdateIssueContext = {}; + if (issueContext && "team" in issueContext && issueContext.team) { + updContext.teamId = asUuid(issueContext.team.id); + if (issueContext.team.key) { + updContext.teamKey = issueContext.team.key; + } + } + if ( + issueContext && + "project" in issueContext && + issueContext.project?.name + ) { + updContext.projectName = issueContext.project.name; + } + + const updIdsInput: ResolveUpdateIssueIdsInput = {}; + if (options.assignee) updIdsInput.assignee = options.assignee; + if (options.project) updIdsInput.project = options.project; + if (!options.clearLabels && options.labels) { + updIdsInput.labels = options.labels.split(",").map((l) => l.trim()); + } + if (!options.clearProjectMilestone && options.projectMilestone) { + updIdsInput.projectMilestone = options.projectMilestone; + } + if (!options.clearCycle && options.cycle) { + updIdsInput.cycle = options.cycle; + } + if (options.status) updIdsInput.status = options.status; + if (!options.clearParentTicket && options.parentTicket) { + updIdsInput.parentTicket = options.parentTicket; + } + + const needsResolution = + updIdsInput.assignee !== undefined || + updIdsInput.project !== undefined || + updIdsInput.labels !== undefined || + updIdsInput.projectMilestone !== undefined || + updIdsInput.cycle !== undefined || + updIdsInput.status !== undefined || + updIdsInput.parentTicket !== undefined; + + const ids: ResolvedUpdateIssueIds = needsResolution + ? await resolveUpdateIssueIds(ctx.gql, updIdsInput, updContext) + : {}; + const input: UpdateIssueInput = {}; if (options.title) { @@ -1364,16 +1402,8 @@ export function setupIssuesCommands(program: Command): void { input.description = options.description; } - if (options.status) { - const teamId = - issueContext && "team" in issueContext && issueContext.team - ? asUuid(issueContext.team.id) - : undefined; - input.stateId = await resolveStatusId( - ctx.gql, - options.status, - teamId, - ); + if (ids.stateId) { + input.stateId = ids.stateId; } if (parsedPriority !== undefined) { @@ -1386,35 +1416,28 @@ export function setupIssuesCommands(program: Command): void { input.estimate = parsedEstimate; } - if (options.assignee) { - input.assigneeId = await resolveUserId(ctx.gql, options.assignee); + if (ids.assigneeId) { + input.assigneeId = ids.assigneeId; } - if (options.project) { - input.projectId = await resolveProjectId(ctx.gql, options.project); + if (ids.projectId) { + input.projectId = ids.projectId; } if (options.clearLabels) { input.labelIds = []; - } else if (options.labels) { - const labelNames = options.labels.split(",").map((l) => l.trim()); - const labelIds = await resolveLabelIds(ctx.gql, labelNames); + } else if (options.labels && ids.labelIds) { + const labelIds = ids.labelIds; + const currentLabels = + issueContext && + "labels" in issueContext && + issueContext.labels?.nodes + ? issueContext.labels.nodes.map((l) => asUuid(l.id)) + : []; if (labelMode === "add") { - const currentLabels = - issueContext && - "labels" in issueContext && - issueContext.labels?.nodes - ? issueContext.labels.nodes.map((l) => asUuid(l.id)) - : []; input.labelIds = [...new Set([...currentLabels, ...labelIds])]; } else if (labelMode === "remove") { - const currentLabels = - issueContext && - "labels" in issueContext && - issueContext.labels?.nodes - ? issueContext.labels.nodes.map((l) => asUuid(l.id)) - : []; input.labelIds = currentLabels.filter( (id) => !labelIds.includes(id), ); @@ -1425,41 +1448,20 @@ export function setupIssuesCommands(program: Command): void { if (options.clearParentTicket) { input.parentId = null; - } else if (options.parentTicket) { - input.parentId = await resolveIssueId( - ctx.gql, - options.parentTicket, - ); + } else if (ids.parentId) { + input.parentId = ids.parentId; } if (options.clearProjectMilestone) { input.projectMilestoneId = null; - } else if (options.projectMilestone) { - const projectName = - issueContext && - "project" in issueContext && - issueContext.project?.name - ? issueContext.project.name - : undefined; - input.projectMilestoneId = await resolveMilestoneId( - ctx.gql, - options.projectMilestone, - projectName, - ); + } else if (ids.projectMilestoneId) { + input.projectMilestoneId = ids.projectMilestoneId; } if (options.clearCycle) { input.cycleId = null; - } else if (options.cycle) { - const teamKey = - issueContext && "team" in issueContext && issueContext.team?.key - ? issueContext.team.key - : undefined; - input.cycleId = await resolveCycleId( - ctx.gql, - options.cycle, - teamKey, - ); + } else if (ids.cycleId) { + input.cycleId = ids.cycleId; } if (options.clearDueDate) { diff --git a/src/resolvers/batch-resolve-mappers.ts b/src/resolvers/batch-resolve-mappers.ts new file mode 100644 index 00000000..e3e6c757 --- /dev/null +++ b/src/resolvers/batch-resolve-mappers.ts @@ -0,0 +1,173 @@ +import { multipleMatchesError, notFoundError } from "../common/errors.js"; +import { asUuid, isUuid, type UUID } from "../common/identifier.js"; +import type { + BatchResolveForCreateQuery, + IssueLabelFilter, +} from "../gql/graphql.js"; + +/** + * Pure mappers shared by the batch resolvers (issue create/update and issue + * search). Each turns a `BatchResolve*` response collection into resolved + * UUIDs, reproducing the exact disambiguation / not-found / UUID-passthrough + * semantics of the corresponding single-entity resolver. + * + * The three batch queries select identical node shapes, so the aliases below + * (derived from `BatchResolveForCreate`) apply to all of them structurally. + */ + +export type TeamNode = BatchResolveForCreateQuery["teams"]["nodes"][number]; +export type UserNode = BatchResolveForCreateQuery["assignees"]["nodes"][number]; +export type ProjectNode = + BatchResolveForCreateQuery["projects"]["nodes"][number]; +export type MilestoneNode = ProjectNode["projectMilestones"]["nodes"][number]; +export type LabelNode = BatchResolveForCreateQuery["labels"]["nodes"][number]; +export type StatusNode = + BatchResolveForCreateQuery["statuses"]["nodes"][number]; +export type CycleNode = BatchResolveForCreateQuery["cycles"]["nodes"][number]; +export type ParentNode = + BatchResolveForCreateQuery["parentIssues"]["nodes"][number]; + +/** + * Builds an `IssueLabelFilter` matching each name case-insensitively (mirroring + * `resolveLabelId`'s `eqIgnoreCase`). An empty list yields a filter that matches + * nothing, so the batch query's `labels` field stays cheap when no labels are + * requested. + */ +export function buildLabelFilter(names: string[]): IssueLabelFilter { + if (names.length === 0) return { name: { in: [] } }; + return { or: names.map((name) => ({ name: { eqIgnoreCase: name } })) }; +} + +/** + * Two-phase user precedence, replicated purely from the `or:[displayName,email]` + * result: an exact (case-insensitive) display-name match wins; multiple name + * matches are ambiguous; otherwise the first email match is used. Matches + * `resolveUserId`. + */ +export function mapUser(nodes: UserNode[], query: string): UUID { + const lower = query.toLowerCase(); + + const byName = nodes.filter((n) => n.displayName.toLowerCase() === lower); + if (byName.length === 1) return asUuid((byName[0] as UserNode).id); + if (byName.length > 1) { + throw multipleMatchesError( + "User", + query, + byName.map((u) => `${u.name} <${u.email}>`), + "Use email or UUID to disambiguate", + ); + } + + const byEmail = nodes.find((n) => n.email.toLowerCase() === lower); + if (byEmail) return asUuid(byEmail.id); + + throw notFoundError("User", query); +} + +/** Matches `resolveProjectId`: exactly one match, else not-found / ambiguous. */ +export function mapProjectNode( + nodes: ProjectNode[], + query: string, +): ProjectNode { + if (nodes.length === 0) throw notFoundError("Project", query); + if (nodes.length > 1) { + throw multipleMatchesError( + "Project", + query, + nodes.map((project) => project.id), + "provide project UUID", + ); + } + return nodes[0] as ProjectNode; +} + +/** Convenience wrapper: {@link mapProjectNode} returning just the UUID. */ +export function mapProjectId(nodes: ProjectNode[], query: string): UUID { + return asUuid(mapProjectNode(nodes, query).id); +} + +/** Matches `resolveLabelIds`: UUID passthrough, else case-insensitive name → id. */ +export function mapLabels(requested: string[], nodes: LabelNode[]): UUID[] { + return requested.map((label) => { + if (isUuid(label)) return asUuid(label); + const match = nodes.find( + (n) => n.name.toLowerCase() === label.toLowerCase(), + ); + if (!match) throw notFoundError("Label", label); + return asUuid(match.id); + }); +} + +/** Matches `resolveStatusId`: first match, scoped not-found context. */ +export function mapStatus( + nodes: StatusNode[], + query: string, + teamContext: string | undefined, +): UUID { + const first = nodes[0]; + if (!first) throw notFoundError("Status", query, teamContext); + return asUuid(first.id); +} + +/** Matches `resolveCycleId`: prefer active > next > previous, else ambiguous. */ +export function mapCycle( + nodes: CycleNode[], + query: string, + teamLabel: string | undefined, +): UUID { + if (nodes.length === 0) { + throw notFoundError( + "Cycle", + query, + teamLabel ? `for team ${teamLabel}` : undefined, + ); + } + + let chosen = + nodes.find((n) => n.isActive) ?? + nodes.find((n) => n.isNext) ?? + nodes.find((n) => n.isPrevious); + if (!chosen && nodes.length === 1) chosen = nodes[0]; + + if (!chosen) { + const matches = nodes.map( + (n) => + `${n.id} (${n.team?.key || "?"} / #${n.number} / ${ + n.startsAt ? new Date(n.startsAt).toISOString() : undefined + })`, + ); + throw multipleMatchesError( + "cycle", + query, + matches, + "use an ID or scope with --team", + ); + } + + return asUuid(chosen.id); +} + +/** Matches `resolveMilestoneId` scoped to a single project's milestones. */ +export function mapMilestone( + nodes: MilestoneNode[], + query: string, + projectName: string | undefined, +): UUID { + if (nodes.length === 0) throw notFoundError("Milestone", query); + if (nodes.length > 1) { + throw multipleMatchesError( + "milestone", + query, + nodes.map((m) => `"${m.name}" in project "${projectName ?? "?"}"`), + "specify --project or use the milestone ID", + ); + } + return asUuid((nodes[0] as MilestoneNode).id); +} + +/** Matches `resolveIssueId`: first match or not-found (UUID handled by caller). */ +export function mapParent(nodes: ParentNode[], query: string): UUID { + const first = nodes[0]; + if (!first) throw notFoundError("Issue", query); + return asUuid(first.id); +} diff --git a/src/resolvers/issue-filter-resolver.ts b/src/resolvers/issue-filter-resolver.ts index 34e11bae..a6872923 100644 --- a/src/resolvers/issue-filter-resolver.ts +++ b/src/resolvers/issue-filter-resolver.ts @@ -1,12 +1,21 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { UUID } from "../common/identifier.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 { notFoundError } from "../common/errors.js"; +import { + asUuid, + isUuid, + parseIssueIdentifier, + type UUID, +} from "../common/identifier.js"; +import { BatchResolveForSearchDocument } from "../gql/graphql.js"; +import { + buildLabelFilter, + mapCycle, + mapLabels, + mapParent, + mapProjectId, + mapUser, +} from "./batch-resolve-mappers.js"; import { resolveStatusId } from "./status-resolver.js"; -import { resolveTeamId } from "./team-resolver.js"; -import { resolveUserId } from "./user-resolver.js"; export interface SearchFilterResolutionInput { team?: string; @@ -30,51 +39,125 @@ export interface SearchFilterResolution { parentId?: UUID; } +/** + * Resolves every human identifier in a search filter in a single + * `BatchResolveForSearch` request, preserving the exact semantics of the + * individual resolvers via the shared batch mappers. + * + * Statuses are the one exception: the batch query returns the workflow states + * of the scoped team (matched client-side by name). When no team is given — or + * a name is not among the returned states — resolution falls back to + * `resolveStatusId`, which matches globally, so status filtering without a team + * keeps working. + */ export async function resolveSearchFilterIds( gqlClient: GraphQLClient, input: SearchFilterResolutionInput, ): Promise<SearchFilterResolution> { + const team = input.team; + const teamIsUuid = team ? isUuid(team) : false; + + const assigneeQuery = + input.assignee && !isUuid(input.assignee) ? input.assignee : null; + const creatorQuery = + input.creator && !isUuid(input.creator) ? input.creator : null; + const projectName = + input.project && !isUuid(input.project) ? input.project : null; + const projectIdVar = + input.project && isUuid(input.project) ? input.project : null; + const cycleName = input.cycle && !isUuid(input.cycle) ? input.cycle : null; + const labelNames = (input.labelNames ?? []).filter((l) => !isUuid(l)); + + const parent = + input.parent && !isUuid(input.parent) + ? parseIssueIdentifier(input.parent) + : null; + + const response = await gqlClient.request(BatchResolveForSearchDocument, { + teamKey: team && !teamIsUuid ? team : null, + teamName: team && !teamIsUuid ? team : null, + teamId: team && teamIsUuid ? team : null, + assigneeQuery, + creatorQuery, + projectName, + projectId: projectIdVar, + labelFilter: buildLabelFilter(labelNames), + cycleName, + parentTeamKey: parent?.teamKey ?? null, + parentIssueNumber: parent?.issueNumber ?? null, + milestoneName: null, + }); + const resolved: SearchFilterResolution = {}; - if (input.team) { - resolved.teamId = await resolveTeamId(gqlClient, input.team); + if (team) { + resolved.teamId = teamIsUuid + ? asUuid(team) + : mapSearchTeamId(response.teams.nodes, team); } if (input.assignee) { - resolved.assigneeId = await resolveUserId(gqlClient, input.assignee); + resolved.assigneeId = isUuid(input.assignee) + ? asUuid(input.assignee) + : mapUser(response.assignees.nodes, input.assignee); } if (input.creator) { - resolved.creatorId = await resolveUserId(gqlClient, input.creator); + resolved.creatorId = isUuid(input.creator) + ? asUuid(input.creator) + : mapUser(response.creators.nodes, input.creator); } if (input.project) { - resolved.projectId = await resolveProjectId(gqlClient, input.project); + resolved.projectId = isUuid(input.project) + ? asUuid(input.project) + : mapProjectId(response.projects.nodes, input.project); } if (input.statusNames && input.statusNames.length > 0) { resolved.stateIds = await Promise.all( - input.statusNames.map((status) => - resolveStatusId(gqlClient, status, resolved.teamId), - ), + input.statusNames.map((name) => { + if (isUuid(name)) return asUuid(name); + const match = response.statuses.nodes.find( + (n) => n.name.toLowerCase() === name.toLowerCase(), + ); + if (match) return asUuid(match.id); + // Not among the scoped team's states (or no team given) — resolve + // individually to match globally, as resolveStatusId did before. + return resolveStatusId(gqlClient, name, resolved.teamId); + }), ); } if (input.labelNames && input.labelNames.length > 0) { - resolved.labelIds = await resolveLabelIds(gqlClient, input.labelNames); + resolved.labelIds = mapLabels(input.labelNames, response.labels.nodes); } if (input.cycle) { - resolved.cycleId = await resolveCycleId( - gqlClient, - input.cycle, - resolved.teamId ?? input.team, - ); + resolved.cycleId = isUuid(input.cycle) + ? asUuid(input.cycle) + : mapCycle( + response.cycles.nodes, + input.cycle, + resolved.teamId ?? input.team, + ); } if (input.parent) { - resolved.parentId = await resolveIssueId(gqlClient, input.parent); + resolved.parentId = isUuid(input.parent) + ? asUuid(input.parent) + : mapParent(response.parentIssues.nodes, input.parent); } return resolved; } + +type SearchTeamNode = { id: string; key: string; name: string }; + +/** Mirrors resolveTeamId: prefer key match, then name; else not-found. */ +function mapSearchTeamId(nodes: SearchTeamNode[], raw: string): UUID { + const match = + nodes.find((n) => n.key === raw) ?? nodes.find((n) => n.name === raw); + if (!match) throw notFoundError("Team", raw); + return asUuid(match.id); +} diff --git a/src/resolvers/issue-mutation-resolver.ts b/src/resolvers/issue-mutation-resolver.ts new file mode 100644 index 00000000..d1daaa10 --- /dev/null +++ b/src/resolvers/issue-mutation-resolver.ts @@ -0,0 +1,370 @@ +import type { GraphQLClient } from "../client/graphql-client.js"; +import { notFoundError } from "../common/errors.js"; +import { + asUuid, + isUuid, + parseIssueIdentifier, + type UUID, +} from "../common/identifier.js"; +import { + BatchResolveForCreateDocument, + BatchResolveForUpdateDocument, +} from "../gql/graphql.js"; +import { + buildLabelFilter, + mapCycle, + mapLabels, + mapMilestone, + mapParent, + mapProjectNode, + mapStatus, + mapUser, + type ProjectNode, + type TeamNode, +} from "./batch-resolve-mappers.js"; +import type { TeamEstimateContext } from "./team-resolver.js"; + +/** + * Batch resolver for issue create / update. + * + * Replaces the per-field sequential resolver calls (`resolveTeamId`, + * `resolveUserId`, `resolveProjectId`, …) with a single `BatchResolve*` + * GraphQL request, then maps the response back to UUIDs while preserving the + * exact disambiguation, not-found and UUID-passthrough semantics of each + * individual resolver. + * + * Create resolves everything in one request. Update inherently needs two + * sequential requests — the target issue must be fetched first (its team / + * project scope the status / cycle / milestone lookups) — the caller supplies + * that context via {@link UpdateIssueContext}. + */ + +const TEAM_ESTIMATION_TYPES = [ + "notUsed", + "exponential", + "fibonacci", + "linear", + "tShirt", +] as const; + +type TeamEstimationType = (typeof TEAM_ESTIMATION_TYPES)[number]; + +function narrowEstimationType( + value: string, + teamLabel: string, +): TeamEstimationType { + if ((TEAM_ESTIMATION_TYPES as readonly string[]).includes(value)) { + return value as TeamEstimationType; + } + throw new Error(`Team "${teamLabel}" is missing required estimation context`); +} + +// --- Create ----------------------------------------------------------------- + +export interface ResolveCreateIssueIdsInput { + /** Team key, name or UUID. Required for create. */ + team: string; + assignee?: string; + project?: string; + labels?: string[]; + /** Milestone name or UUID; requires {@link ResolveCreateIssueIdsInput.project}. */ + projectMilestone?: string; + cycle?: string; + status?: string; + parentTicket?: string; + /** When true, resolve the team's estimation config for `--estimate` validation. */ + withEstimateContext?: boolean; +} + +export interface ResolvedCreateIssueIds { + teamId: UUID; + estimateContext?: TeamEstimateContext; + assigneeId?: UUID; + projectId?: UUID; + labelIds?: UUID[]; + projectMilestoneId?: UUID; + cycleId?: UUID; + stateId?: UUID; + parentId?: UUID; +} + +/** + * Resolves every human identifier needed to create an issue in a single + * `BatchResolveForCreate` request. + */ +export async function resolveCreateIssueIds( + client: GraphQLClient, + input: ResolveCreateIssueIdsInput, +): Promise<ResolvedCreateIssueIds> { + const teamIsUuid = isUuid(input.team); + const assigneeQuery = + input.assignee && !isUuid(input.assignee) ? input.assignee : null; + const projectName = + input.project && !isUuid(input.project) ? input.project : null; + const projectIdVar = + input.project && isUuid(input.project) ? input.project : null; + const milestoneName = + input.projectMilestone && !isUuid(input.projectMilestone) + ? input.projectMilestone + : null; + const statusName = + input.status && !isUuid(input.status) ? input.status : null; + const cycleName = input.cycle && !isUuid(input.cycle) ? input.cycle : null; + const labelNames = (input.labels ?? []).filter((l) => !isUuid(l)); + + const parent = + input.parentTicket && !isUuid(input.parentTicket) + ? parseIssueIdentifier(input.parentTicket) + : null; + + const response = await client.request(BatchResolveForCreateDocument, { + teamKey: teamIsUuid ? null : input.team, + teamName: teamIsUuid ? null : input.team, + teamId: teamIsUuid ? input.team : null, + assigneeQuery, + projectName, + projectId: projectIdVar, + labelFilter: buildLabelFilter(labelNames), + statusName, + cycleName, + milestoneName, + parentTeamKey: parent?.teamKey ?? null, + parentIssueNumber: parent?.issueNumber ?? null, + }); + + // Team (required). Prefer key match, then name, then id — mirrors resolveTeamId. + const teamNode = teamIsUuid + ? response.teams.nodes.find((n) => n.id === input.team) + : findTeamNode(response.teams.nodes, input.team); + const teamId: UUID = + teamIsUuid && !teamNode + ? asUuid(input.team) + : asUuid(requireTeam(teamNode, input.team).id); + + const resolved: ResolvedCreateIssueIds = { teamId }; + + if (input.withEstimateContext) { + const node = requireTeam(teamNode, input.team); + resolved.estimateContext = { + teamId: asUuid(node.id), + teamKey: node.key, + teamName: node.name, + issueEstimationType: narrowEstimationType( + node.issueEstimationType, + input.team, + ), + issueEstimationExtended: node.issueEstimationExtended, + issueEstimationAllowZero: node.issueEstimationAllowZero, + }; + } + + if (input.assignee) { + resolved.assigneeId = isUuid(input.assignee) + ? asUuid(input.assignee) + : mapUser(response.assignees.nodes, input.assignee); + } + + let matchedProject: ProjectNode | undefined; + if (input.project) { + if (isUuid(input.project)) { + resolved.projectId = asUuid(input.project); + matchedProject = response.projects.nodes.find( + (n) => n.id === input.project, + ); + } else { + matchedProject = mapProjectNode(response.projects.nodes, input.project); + resolved.projectId = asUuid(matchedProject.id); + } + } + + if (input.labels && input.labels.length > 0) { + resolved.labelIds = mapLabels(input.labels, response.labels.nodes); + } + + if (input.projectMilestone) { + resolved.projectMilestoneId = isUuid(input.projectMilestone) + ? asUuid(input.projectMilestone) + : mapMilestone( + matchedProject?.projectMilestones.nodes ?? [], + input.projectMilestone, + matchedProject?.name, + ); + } + + if (input.cycle) { + resolved.cycleId = isUuid(input.cycle) + ? asUuid(input.cycle) + : mapCycle(response.cycles.nodes, input.cycle, input.team); + } + + if (input.status) { + resolved.stateId = isUuid(input.status) + ? asUuid(input.status) + : mapStatus(response.statuses.nodes, input.status, `for team ${teamId}`); + } + + if (input.parentTicket) { + resolved.parentId = isUuid(input.parentTicket) + ? asUuid(input.parentTicket) + : mapParent(response.parentIssues.nodes, input.parentTicket); + } + + return resolved; +} + +function findTeamNode(nodes: TeamNode[], raw: string): TeamNode | undefined { + return nodes.find((n) => n.key === raw) ?? nodes.find((n) => n.name === raw); +} + +function requireTeam(node: TeamNode | undefined, raw: string): TeamNode { + if (!node) throw notFoundError("Team", raw); + return node; +} + +// --- Update ----------------------------------------------------------------- + +/** Context derived from the target issue (already fetched) that scopes lookups. */ +export interface UpdateIssueContext { + /** The issue's team UUID — scopes status / cycle resolution. */ + teamId?: UUID; + /** The issue's team key — used in cycle not-found messages. */ + teamKey?: string; + /** The issue's current project name — scopes milestone resolution. */ + projectName?: string; +} + +export interface ResolveUpdateIssueIdsInput { + assignee?: string; + project?: string; + labels?: string[]; + projectMilestone?: string; + cycle?: string; + status?: string; + parentTicket?: string; +} + +export interface ResolvedUpdateIssueIds { + assigneeId?: UUID; + projectId?: UUID; + /** Resolved label UUIDs (add/remove/overwrite set math stays in the command). */ + labelIds?: UUID[]; + projectMilestoneId?: UUID; + cycleId?: UUID; + stateId?: UUID; + parentId?: UUID; +} + +/** + * Resolves the new values for an issue update in a single + * `BatchResolveForUpdate` request. Status / cycle / milestone are scoped by the + * target issue's own team / project supplied in {@link UpdateIssueContext}. + * + * When both `--project` and `--project-milestone` are given, the milestone is + * resolved within the *new* project (an intentional improvement over the prior + * behavior, which scoped it to the issue's old project). + */ +export async function resolveUpdateIssueIds( + client: GraphQLClient, + input: ResolveUpdateIssueIdsInput, + context: UpdateIssueContext, +): Promise<ResolvedUpdateIssueIds> { + const assigneeQuery = + input.assignee && !isUuid(input.assignee) ? input.assignee : null; + + // projectName scopes both --project resolution and milestone lookup: the new + // project when --project is a name, else the issue's current project. + const projectNameVar = + input.project && !isUuid(input.project) + ? input.project + : (context.projectName ?? null); + const projectIdVar = + input.project && isUuid(input.project) ? input.project : null; + + const milestoneName = + input.projectMilestone && !isUuid(input.projectMilestone) + ? input.projectMilestone + : null; + const statusName = + input.status && !isUuid(input.status) ? input.status : null; + const cycleName = input.cycle && !isUuid(input.cycle) ? input.cycle : null; + const labelNames = (input.labels ?? []).filter((l) => !isUuid(l)); + + const parent = + input.parentTicket && !isUuid(input.parentTicket) + ? parseIssueIdentifier(input.parentTicket) + : null; + + const response = await client.request(BatchResolveForUpdateDocument, { + assigneeQuery, + projectName: projectNameVar, + projectId: projectIdVar, + labelFilter: buildLabelFilter(labelNames), + statusName, + cycleName, + teamKey: context.teamKey ?? null, + teamId: context.teamId ?? null, + milestoneName, + parentTeamKey: parent?.teamKey ?? null, + parentIssueNumber: parent?.issueNumber ?? null, + }); + + const resolved: ResolvedUpdateIssueIds = {}; + + if (input.assignee) { + resolved.assigneeId = isUuid(input.assignee) + ? asUuid(input.assignee) + : mapUser(response.assignees.nodes, input.assignee); + } + + // The projects field matches by projectNameVar/projectIdVar; the matched node + // is reused for milestone scoping. + const matchedProject: ProjectNode | undefined = isUuid(input.project ?? "") + ? response.projects.nodes.find((n) => n.id === input.project) + : response.projects.nodes.find( + (n) => n.name.toLowerCase() === (projectNameVar ?? "").toLowerCase(), + ); + + if (input.project) { + resolved.projectId = isUuid(input.project) + ? asUuid(input.project) + : asUuid(mapProjectNode(response.projects.nodes, input.project).id); + } + + if (input.labels && input.labels.length > 0) { + resolved.labelIds = mapLabels(input.labels, response.labels.nodes); + } + + if (input.projectMilestone) { + resolved.projectMilestoneId = isUuid(input.projectMilestone) + ? asUuid(input.projectMilestone) + : mapMilestone( + matchedProject?.projectMilestones.nodes ?? [], + input.projectMilestone, + matchedProject?.name ?? projectNameVar ?? undefined, + ); + } + + if (input.cycle) { + resolved.cycleId = isUuid(input.cycle) + ? asUuid(input.cycle) + : mapCycle(response.cycles.nodes, input.cycle, context.teamKey); + } + + if (input.status) { + resolved.stateId = isUuid(input.status) + ? asUuid(input.status) + : mapStatus( + response.statuses.nodes, + input.status, + context.teamId ? `for team ${context.teamId}` : undefined, + ); + } + + if (input.parentTicket) { + resolved.parentId = isUuid(input.parentTicket) + ? asUuid(input.parentTicket) + : mapParent(response.parentIssues.nodes, input.parentTicket); + } + + return resolved; +} diff --git a/tests/unit/commands/issues.test.ts b/tests/unit/commands/issues.test.ts index fe489b64..f5683794 100644 --- a/tests/unit/commands/issues.test.ts +++ b/tests/unit/commands/issues.test.ts @@ -21,20 +21,13 @@ vi.mock("../../../src/common/output.js", async (importOriginal) => { }; }); -vi.mock("../../../src/resolvers/user-resolver.js", () => ({ - resolveUserId: vi.fn().mockResolvedValue("resolved-user-uuid"), +vi.mock("../../../src/resolvers/issue-mutation-resolver.js", () => ({ + resolveCreateIssueIds: vi.fn(), + resolveUpdateIssueIds: vi.fn(), })); -vi.mock("../../../src/resolvers/team-resolver.js", () => ({ - resolveTeamId: vi.fn().mockResolvedValue("resolved-team-uuid"), - resolveTeamEstimateContext: vi.fn().mockResolvedValue({ - teamId: "resolved-team-uuid", - teamKey: "ENG", - teamName: "Engineering", - issueEstimationType: "fibonacci", - issueEstimationExtended: false, - issueEstimationAllowZero: false, - }), +vi.mock("../../../src/resolvers/issue-filter-resolver.js", () => ({ + resolveSearchFilterIds: vi.fn(), })); vi.mock("../../../src/resolvers/issue-resolver.js", () => ({ @@ -52,26 +45,6 @@ vi.mock("../../../src/resolvers/issue-resolver.js", () => ({ }), })); -vi.mock("../../../src/resolvers/project-resolver.js", () => ({ - resolveProjectId: vi.fn().mockResolvedValue("resolved-project-uuid"), -})); - -vi.mock("../../../src/resolvers/label-resolver.js", () => ({ - resolveLabelIds: vi.fn().mockResolvedValue(["resolved-label-uuid"]), -})); - -vi.mock("../../../src/resolvers/milestone-resolver.js", () => ({ - resolveMilestoneId: vi.fn().mockResolvedValue("resolved-milestone-uuid"), -})); - -vi.mock("../../../src/resolvers/cycle-resolver.js", () => ({ - resolveCycleId: vi.fn().mockResolvedValue("resolved-cycle-uuid"), -})); - -vi.mock("../../../src/resolvers/status-resolver.js", () => ({ - resolveStatusId: vi.fn().mockResolvedValue("resolved-status-uuid"), -})); - vi.mock("../../../src/services/issue-service.js", () => ({ archiveIssue: vi.fn().mockResolvedValue({ id: "resolved-issue-uuid" }), createIssue: vi.fn().mockResolvedValue({ id: "new-issue-id" }), @@ -206,16 +179,15 @@ vi.mock("../../../src/services/discussion-service.js", () => ({ })); import { setupIssuesCommands } from "../../../src/commands/issues.js"; +import { resolveSearchFilterIds } from "../../../src/resolvers/issue-filter-resolver.js"; +import { + resolveCreateIssueIds, + resolveUpdateIssueIds, +} from "../../../src/resolvers/issue-mutation-resolver.js"; import { resolveIssueEstimateContext, resolveIssueId, } from "../../../src/resolvers/issue-resolver.js"; -import { resolveLabelIds } from "../../../src/resolvers/label-resolver.js"; -import { - resolveTeamEstimateContext, - resolveTeamId, -} from "../../../src/resolvers/team-resolver.js"; -import { resolveUserId } from "../../../src/resolvers/user-resolver.js"; import { createDiscussionCommentReaction, deleteDiscussionComment, @@ -257,6 +229,69 @@ import { deleteOwnReactionById, } from "../../../src/services/reaction-service.js"; +// Default echo implementations for the batch resolvers: each provided human +// input is "resolved" to a deterministic UUID. Set once at module scope; +// beforeEach uses clearAllMocks (call history only), so implementations persist. +// Individual tests override with mock*Once for estimate-context / error cases. +vi.mocked(resolveCreateIssueIds).mockImplementation(async (_gql, input) => { + const out: Awaited<ReturnType<typeof resolveCreateIssueIds>> = { + teamId: asUuid("resolved-team-uuid"), + }; + if (input.assignee) out.assigneeId = asUuid("resolved-user-uuid"); + if (input.project) out.projectId = asUuid("resolved-project-uuid"); + if (input.labels) out.labelIds = [asUuid("resolved-label-uuid")]; + if (input.projectMilestone) { + out.projectMilestoneId = asUuid("resolved-milestone-uuid"); + } + if (input.cycle) out.cycleId = asUuid("resolved-cycle-uuid"); + if (input.status) out.stateId = asUuid("resolved-status-uuid"); + if (input.parentTicket) out.parentId = asUuid("resolved-parent-uuid"); + if (input.withEstimateContext) { + out.estimateContext = { + teamId: asUuid("resolved-team-uuid"), + teamKey: "ENG", + teamName: "Engineering", + issueEstimationType: "fibonacci", + issueEstimationExtended: false, + issueEstimationAllowZero: false, + }; + } + return out; +}); + +vi.mocked(resolveUpdateIssueIds).mockImplementation(async (_gql, input) => { + const out: Awaited<ReturnType<typeof resolveUpdateIssueIds>> = {}; + if (input.assignee) out.assigneeId = asUuid("resolved-user-uuid"); + if (input.project) out.projectId = asUuid("resolved-project-uuid"); + if (input.labels) { + out.labelIds = input.labels.map(() => asUuid("resolved-label-uuid")); + } + if (input.projectMilestone) { + out.projectMilestoneId = asUuid("resolved-milestone-uuid"); + } + if (input.cycle) out.cycleId = asUuid("resolved-cycle-uuid"); + if (input.status) out.stateId = asUuid("resolved-status-uuid"); + if (input.parentTicket) out.parentId = asUuid("resolved-parent-uuid"); + return out; +}); + +vi.mocked(resolveSearchFilterIds).mockImplementation(async (_gql, input) => { + const out: Awaited<ReturnType<typeof resolveSearchFilterIds>> = {}; + if (input.team) out.teamId = asUuid("resolved-team-uuid"); + if (input.assignee) out.assigneeId = asUuid("resolved-user-uuid"); + if (input.creator) out.creatorId = asUuid("resolved-creator-uuid"); + if (input.project) out.projectId = asUuid("resolved-project-uuid"); + if (input.statusNames && input.statusNames.length > 0) { + out.stateIds = [asUuid("resolved-status-uuid")]; + } + if (input.labelNames && input.labelNames.length > 0) { + out.labelIds = [asUuid("resolved-label-uuid")]; + } + if (input.cycle) out.cycleId = asUuid("resolved-cycle-uuid"); + if (input.parent) out.parentId = asUuid("resolved-parent-uuid"); + return out; +}); + function createProgram(): Command { const program = new Command(); program.option("--api-token <token>"); @@ -286,7 +321,10 @@ describe("issues create --assignee", () => { "John Doe", ]); - expect(resolveUserId).toHaveBeenCalledWith(expect.anything(), "John Doe"); + expect(resolveCreateIssueIds).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ assignee: "John Doe" }), + ); expect(createIssue).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ assigneeId: "resolved-user-uuid" }), @@ -307,9 +345,9 @@ describe("issues create --assignee", () => { "john@example.com", ]); - expect(resolveUserId).toHaveBeenCalledWith( + expect(resolveCreateIssueIds).toHaveBeenCalledWith( expect.anything(), - "john@example.com", + expect.objectContaining({ assignee: "john@example.com" }), ); expect(createIssue).toHaveBeenCalledWith( expect.anything(), @@ -317,7 +355,7 @@ describe("issues create --assignee", () => { ); }); - it("does not call resolveUserId when --assignee is omitted", async () => { + it("does not resolve an assignee when --assignee is omitted", async () => { const program = createProgram(); await program.parseAsync([ "node", @@ -329,7 +367,10 @@ describe("issues create --assignee", () => { "ENG", ]); - expect(resolveUserId).not.toHaveBeenCalled(); + expect(resolveCreateIssueIds).toHaveBeenCalledWith( + expect.anything(), + expect.not.objectContaining({ assignee: expect.anything() }), + ); expect(createIssue).toHaveBeenCalledWith( expect.anything(), expect.not.objectContaining({ assigneeId: expect.anything() }), @@ -366,13 +407,16 @@ describe("issues create --estimate", () => { }); it("passes estimate 0 through to createIssue when team allows zero", async () => { - vi.mocked(resolveTeamEstimateContext).mockResolvedValueOnce({ + vi.mocked(resolveCreateIssueIds).mockResolvedValueOnce({ teamId: asUuid("resolved-team-uuid"), - teamKey: "ENG", - teamName: "Engineering", - issueEstimationType: "fibonacci", - issueEstimationExtended: false, - issueEstimationAllowZero: true, + estimateContext: { + teamId: asUuid("resolved-team-uuid"), + teamKey: "ENG", + teamName: "Engineering", + issueEstimationType: "fibonacci", + issueEstimationExtended: false, + issueEstimationAllowZero: true, + }, }); const program = createProgram(); @@ -437,13 +481,16 @@ describe("issues create --estimate", () => { }); it("rejects create estimate when team estimation disabled", async () => { - vi.mocked(resolveTeamEstimateContext).mockResolvedValueOnce({ + vi.mocked(resolveCreateIssueIds).mockResolvedValueOnce({ teamId: asUuid("resolved-team-uuid"), - teamKey: "ENG", - teamName: "Engineering", - issueEstimationType: "notUsed", - issueEstimationExtended: false, - issueEstimationAllowZero: false, + estimateContext: { + teamId: asUuid("resolved-team-uuid"), + teamKey: "ENG", + teamName: "Engineering", + issueEstimationType: "notUsed", + issueEstimationExtended: false, + issueEstimationAllowZero: false, + }, }); const program = createProgram(); @@ -496,7 +543,7 @@ describe("issues create numeric option validation", () => { "Invalid --priority: must be an integer between 1 and 4", ), ); - expect(resolveTeamId).not.toHaveBeenCalled(); + expect(resolveCreateIssueIds).not.toHaveBeenCalled(); expect(createIssue).not.toHaveBeenCalled(); }); @@ -519,7 +566,7 @@ describe("issues create numeric option validation", () => { "Invalid --estimate: must be a non-negative integer", ), ); - expect(resolveTeamId).not.toHaveBeenCalled(); + expect(resolveCreateIssueIds).not.toHaveBeenCalled(); expect(createIssue).not.toHaveBeenCalled(); }); @@ -1023,7 +1070,11 @@ describe("issues update --assignee", () => { "Jane Smith", ]); - expect(resolveUserId).toHaveBeenCalledWith(expect.anything(), "Jane Smith"); + expect(resolveUpdateIssueIds).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ assignee: "Jane Smith" }), + expect.anything(), + ); expect(updateIssue).toHaveBeenCalledWith( expect.anything(), "resolved-issue-uuid", @@ -1031,7 +1082,7 @@ describe("issues update --assignee", () => { ); }); - it("does not call resolveUserId when --assignee is omitted", async () => { + it("does not resolve IDs when only non-reference fields change", async () => { const program = createProgram(); await program.parseAsync([ "node", @@ -1043,7 +1094,7 @@ describe("issues update --assignee", () => { "New title", ]); - expect(resolveUserId).not.toHaveBeenCalled(); + expect(resolveUpdateIssueIds).not.toHaveBeenCalled(); }); }); @@ -1979,7 +2030,11 @@ describe("issues update --labels", () => { "remove", ]); - expect(resolveLabelIds).toHaveBeenCalledWith(expect.anything(), ["bug"]); + expect(resolveUpdateIssueIds).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ labels: ["bug"] }), + expect.anything(), + ); expect(updateIssue).toHaveBeenCalledWith( expect.anything(), "resolved-issue-uuid", diff --git a/tests/unit/resolvers/issue-filter-resolver.test.ts b/tests/unit/resolvers/issue-filter-resolver.test.ts index 4cf00c00..4462a8ca 100644 --- a/tests/unit/resolvers/issue-filter-resolver.test.ts +++ b/tests/unit/resolvers/issue-filter-resolver.test.ts @@ -2,99 +2,170 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { resolveSearchFilterIds } from "../../../src/resolvers/issue-filter-resolver.js"; -const { - resolveTeamIdMock, - resolveUserIdMock, - resolveProjectIdMock, - resolveStatusIdMock, - resolveLabelIdsMock, - resolveCycleIdMock, - resolveIssueIdMock, -} = vi.hoisted(() => ({ - resolveTeamIdMock: vi.fn(), - resolveUserIdMock: vi.fn(), - resolveProjectIdMock: vi.fn(), +const { resolveStatusIdMock } = vi.hoisted(() => ({ resolveStatusIdMock: vi.fn(), - resolveLabelIdsMock: vi.fn(), - resolveCycleIdMock: vi.fn(), - resolveIssueIdMock: vi.fn(), -})); - -vi.mock("../../../src/resolvers/team-resolver.js", () => ({ - resolveTeamId: resolveTeamIdMock, -})); - -vi.mock("../../../src/resolvers/user-resolver.js", () => ({ - resolveUserId: resolveUserIdMock, -})); - -vi.mock("../../../src/resolvers/project-resolver.js", () => ({ - resolveProjectId: resolveProjectIdMock, })); vi.mock("../../../src/resolvers/status-resolver.js", () => ({ resolveStatusId: resolveStatusIdMock, })); -vi.mock("../../../src/resolvers/label-resolver.js", () => ({ - resolveLabelIds: resolveLabelIdsMock, -})); - -vi.mock("../../../src/resolvers/cycle-resolver.js", () => ({ - resolveCycleId: resolveCycleIdMock, -})); - -vi.mock("../../../src/resolvers/issue-resolver.js", () => ({ - resolveIssueId: resolveIssueIdMock, -})); +type BatchNodes = { + teams?: Array<{ id: string; key: string; name: string }>; + assignees?: Array<{ + id: string; + name: string; + email: string; + displayName: string; + }>; + creators?: Array<{ + id: string; + name: string; + email: string; + displayName: string; + }>; + projects?: Array<{ + id: string; + name: string; + projectMilestones?: Array<{ id: string; name: string }>; + }>; + labels?: Array<{ id: string; name: string }>; + statuses?: Array<{ + id: string; + name: string; + team: { id: string; key: string }; + }>; + cycles?: Array<{ + id: string; + name: string | null; + isActive: boolean; + isNext: boolean; + isPrevious: boolean; + number: number; + startsAt: string; + team: { id: string; key: string }; + }>; + parentIssues?: Array<{ id: string; identifier: string }>; +}; + +function mockGql(nodes: BatchNodes) { + const request = vi.fn().mockResolvedValue({ + teams: { nodes: nodes.teams ?? [] }, + assignees: { nodes: nodes.assignees ?? [] }, + creators: { nodes: nodes.creators ?? [] }, + projects: { + nodes: (nodes.projects ?? []).map((p) => ({ + ...p, + projectMilestones: { nodes: p.projectMilestones ?? [] }, + })), + }, + labels: { nodes: nodes.labels ?? [] }, + statuses: { nodes: nodes.statuses ?? [] }, + cycles: { nodes: nodes.cycles ?? [] }, + parentIssues: { nodes: nodes.parentIssues ?? [] }, + }); + return { client: { request } as unknown as GraphQLClient, request }; +} describe("resolveSearchFilterIds", () => { beforeEach(() => { vi.resetAllMocks(); }); - it("passes resolved team UUID to status/cycle lookups", async () => { - const gql = {} as unknown as GraphQLClient; - - resolveTeamIdMock.mockResolvedValue("team-uuid"); - resolveStatusIdMock.mockResolvedValue("state-uuid"); - resolveCycleIdMock.mockResolvedValue("cycle-uuid"); + it("resolves all filters in a single batch request", async () => { + const { client, request } = mockGql({ + teams: [{ id: "team-uuid", key: "ENG", name: "Engineering" }], + assignees: [ + { + id: "user-uuid", + name: "John", + email: "john@example.com", + displayName: "John Doe", + }, + ], + projects: [{ id: "project-uuid", name: "Q1" }], + labels: [{ id: "label-uuid", name: "Bug" }], + statuses: [ + { + id: "state-uuid", + name: "Todo", + team: { id: "team-uuid", key: "ENG" }, + }, + ], + cycles: [ + { + id: "cycle-uuid", + name: "Sprint 1", + isActive: true, + isNext: false, + isPrevious: false, + number: 1, + startsAt: "2026-01-01T00:00:00.000Z", + team: { id: "team-uuid", key: "ENG" }, + }, + ], + parentIssues: [{ id: "parent-uuid", identifier: "ENG-1" }], + }); - const result = await resolveSearchFilterIds(gql, { + const result = await resolveSearchFilterIds(client, { team: "ENG", + assignee: "John Doe", + project: "Q1", + // "bug" lower-case must still match "Bug" (case-insensitive). + labelNames: ["bug"], statusNames: ["Todo"], cycle: "Sprint 1", + parent: "ENG-1", }); - expect(resolveTeamIdMock).toHaveBeenCalledWith(gql, "ENG"); - expect(resolveStatusIdMock).toHaveBeenCalledWith(gql, "Todo", "team-uuid"); - expect(resolveCycleIdMock).toHaveBeenCalledWith( - gql, - "Sprint 1", - "team-uuid", - ); + expect(request).toHaveBeenCalledTimes(1); expect(result).toEqual({ teamId: "team-uuid", + assigneeId: "user-uuid", + projectId: "project-uuid", + labelIds: ["label-uuid"], stateIds: ["state-uuid"], cycleId: "cycle-uuid", + parentId: "parent-uuid", }); + // Status matched from the batch response — no per-status fallback call. + expect(resolveStatusIdMock).not.toHaveBeenCalled(); }); - it("falls back to raw team input for cycle lookup when team not pre-resolved", async () => { - const gql = {} as unknown as GraphQLClient; + it("falls back to global status resolution when a name is not team-scoped", async () => { + const { client } = mockGql({}); // no statuses returned (e.g. no team) + resolveStatusIdMock.mockResolvedValue("global-state-uuid"); - resolveCycleIdMock.mockResolvedValue("cycle-uuid"); - - const result = await resolveSearchFilterIds(gql, { - cycle: "Sprint 2", - team: "Engineering", + const result = await resolveSearchFilterIds(client, { + statusNames: ["In Progress"], }); - expect(resolveCycleIdMock).toHaveBeenCalledWith( - gql, - "Sprint 2", - "Engineering", + expect(resolveStatusIdMock).toHaveBeenCalledWith( + client, + "In Progress", + undefined, ); - expect(result).toEqual({ cycleId: "cycle-uuid" }); + expect(result).toEqual({ stateIds: ["global-state-uuid"] }); + }); + + it("throws when the team cannot be resolved", async () => { + const { client } = mockGql({ teams: [] }); + + await expect( + resolveSearchFilterIds(client, { team: "Nope" }), + ).rejects.toThrow('Team "Nope" not found'); + }); + + it("passes UUID inputs through without matching against the response", async () => { + const { client, request } = mockGql({}); + + const result = await resolveSearchFilterIds(client, { + assignee: "550e8400-e29b-41d4-a716-446655440000", + }); + + expect(request).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + assigneeId: "550e8400-e29b-41d4-a716-446655440000", + }); }); }); diff --git a/tests/unit/resolvers/issue-mutation-resolver.test.ts b/tests/unit/resolvers/issue-mutation-resolver.test.ts new file mode 100644 index 00000000..8598511b --- /dev/null +++ b/tests/unit/resolvers/issue-mutation-resolver.test.ts @@ -0,0 +1,414 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { + resolveCreateIssueIds, + resolveUpdateIssueIds, +} from "../../../src/resolvers/issue-mutation-resolver.js"; + +const UUID = "550e8400-e29b-41d4-a716-446655440000"; + +type Nodes = { + teams?: Array<{ + id: string; + key: string; + name: string; + issueEstimationType?: string; + issueEstimationExtended?: boolean; + issueEstimationAllowZero?: boolean; + }>; + assignees?: Array<{ + id: string; + name: string; + email: string; + displayName: string; + }>; + projects?: Array<{ + id: string; + name: string; + projectMilestones?: Array<{ id: string; name: string }>; + }>; + labels?: Array<{ id: string; name: string }>; + statuses?: Array<{ + id: string; + name: string; + team: { id: string; key: string }; + }>; + cycles?: Array<{ + id: string; + name: string | null; + isActive: boolean; + isNext: boolean; + isPrevious: boolean; + number: number; + startsAt: string; + team: { id: string; key: string }; + }>; + parentIssues?: Array<{ id: string; identifier: string }>; +}; + +function buildResponse(nodes: Nodes) { + return { + teams: { + nodes: (nodes.teams ?? []).map((t) => ({ + issueEstimationType: "fibonacci", + issueEstimationExtended: false, + issueEstimationAllowZero: false, + ...t, + })), + }, + assignees: { nodes: nodes.assignees ?? [] }, + projects: { + nodes: (nodes.projects ?? []).map((p) => ({ + id: p.id, + name: p.name, + projectMilestones: { nodes: p.projectMilestones ?? [] }, + })), + }, + labels: { nodes: nodes.labels ?? [] }, + statuses: { nodes: nodes.statuses ?? [] }, + cycles: { nodes: nodes.cycles ?? [] }, + parentIssues: { nodes: nodes.parentIssues ?? [] }, + }; +} + +function mockGql(nodes: Nodes) { + const request = vi.fn().mockResolvedValue(buildResponse(nodes)); + return { client: { request } as unknown as GraphQLClient, request }; +} + +describe("resolveCreateIssueIds", () => { + it("resolves every reference in a single batch request", async () => { + const { client, request } = mockGql({ + teams: [{ id: "team-uuid", key: "ENG", name: "Engineering" }], + assignees: [ + { + id: "user-uuid", + name: "John", + email: "john@example.com", + displayName: "John Doe", + }, + ], + projects: [ + { + id: "project-uuid", + name: "Q1", + projectMilestones: [{ id: "ms-uuid", name: "M1" }], + }, + ], + labels: [{ id: "label-uuid", name: "Bug" }], + statuses: [ + { + id: "state-uuid", + name: "Todo", + team: { id: "team-uuid", key: "ENG" }, + }, + ], + cycles: [ + { + id: "cycle-uuid", + name: "Sprint 1", + isActive: true, + isNext: false, + isPrevious: false, + number: 1, + startsAt: "2026-01-01T00:00:00.000Z", + team: { id: "team-uuid", key: "ENG" }, + }, + ], + parentIssues: [{ id: "parent-uuid", identifier: "ENG-1" }], + }); + + const result = await resolveCreateIssueIds(client, { + team: "ENG", + assignee: "John Doe", + project: "Q1", + labels: ["bug"], // lower-case must match "Bug" + projectMilestone: "M1", + cycle: "Sprint 1", + status: "Todo", + parentTicket: "ENG-1", + }); + + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + teamKey: "ENG", + teamName: "ENG", + assigneeQuery: "John Doe", + labelFilter: { or: [{ name: { eqIgnoreCase: "bug" } }] }, + statusName: "Todo", + cycleName: "Sprint 1", + milestoneName: "M1", + parentTeamKey: "ENG", + parentIssueNumber: 1, + }), + ); + expect(result).toEqual({ + teamId: "team-uuid", + assigneeId: "user-uuid", + projectId: "project-uuid", + labelIds: ["label-uuid"], + projectMilestoneId: "ms-uuid", + cycleId: "cycle-uuid", + stateId: "state-uuid", + parentId: "parent-uuid", + }); + }); + + it("passes UUID inputs through without name lookups", async () => { + const { client, request } = mockGql({}); + + const result = await resolveCreateIssueIds(client, { + team: UUID, + assignee: UUID, + project: UUID, + status: UUID, + parentTicket: UUID, + }); + + expect(request).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + teamKey: null, + teamName: null, + teamId: UUID, + assigneeQuery: null, + projectName: null, + projectId: UUID, + statusName: null, + parentTeamKey: null, + parentIssueNumber: null, + }), + ); + expect(result).toEqual({ + teamId: UUID, + assigneeId: UUID, + projectId: UUID, + stateId: UUID, + parentId: UUID, + }); + }); + + it("prefers a display-name match over an email match", async () => { + const { client } = mockGql({ + teams: [{ id: "team-uuid", key: "ENG", name: "Engineering" }], + assignees: [ + { + id: "by-name", + name: "John", + email: "someone@else.com", + displayName: "John Doe", + }, + { + id: "by-email", + name: "Other", + email: "john doe", + displayName: "Other Person", + }, + ], + }); + + const result = await resolveCreateIssueIds(client, { + team: "ENG", + assignee: "John Doe", + }); + + expect(result.assigneeId).toBe("by-name"); + }); + + it("throws when multiple users match by display name", async () => { + const { client } = mockGql({ + teams: [{ id: "team-uuid", key: "ENG", name: "Engineering" }], + assignees: [ + { + id: "u1", + name: "Alex A", + email: "a1@example.com", + displayName: "Alex", + }, + { + id: "u2", + name: "Alex B", + email: "a2@example.com", + displayName: "Alex", + }, + ], + }); + + await expect( + resolveCreateIssueIds(client, { team: "ENG", assignee: "Alex" }), + ).rejects.toThrow('Multiple Users found matching "Alex"'); + }); + + it("falls back to email when no display name matches", async () => { + const { client } = mockGql({ + teams: [{ id: "team-uuid", key: "ENG", name: "Engineering" }], + assignees: [ + { + id: "u2", + name: "Jane", + email: "jane@example.com", + displayName: "Jane Roe", + }, + ], + }); + + const result = await resolveCreateIssueIds(client, { + team: "ENG", + assignee: "jane@example.com", + }); + + expect(result.assigneeId).toBe("u2"); + }); + + it("throws when an assignee cannot be found", async () => { + const { client } = mockGql({ + teams: [{ id: "team-uuid", key: "ENG", name: "Engineering" }], + }); + + await expect( + resolveCreateIssueIds(client, { team: "ENG", assignee: "ghost" }), + ).rejects.toThrow('User "ghost" not found'); + }); + + it("throws when the team cannot be resolved", async () => { + const { client } = mockGql({ teams: [] }); + + await expect( + resolveCreateIssueIds(client, { team: "NOPE" }), + ).rejects.toThrow('Team "NOPE" not found'); + }); + + it("returns estimate context from the same request when requested", async () => { + const { client, request } = mockGql({ + teams: [ + { + id: "team-uuid", + key: "ENG", + name: "Engineering", + issueEstimationType: "fibonacci", + issueEstimationExtended: true, + issueEstimationAllowZero: true, + }, + ], + }); + + const result = await resolveCreateIssueIds(client, { + team: "ENG", + withEstimateContext: true, + }); + + expect(request).toHaveBeenCalledTimes(1); + expect(result.estimateContext).toEqual({ + teamId: "team-uuid", + teamKey: "ENG", + teamName: "Engineering", + issueEstimationType: "fibonacci", + issueEstimationExtended: true, + issueEstimationAllowZero: true, + }); + }); + + it("throws when a project is not found", async () => { + const { client } = mockGql({ + teams: [{ id: "team-uuid", key: "ENG", name: "Engineering" }], + projects: [], + }); + + await expect( + resolveCreateIssueIds(client, { team: "ENG", project: "Ghost" }), + ).rejects.toThrow('Project "Ghost" not found'); + }); + + it("scopes milestone resolution to the matched project", async () => { + const { client } = mockGql({ + teams: [{ id: "team-uuid", key: "ENG", name: "Engineering" }], + projects: [{ id: "project-uuid", name: "Q1", projectMilestones: [] }], + }); + + await expect( + resolveCreateIssueIds(client, { + team: "ENG", + project: "Q1", + projectMilestone: "Ghost", + }), + ).rejects.toThrow('Milestone "Ghost" not found'); + }); +}); + +describe("resolveUpdateIssueIds", () => { + it("resolves references in a single request scoped by issue context", async () => { + const { client, request } = mockGql({ + assignees: [ + { + id: "user-uuid", + name: "Jane", + email: "jane@example.com", + displayName: "Jane Roe", + }, + ], + statuses: [ + { + id: "state-uuid", + name: "Done", + team: { id: "team-uuid", key: "ENG" }, + }, + ], + }); + + const result = await resolveUpdateIssueIds( + client, + { assignee: "Jane Roe", status: "Done" }, + { teamId: "team-uuid" as never, teamKey: "ENG" }, + ); + + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + assigneeQuery: "Jane Roe", + statusName: "Done", + teamKey: "ENG", + teamId: "team-uuid", + }), + ); + expect(result).toEqual({ + assigneeId: "user-uuid", + stateId: "state-uuid", + }); + }); + + it("parses a parent identifier into filter variables", async () => { + const { client, request } = mockGql({ + parentIssues: [{ id: "parent-uuid", identifier: "ENG-7" }], + }); + + const result = await resolveUpdateIssueIds( + client, + { parentTicket: "ENG-7" }, + {}, + ); + + expect(request).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + parentTeamKey: "ENG", + parentIssueNumber: 7, + }), + ); + expect(result.parentId).toBe("parent-uuid"); + }); + + it("passes UUID inputs through", async () => { + const { client } = mockGql({}); + + const result = await resolveUpdateIssueIds( + client, + { assignee: UUID, project: UUID }, + {}, + ); + + expect(result).toEqual({ assigneeId: UUID, projectId: UUID }); + }); +}); From c0cbc95889c4b7aaafe7e3281e9c8458bb71fefe Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:56:21 +0200 Subject: [PATCH 65/79] fix(resolvers): restore global cycle fallback in search without team The batch search query scopes cycles to a team, so resolving a cycle by name without a --team returned an empty node set and threw "Cycle not found". Fall back to resolveCycleId (global lookup) when the batch response has no cycles, mirroring the existing status fallback, and add a regression test. Refs #126 --- src/resolvers/issue-filter-resolver.ts | 36 ++++++++++++------- .../resolvers/issue-filter-resolver.test.ts | 23 +++++++++++- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/src/resolvers/issue-filter-resolver.ts b/src/resolvers/issue-filter-resolver.ts index a6872923..df7f1e7c 100644 --- a/src/resolvers/issue-filter-resolver.ts +++ b/src/resolvers/issue-filter-resolver.ts @@ -15,6 +15,7 @@ import { mapProjectId, mapUser, } from "./batch-resolve-mappers.js"; +import { resolveCycleId } from "./cycle-resolver.js"; import { resolveStatusId } from "./status-resolver.js"; export interface SearchFilterResolutionInput { @@ -44,11 +45,11 @@ export interface SearchFilterResolution { * `BatchResolveForSearch` request, preserving the exact semantics of the * individual resolvers via the shared batch mappers. * - * Statuses are the one exception: the batch query returns the workflow states - * of the scoped team (matched client-side by name). When no team is given — or - * a name is not among the returned states — resolution falls back to - * `resolveStatusId`, which matches globally, so status filtering without a team - * keeps working. + * Statuses and cycles are the exception: the batch query scopes both to the + * team (matched client-side by name). When no team is given — or the name is + * not among the returned nodes — resolution falls back to `resolveStatusId` / + * `resolveCycleId`, which match globally, so filtering by status or cycle name + * without a team keeps working. */ export async function resolveSearchFilterIds( gqlClient: GraphQLClient, @@ -134,13 +135,24 @@ export async function resolveSearchFilterIds( } if (input.cycle) { - resolved.cycleId = isUuid(input.cycle) - ? asUuid(input.cycle) - : mapCycle( - response.cycles.nodes, - input.cycle, - resolved.teamId ?? input.team, - ); + if (isUuid(input.cycle)) { + resolved.cycleId = asUuid(input.cycle); + } else if (response.cycles.nodes.length > 0) { + resolved.cycleId = mapCycle( + response.cycles.nodes, + input.cycle, + resolved.teamId ?? input.team, + ); + } else { + // No cycles in the batch response (the query scopes them to a team, so + // this is the no-team case) — resolve individually to match globally, as + // resolveCycleId did before. + resolved.cycleId = await resolveCycleId( + gqlClient, + input.cycle, + resolved.teamId ?? input.team, + ); + } } if (input.parent) { diff --git a/tests/unit/resolvers/issue-filter-resolver.test.ts b/tests/unit/resolvers/issue-filter-resolver.test.ts index 4462a8ca..a64fad32 100644 --- a/tests/unit/resolvers/issue-filter-resolver.test.ts +++ b/tests/unit/resolvers/issue-filter-resolver.test.ts @@ -2,14 +2,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { resolveSearchFilterIds } from "../../../src/resolvers/issue-filter-resolver.js"; -const { resolveStatusIdMock } = vi.hoisted(() => ({ +const { resolveStatusIdMock, resolveCycleIdMock } = vi.hoisted(() => ({ resolveStatusIdMock: vi.fn(), + resolveCycleIdMock: vi.fn(), })); vi.mock("../../../src/resolvers/status-resolver.js", () => ({ resolveStatusId: resolveStatusIdMock, })); +vi.mock("../../../src/resolvers/cycle-resolver.js", () => ({ + resolveCycleId: resolveCycleIdMock, +})); + type BatchNodes = { teams?: Array<{ id: string; key: string; name: string }>; assignees?: Array<{ @@ -148,6 +153,22 @@ describe("resolveSearchFilterIds", () => { expect(result).toEqual({ stateIds: ["global-state-uuid"] }); }); + it("falls back to global cycle resolution when no cycles are team-scoped", async () => { + const { client } = mockGql({}); // no cycles returned (e.g. no team) + resolveCycleIdMock.mockResolvedValue("global-cycle-uuid"); + + const result = await resolveSearchFilterIds(client, { + cycle: "Sprint 1", + }); + + expect(resolveCycleIdMock).toHaveBeenCalledWith( + client, + "Sprint 1", + undefined, + ); + expect(result).toEqual({ cycleId: "global-cycle-uuid" }); + }); + it("throws when the team cannot be resolved", async () => { const { client } = mockGql({ teams: [] }); From 58b87455a5e9e1a1fa20b5d6b3b8b502f4456708 Mon Sep 17 00:00:00 2001 From: semantic-release-bot <semantic-release-bot@martynus.net> Date: Fri, 3 Jul 2026 15:00:12 +0000 Subject: [PATCH 66/79] chore(release): 2026.6.0-next.10 [skip ci] ## [2026.6.0-next.10](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.9...v2026.6.0-next.10) (2026-07-03) ### Bug Fixes * **resolvers:** restore global cycle fallback in search without team ([c0cbc95](https://github.com/linearis-oss/linearis/commit/c0cbc95889c4b7aaafe7e3281e9c8458bb71fefe)), closes [#126](https://github.com/linearis-oss/linearis/issues/126) ### Performance Improvements * **issues:** batch-resolve assignee and IDs in create/update ([819c045](https://github.com/linearis-oss/linearis/commit/819c0451c909bbe2c5d297b6b584ce4da1358391)), closes [#126](https://github.com/linearis-oss/linearis/issues/126) --- 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 7e75234b..ca1e2147 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## [2026.6.0-next.10](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.9...v2026.6.0-next.10) (2026-07-03) + +### Bug Fixes + +* **resolvers:** restore global cycle fallback in search without team ([c0cbc95](https://github.com/linearis-oss/linearis/commit/c0cbc95889c4b7aaafe7e3281e9c8458bb71fefe)), closes [#126](https://github.com/linearis-oss/linearis/issues/126) + +### Performance Improvements + +* **issues:** batch-resolve assignee and IDs in create/update ([819c045](https://github.com/linearis-oss/linearis/commit/819c0451c909bbe2c5d297b6b584ce4da1358391)), closes [#126](https://github.com/linearis-oss/linearis/issues/126) + ## [2026.6.0-next.9](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.8...v2026.6.0-next.9) (2026-07-03) ### Bug Fixes diff --git a/package-lock.json b/package-lock.json index 2b049248..db14fd64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linearis", - "version": "2026.6.0-next.9", + "version": "2026.6.0-next.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.6.0-next.9", + "version": "2026.6.0-next.10", "license": "MIT", "dependencies": { "commander": "14.0.3", diff --git a/package.json b/package.json index 2f4d5508..05efd34f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.6.0-next.9", + "version": "2026.6.0-next.10", "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 1233ad995e858ee61b925ac580e4b231c6dfa1c8 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:02:47 +0200 Subject: [PATCH 67/79] feat(skill): add agent skill and Claude Code plugin for the CLI Ships an agentskills.io-standard SKILL.md teaching agents the preflight and discover-then-act protocol, plus Claude Code plugin and marketplace manifests. Documents install paths in the README. --- .claude-plugin/marketplace.json | 22 +++++++++++++++ .claude-plugin/plugin.json | 11 ++++++++ README.md | 38 +++++++++++--------------- skills/linearis/SKILL.md | 48 +++++++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 23 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .claude-plugin/plugin.json create mode 100644 skills/linearis/SKILL.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 00000000..d6279fc1 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,22 @@ +{ + "name": "linearis", + "owner": { + "name": "linearis-oss", + "url": "https://github.com/linearis-oss/linearis" + }, + "metadata": { + "description": "Marketplace for the linearis Claude Code plugin.", + "version": "1.0.0" + }, + "plugins": [ + { + "name": "linearis", + "source": "./", + "description": "Agent skill teaching agents to use the linearis Linear.app CLI.", + "version": "1.0.0", + "author": { "name": "linearis-oss" }, + "homepage": "https://github.com/linearis-oss/linearis", + "license": "MIT" + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 00000000..6f33d71f --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "linearis", + "description": "Agent skill for the linearis Linear.app CLI: preflight, discover-then-act usage protocol, JSON output, ID resolution, discussions, files.", + "version": "1.0.0", + "author": { + "name": "linearis-oss", + "url": "https://github.com/linearis-oss/linearis" + }, + "homepage": "https://github.com/linearis-oss/linearis", + "license": "MIT" +} diff --git a/README.md b/README.md index 0555c53d..4d48f527 100644 --- a/README.md +++ b/README.md @@ -145,36 +145,28 @@ The agent never loads the full API surface into context — it pays for what it Use Linearis when token efficiency matters and you work primarily with issues and related data. Use the MCP when you need full API coverage or tight tool-call integration. -### Example agent prompt +### Agent skill -Add this (or a version adapted to your workflow) to your `AGENTS.md` or `CLAUDE.md` so every session has it in context: +Linearis ships an agent skill (following the [agentskills.io](https://agentskills.io) standard) so your agent knows how to use it — no prompt to paste. The skill preflights the install, advisory-checks for updates, then follows the discover-then-act protocol above. -```markdown -## Linear (project management) +**Any harness (recommended)** — Vercel's skills CLI installs into the right place for 70+ agents and lists it on [skills.sh](https://skills.sh): -Tool: `linearis` CLI, invoked via Bash. All output is JSON. +```bash +npx skills add linearis-oss/linearis +``` + +**Claude Code** — native plugin: -Discovery (do this before acting): run `linearis usage` once for the list of -domains, then `linearis <domain> usage` for a domain's full command reference. -Never guess flags or subcommands — check usage first. +``` +/plugin marketplace add linearis-oss/linearis +/plugin install linearis@linearis +``` -Tickets: always reference by identifier, e.g. `ABC-123`. +**OpenAI Codex** — `npx skills add linearis-oss/linearis` installs to `~/.agents/skills/`; invoke with `/skills` or `$`. -Workflow rules: -- Ask which project a new ticket belongs to when it's unclear; subtasks inherit - the parent's project by default. -- Keep the ticket description in sync when a task in it changes status. -- Record progress that isn't a simple checkbox change in a discussion thread - (`issues discuss`), not in the description. +**pi** — `npx skills add linearis-oss/linearis` (or drop `skills/linearis/` into `.pi/skills/`); invoke `/skill:linearis`. -Files: `files download <url>` only fetches Linear storage URLs -(`uploads.linear.app`), such as images embedded in descriptions or comments. -Upload new files with `files upload <file>`; it returns an `assetUrl` you can -embed in descriptions or comments. `issues read --with-attachments` lists -resources linked to an issue (PRs, docs, external URLs) under an -`attachments.nodes` array whose entries carry a `url` — these are references, -not necessarily downloadable files. -``` +**Google Antigravity** — `npx skills add linearis-oss/linearis` installs to `.agents/skills/`; auto-discovered from the skill list. ## Documentation diff --git a/skills/linearis/SKILL.md b/skills/linearis/SKILL.md new file mode 100644 index 00000000..3d91ab5c --- /dev/null +++ b/skills/linearis/SKILL.md @@ -0,0 +1,48 @@ +--- +name: linearis +description: >- + Manage Linear.app work from the command line with the linearis CLI (bins + linearis / linear), which outputs JSON: issues/tickets, projects, cycles + (sprints), milestones, initiatives (roadmap), documents, labels, teams, + users, and issue discussions/comments. Use when the user mentions Linear, a + ticket identifier like ENG-42 or ABC-123, sprints, triage, or the roadmap, or + asks to create, read, search, update, assign, comment on, or otherwise manage + Linear issues and projects. +license: MIT +compatibility: Requires the linearis CLI (npm i -g linearis), Node >=22, and a Linear API token. +allowed-tools: Bash(linearis:*) Bash(linear:*) Bash(jq:*) +metadata: + author: linearis-oss + version: "1.0.0" +--- + +# linearis + +Drive [Linear.app](https://linear.app) from the shell via the `linearis` CLI (JSON-only output; `linear` is an alias). Do not guess the command surface — the CLI documents itself, and this skill teaches the protocol, not the flags. + +## Preflight (reactive — branch on the CLI's own output; don't pre-run checks every turn) + +- **Not installed** — if the shell reports command-not-found, tell the user linearis isn't installed and offer `npm install -g linearis`. As a no-install fallback, prefix commands with `npx linearis@latest` (adds cold-start latency and needs network per call — fallback, not default). Never silently `npm install -g`. +- **Auth required** — any command may fail with this envelope on stderr and exit code 42: + `{ "error": "AUTHENTICATION_REQUIRED", "action": "USER_ACTION_REQUIRED", "instruction": "Run 'linearis auth' …", "exit_code": 42 }`. + Detect it by `exit_code === 42` / `error === "AUTHENTICATION_REQUIRED"` (not paraphrased text) and surface the CLI's own `instruction`. `linearis auth` is an interactive browser flow you cannot complete — hand it to the user. +- **Updates (advisory, never blocking)** — optionally run `linearis version check` once → `{ current, latest, channel, updateAvailable }`. If `updateAvailable` is true, mention it and ask the user before `npm install -g linearis@latest`, honoring `channel` (don't move a `next` user to `latest`). npm can hang or rate-limit; on any timeout/error just proceed with the installed version. Read the plain installed version with `linearis version` (JSON), not `--version`. + +## Discover, then act + +1. Run `linearis usage` once for the list of domains (issues, projects, cycles, …). +2. Run `linearis <domain> usage` for a domain's full command and flag reference **before** acting. +3. Never invent flags or subcommands — `usage` is authoritative and always current. + +## Output + +Every command prints JSON on stdout. Shape it at the source with the global `--fields identifier,title,state.name` and `--compact` — no external binary, works on Windows and fresh containers. Reach for `jq` only for complex reshaping, and fall back to raw JSON if `jq` is absent. + +## Invariants worth knowing (everything else lives in `usage`) + +- IDs are forgiving: pass a UUID, team key (`ENG`), issue identifier (`ABC-123`), or name interchangeably. Reference tickets by identifier. +- `issues create` requires `--team`; some filters need a scope flag — confirm in `usage` rather than memorizing. +- Threaded discussion lives under `issues discuss` / `discussions` / `replies` / `reply`. The top-level `comments` domain is a deprecated facade (still works) — prefer the `issues` discussion commands. Record non-trivial progress in a discussion thread and keep the description in sync on status changes. +- `files download <url>` only fetches Linear storage URLs (`uploads.linear.app`); `files upload` returns an `assetUrl` you can embed; `issues read --with-attachments` lists linked resources (PRs, docs, URLs) — references, not necessarily downloadable files. + +For anything not covered here, `linearis <domain> usage` is the reference. From 66db24863e1669ac2dad88aa9fc60ca0cfdd0e31 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:07:47 +0200 Subject: [PATCH 68/79] fix(skill): comma-separate allowed-tools in SKILL.md frontmatter The allowed-tools field is parsed as a comma-separated list; the space-separated value was read as a single unmatched pattern, so the Bash pre-approvals never took effect. --- skills/linearis/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/linearis/SKILL.md b/skills/linearis/SKILL.md index 3d91ab5c..f9ef4010 100644 --- a/skills/linearis/SKILL.md +++ b/skills/linearis/SKILL.md @@ -10,7 +10,7 @@ description: >- Linear issues and projects. license: MIT compatibility: Requires the linearis CLI (npm i -g linearis), Node >=22, and a Linear API token. -allowed-tools: Bash(linearis:*) Bash(linear:*) Bash(jq:*) +allowed-tools: Bash(linearis:*), Bash(linear:*), Bash(jq:*) metadata: author: linearis-oss version: "1.0.0" From 4d863bf7f6a30435a1359c062b6573f34567c30f Mon Sep 17 00:00:00 2001 From: semantic-release-bot <semantic-release-bot@martynus.net> Date: Fri, 3 Jul 2026 20:10:29 +0000 Subject: [PATCH 69/79] chore(release): 2026.6.0-next.11 [skip ci] ## [2026.6.0-next.11](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.10...v2026.6.0-next.11) (2026-07-03) ### Features * **skill:** add agent skill and Claude Code plugin for the CLI ([1233ad9](https://github.com/linearis-oss/linearis/commit/1233ad995e858ee61b925ac580e4b231c6dfa1c8)) ### Bug Fixes * **skill:** comma-separate allowed-tools in SKILL.md frontmatter ([66db248](https://github.com/linearis-oss/linearis/commit/66db24863e1669ac2dad88aa9fc60ca0cfdd0e31)) --- 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 ca1e2147..e695d845 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## [2026.6.0-next.11](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.10...v2026.6.0-next.11) (2026-07-03) + +### Features + +* **skill:** add agent skill and Claude Code plugin for the CLI ([1233ad9](https://github.com/linearis-oss/linearis/commit/1233ad995e858ee61b925ac580e4b231c6dfa1c8)) + +### Bug Fixes + +* **skill:** comma-separate allowed-tools in SKILL.md frontmatter ([66db248](https://github.com/linearis-oss/linearis/commit/66db24863e1669ac2dad88aa9fc60ca0cfdd0e31)) + ## [2026.6.0-next.10](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.9...v2026.6.0-next.10) (2026-07-03) ### Bug Fixes diff --git a/package-lock.json b/package-lock.json index db14fd64..e0b253a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linearis", - "version": "2026.6.0-next.10", + "version": "2026.6.0-next.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.6.0-next.10", + "version": "2026.6.0-next.11", "license": "MIT", "dependencies": { "commander": "14.0.3", diff --git a/package.json b/package.json index 05efd34f..9063e71c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.6.0-next.10", + "version": "2026.6.0-next.11", "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 44d579e7262beec1c869f7c49e26a22f76bb9b18 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:15:10 +0200 Subject: [PATCH 70/79] docs: add skills.sh badge to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 4d48f527..dd2c1027 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ [![Node version](https://img.shields.io/node/v/linearis.svg)](https://nodejs.org) [![CI](https://github.com/linearis-oss/linearis/actions/workflows/ci.yml/badge.svg)](https://github.com/linearis-oss/linearis/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.md) +[![skills.sh](https://skills.sh/b/linearis-oss/linearis)](https://skills.sh/linearis-oss/linearis) </div> From 046b81756530920297c5d112ccd8fea3cdadceeb Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:51:56 +0200 Subject: [PATCH 71/79] ci: fail release on failed npm publish and use OIDC trusted publishing Split clean-publish and npm publish into explicit stages so a failed publish propagates a non-zero exit and fails the release. Add a registry read-back guard to catch silent publish failures. Switch npm auth to OIDC trusted publishing (drop NPM_TOKEN) and ensure npm >= 11.5.1 in CI. --- .github/workflows/release-publish.yml | 26 +++++++++++++++----------- .gitignore | 3 +++ .releaserc.cjs | 25 +++++++++++++++++++++++-- package.json | 2 +- 4 files changed, 42 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 3847df5c..fa2a394c 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -97,26 +97,30 @@ jobs: git config user.name "${{ steps.app-bot.outputs.name }}" git config user.email "${{ steps.app-bot.outputs.email }}" + # No `registry-url`: passing it makes setup-node write a + # `_authToken=${NODE_AUTH_TOKEN}` line into .npmrc, which shadows OIDC. + # npm defaults to the official registry, and auth is OIDC trusted + # publishing (no token) — see the "Run semantic-release" step. - name: Setup, install and build uses: ./.github/actions/setup - with: - registry-url: https://registry.npmjs.org - - name: Verify npm auth - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # OIDC trusted publishing requires npm >= 11.5.1; .nvmrc pins only the + # Node major, so the bundled npm minor is not guaranteed. Pin a floor + # (not @latest, which would inject an unreviewed npm into the publish path). + - name: Ensure npm supports trusted publishing + shell: bash run: | - test -n "${NODE_AUTH_TOKEN}" || { - echo "NPM_TOKEN missing (check job environment + secret scope)" - exit 1 - } - npm whoami --registry=https://registry.npmjs.org/ + npm install -g npm@^11.5.1 + npm --version + # Auth is npm OIDC trusted publishing: no NODE_AUTH_TOKEN is set, so the + # npm CLI performs the OIDC exchange (id-token: write + npmjs trusted + # publisher for this workflow/environment). GH_TOKEN/GITHUB_TOKEN are the + # linearis-bot App token, used only for the GitHub release + git push. - name: Run semantic-release env: GH_TOKEN: ${{ steps.app-token.outputs.token }} GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} GITHUB_REF: refs/heads/${{ steps.target.outputs.branch }} GITHUB_REF_NAME: ${{ steps.target.outputs.branch }} run: npm run release:run diff --git a/.gitignore b/.gitignore index 878e3130..8760a204 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ /src/gql/ USAGE.md +# clean-publish staging dir (release publish; see .releaserc.cjs) +/.clean-pkg/ + docs/superpowers/ # pi diff --git a/.releaserc.cjs b/.releaserc.cjs index 310ab0b5..84e47bd8 100644 --- a/.releaserc.cjs +++ b/.releaserc.cjs @@ -35,8 +35,29 @@ module.exports = { [ "@semantic-release/exec", { - publishCmd: - 'npx clean-publish --access public --tag $( [ "$GITHUB_REF_NAME" = "next" ] && echo next || echo latest ) -- --provenance', + // Publish in two explicit stages so a failed `npm publish` FAILS the + // release. `clean-publish --without-publish` only strips the configured + // package.json fields into a deterministic `.clean-pkg` dir (it swallows + // exit codes, so it must NOT own the publish); the real `npm publish` + // then runs directly, and its non-zero exit propagates to this plugin. + // Auth is npm OIDC trusted publishing (no NODE_AUTH_TOKEN in CI). + // Runs under `/bin/sh -c` (POSIX) via @semantic-release/exec shell:true. + publishCmd: [ + "set -e", + "npx clean-publish --without-publish --temp-dir .clean-pkg", + 'VERSION="$(node -p "require(\'./.clean-pkg/package.json\').version")"', + 'TAG="$([ "$GITHUB_REF_NAME" = next ] && echo next || echo latest)"', + 'npm publish ./.clean-pkg --provenance --access public --tag "$TAG"', + // Read-back guard: the original outage was a publish that "succeeded" + // while nothing reached the registry. Fail loudly if the version is + // not actually visible (retry for read-after-write lag). + "for i in $(seq 1 6); do", + ' if npm view "linearis@$VERSION" version; then FOUND=1; break; fi', + ' echo "waiting for registry to reflect $VERSION ($i/6)"; sleep 5', + "done", + '[ "$FOUND" = 1 ] || { echo "publish verification failed: linearis@$VERSION not on registry"; exit 1; }', + "rm -rf .clean-pkg", + ].join("\n"), }, ], ["@semantic-release/github", { successComment: false, failComment: false }], diff --git a/package.json b/package.json index 9063e71c..0b37f9dd 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "knip": "knip --no-config-hints", "knip:ci": "knip --no-config-hints --reporter markdown", "verify:packed-binaries": "node scripts/verify-packed-binaries.mjs", - "release": "npm test && npm run build && npm run verify:packed-binaries && npx clean-publish --access public", + "release": "npm test && npm run build && npm run verify:packed-binaries && npx clean-publish --without-publish --temp-dir .clean-pkg && npm publish ./.clean-pkg --access public && rm -rf .clean-pkg", "prestart": "npm run generate", "predev": "npm run generate", "prebuild": "npm run generate && npm run generate:usage", From 687ceafde4293f734608d0cb4965bdb072a674f2 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:08:20 +0200 Subject: [PATCH 72/79] ci(release): clear stale .clean-pkg before local release publish clean-publish throws on a pre-existing temp dir and, with --without-publish, never removes it. The trailing cleanup is &&-chained, so a failed local 'npm run release' left .clean-pkg behind and broke the next run. Clear it up front. CI is unaffected (fresh checkout). --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0b37f9dd..9194c1e3 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "knip": "knip --no-config-hints", "knip:ci": "knip --no-config-hints --reporter markdown", "verify:packed-binaries": "node scripts/verify-packed-binaries.mjs", - "release": "npm test && npm run build && npm run verify:packed-binaries && npx clean-publish --without-publish --temp-dir .clean-pkg && npm publish ./.clean-pkg --access public && rm -rf .clean-pkg", + "release": "npm test && npm run build && npm run verify:packed-binaries && rm -rf .clean-pkg && npx clean-publish --without-publish --temp-dir .clean-pkg && npm publish ./.clean-pkg --access public && rm -rf .clean-pkg", "prestart": "npm run generate", "predev": "npm run generate", "prebuild": "npm run generate && npm run generate:usage", From 2ee8945be4a992fea924151f195b6a8c141400a5 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:14:29 +0200 Subject: [PATCH 73/79] ci(release): trim verbose comments in release config Keep only the non-obvious rationale (registry-url shadowing OIDC, npm floor, OIDC auth, two-stage publish, read-back guard); drop the restated detail. --- .github/workflows/release-publish.yml | 14 +++----------- .releaserc.cjs | 16 ++++++---------- 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index fa2a394c..47510de9 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -97,26 +97,18 @@ jobs: git config user.name "${{ steps.app-bot.outputs.name }}" git config user.email "${{ steps.app-bot.outputs.email }}" - # No `registry-url`: passing it makes setup-node write a - # `_authToken=${NODE_AUTH_TOKEN}` line into .npmrc, which shadows OIDC. - # npm defaults to the official registry, and auth is OIDC trusted - # publishing (no token) — see the "Run semantic-release" step. + # No registry-url: it writes _authToken into .npmrc, shadowing OIDC. - name: Setup, install and build uses: ./.github/actions/setup - # OIDC trusted publishing requires npm >= 11.5.1; .nvmrc pins only the - # Node major, so the bundled npm minor is not guaranteed. Pin a floor - # (not @latest, which would inject an unreviewed npm into the publish path). + # OIDC trusted publishing needs npm >= 11.5.1; .nvmrc pins only Node major. - name: Ensure npm supports trusted publishing shell: bash run: | npm install -g npm@^11.5.1 npm --version - # Auth is npm OIDC trusted publishing: no NODE_AUTH_TOKEN is set, so the - # npm CLI performs the OIDC exchange (id-token: write + npmjs trusted - # publisher for this workflow/environment). GH_TOKEN/GITHUB_TOKEN are the - # linearis-bot App token, used only for the GitHub release + git push. + # Publishes via OIDC (no NODE_AUTH_TOKEN); GH_TOKEN is only for the release + push. - name: Run semantic-release env: GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.releaserc.cjs b/.releaserc.cjs index 84e47bd8..4c961fa4 100644 --- a/.releaserc.cjs +++ b/.releaserc.cjs @@ -35,22 +35,18 @@ module.exports = { [ "@semantic-release/exec", { - // Publish in two explicit stages so a failed `npm publish` FAILS the - // release. `clean-publish --without-publish` only strips the configured - // package.json fields into a deterministic `.clean-pkg` dir (it swallows - // exit codes, so it must NOT own the publish); the real `npm publish` - // then runs directly, and its non-zero exit propagates to this plugin. - // Auth is npm OIDC trusted publishing (no NODE_AUTH_TOKEN in CI). - // Runs under `/bin/sh -c` (POSIX) via @semantic-release/exec shell:true. + // Two stages so a failed publish fails the release: clean-publish + // swallows exit codes, so it only stages .clean-pkg (--without-publish) + // and the real `npm publish` runs separately and can propagate failure. publishCmd: [ "set -e", "npx clean-publish --without-publish --temp-dir .clean-pkg", 'VERSION="$(node -p "require(\'./.clean-pkg/package.json\').version")"', 'TAG="$([ "$GITHUB_REF_NAME" = next ] && echo next || echo latest)"', 'npm publish ./.clean-pkg --provenance --access public --tag "$TAG"', - // Read-back guard: the original outage was a publish that "succeeded" - // while nothing reached the registry. Fail loudly if the version is - // not actually visible (retry for read-after-write lag). + // Read-back guard: fail if the version is not visible on the + // registry (the outage was a publish that "succeeded" but published + // nothing); retry for read-after-write lag. "for i in $(seq 1 6); do", ' if npm view "linearis@$VERSION" version; then FOUND=1; break; fi', ' echo "waiting for registry to reflect $VERSION ($i/6)"; sleep 5', From 5b0b08223f24038dda28918adc0187b7da037249 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:42:25 +0200 Subject: [PATCH 74/79] chore(conductor): add repository agent prompts Add a [prompts] block to .conductor/settings.toml so every Conductor agent on this repo works like a senior engineer: surgical changes, English output, disciplined git usage, and a commit history held to the same bar as the code. - general: lean, always-on persona + discipline; defers to AGENTS.md for architecture/invariants/checklist and conditions verification on change type (no full build/knip on docs- or config-only edits). Also makes agents base-branch-aware (rebase before substantial work) and treats commits as durable context (well-scoped, extended what/why bodies). - create_pr/code_review/fix_errors/resolve_merge_conflicts/rename_branch: additive-safe action prompts encoding the repo's commitlint rules, release-owned CHANGELOG guard, P0 invariants, and branch naming. create_pr adds a history-hygiene step (rebase, then reshape into well-isolated scoped commits) with a guardrail against rewriting shared/merged history; review and fix-errors flag/clean messy history. Prompt wording synthesized from three expert audits (prompt engineering, Claude Code behavior, Conductor mechanism). --- .conductor/settings.toml | 114 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/.conductor/settings.toml b/.conductor/settings.toml index 1c893dff..91fd5e1a 100644 --- a/.conductor/settings.toml +++ b/.conductor/settings.toml @@ -21,3 +21,117 @@ icon = "package" [scripts.run.check] command = "npm run check:ci" icon = "wrench" + +[prompts] +# Appended to EVERY agent session. Keep lean — AGENTS.md (loaded as CLAUDE.md) is the +# single source of truth for architecture, the P0 invariants, and the verification +# checklist; do not restate them here. +general = """ +You are a senior engineer on Linearis, a JSON-only CLI for Linear.app (TypeScript strict, \ +ESM). Always respond in English, even when addressed in another language. + +AGENTS.md (loaded as CLAUDE.md) is authoritative for the 5-layer architecture, the P0 \ +invariants, and the verification checklist — follow it; do not re-derive or restate its rules. + +Be a careful git operator. Before starting substantial work, bring the branch up to date \ +with its base branch (the branch it will merge into) — fetch, then rebase onto it rather \ +than merging it in — so your changes apply cleanly and the history stays linear. + +Treat the commit history as durable context for future contributors, held to the same \ +standard as the code. Make each commit well-scoped and single-purpose, and give it an \ +extended body explaining not just what changed but why: the problem, the approach, and any \ +trade-offs or alternatives considered. Any TODO you leave must reference an issue. + +Verify with the AGENTS.md checklist, scoped to what you touched, and report exactly what you \ +ran — never claim a check passed without running it. Skip the build/test checklist for \ +docs-, comment-, or config-only changes; run the full checklist before opening a PR. + +Never touch CHANGELOG.md — it is owned by the release workflow. +""" + +# Commit history is a first-class deliverable. Conventional Commits are enforced by +# commitlint in CI over the whole PR range, so malformed messages fail the build. +create_pr = """ +In addition to Conductor's standard Create PR behavior, hold the commit history and PR to \ +these repo rules. + +Commits — Conventional Commits, enforced by commitlint over the full PR range: +- Format `type(scope): subject`; lower-case scope; imperative subject, >= 10 chars. +- Allowed types only: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test. +- Choose the type by real impact: feat/fix/perf/revert trigger a release, the rest do not — \ + do not inflate a chore into a feat. +- One logical change per commit; a blank line before any body/footer. Reference issues in the \ + body (Closes #n, Refs #n, Part of #n) when applicable. +- Give every non-trivial commit an extended body written as context for future contributors: \ + what changed and, more importantly, why — the problem, the approach, and any trade-offs or \ + alternatives considered. Structure it (short prose or bullets); do not just restate the subject. +- Do NOT add AI co-author or Co-authored-by trailers. +- Never include CHANGELOG.md in the branch history — it is release-workflow-owned. + +History hygiene — before opening the PR, rework the branch into a clean, linear history: +- Rebase onto the latest base branch (resolve conflicts; do not merge the base in). +- Reshape the commits into well-isolated, correctly-scoped Conventional Commits: split commits \ + that mix concerns, squash fixup/wip noise, and reorder so each commit stands on its own, \ + builds, and passes its own checks. +- Ensure each resulting commit carries the extended what/why body above. +- Only rewrite history that is yours and unmerged — never rewrite commits already on the base \ + branch or that teammates build on. + +PR: +- The title must itself be a valid Conventional Commit subject. +- Body: what changed and why, the risk/impact, and which AGENTS.md checks you ran to verify. \ + Keep it tight and skimmable. +- Remove design artifacts (e.g. plan files under docs/plans/) before opening the PR. +""" + +# Correctness first, then the repo's own invariants; reference AGENTS.md rather than +# transcribing the full (drift-prone) invariant list. +code_review = """ +In addition to Conductor's standard review, report only real, verifiable issues, most severe \ +first. Do not pad with style nitpicks the linter already covers. + +Prioritise: +- Correctness and reliability: edge cases, error handling, resource/lifecycle bugs, races, \ + wrong assumptions. +- Every P0 invariant in AGENTS.md — especially strict layer separation, ID resolution only in \ + resolvers, no `any`, and explicit return types on exports. +- Test coverage: happy path + primary error case per changed function; tests mock exactly one \ + layer down. +- Maintainability and readability: naming, duplication, unnecessary complexity. +- Commit history: a clean, linear series of well-scoped Conventional Commits with informative \ + what/why bodies — flag mixed-concern, wip, or fixup commits that should be split, squashed, \ + or rebased before merge. +For each finding give file:line, why it is wrong, and a concrete fix. If the change is sound, \ +say so plainly rather than inventing problems. +""" + +# Root cause, minimal fix, verification scaled to the size of the change. +fix_errors = """ +Diagnose the root cause before changing anything — do not patch symptoms or silence the type \ +checker with casts. Make the smallest correct fix, matching surrounding style. When the bug is \ +testable, add or adjust a test that fails before the fix and passes after. Verify with the \ +AGENTS.md checklist scoped to the fix: always run the type check and tests; add the full build \ +and knip when the fix touches generated code, exports, or dependencies. Report exactly what you \ +ran and its result. If chasing the fix left the branch history messy (wip/fixup commits, mixed \ +concerns), fold it back into clean, well-scoped Conventional Commits with what/why bodies before \ +finishing. +""" + +# Never silently drop a side; regenerate generated code from resolved sources, in that order. +resolve_merge_conflicts = """ +Resolve conflicts by understanding the intent of both sides, not by picking one blindly. \ +Preserve every behavioural change from both branches; if two changes are genuinely \ +incompatible, stop and surface it rather than dropping one. For conflicts in generated files \ +(src/gql/), resolve the .graphql sources first, then run `npm run generate` to regenerate \ +rather than hand-merging. Never resurrect CHANGELOG.md edits. After resolving, run \ +`npx tsc --noEmit` and `npm test` to confirm the merged tree is coherent. +""" + +# Conventional-Commit-aligned, descriptive, kebab-case; issue number first when one exists. +rename_branch = """ +Name the branch after the change: `type/short-kebab-summary`, where type is a Conventional \ +Commit type (feat, fix, chore, docs, refactor, ...). Lower-case, hyphen-separated, concise but \ +descriptive. When an issue exists, put its number first: `type/NNN-short-summary` \ +(e.g. fix/142-null-token, feat/issue-search-filters). Conductor may add its own branch prefix; \ +keep the descriptive part in this form regardless. +""" From 38a3ea3379a7c92eba728d0d5ee7537dbd2a452b Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:21:59 +0200 Subject: [PATCH 75/79] feat(issues): add activity command with threaded discussion timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `issues activity <issue>`, which merges an issue's comment threads (root comments plus their nested replies) with its history events into a single chronological timeline, paginated with an opaque id cursor. Why: users and agents inspecting an issue previously had to correlate the separate `discussions` output with Linear's field-change history by hand. Linear exposes no unified activity connection, so the service materializes both connections, normalizes each history event into a compact list of meaningful changes (state, assignee, priority, project, cycle, title, estimate, labels, archived), resolves label IDs to names, and sorts the combined set ascending by creation time. Design notes and trade-offs: - History and comment connections are independent, so both are exhausted concurrently before assembly; label names depend on the history nodes and are resolved afterwards. Empty-change history events are dropped so they do not consume pagination slots. - As a stateless CLI there is no cross-invocation cache, so each `--after` page re-materializes the full timeline before slicing — the same materialize-then-slice model already used for discussion reply pagination. Cheap for typical issues; only pathological histories pay. - `--comments-only` skips history (and the dependent label lookup); `--with-reactions` returns normalized reactions on roots and replies. To feed the activity service, `discussion-service` is refactored to expose reusable reply helpers (`buildThreadRepliesIndex` / `collectThreadReplies`, with `filterThreadReplies` now layered on top) and issue-scoped reply-candidate fetchers, and `GetLabels` gains an `includeArchived` argument (default false) so historic label changes still resolve to names. The fetch-until-empty loop shared by every full-connection fetcher is extracted into a `collectConnection` helper in `common/types` so each fetcher only declares its query, per-page guard, and cursor extraction. Closes #144 --- graphql/queries/activity.graphql | 74 ++++ graphql/queries/labels.graphql | 15 +- src/commands/issues.ts | 43 ++ src/common/types.ts | 34 ++ src/services/activity-service.ts | 427 +++++++++++++++++++ src/services/discussion-service.ts | 74 +++- tests/unit/services/activity-service.test.ts | 366 ++++++++++++++++ 7 files changed, 1025 insertions(+), 8 deletions(-) create mode 100644 graphql/queries/activity.graphql create mode 100644 src/services/activity-service.ts create mode 100644 tests/unit/services/activity-service.test.ts diff --git a/graphql/queries/activity.graphql b/graphql/queries/activity.graphql new file mode 100644 index 00000000..031dbb7b --- /dev/null +++ b/graphql/queries/activity.graphql @@ -0,0 +1,74 @@ +fragment IssueHistoryFields on IssueHistory { + id + createdAt + actor { + id + displayName + } + botActor { + id + name + } + fromState { + id + name + } + toState { + id + name + } + fromAssignee { + id + displayName + } + toAssignee { + id + displayName + } + fromPriority + toPriority + fromProject { + id + name + } + toProject { + id + name + } + fromCycle { + id + number + } + toCycle { + id + number + } + fromTitle + toTitle + fromEstimate + toEstimate + addedLabelIds + removedLabelIds + archived +} + +query GetIssueActivityRef($id: String!) { + issue(id: $id) { + id + identifier + } +} + +query ListIssueActivityHistory($issueId: String!, $first: Int, $after: String) { + issue(id: $issueId) { + history(first: $first, after: $after) { + nodes { + ...IssueHistoryFields + } + pageInfo { + hasNextPage + endCursor + } + } + } +} diff --git a/graphql/queries/labels.graphql b/graphql/queries/labels.graphql index 98b16d12..6c66ceca 100644 --- a/graphql/queries/labels.graphql +++ b/graphql/queries/labels.graphql @@ -44,8 +44,19 @@ query GetIssueLabel($id: String!) { # Variables: # $first: Maximum number of labels to return (default: 50) # $filter: Optional filter (e.g., { team: { id: { eq: "team-uuid" } } }) -query GetLabels($first: Int = 50, $after: String, $filter: IssueLabelFilter) { - issueLabels(first: $first, after: $after, filter: $filter) { +# $includeArchived: Include archived labels (default: false) +query GetLabels( + $first: Int = 50 + $after: String + $filter: IssueLabelFilter + $includeArchived: Boolean = false +) { + issueLabels( + first: $first + after: $after + filter: $filter + includeArchived: $includeArchived + ) { nodes { ...LabelFields } diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 55dc47f0..9e37b872 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -35,6 +35,7 @@ import { resolveIssueEstimateContext, resolveIssueId, } from "../resolvers/issue-resolver.js"; +import { getIssueActivity } from "../services/activity-service.js"; import { createDiscussionCommentReaction, deleteDiscussionComment, @@ -171,6 +172,13 @@ interface DiscussionsOptions { withReactions?: boolean; } +interface ActivityOptions { + limit: string; + after?: string; + commentsOnly?: boolean; + withReactions?: boolean; +} + interface DiscussionBodyOptions { body?: string; } @@ -265,6 +273,7 @@ export const ISSUES_META: DomainMeta = { query: "full-text search term", }, seeAlso: [ + "issues activity <issue>", "comments create <issue>", "documents list --issue <issue>", "attachments list <issue>", @@ -849,6 +858,40 @@ export function setupIssuesCommands(program: Command): void { ), ); + issues + .command("activity <issue>") + .description( + "chronological activity timeline: comment threads plus history events", + ) + .addHelpText( + "after", + `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, + ) + .option("-l, --limit <n>", "max timeline items", "50") + .option("--after <cursor>", "cursor for next page") + .option("--comments-only", "exclude non-comment history events") + .option("--with-reactions", "include normalized comment reactions") + .action( + commandAction<[string, ActivityOptions, Command]>( + async (issue, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const issueId = await resolveIssueId(ctx.gql, issue); + const paginationOptions = buildPaginationOptions( + parseLimit(options.limit), + options.after, + ); + const result = await getIssueActivity(ctx.gql, issueId, { + ...paginationOptions, + commentsOnly: Boolean(options.commentsOnly), + withReactions: Boolean(options.withReactions), + }); + + outputSuccess(result); + }, + ), + ); + issues .command("discussions <issue>") .description("list root discussion threads on an issue") diff --git a/src/common/types.ts b/src/common/types.ts index 86101bdc..0ed84247 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -25,3 +25,37 @@ export function buildPaginationOptions( ): PaginationOptions { return after === undefined ? { limit } : { limit, after }; } + +/** A Relay-style GraphQL connection page. */ +interface Connection<T> { + nodes: readonly T[]; + pageInfo: { hasNextPage: boolean; endCursor?: string | null }; +} + +/** + * Exhaust a cursor-paginated GraphQL connection, returning every node. The + * caller's `fetchPage` requests a single page for the given `after` cursor (and + * performs any per-page guards, e.g. asserting the parent entity exists); + * iteration stops once the server reports no further pages. Centralizes the + * fetch-until-empty loop shared by services that must materialize a whole + * connection before processing it. + */ +export async function collectConnection<TNode>( + fetchPage: (after: string | undefined) => Promise<Connection<TNode>>, +): Promise<TNode[]> { + const nodes: TNode[] = []; + let after: string | undefined; + + while (true) { + const connection = await fetchPage(after); + nodes.push(...connection.nodes); + + if (!connection.pageInfo.hasNextPage || !connection.pageInfo.endCursor) { + break; + } + + after = connection.pageInfo.endCursor; + } + + return nodes; +} diff --git a/src/services/activity-service.ts b/src/services/activity-service.ts new file mode 100644 index 00000000..f8787733 --- /dev/null +++ b/src/services/activity-service.ts @@ -0,0 +1,427 @@ +import type { GraphQLClient } from "../client/graphql-client.js"; +import { asUuid, type UUID } from "../common/identifier.js"; +import { collectConnection } from "../common/types.js"; +import { + GetIssueActivityRefDocument, + GetLabelsDocument, + type IssueHistoryFieldsFragment, + ListIssueActivityHistoryDocument, + ListIssueDiscussionRootsDocument, + ListIssueDiscussionRootsWithReactionsDocument, +} from "../gql/graphql.js"; +import { + buildThreadRepliesIndex, + collectThreadReplies, + type DiscussionThread, + type DiscussionThreadWithReactions, + fetchAllIssueDiscussionReplyCandidates, + fetchAllIssueDiscussionReplyCandidatesWithReactions, + normalizeDiscussionCommentsReactions, +} from "./discussion-service.js"; + +/** Page size used when exhausting a connection to build the merged timeline. */ +const TIMELINE_FETCH_LIMIT = 250; +/** Default number of top-level timeline items returned per page. */ +const DEFAULT_ACTIVITY_LIMIT = 50; + +type NamedRef = { id: string; name: string }; +type UserRef = { id: string; displayName: string }; +type CycleRef = { id: string; number: number }; +/** A label reference; `name` is null when the label no longer exists. */ +type LabelRef = { id: string; name: string | null }; + +/** A single normalized change captured by an issue history event. */ +type ActivityChange = + | { field: "state"; from: NamedRef | null; to: NamedRef | null } + | { field: "assignee"; from: UserRef | null; to: UserRef | null } + | { field: "priority"; from: number | null; to: number | null } + | { field: "project"; from: NamedRef | null; to: NamedRef | null } + | { field: "cycle"; from: CycleRef | null; to: CycleRef | null } + | { field: "title"; from: string | null; to: string | null } + | { field: "estimate"; from: number | null; to: number | null } + | { field: "labels"; added: LabelRef[]; removed: LabelRef[] } + | { field: "archived"; to: boolean }; + +interface ActivityHistoryItem { + type: "history"; + id: string; + createdAt: string; + actor: UserRef | null; + botActor: { id: string | null; name: string | null } | null; + changes: ActivityChange[]; +} + +interface ActivityCommentThreadItem< + TComment extends DiscussionThread | DiscussionThreadWithReactions, +> { + type: "commentThread"; + root: TComment; + replies: TComment[]; +} + +type ActivityItem = + | ActivityHistoryItem + | ActivityCommentThreadItem<DiscussionThread> + | ActivityCommentThreadItem<DiscussionThreadWithReactions>; + +export interface IssueActivityResult { + issue: { id: string; identifier: string }; + activity: ActivityItem[]; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; +} + +export interface IssueActivityOptions { + limit?: number; + after?: string; + commentsOnly?: boolean; + withReactions?: boolean; +} + +function refsDiffer( + from: { id: string } | null, + to: { id: string } | null, +): boolean { + return (from?.id ?? null) !== (to?.id ?? null); +} + +/** Translate a raw issue history node into its list of meaningful changes. */ +function buildHistoryChanges( + node: IssueHistoryFieldsFragment, + labelNames: ReadonlyMap<string, string>, +): ActivityChange[] { + const changes: ActivityChange[] = []; + + const toLabelRef = (id: string): LabelRef => ({ + id, + name: labelNames.get(id) ?? null, + }); + + if (refsDiffer(node.fromState, node.toState)) { + changes.push({ field: "state", from: node.fromState, to: node.toState }); + } + + if (refsDiffer(node.fromAssignee, node.toAssignee)) { + changes.push({ + field: "assignee", + from: node.fromAssignee, + to: node.toAssignee, + }); + } + + if (node.fromPriority !== node.toPriority) { + changes.push({ + field: "priority", + from: node.fromPriority, + to: node.toPriority, + }); + } + + if (refsDiffer(node.fromProject, node.toProject)) { + changes.push({ + field: "project", + from: node.fromProject, + to: node.toProject, + }); + } + + if (refsDiffer(node.fromCycle, node.toCycle)) { + changes.push({ field: "cycle", from: node.fromCycle, to: node.toCycle }); + } + + if (node.fromTitle !== node.toTitle) { + changes.push({ field: "title", from: node.fromTitle, to: node.toTitle }); + } + + if (node.fromEstimate !== node.toEstimate) { + changes.push({ + field: "estimate", + from: node.fromEstimate, + to: node.toEstimate, + }); + } + + const added = node.addedLabelIds ?? []; + const removed = node.removedLabelIds ?? []; + if (added.length > 0 || removed.length > 0) { + changes.push({ + field: "labels", + added: added.map(toLabelRef), + removed: removed.map(toLabelRef), + }); + } + + if (node.archived !== null) { + changes.push({ field: "archived", to: node.archived }); + } + + return changes; +} + +async function fetchAllIssueDiscussionRoots( + client: GraphQLClient, + issueId: UUID, +): Promise<DiscussionThread[]> { + return collectConnection(async (after) => { + const result = await client.request(ListIssueDiscussionRootsDocument, { + issueId, + first: TIMELINE_FETCH_LIMIT, + after, + }); + + if (!result.issue) { + throw new Error(`Issue with ID "${issueId}" not found`); + } + + return result.issue.comments; + }); +} + +async function fetchAllIssueDiscussionRootsWithReactions( + client: GraphQLClient, + issueId: UUID, +): Promise<DiscussionThreadWithReactions[]> { + const nodes = await collectConnection(async (after) => { + const result = await client.request( + ListIssueDiscussionRootsWithReactionsDocument, + { issueId, first: TIMELINE_FETCH_LIMIT, after }, + ); + + if (!result.issue) { + throw new Error(`Issue with ID "${issueId}" not found`); + } + + return result.issue.comments; + }); + + return normalizeDiscussionCommentsReactions(nodes); +} + +async function fetchAllIssueHistory( + client: GraphQLClient, + issueId: UUID, +): Promise<IssueHistoryFieldsFragment[]> { + return collectConnection(async (after) => { + const result = await client.request(ListIssueActivityHistoryDocument, { + issueId, + first: TIMELINE_FETCH_LIMIT, + after, + }); + + if (!result.issue) { + throw new Error(`Issue with ID "${issueId}" not found`); + } + + return result.issue.history; + }); +} + +/** Collect the distinct label IDs referenced by any history node's label changes. */ +function collectLabelIds( + nodes: readonly IssueHistoryFieldsFragment[], +): string[] { + const ids = new Set<string>(); + + for (const node of nodes) { + for (const id of node.addedLabelIds ?? []) { + ids.add(id); + } + for (const id of node.removedLabelIds ?? []) { + ids.add(id); + } + } + + return [...ids]; +} + +/** + * Resolve label IDs referenced by history events to their names so the timeline + * exposes human-readable labels (matching state/assignee/project). Archived + * labels are included so historic label changes still resolve; labels that no + * longer exist are simply absent from the map, yielding a null name. + */ +async function resolveLabelNames( + client: GraphQLClient, + ids: readonly string[], +): Promise<Map<string, string>> { + const names = new Map<string, string>(); + + if (ids.length === 0) { + return names; + } + + const labels = await collectConnection(async (after) => { + const result = await client.request(GetLabelsDocument, { + first: TIMELINE_FETCH_LIMIT, + after, + filter: { id: { in: [...ids] } }, + includeArchived: true, + }); + + return result.issueLabels; + }); + + for (const label of labels) { + names.set(label.id, label.name); + } + + return names; +} + +/** Assemble comment-thread timeline items from root threads and reply candidates. */ +function buildCommentThreadItems< + TComment extends DiscussionThread | DiscussionThreadWithReactions, +>( + roots: readonly TComment[], + candidates: readonly TComment[], +): ActivityCommentThreadItem<TComment>[] { + const childrenByParentId = buildThreadRepliesIndex(candidates); + return roots.map((root) => ({ + type: "commentThread" as const, + root, + replies: collectThreadReplies(childrenByParentId, asUuid(root.id)), + })); +} + +/** + * Fetch an issue's comment threads. Roots and reply candidates are independent + * connections, so they are exhausted concurrently before being assembled. + */ +async function fetchCommentThreadItems( + client: GraphQLClient, + issueId: UUID, + withReactions: boolean, +): Promise<ActivityItem[]> { + if (withReactions) { + const [roots, candidates] = await Promise.all([ + fetchAllIssueDiscussionRootsWithReactions(client, issueId), + fetchAllIssueDiscussionReplyCandidatesWithReactions(client, issueId), + ]); + return buildCommentThreadItems(roots, candidates); + } + + const [roots, candidates] = await Promise.all([ + fetchAllIssueDiscussionRoots(client, issueId), + fetchAllIssueDiscussionReplyCandidates(client, issueId), + ]); + return buildCommentThreadItems(roots, candidates); +} + +interface TimelineEntry { + createdAt: string; + id: string; + item: ActivityItem; +} + +function toTimelineEntry(item: ActivityItem): TimelineEntry { + return item.type === "history" + ? { createdAt: item.createdAt, id: item.id, item } + : { createdAt: item.root.createdAt, id: item.root.id, item }; +} + +function compareTimelineEntries(a: TimelineEntry, b: TimelineEntry): number { + const byCreatedAt = a.createdAt.localeCompare(b.createdAt); + return byCreatedAt !== 0 ? byCreatedAt : a.id.localeCompare(b.id); +} + +interface TimelinePage { + nodes: ActivityItem[]; + hasNextPage: boolean; + endCursor: string | null; +} + +/** Slice a sorted timeline using an opaque id cursor (mirrors reply pagination). */ +function paginateTimeline( + entries: readonly TimelineEntry[], + limit: number, + after?: string, +): TimelinePage { + const startIndex = + after === undefined + ? 0 + : entries.findIndex((entry) => entry.id === after) + 1; + + if (after !== undefined && startIndex === 0) { + throw new Error(`Activity cursor "${after}" not found`); + } + + const page = entries.slice(startIndex, startIndex + limit); + + return { + nodes: page.map((entry) => entry.item), + hasNextPage: startIndex + limit < entries.length, + endCursor: page.at(-1)?.id ?? null, + }; +} + +/** + * Build a chronological activity timeline for an issue: comment threads (root + + * nested replies) merged with issue history events, sorted ascending by + * creation time and paginated with an opaque id cursor. + * + * The timeline is materialized in full on every call before it is sliced: the + * comment and history connections are independent (Linear exposes no unified + * activity connection) and reply nesting needs all reply candidates regardless + * of the requested page. As a stateless CLI there is no cross-invocation cache, + * so each `--after` page re-fetches everything — the same materialize-then-slice + * tradeoff as {@link paginateTimeline}'s sibling in discussion reply pagination. + * This is cheap for typical issues; only pathologically large histories pay for it. + */ +export async function getIssueActivity( + client: GraphQLClient, + issueId: UUID, + options: IssueActivityOptions = {}, +): Promise<IssueActivityResult> { + const { + limit = DEFAULT_ACTIVITY_LIMIT, + after, + commentsOnly = false, + withReactions = false, + } = options; + + const ref = await client.request(GetIssueActivityRefDocument, { + id: issueId, + }); + + if (!ref.issue) { + throw new Error(`Issue with ID "${issueId}" not found`); + } + + // Comment threads and issue history are independent connections; fetch both + // concurrently. Label names depend on the history nodes, so they resolve after. + const [threadItems, historyNodes] = await Promise.all([ + fetchCommentThreadItems(client, issueId, withReactions), + commentsOnly + ? Promise.resolve<IssueHistoryFieldsFragment[]>([]) + : fetchAllIssueHistory(client, issueId), + ]); + + const labelNames = await resolveLabelNames( + client, + collectLabelIds(historyNodes), + ); + + const historyItems: ActivityItem[] = historyNodes + .map((node) => ({ + type: "history" as const, + id: node.id, + createdAt: node.createdAt, + actor: node.actor, + botActor: node.botActor, + changes: buildHistoryChanges(node, labelNames), + })) + // Drop events whose only changes fall outside the captured fragment fields; + // they carry no information and would otherwise consume pagination slots. + .filter((item) => item.changes.length > 0); + + const entries = [...threadItems, ...historyItems] + .map(toTimelineEntry) + .sort(compareTimelineEntries); + + const page = paginateTimeline(entries, limit, after); + + return { + issue: { id: ref.issue.id, identifier: ref.issue.identifier }, + activity: page.nodes, + pageInfo: { hasNextPage: page.hasNextPage, endCursor: page.endCursor }, + }; +} diff --git a/src/services/discussion-service.ts b/src/services/discussion-service.ts index ad4c5841..a04a87b1 100644 --- a/src/services/discussion-service.ts +++ b/src/services/discussion-service.ts @@ -4,7 +4,11 @@ import { requireMutationEntity, requireMutationSuccess, } from "../common/mutation-payload.js"; -import type { PaginatedResult, PaginationOptions } from "../common/types.js"; +import { + collectConnection, + type PaginatedResult, + type PaginationOptions, +} from "../common/types.js"; import { type CommentCreateInput, type CommentUpdateInput, @@ -115,7 +119,7 @@ function normalizeDiscussionCommentReactions< }; } -function normalizeDiscussionCommentsReactions< +export function normalizeDiscussionCommentsReactions< T extends { reactions: Parameters<typeof normalizeReactions>[0] }, >( comments: readonly T[], @@ -402,10 +406,53 @@ async function listDiscussionReplyCandidatesWithReactions( ); } -function filterThreadReplies<T extends DiscussionCommentFieldsFragment>( - comments: readonly T[], - threadId: UUID, -): T[] { +/** + * Fetch every reply candidate (comments with a parent) for an issue directly by + * issue UUID, looping until the connection is exhausted. Used by the activity + * timeline, which resolves the issue up front and does not have a thread context. + */ +export async function fetchAllIssueDiscussionReplyCandidates( + client: GraphQLClient, + issueId: UUID, +): Promise<DiscussionCommentFieldsFragment[]> { + const nodes = await collectConnection(async (after) => { + const result = await client.request( + ListIssueDiscussionReplyCandidatesDocument, + { issueId, first: DISCUSSION_REPLY_FETCH_LIMIT, after }, + ); + + return result.comments; + }); + + return nodes.sort(compareDiscussionCommentsChronologically); +} + +export async function fetchAllIssueDiscussionReplyCandidatesWithReactions( + client: GraphQLClient, + issueId: UUID, +): Promise<DiscussionThreadWithReactions[]> { + const nodes = await collectConnection(async (after) => { + const result = await client.request( + ListIssueDiscussionReplyCandidatesWithReactionsDocument, + { issueId, first: DISCUSSION_REPLY_FETCH_LIMIT, after }, + ); + + return result.comments; + }); + + return normalizeDiscussionCommentsReactions( + nodes.sort(compareDiscussionCommentsChronologically), + ); +} + +/** + * Index reply candidates by their `parentId`, with each sibling list sorted + * chronologically. Building this once lets callers extract many threads' + * replies without rescanning the full candidate list per thread. + */ +export function buildThreadRepliesIndex< + T extends DiscussionCommentFieldsFragment, +>(comments: readonly T[]): Map<string, T[]> { const childrenByParentId = new Map<string, T[]>(); for (const comment of comments) { @@ -419,6 +466,14 @@ function filterThreadReplies<T extends DiscussionCommentFieldsFragment>( childrenByParentId.set(comment.parentId, siblings); } + return childrenByParentId; +} + +/** Walk a pre-built reply index depth-first to collect a thread's replies. */ +export function collectThreadReplies<T extends DiscussionCommentFieldsFragment>( + childrenByParentId: ReadonlyMap<string, T[]>, + threadId: UUID, +): T[] { const replies: T[] = []; const stack = [...(childrenByParentId.get(threadId) ?? [])].reverse(); @@ -448,6 +503,13 @@ function filterThreadReplies<T extends DiscussionCommentFieldsFragment>( return replies; } +function filterThreadReplies<T extends DiscussionCommentFieldsFragment>( + comments: readonly T[], + threadId: UUID, +): T[] { + return collectThreadReplies(buildThreadRepliesIndex(comments), threadId); +} + function paginateDiscussionReplies<T extends DiscussionCommentFieldsFragment>( replies: readonly T[], limit: number, diff --git a/tests/unit/services/activity-service.test.ts b/tests/unit/services/activity-service.test.ts new file mode 100644 index 00000000..ad3dddee --- /dev/null +++ b/tests/unit/services/activity-service.test.ts @@ -0,0 +1,366 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; +import { + GetIssueActivityRefDocument, + GetLabelsDocument, + ListIssueActivityHistoryDocument, + ListIssueDiscussionReplyCandidatesDocument, + ListIssueDiscussionReplyCandidatesWithReactionsDocument, + ListIssueDiscussionRootsDocument, + ListIssueDiscussionRootsWithReactionsDocument, +} from "../../../src/gql/graphql.js"; +import { getIssueActivity } from "../../../src/services/activity-service.js"; +import type { DiscussionThreadWithReactions } from "../../../src/services/discussion-service.js"; + +const ISSUE_ID = asUuid("11111111-1111-1111-1111-111111111111"); +const USER = { id: "user-1", displayName: "Ada" }; + +function createClientMock(): GraphQLClient { + return { request: vi.fn() } as unknown as GraphQLClient; +} + +function comment( + id: string, + createdAt: string, + parentId: string | null = null, +) { + return { + id, + body: `comment-${id}`, + createdAt, + editedAt: null, + parentId, + resolvedAt: null, + resolvingComment: null, + resolvingUser: null, + user: USER, + }; +} + +function commentWithReactions( + id: string, + createdAt: string, + parentId: string | null = null, +) { + return { + ...comment(id, createdAt, parentId), + reactions: [{ id: "r-1", emoji: "👍", user: USER, externalUser: null }], + }; +} + +function historyNode( + id: string, + createdAt: string, + overrides: Record<string, unknown> = {}, +) { + return { + id, + createdAt, + fromPriority: null, + toPriority: null, + fromTitle: null, + toTitle: null, + fromEstimate: null, + toEstimate: null, + addedLabelIds: null, + removedLabelIds: null, + archived: null, + actor: USER, + botActor: null, + fromState: null, + toState: null, + fromAssignee: null, + toAssignee: null, + fromProject: null, + toProject: null, + fromCycle: null, + toCycle: null, + ...overrides, + }; +} + +const EMPTY_PAGE = { hasNextPage: false, endCursor: null }; + +interface MockData { + ref?: { id: string; identifier: string } | null; + roots?: unknown[]; + rootsWithReactions?: unknown[]; + replyCandidates?: unknown[]; + replyCandidatesWithReactions?: unknown[]; + history?: unknown[]; + labels?: { id: string; name: string }[]; +} + +function mockClient(client: GraphQLClient, data: MockData): void { + vi.mocked(client.request).mockImplementation( + async (document: unknown): Promise<unknown> => { + if (document === GetIssueActivityRefDocument) { + return { + issue: + data.ref === undefined + ? { id: ISSUE_ID, identifier: "ENG-1" } + : data.ref, + }; + } + if (document === ListIssueDiscussionRootsDocument) { + return { + issue: { + comments: { nodes: data.roots ?? [], pageInfo: EMPTY_PAGE }, + }, + }; + } + if (document === ListIssueDiscussionRootsWithReactionsDocument) { + return { + issue: { + comments: { + nodes: data.rootsWithReactions ?? [], + pageInfo: EMPTY_PAGE, + }, + }, + }; + } + if (document === ListIssueDiscussionReplyCandidatesDocument) { + return { + comments: { nodes: data.replyCandidates ?? [], pageInfo: EMPTY_PAGE }, + }; + } + if ( + document === ListIssueDiscussionReplyCandidatesWithReactionsDocument + ) { + return { + comments: { + nodes: data.replyCandidatesWithReactions ?? [], + pageInfo: EMPTY_PAGE, + }, + }; + } + if (document === ListIssueActivityHistoryDocument) { + return { + issue: { + history: { nodes: data.history ?? [], pageInfo: EMPTY_PAGE }, + }, + }; + } + if (document === GetLabelsDocument) { + return { + issueLabels: { nodes: data.labels ?? [], pageInfo: EMPTY_PAGE }, + }; + } + throw new Error("Unexpected document in mock"); + }, + ); +} + +describe("getIssueActivity", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("merges comment threads and history into one chronological timeline", async () => { + const client = createClientMock(); + mockClient(client, { + roots: [ + comment("root-1", "2026-04-21T10:00:00.000Z"), + comment("root-2", "2026-04-21T12:00:00.000Z"), + ], + replyCandidates: [ + comment("reply-1", "2026-04-21T11:00:00.000Z", "root-1"), + ], + history: [ + historyNode("hist-1", "2026-04-21T09:00:00.000Z", { + fromState: { id: "s1", name: "Todo" }, + toState: { id: "s2", name: "In Progress" }, + }), + historyNode("hist-2", "2026-04-21T13:00:00.000Z", { + toAssignee: { id: "user-2", displayName: "Alan" }, + }), + ], + }); + + const result = await getIssueActivity(client, ISSUE_ID); + + expect(result.issue).toEqual({ id: ISSUE_ID, identifier: "ENG-1" }); + expect(result.activity.map((item) => item.type)).toEqual([ + "history", + "commentThread", + "commentThread", + "history", + ]); + + const [firstHistory, firstThread] = result.activity; + expect(firstHistory).toMatchObject({ + type: "history", + id: "hist-1", + changes: [ + { + field: "state", + from: { id: "s1", name: "Todo" }, + to: { id: "s2", name: "In Progress" }, + }, + ], + }); + expect(firstThread).toMatchObject({ type: "commentThread" }); + if (firstThread?.type === "commentThread") { + expect(firstThread.root.id).toBe("root-1"); + expect(firstThread.replies.map((reply) => reply.id)).toEqual(["reply-1"]); + } + }); + + it("drops history events whose changes are all outside the captured fields", async () => { + const client = createClientMock(); + mockClient(client, { + history: [ + // No from/to captured field differs -> empty changes, should be dropped. + historyNode("hist-empty", "2026-04-21T09:00:00.000Z"), + historyNode("hist-state", "2026-04-21T10:00:00.000Z", { + fromState: { id: "s1", name: "Todo" }, + toState: { id: "s2", name: "Done" }, + }), + ], + }); + + const result = await getIssueActivity(client, ISSUE_ID); + + expect( + result.activity.map((item) => + item.type === "history" ? item.id : item.root.id, + ), + ).toEqual(["hist-state"]); + }); + + it("resolves label ids on history events to names", async () => { + const client = createClientMock(); + mockClient(client, { + history: [ + historyNode("hist-labels", "2026-04-21T10:00:00.000Z", { + addedLabelIds: ["label-1"], + removedLabelIds: ["label-2"], + }), + ], + labels: [{ id: "label-1", name: "bug" }], + }); + + const result = await getIssueActivity(client, ISSUE_ID); + + const [item] = result.activity; + expect(item).toMatchObject({ + type: "history", + changes: [ + { + field: "labels", + added: [{ id: "label-1", name: "bug" }], + // Unknown/deleted label resolves to a null name. + removed: [{ id: "label-2", name: null }], + }, + ], + }); + expect(client.request).toHaveBeenCalledWith( + GetLabelsDocument, + expect.objectContaining({ + filter: { id: { in: ["label-1", "label-2"] } }, + }), + ); + }); + + it("excludes history events when commentsOnly is set", async () => { + const client = createClientMock(); + mockClient(client, { + roots: [comment("root-1", "2026-04-21T10:00:00.000Z")], + }); + + const result = await getIssueActivity(client, ISSUE_ID, { + commentsOnly: true, + }); + + expect(result.activity.every((item) => item.type === "commentThread")).toBe( + true, + ); + expect(client.request).not.toHaveBeenCalledWith( + ListIssueActivityHistoryDocument, + expect.anything(), + ); + }); + + it("includes normalized reactions on root and replies with withReactions", async () => { + const client = createClientMock(); + mockClient(client, { + rootsWithReactions: [ + commentWithReactions("root-1", "2026-04-21T10:00:00.000Z"), + ], + replyCandidatesWithReactions: [ + commentWithReactions("reply-1", "2026-04-21T11:00:00.000Z", "root-1"), + ], + }); + + const result = await getIssueActivity(client, ISSUE_ID, { + withReactions: true, + commentsOnly: true, + }); + + const [thread] = result.activity; + expect(thread?.type).toBe("commentThread"); + if (thread?.type === "commentThread") { + const root = thread.root as DiscussionThreadWithReactions; + const replies = thread.replies as DiscussionThreadWithReactions[]; + expect(Array.isArray(root.reactions)).toBe(true); + expect(root.reactions[0]).toMatchObject({ emoji: "👍" }); + expect(replies[0]?.reactions[0]).toMatchObject({ emoji: "👍" }); + } + expect(client.request).toHaveBeenCalledWith( + ListIssueDiscussionRootsWithReactionsDocument, + expect.anything(), + ); + }); + + it("paginates the merged timeline with an id cursor", async () => { + const client = createClientMock(); + mockClient(client, { + roots: [ + comment("root-1", "2026-04-21T10:00:00.000Z"), + comment("root-2", "2026-04-21T11:00:00.000Z"), + comment("root-3", "2026-04-21T12:00:00.000Z"), + ], + }); + + const firstPage = await getIssueActivity(client, ISSUE_ID, { + limit: 2, + commentsOnly: true, + }); + expect(firstPage.activity).toHaveLength(2); + expect(firstPage.pageInfo.hasNextPage).toBe(true); + expect(firstPage.pageInfo.endCursor).toBe("root-2"); + + const secondPage = await getIssueActivity(client, ISSUE_ID, { + limit: 2, + after: "root-2", + commentsOnly: true, + }); + expect(secondPage.activity).toHaveLength(1); + expect(secondPage.pageInfo.hasNextPage).toBe(false); + expect(secondPage.pageInfo.endCursor).toBe("root-3"); + }); + + it("throws when the after cursor is unknown", async () => { + const client = createClientMock(); + mockClient(client, { + roots: [comment("root-1", "2026-04-21T10:00:00.000Z")], + }); + + await expect( + getIssueActivity(client, ISSUE_ID, { + after: "does-not-exist", + commentsOnly: true, + }), + ).rejects.toThrow(/cursor "does-not-exist" not found/); + }); + + it("throws when the issue does not exist", async () => { + const client = createClientMock(); + mockClient(client, { ref: null }); + + await expect(getIssueActivity(client, ISSUE_ID)).rejects.toThrow( + /not found/, + ); + }); +}); From 29bb9bda3fbc7ca0746f1adcc91d2d6850480a12 Mon Sep 17 00:00:00 2001 From: semantic-release-bot <semantic-release-bot@martynus.net> Date: Sat, 4 Jul 2026 16:36:27 +0000 Subject: [PATCH 76/79] chore(release): 2026.6.0-next.12 [skip ci] ## [2026.6.0-next.12](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.11...v2026.6.0-next.12) (2026-07-04) ### Features * **issues:** add activity command with threaded discussion timeline ([38a3ea3](https://github.com/linearis-oss/linearis/commit/38a3ea3379a7c92eba728d0d5ee7537dbd2a452b)), closes [#144](https://github.com/linearis-oss/linearis/issues/144) --- 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 e695d845..244b74be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [2026.6.0-next.12](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.11...v2026.6.0-next.12) (2026-07-04) + +### Features + +* **issues:** add activity command with threaded discussion timeline ([38a3ea3](https://github.com/linearis-oss/linearis/commit/38a3ea3379a7c92eba728d0d5ee7537dbd2a452b)), closes [#144](https://github.com/linearis-oss/linearis/issues/144) + ## [2026.6.0-next.11](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.10...v2026.6.0-next.11) (2026-07-03) ### Features diff --git a/package-lock.json b/package-lock.json index e0b253a4..6e51b96d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linearis", - "version": "2026.6.0-next.11", + "version": "2026.6.0-next.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.6.0-next.11", + "version": "2026.6.0-next.12", "license": "MIT", "dependencies": { "commander": "14.0.3", diff --git a/package.json b/package.json index 9194c1e3..d6ed9ad1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.6.0-next.11", + "version": "2026.6.0-next.12", "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 aa2e47230fba54af46f4884903d1d7d380f31e4b Mon Sep 17 00:00:00 2001 From: semantic-release-bot <semantic-release-bot@martynus.net> Date: Sat, 4 Jul 2026 16:43:54 +0000 Subject: [PATCH 77/79] chore(release): 2026.6.0-next.12 [skip ci] ## [2026.6.0-next.12](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.11...v2026.6.0-next.12) (2026-07-04) ### Features * **issues:** add activity command with threaded discussion timeline ([38a3ea3](https://github.com/linearis-oss/linearis/commit/38a3ea3379a7c92eba728d0d5ee7537dbd2a452b)), closes [#144](https://github.com/linearis-oss/linearis/issues/144) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 244b74be..05860c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ * **issues:** add activity command with threaded discussion timeline ([38a3ea3](https://github.com/linearis-oss/linearis/commit/38a3ea3379a7c92eba728d0d5ee7537dbd2a452b)), closes [#144](https://github.com/linearis-oss/linearis/issues/144) +## [2026.6.0-next.12](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.11...v2026.6.0-next.12) (2026-07-04) + +### Features + +* **issues:** add activity command with threaded discussion timeline ([38a3ea3](https://github.com/linearis-oss/linearis/commit/38a3ea3379a7c92eba728d0d5ee7537dbd2a452b)), closes [#144](https://github.com/linearis-oss/linearis/issues/144) + ## [2026.6.0-next.11](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.10...v2026.6.0-next.11) (2026-07-03) ### Features From c89208bc7a14514da62caec25364207fa29fbb18 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:56:12 +0200 Subject: [PATCH 78/79] feat(teams): add team create, update, and membership management Add `teams create`, `teams update`, `teams members`, `teams add-member`, and `teams remove-member` subcommands with resolver-backed team/user ID resolution and paginated membership fetching. Estimation, cycle, and triage settings are exposed as explicit true|false flags. Boolean flags accept only true|false (not 1/0/yes/no) so scripts can set or unset settings unambiguously, and numeric flags reject blank input, which Number() would otherwise coerce to 0. Closes #142 --- graphql/mutations/teams.graphql | 59 ++++ graphql/queries/teams.graphql | 35 +++ src/commands/teams.ts | 362 ++++++++++++++++++++++- src/services/team-service.ts | 192 +++++++++++- tests/integration/teams-cli.test.ts | 32 ++ tests/unit/commands/teams.test.ts | 182 +++++++++++- tests/unit/services/team-service.test.ts | 250 ++++++++++++++++ 7 files changed, 1106 insertions(+), 6 deletions(-) create mode 100644 graphql/mutations/teams.graphql diff --git a/graphql/mutations/teams.graphql b/graphql/mutations/teams.graphql new file mode 100644 index 00000000..3ade8b26 --- /dev/null +++ b/graphql/mutations/teams.graphql @@ -0,0 +1,59 @@ +# ------------------------------------------------------------ +# GraphQL mutations for Linear team operations +# +# Creates and updates teams, and manages team membership. Team +# mutations return the same TeamDetailFields fragment that `teams read` +# queries, so the entity shape matches (the read command additionally +# derives valid estimate options on top of these fields). Membership +# mutations use Linear's TeamMembership entity: a membership joins a +# user to a team and is deleted by its own id. +# ------------------------------------------------------------ + +# Create a new team +# +# Requires at minimum a name. The key is auto-derived from the name +# when omitted. Returns complete detail fields. +mutation CreateTeam($input: TeamCreateInput!) { + teamCreate(input: $input) { + success + team { + ...TeamDetailFields + } + } +} + +# Update an existing team +# +# Updates mutable team metadata and settings, returning detail fields. +mutation UpdateTeam($id: String!, $input: TeamUpdateInput!) { + teamUpdate(id: $id, input: $input) { + success + team { + ...TeamDetailFields + } + } +} + +# Add a user to a team +# +# Creates a TeamMembership joining the user to the team. Set owner to +# grant team-admin rights. +mutation AddTeamMember($input: TeamMembershipCreateInput!) { + teamMembershipCreate(input: $input) { + success + teamMembership { + ...TeamMembershipFields + } + } +} + +# Remove a user from a team +# +# Deletes a TeamMembership by its id (resolved from team + user by the +# service layer). +mutation RemoveTeamMember($id: String!) { + teamMembershipDelete(id: $id) { + success + entityId + } +} diff --git a/graphql/queries/teams.graphql b/graphql/queries/teams.graphql index cc1241af..1a1f26e8 100644 --- a/graphql/queries/teams.graphql +++ b/graphql/queries/teams.graphql @@ -105,3 +105,38 @@ query FindTeams($filter: TeamFilter, $first: Int = 1) { } } } + +# Team membership fields fragment +# +# A membership joins a user to a team. The membership id is required to +# delete the membership; owner marks a team admin. +fragment TeamMembershipFields on TeamMembership { + id + owner + user { + id + name + email + displayName + } +} + +# List a team's memberships +# +# Used by `teams members` and to resolve a membership id from a +# team + user pair for `teams remove-member`. Paginated by the service +# layer so all members are fetched regardless of team size. +query GetTeamMemberships($id: String!, $first: Int = 250, $after: String) { + team(id: $id) { + id + memberships(first: $first, after: $after) { + nodes { + ...TeamMembershipFields + } + pageInfo { + hasNextPage + endCursor + } + } + } +} diff --git a/src/commands/teams.ts b/src/commands/teams.ts index 859e7752..f88fdaec 100644 --- a/src/commands/teams.ts +++ b/src/commands/teams.ts @@ -1,10 +1,26 @@ import type { Command } from "commander"; -import { createContext, getRootOpts } from "../common/context.js"; +import { + type CommandContext, + createContext, + getRootOpts, +} from "../common/context.js"; +import { invalidParameterError } from "../common/errors.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { buildPaginationOptions } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { resolveTeamId } from "../resolvers/team-resolver.js"; -import { getTeam, listTeams } from "../services/team-service.js"; +import { resolveUserId } from "../resolvers/user-resolver.js"; +import { + addTeamMember, + type CreateTeamInput, + createTeam, + getTeam, + listTeamMembers, + listTeams, + removeTeamMember, + type UpdateTeamInput, + updateTeam, +} from "../services/team-service.js"; export const TEAMS_META: DomainMeta = { name: "teams", @@ -12,11 +28,233 @@ export const TEAMS_META: DomainMeta = { context: [ "a team is a group of users that owns issues, cycles, statuses, and", "labels. teams are identified by a short key (e.g. ENG), name, or UUID.", + "teams can be created and updated, and their membership managed with", + "add-member/remove-member. boolean settings take an explicit true|false", + "value so scripts can set or unset them unambiguously.", ].join("\n"), - arguments: {}, - seeAlso: [], + arguments: { + team: "team identifier (key, name, or UUID)", + name: "team display name", + user: "user identifier (display name, email, or UUID)", + }, + seeAlso: ["users list", "issues create --team", "cycles list --team"], }; +const ESTIMATION_TYPES = [ + "notUsed", + "exponential", + "fibonacci", + "linear", + "tShirt", +] as const; + +function parseBooleanOption(flag: string, value: string): boolean { + const normalized = value.trim().toLowerCase(); + if (normalized === "true") return true; + if (normalized === "false") return false; + throw invalidParameterError(flag, `expected true or false, got "${value}"`); +} + +function parseIntegerOption(flag: string, value: string): number { + // Number("") and Number(" ") coerce to 0, so reject blank input first. + const parsed = value.trim() === "" ? Number.NaN : Number(value); + if (!Number.isInteger(parsed)) { + throw invalidParameterError(flag, `expected an integer, got "${value}"`); + } + return parsed; +} + +// Linear types cycle/auto-close durations and cycleStartDay as Float, so +// fractional values are valid (e.g. a cycleStartDay with a time-of-day +// component). Only finite numbers are accepted. +function parseNumberOption(flag: string, value: string): number { + // Number("") and Number(" ") coerce to 0, so reject blank input first. + const parsed = value.trim() === "" ? Number.NaN : Number(value); + if (!Number.isFinite(parsed)) { + throw invalidParameterError(flag, `expected a number, got "${value}"`); + } + return parsed; +} + +function parseEstimationType(value: string): string { + if (!(ESTIMATION_TYPES as readonly string[]).includes(value)) { + throw invalidParameterError( + "--estimation-type", + `expected one of ${ESTIMATION_TYPES.join(", ")}, got "${value}"`, + ); + } + return value; +} + +// Shared mutable fields accepted by both `create` and `update`. `name` is +// handled by the caller (positional for create, --name for update). +interface TeamFieldOptions { + key?: string; + description?: string; + private?: string; + icon?: string; + color?: string; + timezone?: string; + parent?: string; + estimationType?: string; + estimationExtended?: string; + estimationAllowZero?: string; + defaultEstimate?: string; + inheritEstimation?: string; + cyclesEnabled?: string; + cycleDuration?: string; + cycleCooldown?: string; + cycleStartDay?: string; + triageEnabled?: string; + requirePriorityToLeaveTriage?: string; + autoClosePeriod?: string; + autoArchivePeriod?: string; +} + +// Build the shared mutable field set once, resolving the parent team to a +// UUID. Only fields the user provided are included, so `update` never +// overwrites untouched settings. +async function buildTeamFields( + ctx: CommandContext, + options: TeamFieldOptions, +): Promise<UpdateTeamInput> { + const input: UpdateTeamInput = {}; + + if (options.key !== undefined) input.key = options.key; + if (options.description !== undefined) + input.description = options.description; + if (options.icon !== undefined) input.icon = options.icon; + if (options.color !== undefined) input.color = options.color; + if (options.timezone !== undefined) input.timezone = options.timezone; + + if (options.private !== undefined) { + input.private = parseBooleanOption("--private", options.private); + } + + if (options.parent !== undefined) { + input.parentId = await resolveTeamId(ctx.gql, options.parent); + } + + if (options.estimationType !== undefined) { + input.issueEstimationType = parseEstimationType(options.estimationType); + } + if (options.estimationExtended !== undefined) { + input.issueEstimationExtended = parseBooleanOption( + "--estimation-extended", + options.estimationExtended, + ); + } + if (options.estimationAllowZero !== undefined) { + input.issueEstimationAllowZero = parseBooleanOption( + "--estimation-allow-zero", + options.estimationAllowZero, + ); + } + if (options.defaultEstimate !== undefined) { + input.defaultIssueEstimate = parseIntegerOption( + "--default-estimate", + options.defaultEstimate, + ); + } + if (options.inheritEstimation !== undefined) { + input.inheritIssueEstimation = parseBooleanOption( + "--inherit-estimation", + options.inheritEstimation, + ); + } + + if (options.cyclesEnabled !== undefined) { + input.cyclesEnabled = parseBooleanOption( + "--cycles-enabled", + options.cyclesEnabled, + ); + } + if (options.cycleDuration !== undefined) { + input.cycleDuration = parseNumberOption( + "--cycle-duration", + options.cycleDuration, + ); + } + if (options.cycleCooldown !== undefined) { + input.cycleCooldownTime = parseNumberOption( + "--cycle-cooldown", + options.cycleCooldown, + ); + } + if (options.cycleStartDay !== undefined) { + input.cycleStartDay = parseNumberOption( + "--cycle-start-day", + options.cycleStartDay, + ); + } + + if (options.triageEnabled !== undefined) { + input.triageEnabled = parseBooleanOption( + "--triage-enabled", + options.triageEnabled, + ); + } + if (options.requirePriorityToLeaveTriage !== undefined) { + input.requirePriorityToLeaveTriage = parseBooleanOption( + "--require-priority-to-leave-triage", + options.requirePriorityToLeaveTriage, + ); + } + if (options.autoClosePeriod !== undefined) { + input.autoClosePeriod = parseNumberOption( + "--auto-close-period", + options.autoClosePeriod, + ); + } + if (options.autoArchivePeriod !== undefined) { + input.autoArchivePeriod = parseNumberOption( + "--auto-archive-period", + options.autoArchivePeriod, + ); + } + + return input; +} + +// Register the estimation/cycle/triage flags shared by create and update. +function addTeamSettingFlags(command: Command): Command { + return command + .option("--description <text>", "team description") + .option("--private <true|false>", "whether the team is private") + .option("--icon <icon>", "team icon") + .option("--color <color>", "team color (hex)") + .option("--timezone <tz>", "team timezone (e.g. America/New_York)") + .option("--parent <team>", "parent team (key, name, or UUID)") + .option( + "--estimation-type <type>", + `estimation scale (${ESTIMATION_TYPES.join(" | ")})`, + ) + .option( + "--estimation-extended <true|false>", + "add extended estimate points", + ) + .option( + "--estimation-allow-zero <true|false>", + "allow zero-point estimates", + ) + .option("--default-estimate <n>", "default estimate for unestimated issues") + .option( + "--inherit-estimation <true|false>", + "inherit estimation from parent (sub-teams only)", + ) + .option("--cycles-enabled <true|false>", "whether the team uses cycles") + .option("--cycle-duration <weeks>", "cycle length in weeks") + .option("--cycle-cooldown <n>", "cooldown between cycles in weeks") + .option("--cycle-start-day <n>", "day of week a new cycle starts") + .option("--triage-enabled <true|false>", "whether triage mode is enabled") + .option( + "--require-priority-to-leave-triage <true|false>", + "require a priority before leaving triage", + ) + .option("--auto-close-period <months>", "auto-close period in months") + .option("--auto-archive-period <months>", "auto-archive period in months"); +} + export function setupTeamsCommands(program: Command): void { const teams = program.command("teams").description("Team operations"); @@ -56,6 +294,122 @@ export function setupTeamsCommands(program: Command): void { }), ); + addTeamSettingFlags( + teams + .command("create <name>") + .description("create a new team") + .option( + "--key <key>", + "unique team key (auto-derived from name if omitted)", + ), + ).action( + handleCommand(async (...args: unknown[]) => { + const [name, options, command] = args as [ + string, + TeamFieldOptions, + Command, + ]; + const ctx = createContext(getRootOpts(command)); + const fields = await buildTeamFields(ctx, options); + const input: CreateTeamInput = { ...fields, name }; + const result = await createTeam(ctx.gql, input); + outputSuccess(result); + }), + ); + + addTeamSettingFlags( + teams + .command("update <team>") + .description("update an existing team") + .option("--name <name>", "new team name") + .option("--key <key>", "new team key"), + ).action( + handleCommand(async (...args: unknown[]) => { + const [team, options, command] = args as [ + string, + TeamFieldOptions & { name?: string }, + Command, + ]; + const ctx = createContext(getRootOpts(command)); + const input = await buildTeamFields(ctx, options); + if (options.name !== undefined) input.name = options.name; + + if (Object.keys(input).length === 0) { + throw invalidParameterError( + "update options", + "at least one field must be provided", + ); + } + + const teamId = await resolveTeamId(ctx.gql, team); + const result = await updateTeam(ctx.gql, teamId, input); + outputSuccess(result); + }), + ); + + teams + .command("members <team>") + .description("list a team's members") + .action( + handleCommand(async (...args: unknown[]) => { + const team = args[0] as string; + const command = args.at(-1) as Command; + const ctx = createContext(getRootOpts(command)); + const teamId = await resolveTeamId(ctx.gql, team); + const result = await listTeamMembers(ctx.gql, { id: teamId }); + outputSuccess(result); + }), + ); + + teams + .command("add-member <team>") + .description("add a user to a team") + .requiredOption("--user <user>", "user display name, email, or UUID") + .option("--owner <true|false>", "grant team-admin (owner) rights") + .action( + handleCommand(async (...args: unknown[]) => { + const [team, options, command] = args as [ + string, + { user: string; owner?: string }, + Command, + ]; + const ctx = createContext(getRootOpts(command)); + const [teamId, userId] = await Promise.all([ + resolveTeamId(ctx.gql, team), + resolveUserId(ctx.gql, options.user), + ]); + const result = await addTeamMember(ctx.gql, { + teamId, + userId, + ...(options.owner === undefined + ? {} + : { owner: parseBooleanOption("--owner", options.owner) }), + }); + outputSuccess(result); + }), + ); + + teams + .command("remove-member <team>") + .description("remove a user from a team") + .requiredOption("--user <user>", "user display name, email, or UUID") + .action( + handleCommand(async (...args: unknown[]) => { + const [team, options, command] = args as [ + string, + { user: string }, + Command, + ]; + const ctx = createContext(getRootOpts(command)); + const [teamId, userId] = await Promise.all([ + resolveTeamId(ctx.gql, team), + resolveUserId(ctx.gql, options.user), + ]); + const result = await removeTeamMember(ctx.gql, { teamId, userId }); + outputSuccess(result); + }), + ); + teams .command("usage") .description("show detailed usage for teams") diff --git a/src/services/team-service.ts b/src/services/team-service.ts index 653281d9..da5da686 100644 --- a/src/services/team-service.ts +++ b/src/services/team-service.ts @@ -1,10 +1,26 @@ import type { GraphQLClient } from "../client/graphql-client.js"; -import type { UUID } from "../common/identifier.js"; +import { notFoundError } from "../common/errors.js"; +import type { BrandUuidFields, UUID } from "../common/identifier.js"; +import { + requireMutationEntity, + requireMutationSuccess, +} from "../common/mutation-payload.js"; import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { + AddTeamMemberDocument, + type AddTeamMemberMutation, + CreateTeamDocument, + type CreateTeamMutation, GetTeamByIdDocument, type GetTeamByIdQuery, + GetTeamMembershipsDocument, + type GetTeamMembershipsQuery, GetTeamsDocument, + RemoveTeamMemberDocument, + type TeamCreateInput, + type TeamUpdateInput, + UpdateTeamDocument, + type UpdateTeamMutation, } from "../gql/graphql.js"; // Team projection types @@ -26,6 +42,65 @@ export interface Team { name: string; } +export type CreatedTeam = NonNullable<CreateTeamMutation["teamCreate"]["team"]>; +export type UpdatedTeam = NonNullable<UpdateTeamMutation["teamUpdate"]["team"]>; +export type TeamMembership = NonNullable< + AddTeamMemberMutation["teamMembershipCreate"]["teamMembership"] +>; + +// Mutable team fields the command layer may set. UUIDs (parentId) are +// pre-resolved by the command before reaching the service. +type TeamMutableFields = + | "name" + | "key" + | "description" + | "private" + | "icon" + | "color" + | "timezone" + | "parentId" + | "issueEstimationType" + | "issueEstimationExtended" + | "issueEstimationAllowZero" + | "defaultIssueEstimate" + | "inheritIssueEstimation" + | "cyclesEnabled" + | "cycleDuration" + | "cycleCooldownTime" + | "cycleStartDay" + | "triageEnabled" + | "requirePriorityToLeaveTriage" + | "autoClosePeriod" + | "autoArchivePeriod"; + +export type CreateTeamInput = BrandUuidFields< + Pick<TeamCreateInput, TeamMutableFields>, + "parentId" +>; +export type UpdateTeamInput = BrandUuidFields< + Pick<TeamUpdateInput, TeamMutableFields>, + "parentId" +>; + +export interface AddTeamMemberInput { + teamId: UUID; + userId: UUID; + owner?: boolean; +} + +export interface RemoveTeamMemberInput { + teamId: UUID; + userId: UUID; +} + +export type DeletedTeamMembership = { + id: string; + success: true; +}; + +type TeamMembershipNode = + GetTeamMembershipsQuery["team"]["memberships"]["nodes"][number]; + interface GetTeamInput { id: UUID; } @@ -154,3 +229,118 @@ export async function getTeam( estimationSource: source, }; } + +export async function createTeam( + client: GraphQLClient, + input: CreateTeamInput, +): Promise<CreatedTeam> { + const gqlInput: TeamCreateInput = input; + const result = await client.request(CreateTeamDocument, { input: gqlInput }); + + return requireMutationEntity( + result.teamCreate, + "team", + `Failed to create team "${input.name}"`, + ); +} + +export async function updateTeam( + client: GraphQLClient, + id: UUID, + input: UpdateTeamInput, +): Promise<UpdatedTeam> { + const gqlInput: TeamUpdateInput = input; + const result = await client.request(UpdateTeamDocument, { + id, + input: gqlInput, + }); + + return requireMutationEntity( + result.teamUpdate, + "team", + `Failed to update team "${id}"`, + ); +} + +async function fetchTeamMemberships( + client: GraphQLClient, + teamId: UUID, +): Promise<TeamMembershipNode[]> { + const nodes: TeamMembershipNode[] = []; + let after: string | undefined; + + while (true) { + const result = await client.request(GetTeamMembershipsDocument, { + id: teamId, + after, + }); + + if (!result.team) { + throw notFoundError("Team", teamId); + } + + const { memberships } = result.team; + nodes.push(...memberships.nodes); + + if (!memberships.pageInfo.hasNextPage || !memberships.pageInfo.endCursor) { + break; + } + + after = memberships.pageInfo.endCursor; + } + + return nodes; +} + +export async function listTeamMembers( + client: GraphQLClient, + input: GetTeamInput, +): Promise<{ nodes: TeamMembershipNode[] }> { + return { nodes: await fetchTeamMemberships(client, input.id) }; +} + +export async function addTeamMember( + client: GraphQLClient, + input: AddTeamMemberInput, +): Promise<TeamMembership> { + const result = await client.request(AddTeamMemberDocument, { + input: { + teamId: input.teamId, + userId: input.userId, + ...(input.owner === undefined ? {} : { owner: input.owner }), + }, + }); + + return requireMutationEntity( + result.teamMembershipCreate, + "teamMembership", + `Failed to add user "${input.userId}" to team "${input.teamId}"`, + ); +} + +export async function removeTeamMember( + client: GraphQLClient, + input: RemoveTeamMemberInput, +): Promise<DeletedTeamMembership> { + const memberships = await fetchTeamMemberships(client, input.teamId); + const membership = memberships.find((m) => m.user?.id === input.userId); + + if (!membership) { + throw notFoundError( + "Team member", + input.userId, + `on team "${input.teamId}"`, + ); + } + + const result = await client.request(RemoveTeamMemberDocument, { + id: membership.id, + }); + + requireMutationSuccess( + result.teamMembershipDelete, + `Failed to remove user "${input.userId}" from team "${input.teamId}"`, + ); + + return { id: result.teamMembershipDelete.entityId, success: true }; +} diff --git a/tests/integration/teams-cli.test.ts b/tests/integration/teams-cli.test.ts index be3ce10e..ca672ada 100644 --- a/tests/integration/teams-cli.test.ts +++ b/tests/integration/teams-cli.test.ts @@ -34,6 +34,38 @@ describe("Teams CLI Commands", () => { expect(stdout).toContain("Team operations"); expect(stdout).toContain("list"); }); + + it("should list the management subcommands", async () => { + const { stdout } = await execAsync(`node ${CLI_PATH} teams --help`); + + expect(stdout).toContain("create"); + expect(stdout).toContain("update"); + expect(stdout).toContain("members"); + expect(stdout).toContain("add-member"); + expect(stdout).toContain("remove-member"); + }); + }); + + describe("teams create --help", () => { + it("should document create flags", async () => { + const { stdout } = await execAsync( + `node ${CLI_PATH} teams create --help`, + ); + + expect(stdout).toContain("--key"); + expect(stdout).toContain("--estimation-type"); + expect(stdout).toContain("--parent"); + }); + }); + + describe("teams add-member --help", () => { + it("should document the --user flag", async () => { + const { stdout } = await execAsync( + `node ${CLI_PATH} teams add-member --help`, + ); + + expect(stdout).toContain("--user"); + }); }); describe("teams list", () => { diff --git a/tests/unit/commands/teams.test.ts b/tests/unit/commands/teams.test.ts index f1652b91..a23af4f7 100644 --- a/tests/unit/commands/teams.test.ts +++ b/tests/unit/commands/teams.test.ts @@ -21,6 +21,10 @@ vi.mock("../../../src/resolvers/team-resolver.js", () => ({ resolveTeamId: vi.fn().mockResolvedValue("resolved-team-uuid"), })); +vi.mock("../../../src/resolvers/user-resolver.js", () => ({ + resolveUserId: vi.fn().mockResolvedValue("resolved-user-uuid"), +})); + vi.mock("../../../src/services/team-service.js", () => ({ listTeams: vi.fn().mockResolvedValue({ nodes: [{ id: "team-1", key: "ENG", name: "Engineering" }], @@ -38,12 +42,36 @@ vi.mock("../../../src/services/team-service.js", () => ({ ], estimationSource: "self", }), + createTeam: vi + .fn() + .mockResolvedValue({ id: "team-new", key: "NEW", name: "New Team" }), + updateTeam: vi + .fn() + .mockResolvedValue({ id: "team-1", key: "ENG", name: "Renamed" }), + listTeamMembers: vi.fn().mockResolvedValue({ + nodes: [{ id: "m1", owner: true, user: { id: "user-1", name: "Alice" } }], + }), + addTeamMember: vi.fn().mockResolvedValue({ + id: "m1", + owner: false, + user: { id: "user-1", name: "Alice" }, + }), + removeTeamMember: vi.fn().mockResolvedValue({ id: "m1", success: true }), })); import { setupTeamsCommands } from "../../../src/commands/teams.js"; import { outputSuccess } from "../../../src/common/output.js"; import { resolveTeamId } from "../../../src/resolvers/team-resolver.js"; -import { getTeam, listTeams } from "../../../src/services/team-service.js"; +import { resolveUserId } from "../../../src/resolvers/user-resolver.js"; +import { + addTeamMember, + createTeam, + getTeam, + listTeamMembers, + listTeams, + removeTeamMember, + updateTeam, +} from "../../../src/services/team-service.js"; function createProgram(): Command { const program = new Command(); @@ -101,3 +129,155 @@ describe("teams list", () => { }); }); }); + +describe("teams create", () => { + it("builds input from flags and outputs the created team", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "teams", + "create", + "New Team", + "--key", + "NEW", + "--private", + "true", + "--cycles-enabled", + "false", + "--cycle-duration", + "2", + ]); + + expect(createTeam).toHaveBeenCalledWith(expect.anything(), { + name: "New Team", + key: "NEW", + private: true, + cyclesEnabled: false, + cycleDuration: 2, + }); + expect(outputSuccess).toHaveBeenCalledWith({ + id: "team-new", + key: "NEW", + name: "New Team", + }); + }); + + it("rejects an invalid estimation type", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "teams", + "create", + "New Team", + "--estimation-type", + "bogus", + ]); + + expect(createTeam).not.toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); +}); + +describe("teams update", () => { + it("resolves the team and passes only provided fields", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "teams", + "update", + "ENG", + "--name", + "Renamed", + "--triage-enabled", + "true", + ]); + + expect(resolveTeamId).toHaveBeenCalledWith(expect.anything(), "ENG"); + expect(updateTeam).toHaveBeenCalledWith( + expect.anything(), + "resolved-team-uuid", + { name: "Renamed", triageEnabled: true }, + ); + }); + + it("errors when no fields are provided", async () => { + const program = createProgram(); + + await program.parseAsync(["node", "test", "teams", "update", "ENG"]); + + expect(updateTeam).not.toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); +}); + +describe("teams members", () => { + it("lists members for the resolved team", async () => { + const program = createProgram(); + + await program.parseAsync(["node", "test", "teams", "members", "ENG"]); + + expect(listTeamMembers).toHaveBeenCalledWith(expect.anything(), { + id: "resolved-team-uuid", + }); + expect(outputSuccess).toHaveBeenCalledWith({ + nodes: [{ id: "m1", owner: true, user: { id: "user-1", name: "Alice" } }], + }); + }); +}); + +describe("teams add-member", () => { + it("resolves team and user then adds the member", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "teams", + "add-member", + "ENG", + "--user", + "alice@example.com", + "--owner", + "true", + ]); + + expect(resolveTeamId).toHaveBeenCalledWith(expect.anything(), "ENG"); + expect(resolveUserId).toHaveBeenCalledWith( + expect.anything(), + "alice@example.com", + ); + expect(addTeamMember).toHaveBeenCalledWith(expect.anything(), { + teamId: "resolved-team-uuid", + userId: "resolved-user-uuid", + owner: true, + }); + }); +}); + +describe("teams remove-member", () => { + it("resolves team and user then removes the member", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "teams", + "remove-member", + "ENG", + "--user", + "alice@example.com", + ]); + + expect(removeTeamMember).toHaveBeenCalledWith(expect.anything(), { + teamId: "resolved-team-uuid", + userId: "resolved-user-uuid", + }); + expect(outputSuccess).toHaveBeenCalledWith({ id: "m1", success: true }); + }); +}); diff --git a/tests/unit/services/team-service.test.ts b/tests/unit/services/team-service.test.ts index 9c266d25..ab87c316 100644 --- a/tests/unit/services/team-service.test.ts +++ b/tests/unit/services/team-service.test.ts @@ -4,11 +4,16 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { asUuid } from "../../../src/common/identifier.js"; import { + addTeamMember, + createTeam, getTeam, + listTeamMembers, listTeams, + removeTeamMember, type TeamDetail, type TeamEstimateOption, type TeamEstimationSource, + updateTeam, } from "../../../src/services/team-service.js"; const assertTeamDetailShape = (value: TeamDetail): TeamDetail => value; @@ -400,3 +405,248 @@ describe("getTeam", () => { ]); }); }); + +describe("createTeam", () => { + it("returns the created team", async () => { + const client = mockGqlClient({ + teamCreate: { + success: true, + team: { id: "team-new", key: "NEW", name: "New Team" }, + }, + }); + + const result = await createTeam(client, { name: "New Team", key: "NEW" }); + + expect(result.id).toBe("team-new"); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + input: { name: "New Team", key: "NEW" }, + }); + }); + + it("throws when the mutation fails", async () => { + const client = mockGqlClient({ + teamCreate: { success: false, team: null }, + }); + + await expect(createTeam(client, { name: "Fail" })).rejects.toThrow( + 'Failed to create team "Fail"', + ); + }); +}); + +describe("updateTeam", () => { + it("returns the updated team", async () => { + const client = mockGqlClient({ + teamUpdate: { + success: true, + team: { id: "team-1", key: "ENG", name: "Renamed" }, + }, + }); + + const result = await updateTeam(client, asUuid("team-1"), { + name: "Renamed", + }); + + expect(result.name).toBe("Renamed"); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + id: "team-1", + input: { name: "Renamed" }, + }); + }); + + it("throws when the mutation fails", async () => { + const client = mockGqlClient({ + teamUpdate: { success: false, team: null }, + }); + + await expect( + updateTeam(client, asUuid("team-1"), { name: "Renamed" }), + ).rejects.toThrow('Failed to update team "team-1"'); + }); +}); + +describe("listTeamMembers", () => { + it("returns the team's memberships", async () => { + const client = mockGqlClient({ + team: { + id: "team-1", + key: "ENG", + name: "Engineering", + memberships: { + nodes: [ + { id: "m1", owner: true, user: { id: "user-1", name: "Alice" } }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + + const result = await listTeamMembers(client, { id: asUuid("team-1") }); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.id).toBe("m1"); + }); + + it("paginates until all members are fetched", async () => { + const client = mockGqlClientWithSequence([ + { + team: { + id: "team-1", + key: "ENG", + name: "Engineering", + memberships: { + nodes: [ + { id: "m1", owner: true, user: { id: "user-1", name: "Alice" } }, + ], + pageInfo: { hasNextPage: true, endCursor: "cursor-1" }, + }, + }, + }, + { + team: { + id: "team-1", + key: "ENG", + name: "Engineering", + memberships: { + nodes: [ + { id: "m2", owner: false, user: { id: "user-2", name: "Bob" } }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + ]); + + const result = await listTeamMembers(client, { id: asUuid("team-1") }); + + expect(result.nodes.map((m) => m.id)).toEqual(["m1", "m2"]); + expect(client.request).toHaveBeenNthCalledWith(2, expect.anything(), { + id: "team-1", + after: "cursor-1", + }); + }); + + it("throws when the team is not found", async () => { + const client = mockGqlClient({ team: null }); + + await expect( + listTeamMembers(client, { id: asUuid("missing") }), + ).rejects.toThrow('Team "missing" not found'); + }); +}); + +describe("addTeamMember", () => { + it("returns the created membership", async () => { + const client = mockGqlClient({ + teamMembershipCreate: { + success: true, + teamMembership: { + id: "m1", + owner: false, + user: { id: "user-1", name: "Alice" }, + }, + }, + }); + + const result = await addTeamMember(client, { + teamId: asUuid("team-1"), + userId: asUuid("user-1"), + }); + + expect(result.id).toBe("m1"); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + input: { teamId: "team-1", userId: "user-1" }, + }); + }); + + it("passes owner when provided", async () => { + const client = mockGqlClient({ + teamMembershipCreate: { + success: true, + teamMembership: { + id: "m1", + owner: true, + user: { id: "user-1", name: "Alice" }, + }, + }, + }); + + await addTeamMember(client, { + teamId: asUuid("team-1"), + userId: asUuid("user-1"), + owner: true, + }); + + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + input: { teamId: "team-1", userId: "user-1", owner: true }, + }); + }); + + it("throws when the mutation fails", async () => { + const client = mockGqlClient({ + teamMembershipCreate: { success: false, teamMembership: null }, + }); + + await expect( + addTeamMember(client, { + teamId: asUuid("team-1"), + userId: asUuid("user-1"), + }), + ).rejects.toThrow('Failed to add user "user-1" to team "team-1"'); + }); +}); + +describe("removeTeamMember", () => { + it("resolves the membership id and deletes it", async () => { + const client = mockGqlClientWithSequence([ + { + team: { + id: "team-1", + key: "ENG", + name: "Engineering", + memberships: { + nodes: [ + { id: "m1", owner: false, user: { id: "user-1", name: "Alice" } }, + { id: "m2", owner: false, user: { id: "user-2", name: "Bob" } }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + { teamMembershipDelete: { success: true, entityId: "m2" } }, + ]); + + const result = await removeTeamMember(client, { + teamId: asUuid("team-1"), + userId: asUuid("user-2"), + }); + + expect(result).toEqual({ id: "m2", success: true }); + expect(client.request).toHaveBeenNthCalledWith(2, expect.anything(), { + id: "m2", + }); + }); + + it("throws when the user is not a team member", async () => { + const client = mockGqlClient({ + team: { + id: "team-1", + key: "ENG", + name: "Engineering", + memberships: { + nodes: [ + { id: "m1", owner: false, user: { id: "user-1", name: "Alice" } }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + + await expect( + removeTeamMember(client, { + teamId: asUuid("team-1"), + userId: asUuid("user-9"), + }), + ).rejects.toThrow('Team member "user-9" on team "team-1" not found'); + }); +}); From fb32f507d972b3cd2312e13d2547d95b4044aff8 Mon Sep 17 00:00:00 2001 From: semantic-release-bot <semantic-release-bot@martynus.net> Date: Sat, 4 Jul 2026 17:06:32 +0000 Subject: [PATCH 79/79] chore(release): 2026.6.0-next.13 [skip ci] ## [2026.6.0-next.13](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.12...v2026.6.0-next.13) (2026-07-04) ### Features * **teams:** add team create, update, and membership management ([c89208b](https://github.com/linearis-oss/linearis/commit/c89208bc7a14514da62caec25364207fa29fbb18)), closes [#142](https://github.com/linearis-oss/linearis/issues/142) --- 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 05860c30..06257b25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [2026.6.0-next.13](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.12...v2026.6.0-next.13) (2026-07-04) + +### Features + +* **teams:** add team create, update, and membership management ([c89208b](https://github.com/linearis-oss/linearis/commit/c89208bc7a14514da62caec25364207fa29fbb18)), closes [#142](https://github.com/linearis-oss/linearis/issues/142) + ## [2026.6.0-next.12](https://github.com/linearis-oss/linearis/compare/v2026.6.0-next.11...v2026.6.0-next.12) (2026-07-04) ### Features diff --git a/package-lock.json b/package-lock.json index 6e51b96d..c66e36c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linearis", - "version": "2026.6.0-next.12", + "version": "2026.6.0-next.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.6.0-next.12", + "version": "2026.6.0-next.13", "license": "MIT", "dependencies": { "commander": "14.0.3", diff --git a/package.json b/package.json index d6ed9ad1..6acddfab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.6.0-next.12", + "version": "2026.6.0-next.13", "description": "CLI tool for Linear.app with JSON output, smart ID resolution, and optimized GraphQL queries. Designed for LLM agents and humans who prefer structured data.", "main": "dist/main.js", "type": "module",