diff --git a/USAGE.md b/USAGE.md index f80d8d8..98b3476 100644 --- a/USAGE.md +++ b/USAGE.md @@ -58,8 +58,9 @@ arguments: string list options: - --query <text> filter by text search - --limit <n> max results (default: 50) + --query <text> filter by text search + --limit <n> max results (default: 50) + --after <cursor> cursor for next page create options: --description <text> issue body @@ -129,7 +130,9 @@ commands: list [options] list available labels list options: - --team <team> filter by team (key, name, or UUID) + --team <team> filter by team (key, name, or UUID) + --limit <n> max results (default: 50) + --after <cursor> cursor for next page see also: issues create --labels, issues update --labels @@ -144,7 +147,8 @@ commands: list [options] list projects list options: - --limit <n> max results (default: 100) + --limit <n> max results (default: 100) + --after <cursor> cursor for next page see also: milestones list --project, documents list --project @@ -163,9 +167,11 @@ arguments: <cycle> cycle identifier (UUID or name) list options: - --team <team> filter by team (key, name, or UUID) - --active only show active cycles - --window <n> active cycle +/- n neighbors (requires --team) + --team <team> filter by team (key, name, or UUID) + --active only show active cycles + --window <n> active cycle +/- n neighbors (requires --team) + --limit <n> max results (default: 50) + --after <cursor> cursor for next page read options: --team <team> scope name lookup to team @@ -193,6 +199,7 @@ arguments: list options: --project <project> target project (required) --limit <n> max results (default: 50) + --after <cursor> cursor for next page read options: --project <project> scope name lookup to project @@ -233,6 +240,7 @@ list options: --project <project> filter by project name or ID --issue <issue> filter by issue (shows documents attached to the issue) --limit <n> max results (default: 50) + --after <cursor> cursor for next page create options: --title <title> document title (required) @@ -279,7 +287,11 @@ a team is a group of users that owns issues, cycles, statuses, and labels. teams are identified by a short key (e.g. ENG), name, or UUID. commands: - list list all teams + list [options] list all teams + +list options: + --limit <n> max results (default: 50) + --after <cursor> cursor for next page --- @@ -292,4 +304,6 @@ commands: list [options] list workspace members list options: - --active only show active users + --active only show active users + --limit <n> max results (default: 50) + --after <cursor> cursor for next page diff --git a/graphql/queries/cycles.graphql b/graphql/queries/cycles.graphql index e2dd851..e6e621f 100644 --- a/graphql/queries/cycles.graphql +++ b/graphql/queries/cycles.graphql @@ -58,11 +58,15 @@ fragment CycleWithIssuesFields on Cycle { # Variables: # $first: Maximum number of cycles to return (default: 50) # $filter: Optional CycleFilter for team/status filtering -query GetCycles($first: Int = 50, $filter: CycleFilter) { - cycles(first: $first, filter: $filter) { +query GetCycles($first: Int = 50, $after: String, $filter: CycleFilter) { + cycles(first: $first, after: $after, filter: $filter) { nodes { ...CycleFields } + pageInfo { + hasNextPage + endCursor + } } } diff --git a/graphql/queries/documents.graphql b/graphql/queries/documents.graphql index 87a6a50..38d7407 100644 --- a/graphql/queries/documents.graphql +++ b/graphql/queries/documents.graphql @@ -35,10 +35,14 @@ query GetDocument($id: String!) { # List documents with optional filtering # # Fetches a list of documents with optional filtering criteria. -query ListDocuments($first: Int!, $filter: DocumentFilter) { - documents(first: $first, filter: $filter) { +query ListDocuments($first: Int!, $after: String, $filter: DocumentFilter) { + documents(first: $first, after: $after, filter: $filter) { nodes { ...DocumentFields } + pageInfo { + hasNextPage + endCursor + } } } diff --git a/graphql/queries/issues.graphql b/graphql/queries/issues.graphql index 6e1ae7f..b6a4ee8 100644 --- a/graphql/queries/issues.graphql +++ b/graphql/queries/issues.graphql @@ -191,15 +191,20 @@ fragment CompleteIssueSearchFields on IssueSearchResult { # Fetches paginated issues excluding completed ones, # ordered by most recently updated. Includes all relationships # for comprehensive issue data. -query GetIssues($first: Int!, $orderBy: PaginationOrderBy) { +query GetIssues($first: Int!, $after: String, $orderBy: PaginationOrderBy) { issues( first: $first + after: $after orderBy: $orderBy filter: { state: { type: { neq: "completed" } } } ) { nodes { ...CompleteIssueFields } + pageInfo { + hasNextPage + endCursor + } } } @@ -243,11 +248,20 @@ query GetIssueTeam($issueId: String!) { # # Provides full-text search across Linear issues with complete # relationship data for each match. -query SearchIssues($term: String!, $first: Int!) { - searchIssues(term: $term, first: $first, includeArchived: false) { +query SearchIssues($term: String!, $first: Int!, $after: String) { + searchIssues( + term: $term + first: $first + after: $after + includeArchived: false + ) { nodes { ...CompleteIssueSearchFields } + pageInfo { + hasNextPage + endCursor + } } } @@ -257,11 +271,13 @@ query SearchIssues($term: String!, $first: Int!) { # Used by the advanced search functionality with multiple criteria. query FilteredSearchIssues( $first: Int! + $after: String $filter: IssueFilter $orderBy: PaginationOrderBy ) { issues( first: $first + after: $after filter: $filter orderBy: $orderBy includeArchived: false @@ -269,6 +285,10 @@ query FilteredSearchIssues( nodes { ...CompleteIssueFields } + pageInfo { + hasNextPage + endCursor + } } } diff --git a/graphql/queries/labels.graphql b/graphql/queries/labels.graphql index 1391a74..384c7f1 100644 --- a/graphql/queries/labels.graphql +++ b/graphql/queries/labels.graphql @@ -31,10 +31,14 @@ fragment LabelFields on IssueLabel { # Variables: # $first: Maximum number of labels to return (default: 50) # $filter: Optional filter (e.g., { team: { id: { eq: "team-uuid" } } }) -query GetLabels($first: Int = 50, $filter: IssueLabelFilter) { - issueLabels(first: $first, filter: $filter) { +query GetLabels($first: Int = 50, $after: String, $filter: IssueLabelFilter) { + issueLabels(first: $first, after: $after, filter: $filter) { nodes { ...LabelFields } + pageInfo { + hasNextPage + endCursor + } } } diff --git a/graphql/queries/project-milestones.graphql b/graphql/queries/project-milestones.graphql index 8d340a7..24ed9a5 100644 --- a/graphql/queries/project-milestones.graphql +++ b/graphql/queries/project-milestones.graphql @@ -8,11 +8,11 @@ # List project milestones in a project # # Fetches a list of project milestones for a given project. -query ListProjectMilestones($projectId: String!, $first: Int!) { +query ListProjectMilestones($projectId: String!, $first: Int!, $after: String) { project(id: $projectId) { id name - projectMilestones(first: $first) { + projectMilestones(first: $first, after: $after) { nodes { id name @@ -22,6 +22,10 @@ query ListProjectMilestones($projectId: String!, $first: Int!) { createdAt updatedAt } + pageInfo { + hasNextPage + endCursor + } } } } diff --git a/graphql/queries/projects.graphql b/graphql/queries/projects.graphql index 45ecc41..4b2cbc3 100644 --- a/graphql/queries/projects.graphql +++ b/graphql/queries/projects.graphql @@ -32,10 +32,14 @@ fragment ProjectFields on Project { # # Variables: # $first: Maximum number of projects to return (default: 50) -query GetProjects($first: Int = 50) { - projects(first: $first) { +query GetProjects($first: Int = 50, $after: String) { + projects(first: $first, after: $after) { nodes { ...ProjectFields } + pageInfo { + hasNextPage + endCursor + } } } diff --git a/graphql/queries/teams.graphql b/graphql/queries/teams.graphql index 5cc24b1..372a76c 100644 --- a/graphql/queries/teams.graphql +++ b/graphql/queries/teams.graphql @@ -28,10 +28,14 @@ fragment TeamFields on Team { # # Variables: # $first: Maximum number of teams to return (default: 50) -query GetTeams($first: Int = 50) { - teams(first: $first) { +query GetTeams($first: Int = 50, $after: String) { + teams(first: $first, after: $after) { nodes { ...TeamFields } + pageInfo { + hasNextPage + endCursor + } } } diff --git a/graphql/queries/users.graphql b/graphql/queries/users.graphql index c74fb7e..253c6ca 100644 --- a/graphql/queries/users.graphql +++ b/graphql/queries/users.graphql @@ -31,10 +31,14 @@ fragment UserFields on User { # Variables: # $first: Maximum number of users to return (default: 50) # $filter: Optional filter (e.g., { active: { eq: true } }) -query GetUsers($first: Int = 50, $filter: UserFilter) { - users(first: $first, filter: $filter) { +query GetUsers($first: Int = 50, $after: String, $filter: UserFilter) { + users(first: $first, after: $after, filter: $filter) { nodes { ...UserFields } + pageInfo { + hasNextPage + endCursor + } } } diff --git a/src/commands/cycles.ts b/src/commands/cycles.ts index 163e443..85cd8a1 100644 --- a/src/commands/cycles.ts +++ b/src/commands/cycles.ts @@ -5,7 +5,7 @@ import { notFoundError, requiresParameterError, } from "../common/errors.js"; -import { handleCommand, outputSuccess } from "../common/output.js"; +import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { resolveCycleId } from "../resolvers/cycle-resolver.js"; import { resolveTeamId } from "../resolvers/team-resolver.js"; @@ -15,6 +15,8 @@ interface CycleListOptions extends CommandOptions { team?: string; active?: boolean; window?: string; + limit: string; + after?: string; } interface CycleReadOptions extends CommandOptions { @@ -46,12 +48,20 @@ export function setupCyclesCommands(program: Command): void { .option("--team <team>", "filter by team (key, name, or UUID)") .option("--active", "only show active cycles") .option("--window <n>", "active cycle +/- n neighbors (requires --team)") + .option("-l, --limit <n>", "max results", "50") + .option("--after <cursor>", "cursor for next page") .action( handleCommand(async (...args: unknown[]) => { const [options, command] = args as [CycleListOptions, Command]; if (options.window && !options.team) { throw requiresParameterError("--window", "--team"); } + if (options.window && options.after) { + throw invalidParameterError( + "--after", + "cannot be used with --window", + ); + } const ctx = createContext(command.parent!.parent!.opts()); @@ -61,10 +71,11 @@ export function setupCyclesCommands(program: Command): void { : undefined; // Fetch cycles - const allCycles = await listCycles( + const result = await listCycles( ctx.gql, teamId, options.active || false, + { limit: parseLimit(options.limit), after: options.after }, ); if (options.window) { @@ -76,7 +87,7 @@ export function setupCyclesCommands(program: Command): void { ); } - const activeCycle = allCycles.find((c: Cycle) => c.isActive); + const activeCycle = result.nodes.find((c: Cycle) => c.isActive); if (!activeCycle) { throw notFoundError("Active cycle", options.team ?? "", "for team"); } @@ -85,15 +96,18 @@ export function setupCyclesCommands(program: Command): void { const min = activeNumber - n; const max = activeNumber + n; - const filtered = allCycles + const filteredNodes = result.nodes .filter((c: Cycle) => c.number >= min && c.number <= max) .sort((a: Cycle, b: Cycle) => a.number - b.number); - outputSuccess(filtered); + outputSuccess({ + nodes: filteredNodes, + pageInfo: { hasNextPage: false, endCursor: null }, + }); return; } - outputSuccess(allCycles); + outputSuccess(result); }), ); @@ -116,7 +130,7 @@ export function setupCyclesCommands(program: Command): void { const cycleResult = await getCycle( ctx.gql, cycleId, - parseInt(options.limit || "50", 10), + parseLimit(options.limit || "50"), ); outputSuccess(cycleResult); diff --git a/src/commands/documents.ts b/src/commands/documents.ts index a99316b..fcf4526 100644 --- a/src/commands/documents.ts +++ b/src/commands/documents.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; import { createContext } from "../common/context.js"; -import { handleCommand, outputSuccess } from "../common/output.js"; +import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import type { DocumentUpdateInput } from "../gql/graphql.js"; import { resolveIssueId } from "../resolvers/issue-resolver.js"; @@ -41,6 +41,7 @@ interface DocumentListOptions { project?: string; issue?: string; limit?: string; + after?: string; } /** Extracts slug ID from a Linear document URL (e.g. /workspace/document/title-slug-abc123 -> abc123). */ @@ -98,6 +99,7 @@ export function setupDocumentsCommands(program: Command): void { "filter by issue (shows documents attached to the issue)", ) .option("-l, --limit <n>", "max results", "50") + .option("--after <cursor>", "cursor for next page") .action( handleCommand(async (...args: unknown[]) => { const [options, command] = args as [DocumentListOptions, Command]; @@ -110,12 +112,7 @@ export function setupDocumentsCommands(program: Command): void { const rootOpts = command.parent!.parent!.opts(); const ctx = createContext(rootOpts); - const limit = parseInt(options.limit || "50", 10); - if (Number.isNaN(limit) || limit < 1) { - throw new Error( - `Invalid limit "${options.limit}": must be a positive number`, - ); - } + const limit = parseLimit(options.limit || "50"); if (options.issue) { const issueId = await resolveIssueId(ctx.sdk, options.issue); @@ -130,7 +127,10 @@ export function setupDocumentsCommands(program: Command): void { ]; if (documentSlugIds.length === 0) { - outputSuccess([]); + outputSuccess({ + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }); return; } @@ -138,7 +138,10 @@ export function setupDocumentsCommands(program: Command): void { ctx.gql, documentSlugIds, ); - outputSuccess(documents); + outputSuccess({ + nodes: documents, + pageInfo: { hasNextPage: false, endCursor: null }, + }); return; } @@ -149,6 +152,7 @@ export function setupDocumentsCommands(program: Command): void { const documents = await listDocuments(ctx.gql, { limit, + after: options.after, filter: projectId ? { project: { id: { eq: projectId } } } : undefined, diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 88acc4c..dadab50 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -2,7 +2,7 @@ import type { Command } from "commander"; import type { CommandContext } from "../common/context.js"; import { createContext } from "../common/context.js"; import { isUuid, parseIssueIdentifier } from "../common/identifier.js"; -import { handleCommand, outputSuccess } from "../common/output.js"; +import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { type IssueCreateInput, @@ -34,6 +34,7 @@ import { interface ListOptions { query?: string; limit: string; + after?: string; } interface CreateOptions { @@ -174,20 +175,26 @@ export function setupIssuesCommands(program: Command): void { .description("list issues with optional filters") .option("--query <text>", "filter by text search") .option("-l, --limit <n>", "max results", "50") + .option("--after <cursor>", "cursor for next page") .action( handleCommand(async (...args: unknown[]) => { const [options, command] = args as [ListOptions, Command]; const ctx = createContext(command.parent!.parent!.opts()); + const paginationOptions = { + limit: parseLimit(options.limit), + after: options.after, + }; + if (options.query) { const result = await searchIssues( ctx.gql, options.query, - parseInt(options.limit, 10), + paginationOptions, ); outputSuccess(result); } else { - const result = await listIssues(ctx.gql, parseInt(options.limit, 10)); + const result = await listIssues(ctx.gql, paginationOptions); outputSuccess(result); } }), diff --git a/src/commands/labels.ts b/src/commands/labels.ts index dc6c4fe..eaffce6 100644 --- a/src/commands/labels.ts +++ b/src/commands/labels.ts @@ -1,12 +1,14 @@ import type { Command } from "commander"; import { type CommandOptions, createContext } from "../common/context.js"; -import { handleCommand, outputSuccess } from "../common/output.js"; +import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { resolveTeamId } from "../resolvers/team-resolver.js"; import { listLabels } from "../services/label-service.js"; interface ListLabelsOptions extends CommandOptions { team?: string; + limit: string; + after?: string; } export const LABELS_META: DomainMeta = { @@ -29,6 +31,8 @@ export function setupLabelsCommands(program: Command): void { .command("list") .description("list available labels") .option("--team <team>", "filter by team (key, name, or UUID)") + .option("-l, --limit <n>", "max results", "50") + .option("--after <cursor>", "cursor for next page") .action( handleCommand(async (...args: unknown[]) => { const [options, command] = args as [ListLabelsOptions, Command]; @@ -38,7 +42,10 @@ export function setupLabelsCommands(program: Command): void { ? await resolveTeamId(ctx.sdk, options.team) : undefined; - const result = await listLabels(ctx.gql, teamId); + const result = await listLabels(ctx.gql, teamId, { + limit: parseLimit(options.limit), + after: options.after, + }); outputSuccess(result); }), ); diff --git a/src/commands/milestones.ts b/src/commands/milestones.ts index b411290..efe8af4 100644 --- a/src/commands/milestones.ts +++ b/src/commands/milestones.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; import { createContext } from "../common/context.js"; -import { handleCommand, outputSuccess } from "../common/output.js"; +import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import type { ProjectMilestoneUpdateInput } from "../gql/graphql.js"; import { resolveMilestoneId } from "../resolvers/milestone-resolver.js"; @@ -16,6 +16,7 @@ import { interface MilestoneListOptions { project: string; limit?: string; + after?: string; } interface MilestoneReadOptions { @@ -67,6 +68,7 @@ export function setupMilestonesCommands(program: Command): void { .description("list milestones in a project") .requiredOption("--project <project>", "target project (required)") .option("-l, --limit <n>", "max results", "50") + .option("--after <cursor>", "cursor for next page") .action( handleCommand(async (...args: unknown[]) => { const [options, command] = args as [MilestoneListOptions, Command]; @@ -75,11 +77,10 @@ export function setupMilestonesCommands(program: Command): void { // Resolve project ID const projectId = await resolveProjectId(ctx.sdk, options.project); - const milestones = await listMilestones( - ctx.gql, - projectId, - parseInt(options.limit || "50", 10), - ); + const milestones = await listMilestones(ctx.gql, projectId, { + limit: parseLimit(options.limit || "50"), + after: options.after, + }); outputSuccess(milestones); }), @@ -110,7 +111,7 @@ export function setupMilestonesCommands(program: Command): void { const milestoneResult = await getMilestone( ctx.gql, milestoneId, - parseInt(options.limit || "50", 10), + parseLimit(options.limit || "50"), ); outputSuccess(milestoneResult); diff --git a/src/commands/projects.ts b/src/commands/projects.ts index d49f522..2ad8bbb 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; import { createContext } from "../common/context.js"; -import { handleCommand, outputSuccess } from "../common/output.js"; +import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { listProjects } from "../services/project-service.js"; @@ -26,11 +26,18 @@ export function setupProjectsCommands(program: Command): void { .command("list") .description("list projects") .option("-l, --limit <n>", "max results", "100") + .option("--after <cursor>", "cursor for next page") .action( handleCommand(async (...args: unknown[]) => { - const [options, command] = args as [{ limit: string }, Command]; + const [options, command] = args as [ + { limit: string; after?: string }, + Command, + ]; const ctx = createContext(command.parent!.parent!.opts()); - const result = await listProjects(ctx.gql, parseInt(options.limit, 10)); + const result = await listProjects(ctx.gql, { + limit: parseLimit(options.limit), + after: options.after, + }); outputSuccess(result); }), ); diff --git a/src/commands/teams.ts b/src/commands/teams.ts index 2b7ee52..70b9d83 100644 --- a/src/commands/teams.ts +++ b/src/commands/teams.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; -import { type CommandOptions, createContext } from "../common/context.js"; -import { handleCommand, outputSuccess } from "../common/output.js"; +import { createContext } from "../common/context.js"; +import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { listTeams } from "../services/team-service.js"; @@ -23,11 +23,19 @@ export function setupTeamsCommands(program: Command): void { teams .command("list") .description("list all teams") + .option("-l, --limit <n>", "max results", "50") + .option("--after <cursor>", "cursor for next page") .action( handleCommand(async (...args: unknown[]) => { - const [, command] = args as [CommandOptions, Command]; + const [options, command] = args as [ + { limit: string; after?: string }, + Command, + ]; const ctx = createContext(command.parent!.parent!.opts()); - const result = await listTeams(ctx.gql); + const result = await listTeams(ctx.gql, { + limit: parseLimit(options.limit), + after: options.after, + }); outputSuccess(result); }), ); diff --git a/src/commands/users.ts b/src/commands/users.ts index 4148f32..59df6c0 100644 --- a/src/commands/users.ts +++ b/src/commands/users.ts @@ -1,11 +1,13 @@ import type { Command } from "commander"; import { type CommandOptions, createContext } from "../common/context.js"; -import { handleCommand, outputSuccess } from "../common/output.js"; +import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { listUsers } from "../services/user-service.js"; interface ListUsersOptions extends CommandOptions { active?: boolean; + limit: string; + after?: string; } export const USERS_META: DomainMeta = { @@ -28,11 +30,16 @@ export function setupUsersCommands(program: Command): void { .command("list") .description("list workspace members") .option("--active", "only show active users") + .option("-l, --limit <n>", "max results", "50") + .option("--after <cursor>", "cursor for next page") .action( handleCommand(async (...args: unknown[]) => { const [options, command] = args as [ListUsersOptions, Command]; const ctx = createContext(command.parent!.parent!.opts()); - const result = await listUsers(ctx.gql, options.active || false); + const result = await listUsers(ctx.gql, options.active || false, { + limit: parseLimit(options.limit), + after: options.after, + }); outputSuccess(result); }), ); diff --git a/src/common/output.ts b/src/common/output.ts index ca8e11a..3bb42ea 100644 --- a/src/common/output.ts +++ b/src/common/output.ts @@ -1,4 +1,8 @@ -import { AUTH_ERROR_CODE, AuthenticationError } from "./errors.js"; +import { + AUTH_ERROR_CODE, + AuthenticationError, + invalidParameterError, +} from "./errors.js"; export function outputSuccess(data: unknown): void { console.log(JSON.stringify(data, null, 2)); @@ -28,6 +32,14 @@ export function outputAuthError(error: AuthenticationError): void { process.exit(AUTH_ERROR_CODE); } +export function parseLimit(value: string): number { + const limit = parseInt(value, 10); + if (Number.isNaN(limit) || limit < 1) { + throw invalidParameterError("--limit", "must be a positive integer"); + } + return limit; +} + export function handleCommand( asyncFn: (...args: unknown[]) => Promise<void>, ): (...args: unknown[]) => Promise<void> { diff --git a/src/common/types.ts b/src/common/types.ts index 480bd3d..49aa4f8 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -19,6 +19,19 @@ import type { UpdateProjectMilestoneMutation, } from "../gql/graphql.js"; +// Pagination types +export type PageInfo = GetIssuesQuery["issues"]["pageInfo"]; + +export interface PaginatedResult<T> { + nodes: T[]; + pageInfo: PageInfo; +} + +export interface PaginationOptions { + limit?: number; + after?: string; +} + // Issue types export type Issue = GetIssuesQuery["issues"]["nodes"][0]; export type IssueDetail = NonNullable<GetIssueByIdQuery["issue"]>; diff --git a/src/services/cycle-service.ts b/src/services/cycle-service.ts index 38f2257..90cdd4f 100644 --- a/src/services/cycle-service.ts +++ b/src/services/cycle-service.ts @@ -1,4 +1,5 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { type CycleFilter, GetCycleByIdDocument, @@ -31,7 +32,9 @@ export async function listCycles( client: GraphQLClient, teamId?: string, activeOnly: boolean = false, -): Promise<Cycle[]> { + options: PaginationOptions = {}, +): Promise<PaginatedResult<Cycle>> { + const { limit = 50, after } = options; const filter: CycleFilter = {}; if (teamId) { @@ -43,20 +46,24 @@ export async function listCycles( } const result = await client.request<GetCyclesQuery>(GetCyclesDocument, { - first: 50, + first: limit, + after, filter, }); - return result.cycles.nodes.map((cycle) => ({ - id: cycle.id, - number: cycle.number, - name: cycle.name ?? `Cycle ${cycle.number}`, - startsAt: cycle.startsAt, - endsAt: cycle.endsAt, - isActive: cycle.isActive, - isNext: cycle.isNext, - isPrevious: cycle.isPrevious, - })); + return { + nodes: result.cycles.nodes.map((cycle) => ({ + id: cycle.id, + number: cycle.number, + name: cycle.name ?? `Cycle ${cycle.number}`, + startsAt: cycle.startsAt, + endsAt: cycle.endsAt, + isActive: cycle.isActive, + isNext: cycle.isNext, + isPrevious: cycle.isPrevious, + })), + pageInfo: result.cycles.pageInfo, + }; } export async function getCycle( diff --git a/src/services/document-service.ts b/src/services/document-service.ts index 8521c3d..c65c488 100644 --- a/src/services/document-service.ts +++ b/src/services/document-service.ts @@ -3,6 +3,7 @@ import type { CreatedDocument, Document, DocumentListItem, + PaginatedResult, UpdatedDocument, } from "../common/types.js"; import { @@ -73,18 +74,26 @@ export async function listDocuments( client: GraphQLClient, options?: { limit?: number; + after?: string; filter?: DocumentFilter; }, -): Promise<DocumentListItem[]> { +): Promise<PaginatedResult<DocumentListItem>> { const result = await client.request<ListDocumentsQuery>( ListDocumentsDocument, { first: options?.limit ?? 25, + after: options?.after, filter: options?.filter, }, ); - return result.documents?.nodes ?? []; + return { + nodes: result.documents?.nodes ?? [], + pageInfo: result.documents?.pageInfo ?? { + hasNextPage: false, + endCursor: null, + }, + }; } export async function listDocumentsBySlugIds( diff --git a/src/services/issue-service.ts b/src/services/issue-service.ts index a1bea30..a333552 100644 --- a/src/services/issue-service.ts +++ b/src/services/issue-service.ts @@ -5,6 +5,8 @@ import type { IssueByIdentifier, IssueDetail, IssueSearchResult, + PaginatedResult, + PaginationOptions, UpdatedIssue, } from "../common/types.js"; import { @@ -26,13 +28,18 @@ import { export async function listIssues( client: GraphQLClient, - limit: number = 25, -): Promise<Issue[]> { + options: PaginationOptions = {}, +): Promise<PaginatedResult<Issue>> { + const { limit = 25, after } = options; const result = await client.request<GetIssuesQuery>(GetIssuesDocument, { first: limit, + after, orderBy: "updatedAt", }); - return result.issues?.nodes ?? []; + return { + nodes: result.issues?.nodes ?? [], + pageInfo: result.issues.pageInfo, + }; } export async function getIssue( @@ -68,13 +75,18 @@ export async function getIssueByIdentifier( export async function searchIssues( client: GraphQLClient, term: string, - limit: number = 25, -): Promise<IssueSearchResult[]> { + options: PaginationOptions = {}, +): Promise<PaginatedResult<IssueSearchResult>> { + const { limit = 25, after } = options; const result = await client.request<SearchIssuesQuery>(SearchIssuesDocument, { term, first: limit, + after, }); - return result.searchIssues?.nodes ?? []; + return { + nodes: result.searchIssues?.nodes ?? [], + pageInfo: result.searchIssues.pageInfo, + }; } export async function createIssue( diff --git a/src/services/label-service.ts b/src/services/label-service.ts index 733db21..c2e3f4d 100644 --- a/src/services/label-service.ts +++ b/src/services/label-service.ts @@ -1,4 +1,5 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { GetLabelsDocument, type GetLabelsQuery } from "../gql/graphql.js"; export interface Label { @@ -11,18 +12,24 @@ export interface Label { export async function listLabels( client: GraphQLClient, teamId?: string, -): Promise<Label[]> { + options: PaginationOptions = {}, +): Promise<PaginatedResult<Label>> { + const { limit = 50, after } = options; const filter = teamId ? { team: { id: { eq: teamId } } } : undefined; const result = await client.request<GetLabelsQuery>(GetLabelsDocument, { - first: 50, + first: limit, + after, filter, }); - return result.issueLabels.nodes.map((label) => ({ - id: label.id, - name: label.name, - color: label.color, - description: label.description ?? undefined, - })); + return { + nodes: result.issueLabels.nodes.map((label) => ({ + id: label.id, + name: label.name, + color: label.color, + description: label.description ?? undefined, + })), + pageInfo: result.issueLabels.pageInfo, + }; } diff --git a/src/services/milestone-service.ts b/src/services/milestone-service.ts index 8a4eec3..9a9081a 100644 --- a/src/services/milestone-service.ts +++ b/src/services/milestone-service.ts @@ -3,6 +3,8 @@ import type { CreatedMilestone, MilestoneDetail, MilestoneListItem, + PaginatedResult, + PaginationOptions, UpdatedMilestone, } from "../common/types.js"; import { @@ -21,14 +23,21 @@ import { export async function listMilestones( client: GraphQLClient, projectId: string, - limit: number = 50, -): Promise<MilestoneListItem[]> { + options: PaginationOptions = {}, +): Promise<PaginatedResult<MilestoneListItem>> { + const { limit = 50, after } = options; const result = await client.request<ListProjectMilestonesQuery>( ListProjectMilestonesDocument, - { projectId, first: limit }, + { projectId, first: limit, after }, ); - return result.project?.projectMilestones?.nodes ?? []; + return { + nodes: result.project?.projectMilestones?.nodes ?? [], + pageInfo: result.project?.projectMilestones?.pageInfo ?? { + hasNextPage: false, + endCursor: null, + }, + }; } export async function getMilestone( diff --git a/src/services/project-service.ts b/src/services/project-service.ts index 6ccb0c7..ec612bb 100644 --- a/src/services/project-service.ts +++ b/src/services/project-service.ts @@ -1,4 +1,5 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { GetProjectsDocument, type GetProjectsQuery } from "../gql/graphql.js"; export interface Project { @@ -12,18 +13,23 @@ export interface Project { export async function listProjects( client: GraphQLClient, - limit: number = 50, -): Promise<Project[]> { + options: PaginationOptions = {}, +): Promise<PaginatedResult<Project>> { + const { limit = 50, after } = options; const result = await client.request<GetProjectsQuery>(GetProjectsDocument, { first: limit, + after, }); - return result.projects.nodes.map((project) => ({ - id: project.id, - name: project.name, - description: project.description, - state: project.state, - targetDate: project.targetDate ?? undefined, - slugId: project.slugId, - })); + return { + nodes: result.projects.nodes.map((project) => ({ + id: project.id, + name: project.name, + description: project.description, + state: project.state, + targetDate: project.targetDate ?? undefined, + slugId: project.slugId, + })), + pageInfo: result.projects.pageInfo, + }; } diff --git a/src/services/team-service.ts b/src/services/team-service.ts index 1c0e468..d58297e 100644 --- a/src/services/team-service.ts +++ b/src/services/team-service.ts @@ -1,4 +1,5 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { GetTeamsDocument, type GetTeamsQuery } from "../gql/graphql.js"; export interface Team { @@ -7,9 +8,17 @@ export interface Team { name: string; } -export async function listTeams(client: GraphQLClient): Promise<Team[]> { +export async function listTeams( + client: GraphQLClient, + options: PaginationOptions = {}, +): Promise<PaginatedResult<Team>> { + const { limit = 50, after } = options; const result = await client.request<GetTeamsQuery>(GetTeamsDocument, { - first: 50, + first: limit, + after, }); - return result.teams.nodes; + return { + nodes: result.teams.nodes, + pageInfo: result.teams.pageInfo, + }; } diff --git a/src/services/user-service.ts b/src/services/user-service.ts index 1191844..bb2a5e4 100644 --- a/src/services/user-service.ts +++ b/src/services/user-service.ts @@ -1,4 +1,5 @@ import type { GraphQLClient } from "../client/graphql-client.js"; +import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { GetUsersDocument, type GetUsersQuery } from "../gql/graphql.js"; export interface User { @@ -11,13 +12,19 @@ export interface User { export async function listUsers( client: GraphQLClient, activeOnly: boolean = false, -): Promise<User[]> { + options: PaginationOptions = {}, +): Promise<PaginatedResult<User>> { + const { limit = 50, after } = options; const filter = activeOnly ? { active: { eq: true } } : undefined; const result = await client.request<GetUsersQuery>(GetUsersDocument, { - first: 50, + first: limit, + after, filter, }); // Sort by name to match Linear SDK behavior - return result.users.nodes.sort((a, b) => a.name.localeCompare(b.name)); + return { + nodes: result.users.nodes.sort((a, b) => a.name.localeCompare(b.name)), + pageInfo: result.users.pageInfo, + }; } diff --git a/tests/unit/common/output.test.ts b/tests/unit/common/output.test.ts index bda6497..e82d1e4 100644 --- a/tests/unit/common/output.test.ts +++ b/tests/unit/common/output.test.ts @@ -6,6 +6,7 @@ import { outputAuthError, outputError, outputSuccess, + parseLimit, } from "../../../src/common/output.js"; describe("outputSuccess", () => { @@ -87,6 +88,28 @@ describe("handleCommand with AuthenticationError", () => { }); }); +describe("parseLimit", () => { + it("parses valid integer string", () => { + expect(parseLimit("50")).toBe(50); + }); + + it("parses single digit", () => { + expect(parseLimit("1")).toBe(1); + }); + + it("throws on non-numeric string", () => { + expect(() => parseLimit("foo")).toThrow(); + }); + + it("throws on zero", () => { + expect(() => parseLimit("0")).toThrow(); + }); + + it("throws on negative number", () => { + expect(() => parseLimit("-1")).toThrow(); + }); +}); + describe("outputAuthError", () => { it("outputs structured JSON with AUTHENTICATION_REQUIRED", () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/tests/unit/services/comment-service.test.ts b/tests/unit/services/comment-service.test.ts new file mode 100644 index 0000000..0649525 --- /dev/null +++ b/tests/unit/services/comment-service.test.ts @@ -0,0 +1,65 @@ +// tests/unit/services/comment-service.test.ts +import { describe, expect, it, vi } from "vitest"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { createComment } from "../../../src/services/comment-service.js"; + +function mockGqlClient(response: Record<string, unknown>): GraphQLClient { + return { + request: vi.fn().mockResolvedValue(response), + } as unknown as GraphQLClient; +} + +describe("createComment", () => { + it("creates comment successfully", async () => { + const client = mockGqlClient({ + commentCreate: { + success: true, + comment: { + id: "comment-1", + body: "This is a comment", + createdAt: "2025-01-15T10:00:00.000Z", + }, + }, + }); + + const result = await createComment(client, { + issueId: "issue-1", + body: "This is a comment", + }); + + expect(result).toEqual({ + id: "comment-1", + body: "This is a comment", + createdAt: "2025-01-15T10:00:00.000Z", + }); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + input: { issueId: "issue-1", body: "This is a comment" }, + }); + }); + + it("throws when creation fails", async () => { + const client = mockGqlClient({ + commentCreate: { + success: false, + comment: null, + }, + }); + + await expect( + createComment(client, { issueId: "issue-1", body: "test" }), + ).rejects.toThrow("Failed to create comment"); + }); + + it("throws when comment is null despite success", async () => { + const client = mockGqlClient({ + commentCreate: { + success: true, + comment: null, + }, + }); + + await expect( + createComment(client, { issueId: "issue-1", body: "test" }), + ).rejects.toThrow("Failed to create comment"); + }); +}); diff --git a/tests/unit/services/cycle-service.test.ts b/tests/unit/services/cycle-service.test.ts new file mode 100644 index 0000000..3f0f83b --- /dev/null +++ b/tests/unit/services/cycle-service.test.ts @@ -0,0 +1,173 @@ +// tests/unit/services/cycle-service.test.ts +import { describe, expect, it, vi } from "vitest"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { getCycle, listCycles } from "../../../src/services/cycle-service.js"; + +function mockGqlClient(response: Record<string, unknown>): GraphQLClient { + return { + request: vi.fn().mockResolvedValue(response), + } as unknown as GraphQLClient; +} + +describe("listCycles", () => { + it("returns cycles", async () => { + const client = mockGqlClient({ + cycles: { + nodes: [ + { + id: "cyc-1", + number: 1, + name: "Sprint 1", + startsAt: "2025-01-01", + endsAt: "2025-01-14", + isActive: true, + isNext: false, + isPrevious: false, + }, + ], + pageInfo: { hasNextPage: false, endCursor: "c1" }, + }, + }); + const result = await listCycles(client); + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0].id).toBe("cyc-1"); + expect(result.nodes[0].number).toBe(1); + expect(result.nodes[0].name).toBe("Sprint 1"); + expect(result.nodes[0].startsAt).toBe("2025-01-01"); + expect(result.nodes[0].endsAt).toBe("2025-01-14"); + expect(result.nodes[0].isActive).toBe(true); + expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: "c1" }); + }); + + it("returns empty result", async () => { + const client = mockGqlClient({ + cycles: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + const result = await listCycles(client); + expect(result.nodes).toEqual([]); + expect(result.pageInfo.hasNextPage).toBe(false); + }); + + it("passes after cursor", async () => { + const client = mockGqlClient({ + cycles: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + await listCycles(client, undefined, false, { after: "cur1" }); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + first: 50, + after: "cur1", + filter: {}, + }); + }); + + it("uses default limit of 50", async () => { + const client = mockGqlClient({ + cycles: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + await listCycles(client); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + first: 50, + after: undefined, + filter: {}, + }); + }); + + it("filters by team", async () => { + const client = mockGqlClient({ + cycles: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + await listCycles(client, "team-1"); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + first: 50, + after: undefined, + filter: { team: { id: { eq: "team-1" } } }, + }); + }); + + it("filters active only", async () => { + const client = mockGqlClient({ + cycles: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + await listCycles(client, undefined, true); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + first: 50, + after: undefined, + filter: { isActive: { eq: true } }, + }); + }); + + it("uses fallback name for null name", async () => { + const client = mockGqlClient({ + cycles: { + nodes: [ + { + id: "cyc-2", + number: 3, + name: null, + startsAt: "2025-02-01", + endsAt: "2025-02-14", + isActive: false, + isNext: false, + isPrevious: false, + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + const result = await listCycles(client); + expect(result.nodes[0].name).toBe("Cycle 3"); + }); +}); + +describe("getCycle", () => { + it("returns cycle with issues", async () => { + const client = mockGqlClient({ + cycle: { + id: "cyc-1", + number: 1, + name: "Sprint 1", + startsAt: "2025-01-01", + endsAt: "2025-01-14", + isActive: true, + isNext: false, + isPrevious: false, + issues: { + nodes: [ + { + id: "issue-1", + identifier: "ENG-1", + title: "Fix bug", + state: { name: "In Progress" }, + }, + ], + }, + }, + }); + const result = await getCycle(client, "cyc-1"); + expect(result.id).toBe("cyc-1"); + expect(result.name).toBe("Sprint 1"); + expect(result.issues).toHaveLength(1); + expect(result.issues[0].identifier).toBe("ENG-1"); + expect(result.issues[0].state.name).toBe("In Progress"); + }); + + it("throws when cycle not found", async () => { + const client = mockGqlClient({ cycle: null }); + await expect(getCycle(client, "missing-id")).rejects.toThrow("not found"); + }); +}); diff --git a/tests/unit/services/document-service.test.ts b/tests/unit/services/document-service.test.ts index 7924661..3ac591f 100644 --- a/tests/unit/services/document-service.test.ts +++ b/tests/unit/services/document-service.test.ts @@ -76,16 +76,58 @@ describe("updateDocument", () => { describe("listDocuments", () => { it("returns documents list", async () => { const client = mockGqlClient({ - documents: { nodes: [{ id: "1" }, { id: "2" }] }, + documents: { + nodes: [{ id: "1" }, { id: "2" }], + pageInfo: { hasNextPage: false, endCursor: "cursor2" }, + }, }); const result = await listDocuments(client); - expect(result).toHaveLength(2); + expect(result.nodes).toHaveLength(2); + expect(result.pageInfo).toEqual({ + hasNextPage: false, + endCursor: "cursor2", + }); }); - it("returns empty array when no documents", async () => { - const client = mockGqlClient({ documents: { nodes: [] } }); + it("returns empty result when no documents", async () => { + const client = mockGqlClient({ + documents: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); const result = await listDocuments(client); - expect(result).toEqual([]); + expect(result.nodes).toEqual([]); + expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: null }); + }); + + it("passes after cursor to GraphQL request", async () => { + const client = mockGqlClient({ + documents: { + nodes: [{ id: "3" }], + pageInfo: { hasNextPage: false, endCursor: "cursor3" }, + }, + }); + await listDocuments(client, { limit: 10, after: "cursor2" }); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + first: 10, + after: "cursor2", + filter: undefined, + }); + }); + + it("returns pageInfo with hasNextPage true", async () => { + const client = mockGqlClient({ + documents: { + nodes: [{ id: "1" }], + pageInfo: { hasNextPage: true, endCursor: "nextCursor" }, + }, + }); + const result = await listDocuments(client, { limit: 1 }); + expect(result.pageInfo).toEqual({ + hasNextPage: true, + endCursor: "nextCursor", + }); }); }); diff --git a/tests/unit/services/file-service.test.ts b/tests/unit/services/file-service.test.ts new file mode 100644 index 0000000..6aecf44 --- /dev/null +++ b/tests/unit/services/file-service.test.ts @@ -0,0 +1,222 @@ +// tests/unit/services/file-service.test.ts +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FileService } from "../../../src/services/file-service.js"; + +// Mock node:fs/promises +vi.mock("node:fs/promises", () => ({ + access: vi.fn(), + mkdir: vi.fn(), + readFile: vi.fn(), + stat: vi.fn(), + writeFile: vi.fn(), +})); + +// Mock embed-parser +vi.mock("../../../src/common/embed-parser.js", () => ({ + isLinearUploadUrl: vi.fn(), + extractFilenameFromUrl: vi.fn(), +})); + +import { access, mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { + extractFilenameFromUrl, + isLinearUploadUrl, +} from "../../../src/common/embed-parser.js"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +const TEST_TOKEN = "lin_api_test_token"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("downloadFile", () => { + it("rejects non-linear URLs", async () => { + vi.mocked(isLinearUploadUrl).mockReturnValue(false); + + const service = new FileService(TEST_TOKEN); + const result = await service.downloadFile("https://example.com/file.png"); + + expect(result).toEqual({ + success: false, + error: "URL must be from uploads.linear.app domain", + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("downloads file successfully", async () => { + vi.mocked(isLinearUploadUrl).mockReturnValue(true); + vi.mocked(extractFilenameFromUrl).mockReturnValue("image.png"); + vi.mocked(access).mockRejectedValue(new Error("ENOENT")); // file doesn't exist + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(writeFile).mockResolvedValue(undefined); + + const fileContent = new ArrayBuffer(8); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + arrayBuffer: () => Promise.resolve(fileContent), + }); + + const service = new FileService(TEST_TOKEN); + const result = await service.downloadFile( + "https://uploads.linear.app/org/file.png", + ); + + expect(result).toEqual({ + success: true, + filePath: "image.png", + }); + expect(mockFetch).toHaveBeenCalledWith( + "https://uploads.linear.app/org/file.png", + { + method: "GET", + headers: { Authorization: `Bearer ${TEST_TOKEN}` }, + }, + ); + expect(writeFile).toHaveBeenCalled(); + }); + + it("rejects when file already exists", async () => { + vi.mocked(isLinearUploadUrl).mockReturnValue(true); + vi.mocked(extractFilenameFromUrl).mockReturnValue("image.png"); + vi.mocked(access).mockResolvedValue(undefined); // file exists + + const service = new FileService(TEST_TOKEN); + const result = await service.downloadFile( + "https://uploads.linear.app/org/file.png", + ); + + expect(result).toEqual({ + success: false, + error: "File already exists: image.png. Use --overwrite to replace.", + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("handles HTTP error", async () => { + vi.mocked(isLinearUploadUrl).mockReturnValue(true); + vi.mocked(extractFilenameFromUrl).mockReturnValue("image.png"); + vi.mocked(access).mockRejectedValue(new Error("ENOENT")); + + mockFetch.mockResolvedValue({ + ok: false, + status: 403, + statusText: "Forbidden", + }); + + const service = new FileService(TEST_TOKEN); + const result = await service.downloadFile( + "https://uploads.linear.app/org/file.png", + ); + + expect(result).toEqual({ + success: false, + error: "HTTP 403: Forbidden", + statusCode: 403, + }); + }); +}); + +describe("uploadFile", () => { + it("returns error when file not found", async () => { + vi.mocked(access).mockRejectedValue(new Error("ENOENT")); + + const service = new FileService(TEST_TOKEN); + const result = await service.uploadFile("/path/to/missing.png"); + + expect(result).toEqual({ + success: false, + error: "File not found: /path/to/missing.png", + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("returns error when file too large", async () => { + vi.mocked(access).mockResolvedValue(undefined); + vi.mocked(stat).mockResolvedValue({ + size: 25 * 1024 * 1024, // 25MB + } as Awaited<ReturnType<typeof stat>>); + + const service = new FileService(TEST_TOKEN); + const result = await service.uploadFile("/path/to/large.png"); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/File too large/); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("uploads file successfully", async () => { + vi.mocked(access).mockResolvedValue(undefined); + vi.mocked(stat).mockResolvedValue({ + size: 1024, + } as Awaited<ReturnType<typeof stat>>); + vi.mocked(readFile).mockResolvedValue(Buffer.from("file-content")); + + // First fetch: GraphQL fileUpload mutation + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: { + fileUpload: { + success: true, + uploadFile: { + uploadUrl: "https://storage.example.com/upload", + assetUrl: "https://uploads.linear.app/org/asset.png", + headers: [{ key: "x-amz-header", value: "some-value" }], + }, + }, + }, + }), + }); + + // Second fetch: PUT to pre-signed URL + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const service = new FileService(TEST_TOKEN); + const result = await service.uploadFile("/path/to/image.png"); + + expect(result).toEqual({ + success: true, + assetUrl: "https://uploads.linear.app/org/asset.png", + filename: "image.png", + }); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Verify GraphQL call + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + "https://api.linear.app/graphql", + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: TEST_TOKEN, + }, + }), + ); + + // Verify PUT call + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + "https://storage.example.com/upload", + expect.objectContaining({ + method: "PUT", + headers: { + "Content-Type": "image/png", + "x-amz-header": "some-value", + }, + }), + ); + }); +}); diff --git a/tests/unit/services/issue-service.test.ts b/tests/unit/services/issue-service.test.ts index 7c1bf39..b5724f0 100644 --- a/tests/unit/services/issue-service.test.ts +++ b/tests/unit/services/issue-service.test.ts @@ -17,17 +17,74 @@ function mockGqlClient(response: Record<string, unknown>) { describe("listIssues", () => { it("returns issues from query", async () => { const client = mockGqlClient({ - issues: { nodes: [{ id: "1", title: "Test" }] }, + issues: { + nodes: [{ id: "1", title: "Test" }], + pageInfo: { hasNextPage: false, endCursor: "cursor1" }, + }, + }); + const result = await listIssues(client, { limit: 10 }); + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0].id).toBe("1"); + expect(result.pageInfo).toEqual({ + hasNextPage: false, + endCursor: "cursor1", }); - const result = await listIssues(client, 10); - expect(result).toHaveLength(1); - expect(result[0].id).toBe("1"); }); - it("returns empty array when no issues", async () => { - const client = mockGqlClient({ issues: { nodes: [] } }); + it("returns empty result when no issues", async () => { + const client = mockGqlClient({ + issues: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); const result = await listIssues(client); - expect(result).toEqual([]); + expect(result.nodes).toEqual([]); + expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: null }); + }); + + it("uses default limit of 25 when no options provided", async () => { + const client = mockGqlClient({ + issues: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + await listIssues(client); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + first: 25, + after: undefined, + orderBy: "updatedAt", + }); + }); + + it("passes after cursor to GraphQL request", async () => { + const client = mockGqlClient({ + issues: { + nodes: [{ id: "2", title: "Next" }], + pageInfo: { hasNextPage: false, endCursor: "cursor2" }, + }, + }); + await listIssues(client, { limit: 5, after: "cursor1" }); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + first: 5, + after: "cursor1", + orderBy: "updatedAt", + }); + }); + + it("returns pageInfo with hasNextPage true", async () => { + const client = mockGqlClient({ + issues: { + nodes: [{ id: "1", title: "Test" }], + pageInfo: { hasNextPage: true, endCursor: "nextCursor" }, + }, + }); + const result = await listIssues(client, { limit: 1 }); + expect(result.pageInfo).toEqual({ + hasNextPage: true, + endCursor: "nextCursor", + }); }); }); @@ -71,9 +128,32 @@ describe("getIssueByIdentifier", () => { describe("searchIssues", () => { it("returns search results", async () => { const client = mockGqlClient({ - searchIssues: { nodes: [{ id: "1", title: "Match" }] }, + searchIssues: { + nodes: [{ id: "1", title: "Match" }], + pageInfo: { hasNextPage: false, endCursor: "cursor1" }, + }, + }); + const result = await searchIssues(client, "test", { limit: 10 }); + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0].id).toBe("1"); + expect(result.pageInfo).toEqual({ + hasNextPage: false, + endCursor: "cursor1", + }); + }); + + it("passes after cursor to GraphQL request", async () => { + const client = mockGqlClient({ + searchIssues: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + await searchIssues(client, "query", { limit: 5, after: "prevCursor" }); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + term: "query", + first: 5, + after: "prevCursor", }); - const result = await searchIssues(client, "test", 10); - expect(result).toHaveLength(1); }); }); diff --git a/tests/unit/services/label-service.test.ts b/tests/unit/services/label-service.test.ts new file mode 100644 index 0000000..152b7d9 --- /dev/null +++ b/tests/unit/services/label-service.test.ts @@ -0,0 +1,99 @@ +// tests/unit/services/label-service.test.ts +import { describe, expect, it, vi } from "vitest"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { listLabels } from "../../../src/services/label-service.js"; + +function mockGqlClient(response: Record<string, unknown>): GraphQLClient { + return { + request: vi.fn().mockResolvedValue(response), + } as unknown as GraphQLClient; +} + +describe("listLabels", () => { + it("returns labels", async () => { + const client = mockGqlClient({ + issueLabels: { + nodes: [ + { id: "lbl-1", name: "Bug", color: "#ff0000", description: "A bug" }, + ], + pageInfo: { hasNextPage: false, endCursor: "c1" }, + }, + }); + const result = await listLabels(client); + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0].id).toBe("lbl-1"); + expect(result.nodes[0].name).toBe("Bug"); + expect(result.nodes[0].color).toBe("#ff0000"); + expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: "c1" }); + }); + + it("returns empty result", async () => { + const client = mockGqlClient({ + issueLabels: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + const result = await listLabels(client); + expect(result.nodes).toEqual([]); + expect(result.pageInfo.hasNextPage).toBe(false); + }); + + it("passes after cursor", async () => { + const client = mockGqlClient({ + issueLabels: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + await listLabels(client, undefined, { after: "cur1" }); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + first: 50, + after: "cur1", + filter: undefined, + }); + }); + + it("uses default limit of 50", async () => { + const client = mockGqlClient({ + issueLabels: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + await listLabels(client); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + first: 50, + after: undefined, + filter: undefined, + }); + }); + + it("filters by team when teamId provided", async () => { + const client = mockGqlClient({ + issueLabels: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + await listLabels(client, "team-1"); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + first: 50, + after: undefined, + filter: { team: { id: { eq: "team-1" } } }, + }); + }); + + it("converts null description to undefined", async () => { + const client = mockGqlClient({ + issueLabels: { + nodes: [ + { id: "lbl-2", name: "Feature", color: "#00ff00", description: null }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + const result = await listLabels(client); + expect(result.nodes[0].description).toBeUndefined(); + }); +}); diff --git a/tests/unit/services/milestone-service.test.ts b/tests/unit/services/milestone-service.test.ts new file mode 100644 index 0000000..aa04be9 --- /dev/null +++ b/tests/unit/services/milestone-service.test.ts @@ -0,0 +1,180 @@ +// tests/unit/services/milestone-service.test.ts +import { describe, expect, it, vi } from "vitest"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { + createMilestone, + getMilestone, + listMilestones, + updateMilestone, +} from "../../../src/services/milestone-service.js"; + +function mockGqlClient(response: Record<string, unknown>): GraphQLClient { + return { + request: vi.fn().mockResolvedValue(response), + } as unknown as GraphQLClient; +} + +describe("listMilestones", () => { + it("returns milestones", async () => { + const client = mockGqlClient({ + project: { + projectMilestones: { + nodes: [ + { + id: "ms-1", + name: "v1.0", + description: "First release", + targetDate: "2025-06-01", + sortOrder: 0, + }, + ], + pageInfo: { hasNextPage: false, endCursor: "c1" }, + }, + }, + }); + const result = await listMilestones(client, "proj-1"); + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]).toEqual({ + id: "ms-1", + name: "v1.0", + description: "First release", + targetDate: "2025-06-01", + sortOrder: 0, + }); + expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: "c1" }); + }); + + it("returns empty when project is null", async () => { + const client = mockGqlClient({ project: null }); + const result = await listMilestones(client, "missing-proj"); + expect(result.nodes).toEqual([]); + expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: null }); + }); + + it("passes after cursor", async () => { + const client = mockGqlClient({ + project: { + projectMilestones: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + await listMilestones(client, "proj-1", { after: "cur1" }); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + projectId: "proj-1", + first: 50, + after: "cur1", + }); + }); + + it("uses default limit of 50", async () => { + const client = mockGqlClient({ + project: { + projectMilestones: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + await listMilestones(client, "proj-1"); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + projectId: "proj-1", + first: 50, + after: undefined, + }); + }); +}); + +describe("getMilestone", () => { + it("returns milestone detail", async () => { + const client = mockGqlClient({ + projectMilestone: { + id: "ms-1", + name: "v1.0", + description: "First release", + targetDate: "2025-06-01", + sortOrder: 0, + project: { id: "proj-1", name: "Project Alpha" }, + issues: { nodes: [] }, + }, + }); + const result = await getMilestone(client, "ms-1"); + expect(result.id).toBe("ms-1"); + expect(result.name).toBe("v1.0"); + }); + + it("throws when not found", async () => { + const client = mockGqlClient({ projectMilestone: null }); + await expect(getMilestone(client, "missing-id")).rejects.toThrow( + "not found", + ); + }); +}); + +describe("createMilestone", () => { + it("creates milestone", async () => { + const client = mockGqlClient({ + projectMilestoneCreate: { + success: true, + projectMilestone: { + id: "ms-new", + name: "v2.0", + description: "Second release", + targetDate: "2025-12-01", + sortOrder: 1, + }, + }, + }); + const result = await createMilestone(client, { + projectId: "proj-1", + name: "v2.0", + }); + expect(result.id).toBe("ms-new"); + expect(result.name).toBe("v2.0"); + }); + + it("throws on failure", async () => { + const client = mockGqlClient({ + projectMilestoneCreate: { + success: false, + projectMilestone: null, + }, + }); + await expect( + createMilestone(client, { projectId: "proj-1", name: "Bad" }), + ).rejects.toThrow("Failed to create milestone"); + }); +}); + +describe("updateMilestone", () => { + it("updates milestone", async () => { + const client = mockGqlClient({ + projectMilestoneUpdate: { + success: true, + projectMilestone: { + id: "ms-1", + name: "v1.1", + description: "Updated release", + targetDate: "2025-07-01", + sortOrder: 0, + }, + }, + }); + const result = await updateMilestone(client, "ms-1", { name: "v1.1" }); + expect(result.id).toBe("ms-1"); + expect(result.name).toBe("v1.1"); + }); + + it("throws on failure", async () => { + const client = mockGqlClient({ + projectMilestoneUpdate: { + success: false, + projectMilestone: null, + }, + }); + await expect( + updateMilestone(client, "ms-1", { name: "Bad" }), + ).rejects.toThrow("Failed to update milestone"); + }); +}); diff --git a/tests/unit/services/project-service.test.ts b/tests/unit/services/project-service.test.ts new file mode 100644 index 0000000..c13f4a8 --- /dev/null +++ b/tests/unit/services/project-service.test.ts @@ -0,0 +1,97 @@ +// tests/unit/services/project-service.test.ts +import { describe, expect, it, vi } from "vitest"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { listProjects } from "../../../src/services/project-service.js"; + +function mockGqlClient(response: Record<string, unknown>): GraphQLClient { + return { + request: vi.fn().mockResolvedValue(response), + } as unknown as GraphQLClient; +} + +describe("listProjects", () => { + it("returns projects", async () => { + const client = mockGqlClient({ + projects: { + nodes: [ + { + id: "proj-1", + name: "Project Alpha", + description: "A test project", + state: "started", + targetDate: "2025-12-31", + slugId: "alpha", + }, + ], + pageInfo: { hasNextPage: false, endCursor: "c1" }, + }, + }); + const result = await listProjects(client); + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0].id).toBe("proj-1"); + expect(result.nodes[0].name).toBe("Project Alpha"); + expect(result.nodes[0].state).toBe("started"); + expect(result.nodes[0].slugId).toBe("alpha"); + expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: "c1" }); + }); + + it("returns empty result", async () => { + const client = mockGqlClient({ + projects: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + const result = await listProjects(client); + expect(result.nodes).toEqual([]); + expect(result.pageInfo.hasNextPage).toBe(false); + }); + + it("passes after cursor", async () => { + const client = mockGqlClient({ + projects: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + await listProjects(client, { after: "cur1" }); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + first: 50, + after: "cur1", + }); + }); + + it("uses default limit of 50", async () => { + const client = mockGqlClient({ + projects: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + await listProjects(client); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + first: 50, + after: undefined, + }); + }); + + it("converts null targetDate to undefined", async () => { + const client = mockGqlClient({ + projects: { + nodes: [ + { + id: "proj-2", + name: "No Date", + description: "", + state: "planned", + targetDate: null, + slugId: "no-date", + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + const result = await listProjects(client); + expect(result.nodes[0].targetDate).toBeUndefined(); + }); +}); diff --git a/tests/unit/services/team-service.test.ts b/tests/unit/services/team-service.test.ts new file mode 100644 index 0000000..0093f45 --- /dev/null +++ b/tests/unit/services/team-service.test.ts @@ -0,0 +1,67 @@ +// tests/unit/services/team-service.test.ts +import { describe, expect, it, vi } from "vitest"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { listTeams } from "../../../src/services/team-service.js"; + +function mockGqlClient(response: Record<string, unknown>): GraphQLClient { + return { + request: vi.fn().mockResolvedValue(response), + } as unknown as GraphQLClient; +} + +describe("listTeams", () => { + it("returns teams", async () => { + const client = mockGqlClient({ + teams: { + nodes: [{ id: "team-1", key: "ENG", name: "Engineering" }], + pageInfo: { hasNextPage: false, endCursor: "c1" }, + }, + }); + const result = await listTeams(client); + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0].id).toBe("team-1"); + expect(result.nodes[0].key).toBe("ENG"); + expect(result.nodes[0].name).toBe("Engineering"); + expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: "c1" }); + }); + + it("returns empty result", async () => { + const client = mockGqlClient({ + teams: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + const result = await listTeams(client); + expect(result.nodes).toEqual([]); + expect(result.pageInfo.hasNextPage).toBe(false); + }); + + it("passes after cursor", async () => { + const client = mockGqlClient({ + teams: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + await listTeams(client, { after: "cur1" }); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + first: 50, + after: "cur1", + }); + }); + + it("uses default limit of 50", async () => { + const client = mockGqlClient({ + teams: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + await listTeams(client); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + first: 50, + after: undefined, + }); + }); +}); diff --git a/tests/unit/services/user-service.test.ts b/tests/unit/services/user-service.test.ts new file mode 100644 index 0000000..0063b18 --- /dev/null +++ b/tests/unit/services/user-service.test.ts @@ -0,0 +1,84 @@ +// tests/unit/services/user-service.test.ts +import { describe, expect, it, vi } from "vitest"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { listUsers } from "../../../src/services/user-service.js"; + +function mockGqlClient(response: Record<string, unknown>): GraphQLClient { + return { + request: vi.fn().mockResolvedValue(response), + } as unknown as GraphQLClient; +} + +describe("listUsers", () => { + it("returns users sorted by name", async () => { + const client = mockGqlClient({ + users: { + nodes: [ + { id: "u-2", name: "Zoe", email: "zoe@test.com", active: true }, + { id: "u-1", name: "Alice", email: "alice@test.com", active: true }, + ], + pageInfo: { hasNextPage: false, endCursor: "c1" }, + }, + }); + const result = await listUsers(client); + expect(result.nodes[0].name).toBe("Alice"); + expect(result.nodes[1].name).toBe("Zoe"); + }); + + it("returns empty result", async () => { + const client = mockGqlClient({ + users: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + const result = await listUsers(client); + expect(result.nodes).toEqual([]); + expect(result.pageInfo.hasNextPage).toBe(false); + }); + + it("passes after cursor", async () => { + const client = mockGqlClient({ + users: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + await listUsers(client, false, { after: "cur1" }); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + first: 50, + after: "cur1", + filter: undefined, + }); + }); + + it("uses default limit of 50", async () => { + const client = mockGqlClient({ + users: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + await listUsers(client); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + first: 50, + after: undefined, + filter: undefined, + }); + }); + + it("filters active users when activeOnly is true", async () => { + const client = mockGqlClient({ + users: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + await listUsers(client, true); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + first: 50, + after: undefined, + filter: { active: { eq: true } }, + }); + }); +});