main
  1/**
  2 * Issue action handlers for GitHub extension
  3 */
  4
  5import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
  6import type { GhDetails } from "../types";
  7import {
  8	parseIssueList,
  9	getErrorMessage,
 10	extractIssueNumber,
 11	extractIssueUrl,
 12	buildIssueCreateConfirmation,
 13	buildCommentConfirmation,
 14	buildSubIssueConfirmation,
 15	truncate,
 16	execGh,
 17	resolveIssueNodeId,
 18	addSubIssue,
 19	removeSubIssue,
 20	approvalGate,
 21	approvalGateWithBodyPreview,
 22	buildModifyResult,
 23	buildRejectResult,
 24} from "../utils";
 25
 26const ISSUE_LIST_FIELDS = "number,title,state,author,url,labels,assignees,createdAt,updatedAt,body,comments";
 27
 28/**
 29 * List issues
 30 */
 31export async function handleIssueList(
 32	pi: ExtensionAPI,
 33	params: any,
 34	signal: AbortSignal | undefined,
 35	onUpdate: any,
 36	ctx: ExtensionContext,
 37	currentUser: string,
 38): Promise<any> {
 39	const args = ["issue", "list", "--json", ISSUE_LIST_FIELDS];
 40
 41	if (params.state) args.push("--state", params.state);
 42	if (params.label) args.push("--label", params.label);
 43	if (params.assignee) {
 44		args.push("--assignee", params.assignee === "me" ? (currentUser || "@me") : params.assignee);
 45	}
 46	if (params.milestone) args.push("--milestone", params.milestone);
 47	if (params.limit) args.push("--limit", String(params.limit));
 48	else args.push("--limit", "20");
 49
 50	onUpdate?.({ content: [{ type: "text", text: "Fetching issues..." }] });
 51
 52	const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
 53
 54	if (result.code !== 0) {
 55		return {
 56			content: [{ type: "text", text: getErrorMessage(result.stderr, "List issues") }],
 57			details: { action: "issue-list", error: result.stderr } as GhDetails,
 58			isError: true,
 59		};
 60	}
 61
 62	const issues = parseIssueList(result.stdout);
 63
 64	let output = "";
 65	if (issues.length === 0) {
 66		output = "No issues found.";
 67	} else {
 68		output = issues
 69			.map((issue) => {
 70				const labels = issue.labels.length > 0 ? ` [${issue.labels.join(", ")}]` : "";
 71				const assignees = issue.assignees.length > 0 ? ` @${issue.assignees.join(", @")}` : "";
 72				return `#${issue.number} ${issue.title}${labels}${assignees} (${issue.state})`;
 73			})
 74			.join("\n");
 75	}
 76
 77	const issueNumbers = issues.map((i) => i.number);
 78
 79	return {
 80		content: [{ type: "text", text: output }],
 81		details: { action: "issue-list", output, issueNumbers } as GhDetails,
 82	};
 83}
 84
 85/**
 86 * View issue details
 87 */
 88export async function handleIssueView(
 89	pi: ExtensionAPI,
 90	params: any,
 91	signal: AbortSignal | undefined,
 92	onUpdate: any,
 93	ctx: ExtensionContext,
 94): Promise<any> {
 95	if (!params.number) {
 96		return {
 97			content: [{ type: "text", text: "Error: 'number' parameter is required for issue-view action" }],
 98			details: { action: "issue-view", error: "missing_number" } as GhDetails,
 99			isError: true,
100		};
101	}
102
103	onUpdate?.({ content: [{ type: "text", text: `Fetching issue #${params.number}...` }] });
104
105	const result = await execGh(pi, ctx,
106		["issue", "view", String(params.number), "--json", `${ISSUE_LIST_FIELDS},milestone`],
107		{ signal, timeout: 20000 },
108	);
109
110	if (result.code !== 0) {
111		return {
112			content: [{ type: "text", text: getErrorMessage(result.stderr, "View issue") }],
113			details: { action: "issue-view", error: result.stderr, issueNumber: params.number } as GhDetails,
114			isError: true,
115		};
116	}
117
118	let data: any;
119	try {
120		data = JSON.parse(result.stdout);
121	} catch {
122		return {
123			content: [{ type: "text", text: result.stdout }],
124			details: { action: "issue-view", output: result.stdout, issueNumber: params.number } as GhDetails,
125		};
126	}
127
128	let output = "";
129	output += `# Issue #${data.number}: ${data.title}\n\n`;
130	output += `State: ${data.state}\n`;
131	output += `Author: @${data.author?.login ?? "?"}\n`;
132	output += `URL: ${data.url}\n`;
133
134	if (data.labels?.length > 0) {
135		output += `Labels: ${data.labels.map((l: any) => l.name ?? l).join(", ")}\n`;
136	}
137	if (data.assignees?.length > 0) {
138		output += `Assignees: ${data.assignees.map((a: any) => `@${a.login ?? a}`).join(", ")}\n`;
139	}
140	if (data.milestone) {
141		output += `Milestone: ${data.milestone.title ?? data.milestone}\n`;
142	}
143
144	if (data.body) {
145		output += `\n## Description\n\n${data.body}\n`;
146	}
147
148	// Comments are already included in the initial JSON response as an array
149	const comments = Array.isArray(data.comments) ? data.comments : [];
150	if (comments.length > 0) {
151		output += `\n## Comments (${comments.length})\n`;
152		for (const comment of comments) {
153			output += `\n@${comment.author?.login ?? "?"} (${comment.createdAt ?? ""}):\n${comment.body}\n`;
154		}
155	}
156
157	return {
158		content: [{ type: "text", text: output }],
159		details: {
160			action: "issue-view",
161			output,
162			issueNumber: data.number,
163			issueUrl: data.url,
164		} as GhDetails,
165	};
166}
167
168/**
169 * Create issue (requires approval)
170 */
171export async function handleIssueCreate(
172	pi: ExtensionAPI,
173	params: any,
174	signal: AbortSignal | undefined,
175	onUpdate: any,
176	ctx: ExtensionContext,
177): Promise<any> {
178	if (!params.title) {
179		return {
180			content: [{ type: "text", text: "Error: 'title' parameter is required for issue-create action" }],
181			details: { action: "issue-create", error: "missing_title" } as GhDetails,
182			isError: true,
183		};
184	}
185
186	// APPROVAL GATE
187	if (ctx.hasUI) {
188		let confirmMessage = buildIssueCreateConfirmation(params);
189		if (params.parent) {
190			confirmMessage = confirmMessage.replace(
191				"This will create a new issue on GitHub.",
192				`This will create a new issue on GitHub and link it as a sub-issue of #${params.parent}.`,
193			);
194		}
195		
196		// If body is long, offer to preview full content
197		if (params.body && params.body.length > 200) {
198			confirmMessage += `\n\n📝 Body: ${params.body.length} characters (truncated in preview)`;
199			
200			const choice = await ctx.ui.select(
201				`Create Issue?\n\n${confirmMessage}`,
202				["✓ Accept", "👁 Preview body first", "✎ Modify", "✗ Reject"]
203			);
204			
205			if (choice === undefined || choice === "✗ Reject") {
206				ctx.ui.notify("Issue creation rejected", "info");
207				return buildRejectResult("issue creation", { action: "issue-create" });
208			}
209			
210			if (choice === "✎ Modify") {
211				ctx.ui.notify("Issue creation paused for modifications", "info");
212				return buildModifyResult("issue creation", { action: "issue-create" });
213			}
214			
215			if (choice === "👁 Preview body first") {
216				await ctx.ui.editor(
217					`Issue Body Preview (${params.body.length} chars):\n\nTitle: ${params.title}\n\n---\n\n`,
218					params.body
219				);
220				
221				// Ask again after preview
222				const approval = await approvalGate(ctx, "Create Issue?", confirmMessage);
223				if (approval.outcome === "modify") {
224					ctx.ui.notify("Issue creation paused for modifications", "info");
225					return buildModifyResult("issue creation", { action: "issue-create" });
226				}
227				if (approval.outcome === "rejected") {
228					ctx.ui.notify("Issue creation rejected", "info");
229					return buildRejectResult("issue creation", { action: "issue-create" });
230				}
231			}
232		} else {
233			const approval = await approvalGate(ctx, "Create Issue?", confirmMessage);
234			if (approval.outcome === "modify") {
235				ctx.ui.notify("Issue creation paused for modifications", "info");
236				return buildModifyResult("issue creation", { action: "issue-create" });
237			}
238			if (approval.outcome === "rejected") {
239				ctx.ui.notify("Issue creation rejected", "info");
240				return buildRejectResult("issue creation", { action: "issue-create" });
241			}
242		}
243	}
244
245	const args = ["issue", "create", "--title", params.title];
246
247	if (params.body) args.push("--body", params.body);
248	if (params.labels?.length) {
249		for (const label of params.labels) args.push("--label", label);
250	}
251	if (params.assignees?.length) {
252		for (const assignee of params.assignees) args.push("--assignee", assignee);
253	}
254	if (params.milestone) args.push("--milestone", params.milestone);
255
256	onUpdate?.({ content: [{ type: "text", text: "Creating issue..." }] });
257
258	const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
259
260	if (result.code !== 0) {
261		return {
262			content: [{ type: "text", text: getErrorMessage(result.stderr, "Create issue") }],
263			details: { action: "issue-create", error: result.stderr } as GhDetails,
264			isError: true,
265		};
266	}
267
268	const issueNumber = extractIssueNumber(result.stdout);
269	const issueUrl = extractIssueUrl(result.stdout) || result.stdout.trim();
270
271	// If parent is specified, link this issue as a sub-issue
272	let parentLinked = false;
273	let parentError: string | undefined;
274	if (params.parent && issueNumber) {
275		onUpdate?.({ content: [{ type: "text", text: `Created issue #${issueNumber}, linking as sub-issue of #${params.parent}...` }] });
276
277		const parentNode = await resolveIssueNodeId(pi, ctx, params.parent, signal);
278		const childNode = await resolveIssueNodeId(pi, ctx, issueNumber, signal);
279
280		if (!parentNode) {
281			parentError = `Could not resolve parent issue #${params.parent}`;
282		} else if (!childNode) {
283			parentError = `Could not resolve created issue #${issueNumber} for sub-issue linking`;
284		} else {
285			const linkResult = await addSubIssue(pi, ctx, parentNode.id, childNode.id, signal);
286			if (linkResult.success) {
287				parentLinked = true;
288			} else {
289				parentError = linkResult.error;
290			}
291		}
292	}
293
294	let text = `Created issue${issueNumber ? ` #${issueNumber}` : ""}: ${issueUrl}`;
295	if (parentLinked) {
296		text += ` (sub-issue of #${params.parent})`;
297	} else if (parentError) {
298		text += `\nWarning: Failed to link as sub-issue of #${params.parent}: ${parentError}`;
299	}
300
301	return {
302		content: [{ type: "text", text }],
303		details: {
304			action: "issue-create",
305			output: result.stdout.trim(),
306			issueNumber: issueNumber ?? undefined,
307			issueUrl: issueUrl ?? undefined,
308			parentNumber: parentLinked ? params.parent : undefined,
309		} as GhDetails,
310	};
311}
312
313/**
314 * Close issue (requires approval)
315 */
316export async function handleIssueClose(
317	pi: ExtensionAPI,
318	params: any,
319	signal: AbortSignal | undefined,
320	onUpdate: any,
321	ctx: ExtensionContext,
322): Promise<any> {
323	if (!params.number) {
324		return {
325			content: [{ type: "text", text: "Error: 'number' parameter is required for issue-close action" }],
326			details: { action: "issue-close", error: "missing_number" } as GhDetails,
327			isError: true,
328		};
329	}
330
331	// APPROVAL GATE
332	if (ctx.hasUI) {
333		const reason = params.reason || "completed";
334		const approval = await approvalGate(ctx, `Close issue #${params.number}?`, `This will close the issue as "${reason}".`);
335		if (approval.outcome === "modify") {
336			ctx.ui.notify("Close paused for modifications", "info");
337			return buildModifyResult("issue close", { action: "issue-close", issueNumber: params.number });
338		}
339		if (approval.outcome === "rejected") {
340			ctx.ui.notify("Close rejected", "info");
341			return buildRejectResult("issue close", { action: "issue-close", issueNumber: params.number });
342		}
343	}
344
345	const args = ["issue", "close", String(params.number)];
346	if (params.reason) args.push("--reason", params.reason);
347
348	const result = await execGh(pi, ctx, args, { signal, timeout: 20000 });
349
350	if (result.code !== 0) {
351		return {
352			content: [{ type: "text", text: getErrorMessage(result.stderr, "Close issue") }],
353			details: { action: "issue-close", error: result.stderr, issueNumber: params.number } as GhDetails,
354			isError: true,
355		};
356	}
357
358	return {
359		content: [{ type: "text", text: `Closed issue #${params.number}` }],
360		details: { action: "issue-close", output: result.stdout.trim(), issueNumber: params.number } as GhDetails,
361	};
362}
363
364/**
365 * Comment on issue (requires approval)
366 */
367export async function handleIssueComment(
368	pi: ExtensionAPI,
369	params: any,
370	signal: AbortSignal | undefined,
371	onUpdate: any,
372	ctx: ExtensionContext,
373): Promise<any> {
374	if (!params.number) {
375		return {
376			content: [{ type: "text", text: "Error: 'number' parameter is required for issue-comment action" }],
377			details: { action: "issue-comment", error: "missing_number" } as GhDetails,
378			isError: true,
379		};
380	}
381
382	if (!params.body) {
383		return {
384			content: [{ type: "text", text: "Error: 'body' parameter is required for issue-comment action" }],
385			details: { action: "issue-comment", error: "missing_body" } as GhDetails,
386			isError: true,
387		};
388	}
389
390	// APPROVAL GATE
391	if (ctx.hasUI) {
392		const confirmMessage = buildCommentConfirmation("Issue", params.number, params.body);
393		const approval = await approvalGateWithBodyPreview(
394			ctx,
395			`Comment on issue #${params.number}?`,
396			confirmMessage,
397			`Issue #${params.number} Comment Preview (${params.body.length} chars):`,
398			params.body,
399		);
400		if (approval.outcome === "modify") {
401			ctx.ui.notify("Comment paused for modifications", "info");
402			return buildModifyResult("comment", { action: "issue-comment", issueNumber: params.number });
403		}
404		if (approval.outcome === "rejected") {
405			ctx.ui.notify("Comment rejected", "info");
406			return buildRejectResult("comment", { action: "issue-comment", issueNumber: params.number });
407		}
408	}
409
410	onUpdate?.({ content: [{ type: "text", text: `Adding comment to issue #${params.number}...` }] });
411
412	const result = await execGh(pi, ctx,
413		["issue", "comment", String(params.number), "--body", params.body],
414		{ signal, timeout: 20000 },
415	);
416
417	if (result.code !== 0) {
418		return {
419			content: [{ type: "text", text: getErrorMessage(result.stderr, "Comment on issue") }],
420			details: { action: "issue-comment", error: result.stderr, issueNumber: params.number } as GhDetails,
421			isError: true,
422		};
423	}
424
425	return {
426		content: [{ type: "text", text: `Added comment to issue #${params.number}` }],
427		details: { action: "issue-comment", output: result.stdout.trim(), issueNumber: params.number } as GhDetails,
428	};
429}
430
431/**
432 * Edit issue (requires approval)
433 */
434export async function handleIssueEdit(
435	pi: ExtensionAPI,
436	params: any,
437	signal: AbortSignal | undefined,
438	onUpdate: any,
439	ctx: ExtensionContext,
440): Promise<any> {
441	if (!params.number) {
442		return {
443			content: [{ type: "text", text: "Error: 'number' parameter is required for issue-edit action" }],
444			details: { action: "issue-edit", error: "missing_number" } as GhDetails,
445			isError: true,
446		};
447	}
448
449	// Build description of what we're editing
450	const changes: string[] = [];
451	if (params.title) changes.push(`Title: "${params.title}"`);
452	if (params.body) changes.push(`Body: "${truncate(params.body, 100)}"`);
453	if (params.addLabels?.length) changes.push(`Add labels: ${params.addLabels.join(", ")}`);
454	if (params.removeLabels?.length) changes.push(`Remove labels: ${params.removeLabels.join(", ")}`);
455	if (params.addAssignees?.length) changes.push(`Add assignees: ${params.addAssignees.join(", ")}`);
456	if (params.removeAssignees?.length) changes.push(`Remove assignees: ${params.removeAssignees.join(", ")}`);
457	if (params.milestone) changes.push(`Milestone: ${params.milestone}`);
458
459	if (changes.length === 0) {
460		return {
461			content: [{ type: "text", text: "Error: No fields to update specified" }],
462			details: { action: "issue-edit", error: "no_changes" } as GhDetails,
463			isError: true,
464		};
465	}
466
467	// APPROVAL GATE
468	if (ctx.hasUI) {
469		const confirmMessage = `Issue: #${params.number}\n\nChanges:\n${changes.join("\n")}\n\nThis will modify the issue.`;
470		const approval = params.body
471			? await approvalGateWithBodyPreview(
472				ctx,
473				`Edit issue #${params.number}?`,
474				confirmMessage,
475				`Issue #${params.number} New Body Preview (${params.body.length} chars):`,
476				params.body,
477			)
478			: await approvalGate(ctx, `Edit issue #${params.number}?`, confirmMessage);
479		if (approval.outcome === "modify") {
480			ctx.ui.notify("Edit paused for modifications", "info");
481			return buildModifyResult("issue edit", { action: "issue-edit", issueNumber: params.number });
482		}
483		if (approval.outcome === "rejected") {
484			ctx.ui.notify("Edit rejected", "info");
485			return buildRejectResult("issue edit", { action: "issue-edit", issueNumber: params.number });
486		}
487	}
488
489	const args = ["issue", "edit", String(params.number)];
490
491	if (params.title) args.push("--title", params.title);
492	if (params.body) args.push("--body", params.body);
493	if (params.addLabels?.length) {
494		for (const label of params.addLabels) args.push("--add-label", label);
495	}
496	if (params.removeLabels?.length) {
497		for (const label of params.removeLabels) args.push("--remove-label", label);
498	}
499	if (params.addAssignees?.length) {
500		for (const assignee of params.addAssignees) args.push("--add-assignee", assignee);
501	}
502	if (params.removeAssignees?.length) {
503		for (const assignee of params.removeAssignees) args.push("--remove-assignee", assignee);
504	}
505	if (params.milestone) args.push("--milestone", params.milestone);
506
507	onUpdate?.({ content: [{ type: "text", text: `Editing issue #${params.number}...` }] });
508
509	const result = await execGh(pi, ctx, args, { signal, timeout: 20000 });
510
511	if (result.code !== 0) {
512		return {
513			content: [{ type: "text", text: getErrorMessage(result.stderr, "Edit issue") }],
514			details: { action: "issue-edit", error: result.stderr, issueNumber: params.number } as GhDetails,
515			isError: true,
516		};
517	}
518
519	return {
520		content: [{ type: "text", text: `Updated issue #${params.number}: ${changes.join("; ")}` }],
521		details: {
522			action: "issue-edit",
523			output: result.stdout.trim(),
524			issueNumber: params.number,
525		} as GhDetails,
526	};
527}
528
529/**
530 * Add sub-issue relationship (requires approval)
531 */
532export async function handleIssueAddSubIssue(
533	pi: ExtensionAPI,
534	params: any,
535	signal: AbortSignal | undefined,
536	onUpdate: any,
537	ctx: ExtensionContext,
538): Promise<any> {
539	if (!params.number) {
540		return {
541			content: [{ type: "text", text: "Error: 'number' (parent issue) parameter is required for issue-add-sub-issue action" }],
542			details: { action: "issue-add-sub-issue", error: "missing_number" } as GhDetails,
543			isError: true,
544		};
545	}
546
547	if (!params.subIssueNumber) {
548		return {
549			content: [{ type: "text", text: "Error: 'subIssueNumber' parameter is required for issue-add-sub-issue action" }],
550			details: { action: "issue-add-sub-issue", error: "missing_sub_issue_number" } as GhDetails,
551			isError: true,
552		};
553	}
554
555	// APPROVAL GATE
556	if (ctx.hasUI) {
557		const confirmMessage = buildSubIssueConfirmation("add", params.number, params.subIssueNumber);
558		const approval = await approvalGate(ctx, `Add sub-issue #${params.subIssueNumber} to #${params.number}?`, confirmMessage);
559		if (approval.outcome === "modify") {
560			ctx.ui.notify("Add sub-issue paused for modifications", "info");
561			return buildModifyResult("add sub-issue", { action: "issue-add-sub-issue", parentNumber: params.number, subIssueNumber: params.subIssueNumber });
562		}
563		if (approval.outcome === "rejected") {
564			ctx.ui.notify("Add sub-issue rejected", "info");
565			return buildRejectResult("add sub-issue", { action: "issue-add-sub-issue", parentNumber: params.number, subIssueNumber: params.subIssueNumber });
566		}
567	}
568
569	onUpdate?.({ content: [{ type: "text", text: `Linking #${params.subIssueNumber} as sub-issue of #${params.number}...` }] });
570
571	const parentNode = await resolveIssueNodeId(pi, ctx, params.number, signal);
572	if (!parentNode) {
573		return {
574			content: [{ type: "text", text: `Error: Could not resolve parent issue #${params.number}` }],
575			details: { action: "issue-add-sub-issue", error: "parent_not_found", parentNumber: params.number } as GhDetails,
576			isError: true,
577		};
578	}
579
580	const childNode = await resolveIssueNodeId(pi, ctx, params.subIssueNumber, signal);
581	if (!childNode) {
582		return {
583			content: [{ type: "text", text: `Error: Could not resolve sub-issue #${params.subIssueNumber}` }],
584			details: { action: "issue-add-sub-issue", error: "sub_issue_not_found", subIssueNumber: params.subIssueNumber } as GhDetails,
585			isError: true,
586		};
587	}
588
589	const result = await addSubIssue(pi, ctx, parentNode.id, childNode.id, signal);
590
591	if (!result.success) {
592		return {
593			content: [{ type: "text", text: `Error: ${result.error}` }],
594			details: { action: "issue-add-sub-issue", error: result.error, parentNumber: params.number, subIssueNumber: params.subIssueNumber } as GhDetails,
595			isError: true,
596		};
597	}
598
599	return {
600		content: [{ type: "text", text: `Added #${params.subIssueNumber} as sub-issue of #${params.number}` }],
601		details: {
602			action: "issue-add-sub-issue",
603			parentNumber: params.number,
604			subIssueNumber: params.subIssueNumber,
605		} as GhDetails,
606	};
607}
608
609/**
610 * Remove sub-issue relationship (requires approval)
611 */
612export async function handleIssueRemoveSubIssue(
613	pi: ExtensionAPI,
614	params: any,
615	signal: AbortSignal | undefined,
616	onUpdate: any,
617	ctx: ExtensionContext,
618): Promise<any> {
619	if (!params.number) {
620		return {
621			content: [{ type: "text", text: "Error: 'number' (parent issue) parameter is required for issue-remove-sub-issue action" }],
622			details: { action: "issue-remove-sub-issue", error: "missing_number" } as GhDetails,
623			isError: true,
624		};
625	}
626
627	if (!params.subIssueNumber) {
628		return {
629			content: [{ type: "text", text: "Error: 'subIssueNumber' parameter is required for issue-remove-sub-issue action" }],
630			details: { action: "issue-remove-sub-issue", error: "missing_sub_issue_number" } as GhDetails,
631			isError: true,
632		};
633	}
634
635	// APPROVAL GATE
636	if (ctx.hasUI) {
637		const confirmMessage = buildSubIssueConfirmation("remove", params.number, params.subIssueNumber);
638		const approval = await approvalGate(ctx, `Remove sub-issue #${params.subIssueNumber} from #${params.number}?`, confirmMessage);
639		if (approval.outcome === "modify") {
640			ctx.ui.notify("Remove sub-issue paused for modifications", "info");
641			return buildModifyResult("remove sub-issue", { action: "issue-remove-sub-issue", parentNumber: params.number, subIssueNumber: params.subIssueNumber });
642		}
643		if (approval.outcome === "rejected") {
644			ctx.ui.notify("Remove sub-issue rejected", "info");
645			return buildRejectResult("remove sub-issue", { action: "issue-remove-sub-issue", parentNumber: params.number, subIssueNumber: params.subIssueNumber });
646		}
647	}
648
649	onUpdate?.({ content: [{ type: "text", text: `Removing #${params.subIssueNumber} as sub-issue of #${params.number}...` }] });
650
651	const parentNode = await resolveIssueNodeId(pi, ctx, params.number, signal);
652	if (!parentNode) {
653		return {
654			content: [{ type: "text", text: `Error: Could not resolve parent issue #${params.number}` }],
655			details: { action: "issue-remove-sub-issue", error: "parent_not_found", parentNumber: params.number } as GhDetails,
656			isError: true,
657		};
658	}
659
660	const childNode = await resolveIssueNodeId(pi, ctx, params.subIssueNumber, signal);
661	if (!childNode) {
662		return {
663			content: [{ type: "text", text: `Error: Could not resolve sub-issue #${params.subIssueNumber}` }],
664			details: { action: "issue-remove-sub-issue", error: "sub_issue_not_found", subIssueNumber: params.subIssueNumber } as GhDetails,
665			isError: true,
666		};
667	}
668
669	const result = await removeSubIssue(pi, ctx, parentNode.id, childNode.id, signal);
670
671	if (!result.success) {
672		return {
673			content: [{ type: "text", text: `Error: ${result.error}` }],
674			details: { action: "issue-remove-sub-issue", error: result.error, parentNumber: params.number, subIssueNumber: params.subIssueNumber } as GhDetails,
675			isError: true,
676		};
677	}
678
679	return {
680		content: [{ type: "text", text: `Removed #${params.subIssueNumber} as sub-issue of #${params.number}` }],
681		details: {
682			action: "issue-remove-sub-issue",
683			parentNumber: params.number,
684			subIssueNumber: params.subIssueNumber,
685		} as GhDetails,
686	};
687}