From a693726e11552cf4b1a437452b4d49128804834a Mon Sep 17 00:00:00 2001 From: Daniel Wise Date: Thu, 19 Mar 2026 21:01:17 -0700 Subject: [PATCH 1/2] feat(cli): add linear cli v2 foundations Expand core Linear entity coverage, add workflow commands and shared query flags, and upgrade the TUI and human-readable output for the v2 experience. --- packages/cli/src/formatters/output.ts | 55 +- packages/cli/src/help/root-help.ts | 24 +- packages/cli/src/index.ts | 537 ++++++++++++++++- packages/cli/src/runtime/options.ts | 38 +- packages/cli/src/runtime/query.ts | 206 +++++++ packages/cli/src/utils/guards.ts | 11 +- packages/cli/tests/help.test.ts | 49 +- packages/cli/tests/options.test.ts | 85 +++ packages/cli/tests/output.test.ts | 41 ++ .../src/entities/linear-gateway.ts | 544 +++++++++++++++++- packages/linear-core/src/entities/models.ts | 103 ++++ .../linear-core/src/entities/sdk-types.ts | 68 +++ packages/linear-core/src/types/public.ts | 33 ++ packages/linear-core/tests/v2-gateway.test.ts | 416 ++++++++++++++ packages/tui/src/components/Layout.tsx | 36 +- packages/tui/src/screens/IssuesScreen.tsx | 86 +-- packages/tui/tests/app.test.tsx | 6 +- 17 files changed, 2231 insertions(+), 107 deletions(-) create mode 100644 packages/cli/src/runtime/query.ts create mode 100644 packages/cli/tests/options.test.ts create mode 100644 packages/linear-core/tests/v2-gateway.test.ts diff --git a/packages/cli/src/formatters/output.ts b/packages/cli/src/formatters/output.ts index 3a9f693..3a24dfa 100644 --- a/packages/cli/src/formatters/output.ts +++ b/packages/cli/src/formatters/output.ts @@ -111,32 +111,55 @@ function toDocumentRow(item: { }; } -function toHumanRows(items: readonly unknown[]): readonly unknown[] { +function pickFields( + item: Record, + fields: readonly string[] | undefined, +): Record { + if (!fields || fields.length === 0) { + return item; + } + + return Object.fromEntries(fields.map((field) => [field, item[field]])); +} + +function toHumanRowsWithOptions( + items: readonly unknown[], + options: Pick, +): readonly unknown[] { + if (options.view === "detail") { + return items.map((item) => (isRecord(item) ? pickFields(item, options.fields) : item)); + } + if (items.every(isIssueLike)) { - return items.map((item) => ({ - key: item.identifier, - title: item.title, - state: item.stateName ?? "-", - priority: item.priority, - updated: formatUpdatedAt(item.updatedAt), - })); + return items.map((item) => + pickFields( + { + key: item.identifier, + title: item.title, + state: item.stateName ?? "-", + priority: item.priority, + updated: formatUpdatedAt(item.updatedAt), + }, + options.fields, + ), + ); } if (items.every(isDocumentLike)) { - return items.map(toDocumentRow); + return items.map((item) => pickFields(toDocumentRow(item), options.fields)); } - return items; + return items.map((item) => (isRecord(item) ? pickFields(item, options.fields) : item)); } -function printHumanData(data: unknown): void { +function printHumanData(data: unknown, options: GlobalOptions): void { if (Array.isArray(data)) { - console.table(toHumanRows(data)); + console.table(toHumanRowsWithOptions(data, options)); return; } if (isPageResult(data)) { - console.table(toHumanRows(data.items)); + console.table(toHumanRowsWithOptions(data.items, options)); if (data.nextCursor) { console.log(`Next cursor: ${data.nextCursor}`); } @@ -145,11 +168,11 @@ function printHumanData(data: unknown): void { if (data !== null && typeof data === "object") { if (isDocumentLike(data)) { - console.table([toDocumentRow(data)]); + console.table([pickFields(toDocumentRow(data), options.fields)]); return; } - console.table([data]); + console.table([pickFields(data as Record, options.fields)]); return; } @@ -166,7 +189,7 @@ export function renderEnvelope(envelope: OutputEnvelope, options: Gl if (!options.quiet) { console.log(`${envelope.entity}.${envelope.action}`); } - printHumanData(envelope.data); + printHumanData(envelope.data, options); return; } diff --git a/packages/cli/src/help/root-help.ts b/packages/cli/src/help/root-help.ts index 91fb296..191e529 100644 --- a/packages/cli/src/help/root-help.ts +++ b/packages/cli/src/help/root-help.ts @@ -1,17 +1,33 @@ export const rootHelpText = ` -Agent-first Linear CLI +Linear CLI v2 + +Task-first workflows: + linear doctor + linear my-work --mine + linear triage --team ENG + linear project status + linear updates --limit 20 Examples: linear auth login linear auth login --manual linear auth status --json linear auth api-key-set --api-key "$LINEAR_API_KEY" + linear doctor + linear my-work --limit 10 + linear triage --team ENG --view detail linear issues --help linear issues branch --help - linear issues list --limit 10 + linear issues list --limit 10 --state "In Progress" --assignee me linear issues branch ANN-123 --json linear issues browse linear issues create --template "Bug Report" --input '{"teamId":""}' + linear customers list + linear customer-needs list + linear milestones list + linear project-updates list + linear initiative-updates list + linear notifications list linear initiatives list linear documents list linear templates list @@ -20,6 +36,8 @@ Examples: Output: - Human-readable tables by default + - Detail views with --view detail + - Field selection with --fields identifier,title,assigneeName - Strict machine output with --json Docs: @@ -31,6 +49,8 @@ export const issuesHelpText = ` Examples: linear issues --help linear issues list --limit 10 --json + linear issues list --mine --state "Todo" --view detail + linear issues list --fields identifier,title,assigneeName,projectName linear issues create --template "Bug Report" --input '{"teamId":""}' --json `; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f10826b..45e989b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,18 +7,29 @@ import type { SdkAttachmentUpdateInput, SdkCommentInput, SdkCommentUpdateInput, + SdkCustomerInput, + SdkCustomerNeedInput, + SdkCustomerNeedUpdateInput, + SdkCustomerUpdateInput, SdkCycleInput, SdkCycleUpdateInput, SdkDocumentInput, SdkDocumentUpdateInput, SdkInitiativeInput, + SdkInitiativeUpdateCreateInput, SdkInitiativeUpdateInput, + SdkInitiativeUpdateUpdateInput, SdkIssueInput, SdkIssueLabelInput, SdkIssueLabelUpdateInput, SdkIssueUpdateInput, + SdkNotificationUpdateInput, SdkProjectInput, + SdkProjectMilestoneInput, + SdkProjectMilestoneUpdateInput, + SdkProjectUpdateCreateInput, SdkProjectUpdateInput, + SdkProjectUpdateUpdateInput, SdkTeamInput, SdkTeamUpdateInput, SdkTemplateInput, @@ -42,6 +53,16 @@ import { registerResourceCommand } from "./commands/resource.js"; import { renderEnvelope } from "./formatters/output.js"; import { issueBranchHelpText, issuesHelpText, rootHelpText } from "./help/root-help.js"; import { getGlobalOptions } from "./runtime/options.js"; +import { + collectPageResult, + matchesCustomer, + matchesCustomerNeed, + matchesIssue, + matchesMilestone, + matchesNotification, + matchesProject, + matchesProjectUpdate, +} from "./runtime/query.js"; function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object"; @@ -70,6 +91,10 @@ function hasString(value: Record, key: string): boolean { return typeof value[key] === "string"; } +function isNonEmptyRecord(value: unknown): value is Record { + return isRecord(value) && Object.keys(value).length > 0; +} + function ensurePayload( value: unknown, guard: (input: unknown) => input is T, @@ -107,7 +132,7 @@ function isProjectCreateInput(value: unknown): value is SdkProjectInput { } function isProjectUpdateInput(value: unknown): value is SdkProjectUpdateInput { - return isRecord(value) && Object.keys(value).length > 0; + return isNonEmptyRecord(value); } function isDocumentCreateInput(value: unknown): value is SdkDocumentInput { @@ -115,7 +140,7 @@ function isDocumentCreateInput(value: unknown): value is SdkDocumentInput { } function isDocumentUpdateInput(value: unknown): value is SdkDocumentUpdateInput { - return isRecord(value) && Object.keys(value).length > 0; + return isNonEmptyRecord(value); } function isCycleCreateInput(value: unknown): value is SdkCycleInput { @@ -123,7 +148,7 @@ function isCycleCreateInput(value: unknown): value is SdkCycleInput { } function isCycleUpdateInput(value: unknown): value is SdkCycleUpdateInput { - return isRecord(value) && Object.keys(value).length > 0; + return isNonEmptyRecord(value); } function isTeamCreateInput(value: unknown): value is SdkTeamInput { @@ -131,11 +156,11 @@ function isTeamCreateInput(value: unknown): value is SdkTeamInput { } function isTeamUpdateInput(value: unknown): value is SdkTeamUpdateInput { - return isRecord(value) && Object.keys(value).length > 0; + return isNonEmptyRecord(value); } function isUserUpdateInput(value: unknown): value is SdkUserUpdateInput { - return isRecord(value) && Object.keys(value).length > 0; + return isNonEmptyRecord(value); } function isLabelCreateInput(value: unknown): value is SdkIssueLabelInput { @@ -143,7 +168,7 @@ function isLabelCreateInput(value: unknown): value is SdkIssueLabelInput { } function isLabelUpdateInput(value: unknown): value is SdkIssueLabelUpdateInput { - return isRecord(value) && Object.keys(value).length > 0; + return isNonEmptyRecord(value); } function isCommentCreateInput(value: unknown): value is SdkCommentInput { @@ -151,7 +176,7 @@ function isCommentCreateInput(value: unknown): value is SdkCommentInput { } function isCommentUpdateInput(value: unknown): value is SdkCommentUpdateInput { - return isRecord(value) && Object.keys(value).length > 0; + return isNonEmptyRecord(value); } function isAttachmentCreateInput(value: unknown): value is SdkAttachmentInput { @@ -159,7 +184,7 @@ function isAttachmentCreateInput(value: unknown): value is SdkAttachmentInput { } function isAttachmentUpdateInput(value: unknown): value is SdkAttachmentUpdateInput { - return isRecord(value) && Object.keys(value).length > 0; + return isNonEmptyRecord(value); } function isTemplateCreateInput(value: unknown): value is SdkTemplateInput { @@ -172,7 +197,7 @@ function isTemplateCreateInput(value: unknown): value is SdkTemplateInput { } function isTemplateUpdateInput(value: unknown): value is SdkTemplateUpdateInput { - return isRecord(value) && Object.keys(value).length > 0; + return isNonEmptyRecord(value); } function isWorkflowStateCreateInput(value: unknown): value is SdkWorkflowStateInput { @@ -180,7 +205,51 @@ function isWorkflowStateCreateInput(value: unknown): value is SdkWorkflowStateIn } function isWorkflowStateUpdateInput(value: unknown): value is SdkWorkflowStateUpdateInput { - return isRecord(value) && Object.keys(value).length > 0; + return isNonEmptyRecord(value); +} + +function isCustomerCreateInput(value: unknown): value is SdkCustomerInput { + return isRecord(value) && hasString(value, "name"); +} + +function isCustomerUpdateInput(value: unknown): value is SdkCustomerUpdateInput { + return isNonEmptyRecord(value); +} + +function isCustomerNeedCreateInput(value: unknown): value is SdkCustomerNeedInput { + return isNonEmptyRecord(value); +} + +function isCustomerNeedUpdateInput(value: unknown): value is SdkCustomerNeedUpdateInput { + return isNonEmptyRecord(value); +} + +function isProjectMilestoneCreateInput(value: unknown): value is SdkProjectMilestoneInput { + return isRecord(value) && hasString(value, "name"); +} + +function isProjectMilestoneUpdateInput(value: unknown): value is SdkProjectMilestoneUpdateInput { + return isNonEmptyRecord(value); +} + +function isProjectUpdateCreateInput(value: unknown): value is SdkProjectUpdateCreateInput { + return isRecord(value) && hasString(value, "body"); +} + +function isProjectUpdateUpdateInput(value: unknown): value is SdkProjectUpdateUpdateInput { + return isNonEmptyRecord(value); +} + +function isInitiativeUpdateCreateInput(value: unknown): value is SdkInitiativeUpdateCreateInput { + return isRecord(value) && hasString(value, "body"); +} + +function isInitiativeUpdateUpdateInput(value: unknown): value is SdkInitiativeUpdateUpdateInput { + return isNonEmptyRecord(value); +} + +function isNotificationUpdatePayload(value: unknown): value is SdkNotificationUpdateInput { + return isNonEmptyRecord(value); } async function readSecret(prompt: string): Promise { @@ -230,14 +299,27 @@ export function createProgram(authManager = new AuthManager()): Command { program .name("linear") .version(readCliVersion(), "-v, --version", "output the version number") - .description("Agent-first Linear CLI") + .description("Linear CLI v2") .addHelpText("after", rootHelpText) .option("--json", "Output JSON envelope") .option("--profile ", "Profile name to use") .option("--team ", "Default team key") .option("--limit ", "List limit", (value) => Number.parseInt(value, 10)) .option("--cursor ", "Pagination cursor") - .option("--quiet", "Reduce human output noise"); + .option("--quiet", "Reduce human output noise") + .option("--mine", "Limit issue-oriented commands to items assigned to the authenticated user") + .option("--project ", "Project filter") + .option("--cycle ", "Cycle filter") + .option("--state ", "State or type filter") + .option("--assignee ", "Assignee filter") + .option("--label ", "Label filter") + .option("--priority ", "Priority filter") + .option("--status ", "Status or health filter") + .option("--filter ", "Lightweight filter expression, e.g. estimate>2") + .option("--sort ", "Sort by a field, prefix with - for descending") + .option("--view ", "Human output preset: table | detail | dense") + .option("--all", "Drain all pages before filtering") + .option("--fields ", "Comma-separated field selection for human output"); const authCommand = program.command("auth").description("Authentication commands"); @@ -406,10 +488,22 @@ export function createProgram(authManager = new AuthManager()): Command { renderEnvelope(successEnvelope("skills", "install", result), globals); }); - const sessionGateway = async (cmd: Command) => { + const openSessionForCommand = async (cmd: Command) => { + const globals = getGlobalOptions(cmd); + return authManager.openSession({ profile: globals.profile }); + }; + + const sessionGateway = async (cmd: Command) => (await openSessionForCommand(cmd)).gateway; + + const resolveViewerName = async (cmd: Command): Promise => { const globals = getGlobalOptions(cmd); - const session = await authManager.openSession({ profile: globals.profile }); - return session.gateway; + if (!globals.mine && globals.assignee !== "me") { + return undefined; + } + + const session = await openSessionForCommand(cmd); + const viewer = await session.client.viewer; + return viewer.displayName ?? viewer.name; }; registerResourceCommand( @@ -419,10 +513,13 @@ export function createProgram(authManager = new AuthManager()): Command { { list: async (_manager, cmd) => { const globals = getGlobalOptions(cmd); - return (await sessionGateway(cmd)).listIssues({ - limit: globals.limit, - cursor: globals.cursor, - }); + const viewerName = await resolveViewerName(cmd); + const gateway = await sessionGateway(cmd); + return collectPageResult( + (options) => gateway.listIssues(options), + globals, + (issue) => matchesIssue(issue, globals, viewerName), + ); }, get: async (_manager, id, cmd) => (await sessionGateway(cmd)).getIssue(id), create: async (_manager, payload, cmd) => { @@ -536,6 +633,77 @@ export function createProgram(authManager = new AuthManager()): Command { } }); + registerResourceCommand( + program, + "customers", + "Customer commands", + { + list: async (_manager, cmd) => { + const globals = getGlobalOptions(cmd); + const viewerName = await resolveViewerName(cmd); + const gateway = await sessionGateway(cmd); + return collectPageResult( + (options) => gateway.listCustomers(options), + globals, + (customer) => matchesCustomer(customer, globals, viewerName), + ); + }, + get: async (_manager, id, cmd) => (await sessionGateway(cmd)).getCustomer(id), + create: async (_manager, payload, cmd) => + (await sessionGateway(cmd)).createCustomer( + ensurePayload(payload, isCustomerCreateInput, "Customer create payload requires name."), + ), + update: async (_manager, id, payload, cmd) => + (await sessionGateway(cmd)).updateCustomer( + id, + ensurePayload( + payload, + isCustomerUpdateInput, + "Customer update payload must be a non-empty object.", + ), + ), + delete: async (_manager, id, cmd) => (await sessionGateway(cmd)).deleteCustomer(id), + }, + authManager, + ); + + registerResourceCommand( + program, + "customer-needs", + "Customer request commands", + { + list: async (_manager, cmd) => { + const globals = getGlobalOptions(cmd); + const gateway = await sessionGateway(cmd); + return collectPageResult( + (options) => gateway.listCustomerNeeds(options), + globals, + (need) => matchesCustomerNeed(need, globals), + ); + }, + get: async (_manager, id, cmd) => (await sessionGateway(cmd)).getCustomerNeed(id), + create: async (_manager, payload, cmd) => + (await sessionGateway(cmd)).createCustomerNeed( + ensurePayload( + payload, + isCustomerNeedCreateInput, + "Customer need create payload must be a non-empty object.", + ), + ), + update: async (_manager, id, payload, cmd) => + (await sessionGateway(cmd)).updateCustomerNeed( + id, + ensurePayload( + payload, + isCustomerNeedUpdateInput, + "Customer need update payload must be a non-empty object.", + ), + ), + delete: async (_manager, id, cmd) => (await sessionGateway(cmd)).deleteCustomerNeed(id), + }, + authManager, + ); + registerResourceCommand( program, "initiatives", @@ -571,6 +739,39 @@ export function createProgram(authManager = new AuthManager()): Command { authManager, ); + registerResourceCommand( + program, + "initiative-updates", + "Initiative update commands", + { + list: async (_manager, cmd) => { + const globals = getGlobalOptions(cmd); + const gateway = await sessionGateway(cmd); + return collectPageResult((options) => gateway.listInitiativeUpdates(options), globals); + }, + get: async (_manager, id, cmd) => (await sessionGateway(cmd)).getInitiativeUpdate(id), + create: async (_manager, payload, cmd) => + (await sessionGateway(cmd)).createInitiativeUpdate( + ensurePayload( + payload, + isInitiativeUpdateCreateInput, + "Initiative update create payload requires body.", + ), + ), + update: async (_manager, id, payload, cmd) => + (await sessionGateway(cmd)).updateInitiativeUpdate( + id, + ensurePayload( + payload, + isInitiativeUpdateUpdateInput, + "Initiative update payload must be a non-empty object.", + ), + ), + delete: async (_manager, id, cmd) => (await sessionGateway(cmd)).deleteInitiativeUpdate(id), + }, + authManager, + ); + registerResourceCommand( program, "projects", @@ -578,10 +779,12 @@ export function createProgram(authManager = new AuthManager()): Command { { list: async (_manager, cmd) => { const globals = getGlobalOptions(cmd); - return (await sessionGateway(cmd)).listProjects({ - limit: globals.limit, - cursor: globals.cursor, - }); + const gateway = await sessionGateway(cmd); + return collectPageResult( + (options) => gateway.listProjects(options), + globals, + (project) => matchesProject(project, globals), + ); }, get: async (_manager, id, cmd) => (await sessionGateway(cmd)).getProject(id), create: async (_manager, payload, cmd) => @@ -602,6 +805,80 @@ export function createProgram(authManager = new AuthManager()): Command { authManager, ); + registerResourceCommand( + program, + "milestones", + "Project milestone commands", + { + list: async (_manager, cmd) => { + const globals = getGlobalOptions(cmd); + const gateway = await sessionGateway(cmd); + return collectPageResult( + (options) => gateway.listProjectMilestones(options), + globals, + (milestone) => matchesMilestone(milestone, globals), + ); + }, + get: async (_manager, id, cmd) => (await sessionGateway(cmd)).getProjectMilestone(id), + create: async (_manager, payload, cmd) => + (await sessionGateway(cmd)).createProjectMilestone( + ensurePayload( + payload, + isProjectMilestoneCreateInput, + "Milestone create payload requires name.", + ), + ), + update: async (_manager, id, payload, cmd) => + (await sessionGateway(cmd)).updateProjectMilestone( + id, + ensurePayload( + payload, + isProjectMilestoneUpdateInput, + "Milestone update payload must be a non-empty object.", + ), + ), + delete: async (_manager, id, cmd) => (await sessionGateway(cmd)).deleteProjectMilestone(id), + }, + authManager, + ); + + registerResourceCommand( + program, + "project-updates", + "Project update commands", + { + list: async (_manager, cmd) => { + const globals = getGlobalOptions(cmd); + const gateway = await sessionGateway(cmd); + return collectPageResult( + (options) => gateway.listProjectUpdates(options), + globals, + (update) => matchesProjectUpdate(update, globals), + ); + }, + get: async (_manager, id, cmd) => (await sessionGateway(cmd)).getProjectUpdate(id), + create: async (_manager, payload, cmd) => + (await sessionGateway(cmd)).createProjectUpdate( + ensurePayload( + payload, + isProjectUpdateCreateInput, + "Project update create payload requires body.", + ), + ), + update: async (_manager, id, payload, cmd) => + (await sessionGateway(cmd)).updateProjectUpdate( + id, + ensurePayload( + payload, + isProjectUpdateUpdateInput, + "Project update payload must be a non-empty object.", + ), + ), + delete: async (_manager, id, cmd) => (await sessionGateway(cmd)).deleteProjectUpdate(id), + }, + authManager, + ); + registerResourceCommand( program, "documents", @@ -847,6 +1124,35 @@ export function createProgram(authManager = new AuthManager()): Command { authManager, ); + registerResourceCommand( + program, + "notifications", + "Notification commands", + { + list: async (_manager, cmd) => { + const globals = getGlobalOptions(cmd); + const gateway = await sessionGateway(cmd); + return collectPageResult( + (options) => gateway.listNotifications(options), + globals, + (notification) => matchesNotification(notification, globals), + ); + }, + get: async (_manager, id, cmd) => (await sessionGateway(cmd)).getNotification(id), + update: async (_manager, id, payload, cmd) => + (await sessionGateway(cmd)).updateNotification( + id, + ensurePayload( + payload, + isNotificationUpdatePayload, + "Notification update payload must be a non-empty object.", + ), + ), + delete: async (_manager, id, cmd) => (await sessionGateway(cmd)).deleteNotification(id), + }, + authManager, + ); + registerResourceCommand( program, "states", @@ -882,6 +1188,191 @@ export function createProgram(authManager = new AuthManager()): Command { authManager, ); + program + .command("doctor") + .description("Validate auth, profile, API connectivity, and rate limits") + .action(async (_, cmd) => { + const globals = getGlobalOptions(cmd); + + try { + const status = await authManager.status(globals.profile); + const session = await openSessionForCommand(cmd); + const viewer = await session.client.viewer; + const rateLimit = await session.client.rateLimitStatus; + + renderEnvelope( + successEnvelope("doctor", "show", { + status, + viewer: { + id: viewer.id, + name: viewer.displayName ?? viewer.name, + email: viewer.email, + }, + rateLimit, + }), + globals, + ); + } catch (error) { + const normalized = normalizeError(error); + renderEnvelope( + errorEnvelope("doctor", "show", { + code: normalized.code, + message: normalized.message, + details: normalized.details, + }), + globals, + ); + process.exitCode = 1; + } + }); + + program + .command("my-work") + .description("List work assigned to the authenticated user") + .action(async (_, cmd) => { + const globals = getGlobalOptions(cmd); + + try { + const viewerName = await resolveViewerName(cmd); + const gateway = await sessionGateway(cmd); + const data = await collectPageResult( + (options) => gateway.listIssues(options), + { + ...globals, + mine: true, + }, + (issue) => matchesIssue(issue, { ...globals, mine: true }, viewerName), + ); + renderEnvelope(successEnvelope("issues", "list", data), globals); + } catch (error) { + const normalized = normalizeError(error); + renderEnvelope( + errorEnvelope("issues", "list", { + code: normalized.code, + message: normalized.message, + details: normalized.details, + }), + globals, + ); + process.exitCode = 1; + } + }); + + program + .command("triage") + .description("List triage-ready issues, favoring unassigned or triage-state work") + .action(async (_, cmd) => { + const globals = getGlobalOptions(cmd); + + try { + const gateway = await sessionGateway(cmd); + const data = await collectPageResult( + (options) => gateway.listIssues(options), + globals, + (issue) => + matchesIssue(issue, globals) && + (!issue.assigneeName || /triage/i.test(issue.stateName ?? "")), + ); + renderEnvelope(successEnvelope("issues", "list", data), globals); + } catch (error) { + const normalized = normalizeError(error); + renderEnvelope( + errorEnvelope("issues", "list", { + code: normalized.code, + message: normalized.message, + details: normalized.details, + }), + globals, + ); + process.exitCode = 1; + } + }); + + program + .command("updates") + .description("Browse inbox-style notifications and updates") + .action(async (_, cmd) => { + const globals = getGlobalOptions(cmd); + + try { + const gateway = await sessionGateway(cmd); + const data = await collectPageResult( + (options) => gateway.listNotifications(options), + globals, + (notification) => matchesNotification(notification, globals), + ); + renderEnvelope(successEnvelope("notifications", "list", data), globals); + } catch (error) { + const normalized = normalizeError(error); + renderEnvelope( + errorEnvelope("notifications", "list", { + code: normalized.code, + message: normalized.message, + details: normalized.details, + }), + globals, + ); + process.exitCode = 1; + } + }); + + const projectWorkflowCommand = program + .command("project") + .description("Project workflow commands"); + + projectWorkflowCommand + .command("status") + .description("Show project status with milestones and recent updates") + .argument("", "Project id") + .action(async (id, _, cmd) => { + const globals = getGlobalOptions(cmd); + + try { + const gateway = await sessionGateway(cmd); + const [project, milestones, updates] = await Promise.all([ + gateway.getProject(id), + collectPageResult( + (options) => gateway.listProjectMilestones(options), + { + ...globals, + project: id, + all: true, + }, + (milestone) => matchesMilestone(milestone, { ...globals, project: id }), + ), + collectPageResult( + (options) => gateway.listProjectUpdates(options), + { + ...globals, + project: id, + all: true, + }, + (update) => matchesProjectUpdate(update, { ...globals, project: id }), + ), + ]); + + renderEnvelope( + successEnvelope("projects", "show", { + project, + milestones: milestones.items, + updates: updates.items, + }), + globals, + ); + } catch (error) { + const normalized = normalizeError(error); + renderEnvelope( + errorEnvelope("projects", "show", { + code: normalized.code, + message: normalized.message, + details: normalized.details, + }), + globals, + ); + process.exitCode = 1; + } + }); + program .command("tui") .description("Open interactive terminal UI") diff --git a/packages/cli/src/runtime/options.ts b/packages/cli/src/runtime/options.ts index 7601616..f6ae31c 100644 --- a/packages/cli/src/runtime/options.ts +++ b/packages/cli/src/runtime/options.ts @@ -8,18 +8,48 @@ export interface GlobalOptions { readonly limit?: number; readonly cursor?: string; readonly quiet: boolean; + readonly mine?: boolean; + readonly project?: string; + readonly cycle?: string; + readonly state?: string; + readonly assignee?: string; + readonly label?: string; + readonly priority?: string; + readonly status?: string; + readonly filter?: string; + readonly sort?: string; + readonly view?: "table" | "detail" | "dense"; + readonly all?: boolean; + readonly fields?: readonly string[]; } export function getGlobalOptions(command: Command): GlobalOptions { const rawValue = command.optsWithGlobals(); const raw = isRecord(rawValue) ? rawValue : {}; + const fields = readString(raw.fields) + ?.split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0); return { json: readBoolean(raw.json), - profile: readString(raw.profile), - team: readString(raw.team), - limit: readNumber(raw.limit), - cursor: readString(raw.cursor), quiet: readBoolean(raw.quiet), + ...(readString(raw.profile) ? { profile: readString(raw.profile) } : {}), + ...(readString(raw.team) ? { team: readString(raw.team) } : {}), + ...(readNumber(raw.limit) !== undefined ? { limit: readNumber(raw.limit) } : {}), + ...(readString(raw.cursor) ? { cursor: readString(raw.cursor) } : {}), + ...(readBoolean(raw.mine) ? { mine: true } : {}), + ...(readString(raw.project) ? { project: readString(raw.project) } : {}), + ...(readString(raw.cycle) ? { cycle: readString(raw.cycle) } : {}), + ...(readString(raw.state) ? { state: readString(raw.state) } : {}), + ...(readString(raw.assignee) ? { assignee: readString(raw.assignee) } : {}), + ...(readString(raw.label) ? { label: readString(raw.label) } : {}), + ...(readString(raw.priority) ? { priority: readString(raw.priority) } : {}), + ...(readString(raw.status) ? { status: readString(raw.status) } : {}), + ...(readString(raw.filter) ? { filter: readString(raw.filter) } : {}), + ...(readString(raw.sort) ? { sort: readString(raw.sort) } : {}), + ...(readString(raw.view) ? { view: readString(raw.view) as "table" | "detail" | "dense" } : {}), + ...(readBoolean(raw.all) ? { all: true } : {}), + ...(fields && fields.length > 0 ? { fields } : {}), }; } diff --git a/packages/cli/src/runtime/query.ts b/packages/cli/src/runtime/query.ts new file mode 100644 index 0000000..8a0a87c --- /dev/null +++ b/packages/cli/src/runtime/query.ts @@ -0,0 +1,206 @@ +import type { + CustomerNeedRecord, + CustomerRecord, + IssueRecord, + ListOptions, + NotificationRecord, + PageResult, + ProjectMilestoneRecord, + ProjectRecord, + ProjectUpdateRecord, +} from "@wiseiodev/linear-core"; +import type { GlobalOptions } from "./options.js"; + +function asRecord(value: object): Record { + return value as Record; +} + +function normalizeText(value: string | undefined): string | undefined { + return value?.trim().toLowerCase(); +} + +function matchText(value: string | undefined, query: string | undefined): boolean { + if (!query) { + return true; + } + + return normalizeText(value)?.includes(normalizeText(query) ?? "") ?? false; +} + +function runFilterExpression(item: object, expression: string | undefined): boolean { + if (!expression) { + return true; + } + + const greaterThanMatch = expression.match(/^([a-zA-Z0-9_]+)>(.+)$/); + if (greaterThanMatch) { + const [, field = "", rawValue = ""] = greaterThanMatch; + const currentValue = Number(asRecord(item)[field]); + return Number.isFinite(currentValue) && currentValue > Number(rawValue); + } + + const equalsMatch = expression.match(/^([a-zA-Z0-9_]+)=(.+)$/); + if (equalsMatch) { + const [, field = "", rawValue = ""] = equalsMatch; + return String(asRecord(item)[field] ?? "").toLowerCase() === rawValue.trim().toLowerCase(); + } + + return Object.values(asRecord(item)).some((value) => + String(value ?? "") + .toLowerCase() + .includes(expression.toLowerCase()), + ); +} + +function sortItems(items: readonly T[], sort?: string): T[] { + if (!sort) { + return [...items]; + } + + const descending = sort.startsWith("-"); + const field = descending ? sort.slice(1) : sort; + + return [...items].sort((left, right) => { + const leftValue = asRecord(left)[field]; + const rightValue = asRecord(right)[field]; + const comparison = String(leftValue ?? "").localeCompare(String(rightValue ?? ""), undefined, { + numeric: true, + sensitivity: "base", + }); + + return descending ? -comparison : comparison; + }); +} + +function shouldDrainPages(options: GlobalOptions): boolean { + return Boolean( + options.all || + options.mine || + options.project || + options.cycle || + options.state || + options.assignee || + options.label || + options.priority || + options.status || + options.filter || + options.sort, + ); +} + +export async function collectPageResult( + loader: (options: ListOptions) => Promise>, + globals: GlobalOptions, + predicate: (item: T) => boolean = () => true, +): Promise> { + if (!shouldDrainPages(globals)) { + const page = await loader({ + limit: globals.limit, + cursor: globals.cursor, + }); + + return { + items: sortItems(page.items.filter(predicate), globals.sort).slice( + 0, + globals.limit ?? page.items.length, + ), + nextCursor: page.nextCursor, + }; + } + + let cursor = globals.cursor; + const collected: T[] = []; + + do { + const page = await loader({ + limit: globals.limit ?? 50, + cursor, + }); + collected.push(...page.items.filter(predicate)); + cursor = page.nextCursor ?? undefined; + } while (cursor); + + const items = sortItems(collected, globals.sort).slice(0, globals.limit ?? collected.length); + return { + items, + nextCursor: null, + }; +} + +export function matchesIssue( + issue: IssueRecord, + globals: GlobalOptions, + viewerName?: string, +): boolean { + const labels = issue.labelNames?.join(" "); + const assigneeQuery = globals.mine ? (viewerName ?? globals.assignee) : globals.assignee; + return ( + matchText(issue.teamKey ?? issue.teamName, globals.team) && + matchText(issue.projectId ?? issue.projectName, globals.project) && + matchText(issue.cycleId ?? issue.cycleName, globals.cycle) && + matchText(issue.stateName, globals.state) && + matchText(issue.assigneeName, assigneeQuery) && + matchText(labels, globals.label) && + (globals.priority ? String(issue.priority) === String(globals.priority) : true) && + runFilterExpression(issue, globals.filter) + ); +} + +export function matchesProject(project: ProjectRecord, globals: GlobalOptions): boolean { + return ( + matchText(project.state, globals.status) && + matchText(project.id ?? project.name, globals.project) && + runFilterExpression(project, globals.filter) + ); +} + +export function matchesCustomer( + customer: CustomerRecord, + globals: GlobalOptions, + viewerName?: string, +): boolean { + const ownerQuery = globals.mine ? (viewerName ?? globals.assignee) : globals.assignee; + return ( + matchText(customer.ownerName, ownerQuery) && + matchText(customer.statusName, globals.status) && + runFilterExpression(customer, globals.filter) + ); +} + +export function matchesCustomerNeed(need: CustomerNeedRecord, globals: GlobalOptions): boolean { + return ( + matchText(need.customerId ?? need.customerName, globals.project) && + (globals.priority ? String(need.priority) === String(globals.priority) : true) && + runFilterExpression(need, globals.filter) + ); +} + +export function matchesMilestone( + milestone: ProjectMilestoneRecord, + globals: GlobalOptions, +): boolean { + return ( + matchText(milestone.projectId ?? milestone.projectName, globals.project) && + matchText(milestone.status, globals.status) && + runFilterExpression(milestone, globals.filter) + ); +} + +export function matchesProjectUpdate(update: ProjectUpdateRecord, globals: GlobalOptions): boolean { + return ( + matchText(update.projectId ?? update.projectName, globals.project) && + matchText(update.health, globals.status) && + runFilterExpression(update, globals.filter) + ); +} + +export function matchesNotification( + notification: NotificationRecord, + globals: GlobalOptions, +): boolean { + return ( + matchText(notification.category, globals.status) && + matchText(notification.type, globals.state) && + runFilterExpression(notification, globals.filter) + ); +} diff --git a/packages/cli/src/utils/guards.ts b/packages/cli/src/utils/guards.ts index 658220c..6a1f47c 100644 --- a/packages/cli/src/utils/guards.ts +++ b/packages/cli/src/utils/guards.ts @@ -11,5 +11,14 @@ export function readBoolean(value: unknown): boolean { } export function readNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + if (typeof value === "string" && value.trim().length > 0) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : undefined; + } + + return undefined; } diff --git a/packages/cli/tests/help.test.ts b/packages/cli/tests/help.test.ts index 17b0ca1..08f5244 100644 --- a/packages/cli/tests/help.test.ts +++ b/packages/cli/tests/help.test.ts @@ -21,7 +21,7 @@ function captureRenderedHelp(command?: Command): string { } describe("help output", () => { - test("contains docs and skills commands", () => { + test("contains v2 workflow, entity, and discovery commands", () => { const program = createProgram(); const help = program.helpInformation(); const issuesCommand = program.commands.find((command) => command.name() === "issues"); @@ -42,16 +42,54 @@ describe("help output", () => { program.commands.find((command) => command.name() === "comments")?.helpInformation() ?? ""; const attachmentsHelp = program.commands.find((command) => command.name() === "attachments")?.helpInformation() ?? ""; + const customersHelp = + program.commands.find((command) => command.name() === "customers")?.helpInformation() ?? ""; + const customerNeedsHelp = + program.commands.find((command) => command.name() === "customer-needs")?.helpInformation() ?? + ""; + const milestonesHelp = + program.commands.find((command) => command.name() === "milestones")?.helpInformation() ?? ""; + const projectUpdatesHelp = + program.commands.find((command) => command.name() === "project-updates")?.helpInformation() ?? + ""; + const initiativeUpdatesHelp = + program.commands + .find((command) => command.name() === "initiative-updates") + ?.helpInformation() ?? ""; + const notificationsHelp = + program.commands.find((command) => command.name() === "notifications")?.helpInformation() ?? + ""; + const doctorHelp = + program.commands.find((command) => command.name() === "doctor")?.helpInformation() ?? ""; + const myWorkHelp = + program.commands.find((command) => command.name() === "my-work")?.helpInformation() ?? ""; + const triageHelp = + program.commands.find((command) => command.name() === "triage")?.helpInformation() ?? ""; expect(help).toContain("--version"); expect(help).toContain("docs"); expect(help).toContain("skills"); + expect(help).toContain("doctor"); + expect(help).toContain("my-work"); + expect(help).toContain("triage"); expect(help).toContain("issues"); expect(help).toContain("initiatives"); expect(help).toContain("documents"); expect(help).toContain("templates"); + expect(help).toContain("customers"); + expect(help).toContain("customer-needs"); + expect(help).toContain("milestones"); + expect(help).toContain("project-updates"); + expect(help).toContain("initiative-updates"); + expect(help).toContain("notifications"); expect(issuesHelp).toContain("branch"); expect(issuesHelp).toContain("browse"); + expect(issuesHelp).toContain("--mine"); + expect(issuesHelp).toContain("--assignee"); + expect(issuesHelp).toContain("--state"); + expect(issuesHelp).toContain("--priority"); + expect(issuesHelp).toContain("--view"); + expect(issuesHelp).toContain("--fields"); expect(renderedIssuesHelp).toContain("Global Options:"); expect(renderedIssuesHelp).toContain("--json"); expect(renderedIssueBranchHelp).toContain("Global Options:"); @@ -63,5 +101,14 @@ describe("help output", () => { expect(templatesHelp).toContain("list"); expect(commentsHelp).toContain("get"); expect(attachmentsHelp).toContain("update"); + expect(customersHelp).toContain("list"); + expect(customerNeedsHelp).toContain("create"); + expect(milestonesHelp).toContain("get"); + expect(projectUpdatesHelp).toContain("list"); + expect(initiativeUpdatesHelp).toContain("update"); + expect(notificationsHelp).toContain("list"); + expect(doctorHelp).toContain("Validate auth"); + expect(myWorkHelp).toContain("assigned"); + expect(triageHelp).toContain("triage"); }); }); diff --git a/packages/cli/tests/options.test.ts b/packages/cli/tests/options.test.ts new file mode 100644 index 0000000..94eda58 --- /dev/null +++ b/packages/cli/tests/options.test.ts @@ -0,0 +1,85 @@ +import { Command } from "commander"; +import { describe, expect, test } from "vitest"; +import { getGlobalOptions } from "../src/runtime/options.js"; + +describe("getGlobalOptions", () => { + test("parses shared v2 query and presentation options", () => { + const program = new Command(); + program + .option("--json") + .option("--profile ") + .option("--team ") + .option("--limit ", (value) => Number.parseInt(value, 10)) + .option("--cursor ") + .option("--quiet") + .option("--mine") + .option("--project ") + .option("--cycle ") + .option("--state ") + .option("--assignee ") + .option("--label ") + .option("--priority ") + .option("--status ") + .option("--filter ") + .option("--sort ") + .option("--view ") + .option("--all") + .option("--fields "); + + program.parse([ + "node", + "linear", + "--profile", + "work", + "--team", + "ENG", + "--limit", + "25", + "--mine", + "--project", + "proj_1", + "--cycle", + "cycle_1", + "--state", + "Todo", + "--assignee", + "me", + "--label", + "Bug", + "--priority", + "2", + "--status", + "active", + "--filter", + "estimate>2", + "--sort", + "updatedAt", + "--view", + "detail", + "--all", + "--fields", + "identifier,title,assigneeName", + ]); + + expect(getGlobalOptions(program)).toEqual({ + json: false, + profile: "work", + team: "ENG", + limit: 25, + quiet: false, + mine: true, + project: "proj_1", + cycle: "cycle_1", + state: "Todo", + assignee: "me", + label: "Bug", + priority: "2", + status: "active", + filter: "estimate>2", + sort: "updatedAt", + view: "detail", + all: true, + fields: ["identifier", "title", "assigneeName"], + }); + }); +}); diff --git a/packages/cli/tests/output.test.ts b/packages/cli/tests/output.test.ts index f93e9c9..266de87 100644 --- a/packages/cli/tests/output.test.ts +++ b/packages/cli/tests/output.test.ts @@ -118,4 +118,45 @@ describe("renderEnvelope", () => { expect(rows[0]?.key).toBeUndefined(); expect(logSpy).toHaveBeenCalledWith("issues.get"); }); + + test("supports detail views with field selection for issue lists", () => { + const tableSpy = vi.spyOn(console, "table").mockImplementation(() => {}); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + renderEnvelope( + successEnvelope("issues", "list", { + items: [ + { + id: "issue-1", + identifier: "ENG-1", + title: "Fix output formatting", + priority: 2, + stateName: "Todo", + updatedAt: "2026-03-16T17:00:00.000Z", + assigneeName: "Alex Example", + projectName: "CLI v2", + estimate: 3, + }, + ], + nextCursor: null, + }), + { + json: false, + quiet: false, + view: "detail", + fields: ["identifier", "title", "assigneeName", "projectName", "estimate"], + }, + ); + + expect(tableSpy).toHaveBeenCalledWith([ + { + identifier: "ENG-1", + title: "Fix output formatting", + assigneeName: "Alex Example", + projectName: "CLI v2", + estimate: 3, + }, + ]); + expect(logSpy).toHaveBeenCalledWith("issues.list"); + }); }); diff --git a/packages/linear-core/src/entities/linear-gateway.ts b/packages/linear-core/src/entities/linear-gateway.ts index 025992d..0c3d0f6 100644 --- a/packages/linear-core/src/entities/linear-gateway.ts +++ b/packages/linear-core/src/entities/linear-gateway.ts @@ -3,12 +3,18 @@ import type { ListOptions, PageResult } from "../types/public.js"; import type { AttachmentRecord, CommentRecord, + CustomerNeedRecord, + CustomerRecord, CycleRecord, DocumentRecord, InitiativeRecord, + InitiativeUpdateRecord, IssueRecord, LabelRecord, + NotificationRecord, + ProjectMilestoneRecord, ProjectRecord, + ProjectUpdateRecord, TeamRecord, TemplateRecord, UserRecord, @@ -21,6 +27,12 @@ import type { SdkCommentInput, SdkCommentLike, SdkCommentUpdateInput, + SdkCustomerInput, + SdkCustomerLike, + SdkCustomerNeedInput, + SdkCustomerNeedLike, + SdkCustomerNeedUpdateInput, + SdkCustomerUpdateInput, SdkCycleInput, SdkCycleLike, SdkCycleUpdateInput, @@ -29,7 +41,10 @@ import type { SdkDocumentUpdateInput, SdkInitiativeInput, SdkInitiativeLike, + SdkInitiativeUpdateCreateInput, SdkInitiativeUpdateInput, + SdkInitiativeUpdateLike, + SdkInitiativeUpdateUpdateInput, SdkIssueInput, SdkIssueLabelInput, SdkIssueLabelLike, @@ -37,9 +52,17 @@ import type { SdkIssueLike, SdkIssueUpdateInput, SdkLinearClient, + SdkNotificationLike, + SdkNotificationUpdateInput, SdkProjectInput, SdkProjectLike, + SdkProjectMilestoneInput, + SdkProjectMilestoneLike, + SdkProjectMilestoneUpdateInput, + SdkProjectUpdateCreateInput, SdkProjectUpdateInput, + SdkProjectUpdateLike, + SdkProjectUpdateUpdateInput, SdkTeamInput, SdkTeamLike, SdkTeamUpdateInput, @@ -64,7 +87,48 @@ function toDateString(value: Date): string { return value.toISOString(); } -function toIssue(record: SdkIssueLike, stateName?: string): IssueRecord { +async function resolveFetch(value: Promise | undefined): Promise { + return value ? await value : undefined; +} + +async function resolveConnectionNodes( + loader: (() => Promise<{ readonly nodes: readonly T[] }>) | undefined, +): Promise { + if (!loader) { + return []; + } + + const connection = await loader(); + return connection.nodes; +} + +function readDisplayName( + value: { readonly displayName?: string; readonly name?: string } | undefined, +): string | undefined { + return value?.displayName ?? value?.name; +} + +async function toIssue(record: SdkIssueLike): Promise { + const [state, assignee, project, cycle, team, milestone, parent, labels, children, relations] = + await Promise.all([ + resolveFetch(record.state), + resolveFetch(record.assignee), + resolveFetch(record.project), + resolveFetch(record.cycle), + resolveFetch(record.team), + resolveFetch(record.projectMilestone), + resolveFetch(record.parent), + resolveConnectionNodes( + typeof record.labels === "function" ? () => record.labels() : undefined, + ), + resolveConnectionNodes( + typeof record.children === "function" ? () => record.children() : undefined, + ), + resolveConnectionNodes( + typeof record.relations === "function" ? () => record.relations() : undefined, + ), + ]); + return { id: record.id, number: record.number, @@ -73,9 +137,27 @@ function toIssue(record: SdkIssueLike, stateName?: string): IssueRecord { description: record.description ?? undefined, branchName: record.branchName ?? undefined, priority: record.priority, - stateName, + estimate: record.estimate ?? undefined, + dueDate: record.dueDate ?? undefined, + stateName: state?.name, + assigneeId: record.assigneeId ?? undefined, + assigneeName: readDisplayName(assignee), teamId: record.teamId, + teamKey: team?.key, + teamName: team?.displayName ?? team?.name, projectId: record.projectId, + projectName: project?.name, + cycleId: record.cycleId ?? undefined, + cycleName: cycle?.name ?? (cycle ? `Cycle ${cycle.number}` : undefined), + milestoneId: record.projectMilestoneId ?? undefined, + milestoneName: milestone?.name, + parentId: record.parentId ?? undefined, + parentIdentifier: parent?.identifier, + labelNames: labels + .map((label) => label.name) + .filter((value): value is string => typeof value === "string"), + childCount: children.length, + relationCount: relations.length, url: record.url, createdAt: toDateString(record.createdAt), updatedAt: toDateString(record.updatedAt), @@ -97,6 +179,26 @@ function toInitiative(record: SdkInitiativeLike): InitiativeRecord { }; } +function toInitiativeUpdate( + record: SdkInitiativeUpdateLike, + initiativeName?: string, + userName?: string, +): InitiativeUpdateRecord { + return { + id: record.id, + body: record.body, + health: String(record.health), + commentCount: record.commentCount, + initiativeId: record.initiativeId ?? undefined, + initiativeName, + userId: record.userId ?? undefined, + userName, + url: record.url, + createdAt: toDateString(record.createdAt), + updatedAt: toDateString(record.updatedAt), + }; +} + function toProject(record: SdkProjectLike): ProjectRecord { return { id: record.id, @@ -114,6 +216,44 @@ function toProject(record: SdkProjectLike): ProjectRecord { }; } +function toProjectMilestone( + record: SdkProjectMilestoneLike, + projectName?: string, +): ProjectMilestoneRecord { + return { + id: record.id, + name: record.name, + description: record.description ?? undefined, + progress: record.progress, + status: String(record.status), + targetDate: record.targetDate ?? undefined, + projectId: record.projectId ?? undefined, + projectName, + createdAt: toDateString(record.createdAt), + updatedAt: toDateString(record.updatedAt), + }; +} + +function toProjectUpdate( + record: SdkProjectUpdateLike, + projectName?: string, + userName?: string, +): ProjectUpdateRecord { + return { + id: record.id, + body: record.body, + health: String(record.health), + commentCount: record.commentCount, + projectId: record.projectId ?? undefined, + projectName, + userId: record.userId ?? undefined, + userName, + url: record.url, + createdAt: toDateString(record.createdAt), + updatedAt: toDateString(record.updatedAt), + }; +} + function toDocument(record: SdkDocumentLike): DocumentRecord { const description = (record as SdkDocumentLike & { description?: string | null }).description; return { @@ -159,6 +299,55 @@ function toTeam(record: SdkTeamLike): TeamRecord { }; } +function toCustomer( + record: SdkCustomerLike, + ownerName?: string, + statusName?: string, + tierName?: string, +): CustomerRecord { + return { + id: record.id, + name: record.name, + slugId: record.slugId, + domains: record.domains, + externalIds: record.externalIds, + approximateNeedCount: record.approximateNeedCount, + revenue: record.revenue ?? undefined, + size: record.size ?? undefined, + ownerId: record.ownerId ?? undefined, + ownerName, + statusId: record.statusId ?? undefined, + statusName, + tierId: record.tierId ?? undefined, + tierName, + url: record.url, + createdAt: toDateString(record.createdAt), + updatedAt: toDateString(record.updatedAt), + }; +} + +function toCustomerNeed( + record: SdkCustomerNeedLike, + customerName?: string, + issueIdentifier?: string, + projectName?: string, +): CustomerNeedRecord { + return { + id: record.id, + body: record.body ?? undefined, + priority: record.priority, + url: record.url ?? undefined, + customerId: record.customerId ?? undefined, + customerName, + issueId: record.issueId ?? undefined, + issueIdentifier, + projectId: record.projectId ?? undefined, + projectName, + createdAt: toDateString(record.createdAt), + updatedAt: toDateString(record.updatedAt), + }; +} + function toUser(record: SdkUserLike): UserRecord { return { id: record.id, @@ -208,6 +397,19 @@ function toAttachment(record: SdkAttachmentLike): AttachmentRecord { }; } +function toNotification(record: SdkNotificationLike, userName?: string): NotificationRecord { + return { + id: record.id, + type: record.type, + category: String(record.category), + userId: record.userId ?? undefined, + userName, + isRead: Boolean(record.readAt), + createdAt: toDateString(record.createdAt), + updatedAt: toDateString(record.updatedAt), + }; +} + function toWorkflowState(record: SdkWorkflowStateLike): WorkflowStateRecord { return { id: record.id, @@ -260,12 +462,7 @@ export class LinearGateway { public async listIssues(options: ListOptions): Promise> { const connection = await this.client.issues(toListVariables(options)); - const items = await Promise.all( - connection.nodes.map(async (node) => { - const state = node.state ? await node.state : undefined; - return toIssue(node, state?.name); - }), - ); + const items = await Promise.all(connection.nodes.map((node) => toIssue(node))); return { items, @@ -275,8 +472,7 @@ export class LinearGateway { public async getIssue(id: string): Promise { const issue = await this.client.issue(id); - const state = issue.state ? await issue.state : undefined; - return toIssue(issue, state?.name); + return toIssue(issue); } public async getIssueBranchName(idOrIdentifier: string): Promise<{ @@ -297,15 +493,132 @@ export class LinearGateway { public async createIssue(input: SdkIssueInput): Promise { const payload = await this.client.createIssue(input); const issue = await requireEntity(payload.issue, "issue"); - const state = issue.state ? await issue.state : undefined; - return toIssue(issue, state?.name); + return toIssue(issue); } public async updateIssue(id: string, input: SdkIssueUpdateInput): Promise { const payload = await this.client.updateIssue(id, input); const issue = await requireEntity(payload.issue, "issue"); - const state = issue.state ? await issue.state : undefined; - return toIssue(issue, state?.name); + return toIssue(issue); + } + + public async listCustomers(options: ListOptions): Promise> { + const connection = await this.client.customers(toListVariables(options)); + const items = await Promise.all( + connection.nodes.map(async (node) => + toCustomer( + node, + readDisplayName(await resolveFetch(node.owner)), + (await resolveFetch(node.status))?.name, + (await resolveFetch(node.tier))?.name, + ), + ), + ); + + return { + items, + nextCursor: connection.pageInfo.endCursor ?? null, + }; + } + + public async getCustomer(id: string): Promise { + const customer = await this.client.customer(id); + return toCustomer( + customer, + readDisplayName(await resolveFetch(customer.owner)), + (await resolveFetch(customer.status))?.name, + (await resolveFetch(customer.tier))?.name, + ); + } + + public async createCustomer(input: SdkCustomerInput): Promise { + const payload = await this.client.createCustomer(input); + const customer = await requireEntity(payload.customer, "customer"); + return toCustomer( + customer, + readDisplayName(await resolveFetch(customer.owner)), + (await resolveFetch(customer.status))?.name, + (await resolveFetch(customer.tier))?.name, + ); + } + + public async updateCustomer(id: string, input: SdkCustomerUpdateInput): Promise { + const payload = await this.client.updateCustomer(id, input); + const customer = await requireEntity(payload.customer, "customer"); + return toCustomer( + customer, + readDisplayName(await resolveFetch(customer.owner)), + (await resolveFetch(customer.status))?.name, + (await resolveFetch(customer.tier))?.name, + ); + } + + public async deleteCustomer(id: string): Promise<{ readonly success: boolean }> { + const payload = await this.client.deleteCustomer(id); + return { + success: payload.success, + }; + } + + public async listCustomerNeeds(options: ListOptions): Promise> { + const connection = await this.client.customerNeeds(toListVariables(options)); + const items = await Promise.all( + connection.nodes.map(async (node) => + toCustomerNeed( + node, + (await resolveFetch(node.customer))?.name, + (await resolveFetch(node.issue))?.identifier, + (await resolveFetch(node.project))?.name, + ), + ), + ); + + return { + items, + nextCursor: connection.pageInfo.endCursor ?? null, + }; + } + + public async getCustomerNeed(id: string): Promise { + const need = await this.client.customerNeed({ id }); + return toCustomerNeed( + need, + (await resolveFetch(need.customer))?.name, + (await resolveFetch(need.issue))?.identifier, + (await resolveFetch(need.project))?.name, + ); + } + + public async createCustomerNeed(input: SdkCustomerNeedInput): Promise { + const payload = await this.client.createCustomerNeed(input); + const need = await requireEntity(payload.need, "customerNeed"); + return toCustomerNeed( + need, + (await resolveFetch(need.customer))?.name, + (await resolveFetch(need.issue))?.identifier, + (await resolveFetch(need.project))?.name, + ); + } + + public async updateCustomerNeed( + id: string, + input: SdkCustomerNeedUpdateInput, + ): Promise { + const payload = await this.client.updateCustomerNeed(id, input); + const need = await requireEntity(payload.need, "customerNeed"); + return toCustomerNeed( + need, + (await resolveFetch(need.customer))?.name, + (await resolveFetch(need.issue))?.identifier, + (await resolveFetch(need.project))?.name, + ); + } + + public async deleteCustomerNeed(id: string): Promise<{ readonly success: boolean }> { + const payload = await this.client.deleteCustomerNeed(id); + return { + success: payload.success, + }; } public async deleteIssue( @@ -353,6 +666,70 @@ export class LinearGateway { }; } + public async listInitiativeUpdates( + options: ListOptions, + ): Promise> { + const connection = await this.client.initiativeUpdates(toListVariables(options)); + const items = await Promise.all( + connection.nodes.map(async (node) => + toInitiativeUpdate( + node, + (await resolveFetch(node.initiative))?.name, + readDisplayName(await resolveFetch(node.user)), + ), + ), + ); + + return { + items, + nextCursor: connection.pageInfo.endCursor ?? null, + }; + } + + public async getInitiativeUpdate(id: string): Promise { + const update = await this.client.initiativeUpdate(id); + return toInitiativeUpdate( + update, + (await resolveFetch(update.initiative))?.name, + readDisplayName(await resolveFetch(update.user)), + ); + } + + public async createInitiativeUpdate( + input: SdkInitiativeUpdateCreateInput, + ): Promise { + const payload = await this.client.createInitiativeUpdate(input); + const update = await requireEntity(payload.initiativeUpdate, "initiativeUpdate"); + return toInitiativeUpdate( + update, + (await resolveFetch(update.initiative))?.name, + readDisplayName(await resolveFetch(update.user)), + ); + } + + public async updateInitiativeUpdate( + id: string, + input: SdkInitiativeUpdateUpdateInput, + ): Promise { + const payload = await this.client.updateInitiativeUpdate(id, input); + const update = await requireEntity(payload.initiativeUpdate, "initiativeUpdate"); + return toInitiativeUpdate( + update, + (await resolveFetch(update.initiative))?.name, + readDisplayName(await resolveFetch(update.user)), + ); + } + + public async deleteInitiativeUpdate( + id: string, + ): Promise<{ readonly id?: string; readonly success: boolean }> { + const payload = await this.client.archiveInitiativeUpdate(id); + return { + id: payload.entityId, + success: payload.success, + }; + } + public async listProjects(options: ListOptions): Promise> { const connection = await this.client.projects(toListVariables(options)); return { @@ -385,6 +762,110 @@ export class LinearGateway { }; } + public async listProjectMilestones( + options: ListOptions, + ): Promise> { + const connection = await this.client.projectMilestones(toListVariables(options)); + const items = await Promise.all( + connection.nodes.map(async (node) => + toProjectMilestone(node, (await resolveFetch(node.project))?.name), + ), + ); + + return { + items, + nextCursor: connection.pageInfo.endCursor ?? null, + }; + } + + public async getProjectMilestone(id: string): Promise { + const milestone = await this.client.projectMilestone(id); + return toProjectMilestone(milestone, (await resolveFetch(milestone.project))?.name); + } + + public async createProjectMilestone( + input: SdkProjectMilestoneInput, + ): Promise { + const payload = await this.client.createProjectMilestone(input); + const milestone = await requireEntity(payload.projectMilestone, "projectMilestone"); + return toProjectMilestone(milestone, (await resolveFetch(milestone.project))?.name); + } + + public async updateProjectMilestone( + id: string, + input: SdkProjectMilestoneUpdateInput, + ): Promise { + const payload = await this.client.updateProjectMilestone(id, input); + const milestone = await requireEntity(payload.projectMilestone, "projectMilestone"); + return toProjectMilestone(milestone, (await resolveFetch(milestone.project))?.name); + } + + public async deleteProjectMilestone(id: string): Promise<{ readonly success: boolean }> { + const payload = await this.client.deleteProjectMilestone(id); + return { + success: payload.success, + }; + } + + public async listProjectUpdates(options: ListOptions): Promise> { + const connection = await this.client.projectUpdates(toListVariables(options)); + const items = await Promise.all( + connection.nodes.map(async (node) => + toProjectUpdate( + node, + (await resolveFetch(node.project))?.name, + readDisplayName(await resolveFetch(node.user)), + ), + ), + ); + + return { + items, + nextCursor: connection.pageInfo.endCursor ?? null, + }; + } + + public async getProjectUpdate(id: string): Promise { + const update = await this.client.projectUpdate(id); + return toProjectUpdate( + update, + (await resolveFetch(update.project))?.name, + readDisplayName(await resolveFetch(update.user)), + ); + } + + public async createProjectUpdate( + input: SdkProjectUpdateCreateInput, + ): Promise { + const payload = await this.client.createProjectUpdate(input); + const update = await requireEntity(payload.projectUpdate, "projectUpdate"); + return toProjectUpdate( + update, + (await resolveFetch(update.project))?.name, + readDisplayName(await resolveFetch(update.user)), + ); + } + + public async updateProjectUpdate( + id: string, + input: SdkProjectUpdateUpdateInput, + ): Promise { + const payload = await this.client.updateProjectUpdate(id, input); + const update = await requireEntity(payload.projectUpdate, "projectUpdate"); + return toProjectUpdate( + update, + (await resolveFetch(update.project))?.name, + readDisplayName(await resolveFetch(update.user)), + ); + } + + public async deleteProjectUpdate(id: string): Promise<{ readonly success: boolean }> { + const payload = await this.client.deleteProjectUpdate(id); + return { + success: payload.success, + }; + } + public async listDocuments(options: ListOptions): Promise> { const connection = await this.client.documents(toListVariables(options)); return { @@ -585,6 +1066,41 @@ export class LinearGateway { }; } + public async listNotifications(options: ListOptions): Promise> { + const connection = await this.client.notifications(toListVariables(options)); + const items = await Promise.all( + connection.nodes.map(async (node) => + toNotification(node, readDisplayName(await resolveFetch(node.user))), + ), + ); + + return { + items, + nextCursor: connection.pageInfo.endCursor ?? null, + }; + } + + public async getNotification(id: string): Promise { + const notification = await this.client.notification(id); + return toNotification(notification, readDisplayName(await resolveFetch(notification.user))); + } + + public async updateNotification( + id: string, + input: SdkNotificationUpdateInput, + ): Promise { + await this.client.updateNotification(id, input); + const notification = await this.client.notification(id); + return toNotification(notification, readDisplayName(await resolveFetch(notification.user))); + } + + public async deleteNotification(id: string): Promise<{ readonly success: boolean }> { + const payload = await this.client.archiveNotification(id); + return { + success: payload.success, + }; + } + public async listWorkflowStates(options: ListOptions): Promise> { const connection = await this.client.workflowStates(toListVariables(options)); return { diff --git a/packages/linear-core/src/entities/models.ts b/packages/linear-core/src/entities/models.ts index 9bfa32f..20e522f 100644 --- a/packages/linear-core/src/entities/models.ts +++ b/packages/linear-core/src/entities/models.ts @@ -6,9 +6,25 @@ export interface IssueRecord { readonly description?: string; readonly branchName?: string; readonly priority: number; + readonly estimate?: number; + readonly dueDate?: string; readonly stateName?: string; + readonly assigneeId?: string; + readonly assigneeName?: string; readonly teamId?: string; + readonly teamKey?: string; + readonly teamName?: string; readonly projectId?: string; + readonly projectName?: string; + readonly cycleId?: string; + readonly cycleName?: string; + readonly milestoneId?: string; + readonly milestoneName?: string; + readonly parentId?: string; + readonly parentIdentifier?: string; + readonly labelNames?: readonly string[]; + readonly childCount?: number; + readonly relationCount?: number; readonly url: string; readonly createdAt: string; readonly updatedAt: string; @@ -29,6 +45,33 @@ export interface ProjectRecord { readonly updatedAt: string; } +export interface ProjectMilestoneRecord { + readonly id: string; + readonly name: string; + readonly description?: string; + readonly progress: number; + readonly status: string; + readonly targetDate?: string; + readonly projectId?: string; + readonly projectName?: string; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface ProjectUpdateRecord { + readonly id: string; + readonly body: string; + readonly health: string; + readonly commentCount: number; + readonly projectId?: string; + readonly projectName?: string; + readonly userId?: string; + readonly userName?: string; + readonly url: string; + readonly createdAt: string; + readonly updatedAt: string; +} + export interface DocumentRecord { readonly id: string; readonly title: string; @@ -55,6 +98,20 @@ export interface InitiativeRecord { readonly updatedAt: string; } +export interface InitiativeUpdateRecord { + readonly id: string; + readonly body: string; + readonly health: string; + readonly commentCount: number; + readonly initiativeId?: string; + readonly initiativeName?: string; + readonly userId?: string; + readonly userName?: string; + readonly url: string; + readonly createdAt: string; + readonly updatedAt: string; +} + export interface CycleRecord { readonly id: string; readonly number: number; @@ -80,6 +137,41 @@ export interface TeamRecord { readonly updatedAt: string; } +export interface CustomerRecord { + readonly id: string; + readonly name: string; + readonly slugId: string; + readonly domains: readonly string[]; + readonly externalIds: readonly string[]; + readonly approximateNeedCount: number; + readonly revenue?: number; + readonly size?: number; + readonly ownerId?: string; + readonly ownerName?: string; + readonly statusId?: string; + readonly statusName?: string; + readonly tierId?: string; + readonly tierName?: string; + readonly url: string; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface CustomerNeedRecord { + readonly id: string; + readonly body?: string; + readonly priority: number; + readonly url?: string; + readonly customerId?: string; + readonly customerName?: string; + readonly issueId?: string; + readonly issueIdentifier?: string; + readonly projectId?: string; + readonly projectName?: string; + readonly createdAt: string; + readonly updatedAt: string; +} + export interface UserRecord { readonly id: string; readonly name: string; @@ -121,6 +213,17 @@ export interface AttachmentRecord { readonly updatedAt: string; } +export interface NotificationRecord { + readonly id: string; + readonly type: string; + readonly category: string; + readonly userId?: string; + readonly userName?: string; + readonly isRead: boolean; + readonly createdAt: string; + readonly updatedAt: string; +} + export interface WorkflowStateRecord { readonly id: string; readonly name: string; diff --git a/packages/linear-core/src/entities/sdk-types.ts b/packages/linear-core/src/entities/sdk-types.ts index 11d9e3c..fc6ee1c 100644 --- a/packages/linear-core/src/entities/sdk-types.ts +++ b/packages/linear-core/src/entities/sdk-types.ts @@ -4,19 +4,46 @@ export type SdkLinearClient = Pick< LinearClient, | "issues" | "issue" + | "viewer" + | "rateLimitStatus" | "createIssue" | "updateIssue" | "deleteIssue" + | "customers" + | "customer" + | "createCustomer" + | "updateCustomer" + | "deleteCustomer" + | "customerNeeds" + | "customerNeed" + | "createCustomerNeed" + | "updateCustomerNeed" + | "deleteCustomerNeed" | "initiatives" | "initiative" | "createInitiative" | "updateInitiative" | "deleteInitiative" + | "initiativeUpdates" + | "initiativeUpdate" + | "createInitiativeUpdate" + | "updateInitiativeUpdate" + | "archiveInitiativeUpdate" | "projects" | "project" | "createProject" | "updateProject" | "deleteProject" + | "projectMilestones" + | "projectMilestone" + | "createProjectMilestone" + | "updateProjectMilestone" + | "deleteProjectMilestone" + | "projectUpdates" + | "projectUpdate" + | "createProjectUpdate" + | "updateProjectUpdate" + | "deleteProjectUpdate" | "documents" | "document" | "createDocument" @@ -50,6 +77,10 @@ export type SdkLinearClient = Pick< | "createAttachment" | "updateAttachment" | "deleteAttachment" + | "notifications" + | "notification" + | "archiveNotification" + | "updateNotification" | "workflowStates" | "workflowState" | "createWorkflowState" @@ -63,8 +94,13 @@ export type SdkLinearClient = Pick< >; export type SdkIssueLike = Awaited>; +export type SdkCustomerLike = Awaited>; +export type SdkCustomerNeedLike = Awaited>; export type SdkInitiativeLike = Awaited>; +export type SdkInitiativeUpdateLike = Awaited>; export type SdkProjectLike = Awaited>; +export type SdkProjectMilestoneLike = Awaited>; +export type SdkProjectUpdateLike = Awaited>; export type SdkDocumentLike = Awaited>; export type SdkCycleLike = Awaited>; export type SdkTeamLike = Awaited>; @@ -72,12 +108,22 @@ export type SdkUserLike = Awaited>; export type SdkIssueLabelLike = Awaited>; export type SdkCommentLike = Awaited>["nodes"][number]; export type SdkAttachmentLike = Awaited>; +export type SdkNotificationLike = Awaited>; export type SdkWorkflowStateLike = Awaited>; export type SdkTemplateLike = Awaited>; export type SdkIssueConnectionLike = Awaited>; +export type SdkCustomerConnectionLike = Awaited>; +export type SdkCustomerNeedConnectionLike = Awaited>; export type SdkInitiativeConnectionLike = Awaited>; +export type SdkInitiativeUpdateConnectionLike = Awaited< + ReturnType +>; export type SdkProjectConnectionLike = Awaited>; +export type SdkProjectMilestoneConnectionLike = Awaited< + ReturnType +>; +export type SdkProjectUpdateConnectionLike = Awaited>; export type SdkDocumentConnectionLike = Awaited>; export type SdkCycleConnectionLike = Awaited>; export type SdkTeamConnectionLike = Awaited>; @@ -85,12 +131,22 @@ export type SdkUserConnectionLike = Awaited>; export type SdkIssueLabelConnectionLike = Awaited>; export type SdkCommentConnectionLike = Awaited>; export type SdkAttachmentConnectionLike = Awaited>; +export type SdkNotificationConnectionLike = Awaited>; export type SdkWorkflowStateConnectionLike = Awaited>; export type SdkTemplateListLike = Awaited; export type SdkIssuePayloadLike = Awaited>; +export type SdkCustomerPayloadLike = Awaited>; +export type SdkCustomerNeedPayloadLike = Awaited>; export type SdkInitiativePayloadLike = Awaited>; +export type SdkInitiativeUpdatePayloadLike = Awaited< + ReturnType +>; export type SdkProjectPayloadLike = Awaited>; +export type SdkProjectMilestonePayloadLike = Awaited< + ReturnType +>; +export type SdkProjectUpdatePayloadLike = Awaited>; export type SdkDocumentPayloadLike = Awaited>; export type SdkCyclePayloadLike = Awaited>; export type SdkTeamPayloadLike = Awaited>; @@ -98,15 +154,26 @@ export type SdkUserPayloadLike = Awaited> export type SdkIssueLabelPayloadLike = Awaited>; export type SdkCommentPayloadLike = Awaited>; export type SdkAttachmentPayloadLike = Awaited>; +export type SdkNotificationPayloadLike = Awaited>; export type SdkWorkflowStatePayloadLike = Awaited>; export type SdkTemplatePayloadLike = Awaited>; export type SdkIssueInput = Parameters[0]; export type SdkIssueUpdateInput = Parameters[1]; +export type SdkCustomerInput = Parameters[0]; +export type SdkCustomerUpdateInput = Parameters[1]; +export type SdkCustomerNeedInput = Parameters[0]; +export type SdkCustomerNeedUpdateInput = Parameters[1]; export type SdkInitiativeInput = Parameters[0]; export type SdkInitiativeUpdateInput = Parameters[1]; +export type SdkInitiativeUpdateCreateInput = Parameters[0]; +export type SdkInitiativeUpdateUpdateInput = Parameters[1]; export type SdkProjectInput = Parameters[0]; export type SdkProjectUpdateInput = Parameters[1]; +export type SdkProjectMilestoneInput = Parameters[0]; +export type SdkProjectMilestoneUpdateInput = Parameters[1]; +export type SdkProjectUpdateCreateInput = Parameters[0]; +export type SdkProjectUpdateUpdateInput = Parameters[1]; export type SdkDocumentInput = Parameters[0]; export type SdkDocumentUpdateInput = Parameters[1]; export type SdkCycleInput = Parameters[0]; @@ -120,6 +187,7 @@ export type SdkCommentInput = Parameters[0]; export type SdkCommentUpdateInput = Parameters[1]; export type SdkAttachmentInput = Parameters[0]; export type SdkAttachmentUpdateInput = Parameters[1]; +export type SdkNotificationUpdateInput = Parameters[1]; export type SdkWorkflowStateInput = Parameters[0]; export type SdkWorkflowStateUpdateInput = Parameters[1]; export type SdkTemplateInput = Parameters[0]; diff --git a/packages/linear-core/src/types/public.ts b/packages/linear-core/src/types/public.ts index 9214402..5c11ab5 100644 --- a/packages/linear-core/src/types/public.ts +++ b/packages/linear-core/src/types/public.ts @@ -1,8 +1,13 @@ export type LinearEntity = | "auth" | "issues" + | "customers" + | "customer-needs" | "initiatives" + | "initiative-updates" | "projects" + | "milestones" + | "project-updates" | "documents" | "cycles" | "teams" @@ -10,10 +15,12 @@ export type LinearEntity = | "labels" | "comments" | "attachments" + | "notifications" | "states" | "templates" | "skills" | "docs" + | "doctor" | "tui"; export type LinearAction = @@ -73,3 +80,29 @@ export interface ListOptions { readonly limit?: number; readonly cursor?: string | null; } + +export type ViewPreset = "table" | "detail" | "dense"; + +export interface SortSpec { + readonly field: string; + readonly direction?: "asc" | "desc"; +} + +export interface FieldSelection { + readonly fields?: readonly string[]; + readonly view?: ViewPreset; +} + +export interface ListQuery extends ListOptions, FieldSelection { + readonly mine?: boolean; + readonly project?: string; + readonly cycle?: string; + readonly state?: string; + readonly assignee?: string; + readonly label?: string; + readonly priority?: string | number; + readonly status?: string; + readonly filter?: string; + readonly sort?: string | SortSpec; + readonly all?: boolean; +} diff --git a/packages/linear-core/tests/v2-gateway.test.ts b/packages/linear-core/tests/v2-gateway.test.ts new file mode 100644 index 0000000..bd2ca3e --- /dev/null +++ b/packages/linear-core/tests/v2-gateway.test.ts @@ -0,0 +1,416 @@ +import { describe, expect, test } from "vitest"; +import { LinearGateway } from "../src/entities/linear-gateway.js"; +import type { SdkLinearClient } from "../src/entities/sdk-types.js"; + +function createV2Client(): SdkLinearClient { + return { + async issue() { + return { + id: "i_1", + number: 1, + identifier: "ENG-1", + title: "Fix parser", + description: "Parser crashes when a trailing comma is present.", + branchName: "eng-1-fix-parser", + priority: 2, + estimate: 3, + dueDate: "2026-04-01", + url: "https://linear.app/issue/ENG-1", + createdAt: new Date("2026-03-01T00:00:00.000Z"), + updatedAt: new Date("2026-03-02T00:00:00.000Z"), + state: Promise.resolve({ + id: "state_1", + name: "In Progress", + type: "started", + }), + assignee: Promise.resolve({ + id: "user_1", + name: "Alex Example", + displayName: "Alex", + email: "alex@example.com", + active: true, + url: "https://linear.app/user/alex", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + }), + project: Promise.resolve({ + id: "project_1", + name: "CLI v2", + state: "active", + priority: 2, + progress: 0.5, + url: "https://linear.app/project/cli-v2", + createdAt: new Date("2026-02-01T00:00:00.000Z"), + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + }), + cycle: Promise.resolve({ + id: "cycle_1", + number: 9, + name: "Cycle 9", + progress: 0.3, + startsAt: new Date("2026-03-01T00:00:00.000Z"), + endsAt: new Date("2026-03-14T00:00:00.000Z"), + isActive: true, + createdAt: new Date("2026-02-25T00:00:00.000Z"), + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + }), + team: Promise.resolve({ + id: "team_1", + key: "ENG", + name: "Engineering", + displayName: "Engineering", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + }), + projectMilestone: Promise.resolve({ + id: "milestone_1", + name: "Beta", + progress: 0.75, + sortOrder: 1, + status: "inProgress", + targetDate: "2026-04-15", + createdAt: new Date("2026-02-20T00:00:00.000Z"), + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + project: Promise.resolve({ + id: "project_1", + name: "CLI v2", + state: "active", + priority: 2, + progress: 0.5, + url: "https://linear.app/project/cli-v2", + createdAt: new Date("2026-02-01T00:00:00.000Z"), + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + }), + }), + parent: Promise.resolve({ + id: "i_parent", + identifier: "ENG-0", + title: "Parent issue", + }), + async labels() { + return { + nodes: [ + { + id: "label_1", + name: "Bug", + description: "Reliability issue", + color: "#ff0000", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + }, + ], + }; + }, + async children() { + return { + nodes: [ + { + id: "i_child", + }, + ], + }; + }, + async relations() { + return { + nodes: [ + { + id: "rel_1", + type: "blocks", + }, + ], + }; + }, + }; + }, + async customers() { + return { + nodes: [ + { + id: "customer_1", + name: "Acme", + slugId: "acme", + approximateNeedCount: 3, + domains: ["acme.com"], + externalIds: ["zendesk:1"], + revenue: 120000, + size: 300, + url: "https://linear.app/customer/acme", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-03-10T00:00:00.000Z"), + owner: Promise.resolve({ + id: "user_1", + name: "Alex Example", + displayName: "Alex", + email: "alex@example.com", + active: true, + url: "https://linear.app/user/alex", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + }), + status: Promise.resolve({ + id: "status_1", + name: "Active", + }), + tier: Promise.resolve({ + id: "tier_1", + name: "Enterprise", + }), + needs: [], + }, + ], + pageInfo: { + endCursor: "customer-cursor-2", + }, + }; + }, + async customer() { + const connection = await this.customers(); + return connection.nodes[0]; + }, + async customerNeeds() { + return { + nodes: [ + { + id: "need_1", + body: "Need SSO support", + priority: 1, + createdAt: new Date("2026-03-01T00:00:00.000Z"), + updatedAt: new Date("2026-03-10T00:00:00.000Z"), + url: "https://linear.app/request/need_1", + customer: Promise.resolve({ + id: "customer_1", + name: "Acme", + }), + issue: Promise.resolve({ + id: "i_1", + identifier: "ENG-1", + title: "Fix parser", + }), + project: Promise.resolve({ + id: "project_1", + name: "CLI v2", + }), + }, + ], + pageInfo: { + endCursor: "need-cursor-2", + }, + }; + }, + async customerNeed() { + const connection = await this.customerNeeds(); + return connection.nodes[0]; + }, + async projectMilestones() { + return { + nodes: [ + { + id: "milestone_1", + name: "Beta", + description: "Beta launch milestone", + progress: 0.75, + sortOrder: 1, + status: "inProgress", + targetDate: "2026-04-15", + createdAt: new Date("2026-02-20T00:00:00.000Z"), + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + project: Promise.resolve({ + id: "project_1", + name: "CLI v2", + }), + }, + ], + pageInfo: { + endCursor: "milestone-cursor-2", + }, + }; + }, + async projectMilestone() { + const connection = await this.projectMilestones(); + return connection.nodes[0]; + }, + async projectUpdates() { + return { + nodes: [ + { + id: "pu_1", + body: "Project update body", + health: "onTrack", + createdAt: new Date("2026-03-10T00:00:00.000Z"), + updatedAt: new Date("2026-03-10T00:00:00.000Z"), + url: "https://linear.app/update/project/pu_1", + isStale: false, + isDiffHidden: false, + commentCount: 2, + slugId: "pu-1", + reactionData: {}, + reactions: [], + project: Promise.resolve({ + id: "project_1", + name: "CLI v2", + }), + user: Promise.resolve({ + id: "user_1", + name: "Alex Example", + displayName: "Alex", + email: "alex@example.com", + active: true, + url: "https://linear.app/user/alex", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + }), + }, + ], + pageInfo: { + endCursor: "project-update-cursor-2", + }, + }; + }, + async projectUpdate() { + const connection = await this.projectUpdates(); + return connection.nodes[0]; + }, + async initiativeUpdates() { + return { + nodes: [ + { + id: "iu_1", + body: "Initiative update body", + health: "onTrack", + createdAt: new Date("2026-03-11T00:00:00.000Z"), + updatedAt: new Date("2026-03-11T00:00:00.000Z"), + url: "https://linear.app/update/initiative/iu_1", + isStale: false, + isDiffHidden: false, + commentCount: 1, + slugId: "iu-1", + reactionData: {}, + reactions: [], + initiative: Promise.resolve({ + id: "initiative_1", + name: "Agent Platform", + }), + user: Promise.resolve({ + id: "user_1", + name: "Alex Example", + displayName: "Alex", + email: "alex@example.com", + active: true, + url: "https://linear.app/user/alex", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + }), + }, + ], + pageInfo: { + endCursor: "initiative-update-cursor-2", + }, + }; + }, + async initiativeUpdate() { + const connection = await this.initiativeUpdates(); + return connection.nodes[0]; + }, + async notifications() { + return { + nodes: [ + { + id: "notification_1", + type: "issueAssignedToYou", + category: "assignments", + createdAt: new Date("2026-03-12T00:00:00.000Z"), + updatedAt: new Date("2026-03-12T00:00:00.000Z"), + readAt: null, + snoozedUntilAt: null, + user: Promise.resolve({ + id: "user_1", + name: "Alex Example", + displayName: "Alex", + email: "alex@example.com", + active: true, + url: "https://linear.app/user/alex", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + }), + }, + ], + pageInfo: { + endCursor: "notification-cursor-2", + }, + }; + }, + async notification() { + const connection = await this.notifications(); + return connection.nodes[0]; + }, + } as unknown as SdkLinearClient; +} + +describe("LinearGateway v2", () => { + test("gets enriched issue details with related names and summaries", async () => { + const gateway = new LinearGateway(createV2Client()); + + const issue = await gateway.getIssue("ENG-1"); + + expect(issue.assigneeName).toBe("Alex"); + expect(issue.projectName).toBe("CLI v2"); + expect(issue.cycleName).toBe("Cycle 9"); + expect(issue.teamKey).toBe("ENG"); + expect(issue.milestoneName).toBe("Beta"); + expect(issue.parentIdentifier).toBe("ENG-0"); + expect(issue.labelNames).toEqual(["Bug"]); + expect(issue.childCount).toBe(1); + expect(issue.relationCount).toBe(1); + expect(issue.estimate).toBe(3); + expect(issue.dueDate).toBe("2026-04-01"); + }); + + test("lists customers with owner, status, and tier names", async () => { + const gateway = new LinearGateway(createV2Client()); + + const result = await gateway.listCustomers({ limit: 10 }); + + expect(result.items[0]).toMatchObject({ + id: "customer_1", + name: "Acme", + ownerName: "Alex", + statusName: "Active", + tierName: "Enterprise", + approximateNeedCount: 3, + }); + expect(result.nextCursor).toBe("customer-cursor-2"); + }); + + test("lists milestones, updates, and notifications", async () => { + const gateway = new LinearGateway(createV2Client()); + + const milestones = await gateway.listProjectMilestones({ limit: 10 }); + const projectUpdates = await gateway.listProjectUpdates({ limit: 10 }); + const initiativeUpdates = await gateway.listInitiativeUpdates({ limit: 10 }); + const notifications = await gateway.listNotifications({ limit: 10 }); + + expect(milestones.items[0]).toMatchObject({ + name: "Beta", + projectName: "CLI v2", + status: "inProgress", + }); + expect(projectUpdates.items[0]).toMatchObject({ + body: "Project update body", + health: "onTrack", + projectName: "CLI v2", + userName: "Alex", + }); + expect(initiativeUpdates.items[0]).toMatchObject({ + body: "Initiative update body", + health: "onTrack", + initiativeName: "Agent Platform", + userName: "Alex", + }); + expect(notifications.items[0]).toMatchObject({ + type: "issueAssignedToYou", + category: "assignments", + userName: "Alex", + isRead: false, + }); + }); +}); diff --git a/packages/tui/src/components/Layout.tsx b/packages/tui/src/components/Layout.tsx index 5cba52c..f9caf91 100644 --- a/packages/tui/src/components/Layout.tsx +++ b/packages/tui/src/components/Layout.tsx @@ -12,15 +12,33 @@ export function Layout({ screen, children }: PropsWithChildren) { Linear TUI - - Screens: [1] Issues [2] Projects [3] Initiatives [4] Documents [5] Cycles | [tab/shift+tab] - switch | [j/k] move | [n/p] page | [o] open selected | [r] refresh | [q] quit - - - Active: {screen} - - - {children} + + + Navigation + [1] Issues + [2] Projects + [3] Initiatives + [4] Documents + [5] Cycles + [tab] next + [shift-tab] prev + + + {children} + + + + + Active: {screen} | [j/k] move | [n/p] page | [o] open | [r] + refresh | [q] quit + ); diff --git a/packages/tui/src/screens/IssuesScreen.tsx b/packages/tui/src/screens/IssuesScreen.tsx index 4808b9e..1a5b81c 100644 --- a/packages/tui/src/screens/IssuesScreen.tsx +++ b/packages/tui/src/screens/IssuesScreen.tsx @@ -117,41 +117,57 @@ function IssuesTable({ const visibleIssues = issues.slice(start, start + visibleRowCount); return ( - - Key Title State Pri Updated - - {" "} - ------------------------------------------------------------------------------- - - {visibleIssues.map((issue, index) => { - const absoluteIndex = start + index; - const isSelected = absoluteIndex === selectedIndex; - - return ( - - {isSelected ? ">" : " "} {pad(issue.identifier, 8)} {pad(truncate(issue.title, 42), 42)}{" "} - {pad(issue.stateName ?? "-", 14)} {pad(formatPriority(issue.priority), 4)}{" "} - {formatTimestamp(issue.updatedAt)} - - ); - })} - - {" "} - ------------------------------------------------------------------------------- - - - Rows {start + 1}-{Math.min(start + visibleIssues.length, issues.length)} of {issues.length}{" "} - | Prev page [{hasPreviousPage ? "p" : "-"}] | Next page [{hasNextPage ? "n" : "-"}] | Move - [j/k or arrows] | Open [o] - - {selectedIssue ? ( - <> - - Selected: {selectedIssue.identifier} - {selectedIssue.title} - - {selectedIssue.url} - - ) : null} + + + Key Title State Pri Updated + + {" "} + ------------------------------------------------------------------------------- + + {visibleIssues.map((issue, index) => { + const absoluteIndex = start + index; + const isSelected = absoluteIndex === selectedIndex; + + return ( + + {isSelected ? ">" : " "} {pad(issue.identifier, 8)}{" "} + {pad(truncate(issue.title, 42), 42)} {pad(issue.stateName ?? "-", 14)}{" "} + {pad(formatPriority(issue.priority), 4)} {formatTimestamp(issue.updatedAt)} + + ); + })} + + {" "} + ------------------------------------------------------------------------------- + + + Rows {start + 1}-{Math.min(start + visibleIssues.length, issues.length)} of{" "} + {issues.length} | Prev page [{hasPreviousPage ? "p" : "-"}] | Next page [ + {hasNextPage ? "n" : "-"}] + + + + Details + {selectedIssue ? ( + <> + {selectedIssue.identifier} + {selectedIssue.title} + State: {selectedIssue.stateName ?? "-"} + Priority: {formatPriority(selectedIssue.priority)} + Updated: {formatTimestamp(selectedIssue.updatedAt)} + {selectedIssue.url} + + ) : ( + No issue selected. + )} + ); } diff --git a/packages/tui/tests/app.test.tsx b/packages/tui/tests/app.test.tsx index 0de0ed3..0e13006 100644 --- a/packages/tui/tests/app.test.tsx +++ b/packages/tui/tests/app.test.tsx @@ -112,9 +112,11 @@ describe("App", () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(app.lastFrame()).toContain("Linear TUI"); - expect(app.lastFrame()).toContain("Key"); + expect(app.lastFrame()).toContain("Navigation"); + expect(app.lastFrame()).toContain("Details"); expect(app.lastFrame()).toContain("Issue title"); - expect(app.lastFrame()).toContain("Selected: ENG-1"); + expect(app.lastFrame()).toContain("ENG-1"); + expect(app.lastFrame()).toContain("Todo"); app.unmount(); }); From 9fd50b299df3d29e7f64bf6af65b0617fcc44758 Mon Sep 17 00:00:00 2001 From: Daniel Wise Date: Thu, 19 Mar 2026 21:17:44 -0700 Subject: [PATCH 2/2] fix(cli): address PR review feedback Tighten local page collection, fix project matching for customer needs and related filters, and ensure my-work resolves the authenticated viewer before filtering. --- packages/cli/src/index.ts | 9 +- packages/cli/src/runtime/query.ts | 59 ++++++++-- packages/cli/tests/query.test.ts | 177 ++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+), 15 deletions(-) create mode 100644 packages/cli/tests/query.test.ts diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 45e989b..531643b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -495,9 +495,12 @@ export function createProgram(authManager = new AuthManager()): Command { const sessionGateway = async (cmd: Command) => (await openSessionForCommand(cmd)).gateway; - const resolveViewerName = async (cmd: Command): Promise => { + const resolveViewerName = async ( + cmd: Command, + options?: { readonly forceMine?: boolean }, + ): Promise => { const globals = getGlobalOptions(cmd); - if (!globals.mine && globals.assignee !== "me") { + if (!options?.forceMine && !globals.mine && globals.assignee !== "me") { return undefined; } @@ -1233,7 +1236,7 @@ export function createProgram(authManager = new AuthManager()): Command { const globals = getGlobalOptions(cmd); try { - const viewerName = await resolveViewerName(cmd); + const viewerName = await resolveViewerName(cmd, { forceMine: true }); const gateway = await sessionGateway(cmd); const data = await collectPageResult( (options) => gateway.listIssues(options), diff --git a/packages/cli/src/runtime/query.ts b/packages/cli/src/runtime/query.ts index 8a0a87c..381073d 100644 --- a/packages/cli/src/runtime/query.ts +++ b/packages/cli/src/runtime/query.ts @@ -11,6 +11,8 @@ import type { } from "@wiseiodev/linear-core"; import type { GlobalOptions } from "./options.js"; +const PAGE_BATCH_SIZE = 50; + function asRecord(value: object): Record { return value as Record; } @@ -27,6 +29,14 @@ function matchText(value: string | undefined, query: string | undefined): boolea return normalizeText(value)?.includes(normalizeText(query) ?? "") ?? false; } +function matchAnyText(query: string | undefined, ...values: Array): boolean { + if (!query) { + return true; + } + + return values.some((value) => matchText(value, query)); +} + function runFilterExpression(item: object, expression: string | undefined): boolean { if (!expression) { return true; @@ -73,8 +83,12 @@ function sortItems(items: readonly T[], sort?: string): T[] { } function shouldDrainPages(options: GlobalOptions): boolean { + return Boolean(options.all || options.sort); +} + +function hasLocalFiltering(options: GlobalOptions): boolean { return Boolean( - options.all || + options.team || options.mine || options.project || options.cycle || @@ -83,8 +97,7 @@ function shouldDrainPages(options: GlobalOptions): boolean { options.label || options.priority || options.status || - options.filter || - options.sort, + options.filter, ); } @@ -93,7 +106,7 @@ export async function collectPageResult( globals: GlobalOptions, predicate: (item: T) => boolean = () => true, ): Promise> { - if (!shouldDrainPages(globals)) { + if (!shouldDrainPages(globals) && !hasLocalFiltering(globals)) { const page = await loader({ limit: globals.limit, cursor: globals.cursor, @@ -108,12 +121,34 @@ export async function collectPageResult( }; } + if (!shouldDrainPages(globals)) { + const targetCount = globals.limit ?? PAGE_BATCH_SIZE; + let cursor = globals.cursor; + const collected: T[] = []; + + do { + const page = await loader({ + // Fetch full pages locally so lightweight filters do not require --all. + limit: PAGE_BATCH_SIZE, + cursor, + }); + collected.push(...page.items.filter(predicate)); + cursor = page.nextCursor ?? undefined; + } while (cursor && collected.length < targetCount); + + return { + items: collected.slice(0, globals.limit ?? collected.length), + nextCursor: cursor ?? null, + }; + } + let cursor = globals.cursor; const collected: T[] = []; do { const page = await loader({ - limit: globals.limit ?? 50, + // Use a fixed batch size in drain mode to avoid many small-page requests. + limit: PAGE_BATCH_SIZE, cursor, }); collected.push(...page.items.filter(predicate)); @@ -135,9 +170,9 @@ export function matchesIssue( const labels = issue.labelNames?.join(" "); const assigneeQuery = globals.mine ? (viewerName ?? globals.assignee) : globals.assignee; return ( - matchText(issue.teamKey ?? issue.teamName, globals.team) && - matchText(issue.projectId ?? issue.projectName, globals.project) && - matchText(issue.cycleId ?? issue.cycleName, globals.cycle) && + matchAnyText(globals.team, issue.teamKey, issue.teamName) && + matchAnyText(globals.project, issue.projectId, issue.projectName) && + matchAnyText(globals.cycle, issue.cycleId, issue.cycleName) && matchText(issue.stateName, globals.state) && matchText(issue.assigneeName, assigneeQuery) && matchText(labels, globals.label) && @@ -149,7 +184,7 @@ export function matchesIssue( export function matchesProject(project: ProjectRecord, globals: GlobalOptions): boolean { return ( matchText(project.state, globals.status) && - matchText(project.id ?? project.name, globals.project) && + matchAnyText(globals.project, project.id, project.name) && runFilterExpression(project, globals.filter) ); } @@ -169,7 +204,7 @@ export function matchesCustomer( export function matchesCustomerNeed(need: CustomerNeedRecord, globals: GlobalOptions): boolean { return ( - matchText(need.customerId ?? need.customerName, globals.project) && + matchAnyText(globals.project, need.projectId, need.projectName) && (globals.priority ? String(need.priority) === String(globals.priority) : true) && runFilterExpression(need, globals.filter) ); @@ -180,7 +215,7 @@ export function matchesMilestone( globals: GlobalOptions, ): boolean { return ( - matchText(milestone.projectId ?? milestone.projectName, globals.project) && + matchAnyText(globals.project, milestone.projectId, milestone.projectName) && matchText(milestone.status, globals.status) && runFilterExpression(milestone, globals.filter) ); @@ -188,7 +223,7 @@ export function matchesMilestone( export function matchesProjectUpdate(update: ProjectUpdateRecord, globals: GlobalOptions): boolean { return ( - matchText(update.projectId ?? update.projectName, globals.project) && + matchAnyText(globals.project, update.projectId, update.projectName) && matchText(update.health, globals.status) && runFilterExpression(update, globals.filter) ); diff --git a/packages/cli/tests/query.test.ts b/packages/cli/tests/query.test.ts new file mode 100644 index 0000000..b78f9dc --- /dev/null +++ b/packages/cli/tests/query.test.ts @@ -0,0 +1,177 @@ +import type { IssueRecord, PageResult } from "@wiseiodev/linear-core"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createProgram } from "../src/index.js"; +import { collectPageResult, matchesCustomerNeed } from "../src/runtime/query.js"; + +describe("collectPageResult", () => { + test("fetches fixed-size batches until filtered results satisfy the requested limit", async () => { + const loader = vi + .fn< + (options: { + limit?: number; + cursor?: string; + }) => Promise> + >() + .mockResolvedValueOnce({ + items: [{ id: "issue-1", keep: false }], + nextCursor: "cursor-1", + }) + .mockResolvedValueOnce({ + items: [ + { id: "issue-2", keep: true }, + { id: "issue-3", keep: true }, + ], + nextCursor: "cursor-2", + }); + + const result = await collectPageResult( + loader, + { + json: false, + quiet: false, + mine: true, + limit: 2, + }, + (item) => item.keep, + ); + + expect(loader).toHaveBeenNthCalledWith(1, { + limit: 50, + cursor: undefined, + }); + expect(loader).toHaveBeenNthCalledWith(2, { + limit: 50, + cursor: "cursor-1", + }); + expect(result).toEqual({ + items: [ + { id: "issue-2", keep: true }, + { id: "issue-3", keep: true }, + ], + nextCursor: "cursor-2", + }); + }); + + test("uses fixed-size batches for full drains and applies the limit after sorting", async () => { + const loader = vi + .fn< + (options: { + limit?: number; + cursor?: string; + }) => Promise> + >() + .mockResolvedValueOnce({ + items: [{ id: "2", title: "Zulu" }], + nextCursor: "cursor-1", + }) + .mockResolvedValueOnce({ + items: [{ id: "1", title: "Alpha" }], + nextCursor: null, + }); + + const result = await collectPageResult(loader, { + json: false, + quiet: false, + all: true, + sort: "title", + limit: 1, + }); + + expect(loader).toHaveBeenNthCalledWith(1, { + limit: 50, + cursor: undefined, + }); + expect(loader).toHaveBeenNthCalledWith(2, { + limit: 50, + cursor: "cursor-1", + }); + expect(result).toEqual({ + items: [{ id: "1", title: "Alpha" }], + nextCursor: null, + }); + }); +}); + +describe("matchesCustomerNeed", () => { + test("matches the project filter against project fields instead of customer fields", () => { + expect( + matchesCustomerNeed( + { + id: "need-1", + customerId: "customer-1", + customerName: "Acme", + projectId: "project-1", + projectName: "CLI v2", + body: "Need richer CLI commands", + priority: 1, + createdAt: "2026-03-19T00:00:00.000Z", + updatedAt: "2026-03-19T00:00:00.000Z", + }, + { + json: false, + quiet: false, + project: "cli v2", + }, + ), + ).toBe(true); + }); +}); + +describe("my-work", () => { + afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = 0; + }); + + test("resolves the authenticated user even when --mine was implied by the command", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const fakeIssue = { + id: "issue-1", + identifier: "ENG-1", + title: "Only my issue should remain", + priority: 2, + stateName: "Todo", + assigneeName: "Wise Dev", + updatedAt: "2026-03-19T00:00:00.000Z", + } satisfies IssueRecord; + + const otherIssue = { + ...fakeIssue, + id: "issue-2", + identifier: "ENG-2", + assigneeName: "Someone Else", + } satisfies IssueRecord; + + const openSession = vi.fn().mockResolvedValue({ + client: { + viewer: Promise.resolve({ + displayName: "Wise Dev", + name: "wise", + }), + }, + gateway: { + listIssues: vi.fn().mockResolvedValue({ + items: [fakeIssue, otherIssue], + nextCursor: null, + }), + }, + }); + + const program = createProgram({ + openSession, + } as never); + + await program.parseAsync(["node", "linear", "--json", "my-work"]); + + const [[payload]] = logSpy.mock.calls; + expect(openSession).toHaveBeenCalledTimes(2); + expect(JSON.parse(String(payload))).toMatchObject({ + ok: true, + entity: "issues", + action: "list", + data: { + items: [expect.objectContaining({ identifier: "ENG-1" })], + }, + }); + }); +});