flake-update-20260505
  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}