Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions src/commands/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <issueId>`
*
* Lists all relations (both directions) for an issue.
*/
relations.command("list <issueId>")
.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 <issueId> --blocks|--related|--duplicate|--similar <ids>`
*
* 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 <issueId>")
.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 <ids>", "issues this issue blocks (comma-separated)")
.option("--related <ids>", "related issues (comma-separated)")
.option("--duplicate <ids>", "issues this is a duplicate of (comma-separated)")
.option("--similar <ids>", "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 <relationId>`
*
* Removes a specific relation by its UUID.
* Use `relations list` to find relation IDs.
*/
relations.command("remove <relationId>")
.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);
},
),
);
}
46 changes: 45 additions & 1 deletion src/queries/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -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}
`;
89 changes: 88 additions & 1 deletion src/queries/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import {
COMPLETE_ISSUE_FRAGMENT,
COMPLETE_ISSUE_WITH_COMMENTS_FRAGMENT,
ISSUE_RELATIONS_FRAGMENT,
} from "./common.js";

/**
Expand Down Expand Up @@ -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
}
}
`;
Loading