From 02281fca9c9875fd9080b1317f82cc6836156bf9 Mon Sep 17 00:00:00 2001 From: Ralf Schimmel Date: Mon, 15 Dec 2025 09:50:10 +0100 Subject: [PATCH] feat: Add issue relations support (blocks, related, duplicate, similar) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for managing issue relationships through a new `issues relations` subcommand group. This enables tracking dependencies between issues, which is essential for AI agents that need to understand task ordering and blockers. New commands: - `issues relations list ` - List all relations for an issue - `issues relations add --blocks|--related|--duplicate|--similar ` - `issues relations remove ` - Remove a relation by UUID Features: - Support for all Linear relation types: blocks, related, duplicate, similar - Comma-separated IDs for adding multiple relations at once - Human-friendly ID resolution (TEAM-123 format) - Relations included in `issues read` output - Both outgoing and inverse relations combined in output Technical details: - Optimized GraphQL queries with new ISSUE_RELATIONS_FRAGMENT - Proper validation for null/undefined nested relation data - UUID validation for relation removal - Partial success reporting when batch relation creation fails - Comprehensive unit tests (23 tests) and integration tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/commands/issues.ts | 165 ++++++ src/queries/common.ts | 46 +- src/queries/issues.ts | 89 ++- src/utils/graphql-issues-service.ts | 232 ++++++++ src/utils/linear-types.d.ts | 21 + .../integration/issues-relations-cli.test.ts | 143 +++++ .../graphql-issues-service-relations.test.ts | 537 ++++++++++++++++++ 7 files changed, 1231 insertions(+), 2 deletions(-) create mode 100644 tests/integration/issues-relations-cli.test.ts create mode 100644 tests/unit/graphql-issues-service-relations.test.ts 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/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/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..450b9a4 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; 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/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"); + }); + }); +});