@@ -58,6 +58,7 @@ import {
5858 createIssueRelation ,
5959 deleteIssueRelation ,
6060 findIssueRelation ,
61+ listIssueRelations ,
6162} from "../services/issue-relation-service.js" ;
6263import {
6364 archiveIssue ,
@@ -107,6 +108,7 @@ interface CreateOptions {
107108 blockedBy ?: string ;
108109 relatesTo ?: string ;
109110 duplicateOf ?: string ;
111+ similarTo ?: string ;
110112}
111113
112114interface 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
286289interface 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+
291307function 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+
356445async 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 [ ] ) => {
0 commit comments