flake-update-20260505
1/**
2 * Utility functions for Jira extension
3 */
4
5import type { Theme, ExtensionContext } from "@mariozechner/pi-coding-agent";
6import type { JiraIssue } from "./types";
7
8// ============================================================================
9// Serialized confirm helper (prevents parallel approval dialog deadlocks)
10// ============================================================================
11
12// Mutex to serialize concurrent confirmation dialogs.
13// When the LLM issues multiple write tool calls in parallel, each hits
14// ctx.ui.confirm(). Without serialization, multiple concurrent dialogs
15// deadlock the UI.
16let confirmMutex: Promise<void> = Promise.resolve();
17
18/**
19 * Serialized wrapper around ctx.ui.confirm().
20 * Queues concurrent calls so they present one at a time instead of
21 * all at once (which deadlocks the UI).
22 */
23export async function serializedConfirm(
24 ctx: ExtensionContext,
25 title: string,
26 description: string,
27): Promise<boolean> {
28 const result = await new Promise<boolean>((resolve) => {
29 confirmMutex = confirmMutex.then(async () => {
30 const confirmed = await ctx.ui.confirm(title, description);
31 resolve(confirmed);
32 });
33 });
34 return result;
35}
36
37/**
38 * Parse jira issue list output from JSON (--raw flag)
39 */
40export function parseIssueListJSON(output: string): JiraIssue[] {
41 try {
42 const data = JSON.parse(output);
43 if (!Array.isArray(data)) {
44 return [];
45 }
46
47 return data.map((item: any) => ({
48 key: item.key || "?",
49 type: item.fields?.issueType?.name || "?",
50 summary: item.fields?.summary || "",
51 status: item.fields?.status?.name || "?",
52 assignee: item.fields?.assignee?.displayName || "Unassigned",
53 priority: item.fields?.priority?.name !== "Undefined" ? item.fields?.priority?.name : undefined,
54 reporter: item.fields?.reporter?.displayName,
55 }));
56 } catch {
57 // Invalid JSON is expected when jira CLI returns error messages
58 return [];
59 }
60}
61
62/**
63 * Parse jira issue list output (plain text format) - DEPRECATED, use parseIssueListJSON
64 * Kept for backwards compatibility with tests
65 */
66export function parseIssueList(output: string): JiraIssue[] {
67 const lines = output.split("\n").filter((l) => l.trim());
68 const issues: JiraIssue[] = [];
69
70 for (const line of lines) {
71 // Skip header lines
72 if (line.startsWith("TYPE\t") || line.includes("---")) continue;
73
74 // Parse line - jira CLI uses TAB as delimiter with multiple TABs for alignment
75 const parts = line.split("\t").filter(p => p.trim());
76
77 if (parts.length >= 4) {
78 const type = parts[0];
79 const key = parts[1];
80 const status = parts[parts.length - 1];
81 const summary = parts.slice(2, -1).join(" ");
82
83 issues.push({
84 key,
85 type,
86 summary,
87 status,
88 assignee: "Unassigned",
89 priority: undefined,
90 });
91 }
92 }
93
94 return issues;
95}
96
97/**
98 * Get colored status indicator
99 */
100export function getStatusColor(status: string, theme: Theme): string {
101 const normalized = status.toLowerCase();
102
103 if (normalized.includes("done") || normalized.includes("closed")) {
104 return theme.fg("success", `[${status}]`);
105 }
106
107 if (normalized.includes("progress") || normalized.includes("review")) {
108 return theme.fg("accent", `[${status}]`);
109 }
110
111 if (normalized.includes("blocked") || normalized.includes("waiting")) {
112 return theme.fg("error", `[${status}]`);
113 }
114
115 return theme.fg("muted", `[${status}]`);
116}
117
118/**
119 * Get colored priority indicator
120 */
121export function getPriorityColor(priority: string, theme: Theme): string {
122 const normalized = priority.toLowerCase();
123
124 if (normalized.includes("blocker") || normalized.includes("critical")) {
125 return theme.fg("error", priority);
126 }
127
128 if (normalized.includes("major") || normalized.includes("high")) {
129 return theme.fg("warning", priority);
130 }
131
132 return theme.fg("dim", priority);
133}
134
135/**
136 * Truncate text to max length
137 */
138export function truncate(text: string, maxLength: number): string {
139 if (text.length <= maxLength) return text;
140 return text.slice(0, maxLength - 3) + "...";
141}
142
143/**
144 * Extract issue key from jira CLI output
145 */
146export function extractIssueKey(output: string): string | null {
147 const match = output.match(/([A-Z]+-\d+)/);
148 return match ? match[1] : null;
149}
150
151/**
152 * Extract all issue keys from output
153 */
154export function extractIssueKeys(output: string): string[] {
155 const matches = output.match(/\b([A-Z]{2,}-\d+)\b/g);
156 if (!matches) return [];
157
158 // Remove duplicates and return
159 return [...new Set(matches)];
160}
161
162/**
163 * Build confirmation message for create action
164 */
165export function buildCreateConfirmation(params: any): string {
166 let msg = "";
167
168 msg += `Type: ${params.issueType}\n`;
169 msg += `Summary: "${params.summary}"\n`;
170
171 if (params.description) {
172 const preview = params.description.length > 100 ? params.description.slice(0, 97) + "..." : params.description;
173 msg += `Description: ${preview}\n`;
174 }
175
176 if (params.priority) {
177 msg += `Priority: ${params.priority}\n`;
178 }
179
180 if (params.assignee) {
181 msg += `Assignee: ${params.assignee}\n`;
182 }
183
184 if (params.labels && params.labels.length > 0) {
185 msg += `Labels: ${params.labels.join(", ")}\n`;
186 }
187
188 if (params.epic) {
189 msg += `Epic: ${params.epic}\n`;
190 }
191
192 if (params.parent) {
193 msg += `Parent: ${params.parent}\n`;
194 }
195
196 msg += "\nThis will create a new issue in Jira.";
197
198 return msg;
199}
200
201/**
202 * Build confirmation message for update action
203 */
204export function buildUpdateConfirmation(params: any, currentValue?: string): string {
205 let msg = `Issue: ${params.key}\n`;
206 msg += `Field: ${params.field}\n`;
207
208 if (currentValue) {
209 msg += `From: ${currentValue}\n`;
210 }
211
212 msg += `To: ${params.value}\n`;
213 msg += "\nThis will modify the issue in Jira.";
214
215 return msg;
216}
217
218/**
219 * Build confirmation message for transition action
220 */
221export function buildTransitionConfirmation(params: any, currentState?: string): string {
222 let msg = `Issue: ${params.key}\n`;
223
224 if (currentState) {
225 msg += `From: ${currentState}\n`;
226 }
227
228 msg += `To: ${params.state}\n`;
229 msg += "\nThis will change the issue workflow state.";
230
231 return msg;
232}
233
234/**
235 * Build confirmation message for comment action
236 */
237export function buildCommentConfirmation(params: any): string {
238 const preview = params.comment.length > 200 ? params.comment.slice(0, 197) + "..." : params.comment;
239
240 let msg = `Issue: ${params.key}\n\n`;
241 msg += `Comment preview:\n"${preview}"\n\n`;
242 msg += "This will add a public comment visible to all users.";
243
244 return msg;
245}
246
247/**
248 * Format date for display
249 */
250export function formatDate(dateStr: string): string {
251 try {
252 const date = new Date(dateStr);
253 return date.toLocaleDateString();
254 } catch {
255 return dateStr;
256 }
257}
258
259/**
260 * Check if error is authentication related
261 */
262export function isAuthError(stderr: string): boolean {
263 const lower = stderr.toLowerCase();
264 return (
265 lower.includes("authentication") ||
266 lower.includes("unauthorized") ||
267 lower.includes("invalid token") ||
268 lower.includes("permission denied")
269 );
270}
271
272/**
273 * Check if error is network related
274 */
275export function isNetworkError(stderr: string): boolean {
276 const lower = stderr.toLowerCase();
277 return (
278 lower.includes("connection") ||
279 lower.includes("timeout") ||
280 lower.includes("network") ||
281 lower.includes("dial tcp") ||
282 lower.includes("no such host")
283 );
284}
285
286/**
287 * Check if error is not found
288 */
289export function isNotFoundError(stderr: string): boolean {
290 const lower = stderr.toLowerCase();
291 return lower.includes("not found") || lower.includes("does not exist");
292}
293
294/**
295 * Get helpful error message
296 */
297export function getErrorMessage(stderr: string, action: string): string {
298 if (isAuthError(stderr)) {
299 return "Authentication failed. Check API token:\npassage show redhat/issues/atlassian/token";
300 }
301
302 if (isNetworkError(stderr)) {
303 return "Network error. Are you on VPN?";
304 }
305
306 if (isNotFoundError(stderr)) {
307 return `${action} failed: Resource not found`;
308 }
309
310 return stderr;
311}