diff --git a/src/commands/issues.ts b/src/commands/issues.ts index e571b21..718714b 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -350,4 +350,169 @@ export function setupIssuesCommands(program: Command): void { }, ), ); + + // ============================================================================ + // Issue Relations Subcommand Group + // ============================================================================ + + /** + * Issues relations subcommand group + * + * Command: `linearis issues relations` + * + * Manages issue relationships including blocks, related, duplicate, and similar relations. + */ + const relations = issues.command("relations") + .description("Issue relation operations"); + + // Show relations help when no subcommand + relations.action(() => { + relations.help(); + }); + + /** + * List issue relations + * + * Command: `linearis issues relations list ` + * + * Lists all relations (both directions) for an issue. + */ + relations.command("list ") + .description("List relations for an issue.") + .addHelpText( + "after", + `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, + ) + .action( + handleAsyncCommand( + // Commander.js always passes (argument, options, command) to action handlers, + // even when no options are defined. The _options parameter cannot be removed. + async (issueId: string, _options: unknown, command: Command) => { + const [graphQLService, linearService] = await Promise.all([ + createGraphQLService(command.parent!.parent!.parent!.opts()), + createLinearService(command.parent!.parent!.parent!.opts()), + ]); + const issuesService = new GraphQLIssuesService( + graphQLService, + linearService, + ); + + const result = await issuesService.getIssueRelations(issueId); + outputSuccess(result); + }, + ), + ); + + /** + * Add issue relations + * + * Command: `linearis issues relations add --blocks|--related|--duplicate|--similar ` + * + * Adds one or more relations to an issue. Exactly one relation type flag must be specified. + * Supports comma-separated IDs for adding multiple relations at once. + */ + relations.command("add ") + .description("Add relation(s) to an issue.") + .addHelpText( + "after", + `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported. + +Examples: + linearis issues relations add ABC-123 --blocks DEF-456 + linearis issues relations add ABC-123 --related DEF-456,DEF-789 + linearis issues relations add ABC-123 --duplicate DEF-456 + linearis issues relations add ABC-123 --similar DEF-456`, + ) + .option("--blocks ", "issues this issue blocks (comma-separated)") + .option("--related ", "related issues (comma-separated)") + .option("--duplicate ", "issues this is a duplicate of (comma-separated)") + .option("--similar ", "similar issues (comma-separated, note: typically AI-generated)") + .action( + handleAsyncCommand( + async (issueId: string, options: any, command: Command) => { + // Validate exactly one relation type is specified + const typeFlags = [ + options.blocks ? "blocks" : null, + options.related ? "related" : null, + options.duplicate ? "duplicate" : null, + options.similar ? "similar" : null, + ].filter(Boolean); + + if (typeFlags.length === 0) { + throw new Error( + "Must specify one of --blocks, --related, --duplicate, or --similar", + ); + } + if (typeFlags.length > 1) { + throw new Error( + "Cannot specify multiple relation types. Use separate commands.", + ); + } + + const relationType = typeFlags[0] as + | "blocks" + | "related" + | "duplicate" + | "similar"; + const relatedIds = (options[relationType] as string) + .split(",") + .map((id: string) => id.trim()) + .filter((id: string) => id.length > 0); + + if (relatedIds.length === 0) { + throw new Error("At least one related issue ID must be provided"); + } + + const [graphQLService, linearService] = await Promise.all([ + createGraphQLService(command.parent!.parent!.parent!.opts()), + createLinearService(command.parent!.parent!.parent!.opts()), + ]); + const issuesService = new GraphQLIssuesService( + graphQLService, + linearService, + ); + + const result = await issuesService.addIssueRelations( + issueId, + relatedIds, + relationType, + ); + outputSuccess(result); + }, + ), + ); + + /** + * Remove an issue relation + * + * Command: `linearis issues relations remove ` + * + * Removes a specific relation by its UUID. + * Use `relations list` to find relation IDs. + */ + relations.command("remove ") + .description("Remove a relation.") + .addHelpText( + "after", + `\nThe relationId must be a UUID. Use 'issues relations list' to find relation IDs.`, + ) + .action( + handleAsyncCommand( + // Commander.js always passes (argument, options, command) to action handlers, + // even when no options are defined. The _options parameter cannot be removed. + async (relationId: string, _options: unknown, command: Command) => { + const [graphQLService, linearService] = await Promise.all([ + createGraphQLService(command.parent!.parent!.parent!.opts()), + createLinearService(command.parent!.parent!.parent!.opts()), + ]); + const issuesService = new GraphQLIssuesService( + graphQLService, + linearService, + ); + + const result = await issuesService.removeIssueRelation(relationId); + outputSuccess(result); + }, + ), + ); } diff --git a/src/commands/projects.ts b/src/commands/projects.ts index c82b0f3..0759adf 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -1,25 +1,104 @@ import { Command } from "commander"; +import { createGraphQLService } from "../utils/graphql-service.js"; import { createLinearService } from "../utils/linear-service.js"; import { handleAsyncCommand, outputSuccess } from "../utils/output.js"; +import { + ARCHIVE_PROJECT_MUTATION, + CREATE_PROJECT_MUTATION, + FIND_PROJECT_BY_NAME_QUERY, + GET_PROJECT_BY_ID_QUERY, + LIST_PROJECTS_QUERY, + UPDATE_PROJECT_MUTATION, +} from "../queries/projects.js"; +import { isUuid } from "../utils/uuid.js"; +import type { + ProjectCreateOptions, + ProjectListOptions, + ProjectReadOptions, + ProjectUpdateOptions, +} from "../utils/linear-types.js"; +import type { GraphQLService } from "../utils/graphql-service.js"; +import { + multipleMatchesError, + notFoundError, +} from "../utils/error-messages.js"; + +/** + * Transform GraphQL project response to normalize teams and other nested structures + */ +function transformProject(project: any): any { + if (!project) return project; + + return { + ...project, + // Flatten teams.nodes to teams array + teams: project.teams?.nodes || [], + // Flatten members.nodes to members array (if present) + members: project.members?.nodes, + // Flatten projectMilestones.nodes to projectMilestones array (if present) + projectMilestones: project.projectMilestones?.nodes, + // Flatten issues.nodes to issues array (if present) + issues: project.issues?.nodes, + }; +} + +/** + * Helper function to resolve project ID from name or UUID + * + * @param projectNameOrId - Project name or UUID + * @param graphQLService - GraphQL service instance + * @returns Project UUID + * @throws Error if project not found or multiple matches + */ +async function resolveProjectIdFromGraphQL( + projectNameOrId: string, + graphQLService: GraphQLService, +): Promise { + if (isUuid(projectNameOrId)) { + return projectNameOrId; + } + + const result = await graphQLService.rawRequest(FIND_PROJECT_BY_NAME_QUERY, { + name: projectNameOrId, + }); + + const nodes = result.projects?.nodes || []; + + if (nodes.length === 0) { + throw notFoundError("Project", projectNameOrId); + } + + if (nodes.length > 1) { + const matches = nodes.map((p: any) => `"${p.name}" (${p.state})`); + throw multipleMatchesError( + "project", + projectNameOrId, + matches, + "use the project ID", + ); + } + + return nodes[0].id; +} /** * Setup projects commands on the program - * + * * Registers `projects` command group for Linear project management. - * Provides listing functionality with comprehensive project information - * including teams, progress, and leadership details. - * + * Provides CRUD operations: list, read, create, update, archive. + * * @param program - Commander.js program instance to register commands on - * + * * @example * ```typescript * // In main.ts * setupProjectsCommands(program); - * // Enables: linearis projects list [--limit ] + * // Enables: linearis projects list|read|create|update|archive * ``` */ export function setupProjectsCommands(program: Command): void { - const projects = program.command("projects") + const projects = program + .command("projects") .description("Project operations"); // Show projects help when no subcommand @@ -27,27 +106,293 @@ export function setupProjectsCommands(program: Command): void { projects.help(); }); - /** - * List projects - * - * Command: `linearis projects list [--limit ]` - * - * Lists all projects with their teams, leads, and progress information. - * Note: Linear SDK doesn't implement pagination, so all projects are shown. - */ - projects.command("list") + // ============================================================================ + // LIST Command + // ============================================================================ + projects + .command("list") .description("List projects") + .option("-l, --limit ", "limit results", "100") + .option("--include-archived", "include archived projects") + .action( + handleAsyncCommand( + async (options: ProjectListOptions, command: Command) => { + const graphQLService = await createGraphQLService( + command.parent!.parent!.opts(), + ); + + const result = await graphQLService.rawRequest(LIST_PROJECTS_QUERY, { + first: parseInt(options.limit || "100"), + includeArchived: options.includeArchived || false, + }); + + const projects = (result.projects?.nodes || []).map(transformProject); + outputSuccess(projects); + }, + ), + ); + + // ============================================================================ + // READ Command + // ============================================================================ + projects + .command("read ") + .description( + "Get project details including milestones and issues. Accepts UUID or project name.", + ) + .option( + "--milestones-first ", + "how many milestones to fetch (default 25)", + "25", + ) .option( - "-l, --limit ", - "limit results (not implemented by Linear SDK, showing all)", - "100", + "--issues-first ", + "how many issues to fetch (default 50)", + "50", ) - .action(handleAsyncCommand(async (_options: any, command: Command) => { - // Initialize Linear service for project operations - const service = await createLinearService(command.parent!.parent!.opts()); - - // Fetch all projects with their relationships - const result = await service.getProjects(); - outputSuccess(result); - })); + .action( + handleAsyncCommand( + async ( + projectIdOrName: string, + options: ProjectReadOptions, + command: Command, + ) => { + const graphQLService = await createGraphQLService( + command.parent!.parent!.opts(), + ); + + const projectId = await resolveProjectIdFromGraphQL( + projectIdOrName, + graphQLService, + ); + + const milestonesFirst = parseInt(options.milestonesFirst ?? "25"); + const issuesFirst = parseInt(options.issuesFirst ?? "50"); + + const result = await graphQLService.rawRequest( + GET_PROJECT_BY_ID_QUERY, + { + id: projectId, + milestonesFirst: milestonesFirst || 1, // API requires >= 1, use @skip when 0 + issuesFirst: issuesFirst || 1, + skipMilestones: milestonesFirst === 0, + skipIssues: issuesFirst === 0, + }, + ); + + if (!result.project) { + throw notFoundError("Project", projectIdOrName); + } + + outputSuccess(transformProject(result.project)); + }, + ), + ); + + // ============================================================================ + // CREATE Command + // ============================================================================ + projects + .command("create ") + .description("Create a new project") + .requiredOption("--team ", "team key, name, or ID (required)") + .option("-d, --description ", "project description") + .option("--icon ", "project icon") + .option("--color ", "project color (hex code)") + .option("--lead ", "project lead user ID") + .option( + "--priority ", + "priority (0=none, 1=urgent, 2=high, 3=normal, 4=low)", + ) + .option("--start-date ", "start date (YYYY-MM-DD)") + .option("--target-date ", "target date (YYYY-MM-DD)") + .action( + handleAsyncCommand( + async ( + name: string, + options: ProjectCreateOptions, + command: Command, + ) => { + const [graphQLService, linearService] = await Promise.all([ + createGraphQLService(command.parent!.parent!.opts()), + createLinearService(command.parent!.parent!.opts()), + ]); + + // Resolve team ID using LinearService + const teamId = await linearService.resolveTeamId(options.team); + + const result = await graphQLService.rawRequest( + CREATE_PROJECT_MUTATION, + { + name, + teamIds: [teamId], + description: options.description, + icon: options.icon, + color: options.color, + leadId: options.lead, + priority: options.priority + ? parseInt(options.priority) + : undefined, + startDate: options.startDate, + targetDate: options.targetDate, + }, + ); + + if (!result.projectCreate?.success) { + throw new Error("Failed to create project"); + } + + outputSuccess(transformProject(result.projectCreate.project)); + }, + ), + ); + + // ============================================================================ + // UPDATE Command + // ============================================================================ + projects + .command("update ") + .description( + "Update an existing project. Accepts UUID or project name.", + ) + .option("-n, --name ", "new project name") + .option("-d, --description ", "new description") + .option("--icon ", "new icon") + .option("--color ", "new color (hex code)") + .option("--lead ", "new lead user ID") + .option("--clear-lead", "remove project lead") + .option( + "--priority ", + "new priority (0=none, 1=urgent, 2=high, 3=normal, 4=low)", + ) + .option("--start-date ", "new start date (YYYY-MM-DD)") + .option("--clear-start-date", "remove start date") + .option("--target-date ", "new target date (YYYY-MM-DD)") + .option("--clear-target-date", "remove target date") + .option("--team ", "add team association (key, name, or ID)") + .action( + handleAsyncCommand( + async ( + projectIdOrName: string, + options: ProjectUpdateOptions, + command: Command, + ) => { + // Validate mutually exclusive flags + if (options.lead && options.clearLead) { + throw new Error("Cannot use --lead and --clear-lead together"); + } + if (options.startDate && options.clearStartDate) { + throw new Error( + "Cannot use --start-date and --clear-start-date together", + ); + } + if (options.targetDate && options.clearTargetDate) { + throw new Error( + "Cannot use --target-date and --clear-target-date together", + ); + } + + const [graphQLService, linearService] = await Promise.all([ + createGraphQLService(command.parent!.parent!.opts()), + createLinearService(command.parent!.parent!.opts()), + ]); + + const projectId = await resolveProjectIdFromGraphQL( + projectIdOrName, + graphQLService, + ); + + // Build update variables (only include provided fields) + const updateVars: Record = { id: projectId }; + + if (options.name !== undefined) updateVars.name = options.name; + if (options.description !== undefined) { + updateVars.description = options.description; + } + if (options.icon !== undefined) updateVars.icon = options.icon; + if (options.color !== undefined) updateVars.color = options.color; + if (options.priority !== undefined) { + updateVars.priority = parseInt(options.priority); + } + + // Handle lead + if (options.clearLead) { + updateVars.leadId = null; + } else if (options.lead !== undefined) { + updateVars.leadId = options.lead; + } + + // Handle dates + if (options.clearStartDate) { + updateVars.startDate = null; + } else if (options.startDate !== undefined) { + updateVars.startDate = options.startDate; + } + + if (options.clearTargetDate) { + updateVars.targetDate = null; + } else if (options.targetDate !== undefined) { + updateVars.targetDate = options.targetDate; + } + + // Handle team association + if (options.team !== undefined) { + const teamId = await linearService.resolveTeamId(options.team); + updateVars.teamIds = [teamId]; + } + + const result = await graphQLService.rawRequest( + UPDATE_PROJECT_MUTATION, + updateVars, + ); + + if (!result.projectUpdate?.success) { + throw new Error("Failed to update project"); + } + + outputSuccess(transformProject(result.projectUpdate.project)); + }, + ), + ); + + // ============================================================================ + // ARCHIVE Command + // ============================================================================ + projects + .command("archive ") + .description("Archive a project (soft-delete). Accepts UUID or project name.") + .action( + handleAsyncCommand( + async ( + projectIdOrName: string, + _options: unknown, + command: Command, + ) => { + const graphQLService = await createGraphQLService( + command.parent!.parent!.opts(), + ); + + const projectId = await resolveProjectIdFromGraphQL( + projectIdOrName, + graphQLService, + ); + + const result = await graphQLService.rawRequest( + ARCHIVE_PROJECT_MUTATION, + { + id: projectId, + }, + ); + + if (!result.projectArchive?.success) { + throw new Error("Failed to archive project"); + } + + outputSuccess({ + archived: true, + id: projectId, + }); + }, + ), + ); } diff --git a/src/queries/common.ts b/src/queries/common.ts index 97ab002..e186a37 100644 --- a/src/queries/common.ts +++ b/src/queries/common.ts @@ -141,6 +141,49 @@ export const ISSUE_CHILDREN_FRAGMENT = ` } `; +/** + * Issue relations fragment + * Provides both outgoing relations (this issue -> related) and + * incoming/inverse relations (other issue -> this issue) + * Types: blocks, duplicate, related, similar + */ +export const ISSUE_RELATIONS_FRAGMENT = ` + relations { + nodes { + id + type + createdAt + issue { + id + identifier + title + } + relatedIssue { + id + identifier + title + } + } + } + inverseRelations { + nodes { + id + type + createdAt + issue { + id + identifier + title + } + relatedIssue { + id + identifier + title + } + } + } +`; + /** * Complete issue fragment with all relationships * @@ -162,9 +205,10 @@ export const COMPLETE_ISSUE_FRAGMENT = ` `; /** - * Complete issue fragment including comments + * Complete issue fragment including comments and relations */ export const COMPLETE_ISSUE_WITH_COMMENTS_FRAGMENT = ` ${COMPLETE_ISSUE_FRAGMENT} ${ISSUE_COMMENTS_FRAGMENT} + ${ISSUE_RELATIONS_FRAGMENT} `; diff --git a/src/queries/issues.ts b/src/queries/issues.ts index 1bf5177..87de89c 100644 --- a/src/queries/issues.ts +++ b/src/queries/issues.ts @@ -10,6 +10,7 @@ import { COMPLETE_ISSUE_FRAGMENT, COMPLETE_ISSUE_WITH_COMMENTS_FRAGMENT, + ISSUE_RELATIONS_FRAGMENT, } from "./common.js"; /** @@ -371,6 +372,92 @@ export const BATCH_RESOLVE_FOR_CREATE_QUERY = ` } # Resolve cycles by name (team-scoped lookup is preferred but we also provide global fallback) - + + } +`; + +// ============================================================================ +// Issue Relations Queries and Mutations +// ============================================================================ + +/** + * Get issue relations by UUID + * + * Fetches all relations (both directions) for an issue by its UUID. + * Returns both outgoing relations and inverse/incoming relations. + */ +export const GET_ISSUE_RELATIONS_BY_ID_QUERY = ` + query GetIssueRelationsById($id: String!) { + issue(id: $id) { + id + identifier + ${ISSUE_RELATIONS_FRAGMENT} + } + } +`; + +/** + * Get issue relations by identifier (TEAM-123 format) + * + * Fetches all relations (both directions) for an issue using team key + number. + * Returns both outgoing relations and inverse/incoming relations. + */ +export const GET_ISSUE_RELATIONS_BY_IDENTIFIER_QUERY = ` + query GetIssueRelationsByIdentifier($teamKey: String!, $number: Float!) { + issues( + filter: { + team: { key: { eq: $teamKey } } + number: { eq: $number } + } + first: 1 + ) { + nodes { + id + identifier + ${ISSUE_RELATIONS_FRAGMENT} + } + } + } +`; + +/** + * Create issue relation mutation + * + * Creates a new relation between two issues. + * Types: blocks, duplicate, related, similar + */ +export const CREATE_ISSUE_RELATION_MUTATION = ` + mutation CreateIssueRelation($input: IssueRelationCreateInput!) { + issueRelationCreate(input: $input) { + success + issueRelation { + id + type + createdAt + issue { + id + identifier + title + } + relatedIssue { + id + identifier + title + } + } + } + } +`; + +/** + * Delete issue relation mutation + * + * Removes an existing relation by its UUID. + */ +export const DELETE_ISSUE_RELATION_MUTATION = ` + mutation DeleteIssueRelation($id: String!) { + issueRelationDelete(id: $id) { + success + } } `; diff --git a/src/queries/projects.ts b/src/queries/projects.ts new file mode 100644 index 0000000..1077758 --- /dev/null +++ b/src/queries/projects.ts @@ -0,0 +1,199 @@ +/** + * GraphQL queries and mutations for project operations + * + * These queries provide optimized single-request fetching for project data, + * avoiding N+1 query problems from SDK relationship fetching. + */ + +import { COMPLETE_ISSUE_FRAGMENT } from "./common.js"; + +/** + * Core project fields fragment for reuse + */ +export const PROJECT_CORE_FIELDS = ` + id + name + description + slugId + icon + color + state + progress + priority + sortOrder + startDate + targetDate + createdAt + updatedAt +`; + +/** + * Project lead relationship + */ +export const PROJECT_LEAD_FRAGMENT = ` + lead { + id + name + } +`; + +/** + * Project teams relationship + */ +export const PROJECT_TEAMS_FRAGMENT = ` + teams { + nodes { + id + key + name + } + } +`; + +/** + * Complete project fragment with all relationships + */ +export const COMPLETE_PROJECT_FRAGMENT = ` + ${PROJECT_CORE_FIELDS} + ${PROJECT_LEAD_FRAGMENT} + ${PROJECT_TEAMS_FRAGMENT} +`; + +/** + * List all projects with relationships + */ +export const LIST_PROJECTS_QUERY = ` + query ListProjects($first: Int!, $includeArchived: Boolean) { + projects(first: $first, includeArchived: $includeArchived, orderBy: updatedAt) { + nodes { + ${COMPLETE_PROJECT_FRAGMENT} + } + } + } +`; + +/** + * Get single project by ID with full details including milestones and issues + */ +export const GET_PROJECT_BY_ID_QUERY = ` + query GetProject($id: String!, $milestonesFirst: Int, $issuesFirst: Int, $skipMilestones: Boolean!, $skipIssues: Boolean!) { + project(id: $id) { + ${COMPLETE_PROJECT_FRAGMENT} + members { + nodes { + id + name + } + } + projectMilestones(first: $milestonesFirst) @skip(if: $skipMilestones) { + nodes { + id + name + description + targetDate + sortOrder + } + } + issues(first: $issuesFirst) @skip(if: $skipIssues) { + nodes { + ${COMPLETE_ISSUE_FRAGMENT} + } + } + } + } +`; + +/** + * Find project by name (case-insensitive) for name-based ID resolution + */ +export const FIND_PROJECT_BY_NAME_QUERY = ` + query FindProjectByName($name: String!) { + projects(filter: { name: { eqIgnoreCase: $name } }, first: 10) { + nodes { + id + name + state + } + } + } +`; + +/** + * Create a new project + */ +export const CREATE_PROJECT_MUTATION = ` + mutation CreateProject( + $name: String! + $teamIds: [String!]! + $description: String + $icon: String + $color: String + $leadId: String + $priority: Int + $startDate: TimelessDate + $targetDate: TimelessDate + ) { + projectCreate(input: { + name: $name + teamIds: $teamIds + description: $description + icon: $icon + color: $color + leadId: $leadId + priority: $priority + startDate: $startDate + targetDate: $targetDate + }) { + success + project { + ${COMPLETE_PROJECT_FRAGMENT} + } + } + } +`; + +/** + * Update an existing project + */ +export const UPDATE_PROJECT_MUTATION = ` + mutation UpdateProject( + $id: String! + $name: String + $description: String + $icon: String + $color: String + $leadId: String + $priority: Int + $startDate: TimelessDate + $targetDate: TimelessDate + $teamIds: [String!] + ) { + projectUpdate(id: $id, input: { + name: $name + description: $description + icon: $icon + color: $color + leadId: $leadId + priority: $priority + startDate: $startDate + targetDate: $targetDate + teamIds: $teamIds + }) { + success + project { + ${COMPLETE_PROJECT_FRAGMENT} + } + } + } +`; + +/** + * Archive a project (soft-delete) + */ +export const ARCHIVE_PROJECT_MUTATION = ` + mutation ArchiveProject($id: String!) { + projectArchive(id: $id) { + success + } + } +`; diff --git a/src/utils/graphql-issues-service.ts b/src/utils/graphql-issues-service.ts index 6a5e1fe..b12f111 100644 --- a/src/utils/graphql-issues-service.ts +++ b/src/utils/graphql-issues-service.ts @@ -5,9 +5,13 @@ import { BATCH_RESOLVE_FOR_SEARCH_QUERY, BATCH_RESOLVE_FOR_UPDATE_QUERY, CREATE_ISSUE_MUTATION, + CREATE_ISSUE_RELATION_MUTATION, + DELETE_ISSUE_RELATION_MUTATION, FILTERED_SEARCH_ISSUES_QUERY, GET_ISSUE_BY_ID_QUERY, GET_ISSUE_BY_IDENTIFIER_QUERY, + GET_ISSUE_RELATIONS_BY_ID_QUERY, + GET_ISSUE_RELATIONS_BY_IDENTIFIER_QUERY, GET_ISSUES_QUERY, SEARCH_ISSUES_QUERY, UPDATE_ISSUE_MUTATION, @@ -15,6 +19,7 @@ import { import type { CreateIssueArgs, LinearIssue, + LinearIssueRelation, SearchIssuesArgs, UpdateIssueArgs, } from "./linear-types.js"; @@ -896,6 +901,7 @@ export class GraphQLIssuesService { ? new Date(comment.updatedAt).toISOString() : new Date().toISOString()), })) || [], + relations: this.transformRelationsData(issue), createdAt: issue.createdAt instanceof Date ? issue.createdAt.toISOString() : (issue.createdAt @@ -908,4 +914,230 @@ export class GraphQLIssuesService { : new Date().toISOString()), }; } + + /** + * Transform relations data from issue response + * Combines both outgoing relations and inverse relations + */ + private transformRelationsData(issue: any): LinearIssueRelation[] | undefined { + if (!issue.relations?.nodes && !issue.inverseRelations?.nodes) { + return undefined; + } + + const relations: LinearIssueRelation[] = []; + + // Process outgoing relations (this issue -> related issue) + if (issue.relations?.nodes) { + for (const rel of issue.relations.nodes) { + relations.push(this.transformSingleRelation(rel)); + } + } + + // Process inverse relations (other issue -> this issue) + if (issue.inverseRelations?.nodes) { + for (const rel of issue.inverseRelations.nodes) { + relations.push(this.transformSingleRelation(rel)); + } + } + + return relations.length > 0 ? relations : undefined; + } + + /** + * Transform a single relation from GraphQL response + */ + private transformSingleRelation(relation: any): LinearIssueRelation { + // Validate required nested objects exist + if (!relation.issue || !relation.relatedIssue) { + throw new Error( + `Invalid relation data: missing ${!relation.issue ? "issue" : "relatedIssue"} field`, + ); + } + + return { + id: relation.id, + type: relation.type, + issue: { + id: relation.issue.id, + identifier: relation.issue.identifier, + title: relation.issue.title, + }, + relatedIssue: { + id: relation.relatedIssue.id, + identifier: relation.relatedIssue.identifier, + title: relation.relatedIssue.title, + }, + createdAt: relation.createdAt instanceof Date + ? relation.createdAt.toISOString() + : (relation.createdAt + ? new Date(relation.createdAt).toISOString() + : new Date().toISOString()), + }; + } + + // ============================================================================ + // Issue Relations Methods + // ============================================================================ + + /** + * Get relations for an issue + * + * @param issueId - Either a UUID string or TEAM-123 format identifier + * @returns Object with issue info and relations array + */ + async getIssueRelations(issueId: string): Promise<{ + issueId: string; + identifier: string; + relations: LinearIssueRelation[]; + }> { + let issueData; + + if (isUuid(issueId)) { + const result = await this.graphQLService.rawRequest( + GET_ISSUE_RELATIONS_BY_ID_QUERY, + { id: issueId }, + ); + if (!result.issue) { + throw new Error(`Issue with ID "${issueId}" not found`); + } + issueData = result.issue; + } else { + const { teamKey, issueNumber } = parseIssueIdentifier(issueId); + const result = await this.graphQLService.rawRequest( + GET_ISSUE_RELATIONS_BY_IDENTIFIER_QUERY, + { teamKey, number: issueNumber }, + ); + if (!result.issues.nodes.length) { + throw new Error(`Issue with identifier "${issueId}" not found`); + } + issueData = result.issues.nodes[0]; + } + + const relations = this.transformRelationsData(issueData) || []; + + return { + issueId: issueData.id, + identifier: issueData.identifier, + relations, + }; + } + + /** + * Add relations to an issue + * + * @param issueId - Source issue (UUID or TEAM-123) + * @param relatedIssueIds - Target issues (UUIDs or TEAM-123 identifiers) + * @param type - Relation type (blocks, duplicate, related, similar) + * @returns Array of created relations + */ + async addIssueRelations( + issueId: string, + relatedIssueIds: string[], + type: "blocks" | "duplicate" | "related" | "similar", + ): Promise { + // Step 1: Resolve source issue ID + const resolvedSourceId = await this.resolveIssueIdForRelation(issueId); + + // Step 2: Resolve all target issue IDs + const resolvedTargetIds = await this.resolveIssueIds(relatedIssueIds); + + // Step 3: Create relations sequentially (API doesn't support batch creation) + const createdRelations: LinearIssueRelation[] = []; + for (const targetId of resolvedTargetIds) { + const result = await this.graphQLService.rawRequest( + CREATE_ISSUE_RELATION_MUTATION, + { + input: { + issueId: resolvedSourceId, + relatedIssueId: targetId, + type: type, + }, + }, + ); + + if (!result.issueRelationCreate.success) { + // Report partial success if some relations were created before failure + const partialInfo = createdRelations.length > 0 + ? ` (${createdRelations.length} relation(s) were created before this failure)` + : ""; + throw new Error( + `Failed to create relation to issue ${targetId}${partialInfo}`, + ); + } + + createdRelations.push( + this.transformSingleRelation(result.issueRelationCreate.issueRelation), + ); + } + + return createdRelations; + } + + /** + * Remove an issue relation + * + * @param relationId - The relation UUID to delete + */ + async removeIssueRelation(relationId: string): Promise<{ success: boolean }> { + // Validate that relationId is a valid UUID + if (!isUuid(relationId)) { + throw new Error( + `Invalid relation ID "${relationId}": must be a valid UUID. ` + + `Use 'issues relations list' to find relation IDs.`, + ); + } + + const result = await this.graphQLService.rawRequest( + DELETE_ISSUE_RELATION_MUTATION, + { id: relationId }, + ); + + if (!result.issueRelationDelete.success) { + throw new Error(`Failed to delete relation ${relationId}`); + } + + return { success: true }; + } + + /** + * Resolve a single issue ID (for relation operations) + */ + private async resolveIssueIdForRelation(issueId: string): Promise { + if (isUuid(issueId)) { + return issueId; + } + + const { teamKey, issueNumber } = parseIssueIdentifier(issueId); + const result = await this.graphQLService.rawRequest( + GET_ISSUE_BY_IDENTIFIER_QUERY, + { teamKey, number: issueNumber }, + ); + + if (!result.issues.nodes.length) { + throw new Error(`Issue with identifier "${issueId}" not found`); + } + + return result.issues.nodes[0].id; + } + + /** + * Resolve multiple issue IDs + * UUIDs pass through, identifiers are resolved via API + */ + private async resolveIssueIds(issueIds: string[]): Promise { + const results: string[] = []; + + for (const id of issueIds) { + const trimmedId = id.trim(); + if (isUuid(trimmedId)) { + results.push(trimmedId); + } else { + // Resolve identifier + const resolved = await this.resolveIssueIdForRelation(trimmedId); + results.push(resolved); + } + } + + return results; + } } diff --git a/src/utils/linear-types.d.ts b/src/utils/linear-types.d.ts index ec24d51..8c0c0a0 100644 --- a/src/utils/linear-types.d.ts +++ b/src/utils/linear-types.d.ts @@ -67,10 +67,31 @@ export interface LinearIssue { createdAt: string; updatedAt: string; }>; + relations?: LinearIssueRelation[]; createdAt: string; updatedAt: string; } +/** + * Issue relation representing a link between two issues + * Types: blocks, duplicate, related, similar (similar is typically AI-generated) + */ +export interface LinearIssueRelation { + id: string; + type: "blocks" | "duplicate" | "related" | "similar"; + issue: { + id: string; + identifier: string; + title: string; + }; + relatedIssue: { + id: string; + identifier: string; + title: string; + }; + createdAt: string; +} + export interface LinearProject { id: string; name: string; @@ -328,3 +349,77 @@ export interface AttachmentCreateInput { commentBody?: string; iconUrl?: string; } + +// Project CRUD types + +/** + * Extended LinearProject with full details for read command + */ +export interface LinearProjectWithDetails extends LinearProject { + slugId: string; + icon?: string; + color?: string; + priority?: number; + sortOrder?: number; + startDate?: string; + members?: Array<{ + id: string; + name: string; + }>; + projectMilestones?: Array<{ + id: string; + name: string; + description?: string; + targetDate?: string; + sortOrder?: number; + }>; + issues?: LinearIssue[]; +} + +/** + * Options for projects list command + */ +export interface ProjectListOptions { + limit?: string; + includeArchived?: boolean; +} + +/** + * Options for projects read command + */ +export interface ProjectReadOptions { + milestonesFirst?: string; + issuesFirst?: string; +} + +/** + * Options for projects create command + */ +export interface ProjectCreateOptions { + team: string; // Required: team key, name, or ID + description?: string; + icon?: string; + color?: string; + lead?: string; // User ID + priority?: string; // 0-4 + startDate?: string; // ISO date + targetDate?: string; // ISO date +} + +/** + * Options for projects update command + */ +export interface ProjectUpdateOptions { + name?: string; + description?: string; + icon?: string; + color?: string; + lead?: string; + priority?: string; + startDate?: string; + targetDate?: string; + team?: string; // Add team association + clearLead?: boolean; + clearStartDate?: boolean; + clearTargetDate?: boolean; +} diff --git a/tests/integration/issues-relations-cli.test.ts b/tests/integration/issues-relations-cli.test.ts new file mode 100644 index 0000000..f5bc7c2 --- /dev/null +++ b/tests/integration/issues-relations-cli.test.ts @@ -0,0 +1,143 @@ +/** + * Integration tests for issues relations CLI commands + * + * These tests require LINEAR_API_TOKEN to be set in environment. + * If not set, tests will be skipped. + * + * NOTE: These tests document expected behavior but are skipped by default + * to avoid creating test data in production Linear workspaces. + */ + +import { describe, expect, it } from "vitest"; +import { exec } from "child_process"; +import { promisify } from "util"; + +const execAsync = promisify(exec); +const CLI_PATH = "dist/main.js"; +const hasApiToken = !!process.env.LINEAR_API_TOKEN; + +if (!hasApiToken) { + console.warn( + "\n LINEAR_API_TOKEN not set - skipping issues relations integration tests\n" + + " To run these tests, set LINEAR_API_TOKEN in your environment\n", + ); +} + +describe("Issues Relations CLI", () => { + describe("issues relations list", () => { + it.skip("should list relations for an issue by identifier", async () => { + // This test documents the expected behavior for listing relations + // Command: linearis issues relations list ABC-123 + // Expected: JSON object with issueId, identifier, and relations array + + if (!hasApiToken) return; + + const { stdout } = await execAsync( + `node ${CLI_PATH} issues relations list ABC-123`, + ); + const result = JSON.parse(stdout); + + expect(result).toHaveProperty("issueId"); + expect(result).toHaveProperty("identifier"); + expect(result).toHaveProperty("relations"); + expect(Array.isArray(result.relations)).toBe(true); + }); + + it.skip("should return empty relations array for issue with no relations", async () => { + // This test documents that issues without relations return empty array + + if (!hasApiToken) return; + + // Would need an issue known to have no relations + }); + }); + + describe("issues relations add", () => { + it.skip("should add a blocking relation", async () => { + // Command: linearis issues relations add ABC-123 --blocks DEF-456 + // Expected: Array with created relation object + + if (!hasApiToken) return; + + // Skipped to avoid creating test data in production + }); + + it.skip("should add multiple related issues at once", async () => { + // Command: linearis issues relations add ABC-123 --related DEF-456,GHI-789 + // Expected: Array with 2 created relation objects + + if (!hasApiToken) return; + + // Skipped to avoid creating test data in production + }); + + it.skip("should support all relation types", async () => { + // Tests that all 4 relation types work: + // --blocks, --related, --duplicate, --similar + + if (!hasApiToken) return; + + // Skipped to avoid creating test data in production + }); + }); + + describe("issues relations add - validation", () => { + it("should error when no relation type flag is provided", async () => { + const { stdout } = await execAsync( + `node ${CLI_PATH} issues relations add ABC-123 2>&1`, + ).catch((e) => ({ stdout: e.stdout })); + + const result = JSON.parse(stdout); + expect(result.error).toContain( + "Must specify one of --blocks, --related, --duplicate, or --similar", + ); + }); + + it("should error when multiple relation type flags are provided", async () => { + const { stdout } = await execAsync( + `node ${CLI_PATH} issues relations add ABC-123 --blocks DEF-456 --related GHI-789 2>&1`, + ).catch((e) => ({ stdout: e.stdout })); + + const result = JSON.parse(stdout); + expect(result.error).toContain( + "Cannot specify multiple relation types", + ); + }); + }); + + describe("issues relations remove", () => { + it.skip("should remove a relation by UUID", async () => { + // Command: linearis issues relations remove + // Expected: { success: true } + + if (!hasApiToken) return; + + // Skipped to avoid deleting data in production + }); + }); + + describe("issues read - relations in output", () => { + it.skip("should include relations in issue read output", async () => { + // This test documents that 'issues read' now includes relations + // Command: linearis issues read ABC-123 + // Expected: Issue object includes 'relations' array + + if (!hasApiToken) return; + + const { stdout } = await execAsync( + `node ${CLI_PATH} issues read ABC-123`, + ); + const result = JSON.parse(stdout); + + // Relations should be present (may be empty array or undefined if no relations) + // When relations exist, they should have the expected structure + if (result.relations && result.relations.length > 0) { + expect(result.relations[0]).toHaveProperty("id"); + expect(result.relations[0]).toHaveProperty("type"); + expect(result.relations[0]).toHaveProperty("issue"); + expect(result.relations[0]).toHaveProperty("relatedIssue"); + expect(result.relations[0]).toHaveProperty("createdAt"); + } + }); + }); +}); diff --git a/tests/integration/projects-cli.test.ts b/tests/integration/projects-cli.test.ts new file mode 100644 index 0000000..0fea82a --- /dev/null +++ b/tests/integration/projects-cli.test.ts @@ -0,0 +1,242 @@ +/** + * Integration tests for projects CLI commands + * + * These tests require LINEAR_API_TOKEN to be set in environment. + * If not set, tests will be skipped. + * + * NOTE: Create/update/archive tests are skipped by default to avoid + * creating test data in production Linear workspaces. + */ + +import { describe, expect, it } from "vitest"; +import { exec } from "child_process"; +import { promisify } from "util"; + +const execAsync = promisify(exec); +const CLI_PATH = "dist/main.js"; +const hasApiToken = !!process.env.LINEAR_API_TOKEN; + +if (!hasApiToken) { + console.warn( + "\n LINEAR_API_TOKEN not set - skipping projects integration tests\n" + + " To run these tests, set LINEAR_API_TOKEN in your environment\n", + ); +} + +describe("Projects CLI", () => { + describe("projects --help", () => { + it("should display help with all subcommands", async () => { + const { stdout } = await execAsync(`node ${CLI_PATH} projects --help`); + + expect(stdout).toContain("Usage: linearis projects"); + expect(stdout).toContain("Project operations"); + expect(stdout).toContain("list"); + expect(stdout).toContain("read"); + expect(stdout).toContain("create"); + expect(stdout).toContain("update"); + expect(stdout).toContain("archive"); + }); + }); + + describe("projects list", () => { + it.skipIf(!hasApiToken)("should list projects as JSON array", async () => { + const { stdout } = await execAsync(`node ${CLI_PATH} projects list`); + const projects = JSON.parse(stdout); + + expect(Array.isArray(projects)).toBe(true); + if (projects.length > 0) { + expect(projects[0]).toHaveProperty("id"); + expect(projects[0]).toHaveProperty("name"); + expect(projects[0]).toHaveProperty("state"); + expect(projects[0]).toHaveProperty("progress"); + expect(projects[0]).toHaveProperty("teams"); + } + }); + + it.skipIf(!hasApiToken)("should respect limit option", async () => { + const { stdout } = await execAsync( + `node ${CLI_PATH} projects list --limit 5`, + ); + const projects = JSON.parse(stdout); + + expect(Array.isArray(projects)).toBe(true); + expect(projects.length).toBeLessThanOrEqual(5); + }); + + it("should show help for list command", async () => { + const { stdout } = await execAsync( + `node ${CLI_PATH} projects list --help`, + ); + + expect(stdout).toContain("--limit"); + expect(stdout).toContain("--include-archived"); + }); + }); + + describe("projects read", () => { + it.skipIf(!hasApiToken)( + "should read project by name and return full details", + async () => { + // First get a project name from the list + const { stdout: listOutput } = await execAsync( + `node ${CLI_PATH} projects list --limit 1`, + ); + const projects = JSON.parse(listOutput); + + if (projects.length > 0) { + const { stdout } = await execAsync( + `node ${CLI_PATH} projects read "${projects[0].name}"`, + ); + const project = JSON.parse(stdout); + + expect(project.id).toBe(projects[0].id); + expect(project.name).toBe(projects[0].name); + expect(project).toHaveProperty("teams"); + // Read command includes additional fields + expect(project).toHaveProperty("projectMilestones"); + expect(project).toHaveProperty("issues"); + } + }, + { timeout: 30000 }, + ); + + it.skipIf(!hasApiToken)("should read project by UUID", async () => { + // First get a project UUID from the list + const { stdout: listOutput } = await execAsync( + `node ${CLI_PATH} projects list --limit 1`, + ); + const projects = JSON.parse(listOutput); + + if (projects.length > 0) { + const { stdout } = await execAsync( + `node ${CLI_PATH} projects read ${projects[0].id}`, + ); + const project = JSON.parse(stdout); + + expect(project.id).toBe(projects[0].id); + } + }); + + it("should show help for read command", async () => { + const { stdout } = await execAsync( + `node ${CLI_PATH} projects read --help`, + ); + + expect(stdout).toContain("projectIdOrName"); + expect(stdout).toContain("--milestones-first"); + expect(stdout).toContain("--issues-first"); + }); + }); + + describe("projects create", () => { + it("should require --team flag", async () => { + const { stdout } = await execAsync( + `node ${CLI_PATH} projects create "Test Project" 2>&1`, + ).catch((e) => ({ stdout: e.stderr || e.stdout })); + + expect(stdout).toContain("required option"); + expect(stdout).toContain("--team"); + }); + + it("should show help for create command", async () => { + const { stdout } = await execAsync( + `node ${CLI_PATH} projects create --help`, + ); + + expect(stdout).toContain("--team"); + expect(stdout).toContain("--description"); + expect(stdout).toContain("--lead"); + expect(stdout).toContain("--priority"); + expect(stdout).toContain("--start-date"); + expect(stdout).toContain("--target-date"); + }); + + it.skip("should create a project with required fields", async () => { + // Command: linearis projects create "Test Project" --team ABC + // Expected: JSON object with created project + // Skipped to avoid creating test data in production + + if (!hasApiToken) return; + }); + }); + + describe("projects update", () => { + it("should show help for update command", async () => { + const { stdout } = await execAsync( + `node ${CLI_PATH} projects update --help`, + ); + + expect(stdout).toContain("projectIdOrName"); + expect(stdout).toContain("--name"); + expect(stdout).toContain("--description"); + expect(stdout).toContain("--lead"); + expect(stdout).toContain("--clear-lead"); + expect(stdout).toContain("--start-date"); + expect(stdout).toContain("--clear-start-date"); + expect(stdout).toContain("--target-date"); + expect(stdout).toContain("--clear-target-date"); + }); + + it.skip("should update project name", async () => { + // Command: linearis projects update "Test Project" --name "New Name" + // Expected: JSON object with updated project + // Skipped to avoid modifying production data + + if (!hasApiToken) return; + }); + }); + + describe("projects update - validation", () => { + it("should error when using --lead and --clear-lead together", async () => { + const { stdout } = await execAsync( + `node ${CLI_PATH} projects update test-project --lead abc --clear-lead 2>&1`, + ).catch((e) => ({ stdout: e.stdout })); + + const result = JSON.parse(stdout); + expect(result.error).toContain( + "Cannot use --lead and --clear-lead together", + ); + }); + + it("should error when using --start-date and --clear-start-date together", async () => { + const { stdout } = await execAsync( + `node ${CLI_PATH} projects update test-project --start-date 2025-01-01 --clear-start-date 2>&1`, + ).catch((e) => ({ stdout: e.stdout })); + + const result = JSON.parse(stdout); + expect(result.error).toContain( + "Cannot use --start-date and --clear-start-date together", + ); + }); + + it("should error when using --target-date and --clear-target-date together", async () => { + const { stdout } = await execAsync( + `node ${CLI_PATH} projects update test-project --target-date 2025-12-31 --clear-target-date 2>&1`, + ).catch((e) => ({ stdout: e.stdout })); + + const result = JSON.parse(stdout); + expect(result.error).toContain( + "Cannot use --target-date and --clear-target-date together", + ); + }); + }); + + describe("projects archive", () => { + it("should show help for archive command", async () => { + const { stdout } = await execAsync( + `node ${CLI_PATH} projects archive --help`, + ); + + expect(stdout).toContain("projectIdOrName"); + expect(stdout).toContain("Archive a project"); + }); + + it.skip("should archive a project", async () => { + // Command: linearis projects archive "Test Project" + // Expected: { archived: true, id: "..." } + // Skipped to avoid archiving production data + + if (!hasApiToken) return; + }); + }); +}); diff --git a/tests/unit/graphql-issues-service-relations.test.ts b/tests/unit/graphql-issues-service-relations.test.ts new file mode 100644 index 0000000..59a667f --- /dev/null +++ b/tests/unit/graphql-issues-service-relations.test.ts @@ -0,0 +1,537 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { GraphQLIssuesService } from "../../src/utils/graphql-issues-service.js"; +import { GraphQLService } from "../../src/utils/graphql-service.js"; +import { LinearService } from "../../src/utils/linear-service.js"; + +/** + * Unit tests for GraphQLIssuesService relation methods + * + * These tests verify the relation transformation and validation logic + * using mocked GraphQL responses. + */ + +// Mock the services +const mockGraphQLService = { + rawRequest: vi.fn(), +} as unknown as GraphQLService; + +const mockLinearService = { + resolveStatusId: vi.fn(), +} as unknown as LinearService; + +describe("GraphQLIssuesService - Relations", () => { + let service: GraphQLIssuesService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new GraphQLIssuesService(mockGraphQLService, mockLinearService); + }); + + // Valid UUID format for testing + const UUID_1 = "550e8400-e29b-12d3-a456-426614174000"; + const UUID_2 = "660e8400-e29b-12d3-a456-426614174001"; + const UUID_3 = "770e8400-e29b-12d3-a456-426614174002"; + const REL_UUID_1 = "880e8400-e29b-12d3-a456-426614174003"; + const REL_UUID_2 = "990e8400-e29b-12d3-a456-426614174004"; + + describe("getIssueRelations", () => { + it("should return empty relations array when issue has no relations", async () => { + mockGraphQLService.rawRequest = vi.fn().mockResolvedValue({ + issue: { + id: UUID_1, + identifier: "ABC-123", + relations: { nodes: [] }, + inverseRelations: { nodes: [] }, + }, + }); + + const result = await service.getIssueRelations(UUID_1); + + expect(result).toEqual({ + issueId: UUID_1, + identifier: "ABC-123", + relations: [], + }); + }); + + it("should transform outgoing relations correctly", async () => { + mockGraphQLService.rawRequest = vi.fn().mockResolvedValue({ + issue: { + id: UUID_1, + identifier: "ABC-123", + relations: { + nodes: [ + { + id: REL_UUID_1, + type: "blocks", + createdAt: "2025-01-15T10:30:00.000Z", + issue: { + id: UUID_1, + identifier: "ABC-123", + title: "Source Issue", + }, + relatedIssue: { + id: UUID_2, + identifier: "DEF-456", + title: "Blocked Issue", + }, + }, + ], + }, + inverseRelations: { nodes: [] }, + }, + }); + + const result = await service.getIssueRelations(UUID_1); + + expect(result.relations).toHaveLength(1); + expect(result.relations[0]).toEqual({ + id: REL_UUID_1, + type: "blocks", + createdAt: "2025-01-15T10:30:00.000Z", + issue: { + id: UUID_1, + identifier: "ABC-123", + title: "Source Issue", + }, + relatedIssue: { + id: UUID_2, + identifier: "DEF-456", + title: "Blocked Issue", + }, + }); + }); + + it("should transform inverse relations correctly", async () => { + mockGraphQLService.rawRequest = vi.fn().mockResolvedValue({ + issue: { + id: UUID_2, + identifier: "DEF-456", + relations: { nodes: [] }, + inverseRelations: { + nodes: [ + { + id: REL_UUID_1, + type: "blocks", + createdAt: "2025-01-15T10:30:00.000Z", + issue: { + id: UUID_1, + identifier: "ABC-123", + title: "Blocking Issue", + }, + relatedIssue: { + id: UUID_2, + identifier: "DEF-456", + title: "This Issue", + }, + }, + ], + }, + }, + }); + + const result = await service.getIssueRelations(UUID_2); + + expect(result.relations).toHaveLength(1); + expect(result.relations[0].type).toBe("blocks"); + }); + + it("should combine outgoing and inverse relations", async () => { + mockGraphQLService.rawRequest = vi.fn().mockResolvedValue({ + issue: { + id: UUID_1, + identifier: "ABC-123", + relations: { + nodes: [ + { + id: REL_UUID_1, + type: "blocks", + createdAt: "2025-01-15T10:30:00.000Z", + issue: { id: UUID_1, identifier: "ABC-123", title: "Issue 1" }, + relatedIssue: { id: UUID_2, identifier: "DEF-456", title: "Issue 2" }, + }, + ], + }, + inverseRelations: { + nodes: [ + { + id: REL_UUID_2, + type: "related", + createdAt: "2025-01-16T10:30:00.000Z", + issue: { id: UUID_3, identifier: "GHI-789", title: "Issue 3" }, + relatedIssue: { id: UUID_1, identifier: "ABC-123", title: "Issue 1" }, + }, + ], + }, + }, + }); + + const result = await service.getIssueRelations(UUID_1); + + expect(result.relations).toHaveLength(2); + expect(result.relations[0].id).toBe(REL_UUID_1); + expect(result.relations[1].id).toBe(REL_UUID_2); + }); + + it("should resolve TEAM-123 identifier to UUID", async () => { + mockGraphQLService.rawRequest = vi.fn().mockResolvedValue({ + issues: { + nodes: [ + { + id: UUID_1, + identifier: "ABC-123", + relations: { nodes: [] }, + inverseRelations: { nodes: [] }, + }, + ], + }, + }); + + const result = await service.getIssueRelations("ABC-123"); + + expect(result.issueId).toBe(UUID_1); + expect(result.identifier).toBe("ABC-123"); + }); + + it("should throw error when issue not found by UUID", async () => { + mockGraphQLService.rawRequest = vi.fn().mockResolvedValue({ + issue: null, + }); + + await expect( + service.getIssueRelations(UUID_1), + ).rejects.toThrow(`Issue with ID "${UUID_1}" not found`); + }); + + it("should throw error when issue not found by identifier", async () => { + mockGraphQLService.rawRequest = vi.fn().mockResolvedValue({ + issues: { nodes: [] }, + }); + + await expect(service.getIssueRelations("XYZ-999")).rejects.toThrow( + 'Issue with identifier "XYZ-999" not found', + ); + }); + }); + + describe("addIssueRelations", () => { + it("should create single relation successfully", async () => { + // First call resolves source issue ID + mockGraphQLService.rawRequest = vi + .fn() + .mockResolvedValueOnce({ + issues: { + nodes: [{ id: UUID_1 }], + }, + }) + // Second call resolves target issue ID + .mockResolvedValueOnce({ + issues: { + nodes: [{ id: UUID_2 }], + }, + }) + // Third call creates the relation + .mockResolvedValueOnce({ + issueRelationCreate: { + success: true, + issueRelation: { + id: REL_UUID_1, + type: "blocks", + createdAt: "2025-01-15T10:30:00.000Z", + issue: { id: UUID_1, identifier: "ABC-123", title: "Source" }, + relatedIssue: { id: UUID_2, identifier: "DEF-456", title: "Target" }, + }, + }, + }); + + const result = await service.addIssueRelations( + "ABC-123", + ["DEF-456"], + "blocks", + ); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(REL_UUID_1); + expect(result[0].type).toBe("blocks"); + }); + + it("should create multiple relations sequentially", async () => { + // Resolve source UUID + mockGraphQLService.rawRequest = vi + .fn() + .mockResolvedValueOnce({ issues: { nodes: [{ id: UUID_1 }] } }) + // Resolve first target UUID + .mockResolvedValueOnce({ issues: { nodes: [{ id: UUID_2 }] } }) + // Resolve second target UUID + .mockResolvedValueOnce({ issues: { nodes: [{ id: UUID_3 }] } }) + // Create first relation + .mockResolvedValueOnce({ + issueRelationCreate: { + success: true, + issueRelation: { + id: REL_UUID_1, + type: "related", + createdAt: "2025-01-15T10:30:00.000Z", + issue: { id: UUID_1, identifier: "ABC-123", title: "Source" }, + relatedIssue: { id: UUID_2, identifier: "DEF-456", title: "Target 1" }, + }, + }, + }) + // Create second relation + .mockResolvedValueOnce({ + issueRelationCreate: { + success: true, + issueRelation: { + id: REL_UUID_2, + type: "related", + createdAt: "2025-01-15T10:31:00.000Z", + issue: { id: UUID_1, identifier: "ABC-123", title: "Source" }, + relatedIssue: { id: UUID_3, identifier: "GHI-789", title: "Target 2" }, + }, + }, + }); + + const result = await service.addIssueRelations( + "ABC-123", + ["DEF-456", "GHI-789"], + "related", + ); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe(REL_UUID_1); + expect(result[1].id).toBe(REL_UUID_2); + }); + + it("should pass through UUIDs without resolution", async () => { + mockGraphQLService.rawRequest = vi.fn().mockResolvedValueOnce({ + issueRelationCreate: { + success: true, + issueRelation: { + id: REL_UUID_1, + type: "duplicate", + createdAt: "2025-01-15T10:30:00.000Z", + issue: { id: UUID_1, identifier: "ABC-123", title: "Source" }, + relatedIssue: { id: UUID_2, identifier: "DEF-456", title: "Target" }, + }, + }, + }); + + const result = await service.addIssueRelations( + UUID_1, + [UUID_2], + "duplicate", + ); + + expect(result).toHaveLength(1); + // Verify that rawRequest was called only once (no resolution calls) + expect(mockGraphQLService.rawRequest).toHaveBeenCalledTimes(1); + }); + + it("should throw error when relation creation fails", async () => { + mockGraphQLService.rawRequest = vi.fn().mockResolvedValueOnce({ + issueRelationCreate: { + success: false, + }, + }); + + await expect( + service.addIssueRelations(UUID_1, [UUID_2], "blocks"), + ).rejects.toThrow(`Failed to create relation to issue ${UUID_2}`); + }); + + it("should report partial success when some relations fail", async () => { + const UUID_4 = "aa0e8400-e29b-12d3-a456-426614174005"; + + mockGraphQLService.rawRequest = vi + .fn() + // First relation succeeds + .mockResolvedValueOnce({ + issueRelationCreate: { + success: true, + issueRelation: { + id: REL_UUID_1, + type: "related", + createdAt: "2025-01-15T10:30:00.000Z", + issue: { id: UUID_1, identifier: "ABC-123", title: "Source" }, + relatedIssue: { id: UUID_2, identifier: "DEF-456", title: "Target 1" }, + }, + }, + }) + // Second relation succeeds + .mockResolvedValueOnce({ + issueRelationCreate: { + success: true, + issueRelation: { + id: REL_UUID_2, + type: "related", + createdAt: "2025-01-15T10:31:00.000Z", + issue: { id: UUID_1, identifier: "ABC-123", title: "Source" }, + relatedIssue: { id: UUID_3, identifier: "GHI-789", title: "Target 2" }, + }, + }, + }) + // Third relation fails + .mockResolvedValueOnce({ + issueRelationCreate: { + success: false, + }, + }); + + await expect( + service.addIssueRelations(UUID_1, [UUID_2, UUID_3, UUID_4], "related"), + ).rejects.toThrow( + `Failed to create relation to issue ${UUID_4} (2 relation(s) were created before this failure)`, + ); + }); + }); + + describe("removeIssueRelation", () => { + it("should delete relation successfully", async () => { + mockGraphQLService.rawRequest = vi.fn().mockResolvedValue({ + issueRelationDelete: { + success: true, + }, + }); + + const result = await service.removeIssueRelation(REL_UUID_1); + + expect(result).toEqual({ success: true }); + }); + + it("should throw error when deletion fails", async () => { + mockGraphQLService.rawRequest = vi.fn().mockResolvedValue({ + issueRelationDelete: { + success: false, + }, + }); + + await expect( + service.removeIssueRelation(REL_UUID_1), + ).rejects.toThrow(`Failed to delete relation ${REL_UUID_1}`); + }); + + it("should throw error when relationId is not a valid UUID", async () => { + await expect( + service.removeIssueRelation("not-a-uuid"), + ).rejects.toThrow( + `Invalid relation ID "not-a-uuid": must be a valid UUID. Use 'issues relations list' to find relation IDs.`, + ); + + // Verify rawRequest was never called + expect(mockGraphQLService.rawRequest).not.toHaveBeenCalled(); + }); + + it("should throw error when relationId looks like an issue identifier", async () => { + await expect( + service.removeIssueRelation("ABC-123"), + ).rejects.toThrow( + `Invalid relation ID "ABC-123": must be a valid UUID. Use 'issues relations list' to find relation IDs.`, + ); + }); + }); + + describe("relation type support", () => { + it.each(["blocks", "duplicate", "related", "similar"] as const)( + "should support %s relation type", + async (type) => { + mockGraphQLService.rawRequest = vi.fn().mockResolvedValueOnce({ + issueRelationCreate: { + success: true, + issueRelation: { + id: REL_UUID_1, + type: type, + createdAt: "2025-01-15T10:30:00.000Z", + issue: { id: UUID_1, identifier: "ABC-123", title: "Source" }, + relatedIssue: { id: UUID_2, identifier: "DEF-456", title: "Target" }, + }, + }, + }); + + const result = await service.addIssueRelations( + UUID_1, + [UUID_2], + type, + ); + + expect(result[0].type).toBe(type); + }, + ); + }); + + describe("relation data validation", () => { + it("should throw error when relation.issue is null", async () => { + mockGraphQLService.rawRequest = vi.fn().mockResolvedValue({ + issue: { + id: UUID_1, + identifier: "ABC-123", + relations: { + nodes: [ + { + id: REL_UUID_1, + type: "blocks", + createdAt: "2025-01-15T10:30:00.000Z", + issue: null, + relatedIssue: { id: UUID_2, identifier: "DEF-456", title: "Target" }, + }, + ], + }, + inverseRelations: { nodes: [] }, + }, + }); + + await expect( + service.getIssueRelations(UUID_1), + ).rejects.toThrow("Invalid relation data: missing issue field"); + }); + + it("should throw error when relation.relatedIssue is null", async () => { + mockGraphQLService.rawRequest = vi.fn().mockResolvedValue({ + issue: { + id: UUID_1, + identifier: "ABC-123", + relations: { + nodes: [ + { + id: REL_UUID_1, + type: "blocks", + createdAt: "2025-01-15T10:30:00.000Z", + issue: { id: UUID_1, identifier: "ABC-123", title: "Source" }, + relatedIssue: null, + }, + ], + }, + inverseRelations: { nodes: [] }, + }, + }); + + await expect( + service.getIssueRelations(UUID_1), + ).rejects.toThrow("Invalid relation data: missing relatedIssue field"); + }); + + it("should throw error when relation.issue is undefined", async () => { + mockGraphQLService.rawRequest = vi.fn().mockResolvedValue({ + issue: { + id: UUID_1, + identifier: "ABC-123", + relations: { + nodes: [ + { + id: REL_UUID_1, + type: "blocks", + createdAt: "2025-01-15T10:30:00.000Z", + // issue field is missing (undefined) + relatedIssue: { id: UUID_2, identifier: "DEF-456", title: "Target" }, + }, + ], + }, + inverseRelations: { nodes: [] }, + }, + }); + + await expect( + service.getIssueRelations(UUID_1), + ).rejects.toThrow("Invalid relation data: missing issue field"); + }); + }); +});