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}