flake-update-20260505
1/**
2 * Pi Extension: Jira Issue Management
3 *
4 * Provides Jira issue management for issues.redhat.com with:
5 * - Read operations: me, list, view, search
6 * - Write operations (with approval): create, update, comment, transition
7 * - Custom rendering for issues
8 * - State management for current user
9 *
10 * Configuration:
11 * ~/.config/.jira/.config.yml - Jira CLI config
12 * passage show redhat/issues/atlassian/token - API token
13 *
14 * Requirements:
15 * - jira CLI: go install github.com/ankitpokhrel/jira-cli/cmd/jira@latest
16 * - Red Hat VPN connection
17 */
18
19import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
20import {
21 Text,
22 type AutocompleteItem,
23 type AutocompleteProvider,
24 type AutocompleteSuggestions,
25 fuzzyFilter,
26} from "@mariozechner/pi-tui";
27import { Type } from "@sinclair/typebox";
28import { StringEnum } from "@mariozechner/pi-ai";
29
30import type { JiraDetails } from "./types";
31import {
32 handleMe,
33 handleList,
34 handleView,
35 handleSearch,
36 handleCreate,
37 handleUpdate,
38 handleComment,
39 handleTransition,
40} from "./actions";
41import { handleEpicView, handleLinkToEpic } from "./epic-actions";
42import { handleLink, handleUnlink } from "./link-actions";
43import { handleAttach, handleListAttachments } from "./attachment-actions";
44import { parseIssueListJSON, getStatusColor, getPriorityColor, truncate } from "./utils";
45
46export default function (pi: ExtensionAPI) {
47 // ========================================================================
48 // State Management
49 // ========================================================================
50
51 let currentUser = "";
52 let recentIssues: Array<{ key: string; summary: string }> = [];
53
54 // Reconstruct state from session
55 const reconstructState = (ctx: ExtensionContext) => {
56 currentUser = "";
57 recentIssues = [];
58
59 for (const entry of ctx.sessionManager.getBranch()) {
60 if (entry.type !== "message") continue;
61 const msg = entry.message;
62 if (msg.role !== "toolResult" || msg.toolName !== "jira") continue;
63
64 const details = msg.details as JiraDetails | undefined;
65 if (details?.action === "me" && details.output) {
66 currentUser = details.output.trim();
67 }
68
69 // Track recent issue keys (extract summary from output if available)
70 if (details?.issueKey && !recentIssues.find(i => i.key === details.issueKey)) {
71 // Try to extract summary from view output
72 let summary = details.issueKey;
73 if (details.output) {
74 const match = details.output.match(/^Summary:\s*(.+)$/m);
75 if (match?.[1]) summary = match[1].trim();
76 }
77 recentIssues.push({ key: details.issueKey, summary });
78 }
79 if (details?.issueKeys) {
80 for (const key of details.issueKeys) {
81 if (!recentIssues.find(i => i.key === key)) {
82 recentIssues.push({ key, summary: key });
83 }
84 }
85 }
86 }
87
88 // Keep only last 20 issues
89 if (recentIssues.length > 20) {
90 recentIssues = recentIssues.slice(-20);
91 }
92 };
93
94 // Session events
95 pi.on("session_start", async (_event, ctx) => {
96 reconstructState(ctx);
97 setupJiraAutocomplete(pi, ctx);
98 });
99 pi.on("session_switch", async (_event, ctx) => reconstructState(ctx));
100 pi.on("session_fork", async (_event, ctx) => reconstructState(ctx));
101 pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
102
103 // ========================================================================
104 // Tool Registration
105 // ========================================================================
106
107 pi.registerTool({
108 name: "jira",
109 label: "Jira",
110 description:
111 "Manage Jira issues on issues.redhat.com. " +
112 "Actions: me (get current user), list (list issues), view (view issue details), " +
113 "search (JQL search), create (create issue), update (update field), " +
114 "comment (add comment), transition (change state). " +
115 "Use 'me' as assignee value to refer to current user. " +
116 "Write operations (create, update, comment, transition, link, unlink, attach) require user approval. " +
117 "IMPORTANT: Call write operations ONE AT A TIME, never in parallel — parallel approval dialogs deadlock the UI.",
118
119 parameters: Type.Object({
120 action: StringEnum([
121 "me",
122 "list",
123 "view",
124 "search",
125 "create",
126 "update",
127 "comment",
128 "transition",
129 "epic-view",
130 "link-to-epic",
131 "link",
132 "unlink",
133 "attach",
134 "list-attachments",
135 ] as const),
136
137 // List/Search parameters
138 assignee: Type.Optional(
139 Type.String({
140 description: "Filter by assignee (username or 'me' for current user)",
141 }),
142 ),
143 status: Type.Optional(
144 Type.String({
145 description: "Filter by status (comma-separated, or '~Done' for not Done)",
146 }),
147 ),
148 type: Type.Optional(
149 Type.String({
150 description: "Filter by type: Bug, Task, Story, Epic, Feature (comma-separated)",
151 }),
152 ),
153 priority: Type.Optional(
154 Type.String({
155 description: "Filter by priority: Blocker, Critical, Major, Minor, Trivial (comma-separated)",
156 }),
157 ),
158 limit: Type.Optional(
159 Type.Number({
160 description: "Maximum number of results (default 20 for list, 50 for search)",
161 }),
162 ),
163 epic: Type.Optional(
164 Type.String({
165 description: "Filter by epic key (e.g., SRVKP-1234)",
166 }),
167 ),
168
169 // View parameter
170 key: Type.Optional(
171 Type.String({
172 description: "Issue key (e.g., SRVKP-1234)",
173 }),
174 ),
175
176 // Search parameter
177 jql: Type.Optional(
178 Type.String({
179 description: "JQL query for advanced search",
180 }),
181 ),
182
183 // Create parameters
184 issueType: Type.Optional(
185 Type.String({
186 description: "Issue type: Bug, Task, Story, Epic, Feature, Sub-task",
187 }),
188 ),
189 summary: Type.Optional(
190 Type.String({
191 description: "Issue title/summary",
192 }),
193 ),
194 description: Type.Optional(
195 Type.String({
196 description: "Issue description",
197 }),
198 ),
199 labels: Type.Optional(
200 Type.Array(Type.String(), {
201 description: "Issue labels",
202 }),
203 ),
204 parent: Type.Optional(
205 Type.String({
206 description: "Parent issue key for sub-tasks",
207 }),
208 ),
209
210 // Update parameters
211 field: Type.Optional(
212 Type.String({
213 description: "Field to update: assignee, priority, labels, summary, description",
214 }),
215 ),
216 value: Type.Optional(
217 Type.String({
218 description: "New value for the field",
219 }),
220 ),
221
222 // Comment parameter
223 comment: Type.Optional(
224 Type.String({
225 description: "Comment text",
226 }),
227 ),
228
229 // Transition parameter
230 state: Type.Optional(
231 Type.String({
232 description: "New state: To Do, In Progress, Code Review, QE Review, Done, Blocked, etc.",
233 }),
234 ),
235
236 // Epic parameters
237 issue: Type.Optional(
238 Type.String({
239 description: "Issue key to link to epic",
240 }),
241 ),
242
243 // Link parameters
244 from: Type.Optional(
245 Type.String({
246 description: "Source issue key for linking",
247 }),
248 ),
249 to: Type.Optional(
250 Type.String({
251 description: "Target issue key for linking",
252 }),
253 ),
254 linkType: Type.Optional(
255 Type.String({
256 description: "Link type name (case-insensitive): Blocks, Related, Duplicate, Cloners, Depend, Causality, Document, Incorporates, Informs, Triggers",
257 }),
258 ),
259
260 // Attachment parameter
261 file: Type.Optional(
262 Type.String({
263 description: "File path to attach",
264 }),
265 ),
266 }),
267
268 async execute(toolCallId, params, signal, onUpdate, ctx) {
269 try {
270 // Route to appropriate handler
271 switch (params.action) {
272 case "me":
273 return await handleMe(pi, params, signal, onUpdate, ctx);
274
275 case "list":
276 return await handleList(pi, params, signal, onUpdate, ctx, currentUser);
277
278 case "view":
279 return await handleView(pi, params, signal, onUpdate, ctx);
280
281 case "search":
282 return await handleSearch(pi, params, signal, onUpdate, ctx);
283
284 case "create":
285 return await handleCreate(pi, params, signal, onUpdate, ctx, currentUser);
286
287 case "update":
288 return await handleUpdate(pi, params, signal, onUpdate, ctx, currentUser);
289
290 case "comment":
291 return await handleComment(pi, params, signal, onUpdate, ctx);
292
293 case "transition":
294 return await handleTransition(pi, params, signal, onUpdate, ctx);
295
296 case "epic-view":
297 return await handleEpicView(pi, params, signal, onUpdate, ctx);
298
299 case "link-to-epic":
300 return await handleLinkToEpic(pi, params, signal, onUpdate, ctx);
301
302 case "link":
303 return await handleLink(pi, params, signal, onUpdate, ctx);
304
305 case "unlink":
306 return await handleUnlink(pi, params, signal, onUpdate, ctx);
307
308 case "attach":
309 return await handleAttach(pi, params, signal, onUpdate, ctx);
310
311 case "list-attachments":
312 return await handleListAttachments(pi, params, signal, onUpdate, ctx);
313
314 default:
315 return {
316 content: [{ type: "text", text: `Unknown action: ${params.action}` }],
317 details: { action: params.action, error: "unknown_action" } as JiraDetails,
318 isError: true,
319 };
320 }
321 } catch (error) {
322 return {
323 content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
324 details: { action: params.action, error: String(error) } as JiraDetails,
325 isError: true,
326 };
327 }
328 },
329
330 // ====================================================================
331 // Custom Rendering
332 // ====================================================================
333
334 renderCall(args, theme) {
335 let text = theme.fg("toolTitle", theme.bold("jira "));
336 text += theme.fg("muted", args.action);
337
338 if (args.key) {
339 text += " " + theme.fg("accent", args.key);
340 }
341
342 if (args.summary) {
343 text += " " + theme.fg("dim", `"${truncate(args.summary, 50)}"`);
344 }
345
346 if (args.jql) {
347 text += " " + theme.fg("dim", `"${truncate(args.jql, 50)}"`);
348 }
349
350 return new Text(text, 0, 0);
351 },
352
353 renderResult(result, { expanded }, theme) {
354 const details = result.details as JiraDetails | undefined;
355
356 if (!details) {
357 const text = result.content[0];
358 return new Text(text?.type === "text" ? text.text : "", 0, 0);
359 }
360
361 if (details.error) {
362 return new Text(theme.fg("error", `✗ Error: ${details.error}`), 0, 0);
363 }
364
365 if (details.cancelled) {
366 return new Text(theme.fg("warning", "✗ Cancelled by user"), 0, 0);
367 }
368
369 switch (details.action) {
370 case "me":
371 return renderMe(details, theme);
372
373 case "list":
374 case "search":
375 return renderList(details, expanded, theme);
376
377 case "view":
378 return renderView(details, expanded, theme);
379
380 case "create":
381 return renderCreate(details, theme);
382
383 case "update":
384 return renderUpdate(details, theme);
385
386 case "comment":
387 return renderComment(details, theme);
388
389 case "transition":
390 return renderTransition(details, theme);
391
392 case "epic-view":
393 return renderEpicView(details, expanded, theme);
394
395 case "link-to-epic":
396 return renderLinkToEpic(details, theme);
397
398 case "link":
399 return renderLink(details, theme);
400
401 case "unlink":
402 return renderUnlink(details, theme);
403
404 case "attach":
405 return renderAttach(details, theme);
406
407 case "list-attachments":
408 return renderListAttachments(details, expanded, theme);
409
410 default:
411 return new Text(details.output || "", 0, 0);
412 }
413 },
414 });
415
416 // ========================================================================
417 // Slash Commands
418 // ========================================================================
419
420 // /jira - Show my open issues
421 pi.registerCommand("jira", {
422 description: "Show my open Jira issues in SRVKP project",
423 handler: async (_args, ctx) => {
424 if (!ctx.hasUI) {
425 ctx.ui.notify("/jira requires interactive mode", "error");
426 return;
427 }
428
429 // Get current user if not cached
430 if (!currentUser) {
431 const meResult = await pi.exec("jira", ["me"], { timeout: 10000 });
432 if (meResult.code === 0) {
433 currentUser = meResult.stdout.trim();
434 }
435 }
436
437 // List my open issues directly (default to SRVKP project)
438 const args = [
439 "issue",
440 "list",
441 "--raw",
442 "--project",
443 "SRVKP",
444 "-a",
445 currentUser || "currentUser()",
446 "-s",
447 "~Done",
448 "--paginate",
449 "20",
450 ];
451
452 const result = await pi.exec("jira", args, { timeout: 30000 });
453
454 if (result.code !== 0) {
455 ctx.ui.notify(`Error: ${result.stderr}`, "error");
456 return;
457 }
458
459 const issues = parseIssueListJSON(result.stdout);
460
461 // Track recent issues for auto-completion
462 for (const issue of issues) {
463 if (!recentIssues.find((i) => i.key === issue.key)) {
464 recentIssues.push({ key: issue.key, summary: issue.summary });
465 }
466 }
467 // Keep only last 20
468 if (recentIssues.length > 20) {
469 recentIssues.splice(0, recentIssues.length - 20);
470 }
471
472 // Display results directly (like org-todos)
473 const lines: string[] = [];
474 lines.push("## 📋 My Open Issues (SRVKP)");
475 lines.push("");
476 lines.push(`_Command: \`jira ${args.join(" ")}\`_`);
477 lines.push("");
478
479 if (issues.length === 0) {
480 lines.push("*No open issues* ✨");
481 } else {
482 // Table format for better alignment
483 lines.push("| Key | Type | Priority | Status | Summary |");
484 lines.push("|-----|------|----------|--------|---------|");
485 for (const issue of issues) {
486 const priority = issue.priority || "-";
487 const summary = truncate(issue.summary, 80);
488 lines.push(`| ${issue.key} | ${issue.type} | ${priority} | ${issue.status} | ${summary} |`);
489 }
490 }
491
492 pi.sendMessage({
493 customType: "jira-list",
494 content: lines.join("\n"),
495 display: true,
496 });
497 },
498 });
499
500 // /jira-view <key> - View specific issue
501 pi.registerCommand("jira-view", {
502 description: "View a Jira issue (e.g., /jira-view SRVKP-1234)",
503 getArgumentCompletions: (prefix: string) => {
504 // Suggest recent issues from session
505 if (recentIssues.length === 0) return null;
506
507 const normalizedPrefix = prefix.trim().toUpperCase();
508
509 const items = recentIssues.map((issue) => ({
510 value: issue.key,
511 label: `${issue.key} - ${truncate(issue.summary, 60)}`,
512 }));
513
514 // If no prefix, show all. Otherwise filter by prefix.
515 if (!normalizedPrefix) {
516 return items;
517 }
518
519 const filtered = items.filter((i) => i.value.startsWith(normalizedPrefix));
520 return filtered.length > 0 ? filtered : null;
521 },
522 handler: async (args, ctx) => {
523 if (!args) {
524 ctx.ui.notify("Usage: /jira-view ISSUE-KEY", "error");
525 return;
526 }
527
528 const issueKey = args.trim();
529
530 // Validate issue key format (at least 2 uppercase letters, dash, numbers)
531 if (!/^[A-Z]{2,}-\d+$/.test(issueKey)) {
532 ctx.ui.notify(`Invalid issue key format: ${issueKey}`, "error");
533 return;
534 }
535
536 // View issue directly
537 const result = await pi.exec("jira", ["issue", "view", issueKey, "--plain"], { timeout: 20000 });
538
539 if (result.code !== 0) {
540 ctx.ui.notify(`Error: ${result.stderr}`, "error");
541 return;
542 }
543
544 // Track for auto-completion
545 // Extract summary from output first
546 let summary = "";
547 const summaryMatch = result.stdout.match(/^Summary:\s*(.+)$/m);
548 if (summaryMatch && summaryMatch[1]) {
549 summary = summaryMatch[1].trim();
550 }
551
552 if (!recentIssues.find((i) => i.key === issueKey)) {
553 recentIssues.push({ key: issueKey, summary: summary || issueKey });
554 }
555 // Keep only last 20
556 if (recentIssues.length > 20) {
557 recentIssues.splice(0, recentIssues.length - 20);
558 }
559
560 // Build heading with summary
561 const title = summary ? `${issueKey} - ${summary}` : issueKey;
562
563 // Display result directly
564 pi.sendMessage({
565 customType: "jira-view",
566 content: `## 🔍 ${title}\n\n_Command: \`jira issue view ${issueKey} --plain\`_\n\n${result.stdout}`,
567 display: true,
568 });
569 },
570 });
571
572 // /jira-search <query> - Search with JQL
573 pi.registerCommand("jira-search", {
574 description: "Search Jira with JQL (defaults to SRVKP project)",
575 getArgumentCompletions: (prefix: string) => {
576 // DON'T trim - we need to know if there's a trailing space
577 const hasTrailingSpace = prefix.endsWith(" ");
578 const trimmed = prefix.trim();
579
580 // Get the words
581 const words = trimmed.split(/\s+/).filter((w) => w.length > 0);
582 const lastWord = hasTrailingSpace ? "" : (words[words.length - 1] || "");
583 const previousWord = words.length >= 2 ? words[words.length - 2] : "";
584 const beforeLastWord = lastWord ? trimmed.substring(0, trimmed.length - lastWord.length) : trimmed + " ";
585
586 // Field completions (start of query or after AND/OR)
587 const fields = [
588 { value: "status=", label: "status= - Issue status" },
589 { value: "assignee=", label: "assignee= - Assigned to" },
590 { value: "priority=", label: "priority= - Priority level" },
591 { value: "type=", label: "type= - Issue type" },
592 { value: "created", label: "created - Creation date" },
593 { value: "updated", label: "updated - Last update" },
594 { value: "labels=", label: "labels= - Issue labels" },
595 { value: '"Epic Link"=', label: '"Epic Link"= - Epic parent' },
596 { value: "reporter=", label: "reporter= - Who reported" },
597 ];
598
599 // Status values
600 const statuses = [
601 { value: "status=Blocked", label: "Blocked" },
602 { value: 'status="Code Review"', label: "Code Review" },
603 { value: 'status="In Progress"', label: "In Progress" },
604 { value: 'status="To Do"', label: "To Do" },
605 { value: "status=Done", label: "Done" },
606 { value: "status=Waiting", label: "Waiting" },
607 ];
608
609 // Assignee values
610 const assignees = [
611 { value: "assignee=currentUser()", label: "Current user (me)" },
612 { value: "assignee=EMPTY", label: "Unassigned" },
613 ];
614
615 // Priority values
616 const priorities = [
617 { value: "priority=Blocker", label: "Blocker" },
618 { value: "priority=Critical", label: "Critical" },
619 { value: "priority=Major", label: "Major" },
620 { value: "priority IN (Blocker,Critical)", label: "High priority (Blocker,Critical)" },
621 ];
622
623 // Type values
624 const types = [
625 { value: "type=Bug", label: "Bug" },
626 { value: "type=Epic", label: "Epic" },
627 { value: "type=Task", label: "Task" },
628 { value: "type=Story", label: "Story" },
629 { value: 'type IN (Bug,Task)', label: "Bugs and tasks" },
630 ];
631
632 // Time shortcuts
633 const times = [
634 { value: "created >= -7d", label: "Created in last 7 days" },
635 { value: "created >= -30d", label: "Created in last 30 days" },
636 { value: "updated >= -7d", label: "Updated in last 7 days" },
637 { value: "updated <= -30d", label: "Stale (30+ days)" },
638 ];
639
640 // Logical operators
641 const operators = [
642 { value: "AND ", label: "AND - Both conditions" },
643 { value: "OR ", label: "OR - Either condition" },
644 ];
645
646 // Combine all completions
647 let completions: AutocompleteItem[] = [];
648
649 // Context-aware suggestions
650 if (lastWord.toLowerCase().startsWith("status")) {
651 // Typing "status..." → show status values
652 completions = statuses.map((s) => ({
653 value: beforeLastWord + s.value,
654 label: s.label,
655 }));
656 } else if (lastWord.toLowerCase().startsWith("assignee")) {
657 // Typing "assignee..." → show assignee values
658 completions = assignees.map((a) => ({
659 value: beforeLastWord + a.value,
660 label: a.label,
661 }));
662 } else if (lastWord.toLowerCase().startsWith("priority")) {
663 // Typing "priority..." → show priority values
664 completions = priorities.map((p) => ({
665 value: beforeLastWord + p.value,
666 label: p.label,
667 }));
668 } else if (lastWord.toLowerCase().startsWith("type")) {
669 // Typing "type..." → show type values
670 completions = types.map((t) => ({
671 value: beforeLastWord + t.value,
672 label: t.label,
673 }));
674 } else if (lastWord.toLowerCase().startsWith("created") || lastWord.toLowerCase().startsWith("updated")) {
675 // Typing "created..." or "updated..." → show time values
676 completions = times
677 .filter((t) => t.value.toLowerCase().startsWith(lastWord.toLowerCase()))
678 .map((t) => ({
679 value: beforeLastWord + t.value,
680 label: t.label,
681 }));
682 } else if (hasTrailingSpace && (previousWord === "AND" || previousWord === "OR")) {
683 // Just typed "AND " or "OR " → suggest fields
684 completions = fields.map((f) => ({
685 value: trimmed + " " + f.value,
686 label: f.label,
687 }));
688 } else if (hasTrailingSpace || lastWord.length === 0) {
689 // Ends with space but not after AND/OR → suggest operators
690 completions = operators.map((o) => ({
691 value: trimmed + " " + o.value,
692 label: o.label,
693 }));
694 } else if (trimmed.length > 0) {
695 // In the middle of typing something → suggest operators
696 completions = operators.map((o) => ({
697 value: trimmed + " " + o.value,
698 label: o.label,
699 }));
700 } else {
701 // Empty or start of query → suggest fields
702 completions = fields.map((f) => ({
703 value: f.value,
704 label: f.label,
705 }));
706 }
707
708 // Filter by what user has typed (only if lastWord is not empty to avoid matching everything)
709 if (lastWord.length > 0) {
710 const filtered = completions.filter(
711 (c) =>
712 c.value.toLowerCase().includes(lastWord.toLowerCase()) ||
713 c.label.toLowerCase().includes(lastWord.toLowerCase()),
714 );
715 return filtered.length > 0 ? filtered : null;
716 }
717
718 return completions.length > 0 ? completions : null;
719 },
720 handler: async (args, ctx) => {
721 if (!args) {
722 ctx.ui.notify("Usage: /jira-search <JQL query>", "error");
723 return;
724 }
725
726 let jql = args.trim();
727
728 // Default to SRVKP project if no project specified in JQL
729 if (!jql.toLowerCase().includes("project")) {
730 jql = `project = SRVKP AND ${jql}`;
731 }
732
733 // Search directly
734 const result = await pi.exec("jira", ["issue", "list", "--raw", "--jql", jql, "--paginate", "50"], {
735 timeout: 30000,
736 });
737
738 if (result.code !== 0) {
739 ctx.ui.notify(`Error: ${result.stderr}`, "error");
740 return;
741 }
742
743 const issues = parseIssueListJSON(result.stdout);
744
745 // Track recent issues for auto-completion
746 for (const issue of issues) {
747 if (!recentIssues.find((i) => i.key === issue.key)) {
748 recentIssues.push({ key: issue.key, summary: issue.summary });
749 }
750 }
751 // Keep only last 20
752 if (recentIssues.length > 20) {
753 recentIssues.splice(0, recentIssues.length - 20);
754 }
755
756 // Display results directly
757 const lines: string[] = [];
758 lines.push(`## 🔎 Search Results`);
759 lines.push(`Query: \`${jql}\``);
760 lines.push("");
761
762 if (issues.length === 0) {
763 lines.push("*No issues found*");
764 } else {
765 // Table format
766 lines.push("| Key | Type | Priority | Status | Summary |");
767 lines.push("|-----|------|----------|--------|---------|");
768 for (const issue of issues) {
769 const priority = issue.priority || "-";
770 const summary = truncate(issue.summary, 80);
771 lines.push(`| ${issue.key} | ${issue.type} | ${priority} | ${issue.status} | ${summary} |`);
772 }
773 }
774
775 pi.sendMessage({
776 customType: "jira-search",
777 content: lines.join("\n"),
778 display: true,
779 });
780 },
781 });
782
783 // /jira-mine - My issues (shorthand)
784 pi.registerCommand("jira-mine", {
785 description: "Show all issues assigned to me in SRVKP",
786 handler: async (_args, ctx) => {
787 // Get current user if not cached
788 if (!currentUser) {
789 const meResult = await pi.exec("jira", ["me"], { timeout: 10000 });
790 if (meResult.code === 0) {
791 currentUser = meResult.stdout.trim();
792 }
793 }
794
795 // List all my issues directly (default to SRVKP)
796 const args = [
797 "issue",
798 "list",
799 "--raw",
800 "--project",
801 "SRVKP",
802 "-a",
803 currentUser || "currentUser()",
804 "--paginate",
805 "50",
806 ];
807
808 const result = await pi.exec("jira", args, { timeout: 30000 });
809
810 if (result.code !== 0) {
811 ctx.ui.notify(`Error: ${result.stderr}`, "error");
812 return;
813 }
814
815 const issues = parseIssueListJSON(result.stdout);
816
817 // Track recent issues for auto-completion
818 for (const issue of issues) {
819 if (!recentIssues.find((i) => i.key === issue.key)) {
820 recentIssues.push({ key: issue.key, summary: issue.summary });
821 }
822 }
823 // Keep only last 20
824 if (recentIssues.length > 20) {
825 recentIssues.splice(0, recentIssues.length - 20);
826 }
827
828 // Display results directly
829 const lines: string[] = [];
830 lines.push("## 👤 My Issues (SRVKP)");
831 lines.push("");
832 lines.push(`_Command: \`jira ${args.join(" ")}\`_`);
833 lines.push("");
834
835 if (issues.length === 0) {
836 lines.push("*No issues assigned to you*");
837 } else {
838 // Group by status
839 const byStatus = new Map<string, typeof issues>();
840 for (const issue of issues) {
841 const status = issue.status;
842 if (!byStatus.has(status)) {
843 byStatus.set(status, []);
844 }
845 byStatus.get(status)!.push(issue);
846 }
847
848 for (const [status, statusIssues] of byStatus) {
849 lines.push(`### ${status} (${statusIssues.length})`);
850 lines.push("");
851 lines.push("| Key | Type | Priority | Summary |");
852 lines.push("|-----|------|----------|---------|");
853 for (const issue of statusIssues) {
854 const priority = issue.priority || "-";
855 const summary = truncate(issue.summary, 80);
856 lines.push(`| ${issue.key} | ${issue.type} | ${priority} | ${summary} |`);
857 }
858 lines.push("");
859 }
860 }
861
862 pi.sendMessage({
863 customType: "jira-mine",
864 content: lines.join("\n"),
865 display: true,
866 });
867 },
868 });
869
870 // /jira-blocked - Blocked issues
871 pi.registerCommand("jira-blocked", {
872 description: "Show blocked issues in SRVKP project",
873 handler: async (_args, ctx) => {
874 // Search for blocked issues using JQL
875 const jql = 'project = SRVKP AND status IN (Blocked, Waiting)';
876 const args = ["issue", "list", "--raw", "--jql", jql, "--paginate", "50"];
877
878 const result = await pi.exec("jira", args, { timeout: 30000 });
879
880 if (result.code !== 0) {
881 ctx.ui.notify(`Error: ${result.stderr}`, "error");
882 return;
883 }
884
885 const issues = parseIssueListJSON(result.stdout);
886
887 // Track recent issues for auto-completion
888 for (const issue of issues) {
889 if (!recentIssues.find((i) => i.key === issue.key)) {
890 recentIssues.push({ key: issue.key, summary: issue.summary });
891 }
892 }
893 // Keep only last 20
894 if (recentIssues.length > 20) {
895 recentIssues.splice(0, recentIssues.length - 20);
896 }
897
898 // Display results directly
899 const lines: string[] = [];
900 lines.push("## 🚫 Blocked Issues (SRVKP)");
901 lines.push("");
902 lines.push(`_Command: \`jira ${args.join(" ")}\`_`);
903 lines.push("");
904
905 if (issues.length === 0) {
906 lines.push("*No blocked issues* ✅");
907 } else {
908 // Table format
909 lines.push("| Key | Type | Priority | Status | Assignee | Summary |");
910 lines.push("|-----|------|----------|--------|----------|---------|");
911 for (const issue of issues) {
912 const priority = issue.priority || "-";
913 const assignee = issue.assignee || "Unassigned";
914 const summary = truncate(issue.summary, 60);
915 lines.push(`| ${issue.key} | ${issue.type} | ${priority} | ${issue.status} | ${assignee} | ${summary} |`);
916 }
917 }
918
919 pi.sendMessage({
920 customType: "jira-blocked",
921 content: lines.join("\n"),
922 display: true,
923 });
924 },
925 });
926
927 // /jira-recent - Recent issues from session
928 pi.registerCommand("jira-recent", {
929 description: "Show recently viewed issues from this session",
930 handler: async (_args, ctx) => {
931 if (!ctx.hasUI) {
932 ctx.ui.notify("/jira-recent requires interactive mode", "error");
933 return;
934 }
935
936 if (recentIssues.length === 0) {
937 ctx.ui.notify("No recent issues in this session", "info");
938 return;
939 }
940
941 const lines = ["## 🕒 Recent Issues"];
942 lines.push("");
943 lines.push("| # | Key | Summary |");
944 lines.push("|---|-----|---------|");
945
946 recentIssues.forEach((issue, i) => {
947 const summary = truncate(issue.summary, 80);
948 lines.push(`| ${i + 1} | ${issue.key} | ${summary} |`);
949 });
950
951 pi.sendMessage({
952 customType: "jira-recent",
953 content: lines.join("\n"),
954 display: true,
955 });
956 },
957 });
958
959 // ========================================================================
960 // Auto-detect Jira Issue Keys in User Input
961 // ========================================================================
962
963 pi.on("input", async (event, ctx) => {
964 // Only process interactive input
965 if (event.source !== "interactive") {
966 return { action: "continue" };
967 }
968
969 // Detect Jira issue keys (e.g., SRVKP-1234, KONFLUX-456, etc.)
970 const issueKeyPattern = /\b([A-Z]{2,}-\d+)\b/g;
971 const matches = event.text.match(issueKeyPattern);
972
973 if (!matches || matches.length === 0) {
974 return { action: "continue" };
975 }
976
977 // Remove duplicates
978 const uniqueKeys = [...new Set(matches)];
979
980 // If user just typed issue keys without context, offer to view them
981 const justKeys = event.text.trim().split(/\s+/).every((word) => /^[A-Z]{2,}-\d+$/.test(word));
982
983 if (justKeys && uniqueKeys.length <= 3 && ctx.hasUI) {
984 // Transform to view request
985 if (uniqueKeys.length === 1) {
986 return {
987 action: "transform",
988 text: `View Jira issue ${uniqueKeys[0]}`,
989 };
990 } else {
991 return {
992 action: "transform",
993 text: `View Jira issues: ${uniqueKeys.join(", ")}`,
994 };
995 }
996 }
997
998 // Otherwise, just continue (LLM will see the keys in context)
999 return { action: "continue" };
1000 });
1001}
1002
1003// ============================================================================
1004// Autocomplete: Jira Issues
1005// ============================================================================
1006
1007type JiraIssueItem = {
1008 key: string;
1009 summary: string;
1010 status: string;
1011};
1012
1013const JIRA_MAX_SUGGESTIONS = 20;
1014
1015/**
1016 * Extract a Jira trigger token from text before cursor.
1017 * Matches:
1018 * - `j:query` — explicit Jira search (preceded by whitespace or start)
1019 * - `PROJ-123` — uppercase project key pattern (preceded by whitespace or start)
1020 */
1021function extractJiraToken(textBeforeCursor: string): { trigger: "j:" | "key"; query: string } | undefined {
1022 // j: trigger
1023 const jMatch = textBeforeCursor.match(/(?:^|[ \t])j:([^\s]*)$/);
1024 if (jMatch) return { trigger: "j:", query: jMatch[1] };
1025
1026 // PROJ- pattern (2+ uppercase letters followed by dash and optional digits)
1027 const keyMatch = textBeforeCursor.match(/(?:^|[ \t])([A-Z][A-Z]+-\d*)$/);
1028 if (keyMatch) return { trigger: "key", query: keyMatch[1] };
1029
1030 return undefined;
1031}
1032
1033function formatJiraItem(issue: JiraIssueItem): AutocompleteItem {
1034 return {
1035 value: issue.key,
1036 label: issue.key,
1037 description: `[${issue.status}] ${issue.summary}`,
1038 };
1039}
1040
1041function filterJiraItems(items: JiraIssueItem[], query: string, trigger: "j:" | "key"): AutocompleteItem[] {
1042 if (trigger === "key") {
1043 // For PROJ-123 pattern, filter by key prefix
1044 const upper = query.toUpperCase();
1045 const matches = items
1046 .filter((item) => item.key.startsWith(upper))
1047 .slice(0, JIRA_MAX_SUGGESTIONS)
1048 .map(formatJiraItem);
1049 if (matches.length > 0) return matches;
1050
1051 // Fall back to fuzzy
1052 return fuzzyFilter(items, query, (item) => `${item.key} ${item.summary}`)
1053 .slice(0, JIRA_MAX_SUGGESTIONS)
1054 .map(formatJiraItem);
1055 }
1056
1057 // j: trigger — fuzzy search across key and summary
1058 if (!query.trim()) {
1059 return items.slice(0, JIRA_MAX_SUGGESTIONS).map(formatJiraItem);
1060 }
1061
1062 return fuzzyFilter(items, query, (item) => `${item.key} ${item.summary}`)
1063 .slice(0, JIRA_MAX_SUGGESTIONS)
1064 .map(formatJiraItem);
1065}
1066
1067function createJiraAutocompleteProvider(
1068 current: AutocompleteProvider,
1069 getItems: () => Promise<JiraIssueItem[] | undefined>,
1070): AutocompleteProvider {
1071 return {
1072 async getSuggestions(lines, cursorLine, cursorCol, options): Promise<AutocompleteSuggestions | null> {
1073 const currentLine = lines[cursorLine] ?? "";
1074 const textBeforeCursor = currentLine.slice(0, cursorCol);
1075 const token = extractJiraToken(textBeforeCursor);
1076
1077 if (!token) {
1078 return current.getSuggestions(lines, cursorLine, cursorCol, options);
1079 }
1080
1081 const items = await getItems();
1082 if (options.signal.aborted || !items || items.length === 0) {
1083 return current.getSuggestions(lines, cursorLine, cursorCol, options);
1084 }
1085
1086 const suggestions = filterJiraItems(items, token.query, token.trigger);
1087 if (suggestions.length === 0) {
1088 return current.getSuggestions(lines, cursorLine, cursorCol, options);
1089 }
1090
1091 const prefix = token.trigger === "j:" ? `j:${token.query}` : token.query;
1092 return { items: suggestions, prefix };
1093 },
1094
1095 applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
1096 // Handle j: and PROJ- completions ourselves
1097 if (prefix.startsWith("j:") || /^[A-Z]{2,}-/.test(prefix)) {
1098 const currentLine = lines[cursorLine] || "";
1099 const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
1100 const afterCursor = currentLine.slice(cursorCol);
1101 const newLine = beforePrefix + item.value + " " + afterCursor;
1102 const newLines = [...lines];
1103 newLines[cursorLine] = newLine;
1104 return {
1105 lines: newLines,
1106 cursorLine,
1107 cursorCol: beforePrefix.length + item.value.length + 1,
1108 };
1109 }
1110 return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
1111 },
1112
1113 shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
1114 return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
1115 },
1116 };
1117}
1118
1119function setupJiraAutocomplete(pi: ExtensionAPI, ctx: ExtensionContext): void {
1120 let itemsPromise: Promise<JiraIssueItem[] | undefined> | undefined;
1121
1122 const getItems = async (): Promise<JiraIssueItem[] | undefined> => {
1123 itemsPromise ||= (async () => {
1124 // Fetch assigned + recently viewed issues via JQL
1125 // Note: -a currentUser() doesn't work on all Jira instances,
1126 // and issueHistory() doesn't support ORDER BY — use explicit JQL.
1127 const [assignedResult, recentResult] = await Promise.all([
1128 pi.exec("jira", [
1129 "issue", "list", "--raw",
1130 "--jql", "assignee = currentUser() AND status != Done",
1131 "--paginate", "50",
1132 ], { timeout: 15000 }),
1133 pi.exec("jira", [
1134 "issue", "list", "--raw",
1135 "--jql", "issue in issueHistory()",
1136 "--paginate", "50",
1137 ], { timeout: 15000 }),
1138 ]);
1139
1140 const seen = new Set<string>();
1141 const items: JiraIssueItem[] = [];
1142
1143 for (const result of [assignedResult, recentResult]) {
1144 if (result.code !== 0) continue;
1145 const issues = parseIssueListJSON(result.stdout);
1146 for (const issue of issues) {
1147 if (seen.has(issue.key)) continue;
1148 seen.add(issue.key);
1149 items.push({ key: issue.key, summary: issue.summary, status: issue.status });
1150 }
1151 }
1152
1153 if (items.length === 0) return undefined;
1154 return items;
1155 })();
1156 return itemsPromise;
1157 };
1158
1159 // Preload in background
1160 void getItems();
1161 ctx.ui.addAutocompleteProvider((current) => createJiraAutocompleteProvider(current, getItems));
1162}
1163
1164// ============================================================================
1165// Rendering Functions
1166// ============================================================================
1167
1168function renderMe(details: JiraDetails, theme: Theme): Text {
1169 return new Text(theme.fg("muted", `User: ${theme.fg("accent", details.output || "")}`), 0, 0);
1170}
1171
1172function renderList(details: JiraDetails, expanded: boolean, theme: Theme): Text {
1173 // Use structured issues from details if available, otherwise fall back to output text
1174 const issues = details.issues || [];
1175
1176 if (issues.length === 0) {
1177 if (details.output && details.output !== "No issues found") {
1178 // Fallback: show raw output
1179 return new Text(details.output, 0, 0);
1180 }
1181 return new Text(theme.fg("dim", "No issues found"), 0, 0);
1182 }
1183
1184 let text = theme.fg("muted", `${issues.length} issue(s):`);
1185
1186 const display = expanded ? issues : issues.slice(0, 5);
1187
1188 for (const issue of display) {
1189 const key = theme.fg("accent", issue.key);
1190 const status = getStatusColor(issue.status, theme);
1191 const priority = issue.priority ? getPriorityColor(issue.priority, theme) : "";
1192 const summary = expanded ? issue.summary : truncate(issue.summary, 60);
1193
1194 text += `\n${key} ${status}`;
1195 if (priority) {
1196 text += ` ${priority}`;
1197 }
1198 text += ` ${theme.fg("text", summary)}`;
1199 }
1200
1201 if (!expanded && issues.length > 5) {
1202 text += `\n${theme.fg("dim", `... ${issues.length - 5} more (expand for all)`)}`;
1203 }
1204
1205 return new Text(text, 0, 0);
1206}
1207
1208function renderView(details: JiraDetails, expanded: boolean, theme: Theme): Text {
1209 if (!details.output) {
1210 return new Text(theme.fg("dim", "No issue details"), 0, 0);
1211 }
1212
1213 const issueKey = details.issueKey || "Issue";
1214 const header = theme.fg("accent", theme.bold(issueKey));
1215
1216 if (expanded) {
1217 // Show full output
1218 return new Text(`${header}\n\n${details.output}`, 0, 0);
1219 } else {
1220 // Show summary (first 15 lines)
1221 const lines = details.output.split("\n");
1222 const preview = lines.slice(0, 15).join("\n");
1223
1224 let text = `${header}\n\n${preview}`;
1225
1226 if (lines.length > 15) {
1227 text += `\n${theme.fg("dim", `... ${lines.length - 15} more lines (expand for full view)`)}`;
1228 }
1229
1230 return new Text(text, 0, 0);
1231 }
1232}
1233
1234function renderCreate(details: JiraDetails, theme: Theme): Text {
1235 const key = details.issueKey || "issue";
1236 return new Text(theme.fg("success", "✓ Created ") + theme.fg("accent", theme.bold(key)), 0, 0);
1237}
1238
1239function renderUpdate(details: JiraDetails, theme: Theme): Text {
1240 const key = details.issueKey || "issue";
1241 const field = details.field || "field";
1242 const value = details.newValue || "value";
1243
1244 return new Text(
1245 theme.fg("success", "✓ Updated ") + theme.fg("accent", key) + theme.fg("muted", ` (${field} → ${value})`),
1246 0,
1247 0,
1248 );
1249}
1250
1251function renderComment(details: JiraDetails, theme: Theme): Text {
1252 const key = details.issueKey || "issue";
1253 return new Text(theme.fg("success", "✓ Comment added to ") + theme.fg("accent", key), 0, 0);
1254}
1255
1256function renderTransition(details: JiraDetails, theme: Theme): Text {
1257 const key = details.issueKey || "issue";
1258 const state = details.toKey || "new state";
1259
1260 return new Text(theme.fg("success", "✓ Moved ") + theme.fg("accent", key) + theme.fg("muted", ` → ${state}`), 0, 0);
1261}
1262
1263function renderEpicView(details: JiraDetails, expanded: boolean, theme: Theme): Text {
1264 if (!details.output) {
1265 return new Text(theme.fg("dim", "No epic details"), 0, 0);
1266 }
1267
1268 const issueKey = details.issueKey || "Epic";
1269 const header = theme.fg("accent", theme.bold(`Epic: ${issueKey}`));
1270
1271 if (expanded) {
1272 return new Text(`${header}\n\n${details.output}`, 0, 0);
1273 } else {
1274 // Show summary (first 20 lines)
1275 const lines = details.output.split("\n");
1276 const preview = lines.slice(0, 20).join("\n");
1277
1278 let text = `${header}\n\n${preview}`;
1279
1280 if (lines.length > 20) {
1281 text += `\n${theme.fg("dim", `... ${lines.length - 20} more lines (expand for full view)`)}`;
1282 }
1283
1284 return new Text(text, 0, 0);
1285 }
1286}
1287
1288function renderLinkToEpic(details: JiraDetails, theme: Theme): Text {
1289 const issue = details.fromKey || "issue";
1290 const epic = details.toKey || "epic";
1291
1292 return new Text(theme.fg("success", "✓ Linked ") + theme.fg("accent", issue) + theme.fg("muted", ` → epic ${epic}`), 0, 0);
1293}
1294
1295function renderLink(details: JiraDetails, theme: Theme): Text {
1296 const from = details.fromKey || "issue";
1297 const to = details.toKey || "issue";
1298
1299 return new Text(theme.fg("success", "✓ Linked ") + theme.fg("accent", from) + theme.fg("muted", ` ↔ ${to}`), 0, 0);
1300}
1301
1302function renderUnlink(details: JiraDetails, theme: Theme): Text {
1303 const from = details.fromKey || "issue";
1304 const to = details.toKey || "issue";
1305
1306 return new Text(theme.fg("success", "✓ Unlinked ") + theme.fg("accent", from) + theme.fg("muted", ` ↮ ${to}`), 0, 0);
1307}
1308
1309function renderAttach(details: JiraDetails, theme: Theme): Text {
1310 const key = details.issueKey || "issue";
1311
1312 return new Text(theme.fg("success", "✓ Attached file to ") + theme.fg("accent", key), 0, 0);
1313}
1314
1315function renderListAttachments(details: JiraDetails, expanded: boolean, theme: Theme): Text {
1316 if (!details.output) {
1317 return new Text(theme.fg("dim", "No attachments"), 0, 0);
1318 }
1319
1320 const issueKey = details.issueKey || "Issue";
1321 const header = theme.fg("accent", `${issueKey} attachments:`);
1322
1323 if (expanded || details.output.split("\n").length <= 10) {
1324 return new Text(`${header}\n${details.output}`, 0, 0);
1325 } else {
1326 const lines = details.output.split("\n");
1327 const preview = lines.slice(0, 10).join("\n");
1328 return new Text(`${header}\n${preview}\n${theme.fg("dim", `... ${lines.length - 10} more`)}`, 0, 0);
1329 }
1330}