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