Skip to content

Commit 970b8b4

Browse files
committed
feat(issues): restore relation commands
1 parent aa91a15 commit 970b8b4

5 files changed

Lines changed: 425 additions & 10 deletions

File tree

graphql/mutations/issue-relations.graphql

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,33 @@
66
fragment IssueRelationFields on IssueRelation {
77
id
88
type
9+
createdAt
10+
issue {
11+
id
12+
identifier
13+
title
14+
}
915
relatedIssue {
1016
id
1117
identifier
18+
title
1219
}
1320
}
1421

1522
# Fragment for inverse relation fields used in issue output
1623
fragment InverseIssueRelationFields on IssueRelation {
1724
id
1825
type
26+
createdAt
1927
issue {
2028
id
2129
identifier
30+
title
31+
}
32+
relatedIssue {
33+
id
34+
identifier
35+
title
2236
}
2337
}
2438

@@ -44,6 +58,8 @@ mutation DeleteIssueRelation($id: String!) {
4458
# Used by --remove-relation to locate the relation ID before deletion
4559
query GetIssueRelations($issueId: String!) {
4660
issue(id: $issueId) {
61+
id
62+
identifier
4763
relations {
4864
nodes {
4965
...IssueRelationFields

src/commands/issues.ts

Lines changed: 176 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import {
5858
createIssueRelation,
5959
deleteIssueRelation,
6060
findIssueRelation,
61+
listIssueRelations,
6162
} from "../services/issue-relation-service.js";
6263
import {
6364
archiveIssue,
@@ -107,6 +108,7 @@ interface CreateOptions {
107108
blockedBy?: string;
108109
relatesTo?: string;
109110
duplicateOf?: string;
111+
similarTo?: string;
110112
}
111113

112114
interface UpdateOptions {
@@ -133,6 +135,7 @@ interface UpdateOptions {
133135
blockedBy?: string;
134136
relatesTo?: string;
135137
duplicateOf?: string;
138+
similarTo?: string;
136139
removeRelation?: string;
137140
}
138141

@@ -284,15 +287,29 @@ export const ISSUES_META: DomainMeta = {
284287
};
285288

286289
interface RelationAction {
287-
type: "blocks" | "blockedBy" | "relatesTo" | "duplicateOf" | "remove";
290+
type:
291+
| "blocks"
292+
| "blockedBy"
293+
| "relatesTo"
294+
| "duplicateOf"
295+
| "similarTo"
296+
| "remove";
288297
targets: string[];
289298
}
290299

300+
interface RelationAddOptions {
301+
blocks?: string;
302+
related?: string;
303+
duplicate?: string;
304+
similar?: string;
305+
}
306+
291307
function parseRelationFlags(flags: {
292308
blocks?: string;
293309
blockedBy?: string;
294310
relatesTo?: string;
295311
duplicateOf?: string;
312+
similarTo?: string;
296313
removeRelation?: string;
297314
}): RelationAction[] {
298315
const entries: Array<{
@@ -303,6 +320,7 @@ function parseRelationFlags(flags: {
303320
{ type: "blockedBy", raw: flags.blockedBy },
304321
{ type: "relatesTo", raw: flags.relatesTo },
305322
{ type: "duplicateOf", raw: flags.duplicateOf },
323+
{ type: "similarTo", raw: flags.similarTo },
306324
{ type: "remove", raw: flags.removeRelation },
307325
];
308326

@@ -327,7 +345,7 @@ function parseRelationFlags(flags: {
327345
];
328346
if (targets.length === 0) {
329347
throw new Error(
330-
`Relation flag --${type === "remove" ? "remove-relation" : type} must not be empty`,
348+
`Relation flag ${relationFlagName(type)} must not be empty`,
331349
);
332350
}
333351
actions.push({ type, targets });
@@ -340,19 +358,90 @@ function parseRelationFlags(flags: {
340358
const prev = seen.get(target);
341359
if (prev) {
342360
throw new Error(
343-
`${target} appears in multiple relation flags (${prev} and --${action.type === "remove" ? "remove-relation" : action.type})`,
361+
`${target} appears in multiple relation flags (${prev} and ${relationFlagName(action.type)})`,
344362
);
345363
}
346-
seen.set(
347-
target,
348-
`--${action.type === "remove" ? "remove-relation" : action.type}`,
349-
);
364+
seen.set(target, relationFlagName(action.type));
350365
}
351366
}
352367

353368
return actions;
354369
}
355370

371+
function relationFlagName(type: RelationAction["type"]): string {
372+
switch (type) {
373+
case "blocks":
374+
return "--blocks";
375+
case "blockedBy":
376+
return "--blocked-by";
377+
case "relatesTo":
378+
return "--relates-to";
379+
case "duplicateOf":
380+
return "--duplicate-of";
381+
case "similarTo":
382+
return "--similar-to";
383+
case "remove":
384+
return "--remove-relation";
385+
}
386+
}
387+
388+
function relationTypeFromAddFlag(
389+
type: "blocks" | "related" | "duplicate" | "similar",
390+
): IssueRelationType {
391+
switch (type) {
392+
case "blocks":
393+
return IssueRelationType.Blocks;
394+
case "related":
395+
return IssueRelationType.Related;
396+
case "duplicate":
397+
return IssueRelationType.Duplicate;
398+
case "similar":
399+
return IssueRelationType.Similar;
400+
}
401+
}
402+
403+
function parseRelationAddOptions(options: RelationAddOptions): {
404+
type: IssueRelationType;
405+
targets: string[];
406+
} {
407+
const typeFlags = [
408+
options.blocks ? "blocks" : null,
409+
options.related ? "related" : null,
410+
options.duplicate ? "duplicate" : null,
411+
options.similar ? "similar" : null,
412+
].filter((type): type is keyof RelationAddOptions => type !== null);
413+
414+
if (typeFlags.length === 0) {
415+
throw new Error(
416+
"Must specify one of --blocks, --related, --duplicate, or --similar",
417+
);
418+
}
419+
420+
if (typeFlags.length > 1) {
421+
throw new Error("Cannot specify multiple relation types");
422+
}
423+
424+
const type = typeFlags[0];
425+
const rawTargets = options[type] ?? "";
426+
const targets = [
427+
...new Set(
428+
rawTargets
429+
.split(",")
430+
.map((target) => target.trim())
431+
.filter(Boolean),
432+
),
433+
];
434+
435+
if (targets.length === 0) {
436+
throw new Error("At least one related issue ID must be provided");
437+
}
438+
439+
return {
440+
type: relationTypeFromAddFlag(type),
441+
targets,
442+
};
443+
}
444+
356445
async function resolveAndApplyRelations(
357446
ctx: CommandContext,
358447
issueId: string,
@@ -400,6 +489,13 @@ async function resolveAndApplyRelations(
400489
type: IssueRelationType.Duplicate,
401490
});
402491
break;
492+
case "similarTo":
493+
await createIssueRelation(ctx.gql, {
494+
issueId,
495+
relatedIssueId: targetId,
496+
type: IssueRelationType.Similar,
497+
});
498+
break;
403499
case "remove": {
404500
const relationId = await findIssueRelation(
405501
ctx.gql,
@@ -450,6 +546,77 @@ export function setupIssuesCommands(program: Command): void {
450546

451547
issues.action(() => issues.help());
452548

549+
const relations = issues
550+
.command("relations")
551+
.description("Issue relation operations");
552+
553+
relations.action(() => relations.help());
554+
555+
relations
556+
.command("list <issue>")
557+
.description("list relations for an issue")
558+
.action(
559+
handleCommand(async (...args: unknown[]) => {
560+
const [issue, , command] = args as [string, unknown, Command];
561+
const ctx = createContext(getRootOpts(command));
562+
const issueId = await resolveIssueId(ctx.sdk, issue);
563+
const result = await listIssueRelations(ctx.gql, issueId);
564+
565+
outputSuccess(result);
566+
}),
567+
);
568+
569+
relations
570+
.command("add <issue>")
571+
.description("add relation(s) to an issue")
572+
.option("--blocks <issues>", "issues this issue blocks (comma-separated)")
573+
.option("--related <issues>", "related issues (comma-separated)")
574+
.option(
575+
"--duplicate <issues>",
576+
"issues this is a duplicate of (comma-separated)",
577+
)
578+
.option("--similar <issues>", "similar issues (comma-separated)")
579+
.action(
580+
handleCommand(async (...args: unknown[]) => {
581+
const [issue, options, command] = args as [
582+
string,
583+
RelationAddOptions,
584+
Command,
585+
];
586+
const relation = parseRelationAddOptions(options);
587+
const ctx = createContext(getRootOpts(command));
588+
const sourceIssueId = await resolveIssueId(ctx.sdk, issue);
589+
const targetIds = await Promise.all(
590+
relation.targets.map((target) => resolveIssueId(ctx.sdk, target)),
591+
);
592+
593+
const created = await Promise.all(
594+
targetIds.map((targetId) =>
595+
createIssueRelation(ctx.gql, {
596+
issueId: sourceIssueId,
597+
relatedIssueId: targetId,
598+
type: relation.type,
599+
}),
600+
),
601+
);
602+
603+
outputSuccess(created);
604+
}),
605+
);
606+
607+
relations
608+
.command("remove <relation>")
609+
.description("remove a relation by UUID")
610+
.action(
611+
handleCommand(async (...args: unknown[]) => {
612+
const [relation, , command] = args as [string, unknown, Command];
613+
const ctx = createContext(getRootOpts(command));
614+
const result = await deleteIssueRelation(ctx.gql, relation);
615+
616+
outputSuccess(result);
617+
}),
618+
);
619+
453620
addFilterOptions(
454621
issues
455622
.command("list")
@@ -988,6 +1155,7 @@ export function setupIssuesCommands(program: Command): void {
9881155
.option("--blocked-by <issue>", "this issue is blocked by <issue>")
9891156
.option("--relates-to <issue>", "this issue relates to <issue>")
9901157
.option("--duplicate-of <issue>", "this issue duplicates <issue>")
1158+
.option("--similar-to <issue>", "this issue is similar to <issue>")
9911159
.action(
9921160
handleCommand(async (...args: unknown[]) => {
9931161
const [title, options, command] = args as [
@@ -1140,6 +1308,7 @@ export function setupIssuesCommands(program: Command): void {
11401308
.option("--blocked-by <issue>", "add blocked-by relation")
11411309
.option("--relates-to <issue>", "add relates-to relation")
11421310
.option("--duplicate-of <issue>", "add duplicate relation")
1311+
.option("--similar-to <issue>", "add similar relation")
11431312
.option("--remove-relation <issue>", "remove relation with <issue>")
11441313
.action(
11451314
handleCommand(async (...args: unknown[]) => {

src/services/issue-relation-service.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
type IssueRelationType,
1212
} from "../gql/graphql.js";
1313

14+
type IssueRelationsIssue = NonNullable<GetIssueRelationsQuery["issue"]>;
15+
1416
export async function createIssueRelation(
1517
client: GraphQLClient,
1618
input: {
@@ -29,6 +31,36 @@ export async function createIssueRelation(
2931
return result.issueRelationCreate.issueRelation;
3032
}
3133

34+
export async function listIssueRelations(
35+
client: GraphQLClient,
36+
issueId: string,
37+
): Promise<{
38+
issueId: string;
39+
identifier: string;
40+
relations: Array<
41+
| IssueRelationsIssue["relations"]["nodes"][0]
42+
| IssueRelationsIssue["inverseRelations"]["nodes"][0]
43+
>;
44+
}> {
45+
const result = await client.request<GetIssueRelationsQuery>(
46+
GetIssueRelationsDocument,
47+
{ issueId },
48+
);
49+
50+
if (!result.issue) {
51+
throw notFoundError("Issue", issueId);
52+
}
53+
54+
return {
55+
issueId: result.issue.id,
56+
identifier: result.issue.identifier,
57+
relations: [
58+
...result.issue.relations.nodes,
59+
...result.issue.inverseRelations.nodes,
60+
],
61+
};
62+
}
63+
3264
export async function findIssueRelation(
3365
client: GraphQLClient,
3466
issueId: string,

0 commit comments

Comments
 (0)