main
  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}