Skip to content

Commit bd007f3

Browse files
authored
feat: add issue relation command for managing dependencies (#115)
## Summary Adds `linear issue relation` command with subcommands for managing issue relations/dependencies: - **`add`** - Create relations between issues - **`remove`** - Remove existing relations - **`list`** - List all relations for an issue ## Supported Relation Types | Type | Description | Example | |------|-------------|---------| | `blocks` | Issue blocks another | `linear issue relation add ENG-123 blocks ENG-456` | | `blocked-by` | Issue is blocked by another | `linear issue relation add ENG-123 blocked-by ENG-100` | | `related` | Issues are related | `linear issue relation add ENG-123 related ENG-456` | | `duplicate` | Issue is duplicate of another | `linear issue relation add ENG-123 duplicate ENG-100` | ## Examples ```bash # Mark ENG-123 as blocked by ENG-100 linear issue relation add ENG-123 blocked-by ENG-100 # Mark ENG-123 as blocking ENG-456 linear issue relation add ENG-123 blocks ENG-456 # List all relations for an issue linear issue relation list ENG-123 # Remove a relation linear issue relation remove ENG-123 blocked-by ENG-100 ``` ## Implementation Notes - The `blocked-by` relation type is implemented by reversing the issue order with the `blocks` API type, providing a more intuitive CLI experience - Uses existing utilities (`getIssueIdentifier`, `getIssueId`) for consistent issue resolution - Follows the existing command structure and patterns in the codebase ## Motivation This enables CLI users to manage issue dependencies, which is useful for: - Automating issue management in scripts - AI agents that need to set up dependency relationships - Quick dependency management without leaving the terminal
1 parent aff8f82 commit bd007f3

File tree

2 files changed

+366
-0
lines changed

2 files changed

+366
-0
lines changed
Lines changed: 364 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,364 @@
1+
import { Command } from "@cliffy/command"
2+
import { gql } from "../../__codegen__/gql.ts"
3+
import { getGraphQLClient } from "../../utils/graphql.ts"
4+
import { getIssueId, getIssueIdentifier } from "../../utils/linear.ts"
5+
6+
const RELATION_TYPES = ["blocks", "blocked-by", "related", "duplicate"] as const
7+
type RelationType = (typeof RELATION_TYPES)[number]
8+
9+
// Map CLI-friendly names to Linear API types
10+
// Note: "blocked-by" is implemented by reversing the issue order with "blocks"
11+
function getApiRelationType(
12+
type: RelationType,
13+
): "blocks" | "related" | "duplicate" {
14+
if (type === "blocked-by") return "blocks"
15+
return type
16+
}
17+
18+
const addRelationCommand = new Command()
19+
.name("add")
20+
.description("Add a relation between two issues")
21+
.arguments("<issueId:string> <relationType:string> <relatedIssueId:string>")
22+
.example(
23+
"Mark issue as blocked by another",
24+
"linear issue relation add ENG-123 blocked-by ENG-100",
25+
)
26+
.example(
27+
"Mark issue as blocking another",
28+
"linear issue relation add ENG-123 blocks ENG-456",
29+
)
30+
.example(
31+
"Mark issues as related",
32+
"linear issue relation add ENG-123 related ENG-456",
33+
)
34+
.example(
35+
"Mark issue as duplicate",
36+
"linear issue relation add ENG-123 duplicate ENG-100",
37+
)
38+
.option("--no-color", "Disable colored output")
39+
.action(async ({ color }, issueIdArg, relationTypeArg, relatedIssueIdArg) => {
40+
try {
41+
// Validate relation type
42+
const relationType = relationTypeArg.toLowerCase() as RelationType
43+
if (!RELATION_TYPES.includes(relationType)) {
44+
console.error(
45+
`Invalid relation type: ${relationTypeArg}. Must be one of: ${
46+
RELATION_TYPES.join(", ")
47+
}`,
48+
)
49+
Deno.exit(1)
50+
}
51+
52+
// Get issue identifiers
53+
const issueIdentifier = await getIssueIdentifier(issueIdArg)
54+
if (!issueIdentifier) {
55+
console.error(`Could not resolve issue identifier: ${issueIdArg}`)
56+
Deno.exit(1)
57+
}
58+
59+
const relatedIssueIdentifier = await getIssueIdentifier(relatedIssueIdArg)
60+
if (!relatedIssueIdentifier) {
61+
console.error(
62+
`Could not resolve related issue identifier: ${relatedIssueIdArg}`,
63+
)
64+
Deno.exit(1)
65+
}
66+
67+
const { Spinner } = await import("@std/cli/unstable-spinner")
68+
const showSpinner = color
69+
const spinner = showSpinner ? new Spinner() : null
70+
spinner?.start()
71+
72+
// Get issue IDs
73+
const issueId = await getIssueId(issueIdentifier)
74+
if (!issueId) {
75+
spinner?.stop()
76+
console.error(`Could not find issue: ${issueIdentifier}`)
77+
Deno.exit(1)
78+
}
79+
80+
const relatedIssueId = await getIssueId(relatedIssueIdentifier)
81+
if (!relatedIssueId) {
82+
spinner?.stop()
83+
console.error(`Could not find related issue: ${relatedIssueIdentifier}`)
84+
Deno.exit(1)
85+
}
86+
87+
// For "blocked-by", we swap the issues so the relation is correct
88+
// "A blocked-by B" means "B blocks A"
89+
const apiType = getApiRelationType(relationType)
90+
const [fromId, toId] = relationType === "blocked-by"
91+
? [relatedIssueId, issueId]
92+
: [issueId, relatedIssueId]
93+
94+
const createRelationMutation = gql(`
95+
mutation CreateIssueRelation($input: IssueRelationCreateInput!) {
96+
issueRelationCreate(input: $input) {
97+
success
98+
issueRelation {
99+
id
100+
type
101+
issue { identifier }
102+
relatedIssue { identifier }
103+
}
104+
}
105+
}
106+
`)
107+
108+
const client = getGraphQLClient()
109+
const data = await client.request(createRelationMutation, {
110+
input: {
111+
issueId: fromId,
112+
relatedIssueId: toId,
113+
type: apiType,
114+
},
115+
})
116+
117+
spinner?.stop()
118+
119+
if (!data.issueRelationCreate.success) {
120+
console.error("Failed to create relation")
121+
Deno.exit(1)
122+
}
123+
124+
const relation = data.issueRelationCreate.issueRelation
125+
if (relation) {
126+
console.log(
127+
`✓ Created relation: ${relation.issue.identifier} ${relationType} ${relation.relatedIssue.identifier}`,
128+
)
129+
}
130+
} catch (error) {
131+
console.error("Failed to create relation:", error)
132+
Deno.exit(1)
133+
}
134+
})
135+
136+
const deleteRelationCommand = new Command()
137+
.name("delete")
138+
.description("Delete a relation between two issues")
139+
.arguments("<issueId:string> <relationType:string> <relatedIssueId:string>")
140+
.option("--no-color", "Disable colored output")
141+
.action(async ({ color }, issueIdArg, relationTypeArg, relatedIssueIdArg) => {
142+
try {
143+
// Validate relation type
144+
const relationType = relationTypeArg.toLowerCase() as RelationType
145+
if (!RELATION_TYPES.includes(relationType)) {
146+
console.error(
147+
`Invalid relation type: ${relationTypeArg}. Must be one of: ${
148+
RELATION_TYPES.join(", ")
149+
}`,
150+
)
151+
Deno.exit(1)
152+
}
153+
154+
// Get issue identifiers
155+
const issueIdentifier = await getIssueIdentifier(issueIdArg)
156+
if (!issueIdentifier) {
157+
console.error(`Could not resolve issue identifier: ${issueIdArg}`)
158+
Deno.exit(1)
159+
}
160+
161+
const relatedIssueIdentifier = await getIssueIdentifier(relatedIssueIdArg)
162+
if (!relatedIssueIdentifier) {
163+
console.error(
164+
`Could not resolve related issue identifier: ${relatedIssueIdArg}`,
165+
)
166+
Deno.exit(1)
167+
}
168+
169+
const { Spinner } = await import("@std/cli/unstable-spinner")
170+
const showSpinner = color
171+
const spinner = showSpinner ? new Spinner() : null
172+
spinner?.start()
173+
174+
// Get issue IDs
175+
const issueId = await getIssueId(issueIdentifier)
176+
if (!issueId) {
177+
spinner?.stop()
178+
console.error(`Could not find issue: ${issueIdentifier}`)
179+
Deno.exit(1)
180+
}
181+
182+
const relatedIssueId = await getIssueId(relatedIssueIdentifier)
183+
if (!relatedIssueId) {
184+
spinner?.stop()
185+
console.error(`Could not find related issue: ${relatedIssueIdentifier}`)
186+
Deno.exit(1)
187+
}
188+
189+
// Find the relation
190+
const apiType = getApiRelationType(relationType)
191+
const [fromId, toId] = relationType === "blocked-by"
192+
? [relatedIssueId, issueId]
193+
: [issueId, relatedIssueId]
194+
195+
const findRelationQuery = gql(`
196+
query FindIssueRelation($issueId: String!) {
197+
issue(id: $issueId) {
198+
relations {
199+
nodes {
200+
id
201+
type
202+
relatedIssue { id }
203+
}
204+
}
205+
}
206+
}
207+
`)
208+
209+
const client = getGraphQLClient()
210+
const findData = await client.request(findRelationQuery, {
211+
issueId: fromId,
212+
})
213+
214+
const relation = findData.issue?.relations.nodes.find(
215+
(r: { type: string; relatedIssue: { id: string } }) =>
216+
r.type === apiType && r.relatedIssue.id === toId,
217+
)
218+
219+
if (!relation) {
220+
spinner?.stop()
221+
console.error(
222+
`No ${relationType} relation found between ${issueIdentifier} and ${relatedIssueIdentifier}`,
223+
)
224+
Deno.exit(1)
225+
}
226+
227+
const deleteRelationMutation = gql(`
228+
mutation DeleteIssueRelation($id: String!) {
229+
issueRelationDelete(id: $id) {
230+
success
231+
}
232+
}
233+
`)
234+
235+
const deleteData = await client.request(deleteRelationMutation, {
236+
id: relation.id,
237+
})
238+
239+
spinner?.stop()
240+
241+
if (!deleteData.issueRelationDelete.success) {
242+
console.error("Failed to delete relation")
243+
Deno.exit(1)
244+
}
245+
246+
console.log(
247+
`✓ Deleted relation: ${issueIdentifier} ${relationType} ${relatedIssueIdentifier}`,
248+
)
249+
} catch (error) {
250+
console.error("Failed to delete relation:", error)
251+
Deno.exit(1)
252+
}
253+
})
254+
255+
const listRelationsCommand = new Command()
256+
.name("list")
257+
.description("List relations for an issue")
258+
.arguments("[issueId:string]")
259+
.option("--no-color", "Disable colored output")
260+
.action(async ({ color }, issueIdArg) => {
261+
try {
262+
const issueIdentifier = await getIssueIdentifier(issueIdArg)
263+
if (!issueIdentifier) {
264+
console.error(
265+
"Could not determine issue ID. Please provide an issue ID like 'ENG-123'.",
266+
)
267+
Deno.exit(1)
268+
}
269+
270+
const { Spinner } = await import("@std/cli/unstable-spinner")
271+
const showSpinner = color
272+
const spinner = showSpinner ? new Spinner() : null
273+
spinner?.start()
274+
275+
const listRelationsQuery = gql(`
276+
query ListIssueRelations($issueId: String!) {
277+
issue(id: $issueId) {
278+
identifier
279+
title
280+
relations {
281+
nodes {
282+
id
283+
type
284+
relatedIssue {
285+
identifier
286+
title
287+
}
288+
}
289+
}
290+
inverseRelations {
291+
nodes {
292+
id
293+
type
294+
issue {
295+
identifier
296+
title
297+
}
298+
}
299+
}
300+
}
301+
}
302+
`)
303+
304+
const client = getGraphQLClient()
305+
const data = await client.request(listRelationsQuery, {
306+
issueId: issueIdentifier,
307+
})
308+
309+
spinner?.stop()
310+
311+
if (!data.issue) {
312+
console.error(`Issue not found: ${issueIdentifier}`)
313+
Deno.exit(1)
314+
}
315+
316+
const { identifier, title, relations, inverseRelations } = data.issue
317+
318+
console.log(`Relations for ${identifier}: ${title}`)
319+
console.log()
320+
321+
const outgoing = relations.nodes
322+
const incoming = inverseRelations.nodes
323+
324+
if (outgoing.length === 0 && incoming.length === 0) {
325+
console.log(" No relations")
326+
return
327+
}
328+
329+
if (outgoing.length > 0) {
330+
console.log("Outgoing:")
331+
for (const rel of outgoing) {
332+
console.log(
333+
` ${identifier} ${rel.type} ${rel.relatedIssue.identifier}: ${rel.relatedIssue.title}`,
334+
)
335+
}
336+
}
337+
338+
if (incoming.length > 0) {
339+
if (outgoing.length > 0) console.log()
340+
console.log("Incoming:")
341+
for (const rel of incoming) {
342+
// Show inverse perspective
343+
const displayType = rel.type === "blocks" ? "blocked-by" : rel.type
344+
console.log(
345+
` ${identifier} ${displayType} ${rel.issue.identifier}: ${rel.issue.title}`,
346+
)
347+
}
348+
}
349+
} catch (error) {
350+
console.error("Failed to list relations:", error)
351+
Deno.exit(1)
352+
}
353+
})
354+
355+
// Export the main command after subcommands are defined
356+
export const relationCommand = new Command()
357+
.name("relation")
358+
.description("Manage issue relations (dependencies)")
359+
.action(function () {
360+
this.showHelp()
361+
})
362+
.command("add", addRelationCommand)
363+
.command("delete", deleteRelationCommand)
364+
.command("list", listRelationsCommand)

src/commands/issue/issue.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { commitsCommand } from "./issue-commits.ts"
88
import { idCommand } from "./issue-id.ts"
99
import { listCommand } from "./issue-list.ts"
1010
import { pullRequestCommand } from "./issue-pull-request.ts"
11+
import { relationCommand } from "./issue-relation.ts"
1112
import { startCommand } from "./issue-start.ts"
1213
import { titleCommand } from "./issue-title.ts"
1314
import { updateCommand } from "./issue-update.ts"
@@ -33,3 +34,4 @@ export const issueCommand = new Command()
3334
.command("update", updateCommand)
3435
.command("comment", commentCommand)
3536
.command("attach", attachCommand)
37+
.command("relation", relationCommand)

0 commit comments

Comments
 (0)