main
  1/**
  2 * Action handlers for Jira extension
  3 */
  4
  5import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
  6import type { JiraDetails } from "./types";
  7import {
  8	buildCommentConfirmation,
  9	buildCreateConfirmation,
 10	buildTransitionConfirmation,
 11	buildUpdateConfirmation,
 12	extractIssueKey,
 13	extractIssueKeys,
 14	getErrorMessage,
 15	parseIssueListJSON,
 16	serializedConfirm,
 17} from "./utils";
 18
 19/**
 20 * Get current Jira user
 21 */
 22export async function handleMe(
 23	pi: ExtensionAPI,
 24	params: any,
 25	signal: AbortSignal | undefined,
 26	onUpdate: any,
 27	ctx: ExtensionContext,
 28): Promise<any> {
 29	const result = await pi.exec("jira", ["me"], { signal, timeout: 10000 });
 30
 31	if (result.code !== 0) {
 32		return {
 33			content: [{ type: "text", text: getErrorMessage(result.stderr, "Get current user") }],
 34			details: { action: "me", error: result.stderr } as JiraDetails,
 35			isError: true,
 36		};
 37	}
 38
 39	const user = result.stdout.trim();
 40
 41	return {
 42		content: [{ type: "text", text: `Current user: ${user}` }],
 43		details: { action: "me", output: user } as JiraDetails,
 44	};
 45}
 46
 47/**
 48 * List Jira issues
 49 */
 50export async function handleList(
 51	pi: ExtensionAPI,
 52	params: any,
 53	signal: AbortSignal | undefined,
 54	onUpdate: any,
 55	ctx: ExtensionContext,
 56	currentUser: string,
 57): Promise<any> {
 58	// Build jira command - use --raw for JSON output
 59	const args = ["issue", "list", "--raw"];
 60
 61	// Handle assignee
 62	if (params.assignee) {
 63		if (params.assignee === "me") {
 64			// Get current user if not cached
 65			if (!currentUser) {
 66				const meResult = await pi.exec("jira", ["me"], { signal, timeout: 10000 });
 67				if (meResult.code === 0) {
 68					currentUser = meResult.stdout.trim();
 69				}
 70			}
 71			args.push("-a", currentUser || params.assignee);
 72		} else {
 73			args.push("-a", params.assignee);
 74		}
 75	}
 76
 77	// Handle status
 78	if (params.status) {
 79		args.push("-s", params.status);
 80	}
 81
 82	// Handle type
 83	if (params.type) {
 84		args.push("-t", params.type);
 85	}
 86
 87	// Handle priority
 88	if (params.priority) {
 89		args.push("-p", params.priority);
 90	}
 91
 92	// Handle epic filter (via JQL)
 93	if (params.epic) {
 94		// Use JQL for epic filtering
 95		const jql = buildListJQL(params, currentUser);
 96		args.length = 0; // Clear args
 97		args.push("issue", "list", "--raw", "--jql", jql);
 98	}
 99
100	// Handle limit (jira CLI uses --paginate, not --limit)
101	if (params.limit) {
102		args.push("--paginate", String(params.limit));
103	} else {
104		args.push("--paginate", "20");
105	}
106
107	onUpdate?.({ content: [{ type: "text", text: "Fetching issues..." }] });
108
109	const result = await pi.exec("jira", args, { signal, timeout: 30000 });
110
111	if (result.code !== 0) {
112		return {
113			content: [{ type: "text", text: getErrorMessage(result.stderr, "List issues") }],
114			details: { action: "list", error: result.stderr } as JiraDetails,
115			isError: true,
116		};
117	}
118
119	// Parse JSON output
120	const issues = parseIssueListJSON(result.stdout);
121	
122	// Format for LLM
123	let output = "";
124	if (issues.length === 0) {
125		output = "No issues found";
126	} else {
127		output = issues.map(issue => {
128			const priority = issue.priority ? `[${issue.priority}]` : "";
129			const assignee = issue.assignee !== "Unassigned" ? `(@${issue.assignee})` : "";
130			return `${issue.key} [${issue.type}] ${priority} [${issue.status}] ${issue.summary} ${assignee}`;
131		}).join("\n");
132	}
133	
134	// Extract issue keys for tracking
135	const issueKeys = issues.map(i => i.key);
136
137	return {
138		content: [{ type: "text", text: output }],
139		details: { action: "list", output, issueKeys, issues } as JiraDetails,
140	};
141}
142
143/**
144 * View Jira issue details
145 */
146export async function handleView(
147	pi: ExtensionAPI,
148	params: any,
149	signal: AbortSignal | undefined,
150	onUpdate: any,
151	ctx: ExtensionContext,
152): Promise<any> {
153	if (!params.key) {
154		return {
155			content: [{ type: "text", text: "Error: 'key' parameter is required for view action" }],
156			details: { action: "view", error: "missing_key" } as JiraDetails,
157			isError: true,
158		};
159	}
160
161	onUpdate?.({ content: [{ type: "text", text: `Fetching ${params.key}...` }] });
162
163	const result = await pi.exec("jira", ["issue", "view", params.key, "--plain"], { signal, timeout: 20000 });
164
165	if (result.code !== 0) {
166		return {
167			content: [{ type: "text", text: getErrorMessage(result.stderr, "View issue") }],
168			details: { action: "view", error: result.stderr, issueKey: params.key } as JiraDetails,
169			isError: true,
170		};
171	}
172
173	return {
174		content: [{ type: "text", text: result.stdout }],
175		details: { action: "view", output: result.stdout, issueKey: params.key } as JiraDetails,
176	};
177}
178
179/**
180 * Search Jira issues using JQL
181 */
182export async function handleSearch(
183	pi: ExtensionAPI,
184	params: any,
185	signal: AbortSignal | undefined,
186	onUpdate: any,
187	ctx: ExtensionContext,
188): Promise<any> {
189	if (!params.jql) {
190		return {
191			content: [{ type: "text", text: "Error: 'jql' parameter is required for search action" }],
192			details: { action: "search", error: "missing_jql" } as JiraDetails,
193			isError: true,
194		};
195	}
196
197	const args = ["issue", "list", "--raw", "--jql", params.jql];
198
199	if (params.limit) {
200		args.push("--paginate", String(params.limit));
201	} else {
202		args.push("--paginate", "50");
203	}
204
205	onUpdate?.({ content: [{ type: "text", text: "Searching..." }] });
206
207	const result = await pi.exec("jira", args, { signal, timeout: 30000 });
208
209	if (result.code !== 0) {
210		return {
211			content: [{ type: "text", text: getErrorMessage(result.stderr, "Search") }],
212			details: { action: "search", error: result.stderr } as JiraDetails,
213			isError: true,
214		};
215	}
216
217	// Parse JSON output
218	const issues = parseIssueListJSON(result.stdout);
219	
220	// Format for LLM
221	let output = "";
222	if (issues.length === 0) {
223		output = "No issues found";
224	} else {
225		output = issues.map(issue => {
226			const priority = issue.priority ? `[${issue.priority}]` : "";
227			const assignee = issue.assignee !== "Unassigned" ? `(@${issue.assignee})` : "";
228			return `${issue.key} [${issue.type}] ${priority} [${issue.status}] ${issue.summary} ${assignee}`;
229		}).join("\n");
230	}
231	
232	// Extract issue keys for tracking
233	const issueKeys = issues.map(i => i.key);
234
235	return {
236		content: [{ type: "text", text: output }],
237		details: { action: "search", output, issueKeys, issues } as JiraDetails,
238	};
239}
240
241/**
242 * Create Jira issue (requires approval)
243 */
244export async function handleCreate(
245	pi: ExtensionAPI,
246	params: any,
247	signal: AbortSignal | undefined,
248	onUpdate: any,
249	ctx: ExtensionContext,
250	currentUser: string,
251): Promise<any> {
252	// Validate required parameters
253	if (!params.issueType) {
254		return {
255			content: [{ type: "text", text: "Error: 'issueType' parameter is required for create action" }],
256			details: { action: "create", error: "missing_type" } as JiraDetails,
257			isError: true,
258		};
259	}
260
261	if (!params.summary) {
262		return {
263			content: [{ type: "text", text: "Error: 'summary' parameter is required for create action" }],
264			details: { action: "create", error: "missing_summary" } as JiraDetails,
265			isError: true,
266		};
267	}
268
269	// APPROVAL GATE
270	if (ctx.hasUI) {
271		const confirmMessage = buildCreateConfirmation(params);
272
273		const confirmed = await serializedConfirm(ctx, "Create Jira Issue?", confirmMessage);
274
275		if (!confirmed) {
276			ctx.ui.notify("Create cancelled", "info");
277			return {
278				content: [{ type: "text", text: "Create operation cancelled by user" }],
279				details: { action: "create", cancelled: true } as JiraDetails,
280			};
281		}
282	}
283
284	// Build jira command
285	const args = ["issue", "create", "--type", params.issueType, "--summary", params.summary, "--no-input"];
286
287	if (params.description) {
288		args.push("-b", params.description);
289	}
290
291	if (params.priority) {
292		args.push("-y", params.priority);
293	}
294
295	if (params.assignee) {
296		const assignee = params.assignee === "me" ? currentUser || params.assignee : params.assignee;
297		args.push("-a", assignee);
298	}
299
300	if (params.labels && params.labels.length > 0) {
301		// Each label needs a separate -l flag (stringArray)
302		for (const label of params.labels) {
303			args.push("-l", label);
304		}
305	}
306
307	if (params.parent) {
308		args.push("-P", params.parent);
309	}
310
311	// Note: epic linking might need to be done via edit after creation
312	// depending on jira-cli version
313
314	onUpdate?.({ content: [{ type: "text", text: "Creating issue..." }] });
315
316	const result = await pi.exec("jira", args, { signal, timeout: 30000 });
317
318	if (result.code !== 0) {
319		return {
320			content: [{ type: "text", text: getErrorMessage(result.stderr, "Create issue") }],
321			details: { action: "create", error: result.stderr } as JiraDetails,
322			isError: true,
323		};
324	}
325
326	// Extract issue key from output
327	const issueKey = extractIssueKey(result.stdout) || "unknown";
328
329	return {
330		content: [{ type: "text", text: `Created issue: ${issueKey}\n\n${result.stdout}` }],
331		details: { action: "create", output: result.stdout, issueKey } as JiraDetails,
332	};
333}
334
335/**
336 * Update Jira issue field (requires approval)
337 */
338export async function handleUpdate(
339	pi: ExtensionAPI,
340	params: any,
341	signal: AbortSignal | undefined,
342	onUpdate: any,
343	ctx: ExtensionContext,
344	currentUser: string,
345): Promise<any> {
346	if (!params.key) {
347		return {
348			content: [{ type: "text", text: "Error: 'key' parameter is required for update action" }],
349			details: { action: "update", error: "missing_key" } as JiraDetails,
350			isError: true,
351		};
352	}
353
354	if (!params.field) {
355		return {
356			content: [{ type: "text", text: "Error: 'field' parameter is required for update action" }],
357			details: { action: "update", error: "missing_field" } as JiraDetails,
358			isError: true,
359		};
360	}
361
362	if (!params.value) {
363		return {
364			content: [{ type: "text", text: "Error: 'value' parameter is required for update action" }],
365			details: { action: "update", error: "missing_value" } as JiraDetails,
366			isError: true,
367		};
368	}
369
370	// APPROVAL GATE
371	if (ctx.hasUI) {
372		const confirmMessage = buildUpdateConfirmation(params);
373
374		const confirmed = await serializedConfirm(ctx, `Update ${params.key}?`, confirmMessage);
375
376		if (!confirmed) {
377			ctx.ui.notify("Update cancelled", "info");
378			return {
379				content: [{ type: "text", text: "Update operation cancelled by user" }],
380				details: { action: "update", cancelled: true, issueKey: params.key } as JiraDetails,
381			};
382		}
383	}
384
385	// Build command based on field
386	let args: string[];
387	const value = params.value === "me" ? currentUser || params.value : params.value;
388
389	switch (params.field) {
390		case "assignee":
391			args = ["issue", "assign", params.key, value];
392			break;
393
394		case "labels":
395			// Each label needs a separate -l flag (stringArray)
396			args = ["issue", "edit", params.key, "--no-input"];
397			for (const label of value.split(",")) {
398				args.push("-l", label.trim());
399			}
400			break;
401
402		case "priority":
403			args = ["issue", "edit", params.key, "-y", value, "--no-input"];
404			break;
405
406		case "summary":
407			args = ["issue", "edit", params.key, "-s", value, "--no-input"];
408			break;
409
410		case "description":
411			args = ["issue", "edit", params.key, "-b", value, "--no-input"];
412			break;
413
414		default:
415			return {
416				content: [{ type: "text", text: `Error: Unsupported field: ${params.field}` }],
417				details: { action: "update", error: "unsupported_field", issueKey: params.key } as JiraDetails,
418				isError: true,
419			};
420	}
421
422	onUpdate?.({ content: [{ type: "text", text: `Updating ${params.key}...` }] });
423
424	const result = await pi.exec("jira", args, { signal, timeout: 20000 });
425
426	if (result.code !== 0) {
427		return {
428			content: [{ type: "text", text: getErrorMessage(result.stderr, "Update issue") }],
429			details: { action: "update", error: result.stderr, issueKey: params.key } as JiraDetails,
430			isError: true,
431		};
432	}
433
434	return {
435		content: [{ type: "text", text: `Updated ${params.key}: ${params.field} = ${value}\n\n${result.stdout}` }],
436		details: {
437			action: "update",
438			output: result.stdout,
439			issueKey: params.key,
440			field: params.field,
441			newValue: value,
442		} as JiraDetails,
443	};
444}
445
446/**
447 * Add comment to Jira issue (requires approval)
448 */
449export async function handleComment(
450	pi: ExtensionAPI,
451	params: any,
452	signal: AbortSignal | undefined,
453	onUpdate: any,
454	ctx: ExtensionContext,
455): Promise<any> {
456	if (!params.key) {
457		return {
458			content: [{ type: "text", text: "Error: 'key' parameter is required for comment action" }],
459			details: { action: "comment", error: "missing_key" } as JiraDetails,
460			isError: true,
461		};
462	}
463
464	if (!params.comment) {
465		return {
466			content: [{ type: "text", text: "Error: 'comment' parameter is required for comment action" }],
467			details: { action: "comment", error: "missing_comment" } as JiraDetails,
468			isError: true,
469		};
470	}
471
472	// APPROVAL GATE
473	if (ctx.hasUI) {
474		const confirmMessage = buildCommentConfirmation(params);
475
476		const confirmed = await serializedConfirm(ctx, `Add comment to ${params.key}?`, confirmMessage);
477
478		if (!confirmed) {
479			ctx.ui.notify("Comment cancelled", "info");
480			return {
481				content: [{ type: "text", text: "Comment operation cancelled by user" }],
482				details: { action: "comment", cancelled: true, issueKey: params.key } as JiraDetails,
483			};
484		}
485	}
486
487	onUpdate?.({ content: [{ type: "text", text: `Adding comment to ${params.key}...` }] });
488
489	// Use jira CLI's positional argument for comment body + --no-input to skip editor
490	const result = await pi.exec("jira", ["issue", "comment", "add", params.key, params.comment, "--no-input"], {
491		signal,
492		timeout: 20000,
493	});
494
495	if (result.code !== 0) {
496		return {
497			content: [{ type: "text", text: getErrorMessage(result.stderr, "Add comment") }],
498			details: { action: "comment", error: result.stderr, issueKey: params.key } as JiraDetails,
499			isError: true,
500		};
501	}
502
503	return {
504		content: [{ type: "text", text: `Added comment to ${params.key}` }],
505		details: { action: "comment", output: result.stdout, issueKey: params.key } as JiraDetails,
506	};
507}
508
509/**
510 * Transition Jira issue to new state (requires approval)
511 */
512export async function handleTransition(
513	pi: ExtensionAPI,
514	params: any,
515	signal: AbortSignal | undefined,
516	onUpdate: any,
517	ctx: ExtensionContext,
518): Promise<any> {
519	if (!params.key) {
520		return {
521			content: [{ type: "text", text: "Error: 'key' parameter is required for transition action" }],
522			details: { action: "transition", error: "missing_key" } as JiraDetails,
523			isError: true,
524		};
525	}
526
527	if (!params.state) {
528		return {
529			content: [{ type: "text", text: "Error: 'state' parameter is required for transition action" }],
530			details: { action: "transition", error: "missing_state" } as JiraDetails,
531			isError: true,
532		};
533	}
534
535	// APPROVAL GATE
536	if (ctx.hasUI) {
537		const confirmMessage = buildTransitionConfirmation(params);
538
539		const confirmed = await serializedConfirm(ctx, `Transition ${params.key}?`, confirmMessage);
540
541		if (!confirmed) {
542			ctx.ui.notify("Transition cancelled", "info");
543			return {
544				content: [{ type: "text", text: "Transition operation cancelled by user" }],
545				details: { action: "transition", cancelled: true, issueKey: params.key } as JiraDetails,
546			};
547		}
548	}
549
550	onUpdate?.({ content: [{ type: "text", text: `Moving ${params.key} to ${params.state}...` }] });
551
552	const result = await pi.exec("jira", ["issue", "move", params.key, params.state], { signal, timeout: 20000 });
553
554	if (result.code !== 0) {
555		return {
556			content: [{ type: "text", text: getErrorMessage(result.stderr, "Transition issue") }],
557			details: { action: "transition", error: result.stderr, issueKey: params.key } as JiraDetails,
558			isError: true,
559		};
560	}
561
562	return {
563		content: [{ type: "text", text: `Moved ${params.key} to ${params.state}` }],
564		details: {
565			action: "transition",
566			output: result.stdout,
567			issueKey: params.key,
568			toKey: params.state,
569		} as JiraDetails,
570	};
571}
572
573/**
574 * Helper: Build JQL query from list parameters
575 */
576function buildListJQL(params: any, currentUser: string): string {
577	const conditions: string[] = [];
578
579	if (params.assignee) {
580		const assignee = params.assignee === "me" ? currentUser || params.assignee : params.assignee;
581		conditions.push(`assignee = ${assignee}`);
582	}
583
584	if (params.status) {
585		// Handle ~Done syntax for "not Done"
586		if (params.status.startsWith("~")) {
587			conditions.push(`status != ${params.status.slice(1)}`);
588		} else {
589			// Handle comma-separated statuses
590			const statuses = params.status
591				.split(",")
592				.map((s: string) => `"${s.trim()}"`)
593				.join(",");
594			conditions.push(`status IN (${statuses})`);
595		}
596	}
597
598	if (params.type) {
599		const types = params.type
600			.split(",")
601			.map((t: string) => `"${t.trim()}"`)
602			.join(",");
603		conditions.push(`type IN (${types})`);
604	}
605
606	if (params.priority) {
607		const priorities = params.priority
608			.split(",")
609			.map((p: string) => `"${p.trim()}"`)
610			.join(",");
611		conditions.push(`priority IN (${priorities})`);
612	}
613
614	if (params.epic) {
615		conditions.push(`"Epic Link" = ${params.epic}`);
616	}
617
618	return conditions.join(" AND ");
619}