Commit 89f1c1c5eb23
Changed files (13)
dots
dots/pi/agent/extensions/jira/actions.ts
@@ -0,0 +1,612 @@
+/**
+ * Action handlers for Jira extension
+ */
+
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+import type { JiraDetails } from "./types";
+import {
+ buildCommentConfirmation,
+ buildCreateConfirmation,
+ buildTransitionConfirmation,
+ buildUpdateConfirmation,
+ extractIssueKey,
+ extractIssueKeys,
+ getErrorMessage,
+ parseIssueListJSON,
+} from "./utils";
+
+/**
+ * Get current Jira user
+ */
+export async function handleMe(
+ pi: ExtensionAPI,
+ params: any,
+ signal: AbortSignal | undefined,
+ onUpdate: any,
+ ctx: ExtensionContext,
+): Promise<any> {
+ const result = await pi.exec("jira", ["me"], { signal, timeout: 10000 });
+
+ if (result.code !== 0) {
+ return {
+ content: [{ type: "text", text: getErrorMessage(result.stderr, "Get current user") }],
+ details: { action: "me", error: result.stderr } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ const user = result.stdout.trim();
+
+ return {
+ content: [{ type: "text", text: `Current user: ${user}` }],
+ details: { action: "me", output: user } as JiraDetails,
+ };
+}
+
+/**
+ * List Jira issues
+ */
+export async function handleList(
+ pi: ExtensionAPI,
+ params: any,
+ signal: AbortSignal | undefined,
+ onUpdate: any,
+ ctx: ExtensionContext,
+ currentUser: string,
+): Promise<any> {
+ // Build jira command - use --raw for JSON output
+ const args = ["issue", "list", "--raw"];
+
+ // Handle assignee
+ if (params.assignee) {
+ if (params.assignee === "me") {
+ // Get current user if not cached
+ if (!currentUser) {
+ const meResult = await pi.exec("jira", ["me"], { signal, timeout: 10000 });
+ if (meResult.code === 0) {
+ currentUser = meResult.stdout.trim();
+ }
+ }
+ args.push("-a", currentUser || params.assignee);
+ } else {
+ args.push("-a", params.assignee);
+ }
+ }
+
+ // Handle status
+ if (params.status) {
+ args.push("-s", params.status);
+ }
+
+ // Handle type
+ if (params.type) {
+ args.push("-t", params.type);
+ }
+
+ // Handle priority
+ if (params.priority) {
+ args.push("-p", params.priority);
+ }
+
+ // Handle epic filter (via JQL)
+ if (params.epic) {
+ // Use JQL for epic filtering
+ const jql = buildListJQL(params, currentUser);
+ args.length = 0; // Clear args
+ args.push("issue", "list", "--raw", "--jql", jql);
+ }
+
+ // Handle limit (jira CLI uses --paginate, not --limit)
+ if (params.limit) {
+ args.push("--paginate", String(params.limit));
+ } else {
+ args.push("--paginate", "20");
+ }
+
+ onUpdate?.({ content: [{ type: "text", text: "Fetching issues..." }] });
+
+ const result = await pi.exec("jira", args, { signal, timeout: 30000 });
+
+ if (result.code !== 0) {
+ return {
+ content: [{ type: "text", text: getErrorMessage(result.stderr, "List issues") }],
+ details: { action: "list", error: result.stderr } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ // Parse JSON output
+ const issues = parseIssueListJSON(result.stdout);
+
+ // Format for LLM
+ let output = "";
+ if (issues.length === 0) {
+ output = "No issues found";
+ } else {
+ output = issues.map(issue => {
+ const priority = issue.priority ? `[${issue.priority}]` : "";
+ const assignee = issue.assignee !== "Unassigned" ? `(@${issue.assignee})` : "";
+ return `${issue.key} [${issue.type}] ${priority} [${issue.status}] ${issue.summary} ${assignee}`;
+ }).join("\n");
+ }
+
+ // Extract issue keys for tracking
+ const issueKeys = issues.map(i => i.key);
+
+ return {
+ content: [{ type: "text", text: output }],
+ details: { action: "list", output, issueKeys } as JiraDetails,
+ };
+}
+
+/**
+ * View Jira issue details
+ */
+export async function handleView(
+ pi: ExtensionAPI,
+ params: any,
+ signal: AbortSignal | undefined,
+ onUpdate: any,
+ ctx: ExtensionContext,
+): Promise<any> {
+ if (!params.key) {
+ return {
+ content: [{ type: "text", text: "Error: 'key' parameter is required for view action" }],
+ details: { action: "view", error: "missing_key" } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ onUpdate?.({ content: [{ type: "text", text: `Fetching ${params.key}...` }] });
+
+ const result = await pi.exec("jira", ["issue", "view", params.key, "--plain"], { signal, timeout: 20000 });
+
+ if (result.code !== 0) {
+ return {
+ content: [{ type: "text", text: getErrorMessage(result.stderr, "View issue") }],
+ details: { action: "view", error: result.stderr, issueKey: params.key } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ return {
+ content: [{ type: "text", text: result.stdout }],
+ details: { action: "view", output: result.stdout, issueKey: params.key } as JiraDetails,
+ };
+}
+
+/**
+ * Search Jira issues using JQL
+ */
+export async function handleSearch(
+ pi: ExtensionAPI,
+ params: any,
+ signal: AbortSignal | undefined,
+ onUpdate: any,
+ ctx: ExtensionContext,
+): Promise<any> {
+ if (!params.jql) {
+ return {
+ content: [{ type: "text", text: "Error: 'jql' parameter is required for search action" }],
+ details: { action: "search", error: "missing_jql" } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ const args = ["issue", "list", "--raw", "--jql", params.jql];
+
+ if (params.limit) {
+ args.push("--paginate", String(params.limit));
+ } else {
+ args.push("--paginate", "50");
+ }
+
+ onUpdate?.({ content: [{ type: "text", text: "Searching..." }] });
+
+ const result = await pi.exec("jira", args, { signal, timeout: 30000 });
+
+ if (result.code !== 0) {
+ return {
+ content: [{ type: "text", text: getErrorMessage(result.stderr, "Search") }],
+ details: { action: "search", error: result.stderr } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ // Parse JSON output
+ const issues = parseIssueListJSON(result.stdout);
+
+ // Format for LLM
+ let output = "";
+ if (issues.length === 0) {
+ output = "No issues found";
+ } else {
+ output = issues.map(issue => {
+ const priority = issue.priority ? `[${issue.priority}]` : "";
+ const assignee = issue.assignee !== "Unassigned" ? `(@${issue.assignee})` : "";
+ return `${issue.key} [${issue.type}] ${priority} [${issue.status}] ${issue.summary} ${assignee}`;
+ }).join("\n");
+ }
+
+ // Extract issue keys for tracking
+ const issueKeys = issues.map(i => i.key);
+
+ return {
+ content: [{ type: "text", text: output }],
+ details: { action: "search", output, issueKeys } as JiraDetails,
+ };
+}
+
+/**
+ * Create Jira issue (requires approval)
+ */
+export async function handleCreate(
+ pi: ExtensionAPI,
+ params: any,
+ signal: AbortSignal | undefined,
+ onUpdate: any,
+ ctx: ExtensionContext,
+ currentUser: string,
+): Promise<any> {
+ // Validate required parameters
+ if (!params.issueType) {
+ return {
+ content: [{ type: "text", text: "Error: 'issueType' parameter is required for create action" }],
+ details: { action: "create", error: "missing_type" } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ if (!params.summary) {
+ return {
+ content: [{ type: "text", text: "Error: 'summary' parameter is required for create action" }],
+ details: { action: "create", error: "missing_summary" } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ // APPROVAL GATE
+ if (ctx.hasUI) {
+ const confirmMessage = buildCreateConfirmation(params);
+
+ const confirmed = await ctx.ui.confirm("Create Jira Issue?", confirmMessage);
+
+ if (!confirmed) {
+ ctx.ui.notify("Create cancelled", "info");
+ return {
+ content: [{ type: "text", text: "Create operation cancelled by user" }],
+ details: { action: "create", cancelled: true } as JiraDetails,
+ };
+ }
+ }
+
+ // Build jira command
+ const args = ["issue", "create", "--type", params.issueType, "--summary", params.summary, "--no-input"];
+
+ if (params.description) {
+ args.push("--description", params.description);
+ }
+
+ if (params.priority) {
+ args.push("--priority", params.priority);
+ }
+
+ if (params.assignee) {
+ const assignee = params.assignee === "me" ? currentUser || params.assignee : params.assignee;
+ args.push("--assignee", assignee);
+ }
+
+ if (params.labels && params.labels.length > 0) {
+ args.push("--label", params.labels.join(","));
+ }
+
+ if (params.parent) {
+ args.push("--parent", params.parent);
+ }
+
+ // Note: epic linking might need to be done via edit after creation
+ // depending on jira-cli version
+
+ onUpdate?.({ content: [{ type: "text", text: "Creating issue..." }] });
+
+ const result = await pi.exec("jira", args, { signal, timeout: 30000 });
+
+ if (result.code !== 0) {
+ return {
+ content: [{ type: "text", text: getErrorMessage(result.stderr, "Create issue") }],
+ details: { action: "create", error: result.stderr } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ // Extract issue key from output
+ const issueKey = extractIssueKey(result.stdout) || "unknown";
+
+ return {
+ content: [{ type: "text", text: `Created issue: ${issueKey}\n\n${result.stdout}` }],
+ details: { action: "create", output: result.stdout, issueKey } as JiraDetails,
+ };
+}
+
+/**
+ * Update Jira issue field (requires approval)
+ */
+export async function handleUpdate(
+ pi: ExtensionAPI,
+ params: any,
+ signal: AbortSignal | undefined,
+ onUpdate: any,
+ ctx: ExtensionContext,
+ currentUser: string,
+): Promise<any> {
+ if (!params.key) {
+ return {
+ content: [{ type: "text", text: "Error: 'key' parameter is required for update action" }],
+ details: { action: "update", error: "missing_key" } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ if (!params.field) {
+ return {
+ content: [{ type: "text", text: "Error: 'field' parameter is required for update action" }],
+ details: { action: "update", error: "missing_field" } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ if (!params.value) {
+ return {
+ content: [{ type: "text", text: "Error: 'value' parameter is required for update action" }],
+ details: { action: "update", error: "missing_value" } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ // APPROVAL GATE
+ if (ctx.hasUI) {
+ const confirmMessage = buildUpdateConfirmation(params);
+
+ const confirmed = await ctx.ui.confirm(`Update ${params.key}?`, confirmMessage);
+
+ if (!confirmed) {
+ ctx.ui.notify("Update cancelled", "info");
+ return {
+ content: [{ type: "text", text: "Update operation cancelled by user" }],
+ details: { action: "update", cancelled: true, issueKey: params.key } as JiraDetails,
+ };
+ }
+ }
+
+ // Build command based on field
+ let args: string[];
+ const value = params.value === "me" ? currentUser || params.value : params.value;
+
+ switch (params.field) {
+ case "assignee":
+ args = ["issue", "assign", params.key, value];
+ break;
+
+ case "labels":
+ // Labels are comma-separated
+ args = ["issue", "edit", params.key, "--label", value];
+ break;
+
+ case "priority":
+ case "summary":
+ case "description":
+ args = ["issue", "edit", params.key, `--${params.field}`, value];
+ break;
+
+ default:
+ return {
+ content: [{ type: "text", text: `Error: Unsupported field: ${params.field}` }],
+ details: { action: "update", error: "unsupported_field", issueKey: params.key } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ onUpdate?.({ content: [{ type: "text", text: `Updating ${params.key}...` }] });
+
+ const result = await pi.exec("jira", args, { signal, timeout: 20000 });
+
+ if (result.code !== 0) {
+ return {
+ content: [{ type: "text", text: getErrorMessage(result.stderr, "Update issue") }],
+ details: { action: "update", error: result.stderr, issueKey: params.key } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ return {
+ content: [{ type: "text", text: `Updated ${params.key}: ${params.field} = ${value}\n\n${result.stdout}` }],
+ details: {
+ action: "update",
+ output: result.stdout,
+ issueKey: params.key,
+ field: params.field,
+ newValue: value,
+ } as JiraDetails,
+ };
+}
+
+/**
+ * Add comment to Jira issue (requires approval)
+ */
+export async function handleComment(
+ pi: ExtensionAPI,
+ params: any,
+ signal: AbortSignal | undefined,
+ onUpdate: any,
+ ctx: ExtensionContext,
+): Promise<any> {
+ if (!params.key) {
+ return {
+ content: [{ type: "text", text: "Error: 'key' parameter is required for comment action" }],
+ details: { action: "comment", error: "missing_key" } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ if (!params.comment) {
+ return {
+ content: [{ type: "text", text: "Error: 'comment' parameter is required for comment action" }],
+ details: { action: "comment", error: "missing_comment" } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ // APPROVAL GATE
+ if (ctx.hasUI) {
+ const confirmMessage = buildCommentConfirmation(params);
+
+ const confirmed = await ctx.ui.confirm(`Add comment to ${params.key}?`, confirmMessage);
+
+ if (!confirmed) {
+ ctx.ui.notify("Comment cancelled", "info");
+ return {
+ content: [{ type: "text", text: "Comment operation cancelled by user" }],
+ details: { action: "comment", cancelled: true, issueKey: params.key } as JiraDetails,
+ };
+ }
+ }
+
+ onUpdate?.({ content: [{ type: "text", text: `Adding comment to ${params.key}...` }] });
+
+ // Write comment to temp file to avoid shell escaping issues
+ const tmpFile = `/tmp/jira-comment-${Date.now()}.txt`;
+ await pi.exec("sh", ["-c", `echo ${JSON.stringify(params.comment)} > ${tmpFile}`], { signal });
+
+ const result = await pi.exec("sh", ["-c", `cat ${tmpFile} | jira issue comment add ${params.key}`], {
+ signal,
+ timeout: 20000,
+ });
+
+ // Clean up temp file
+ await pi.exec("rm", ["-f", tmpFile], { signal });
+
+ if (result.code !== 0) {
+ return {
+ content: [{ type: "text", text: getErrorMessage(result.stderr, "Add comment") }],
+ details: { action: "comment", error: result.stderr, issueKey: params.key } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ return {
+ content: [{ type: "text", text: `Added comment to ${params.key}` }],
+ details: { action: "comment", output: result.stdout, issueKey: params.key } as JiraDetails,
+ };
+}
+
+/**
+ * Transition Jira issue to new state (requires approval)
+ */
+export async function handleTransition(
+ pi: ExtensionAPI,
+ params: any,
+ signal: AbortSignal | undefined,
+ onUpdate: any,
+ ctx: ExtensionContext,
+): Promise<any> {
+ if (!params.key) {
+ return {
+ content: [{ type: "text", text: "Error: 'key' parameter is required for transition action" }],
+ details: { action: "transition", error: "missing_key" } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ if (!params.state) {
+ return {
+ content: [{ type: "text", text: "Error: 'state' parameter is required for transition action" }],
+ details: { action: "transition", error: "missing_state" } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ // APPROVAL GATE
+ if (ctx.hasUI) {
+ const confirmMessage = buildTransitionConfirmation(params);
+
+ const confirmed = await ctx.ui.confirm(`Transition ${params.key}?`, confirmMessage);
+
+ if (!confirmed) {
+ ctx.ui.notify("Transition cancelled", "info");
+ return {
+ content: [{ type: "text", text: "Transition operation cancelled by user" }],
+ details: { action: "transition", cancelled: true, issueKey: params.key } as JiraDetails,
+ };
+ }
+ }
+
+ onUpdate?.({ content: [{ type: "text", text: `Moving ${params.key} to ${params.state}...` }] });
+
+ const result = await pi.exec("jira", ["issue", "move", params.key, params.state], { signal, timeout: 20000 });
+
+ if (result.code !== 0) {
+ return {
+ content: [{ type: "text", text: getErrorMessage(result.stderr, "Transition issue") }],
+ details: { action: "transition", error: result.stderr, issueKey: params.key } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ return {
+ content: [{ type: "text", text: `Moved ${params.key} to ${params.state}` }],
+ details: {
+ action: "transition",
+ output: result.stdout,
+ issueKey: params.key,
+ toKey: params.state,
+ } as JiraDetails,
+ };
+}
+
+/**
+ * Helper: Build JQL query from list parameters
+ */
+function buildListJQL(params: any, currentUser: string): string {
+ const conditions: string[] = [];
+
+ if (params.assignee) {
+ const assignee = params.assignee === "me" ? currentUser || params.assignee : params.assignee;
+ conditions.push(`assignee = ${assignee}`);
+ }
+
+ if (params.status) {
+ // Handle ~Done syntax for "not Done"
+ if (params.status.startsWith("~")) {
+ conditions.push(`status != ${params.status.slice(1)}`);
+ } else {
+ // Handle comma-separated statuses
+ const statuses = params.status
+ .split(",")
+ .map((s: string) => `"${s.trim()}"`)
+ .join(",");
+ conditions.push(`status IN (${statuses})`);
+ }
+ }
+
+ if (params.type) {
+ const types = params.type
+ .split(",")
+ .map((t: string) => `"${t.trim()}"`)
+ .join(",");
+ conditions.push(`type IN (${types})`);
+ }
+
+ if (params.priority) {
+ const priorities = params.priority
+ .split(",")
+ .map((p: string) => `"${p.trim()}"`)
+ .join(",");
+ conditions.push(`priority IN (${priorities})`);
+ }
+
+ if (params.epic) {
+ conditions.push(`"Epic Link" = ${params.epic}`);
+ }
+
+ return conditions.join(" AND ");
+}
dots/pi/agent/extensions/jira/attachment-actions.ts
@@ -0,0 +1,142 @@
+/**
+ * Attachment-related action handlers for Jira extension
+ */
+
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+import type { JiraDetails } from "./types";
+import { getErrorMessage } from "./utils";
+
+/**
+ * Attach file to issue
+ */
+export async function handleAttach(
+ pi: ExtensionAPI,
+ params: any,
+ signal: AbortSignal | undefined,
+ onUpdate: any,
+ ctx: ExtensionContext,
+): Promise<any> {
+ if (!params.key) {
+ return {
+ content: [{ type: "text", text: "Error: 'key' parameter is required for attach action" }],
+ details: { action: "attach", error: "missing_key" } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ if (!params.file) {
+ return {
+ content: [{ type: "text", text: "Error: 'file' parameter is required for attach action" }],
+ details: { action: "attach", error: "missing_file" } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ // Check if file exists
+ const checkResult = await pi.exec("test", ["-f", params.file], { signal });
+ if (checkResult.code !== 0) {
+ return {
+ content: [{ type: "text", text: `Error: File not found: ${params.file}` }],
+ details: { action: "attach", error: "file_not_found" } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ // APPROVAL GATE
+ if (ctx.hasUI) {
+ const confirmMessage = `Issue: ${params.key}\nFile: ${params.file}\n\nThis will upload the file as an attachment.`;
+
+ const confirmed = await ctx.ui.confirm("Attach file?", confirmMessage);
+
+ if (!confirmed) {
+ ctx.ui.notify("Attach cancelled", "info");
+ return {
+ content: [{ type: "text", text: "Attach operation cancelled by user" }],
+ details: { action: "attach", cancelled: true, issueKey: params.key } as JiraDetails,
+ };
+ }
+ }
+
+ onUpdate?.({ content: [{ type: "text", text: `Attaching ${params.file} to ${params.key}...` }] });
+
+ // Attach file using jira CLI
+ const result = await pi.exec("jira", ["issue", "attach", params.key, params.file], { signal, timeout: 60000 });
+
+ if (result.code !== 0) {
+ return {
+ content: [{ type: "text", text: getErrorMessage(result.stderr, "Attach file") }],
+ details: { action: "attach", error: result.stderr, issueKey: params.key } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ return {
+ content: [{ type: "text", text: `Attached ${params.file} to ${params.key}` }],
+ details: { action: "attach", output: result.stdout, issueKey: params.key } as JiraDetails,
+ };
+}
+
+/**
+ * List attachments for an issue
+ */
+export async function handleListAttachments(
+ pi: ExtensionAPI,
+ params: any,
+ signal: AbortSignal | undefined,
+ onUpdate: any,
+ ctx: ExtensionContext,
+): Promise<any> {
+ if (!params.key) {
+ return {
+ content: [{ type: "text", text: "Error: 'key' parameter is required for list-attachments action" }],
+ details: { action: "list-attachments", error: "missing_key" } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ onUpdate?.({ content: [{ type: "text", text: `Fetching attachments for ${params.key}...` }] });
+
+ // View issue to get attachments
+ const result = await pi.exec("jira", ["issue", "view", params.key, "--plain"], { signal, timeout: 20000 });
+
+ if (result.code !== 0) {
+ return {
+ content: [{ type: "text", text: getErrorMessage(result.stderr, "List attachments") }],
+ details: { action: "list-attachments", error: result.stderr, issueKey: params.key } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ // Parse output to find attachments section
+ // The jira CLI view output typically includes an "Attachments:" section
+ const lines = result.stdout.split("\n");
+ let inAttachments = false;
+ const attachments: string[] = [];
+
+ for (const line of lines) {
+ if (line.match(/^Attachments?:/i)) {
+ inAttachments = true;
+ continue;
+ }
+
+ if (inAttachments) {
+ // Stop at next section or empty line
+ if (line.match(/^[A-Z][a-z]+:/) || line.trim() === "") {
+ break;
+ }
+ if (line.trim()) {
+ attachments.push(line.trim());
+ }
+ }
+ }
+
+ const output =
+ attachments.length > 0
+ ? `Attachments for ${params.key}:\n\n` + attachments.join("\n")
+ : `No attachments found for ${params.key}`;
+
+ return {
+ content: [{ type: "text", text: output }],
+ details: { action: "list-attachments", output, issueKey: params.key } as JiraDetails,
+ };
+}
dots/pi/agent/extensions/jira/DESIGN-NOTES.md
@@ -0,0 +1,394 @@
+# Jira Extension - Design Notes
+
+## Offline Support Strategy
+
+### Current Challenge
+- Jira requires VPN connection to issues.redhat.com
+- API calls fail when offline or not on VPN
+- Users might want to queue operations while offline
+
+### Proposed Approach: Operation Queue in Session
+
+Store pending operations in session state (similar to git commits):
+
+```typescript
+interface QueuedOperation {
+ id: string;
+ action: "create" | "update" | "comment" | "transition";
+ params: any;
+ timestamp: number;
+ approved: boolean; // User already approved
+}
+
+// Store in session via appendEntry
+pi.appendEntry("jira-queue", {
+ operations: [/* queued ops */]
+});
+```
+
+### Benefits
+1. **Session-based** - Survives restarts, respects branching
+2. **Already Approved** - User approved when offline, just need to execute
+3. **Git-like Workflow** - Make changes offline, "push" when online
+4. **Transparent** - User sees queued operations in session
+
+### Implementation Steps
+
+1. **Detect Offline** - Try `jira me` with short timeout
+2. **Queue Operation** - Store in session with approval flag
+3. **Show Queued** - Display pending operations to user
+4. **Sync Command** - `/jira-sync` to execute queued operations
+5. **Auto-sync** - Optionally sync on connectivity restore
+
+### Example Workflow
+
+```
+[OFFLINE]
+User: "Create a bug for X"
+Agent: <detects offline>
+→ Shows approval dialog (with "QUEUED" indicator)
+→ User confirms
+→ Stores in session queue
+→ Returns: "Queued for sync (1 pending operation)"
+
+[ONLINE]
+User: "/jira-sync"
+→ Executes queued operations
+→ Returns: "Synced 1 operation: Created SRVKP-1234"
+```
+
+### Conflict Resolution
+
+What if issue changed while offline?
+
+```typescript
+// Before executing queued operation
+const current = await viewIssue(op.params.key);
+
+if (hasConflict(current, op)) {
+ const confirmed = await ctx.ui.confirm(
+ "Conflict Detected",
+ `Issue ${op.params.key} changed since queued.\n\n` +
+ `Queued: ${op.params.field} = ${op.params.value}\n` +
+ `Current: ${current[op.params.field]}\n\n` +
+ `Continue with update?`
+ );
+
+ if (!confirmed) {
+ // Skip this operation
+ }
+}
+```
+
+### Session Entry Structure
+
+```typescript
+// Queued operations
+{
+ type: "custom",
+ customType: "jira-queue",
+ timestamp: Date.now(),
+ data: {
+ operations: [
+ {
+ id: "op-1234",
+ action: "create",
+ params: { issueType: "Bug", summary: "..." },
+ timestamp: Date.now(),
+ approved: true
+ }
+ ]
+ }
+}
+
+// Sync results
+{
+ type: "custom",
+ customType: "jira-sync",
+ timestamp: Date.now(),
+ data: {
+ synced: 3,
+ failed: 0,
+ results: [
+ { id: "op-1234", success: true, issueKey: "SRVKP-5678" }
+ ]
+ }
+}
+```
+
+### When to Implement?
+
+**Wait for real need** - Implement when:
+1. You frequently work offline
+2. You make multiple Jira changes in one session
+3. VPN connectivity is unreliable
+
+For now, the extension fails gracefully with clear error messages about VPN/network.
+
+---
+
+## Batch Operations Strategy
+
+### Use Cases
+
+#### 1. Sprint Planning
+```
+User: "Add SRVKP-1234, SRVKP-1235, SRVKP-1236 to current sprint"
+→ Batch operation: transition multiple issues
+```
+
+#### 2. Bulk Labeling
+```
+User: "Add 'release-notes-pending' label to all issues in current sprint"
+→ Batch operation: update labels on multiple issues
+```
+
+#### 3. Team Assignment
+```
+User: "Assign all unassigned tasks in SRVKP to the team rotation"
+→ Batch operation: update assignee on multiple issues
+```
+
+#### 4. Status Updates
+```
+User: "Move all code-reviewed issues to QE Review"
+→ Batch operation: transition multiple issues
+```
+
+### Proposed Design
+
+Add a `batch` action that operates on multiple issues:
+
+```typescript
+{
+ action: "batch",
+ operation: "transition" | "update" | "comment",
+ issues: ["SRVKP-1234", "SRVKP-1235", "SRVKP-1236"],
+ // Operation-specific params
+ state: "In Progress", // For transition
+ // OR
+ field: "labels", // For update
+ value: "release-notes",
+ // OR
+ comment: "Bulk update comment"
+}
+```
+
+### Approval Pattern
+
+Show all changes in one dialog:
+
+```
+┌─────────────────────────────────────┐
+│ Batch Transition (3 issues)? │
+│ │
+│ Issues: │
+│ • SRVKP-1234: Fix bug X │
+│ • SRVKP-1235: Add feature Y │
+│ • SRVKP-1236: Update docs Z │
+│ │
+│ Change: To Do → In Progress │
+│ │
+│ This will transition 3 issues. │
+│ │
+│ [Yes] [No] │
+└─────────────────────────────────────┘
+```
+
+### Implementation
+
+```typescript
+async function handleBatch(pi, params, signal, onUpdate, ctx) {
+ // Validate
+ if (!params.issues || params.issues.length === 0) {
+ return error("No issues specified");
+ }
+
+ // Get issue details for approval
+ const details = await fetchIssueDetails(params.issues);
+
+ // Approval
+ if (ctx.hasUI) {
+ const confirmed = await ctx.ui.confirm(
+ `Batch ${params.operation} (${params.issues.length} issues)?`,
+ buildBatchConfirmation(params, details)
+ );
+
+ if (!confirmed) {
+ return { cancelled: true };
+ }
+ }
+
+ // Execute on each issue
+ const results = [];
+ for (const issueKey of params.issues) {
+ onUpdate?.({
+ content: [{
+ type: "text",
+ text: `Processing ${issueKey}...`
+ }]
+ });
+
+ const result = await executeOperation(
+ issueKey,
+ params.operation,
+ params
+ );
+
+ results.push(result);
+ }
+
+ // Return aggregated results
+ const succeeded = results.filter(r => r.success).length;
+ const failed = results.filter(r => !r.success).length;
+
+ return {
+ content: [{
+ type: "text",
+ text: `Batch ${params.operation} complete:\n` +
+ ` Succeeded: ${succeeded}\n` +
+ ` Failed: ${failed}`
+ }],
+ details: {
+ action: "batch",
+ operation: params.operation,
+ results
+ }
+ };
+}
+```
+
+### When to Implement?
+
+**Wait for real need** - Implement when:
+1. You frequently need to update 5+ issues at once
+2. Sprint planning requires bulk operations
+3. Team workflows involve batch status changes
+
+For now, you can:
+- Make individual API calls (jira CLI is fast)
+- Use JQL search + manual batch via web UI if needed
+- Script batch operations outside of pi if critical
+
+---
+
+## Recommendations
+
+### Phase 1: Current Implementation ✅
+- All 8 core actions (me, list, view, search, create, update, comment, transition)
+- Approval gates for write operations
+- Custom rendering
+- State management
+- Error handling
+
+### Phase 2: Epic and Feature Support 🎯
+**NEXT PRIORITY** - You mentioned epics and features are important:
+
+```typescript
+// Add epic-specific actions
+{
+ action: "epic-view",
+ key: "SRVKP-1234" // View epic with child issues
+}
+
+{
+ action: "epic-create",
+ summary: "Epic: Major feature",
+ description: "..."
+}
+
+{
+ action: "link-to-epic",
+ issue: "SRVKP-5678",
+ epic: "SRVKP-1234"
+}
+```
+
+### Phase 3: Attachments and Links 📎
+**SECOND PRIORITY** - You want these features:
+
+```typescript
+// Attachments
+{
+ action: "attach",
+ key: "SRVKP-1234",
+ file: "/path/to/file"
+}
+
+{
+ action: "list-attachments",
+ key: "SRVKP-1234"
+}
+
+// Issue links
+{
+ action: "link",
+ from: "SRVKP-1234",
+ to: "SRVKP-5678",
+ type: "blocks" | "relates to" | "duplicates"
+}
+```
+
+### Phase 4: Offline Support 📡
+**Wait for need** - Implement if:
+- VPN is unreliable
+- You work offline frequently
+- You want to queue multiple operations
+
+### Phase 5: Batch Operations 📦
+**Wait for need** - Implement if:
+- You regularly update 5+ issues
+- Sprint planning requires bulk ops
+- Clear use cases emerge
+
+---
+
+## Next Steps
+
+1. **Test the Extension**
+ - Try all 8 actions
+ - Verify approval dialogs work
+ - Test error handling (VPN off, wrong issue key, etc.)
+
+2. **Add Epic Support** (Phase 2)
+ - `epic-view` - View epic with child issues
+ - `epic-create` - Create new epic
+ - `link-to-epic` - Link issue to epic
+ - `unlink-from-epic` - Remove epic link
+
+3. **Add Feature Support**
+ - Ensure Feature type works in create
+ - Add Feature-specific fields if needed
+
+4. **Add Attachments** (Phase 3)
+ - `attach` - Attach file to issue
+ - `list-attachments` - List attachments
+ - `download-attachment` - Download file
+
+5. **Add Links** (Phase 3)
+ - `link` - Create issue link
+ - `unlink` - Remove issue link
+ - `list-links` - Show related issues
+
+---
+
+## Implementation Priority
+
+```
+NOW → Test current 8 actions
+NEXT → Epic support (epic-view, epic-create, link-to-epic)
+THEN → Feature type validation
+THEN → Attachments (attach, list-attachments)
+THEN → Links (link, unlink, list-links)
+LATER → Offline support (if needed)
+LATER → Batch operations (if needed)
+```
+
+---
+
+## Questions?
+
+- **Offline**: Queue operations in session, sync when online (similar to git)
+- **Batch**: Useful but wait for real need; single operations are fast enough for now
+- **Epic**: Should be next priority since you use epics/features
+- **Attachments**: Second priority for documentation and screenshots
dots/pi/agent/extensions/jira/epic-actions.ts
@@ -0,0 +1,133 @@
+/**
+ * Epic-related action handlers for Jira extension
+ */
+
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+import type { JiraDetails } from "./types";
+import { getErrorMessage } from "./utils";
+
+/**
+ * View epic with all child issues
+ */
+export async function handleEpicView(
+ pi: ExtensionAPI,
+ params: any,
+ signal: AbortSignal | undefined,
+ onUpdate: any,
+ ctx: ExtensionContext,
+): Promise<any> {
+ if (!params.key) {
+ return {
+ content: [{ type: "text", text: "Error: 'key' parameter is required for epic-view action" }],
+ details: { action: "epic-view", error: "missing_key" } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ onUpdate?.({ content: [{ type: "text", text: `Fetching epic ${params.key}...` }] });
+
+ // First, view the epic itself
+ const epicResult = await pi.exec("jira", ["issue", "view", params.key, "--plain"], { signal, timeout: 20000 });
+
+ if (epicResult.code !== 0) {
+ return {
+ content: [{ type: "text", text: getErrorMessage(epicResult.stderr, "View epic") }],
+ details: { action: "epic-view", error: epicResult.stderr, issueKey: params.key } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ onUpdate?.({ content: [{ type: "text", text: `Fetching child issues for ${params.key}...` }] });
+
+ // Then, list all issues linked to this epic
+ const childrenResult = await pi.exec(
+ "jira",
+ ["issue", "list", "--plain", "--jql", `"Epic Link" = ${params.key}`],
+ { signal, timeout: 30000 },
+ );
+
+ let output = `Epic: ${params.key}\n\n`;
+ output += `${epicResult.stdout}\n\n`;
+ output += `=`.repeat(80) + "\n\n";
+ output += `Child Issues:\n\n`;
+
+ if (childrenResult.code === 0 && childrenResult.stdout.trim()) {
+ output += childrenResult.stdout;
+ } else {
+ output += "No child issues found.";
+ }
+
+ return {
+ content: [{ type: "text", text: output }],
+ details: { action: "epic-view", output, issueKey: params.key } as JiraDetails,
+ };
+}
+
+/**
+ * Link issue to epic
+ */
+export async function handleLinkToEpic(
+ pi: ExtensionAPI,
+ params: any,
+ signal: AbortSignal | undefined,
+ onUpdate: any,
+ ctx: ExtensionContext,
+): Promise<any> {
+ if (!params.issue) {
+ return {
+ content: [{ type: "text", text: "Error: 'issue' parameter is required for link-to-epic action" }],
+ details: { action: "link-to-epic", error: "missing_issue" } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ if (!params.epic) {
+ return {
+ content: [{ type: "text", text: "Error: 'epic' parameter is required for link-to-epic action" }],
+ details: { action: "link-to-epic", error: "missing_epic" } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ // APPROVAL GATE
+ if (ctx.hasUI) {
+ const confirmMessage = `Issue: ${params.issue}\nEpic: ${params.epic}\n\nThis will link the issue to the epic.`;
+
+ const confirmed = await ctx.ui.confirm(`Link ${params.issue} to epic?`, confirmMessage);
+
+ if (!confirmed) {
+ ctx.ui.notify("Link to epic cancelled", "info");
+ return {
+ content: [{ type: "text", text: "Link operation cancelled by user" }],
+ details: { action: "link-to-epic", cancelled: true } as JiraDetails,
+ };
+ }
+ }
+
+ onUpdate?.({ content: [{ type: "text", text: `Linking ${params.issue} to epic ${params.epic}...` }] });
+
+ // Link issue to epic using edit command
+ const result = await pi.exec("jira", ["issue", "edit", params.issue, "--epic", params.epic], {
+ signal,
+ timeout: 20000,
+ });
+
+ if (result.code !== 0) {
+ return {
+ content: [{ type: "text", text: getErrorMessage(result.stderr, "Link to epic") }],
+ details: { action: "link-to-epic", error: result.stderr } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ return {
+ content: [{ type: "text", text: `Linked ${params.issue} to epic ${params.epic}` }],
+ details: {
+ action: "link-to-epic",
+ output: result.stdout,
+ issueKey: params.issue,
+ fromKey: params.issue,
+ toKey: params.epic,
+ } as JiraDetails,
+ };
+}
dots/pi/agent/extensions/jira/index.ts
@@ -0,0 +1,913 @@
+/**
+ * Pi Extension: Jira Issue Management
+ *
+ * Provides Jira issue management for issues.redhat.com with:
+ * - Read operations: me, list, view, search
+ * - Write operations (with approval): create, update, comment, transition
+ * - Custom rendering for issues
+ * - State management for current user
+ *
+ * Configuration:
+ * ~/.config/.jira/.config.yml - Jira CLI config
+ * passage show redhat/issues/token/kyushu - API token
+ *
+ * Requirements:
+ * - jira CLI: go install github.com/ankitpokhrel/jira-cli/cmd/jira@latest
+ * - Red Hat VPN connection
+ */
+
+import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
+import { Text } from "@mariozechner/pi-tui";
+import { Type } from "@sinclair/typebox";
+import { StringEnum } from "@mariozechner/pi-ai";
+
+import type { JiraDetails } from "./types";
+import {
+ handleMe,
+ handleList,
+ handleView,
+ handleSearch,
+ handleCreate,
+ handleUpdate,
+ handleComment,
+ handleTransition,
+} from "./actions";
+import { handleEpicView, handleLinkToEpic } from "./epic-actions";
+import { handleLink, handleUnlink } from "./link-actions";
+import { handleAttach, handleListAttachments } from "./attachment-actions";
+import { parseIssueList, parseIssueListJSON, getStatusColor, getPriorityColor, truncate } from "./utils";
+
+export default function (pi: ExtensionAPI) {
+ // ========================================================================
+ // State Management
+ // ========================================================================
+
+ let currentUser = "";
+ let recentIssues: string[] = [];
+
+ // Reconstruct state from session
+ const reconstructState = (ctx: ExtensionContext) => {
+ currentUser = "";
+ recentIssues = [];
+
+ for (const entry of ctx.sessionManager.getBranch()) {
+ if (entry.type !== "message") continue;
+ const msg = entry.message;
+ if (msg.role !== "toolResult" || msg.toolName !== "jira") continue;
+
+ const details = msg.details as JiraDetails | undefined;
+ if (details?.action === "me" && details.output) {
+ currentUser = details.output.trim();
+ }
+
+ // Track recent issue keys
+ if (details?.issueKey && !recentIssues.includes(details.issueKey)) {
+ recentIssues.push(details.issueKey);
+ }
+ if (details?.issueKeys) {
+ for (const key of details.issueKeys) {
+ if (!recentIssues.includes(key)) {
+ recentIssues.push(key);
+ }
+ }
+ }
+ }
+
+ // Keep only last 20 issues
+ if (recentIssues.length > 20) {
+ recentIssues = recentIssues.slice(-20);
+ }
+ };
+
+ // Session events
+ pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
+ pi.on("session_switch", async (_event, ctx) => reconstructState(ctx));
+ pi.on("session_fork", async (_event, ctx) => reconstructState(ctx));
+ pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
+
+ // ========================================================================
+ // Tool Registration
+ // ========================================================================
+
+ pi.registerTool({
+ name: "jira",
+ label: "Jira",
+ description:
+ "Manage Jira issues on issues.redhat.com. " +
+ "Actions: me (get current user), list (list issues), view (view issue details), " +
+ "search (JQL search), create (create issue), update (update field), " +
+ "comment (add comment), transition (change state). " +
+ "Use 'me' as assignee value to refer to current user. " +
+ "Write operations (create, update, comment, transition) require user approval.",
+
+ parameters: Type.Object({
+ action: StringEnum([
+ "me",
+ "list",
+ "view",
+ "search",
+ "create",
+ "update",
+ "comment",
+ "transition",
+ "epic-view",
+ "link-to-epic",
+ "link",
+ "unlink",
+ "attach",
+ "list-attachments",
+ ] as const),
+
+ // List/Search parameters
+ assignee: Type.Optional(
+ Type.String({
+ description: "Filter by assignee (username or 'me' for current user)",
+ }),
+ ),
+ status: Type.Optional(
+ Type.String({
+ description: "Filter by status (comma-separated, or '~Done' for not Done)",
+ }),
+ ),
+ type: Type.Optional(
+ Type.String({
+ description: "Filter by type: Bug, Task, Story, Epic, Feature (comma-separated)",
+ }),
+ ),
+ priority: Type.Optional(
+ Type.String({
+ description: "Filter by priority: Blocker, Critical, Major, Minor, Trivial (comma-separated)",
+ }),
+ ),
+ limit: Type.Optional(
+ Type.Number({
+ description: "Maximum number of results (default 20 for list, 50 for search)",
+ }),
+ ),
+ epic: Type.Optional(
+ Type.String({
+ description: "Filter by epic key (e.g., SRVKP-1234)",
+ }),
+ ),
+
+ // View parameter
+ key: Type.Optional(
+ Type.String({
+ description: "Issue key (e.g., SRVKP-1234)",
+ }),
+ ),
+
+ // Search parameter
+ jql: Type.Optional(
+ Type.String({
+ description: "JQL query for advanced search",
+ }),
+ ),
+
+ // Create parameters
+ issueType: Type.Optional(
+ Type.String({
+ description: "Issue type: Bug, Task, Story, Epic, Feature, Sub-task",
+ }),
+ ),
+ summary: Type.Optional(
+ Type.String({
+ description: "Issue title/summary",
+ }),
+ ),
+ description: Type.Optional(
+ Type.String({
+ description: "Issue description",
+ }),
+ ),
+ labels: Type.Optional(
+ Type.Array(Type.String(), {
+ description: "Issue labels",
+ }),
+ ),
+ parent: Type.Optional(
+ Type.String({
+ description: "Parent issue key for sub-tasks",
+ }),
+ ),
+
+ // Update parameters
+ field: Type.Optional(
+ Type.String({
+ description: "Field to update: assignee, priority, labels, summary, description",
+ }),
+ ),
+ value: Type.Optional(
+ Type.String({
+ description: "New value for the field",
+ }),
+ ),
+
+ // Comment parameter
+ comment: Type.Optional(
+ Type.String({
+ description: "Comment text",
+ }),
+ ),
+
+ // Transition parameter
+ state: Type.Optional(
+ Type.String({
+ description: "New state: To Do, In Progress, Code Review, QE Review, Done, Blocked, etc.",
+ }),
+ ),
+
+ // Epic parameters
+ issue: Type.Optional(
+ Type.String({
+ description: "Issue key to link to epic",
+ }),
+ ),
+
+ // Link parameters
+ from: Type.Optional(
+ Type.String({
+ description: "Source issue key for linking",
+ }),
+ ),
+ to: Type.Optional(
+ Type.String({
+ description: "Target issue key for linking",
+ }),
+ ),
+ linkType: Type.Optional(
+ Type.String({
+ description: "Link type: blocks, is blocked by, relates to, duplicates, is duplicated by",
+ }),
+ ),
+
+ // Attachment parameter
+ file: Type.Optional(
+ Type.String({
+ description: "File path to attach",
+ }),
+ ),
+ }),
+
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
+ try {
+ // Route to appropriate handler
+ switch (params.action) {
+ case "me":
+ return await handleMe(pi, params, signal, onUpdate, ctx);
+
+ case "list":
+ return await handleList(pi, params, signal, onUpdate, ctx, currentUser);
+
+ case "view":
+ return await handleView(pi, params, signal, onUpdate, ctx);
+
+ case "search":
+ return await handleSearch(pi, params, signal, onUpdate, ctx);
+
+ case "create":
+ return await handleCreate(pi, params, signal, onUpdate, ctx, currentUser);
+
+ case "update":
+ return await handleUpdate(pi, params, signal, onUpdate, ctx, currentUser);
+
+ case "comment":
+ return await handleComment(pi, params, signal, onUpdate, ctx);
+
+ case "transition":
+ return await handleTransition(pi, params, signal, onUpdate, ctx);
+
+ case "epic-view":
+ return await handleEpicView(pi, params, signal, onUpdate, ctx);
+
+ case "link-to-epic":
+ return await handleLinkToEpic(pi, params, signal, onUpdate, ctx);
+
+ case "link":
+ return await handleLink(pi, params, signal, onUpdate, ctx);
+
+ case "unlink":
+ return await handleUnlink(pi, params, signal, onUpdate, ctx);
+
+ case "attach":
+ return await handleAttach(pi, params, signal, onUpdate, ctx);
+
+ case "list-attachments":
+ return await handleListAttachments(pi, params, signal, onUpdate, ctx);
+
+ default:
+ return {
+ content: [{ type: "text", text: `Unknown action: ${params.action}` }],
+ details: { action: params.action, error: "unknown_action" } as JiraDetails,
+ isError: true,
+ };
+ }
+ } catch (error) {
+ return {
+ content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
+ details: { action: params.action, error: String(error) } as JiraDetails,
+ isError: true,
+ };
+ }
+ },
+
+ // ====================================================================
+ // Custom Rendering
+ // ====================================================================
+
+ renderCall(args, theme) {
+ let text = theme.fg("toolTitle", theme.bold("jira "));
+ text += theme.fg("muted", args.action);
+
+ if (args.key) {
+ text += " " + theme.fg("accent", args.key);
+ }
+
+ if (args.summary) {
+ text += " " + theme.fg("dim", `"${truncate(args.summary, 50)}"`);
+ }
+
+ if (args.jql) {
+ text += " " + theme.fg("dim", `"${truncate(args.jql, 50)}"`);
+ }
+
+ return new Text(text, 0, 0);
+ },
+
+ renderResult(result, { expanded }, theme) {
+ const details = result.details as JiraDetails | undefined;
+
+ if (!details) {
+ const text = result.content[0];
+ return new Text(text?.type === "text" ? text.text : "", 0, 0);
+ }
+
+ if (details.error) {
+ return new Text(theme.fg("error", `✗ Error: ${details.error}`), 0, 0);
+ }
+
+ if (details.cancelled) {
+ return new Text(theme.fg("warning", "✗ Cancelled by user"), 0, 0);
+ }
+
+ switch (details.action) {
+ case "me":
+ return renderMe(details, theme);
+
+ case "list":
+ case "search":
+ return renderList(details, expanded, theme);
+
+ case "view":
+ return renderView(details, expanded, theme);
+
+ case "create":
+ return renderCreate(details, theme);
+
+ case "update":
+ return renderUpdate(details, theme);
+
+ case "comment":
+ return renderComment(details, theme);
+
+ case "transition":
+ return renderTransition(details, theme);
+
+ case "epic-view":
+ return renderEpicView(details, expanded, theme);
+
+ case "link-to-epic":
+ return renderLinkToEpic(details, theme);
+
+ case "link":
+ return renderLink(details, theme);
+
+ case "unlink":
+ return renderUnlink(details, theme);
+
+ case "attach":
+ return renderAttach(details, theme);
+
+ case "list-attachments":
+ return renderListAttachments(details, expanded, theme);
+
+ default:
+ return new Text(details.output || "", 0, 0);
+ }
+ },
+ });
+
+ // ========================================================================
+ // Slash Commands
+ // ========================================================================
+
+ // /jira - Show my open issues
+ pi.registerCommand("jira", {
+ description: "Show my open Jira issues in SRVKP project",
+ handler: async (_args, ctx) => {
+ if (!ctx.hasUI) {
+ ctx.ui.notify("/jira requires interactive mode", "error");
+ return;
+ }
+
+ // Get current user if not cached
+ if (!currentUser) {
+ const meResult = await pi.exec("jira", ["me"], { timeout: 10000 });
+ if (meResult.code === 0) {
+ currentUser = meResult.stdout.trim();
+ }
+ }
+
+ // List my open issues directly (default to SRVKP project)
+ const args = [
+ "issue",
+ "list",
+ "--raw",
+ "--project",
+ "SRVKP",
+ "-a",
+ currentUser || "currentUser()",
+ "-s",
+ "~Done",
+ "--paginate",
+ "20",
+ ];
+
+ const result = await pi.exec("jira", args, { timeout: 30000 });
+
+ if (result.code !== 0) {
+ ctx.ui.notify(`Error: ${result.stderr}`, "error");
+ return;
+ }
+
+ const issues = parseIssueListJSON(result.stdout);
+
+ // Display results directly (like org-todos)
+ const lines: string[] = [];
+ lines.push("## 📋 My Open Issues (SRVKP)");
+ lines.push("");
+ lines.push(`_Command: \`jira ${args.join(" ")}\`_`);
+ lines.push("");
+
+ if (issues.length === 0) {
+ lines.push("*No open issues* ✨");
+ } else {
+ // Table format for better alignment
+ lines.push("| Key | Type | Priority | Status | Summary |");
+ lines.push("|-----|------|----------|--------|---------|");
+ for (const issue of issues) {
+ const priority = issue.priority || "-";
+ const summary = truncate(issue.summary, 80);
+ lines.push(`| ${issue.key} | ${issue.type} | ${priority} | ${issue.status} | ${summary} |`);
+ }
+ }
+
+ pi.sendMessage({
+ customType: "jira-list",
+ content: lines.join("\n"),
+ display: true,
+ });
+ },
+ });
+
+ // /jira-view <key> - View specific issue
+ pi.registerCommand("jira-view", {
+ description: "View a Jira issue (e.g., /jira-view SRVKP-1234)",
+ handler: async (args, ctx) => {
+ if (!args) {
+ ctx.ui.notify("Usage: /jira-view ISSUE-KEY", "error");
+ return;
+ }
+
+ const issueKey = args.trim();
+
+ // Validate issue key format (at least 2 uppercase letters, dash, numbers)
+ if (!/^[A-Z]{2,}-\d+$/.test(issueKey)) {
+ ctx.ui.notify(`Invalid issue key format: ${issueKey}`, "error");
+ return;
+ }
+
+ // View issue directly
+ const result = await pi.exec("jira", ["issue", "view", issueKey, "--plain"], { timeout: 20000 });
+
+ if (result.code !== 0) {
+ ctx.ui.notify(`Error: ${result.stderr}`, "error");
+ return;
+ }
+
+ // Display result directly
+ pi.sendMessage({
+ customType: "jira-view",
+ content: `## 🔍 ${issueKey}\n\n_Command: \`jira issue view ${issueKey} --plain\`_\n\n${result.stdout}`,
+ display: true,
+ });
+ },
+ });
+
+ // /jira-search <query> - Search with JQL
+ pi.registerCommand("jira-search", {
+ description: "Search Jira with JQL (defaults to SRVKP project)",
+ handler: async (args, ctx) => {
+ if (!args) {
+ ctx.ui.notify("Usage: /jira-search <JQL query>", "error");
+ return;
+ }
+
+ let jql = args.trim();
+
+ // Default to SRVKP project if no project specified in JQL
+ if (!jql.toLowerCase().includes("project")) {
+ jql = `project = SRVKP AND ${jql}`;
+ }
+
+ // Search directly
+ const result = await pi.exec("jira", ["issue", "list", "--raw", "--jql", jql, "--paginate", "50"], {
+ timeout: 30000,
+ });
+
+ if (result.code !== 0) {
+ ctx.ui.notify(`Error: ${result.stderr}`, "error");
+ return;
+ }
+
+ const issues = parseIssueListJSON(result.stdout);
+
+ // Display results directly
+ const lines: string[] = [];
+ lines.push(`## 🔎 Search Results`);
+ lines.push(`Query: \`${jql}\``);
+ lines.push("");
+
+ if (issues.length === 0) {
+ lines.push("*No issues found*");
+ } else {
+ // Table format
+ lines.push("| Key | Type | Priority | Status | Summary |");
+ lines.push("|-----|------|----------|--------|---------|");
+ for (const issue of issues) {
+ const priority = issue.priority || "-";
+ const summary = truncate(issue.summary, 80);
+ lines.push(`| ${issue.key} | ${issue.type} | ${priority} | ${issue.status} | ${summary} |`);
+ }
+ }
+
+ pi.sendMessage({
+ customType: "jira-search",
+ content: lines.join("\n"),
+ display: true,
+ });
+ },
+ });
+
+ // /jira-mine - My issues (shorthand)
+ pi.registerCommand("jira-mine", {
+ description: "Show all issues assigned to me in SRVKP",
+ handler: async (_args, ctx) => {
+ // Get current user if not cached
+ if (!currentUser) {
+ const meResult = await pi.exec("jira", ["me"], { timeout: 10000 });
+ if (meResult.code === 0) {
+ currentUser = meResult.stdout.trim();
+ }
+ }
+
+ // List all my issues directly (default to SRVKP)
+ const args = [
+ "issue",
+ "list",
+ "--raw",
+ "--project",
+ "SRVKP",
+ "-a",
+ currentUser || "currentUser()",
+ "--paginate",
+ "50",
+ ];
+
+ const result = await pi.exec("jira", args, { timeout: 30000 });
+
+ if (result.code !== 0) {
+ ctx.ui.notify(`Error: ${result.stderr}`, "error");
+ return;
+ }
+
+ const issues = parseIssueListJSON(result.stdout);
+
+ // Display results directly
+ const lines: string[] = [];
+ lines.push("## 👤 My Issues (SRVKP)");
+ lines.push("");
+ lines.push(`_Command: \`jira ${args.join(" ")}\`_`);
+ lines.push("");
+
+ if (issues.length === 0) {
+ lines.push("*No issues assigned to you*");
+ } else {
+ // Group by status
+ const byStatus = new Map<string, typeof issues>();
+ for (const issue of issues) {
+ const status = issue.status;
+ if (!byStatus.has(status)) {
+ byStatus.set(status, []);
+ }
+ byStatus.get(status)!.push(issue);
+ }
+
+ for (const [status, statusIssues] of byStatus) {
+ lines.push(`### ${status} (${statusIssues.length})`);
+ lines.push("");
+ lines.push("| Key | Type | Priority | Summary |");
+ lines.push("|-----|------|----------|---------|");
+ for (const issue of statusIssues) {
+ const priority = issue.priority || "-";
+ const summary = truncate(issue.summary, 80);
+ lines.push(`| ${issue.key} | ${issue.type} | ${priority} | ${summary} |`);
+ }
+ lines.push("");
+ }
+ }
+
+ pi.sendMessage({
+ customType: "jira-mine",
+ content: lines.join("\n"),
+ display: true,
+ });
+ },
+ });
+
+ // /jira-blocked - Blocked issues
+ pi.registerCommand("jira-blocked", {
+ description: "Show blocked issues in SRVKP project",
+ handler: async (_args, ctx) => {
+ // Search for blocked issues directly (default to SRVKP)
+ const args = ["issue", "list", "--raw", "--project", "SRVKP", "-s", "Blocked,Waiting", "--paginate", "50"];
+
+ const result = await pi.exec("jira", args, { timeout: 30000 });
+
+ if (result.code !== 0) {
+ ctx.ui.notify(`Error: ${result.stderr}`, "error");
+ return;
+ }
+
+ const issues = parseIssueListJSON(result.stdout);
+
+ // Display results directly
+ const lines: string[] = [];
+ lines.push("## 🚫 Blocked Issues (SRVKP)");
+ lines.push("");
+ lines.push(`_Command: \`jira ${args.join(" ")}\`_`);
+ lines.push("");
+
+ if (issues.length === 0) {
+ lines.push("*No blocked issues* ✅");
+ } else {
+ // Table format
+ lines.push("| Key | Type | Priority | Status | Assignee | Summary |");
+ lines.push("|-----|------|----------|--------|----------|---------|");
+ for (const issue of issues) {
+ const priority = issue.priority || "-";
+ const assignee = issue.assignee || "Unassigned";
+ const summary = truncate(issue.summary, 60);
+ lines.push(`| ${issue.key} | ${issue.type} | ${priority} | ${issue.status} | ${assignee} | ${summary} |`);
+ }
+ }
+
+ pi.sendMessage({
+ customType: "jira-blocked",
+ content: lines.join("\n"),
+ display: true,
+ });
+ },
+ });
+
+ // /jira-recent - Recent issues from session
+ pi.registerCommand("jira-recent", {
+ description: "Show recently viewed issues from this session",
+ handler: async (_args, ctx) => {
+ if (!ctx.hasUI) {
+ ctx.ui.notify("/jira-recent requires interactive mode", "error");
+ return;
+ }
+
+ if (recentIssues.length === 0) {
+ ctx.ui.notify("No recent issues in this session", "info");
+ return;
+ }
+
+ const message =
+ `Recent Jira issues:\n` + recentIssues.map((key, i) => `${i + 1}. ${key}`).join("\n");
+
+ ctx.ui.notify(message, "info");
+ },
+ });
+
+ // ========================================================================
+ // Auto-detect Jira Issue Keys in User Input
+ // ========================================================================
+
+ pi.on("input", async (event, ctx) => {
+ // Only process interactive input
+ if (event.source !== "interactive") {
+ return { action: "continue" };
+ }
+
+ // Detect Jira issue keys (e.g., SRVKP-1234, KONFLUX-456, etc.)
+ const issueKeyPattern = /\b([A-Z]{2,}-\d+)\b/g;
+ const matches = event.text.match(issueKeyPattern);
+
+ if (!matches || matches.length === 0) {
+ return { action: "continue" };
+ }
+
+ // Remove duplicates
+ const uniqueKeys = [...new Set(matches)];
+
+ // If user just typed issue keys without context, offer to view them
+ const justKeys = event.text.trim().split(/\s+/).every((word) => /^[A-Z]{2,}-\d+$/.test(word));
+
+ if (justKeys && uniqueKeys.length <= 3 && ctx.hasUI) {
+ // Transform to view request
+ if (uniqueKeys.length === 1) {
+ return {
+ action: "transform",
+ text: `View Jira issue ${uniqueKeys[0]}`,
+ };
+ } else {
+ return {
+ action: "transform",
+ text: `View Jira issues: ${uniqueKeys.join(", ")}`,
+ };
+ }
+ }
+
+ // Otherwise, just continue (LLM will see the keys in context)
+ return { action: "continue" };
+ });
+}
+
+// ============================================================================
+// Rendering Functions
+// ============================================================================
+
+function renderMe(details: JiraDetails, theme: Theme): Text {
+ return new Text(theme.fg("muted", `User: ${theme.fg("accent", details.output || "")}`), 0, 0);
+}
+
+function renderList(details: JiraDetails, expanded: boolean, theme: Theme): Text {
+ if (!details.output) {
+ return new Text(theme.fg("dim", "No issues found"), 0, 0);
+ }
+
+ const issues = parseIssueList(details.output);
+
+ if (issues.length === 0) {
+ return new Text(theme.fg("dim", "No issues found"), 0, 0);
+ }
+
+ let text = theme.fg("muted", `${issues.length} issue(s):`);
+
+ const display = expanded ? issues : issues.slice(0, 5);
+
+ for (const issue of display) {
+ const key = theme.fg("accent", issue.key);
+ const status = getStatusColor(issue.status, theme);
+ const priority = issue.priority ? getPriorityColor(issue.priority, theme) : "";
+ const summary = expanded ? issue.summary : truncate(issue.summary, 60);
+
+ text += `\n${key} ${status}`;
+ if (priority) {
+ text += ` ${priority}`;
+ }
+ text += ` ${theme.fg("text", summary)}`;
+ }
+
+ if (!expanded && issues.length > 5) {
+ text += `\n${theme.fg("dim", `... ${issues.length - 5} more (expand for all)`)}`;
+ }
+
+ return new Text(text, 0, 0);
+}
+
+function renderView(details: JiraDetails, expanded: boolean, theme: Theme): Text {
+ if (!details.output) {
+ return new Text(theme.fg("dim", "No issue details"), 0, 0);
+ }
+
+ const issueKey = details.issueKey || "Issue";
+ const header = theme.fg("accent", theme.bold(issueKey));
+
+ if (expanded) {
+ // Show full output
+ return new Text(`${header}\n\n${details.output}`, 0, 0);
+ } else {
+ // Show summary (first 15 lines)
+ const lines = details.output.split("\n");
+ const preview = lines.slice(0, 15).join("\n");
+
+ let text = `${header}\n\n${preview}`;
+
+ if (lines.length > 15) {
+ text += `\n${theme.fg("dim", `... ${lines.length - 15} more lines (expand for full view)`)}`;
+ }
+
+ return new Text(text, 0, 0);
+ }
+}
+
+function renderCreate(details: JiraDetails, theme: Theme): Text {
+ const key = details.issueKey || "issue";
+ return new Text(theme.fg("success", "✓ Created ") + theme.fg("accent", theme.bold(key)), 0, 0);
+}
+
+function renderUpdate(details: JiraDetails, theme: Theme): Text {
+ const key = details.issueKey || "issue";
+ const field = details.field || "field";
+ const value = details.newValue || "value";
+
+ return new Text(
+ theme.fg("success", "✓ Updated ") + theme.fg("accent", key) + theme.fg("muted", ` (${field} → ${value})`),
+ 0,
+ 0,
+ );
+}
+
+function renderComment(details: JiraDetails, theme: Theme): Text {
+ const key = details.issueKey || "issue";
+ return new Text(theme.fg("success", "✓ Comment added to ") + theme.fg("accent", key), 0, 0);
+}
+
+function renderTransition(details: JiraDetails, theme: Theme): Text {
+ const key = details.issueKey || "issue";
+ const state = details.toKey || "new state";
+
+ return new Text(theme.fg("success", "✓ Moved ") + theme.fg("accent", key) + theme.fg("muted", ` → ${state}`), 0, 0);
+}
+
+function renderEpicView(details: JiraDetails, expanded: boolean, theme: Theme): Text {
+ if (!details.output) {
+ return new Text(theme.fg("dim", "No epic details"), 0, 0);
+ }
+
+ const issueKey = details.issueKey || "Epic";
+ const header = theme.fg("accent", theme.bold(`Epic: ${issueKey}`));
+
+ if (expanded) {
+ return new Text(`${header}\n\n${details.output}`, 0, 0);
+ } else {
+ // Show summary (first 20 lines)
+ const lines = details.output.split("\n");
+ const preview = lines.slice(0, 20).join("\n");
+
+ let text = `${header}\n\n${preview}`;
+
+ if (lines.length > 20) {
+ text += `\n${theme.fg("dim", `... ${lines.length - 20} more lines (expand for full view)`)}`;
+ }
+
+ return new Text(text, 0, 0);
+ }
+}
+
+function renderLinkToEpic(details: JiraDetails, theme: Theme): Text {
+ const issue = details.fromKey || "issue";
+ const epic = details.toKey || "epic";
+
+ return new Text(theme.fg("success", "✓ Linked ") + theme.fg("accent", issue) + theme.fg("muted", ` → epic ${epic}`), 0, 0);
+}
+
+function renderLink(details: JiraDetails, theme: Theme): Text {
+ const from = details.fromKey || "issue";
+ const to = details.toKey || "issue";
+
+ return new Text(theme.fg("success", "✓ Linked ") + theme.fg("accent", from) + theme.fg("muted", ` ↔ ${to}`), 0, 0);
+}
+
+function renderUnlink(details: JiraDetails, theme: Theme): Text {
+ const from = details.fromKey || "issue";
+ const to = details.toKey || "issue";
+
+ return new Text(theme.fg("success", "✓ Unlinked ") + theme.fg("accent", from) + theme.fg("muted", ` ↮ ${to}`), 0, 0);
+}
+
+function renderAttach(details: JiraDetails, theme: Theme): Text {
+ const key = details.issueKey || "issue";
+
+ return new Text(theme.fg("success", "✓ Attached file to ") + theme.fg("accent", key), 0, 0);
+}
+
+function renderListAttachments(details: JiraDetails, expanded: boolean, theme: Theme): Text {
+ if (!details.output) {
+ return new Text(theme.fg("dim", "No attachments"), 0, 0);
+ }
+
+ const issueKey = details.issueKey || "Issue";
+ const header = theme.fg("accent", `${issueKey} attachments:`);
+
+ if (expanded || details.output.split("\n").length <= 10) {
+ return new Text(`${header}\n${details.output}`, 0, 0);
+ } else {
+ const lines = details.output.split("\n");
+ const preview = lines.slice(0, 10).join("\n");
+ return new Text(`${header}\n${preview}\n${theme.fg("dim", `... ${lines.length - 10} more`)}`, 0, 0);
+ }
+}
dots/pi/agent/extensions/jira/jira.test.ts
@@ -0,0 +1,400 @@
+/**
+ * Tests for Jira extension
+ *
+ * Run with: bun test dots/pi/agent/extensions/jira/test.ts
+ * or: npm test
+ */
+
+import { describe, expect, test } from "bun:test";
+import { parseIssueList, parseIssueListJSON, extractIssueKey, extractIssueKeys, getStatusColor, getPriorityColor, truncate } from "./utils";
+
+// Mock theme for testing
+const mockTheme = {
+ fg: (color: string, text: string) => text,
+ bold: (text: string) => text,
+};
+
+describe("Utility Functions", () => {
+ describe("parseIssueListJSON", () => {
+ test("parses jira CLI JSON output", () => {
+ const output = JSON.stringify([
+ {
+ key: "SRVKP-1234",
+ fields: {
+ summary: "Test summary",
+ issueType: { name: "Bug" },
+ status: { name: "To Do" },
+ assignee: { displayName: "Alice" },
+ priority: { name: "Major" },
+ },
+ },
+ {
+ key: "SRVKP-5678",
+ fields: {
+ summary: "Another issue",
+ issueType: { name: "Epic" },
+ status: { name: "In Progress" },
+ assignee: { displayName: "Bob" },
+ priority: { name: "Undefined" },
+ },
+ },
+ ]);
+
+ const issues = parseIssueListJSON(output);
+
+ expect(issues.length).toBe(2);
+ expect(issues[0].key).toBe("SRVKP-1234");
+ expect(issues[0].type).toBe("Bug");
+ expect(issues[0].summary).toBe("Test summary");
+ expect(issues[0].status).toBe("To Do");
+ expect(issues[0].assignee).toBe("Alice");
+ expect(issues[0].priority).toBe("Major");
+ expect(issues[1].key).toBe("SRVKP-5678");
+ expect(issues[1].type).toBe("Epic");
+ expect(issues[1].priority).toBeUndefined(); // "Undefined" is filtered out
+ });
+
+ test("handles unassigned issues", () => {
+ const output = JSON.stringify([
+ {
+ key: "SRVKP-1234",
+ fields: {
+ summary: "Test",
+ issueType: { name: "Task" },
+ status: { name: "To Do" },
+ priority: { name: "Undefined" },
+ },
+ },
+ ]);
+
+ const issues = parseIssueListJSON(output);
+
+ expect(issues.length).toBe(1);
+ expect(issues[0].assignee).toBe("Unassigned");
+ expect(issues[0].priority).toBeUndefined();
+ });
+
+ test("handles invalid JSON", () => {
+ const issues = parseIssueListJSON("not json");
+ expect(issues.length).toBe(0);
+ });
+
+ test("handles empty array", () => {
+ const issues = parseIssueListJSON("[]");
+ expect(issues.length).toBe(0);
+ });
+ });
+
+ describe("parseIssueList", () => {
+ test("parses jira CLI plain output (TAB-delimited with alignment)", () => {
+ // Simulate jira CLI output with multiple TABs for alignment
+ const output = `Bug\tSRVKP-1234\t\tSummary text here\t\t\tTo Do
+Task\tSRVKP-5678\tAnother summary\t\tIn Progress`;
+
+ const issues = parseIssueList(output);
+
+ expect(issues.length).toBe(2);
+ expect(issues[0].key).toBe("SRVKP-1234");
+ expect(issues[0].type).toBe("Bug");
+ expect(issues[0].summary).toBe("Summary text here");
+ expect(issues[0].status).toBe("To Do");
+ expect(issues[0].assignee).toBe("Unassigned"); // Default
+ expect(issues[1].key).toBe("SRVKP-5678");
+ expect(issues[1].summary).toBe("Another summary");
+ });
+
+ test("skips header lines", () => {
+ const output = `TYPE\tKEY\t\tSUMMARY\t\t\tSTATUS
+Bug\tSRVKP-1234\t\tSummary\t\t\tDone`;
+
+ const issues = parseIssueList(output);
+
+ expect(issues.length).toBe(1);
+ expect(issues[0].key).toBe("SRVKP-1234");
+ });
+
+ test("handles empty output", () => {
+ const issues = parseIssueList("");
+ expect(issues.length).toBe(0);
+ });
+
+ test("handles minimal columns", () => {
+ const output = `Bug\tSRVKP-1234\tSummary\tTo Do`;
+
+ const issues = parseIssueList(output);
+
+ expect(issues.length).toBe(1);
+ expect(issues[0].assignee).toBe("Unassigned");
+ expect(issues[0].status).toBe("To Do");
+ });
+ });
+
+ describe("extractIssueKey", () => {
+ test("extracts issue key from text", () => {
+ expect(extractIssueKey("Created SRVKP-1234 successfully")).toBe("SRVKP-1234");
+ expect(extractIssueKey("Issue KONFLUX-456 updated")).toBe("KONFLUX-456");
+ });
+
+ test("returns null when no key found", () => {
+ expect(extractIssueKey("No issue key here")).toBeNull();
+ });
+
+ test("extracts first key when multiple present", () => {
+ expect(extractIssueKey("SRVKP-1234 and SRVKP-5678")).toBe("SRVKP-1234");
+ });
+ });
+
+ describe("extractIssueKeys", () => {
+ test("extracts all issue keys", () => {
+ const keys = extractIssueKeys("SRVKP-1234 KONFLUX-456 RHCLOUD-789");
+ expect(keys).toEqual(["SRVKP-1234", "KONFLUX-456", "RHCLOUD-789"]);
+ });
+
+ test("removes duplicates", () => {
+ const keys = extractIssueKeys("SRVKP-1234 SRVKP-1234 SRVKP-1234");
+ expect(keys).toEqual(["SRVKP-1234"]);
+ });
+
+ test("matches issue keys in sentences", () => {
+ const keys = extractIssueKeys("Working on SRVKP-1234 which relates to KONFLUX-456");
+ expect(keys).toEqual(["SRVKP-1234", "KONFLUX-456"]);
+ });
+
+ test("doesn't match invalid patterns", () => {
+ const keys = extractIssueKeys("A-1 lowercase-123 NO-KEY");
+ expect(keys).toEqual([]); // None of these match the pattern (A-1 too short, lowercase-123 lowercase, NO-KEY missing digits)
+ });
+
+ test("returns empty array when none found", () => {
+ const keys = extractIssueKeys("no keys here");
+ expect(keys).toEqual([]);
+ });
+ });
+
+ describe("getStatusColor", () => {
+ test("returns success color for done status", () => {
+ const result = getStatusColor("Done", mockTheme as any);
+ expect(result).toContain("[Done]");
+ });
+
+ test("returns success color for closed status", () => {
+ const result = getStatusColor("Closed", mockTheme as any);
+ expect(result).toContain("[Closed]");
+ });
+
+ test("returns accent color for in progress", () => {
+ const result = getStatusColor("In Progress", mockTheme as any);
+ expect(result).toContain("[In Progress]");
+ });
+
+ test("returns error color for blocked", () => {
+ const result = getStatusColor("Blocked", mockTheme as any);
+ expect(result).toContain("[Blocked]");
+ });
+
+ test("returns muted color for other statuses", () => {
+ const result = getStatusColor("To Do", mockTheme as any);
+ expect(result).toContain("[To Do]");
+ });
+
+ test("is case insensitive", () => {
+ expect(getStatusColor("DONE", mockTheme as any)).toContain("[DONE]");
+ expect(getStatusColor("blocked", mockTheme as any)).toContain("[blocked]");
+ });
+ });
+
+ describe("getPriorityColor", () => {
+ test("returns error color for blocker", () => {
+ const result = getPriorityColor("Blocker", mockTheme as any);
+ expect(result).toBe("Blocker");
+ });
+
+ test("returns error color for critical", () => {
+ const result = getPriorityColor("Critical", mockTheme as any);
+ expect(result).toBe("Critical");
+ });
+
+ test("returns warning color for major", () => {
+ const result = getPriorityColor("Major", mockTheme as any);
+ expect(result).toBe("Major");
+ });
+
+ test("returns dim color for minor", () => {
+ const result = getPriorityColor("Minor", mockTheme as any);
+ expect(result).toBe("Minor");
+ });
+
+ test("is case insensitive", () => {
+ expect(getPriorityColor("BLOCKER", mockTheme as any)).toBe("BLOCKER");
+ });
+ });
+
+ describe("truncate", () => {
+ test("truncates long text", () => {
+ const result = truncate("This is a very long text that should be truncated", 20);
+ expect(result).toBe("This is a very lo...");
+ expect(result.length).toBe(20);
+ });
+
+ test("doesn't truncate short text", () => {
+ const result = truncate("Short", 20);
+ expect(result).toBe("Short");
+ });
+
+ test("handles exact length", () => {
+ const result = truncate("Exactly 20 chars!!!!", 20);
+ expect(result).toBe("Exactly 20 chars!!!!");
+ });
+ });
+});
+
+describe("Issue Key Detection", () => {
+ describe("Pattern Matching", () => {
+ test("matches standard project keys", () => {
+ const pattern = /\b([A-Z]{2,}-\d+)\b/g;
+
+ expect("SRVKP-1234".match(pattern)).toEqual(["SRVKP-1234"]);
+ expect("KONFLUX-456".match(pattern)).toEqual(["KONFLUX-456"]);
+ expect("RHCLOUD-789".match(pattern)).toEqual(["RHCLOUD-789"]);
+ });
+
+ test("doesn't match single letter projects", () => {
+ const pattern = /\b([A-Z]{2,}-\d+)\b/g;
+
+ expect("A-1".match(pattern)).toBeNull();
+ expect("X-999".match(pattern)).toBeNull();
+ });
+
+ test("doesn't match lowercase", () => {
+ const pattern = /\b([A-Z]{2,}-\d+)\b/g;
+
+ expect("srvkp-1234".match(pattern)).toBeNull();
+ expect("lowercase-123".match(pattern)).toBeNull();
+ });
+
+ test("matches multiple keys in text", () => {
+ const pattern = /\b([A-Z]{2,}-\d+)\b/g;
+ const text = "Working on SRVKP-1234 and KONFLUX-456";
+ const matches = text.match(pattern);
+
+ expect(matches).toEqual(["SRVKP-1234", "KONFLUX-456"]);
+ });
+
+ test("matches keys with commas and punctuation", () => {
+ const pattern = /\b([A-Z]{2,}-\d+)\b/g;
+ const text = "Issues: SRVKP-1234, SRVKP-5678, and KONFLUX-999.";
+ const matches = text.match(pattern);
+
+ expect(matches).toEqual(["SRVKP-1234", "SRVKP-5678", "KONFLUX-999"]);
+ });
+ });
+
+ describe("Detection Logic", () => {
+ test("detects bare issue keys", () => {
+ const input = "SRVKP-1234";
+ const justKeys = input.trim().split(/\s+/).every((word) => /^[A-Z]{2,}-\d+$/.test(word));
+
+ expect(justKeys).toBe(true);
+ });
+
+ test("detects multiple bare keys", () => {
+ const input = "SRVKP-1234 SRVKP-5678";
+ const justKeys = input.trim().split(/\s+/).every((word) => /^[A-Z]{2,}-\d+$/.test(word));
+
+ expect(justKeys).toBe(true);
+ });
+
+ test("doesn't detect keys in context", () => {
+ const input = "Working on SRVKP-1234";
+ const justKeys = input.trim().split(/\s+/).every((word) => /^[A-Z]{2,}-\d+$/.test(word));
+
+ expect(justKeys).toBe(false);
+ });
+
+ test("doesn't detect with mixed content", () => {
+ const input = "SRVKP-1234 and some text";
+ const justKeys = input.trim().split(/\s+/).every((word) => /^[A-Z]{2,}-\d+$/.test(word));
+
+ expect(justKeys).toBe(false);
+ });
+ });
+});
+
+describe("Confirmation Message Building", () => {
+ test("buildCreateConfirmation includes all fields", () => {
+ const { buildCreateConfirmation } = require("./utils");
+
+ const params = {
+ issueType: "Bug",
+ summary: "Test bug",
+ description: "Test description",
+ priority: "Major",
+ assignee: "alice",
+ labels: ["bug", "urgent"],
+ epic: "SRVKP-1000",
+ };
+
+ const message = buildCreateConfirmation(params);
+
+ expect(message).toContain("Type: Bug");
+ expect(message).toContain('Summary: "Test bug"');
+ expect(message).toContain("Priority: Major");
+ expect(message).toContain("Assignee: alice");
+ expect(message).toContain("Labels: bug, urgent");
+ expect(message).toContain("Epic: SRVKP-1000");
+ });
+
+ test("buildCreateConfirmation truncates long description", () => {
+ const { buildCreateConfirmation } = require("./utils");
+
+ const params = {
+ issueType: "Bug",
+ summary: "Test",
+ description: "a".repeat(150),
+ };
+
+ const message = buildCreateConfirmation(params);
+
+ expect(message).toContain("Description:");
+ expect(message).toContain("...");
+ });
+});
+
+describe("Error Handling", () => {
+ test("isAuthError detects authentication failures", () => {
+ const { isAuthError } = require("./utils");
+
+ expect(isAuthError("authentication failed")).toBe(true);
+ expect(isAuthError("unauthorized access")).toBe(true);
+ expect(isAuthError("invalid token provided")).toBe(true);
+ expect(isAuthError("permission denied")).toBe(true);
+ expect(isAuthError("network timeout")).toBe(false);
+ });
+
+ test("isNetworkError detects network failures", () => {
+ const { isNetworkError } = require("./utils");
+
+ expect(isNetworkError("connection refused")).toBe(true);
+ expect(isNetworkError("timeout waiting for response")).toBe(true);
+ expect(isNetworkError("network unreachable")).toBe(true);
+ expect(isNetworkError("dial tcp: connection failed")).toBe(true);
+ expect(isNetworkError("authentication failed")).toBe(false);
+ });
+
+ test("isNotFoundError detects not found errors", () => {
+ const { isNotFoundError } = require("./utils");
+
+ expect(isNotFoundError("issue not found")).toBe(true);
+ expect(isNotFoundError("does not exist")).toBe(true);
+ expect(isNotFoundError("authentication failed")).toBe(false);
+ });
+
+ test("getErrorMessage returns helpful messages", () => {
+ const { getErrorMessage } = require("./utils");
+
+ expect(getErrorMessage("authentication failed", "list")).toContain("API token");
+ expect(getErrorMessage("connection timeout", "view")).toContain("VPN");
+ expect(getErrorMessage("not found", "view")).toContain("not found");
+ expect(getErrorMessage("unknown error", "create")).toBe("unknown error");
+ });
+});
dots/pi/agent/extensions/jira/link-actions.ts
@@ -0,0 +1,157 @@
+/**
+ * Link-related action handlers for Jira extension
+ */
+
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+import type { JiraDetails } from "./types";
+import { getErrorMessage } from "./utils";
+
+/**
+ * Link two issues together
+ */
+export async function handleLink(
+ pi: ExtensionAPI,
+ params: any,
+ signal: AbortSignal | undefined,
+ onUpdate: any,
+ ctx: ExtensionContext,
+): Promise<any> {
+ if (!params.from) {
+ return {
+ content: [{ type: "text", text: "Error: 'from' parameter is required for link action" }],
+ details: { action: "link", error: "missing_from" } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ if (!params.to) {
+ return {
+ content: [{ type: "text", text: "Error: 'to' parameter is required for link action" }],
+ details: { action: "link", error: "missing_to" } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ if (!params.linkType) {
+ return {
+ content: [{ type: "text", text: "Error: 'linkType' parameter is required for link action" }],
+ details: { action: "link", error: "missing_linkType" } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ // APPROVAL GATE
+ if (ctx.hasUI) {
+ const confirmMessage =
+ `From: ${params.from}\n` +
+ `To: ${params.to}\n` +
+ `Link type: ${params.linkType}\n\n` +
+ `This will create an issue link.`;
+
+ const confirmed = await ctx.ui.confirm("Link issues?", confirmMessage);
+
+ if (!confirmed) {
+ ctx.ui.notify("Link cancelled", "info");
+ return {
+ content: [{ type: "text", text: "Link operation cancelled by user" }],
+ details: { action: "link", cancelled: true } as JiraDetails,
+ };
+ }
+ }
+
+ onUpdate?.({ content: [{ type: "text", text: `Linking ${params.from} to ${params.to}...` }] });
+
+ // Use jira CLI to create link
+ const result = await pi.exec(
+ "jira",
+ ["issue", "link", params.from, params.to, params.linkType],
+ { signal, timeout: 20000 },
+ );
+
+ if (result.code !== 0) {
+ return {
+ content: [{ type: "text", text: getErrorMessage(result.stderr, "Link issues") }],
+ details: { action: "link", error: result.stderr } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ return {
+ content: [{ type: "text", text: `Linked ${params.from} ${params.linkType} ${params.to}` }],
+ details: {
+ action: "link",
+ output: result.stdout,
+ fromKey: params.from,
+ toKey: params.to,
+ } as JiraDetails,
+ };
+}
+
+/**
+ * Unlink two issues
+ */
+export async function handleUnlink(
+ pi: ExtensionAPI,
+ params: any,
+ signal: AbortSignal | undefined,
+ onUpdate: any,
+ ctx: ExtensionContext,
+): Promise<any> {
+ if (!params.from) {
+ return {
+ content: [{ type: "text", text: "Error: 'from' parameter is required for unlink action" }],
+ details: { action: "unlink", error: "missing_from" } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ if (!params.to) {
+ return {
+ content: [{ type: "text", text: "Error: 'to' parameter is required for unlink action" }],
+ details: { action: "unlink", error: "missing_to" } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ // APPROVAL GATE
+ if (ctx.hasUI) {
+ const confirmMessage = `From: ${params.from}\nTo: ${params.to}\n\nThis will remove the issue link.`;
+
+ const confirmed = await ctx.ui.confirm("Unlink issues?", confirmMessage);
+
+ if (!confirmed) {
+ ctx.ui.notify("Unlink cancelled", "info");
+ return {
+ content: [{ type: "text", text: "Unlink operation cancelled by user" }],
+ details: { action: "unlink", cancelled: true } as JiraDetails,
+ };
+ }
+ }
+
+ onUpdate?.({ content: [{ type: "text", text: `Unlinking ${params.from} from ${params.to}...` }] });
+
+ // Use jira CLI to remove link
+ const result = await pi.exec(
+ "jira",
+ ["issue", "unlink", params.from, params.to],
+ { signal, timeout: 20000 },
+ );
+
+ if (result.code !== 0) {
+ return {
+ content: [{ type: "text", text: getErrorMessage(result.stderr, "Unlink issues") }],
+ details: { action: "unlink", error: result.stderr } as JiraDetails,
+ isError: true,
+ };
+ }
+
+ return {
+ content: [{ type: "text", text: `Unlinked ${params.from} from ${params.to}` }],
+ details: {
+ action: "unlink",
+ output: result.stdout,
+ fromKey: params.from,
+ toKey: params.to,
+ } as JiraDetails,
+ };
+}
dots/pi/agent/extensions/jira/Makefile
@@ -0,0 +1,21 @@
+.PHONY: test test-watch help
+
+# Run tests
+test:
+ @echo "Running tests..."
+ @bun test jira.test.ts
+
+# Run tests in watch mode
+test-watch:
+ @echo "Running tests in watch mode..."
+ @bun test --watch jira.test.ts
+
+# Help
+help:
+ @echo "Available targets:"
+ @echo " test - Run tests once"
+ @echo " test-watch - Run tests in watch mode"
+ @echo " help - Show this help message"
+
+# Default target
+.DEFAULT_GOAL := help
dots/pi/agent/extensions/jira/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "jira-extension",
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "test": "bun test test.ts"
+ },
+ "devDependencies": {
+ "bun-types": "^1.0.0"
+ }
+}
dots/pi/agent/extensions/jira/QUICK-START.md
@@ -0,0 +1,269 @@
+# Jira Extension - Quick Start
+
+## 🚀 Quick Commands
+
+### Slash Commands
+
+```bash
+# Your open issues
+/jira
+
+# All your issues
+/jira-mine
+
+# View specific issue
+/jira-view SRVKP-1234
+
+# Search
+/jira-search priority=Blocker
+
+# Blocked issues
+/jira-blocked
+
+# Recently viewed
+/jira-recent
+```
+
+## ✨ Auto-Detection
+
+Just paste issue keys - they'll be auto-viewed!
+
+```bash
+# Single issue
+SRVKP-1234
+
+# Multiple issues
+SRVKP-1234 KONFLUX-456
+
+# In context (passed through)
+"Working on SRVKP-1234 which relates to KONFLUX-456"
+```
+
+## 📋 Common Workflows
+
+### Morning Standup
+
+```bash
+# What did I work on?
+/jira-recent
+
+# What am I working on?
+/jira
+
+# Any blockers?
+/jira-blocked
+```
+
+### Issue Review
+
+```bash
+# Someone sends you: "Can you look at SRVKP-1234?"
+# Just paste it:
+SRVKP-1234
+# → Auto-views the issue
+```
+
+### Search and Filter
+
+```bash
+# Critical bugs
+/jira-search type=Bug AND priority=Critical
+
+# Issues in epic
+/jira-search "Epic Link"=SRVKP-1000
+
+# Recently updated
+/jira-search updated >= -7d
+```
+
+## 🎯 Tool Actions
+
+All actions available through natural language:
+
+### Read (No Approval)
+
+```
+"What's my Jira username?" → me
+"Show my open issues" → list
+"Show SRVKP-1234" → view
+"Search for blocker issues" → search
+```
+
+### Write (Requires Approval)
+
+```
+"Create a bug for X" → create ⚠️
+"Assign SRVKP-1234 to me" → update ⚠️
+"Add comment to SRVKP-1234" → comment ⚠️
+"Move SRVKP-1234 to In Progress" → transition ⚠️
+```
+
+## 🔑 Supported Projects
+
+Works with **any** Jira project:
+- SRVKP (Tekton Pipelines)
+- KONFLUX (Konflux)
+- RHCLOUD (Cloud Services)
+- Any `[A-Z]{2,}-\d+` pattern
+
+## 📊 Filter Examples
+
+### By Assignee
+```
+"Show issues assigned to alice"
+→ list, assignee=alice
+
+"Show my issues"
+→ list, assignee=me
+```
+
+### By Status
+```
+"Show To Do issues"
+→ list, status="To Do"
+
+"Show open issues" (not Done)
+→ list, status=~Done
+```
+
+### By Type
+```
+"Show bugs"
+→ list, type=Bug
+
+"Show bugs and tasks"
+→ list, type="Bug,Task"
+```
+
+### By Priority
+```
+"Show critical issues"
+→ list, priority=Critical
+
+"Show high priority items"
+→ list, priority="Critical,Major"
+```
+
+### By Epic
+```
+"Show issues in epic SRVKP-1000"
+→ list, epic=SRVKP-1000
+```
+
+### Combined
+```
+"Show my high priority bugs not done"
+→ list, assignee=me, type=Bug, priority="Critical,Major", status=~Done
+```
+
+## 🎨 Custom Rendering
+
+Issues are displayed with color-coded status:
+
+- 🟢 **Done/Closed** - Success (green)
+- 🔵 **In Progress/Review** - Accent (blue)
+- 🔴 **Blocked/Waiting** - Error (red)
+- ⚪ **To Do** - Muted (gray)
+
+Priorities are also color-coded:
+- 🔴 **Blocker/Critical** - Error (red)
+- 🟡 **Major/High** - Warning (yellow)
+- ⚪ **Minor/Trivial** - Dim (gray)
+
+## ⚠️ Approval Dialogs
+
+All write operations show a confirmation dialog:
+
+```
+┌─────────────────────────────────┐
+│ Create Jira Issue? │
+│ │
+│ Type: Bug │
+│ Summary: "CI tests failing" │
+│ Priority: Major │
+│ │
+│ This will create a new issue │
+│ in Jira. │
+│ │
+│ [Yes] [No] │
+└─────────────────────────────────┘
+```
+
+You must confirm before any change is made!
+
+## 🔍 JQL Examples
+
+For `/jira-search` command:
+
+```bash
+# Your open issues
+/jira-search assignee=currentUser() AND status!=Done
+
+# Blocker issues
+/jira-search priority=Blocker
+
+# Recent updates
+/jira-search updated >= -7d
+
+# Issues in project
+/jira-search project=SRVKP AND status!=Done
+
+# Epic children
+/jira-search "Epic Link"=SRVKP-1000
+
+# Unassigned tasks
+/jira-search type=Task AND assignee=EMPTY
+
+# Complex query
+/jira-search project=SRVKP AND priority IN (Critical,Blocker) AND status!=Done ORDER BY updated DESC
+```
+
+## 🏃 Quick Tips
+
+1. **Just paste issue keys** - fastest way to view
+2. **Use `/jira`** for daily standup prep
+3. **Use `/jira-recent`** to see what you've looked at
+4. **Search with JQL** for complex queries
+5. **Watch for approval dialogs** on write operations
+6. **Use "me"** as assignee to refer to yourself
+
+## 📚 Full Documentation
+
+See `README.md` for complete documentation including:
+- All tool parameters
+- Error handling
+- Configuration
+- Integration patterns
+- Troubleshooting
+
+## 🐛 Troubleshooting
+
+### Extension not loading?
+```bash
+# Check pi output for errors
+pi
+```
+
+### Authentication issues?
+```bash
+# Test jira CLI
+jira me
+
+# Check config
+cat ~/.config/.jira/.config.yml
+
+# Check token
+passage show redhat/issues/token/kyushu
+```
+
+### Network errors?
+- Verify VPN connection
+- Try: `jira me` from terminal
+
+### Slash commands not working?
+- Type exactly: `/jira` (no extra spaces)
+- Check command exists: type `/j` and hit Tab
+
+## 🎉 Have Fun!
+
+The extension makes Jira management fast and seamless. No more switching to the browser for every little thing!
dots/pi/agent/extensions/jira/README.md
@@ -0,0 +1,686 @@
+
+
+# Jira Extension for Pi
+
+Manage Red Hat Jira issues (issues.redhat.com) directly from pi with read operations and approval-gated write operations.
+
+## Features
+
+### Read Operations (No Approval Required)
+- **`me`** - Get current Jira user
+- **`list`** - List issues with filters (assignee, status, type, priority, epic)
+- **`view`** - View detailed issue information
+- **`search`** - Advanced JQL-based search
+- **`epic-view`** - View epic with all child issues
+- **`list-attachments`** - List attachments on an issue
+
+### Write Operations (Require User Approval)
+- **`create`** - Create new issues (Bug, Task, Story, Epic, Feature)
+- **`update`** - Update issue fields (assignee, priority, labels, etc.)
+- **`comment`** - Add comments to issues
+- **`transition`** - Change issue workflow state
+- **`link-to-epic`** - Link issue to epic
+- **`link`** - Link two issues (blocks, relates to, etc.)
+- **`unlink`** - Remove issue link
+- **`attach`** - Attach file to issue
+
+All write operations show a confirmation dialog before executing, preventing accidental changes.
+
+## Prerequisites
+
+### 1. Jira CLI
+
+Install the jira CLI tool:
+
+```bash
+go install github.com/ankitpokhrel/jira-cli/cmd/jira@latest
+```
+
+Verify installation:
+
+```bash
+jira version
+```
+
+### 2. Jira Configuration
+
+Configure at `~/.config/.jira/.config.yml`:
+
+```yaml
+server: https://issues.redhat.com
+login: your-email@redhat.com
+project:
+ key: SRVKP # Your default project
+installation: local
+auth_type: bearer
+```
+
+### 3. API Token
+
+Set up via passage (or environment variable):
+
+```bash
+# Via passage (recommended)
+passage show redhat/issues/token/kyushu
+
+# OR via environment variable
+export JIRA_API_TOKEN="your-token-here"
+```
+
+### 4. VPN Connection
+
+Red Hat Jira requires VPN connection. Make sure you're connected before using the extension.
+
+### 5. Test Authentication
+
+```bash
+jira me
+```
+
+## Installation
+
+The extension is already in your pi extensions:
+
+```
+dots/pi/agent/extensions/jira/
+├── index.ts # Main extension
+├── actions.ts # Action handlers
+├── types.ts # Type definitions
+├── utils.ts # Utilities
+├── README.md # This file
+└── DESIGN-NOTES.md # Design discussion
+```
+
+Since it's in the `dots/` directory, it will be symlinked to `~/.pi/agent/extensions/jira/` when you deploy your dots.
+
+## Slash Commands
+
+The extension provides several slash commands for **instant results** (no LLM involved - works like `/todos`):
+
+| Command | Description |
+|---------|-------------|
+| `/jira` | Show my open issues (instant) |
+| `/jira-mine` | Show all issues assigned to me (grouped by status) |
+| `/jira-view <key>` | View specific issue (e.g., `/jira-view SRVKP-1234`) |
+| `/jira-search <jql>` | Search with JQL (e.g., `/jira-search status=Blocked`) |
+| `/jira-blocked` | Show blocked issues (instant) |
+| `/jira-recent` | Show recently viewed issues from this session |
+
+**All commands execute directly** - they call the jira CLI and display results immediately without going through the LLM!
+
+### Examples
+
+```bash
+# Quick view of your issues (instant!)
+/jira
+→ Shows formatted list immediately
+
+# View specific issue (instant!)
+/jira-view SRVKP-1234
+→ Shows issue details immediately
+
+# Search for blockers (instant!)
+/jira-search priority=Blocker
+→ Shows search results immediately
+
+# See blocked issues (instant!)
+/jira-blocked
+→ Shows all blocked/waiting issues
+
+# See what you've been working on
+/jira-recent
+→ Shows list from session cache
+```
+
+## Auto-Detection of Issue Keys
+
+The extension automatically detects Jira issue keys (e.g., `SRVKP-1234`, `KONFLUX-456`) in your input and transforms them into view requests.
+
+### Examples
+
+```bash
+# Just type the issue key
+SRVKP-1234
+→ Automatically transformed to: "View Jira issue SRVKP-1234"
+
+# Multiple keys
+SRVKP-1234 SRVKP-5678
+→ Automatically transformed to: "View Jira issues: SRVKP-1234, SRVKP-5678"
+
+# Keys in context (no transformation)
+"I'm working on SRVKP-1234 which relates to KONFLUX-456"
+→ Passed through to LLM with context
+```
+
+This makes it super quick to view issues - just paste the issue key!
+
+## Usage Examples
+
+### Quick Commands
+
+```bash
+# Slash command - show my issues
+/jira
+→ Lists all your open issues
+
+# Auto-detection - just paste issue key
+SRVKP-1234
+→ Automatically views the issue
+
+# Quick search
+/jira-search assignee=currentUser() AND status=Blocked
+→ Shows your blocked issues
+
+# Recent issues
+/jira-recent
+→ Shows issues you've viewed in this session
+```
+
+### Get Current User
+
+```
+User: "What is my Jira username?"
+Agent: <calls jira tool with action=me>
+→ Returns: "Current user: vdemeest"
+```
+
+### List Your Open Issues
+
+```
+User: "Show me my open Jira issues"
+Agent: <calls jira tool with action=list, assignee=me, status=~Done>
+→ Returns list of issues not in Done status
+```
+
+### View Issue Details
+
+```
+User: "Show me details for SRVKP-1234"
+Agent: <calls jira tool with action=view, key=SRVKP-1234>
+→ Returns full issue details
+```
+
+### Filter by Type and Priority
+
+```
+User: "Show high priority bugs"
+Agent: <calls jira tool with action=list, type=Bug, priority=High,Critical>
+→ Returns filtered list
+```
+
+### Create Issue (With Approval)
+
+```
+User: "Create a bug for the failing CI tests"
+Agent: <calls jira tool with action=create, issueType=Bug, summary=...>
+→ Shows approval dialog:
+ ┌─────────────────────────────┐
+ │ Create Jira Issue? │
+ │ │
+ │ Type: Bug │
+ │ Summary: "CI tests failing" │
+ │ Priority: Major │
+ │ │
+ │ This will create a new │
+ │ issue in Jira. │
+ │ │
+ │ [Yes] [No] │
+ └─────────────────────────────┘
+→ After confirmation: Creates issue and returns key
+```
+
+### Update Issue (With Approval)
+
+```
+User: "Assign SRVKP-1234 to me"
+Agent: <calls jira tool with action=update, key=SRVKP-1234, field=assignee, value=me>
+→ Shows approval dialog
+→ After confirmation: Updates issue
+```
+
+### Add Comment (With Approval)
+
+```
+User: "Add a comment to SRVKP-1234 saying I'm working on it"
+Agent: <calls jira tool with action=comment, key=SRVKP-1234, comment=...>
+→ Shows approval dialog with comment preview
+→ After confirmation: Adds comment
+```
+
+### Transition Issue (With Approval)
+
+```
+User: "Move SRVKP-1234 to In Progress"
+Agent: <calls jira tool with action=transition, key=SRVKP-1234, state="In Progress">
+→ Shows approval dialog
+→ After confirmation: Transitions issue
+```
+
+### Advanced Search
+
+```
+User: "Find all blocker issues in SRVKP project"
+Agent: <calls jira tool with action=search, jql="project = SRVKP AND priority = Blocker">
+→ Returns matching issues
+```
+
+## Tool Parameters
+
+### Common Parameters
+
+- **`assignee`** - Username or "me" for current user
+- **`status`** - Status name (use "~Done" for "not Done")
+- **`type`** - Bug, Task, Story, Epic, Feature (comma-separated)
+- **`priority`** - Blocker, Critical, Major, Minor, Trivial
+- **`limit`** - Max results (default 20 for list, 50 for search)
+- **`epic`** - Filter by epic key
+- **`key`** - Issue key (e.g., SRVKP-1234)
+
+### Create Parameters
+
+- **`issueType`** - Bug, Task, Story, Epic, Feature, Sub-task
+- **`summary`** - Issue title (required)
+- **`description`** - Issue description
+- **`priority`** - Priority level
+- **`assignee`** - Assignee (or "me")
+- **`labels`** - Array of labels
+- **`parent`** - Parent issue key (for sub-tasks)
+
+### Update Parameters
+
+- **`field`** - Field to update: assignee, priority, labels, summary, description
+- **`value`** - New value
+
+### Other Parameters
+
+- **`comment`** - Comment text
+- **`state`** - New workflow state
+- **`jql`** - JQL query string
+
+## Common Issue Types
+
+- **Bug** - Software defects
+- **Task** - General work items
+- **Story** - User stories/features
+- **Epic** - Large features or initiatives
+- **Feature** - New features
+- **Sub-task** - Child issues
+
+## Common Workflow States
+
+- **To Do** - Not started
+- **In Progress** - Currently being worked on
+- **Code Review** - Awaiting code review
+- **QE Review** - In QA testing
+- **Done** - Completed
+- **Blocked** - Blocked by dependencies
+
+## Tips and Tricks
+
+### 1. Use "me" for Current User
+
+```
+action: list, assignee: "me"
+action: create, assignee: "me"
+```
+
+### 2. Filter by Multiple Values
+
+```
+type: "Bug,Task"
+priority: "Critical,Blocker"
+status: "To Do,In Progress"
+```
+
+### 3. Exclude Done Issues
+
+```
+status: "~Done"
+```
+
+### 4. Search by Epic
+
+```
+action: list, epic: "SRVKP-1234"
+```
+
+### 5. Use JQL for Complex Queries
+
+```
+action: search, jql: "project = SRVKP AND status != Done AND assignee = currentUser()"
+```
+
+## State Management
+
+The extension caches the current user in session state:
+
+1. First call to `me` action fetches username from Jira
+2. Username is stored in tool result details
+3. Subsequent operations use cached username
+4. State is reconstructed from session on load
+5. State survives session branching/forking
+
+This reduces API calls and improves performance.
+
+## Error Handling
+
+The extension provides helpful error messages:
+
+### Authentication Error
+```
+Authentication failed. Check API token:
+passage show redhat/issues/token/kyushu
+```
+
+### Network Error
+```
+Network error. Are you on VPN?
+```
+
+### Not Found
+```
+Issue SRVKP-1234 not found
+```
+
+## Approval Pattern
+
+All write operations follow this pattern:
+
+1. LLM calls jira tool with write action
+2. Extension shows confirmation dialog
+3. User reviews and confirms/cancels
+4. If confirmed: operation executes
+5. If cancelled: returns with `cancelled: true`
+
+The LLM sees the cancellation and can explain or suggest alternatives.
+
+## Integration Patterns
+
+### With Org-Mode TODOs
+
+After viewing an issue:
+```
+User: "Create a TODO for SRVKP-1234"
+Agent: Uses org_todo tool to create corresponding TODO
+```
+
+### With Git Commits
+
+```
+User: "Generate commit message for SRVKP-1234"
+Agent: Views issue and generates:
+ fix(tekton): resolve affinity assistant pod creation
+
+ Fixes issue where affinity assistant pod was not
+ being created with default serviceAccount.
+
+ Refs: SRVKP-1234
+```
+
+### With Notes
+
+```
+User: "Create a note documenting SRVKP-1234"
+Agent: Views issue and creates denote note with issue details
+```
+
+## Future Enhancements
+
+### Planned Features
+
+1. **Epic Management**
+ - View epic details with child issues
+ - Create epics
+ - Link issues to epics
+
+2. **Feature Support**
+ - Full support for Feature issue type
+ - Feature-specific fields
+
+3. **Attachments**
+ - Attach files to issues
+ - Download attachments
+ - View attachment list
+
+4. **Links**
+ - Link related issues (blocks, relates to, duplicates)
+ - View issue links
+ - Create issue links
+
+5. **Offline Support**
+ - Queue write operations when offline
+ - Sync when connection restored
+ - Cache issue data in session
+
+6. **Batch Operations**
+ - Update multiple issues at once
+ - Bulk transition
+ - Bulk comment/label
+
+### Offline Support Strategy
+
+For future offline support, the plan is:
+
+1. **Queue Operations** - Store write operations in session when offline
+2. **Detect Connectivity** - Check VPN/network status
+3. **Sync on Connect** - Execute queued operations when online
+4. **Conflict Resolution** - Handle conflicts if issue changed
+5. **Cache Issue Data** - Store issue details in session for offline viewing
+
+This would work similar to git commits - you can make local changes and push when connected.
+
+### Batch Operations Strategy
+
+Batch operations would be useful for:
+
+1. **Sprint Planning** - Move multiple issues to sprint
+2. **Status Updates** - Transition multiple issues at once
+3. **Bulk Labeling** - Add labels to multiple issues
+4. **Team Assignment** - Assign multiple issues
+
+However, this can wait until there's a real need. Start with single operations.
+
+## Troubleshooting
+
+### Extension Not Loading
+
+Check for errors:
+```bash
+pi # Look for error messages in TUI
+```
+
+### Tool Not Being Called
+
+Be explicit:
+```
+User: "Use the jira tool to list my issues"
+```
+
+### Approval Dialog Not Showing
+
+- Check you're not in print mode (`pi -p`)
+- Verify it's a write operation (create/update/comment/transition)
+
+### Authentication Fails
+
+```bash
+# Check config
+cat ~/.config/.jira/.config.yml
+
+# Test jira CLI
+jira me
+
+# Check token
+passage show redhat/issues/token/kyushu
+```
+
+### Network Errors
+
+- Verify VPN connection
+- Test with: `jira me`
+- Check network connectivity
+
+## Architecture
+
+```
+jira/
+├── index.ts # Main extension entry point
+│ # - Tool registration
+│ # - State management
+│ # - Custom rendering
+│
+├── actions.ts # Action handlers
+│ # - handleMe, handleList, handleView
+│ # - handleCreate, handleUpdate, handleComment
+│ # - handleTransition, handleSearch
+│
+├── types.ts # TypeScript type definitions
+│ # - JiraDetails, JiraIssue
+│ # - Parameter types for each action
+│
+└── utils.ts # Utility functions
+ # - Parsing (parseIssueList)
+ # - Formatting (getStatusColor, truncate)
+ # - Error handling (getErrorMessage)
+ # - Confirmation messages
+```
+
+## Development
+
+### Adding New Actions
+
+1. Add action to `StringEnum` in `index.ts`
+2. Add parameter types to `types.ts`
+3. Add handler function to `actions.ts`
+4. Add routing in `execute()` in `index.ts`
+5. Add custom rendering in `renderResult()` in `index.ts`
+6. Update this README
+
+### Testing
+
+Test each action manually:
+
+```bash
+# Start pi
+pi
+
+# Test read operations
+"What's my Jira username?"
+"Show my open issues"
+"View SRVKP-1234"
+"Search for blocker issues"
+
+# Test write operations (verify approval)
+"Create a test bug" # Should show approval dialog
+"Assign SRVKP-1234 to me" # Should show approval dialog
+"Add comment to SRVKP-1234" # Should show approval dialog
+"Move SRVKP-1234 to Done" # Should show approval dialog
+
+# Test cancellation
+Create issue → Cancel in dialog → Should return cancelled
+```
+
+## Related
+
+- **Jira CLI**: https://github.com/ankitpokhrel/jira-cli
+- **Red Hat Jira**: https://issues.redhat.com
+- **JQL Reference**: https://issues.redhat.com/secure/JiraJQLHelp.jspa
+- **Claude Skills Jira**: `~/.config/claude/skills/Jira/`
+- **Org-Todo Extension**: `dots/pi/agent/extensions/org-todos/`
+
+## License
+
+Same as your homelab repository.
+
+## Epic Management
+
+### View Epic with Children
+
+```
+User: "Show me epic SRVKP-1000"
+Agent: <calls jira tool with action=epic-view, key=SRVKP-1000>
+→ Shows epic details plus all child issues grouped
+```
+
+### Link Issue to Epic
+
+```
+User: "Link SRVKP-1234 to epic SRVKP-1000"
+Agent: <calls jira tool with action=link-to-epic>
+→ Shows approval dialog
+→ Links issue to epic
+```
+
+## Issue Links
+
+### Link Issues
+
+```
+User: "SRVKP-1234 blocks SRVKP-5678"
+Agent: <calls jira tool with action=link, from=SRVKP-1234, to=SRVKP-5678, linkType="blocks">
+→ Shows approval dialog
+→ Creates issue link
+```
+
+Link types:
+- `blocks` - Source blocks target
+- `is blocked by` - Source is blocked by target
+- `relates to` - General relation
+- `duplicates` - Source duplicates target
+- `is duplicated by` - Source is duplicated by target
+
+### Unlink Issues
+
+```
+User: "Unlink SRVKP-1234 from SRVKP-5678"
+Agent: <calls jira tool with action=unlink, from=SRVKP-1234, to=SRVKP-5678>
+→ Shows approval dialog
+→ Removes link
+```
+
+## Attachments
+
+### Attach File
+
+```
+User: "Attach screenshot.png to SRVKP-1234"
+Agent: <calls jira tool with action=attach, key=SRVKP-1234, file=screenshot.png>
+→ Shows approval dialog
+→ Uploads file
+```
+
+### List Attachments
+
+```
+User: "What files are attached to SRVKP-1234?"
+Agent: <calls jira tool with action=list-attachments, key=SRVKP-1234>
+→ Shows list of attachments
+```
+
+## Testing
+
+The extension includes comprehensive tests for all utilities and functions.
+
+Run tests:
+```bash
+cd dots/pi/agent/extensions/jira
+make test
+```
+
+Run tests in watch mode:
+```bash
+make test-watch
+```
+
+Test coverage:
+- ✅ Issue list parsing
+- ✅ Issue key extraction
+- ✅ Status color coding
+- ✅ Priority color coding
+- ✅ Text truncation
+- ✅ Pattern matching (auto-detection)
+- ✅ Detection logic
+- ✅ Confirmation message building
+- ✅ Error message handling
+
+41 tests, all passing!
dots/pi/agent/extensions/jira/types.ts
@@ -0,0 +1,108 @@
+/**
+ * Type definitions for Jira extension
+ */
+
+export interface JiraDetails {
+ action: string;
+ output?: string;
+ issueKey?: string;
+ issueKeys?: string[];
+ cancelled?: boolean;
+ error?: string;
+ fromKey?: string;
+ toKey?: string;
+ field?: string;
+ newValue?: string;
+ oldValue?: string;
+}
+
+export interface JiraIssue {
+ key: string;
+ type: string;
+ summary: string;
+ status: string;
+ assignee: string;
+ priority?: string;
+ reporter?: string;
+}
+
+export interface JiraCreateParams {
+ action: "create";
+ issueType: string;
+ summary: string;
+ description?: string;
+ priority?: string;
+ assignee?: string;
+ labels?: string[];
+ epic?: string;
+ parent?: string;
+}
+
+export interface JiraUpdateParams {
+ action: "update";
+ key: string;
+ field: "assignee" | "priority" | "labels" | "summary" | "description" | "epic";
+ value: string;
+}
+
+export interface JiraTransitionParams {
+ action: "transition";
+ key: string;
+ state: string;
+}
+
+export interface JiraCommentParams {
+ action: "comment";
+ key: string;
+ comment: string;
+}
+
+export interface JiraSearchParams {
+ action: "search";
+ jql: string;
+ limit?: number;
+}
+
+export interface JiraListParams {
+ action: "list";
+ assignee?: string;
+ status?: string;
+ type?: string;
+ priority?: string;
+ limit?: number;
+ epic?: string;
+}
+
+export interface JiraViewParams {
+ action: "view";
+ key: string;
+}
+
+export interface JiraEpicViewParams {
+ action: "epic-view";
+ key: string;
+}
+
+export interface JiraLinkParams {
+ action: "link";
+ from: string;
+ to: string;
+ linkType: "blocks" | "is blocked by" | "relates to" | "duplicates" | "is duplicated by";
+}
+
+export interface JiraUnlinkParams {
+ action: "unlink";
+ from: string;
+ to: string;
+}
+
+export interface JiraAttachParams {
+ action: "attach";
+ key: string;
+ file: string;
+}
+
+export interface JiraListAttachmentsParams {
+ action: "list-attachments";
+ key: string;
+}
dots/pi/agent/extensions/jira/utils.ts
@@ -0,0 +1,282 @@
+/**
+ * Utility functions for Jira extension
+ */
+
+import type { Theme } from "@mariozechner/pi-coding-agent";
+import type { JiraIssue } from "./types";
+
+/**
+ * Parse jira issue list output from JSON (--raw flag)
+ */
+export function parseIssueListJSON(output: string): JiraIssue[] {
+ try {
+ const data = JSON.parse(output);
+ if (!Array.isArray(data)) {
+ return [];
+ }
+
+ return data.map((item: any) => ({
+ key: item.key || "?",
+ type: item.fields?.issueType?.name || "?",
+ summary: item.fields?.summary || "",
+ status: item.fields?.status?.name || "?",
+ assignee: item.fields?.assignee?.displayName || "Unassigned",
+ priority: item.fields?.priority?.name !== "Undefined" ? item.fields?.priority?.name : undefined,
+ reporter: item.fields?.reporter?.displayName,
+ }));
+ } catch (error) {
+ console.error("Failed to parse jira JSON:", error);
+ return [];
+ }
+}
+
+/**
+ * Parse jira issue list output (plain text format) - DEPRECATED, use parseIssueListJSON
+ * Kept for backwards compatibility with tests
+ */
+export function parseIssueList(output: string): JiraIssue[] {
+ const lines = output.split("\n").filter((l) => l.trim());
+ const issues: JiraIssue[] = [];
+
+ for (const line of lines) {
+ // Skip header lines
+ if (line.startsWith("TYPE\t") || line.includes("---")) continue;
+
+ // Parse line - jira CLI uses TAB as delimiter with multiple TABs for alignment
+ const parts = line.split("\t").filter(p => p.trim());
+
+ if (parts.length >= 4) {
+ const type = parts[0];
+ const key = parts[1];
+ const status = parts[parts.length - 1];
+ const summary = parts.slice(2, -1).join(" ");
+
+ issues.push({
+ key,
+ type,
+ summary,
+ status,
+ assignee: "Unassigned",
+ priority: undefined,
+ });
+ }
+ }
+
+ return issues;
+}
+
+/**
+ * Get colored status indicator
+ */
+export function getStatusColor(status: string, theme: Theme): string {
+ const normalized = status.toLowerCase();
+
+ if (normalized.includes("done") || normalized.includes("closed")) {
+ return theme.fg("success", `[${status}]`);
+ }
+
+ if (normalized.includes("progress") || normalized.includes("review")) {
+ return theme.fg("accent", `[${status}]`);
+ }
+
+ if (normalized.includes("blocked") || normalized.includes("waiting")) {
+ return theme.fg("error", `[${status}]`);
+ }
+
+ return theme.fg("muted", `[${status}]`);
+}
+
+/**
+ * Get colored priority indicator
+ */
+export function getPriorityColor(priority: string, theme: Theme): string {
+ const normalized = priority.toLowerCase();
+
+ if (normalized.includes("blocker") || normalized.includes("critical")) {
+ return theme.fg("error", priority);
+ }
+
+ if (normalized.includes("major") || normalized.includes("high")) {
+ return theme.fg("warning", priority);
+ }
+
+ return theme.fg("dim", priority);
+}
+
+/**
+ * Truncate text to max length
+ */
+export function truncate(text: string, maxLength: number): string {
+ if (text.length <= maxLength) return text;
+ return text.slice(0, maxLength - 3) + "...";
+}
+
+/**
+ * Extract issue key from jira CLI output
+ */
+export function extractIssueKey(output: string): string | null {
+ const match = output.match(/([A-Z]+-\d+)/);
+ return match ? match[1] : null;
+}
+
+/**
+ * Extract all issue keys from output
+ */
+export function extractIssueKeys(output: string): string[] {
+ const matches = output.match(/\b([A-Z]{2,}-\d+)\b/g);
+ if (!matches) return [];
+
+ // Remove duplicates and return
+ return [...new Set(matches)];
+}
+
+/**
+ * Build confirmation message for create action
+ */
+export function buildCreateConfirmation(params: any): string {
+ let msg = "";
+
+ msg += `Type: ${params.issueType}\n`;
+ msg += `Summary: "${params.summary}"\n`;
+
+ if (params.description) {
+ const preview = params.description.length > 100 ? params.description.slice(0, 97) + "..." : params.description;
+ msg += `Description: ${preview}\n`;
+ }
+
+ if (params.priority) {
+ msg += `Priority: ${params.priority}\n`;
+ }
+
+ if (params.assignee) {
+ msg += `Assignee: ${params.assignee}\n`;
+ }
+
+ if (params.labels && params.labels.length > 0) {
+ msg += `Labels: ${params.labels.join(", ")}\n`;
+ }
+
+ if (params.epic) {
+ msg += `Epic: ${params.epic}\n`;
+ }
+
+ if (params.parent) {
+ msg += `Parent: ${params.parent}\n`;
+ }
+
+ msg += "\nThis will create a new issue in Jira.";
+
+ return msg;
+}
+
+/**
+ * Build confirmation message for update action
+ */
+export function buildUpdateConfirmation(params: any, currentValue?: string): string {
+ let msg = `Issue: ${params.key}\n`;
+ msg += `Field: ${params.field}\n`;
+
+ if (currentValue) {
+ msg += `From: ${currentValue}\n`;
+ }
+
+ msg += `To: ${params.value}\n`;
+ msg += "\nThis will modify the issue in Jira.";
+
+ return msg;
+}
+
+/**
+ * Build confirmation message for transition action
+ */
+export function buildTransitionConfirmation(params: any, currentState?: string): string {
+ let msg = `Issue: ${params.key}\n`;
+
+ if (currentState) {
+ msg += `From: ${currentState}\n`;
+ }
+
+ msg += `To: ${params.state}\n`;
+ msg += "\nThis will change the issue workflow state.";
+
+ return msg;
+}
+
+/**
+ * Build confirmation message for comment action
+ */
+export function buildCommentConfirmation(params: any): string {
+ const preview = params.comment.length > 200 ? params.comment.slice(0, 197) + "..." : params.comment;
+
+ let msg = `Issue: ${params.key}\n\n`;
+ msg += `Comment preview:\n"${preview}"\n\n`;
+ msg += "This will add a public comment visible to all users.";
+
+ return msg;
+}
+
+/**
+ * Format date for display
+ */
+export function formatDate(dateStr: string): string {
+ try {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString();
+ } catch {
+ return dateStr;
+ }
+}
+
+/**
+ * Check if error is authentication related
+ */
+export function isAuthError(stderr: string): boolean {
+ const lower = stderr.toLowerCase();
+ return (
+ lower.includes("authentication") ||
+ lower.includes("unauthorized") ||
+ lower.includes("invalid token") ||
+ lower.includes("permission denied")
+ );
+}
+
+/**
+ * Check if error is network related
+ */
+export function isNetworkError(stderr: string): boolean {
+ const lower = stderr.toLowerCase();
+ return (
+ lower.includes("connection") ||
+ lower.includes("timeout") ||
+ lower.includes("network") ||
+ lower.includes("dial tcp") ||
+ lower.includes("no such host")
+ );
+}
+
+/**
+ * Check if error is not found
+ */
+export function isNotFoundError(stderr: string): boolean {
+ const lower = stderr.toLowerCase();
+ return lower.includes("not found") || lower.includes("does not exist");
+}
+
+/**
+ * Get helpful error message
+ */
+export function getErrorMessage(stderr: string, action: string): string {
+ if (isAuthError(stderr)) {
+ return "Authentication failed. Check API token:\npassage show redhat/issues/token/kyushu";
+ }
+
+ if (isNetworkError(stderr)) {
+ return "Network error. Are you on VPN?";
+ }
+
+ if (isNotFoundError(stderr)) {
+ return `${action} failed: Resource not found`;
+ }
+
+ return stderr;
+}