main
1/**
2 * Link-related action handlers for Jira extension
3 */
4
5import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
6import type { JiraDetails } from "./types";
7import { getErrorMessage, serializedConfirm } from "./utils";
8
9/**
10 * Map common link type aliases/labels to the correct Jira link type names.
11 * The jira CLI expects the link type *name* (e.g., "Blocks", "Related"),
12 * not the inward/outward relationship labels (e.g., "is blocked by", "relates to").
13 */
14const LINK_TYPE_ALIASES: Record<string, string> = {
15 // Aliases for "Blocks"
16 "blocks": "Blocks",
17 "is blocked by": "Blocks",
18 "blocked by": "Blocks",
19 // Aliases for "Related"
20 "relates to": "Related",
21 "related": "Related",
22 "related to": "Related",
23 // Aliases for "Duplicate"
24 "duplicates": "Duplicate",
25 "is duplicated by": "Duplicate",
26 "duplicate": "Duplicate",
27 // Aliases for "Cloners"
28 "clones": "Cloners",
29 "is cloned by": "Cloners",
30 "cloners": "Cloners",
31 // Aliases for "Depend"
32 "depends on": "Depend",
33 "is depended on by": "Depend",
34 "depend": "Depend",
35 // Aliases for "Causality"
36 "causes": "Causality",
37 "is caused by": "Causality",
38 "causality": "Causality",
39 // Aliases for "Document"
40 "documents": "Document",
41 "is documented by": "Document",
42 "document": "Document",
43 // Aliases for "Incorporates"
44 "incorporates": "Incorporates",
45 "is incorporated by": "Incorporates",
46 // Aliases for "Informs"
47 "informs": "Informs",
48 "is informed by": "Informs",
49 // Aliases for "Triggers"
50 "triggers": "Triggers",
51 "is triggered by": "Triggers",
52};
53
54/**
55 * Resolve a link type string to the correct Jira link type name.
56 * Handles case-insensitive matching and common aliases.
57 */
58function resolveLinkType(linkType: string): string {
59 // Try direct alias lookup (case-insensitive)
60 const normalized = linkType.toLowerCase().trim();
61 if (LINK_TYPE_ALIASES[normalized]) {
62 return LINK_TYPE_ALIASES[normalized];
63 }
64 // Return as-is (the jira CLI is case-insensitive for valid names)
65 return linkType;
66}
67
68/**
69 * Link two issues together
70 */
71export async function handleLink(
72 pi: ExtensionAPI,
73 params: any,
74 signal: AbortSignal | undefined,
75 onUpdate: any,
76 ctx: ExtensionContext,
77): Promise<any> {
78 if (!params.from) {
79 return {
80 content: [{ type: "text", text: "Error: 'from' parameter is required for link action" }],
81 details: { action: "link", error: "missing_from" } as JiraDetails,
82 isError: true,
83 };
84 }
85
86 if (!params.to) {
87 return {
88 content: [{ type: "text", text: "Error: 'to' parameter is required for link action" }],
89 details: { action: "link", error: "missing_to" } as JiraDetails,
90 isError: true,
91 };
92 }
93
94 if (!params.linkType) {
95 return {
96 content: [{ type: "text", text: "Error: 'linkType' parameter is required for link action" }],
97 details: { action: "link", error: "missing_linkType" } as JiraDetails,
98 isError: true,
99 };
100 }
101
102 // Resolve link type aliases to correct Jira link type names
103 const resolvedLinkType = resolveLinkType(params.linkType);
104
105 // APPROVAL GATE
106 if (ctx.hasUI) {
107 const confirmMessage =
108 `From: ${params.from}\n` +
109 `To: ${params.to}\n` +
110 `Link type: ${resolvedLinkType}\n\n` +
111 `This will create an issue link.`;
112
113 const confirmed = await serializedConfirm(ctx, "Link issues?", confirmMessage);
114
115 if (!confirmed) {
116 ctx.ui.notify("Link cancelled", "info");
117 return {
118 content: [{ type: "text", text: "Link operation cancelled by user" }],
119 details: { action: "link", cancelled: true } as JiraDetails,
120 };
121 }
122 }
123
124 onUpdate?.({ content: [{ type: "text", text: `Linking ${params.from} to ${params.to}...` }] });
125
126 // Use jira CLI to create link
127 const result = await pi.exec(
128 "jira",
129 ["issue", "link", params.from, params.to, resolvedLinkType],
130 { signal, timeout: 20000 },
131 );
132
133 if (result.code !== 0) {
134 return {
135 content: [{ type: "text", text: getErrorMessage(result.stderr, "Link issues") }],
136 details: { action: "link", error: result.stderr } as JiraDetails,
137 isError: true,
138 };
139 }
140
141 return {
142 content: [{ type: "text", text: `Linked ${params.from} ${resolvedLinkType} ${params.to}` }],
143 details: {
144 action: "link",
145 output: result.stdout,
146 fromKey: params.from,
147 toKey: params.to,
148 } as JiraDetails,
149 };
150}
151
152/**
153 * Unlink two issues
154 */
155export async function handleUnlink(
156 pi: ExtensionAPI,
157 params: any,
158 signal: AbortSignal | undefined,
159 onUpdate: any,
160 ctx: ExtensionContext,
161): Promise<any> {
162 if (!params.from) {
163 return {
164 content: [{ type: "text", text: "Error: 'from' parameter is required for unlink action" }],
165 details: { action: "unlink", error: "missing_from" } as JiraDetails,
166 isError: true,
167 };
168 }
169
170 if (!params.to) {
171 return {
172 content: [{ type: "text", text: "Error: 'to' parameter is required for unlink action" }],
173 details: { action: "unlink", error: "missing_to" } as JiraDetails,
174 isError: true,
175 };
176 }
177
178 // APPROVAL GATE
179 if (ctx.hasUI) {
180 const confirmMessage = `From: ${params.from}\nTo: ${params.to}\n\nThis will remove the issue link.`;
181
182 const confirmed = await serializedConfirm(ctx, "Unlink issues?", confirmMessage);
183
184 if (!confirmed) {
185 ctx.ui.notify("Unlink cancelled", "info");
186 return {
187 content: [{ type: "text", text: "Unlink operation cancelled by user" }],
188 details: { action: "unlink", cancelled: true } as JiraDetails,
189 };
190 }
191 }
192
193 onUpdate?.({ content: [{ type: "text", text: `Unlinking ${params.from} from ${params.to}...` }] });
194
195 // Use jira CLI to remove link
196 const result = await pi.exec(
197 "jira",
198 ["issue", "unlink", params.from, params.to],
199 { signal, timeout: 20000 },
200 );
201
202 if (result.code !== 0) {
203 return {
204 content: [{ type: "text", text: getErrorMessage(result.stderr, "Unlink issues") }],
205 details: { action: "unlink", error: result.stderr } as JiraDetails,
206 isError: true,
207 };
208 }
209
210 return {
211 content: [{ type: "text", text: `Unlinked ${params.from} from ${params.to}` }],
212 details: {
213 action: "unlink",
214 output: result.stdout,
215 fromKey: params.from,
216 toKey: params.to,
217 } as JiraDetails,
218 };
219}