Commit d70051ccb288

Vincent Demeester <vincent@sbr.pm>
2026-02-16 15:55:29
github extension: add sub-issue support
Add three capabilities for managing GitHub sub-issues via GraphQL API: - issue-create with 'parent' param: creates issue and links as sub-issue - issue-add-sub-issue: links existing issue as sub-issue (number=parent, subIssueNumber=child) - issue-remove-sub-issue: removes sub-issue relationship Uses GitHub's addSubIssue/removeSubIssue GraphQL mutations with node ID resolution. All write operations go through approval gates.
1 parent dcad4f7
Changed files (5)
dots/pi/agent/extensions/github/actions/issue.ts
@@ -11,8 +11,12 @@ import {
 	extractIssueUrl,
 	buildIssueCreateConfirmation,
 	buildCommentConfirmation,
+	buildSubIssueConfirmation,
 	truncate,
 	execGh,
+	resolveIssueNodeId,
+	addSubIssue,
+	removeSubIssue,
 } from "../utils";
 
 const ISSUE_LIST_FIELDS = "number,title,state,author,url,labels,assignees,createdAt,updatedAt,body,comments";
@@ -191,6 +195,12 @@ export async function handleIssueCreate(
 	// APPROVAL GATE
 	if (ctx.hasUI) {
 		let confirmMessage = buildIssueCreateConfirmation(params);
+		if (params.parent) {
+			confirmMessage = confirmMessage.replace(
+				"This will create a new issue on GitHub.",
+				`This will create a new issue on GitHub and link it as a sub-issue of #${params.parent}.`,
+			);
+		}
 		
 		// If body is long, offer to preview full content
 		if (params.body && params.body.length > 200) {
@@ -265,13 +275,44 @@ export async function handleIssueCreate(
 	const issueNumber = extractIssueNumber(result.stdout);
 	const issueUrl = extractIssueUrl(result.stdout) || result.stdout.trim();
 
+	// If parent is specified, link this issue as a sub-issue
+	let parentLinked = false;
+	let parentError: string | undefined;
+	if (params.parent && issueNumber) {
+		onUpdate?.({ content: [{ type: "text", text: `Created issue #${issueNumber}, linking as sub-issue of #${params.parent}...` }] });
+
+		const parentNode = await resolveIssueNodeId(pi, ctx, params.parent, signal);
+		const childNode = await resolveIssueNodeId(pi, ctx, issueNumber, signal);
+
+		if (!parentNode) {
+			parentError = `Could not resolve parent issue #${params.parent}`;
+		} else if (!childNode) {
+			parentError = `Could not resolve created issue #${issueNumber} for sub-issue linking`;
+		} else {
+			const linkResult = await addSubIssue(pi, ctx, parentNode.id, childNode.id, signal);
+			if (linkResult.success) {
+				parentLinked = true;
+			} else {
+				parentError = linkResult.error;
+			}
+		}
+	}
+
+	let text = `Created issue${issueNumber ? ` #${issueNumber}` : ""}: ${issueUrl}`;
+	if (parentLinked) {
+		text += ` (sub-issue of #${params.parent})`;
+	} else if (parentError) {
+		text += `\nWarning: Failed to link as sub-issue of #${params.parent}: ${parentError}`;
+	}
+
 	return {
-		content: [{ type: "text", text: `Created issue${issueNumber ? ` #${issueNumber}` : ""}: ${issueUrl}` }],
+		content: [{ type: "text", text }],
 		details: {
 			action: "issue-create",
 			output: result.stdout.trim(),
 			issueNumber: issueNumber ?? undefined,
 			issueUrl: issueUrl ?? undefined,
+			parentNumber: parentLinked ? params.parent : undefined,
 		} as GhDetails,
 	};
 }
@@ -477,3 +518,161 @@ export async function handleIssueEdit(
 		} as GhDetails,
 	};
 }
+
+/**
+ * Add sub-issue relationship (requires approval)
+ */
+export async function handleIssueAddSubIssue(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.number) {
+		return {
+			content: [{ type: "text", text: "Error: 'number' (parent issue) parameter is required for issue-add-sub-issue action" }],
+			details: { action: "issue-add-sub-issue", error: "missing_number" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	if (!params.subIssueNumber) {
+		return {
+			content: [{ type: "text", text: "Error: 'subIssueNumber' parameter is required for issue-add-sub-issue action" }],
+			details: { action: "issue-add-sub-issue", error: "missing_sub_issue_number" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	// APPROVAL GATE
+	if (ctx.hasUI) {
+		const confirmMessage = buildSubIssueConfirmation("add", params.number, params.subIssueNumber);
+		const confirmed = await ctx.ui.confirm(`Add sub-issue #${params.subIssueNumber} to #${params.number}?`, confirmMessage);
+		if (!confirmed) {
+			ctx.ui.notify("Add sub-issue cancelled", "info");
+			return {
+				content: [{ type: "text", text: "Add sub-issue cancelled by user" }],
+				details: { action: "issue-add-sub-issue", cancelled: true } as GhDetails,
+			};
+		}
+	}
+
+	onUpdate?.({ content: [{ type: "text", text: `Linking #${params.subIssueNumber} as sub-issue of #${params.number}...` }] });
+
+	const parentNode = await resolveIssueNodeId(pi, ctx, params.number, signal);
+	if (!parentNode) {
+		return {
+			content: [{ type: "text", text: `Error: Could not resolve parent issue #${params.number}` }],
+			details: { action: "issue-add-sub-issue", error: "parent_not_found", parentNumber: params.number } as GhDetails,
+			isError: true,
+		};
+	}
+
+	const childNode = await resolveIssueNodeId(pi, ctx, params.subIssueNumber, signal);
+	if (!childNode) {
+		return {
+			content: [{ type: "text", text: `Error: Could not resolve sub-issue #${params.subIssueNumber}` }],
+			details: { action: "issue-add-sub-issue", error: "sub_issue_not_found", subIssueNumber: params.subIssueNumber } as GhDetails,
+			isError: true,
+		};
+	}
+
+	const result = await addSubIssue(pi, ctx, parentNode.id, childNode.id, signal);
+
+	if (!result.success) {
+		return {
+			content: [{ type: "text", text: `Error: ${result.error}` }],
+			details: { action: "issue-add-sub-issue", error: result.error, parentNumber: params.number, subIssueNumber: params.subIssueNumber } as GhDetails,
+			isError: true,
+		};
+	}
+
+	return {
+		content: [{ type: "text", text: `Added #${params.subIssueNumber} as sub-issue of #${params.number}` }],
+		details: {
+			action: "issue-add-sub-issue",
+			parentNumber: params.number,
+			subIssueNumber: params.subIssueNumber,
+		} as GhDetails,
+	};
+}
+
+/**
+ * Remove sub-issue relationship (requires approval)
+ */
+export async function handleIssueRemoveSubIssue(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.number) {
+		return {
+			content: [{ type: "text", text: "Error: 'number' (parent issue) parameter is required for issue-remove-sub-issue action" }],
+			details: { action: "issue-remove-sub-issue", error: "missing_number" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	if (!params.subIssueNumber) {
+		return {
+			content: [{ type: "text", text: "Error: 'subIssueNumber' parameter is required for issue-remove-sub-issue action" }],
+			details: { action: "issue-remove-sub-issue", error: "missing_sub_issue_number" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	// APPROVAL GATE
+	if (ctx.hasUI) {
+		const confirmMessage = buildSubIssueConfirmation("remove", params.number, params.subIssueNumber);
+		const confirmed = await ctx.ui.confirm(`Remove sub-issue #${params.subIssueNumber} from #${params.number}?`, confirmMessage);
+		if (!confirmed) {
+			ctx.ui.notify("Remove sub-issue cancelled", "info");
+			return {
+				content: [{ type: "text", text: "Remove sub-issue cancelled by user" }],
+				details: { action: "issue-remove-sub-issue", cancelled: true } as GhDetails,
+			};
+		}
+	}
+
+	onUpdate?.({ content: [{ type: "text", text: `Removing #${params.subIssueNumber} as sub-issue of #${params.number}...` }] });
+
+	const parentNode = await resolveIssueNodeId(pi, ctx, params.number, signal);
+	if (!parentNode) {
+		return {
+			content: [{ type: "text", text: `Error: Could not resolve parent issue #${params.number}` }],
+			details: { action: "issue-remove-sub-issue", error: "parent_not_found", parentNumber: params.number } as GhDetails,
+			isError: true,
+		};
+	}
+
+	const childNode = await resolveIssueNodeId(pi, ctx, params.subIssueNumber, signal);
+	if (!childNode) {
+		return {
+			content: [{ type: "text", text: `Error: Could not resolve sub-issue #${params.subIssueNumber}` }],
+			details: { action: "issue-remove-sub-issue", error: "sub_issue_not_found", subIssueNumber: params.subIssueNumber } as GhDetails,
+			isError: true,
+		};
+	}
+
+	const result = await removeSubIssue(pi, ctx, parentNode.id, childNode.id, signal);
+
+	if (!result.success) {
+		return {
+			content: [{ type: "text", text: `Error: ${result.error}` }],
+			details: { action: "issue-remove-sub-issue", error: result.error, parentNumber: params.number, subIssueNumber: params.subIssueNumber } as GhDetails,
+			isError: true,
+		};
+	}
+
+	return {
+		content: [{ type: "text", text: `Removed #${params.subIssueNumber} as sub-issue of #${params.number}` }],
+		details: {
+			action: "issue-remove-sub-issue",
+			parentNumber: params.number,
+			subIssueNumber: params.subIssueNumber,
+		} as GhDetails,
+	};
+}
dots/pi/agent/extensions/github/github.test.ts
@@ -34,6 +34,7 @@ import {
 	buildReviewEditConfirmation,
 	buildReviewCommentEditConfirmation,
 	buildReviewCommentDeleteConfirmation,
+	buildSubIssueConfirmation,
 	isAuthError,
 	isNotFoundError,
 	isRepoError,
@@ -690,6 +691,22 @@ describe("Confirmation Builders", () => {
 		expect(msg).toContain("888");
 		expect(msg).toContain("permanently delete");
 	});
+
+	test("buildSubIssueConfirmation add includes parent and child", () => {
+		const msg = buildSubIssueConfirmation("add", 100, 200);
+		expect(msg).toContain("Add");
+		expect(msg).toContain("#100");
+		expect(msg).toContain("#200");
+		expect(msg).toContain("child of the parent");
+	});
+
+	test("buildSubIssueConfirmation remove includes parent and child", () => {
+		const msg = buildSubIssueConfirmation("remove", 100, 200);
+		expect(msg).toContain("Remove");
+		expect(msg).toContain("#100");
+		expect(msg).toContain("#200");
+		expect(msg).toContain("remove the parent-child");
+	});
 });
 
 // ============================================================================
dots/pi/agent/extensions/github/index.ts
@@ -53,6 +53,8 @@ import {
 	handleIssueClose,
 	handleIssueComment,
 	handleIssueEdit,
+	handleIssueAddSubIssue,
+	handleIssueRemoveSubIssue,
 } from "./actions/issue";
 import { handleRepoView, handleReleaseList } from "./actions/repo";
 import {
@@ -152,12 +154,15 @@ export default function (pi: ExtensionAPI) {
 			"pr-ready, pr-close, " +
 			"checks, checks-log, checks-restart, run-list, run-view, " +
 			"issue-list, issue-view, issue-create, issue-close, issue-comment, issue-edit, " +
+			"issue-add-sub-issue, issue-remove-sub-issue, " +
 			"repo-view, release-list. " +
 			"Use pr-line-comment to post an inline comment on a specific file/line in the diff. " +
 			"Use pr-review-comments to submit a review (approve/request-changes/comment) with multiple inline comments at once. " +
 			"Use pr-reviews-list/pr-review-edit to list and edit top-level review bodies. " +
 			"Use pr-review-comments-list/pr-review-comment-edit/pr-review-comment-delete to manage inline review comments. " +
 			"Use checks-log with either runId or number (PR number) to get failed check logs - if PR number is provided, the first failed run will be used. " +
+			"Use issue-create with 'parent' param to automatically link as sub-issue. " +
+			"Use issue-add-sub-issue/issue-remove-sub-issue to manage sub-issue relationships on existing issues (number=parent, subIssueNumber=child). " +
 			"Write operations (create, merge, review, comment, close, restart, edit, delete) require user approval.",
 
 		parameters: Type.Object({
@@ -190,6 +195,8 @@ export default function (pi: ExtensionAPI) {
 				"issue-close",
 				"issue-comment",
 				"issue-edit",
+				"issue-add-sub-issue",
+				"issue-remove-sub-issue",
 				"repo-view",
 				"release-list",
 			] as const),
@@ -257,6 +264,10 @@ export default function (pi: ExtensionAPI) {
 
 			// Issue close
 			reason: Type.Optional(Type.String({ description: "Close reason: completed, not planned" })),
+
+			// Sub-issues
+			parent: Type.Optional(Type.Number({ description: "Parent issue number (for issue-create, links created issue as sub-issue)" })),
+			subIssueNumber: Type.Optional(Type.Number({ description: "Sub-issue number (for issue-add-sub-issue, issue-remove-sub-issue)" })),
 		}),
 
 		async execute(toolCallId, params, signal, onUpdate, ctx) {
@@ -323,6 +334,10 @@ export default function (pi: ExtensionAPI) {
 						return await handleIssueComment(pi, params, signal, onUpdate, ctx);
 					case "issue-edit":
 						return await handleIssueEdit(pi, params, signal, onUpdate, ctx);
+					case "issue-add-sub-issue":
+						return await handleIssueAddSubIssue(pi, params, signal, onUpdate, ctx);
+					case "issue-remove-sub-issue":
+						return await handleIssueRemoveSubIssue(pi, params, signal, onUpdate, ctx);
 
 					// Repo actions
 					case "repo-view":
@@ -486,13 +501,31 @@ export default function (pi: ExtensionAPI) {
 				case "issue-view":
 					return renderLongOutput(details, expanded, theme, "Issue");
 				case "issue-create":
-					return renderCreated(details, theme, "Issue", details.issueNumber, details.issueUrl);
+					return renderCreated(details, theme, "Issue", details.issueNumber, details.issueUrl, details.parentNumber);
 				case "issue-close":
 					return new Text(theme.fg("success", "✓ Closed issue ") + theme.fg("accent", `#${details.issueNumber}`), 0, 0);
 				case "issue-comment":
 					return new Text(theme.fg("success", "✓ Comment added to issue ") + theme.fg("accent", `#${details.issueNumber}`), 0, 0);
 				case "issue-edit":
 					return new Text(theme.fg("success", "✓ Updated issue ") + theme.fg("accent", `#${details.issueNumber}`), 0, 0);
+				case "issue-add-sub-issue":
+					return new Text(
+						theme.fg("success", "✓ Added ") +
+							theme.fg("accent", `#${details.subIssueNumber}`) +
+							theme.fg("success", " as sub-issue of ") +
+							theme.fg("accent", `#${details.parentNumber}`),
+						0,
+						0,
+					);
+				case "issue-remove-sub-issue":
+					return new Text(
+						theme.fg("success", "✓ Removed ") +
+							theme.fg("accent", `#${details.subIssueNumber}`) +
+							theme.fg("success", " as sub-issue of ") +
+							theme.fg("accent", `#${details.parentNumber}`),
+						0,
+						0,
+					);
 
 				case "repo-view":
 				case "release-list":
@@ -1043,9 +1076,10 @@ function renderDiff(details: GhDetails, expanded: boolean, theme: Theme): Text {
 	return new Text(theme.fg("muted", summary), 0, 0);
 }
 
-function renderCreated(details: GhDetails, theme: Theme, kind: string, number?: number, url?: string): Text {
+function renderCreated(details: GhDetails, theme: Theme, kind: string, number?: number, url?: string, parentNumber?: number): Text {
 	let text = theme.fg("success", `✓ Created ${kind} `);
 	if (number) text += theme.fg("accent", theme.bold(`#${number}`));
 	if (url) text += theme.fg("dim", ` ${url}`);
+	if (parentNumber) text += theme.fg("muted", ` (sub-issue of #${parentNumber})`);
 	return new Text(text, 0, 0);
 }
dots/pi/agent/extensions/github/types.ts
@@ -29,6 +29,9 @@ export interface GhDetails {
 	commentCount?: number;
 	reviewId?: number;
 	commentId?: number;
+	// Sub-issues
+	parentNumber?: number;
+	subIssueNumber?: number;
 	// Generic
 	field?: string;
 	newValue?: string;
dots/pi/agent/extensions/github/utils.ts
@@ -66,7 +66,7 @@ async function detectWorktreeContext(
 			// Look for git_worktree tool results (from custom git extension)
 			if (msg.role === "toolResult" && msg.toolName === "git_worktree") {
 				const details = msg.details as any;
-				const timestamp = entry.timestamp || 0;
+				const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
 				
 				// git_worktree create returns: { path: string, branch: string }
 				if (details?.path && timestamp > mostRecentTimestamp) {
@@ -608,6 +608,17 @@ export function buildReviewCommentDeleteConfirmation(commentId: number): string
 	return msg;
 }
 
+export function buildSubIssueConfirmation(action: "add" | "remove", parentNumber: number, subIssueNumber: number): string {
+	const verb = action === "add" ? "Add" : "Remove";
+	let msg = `${verb} sub-issue relationship:\n\n`;
+	msg += `Parent: #${parentNumber}\n`;
+	msg += `Sub-issue: #${subIssueNumber}\n\n`;
+	msg += action === "add"
+		? "This will make the sub-issue a child of the parent issue."
+		: "This will remove the parent-child relationship between these issues.";
+	return msg;
+}
+
 export function buildCommentConfirmation(kind: string, number: number, comment: string): string {
 	const preview = comment.length > 200 ? comment.slice(0, 197) + "..." : comment;
 	let msg = `${kind}: #${number}\n\n`;
@@ -616,6 +627,113 @@ export function buildCommentConfirmation(kind: string, number: number, comment:
 	return msg;
 }
 
+// ============================================================================
+// GraphQL helpers
+// ============================================================================
+
+/**
+ * Resolve an issue number to its GitHub node ID via GraphQL.
+ * Requires the owner/repo to be detected from the current git context.
+ */
+export async function resolveIssueNodeId(
+	pi: ExtensionAPI,
+	ctx: ExtensionContext,
+	issueNumber: number,
+	signal?: AbortSignal,
+): Promise<{ id: string; owner: string; repo: string } | null> {
+	// Get owner/repo from the current git context
+	const nwoResult = await execGh(pi, ctx, [
+		"repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner",
+	], { signal, timeout: 10000 });
+
+	if (nwoResult.code !== 0) return null;
+
+	const nwo = nwoResult.stdout.trim();
+	const [owner, repo] = nwo.split("/");
+	if (!owner || !repo) return null;
+
+	const query = `query($owner: String!, $repo: String!, $number: Int!) {
+		repository(owner: $owner, name: $repo) {
+			issue(number: $number) { id }
+		}
+	}`;
+
+	const result = await execGh(pi, ctx, [
+		"api", "graphql",
+		"-f", `query=${query}`,
+		"-f", `owner=${owner}`,
+		"-f", `repo=${repo}`,
+		"-F", `number=${issueNumber}`,
+		"--jq", ".data.repository.issue.id",
+	], { signal, timeout: 10000 });
+
+	if (result.code !== 0 || !result.stdout.trim()) return null;
+
+	return { id: result.stdout.trim(), owner, repo };
+}
+
+/**
+ * Add a sub-issue relationship between two issues via GraphQL.
+ */
+export async function addSubIssue(
+	pi: ExtensionAPI,
+	ctx: ExtensionContext,
+	parentNodeId: string,
+	subIssueNodeId: string,
+	signal?: AbortSignal,
+): Promise<{ success: boolean; error?: string }> {
+	const mutation = `mutation($parentId: ID!, $subIssueId: ID!) {
+		addSubIssue(input: { issueId: $parentId, subIssueId: $subIssueId }) {
+			issue { number }
+			subIssue { number }
+		}
+	}`;
+
+	const result = await execGh(pi, ctx, [
+		"api", "graphql",
+		"-f", `query=${mutation}`,
+		"-f", `parentId=${parentNodeId}`,
+		"-f", `subIssueId=${subIssueNodeId}`,
+	], { signal, timeout: 10000 });
+
+	if (result.code !== 0) {
+		return { success: false, error: result.stderr.trim() || "Failed to add sub-issue" };
+	}
+
+	return { success: true };
+}
+
+/**
+ * Remove a sub-issue relationship between two issues via GraphQL.
+ */
+export async function removeSubIssue(
+	pi: ExtensionAPI,
+	ctx: ExtensionContext,
+	parentNodeId: string,
+	subIssueNodeId: string,
+	signal?: AbortSignal,
+): Promise<{ success: boolean; error?: string }> {
+	const mutation = `mutation($parentId: ID!, $subIssueId: ID!) {
+		removeSubIssue(input: { issueId: $parentId, subIssueId: $subIssueId }) {
+			issue { number }
+			subIssue { number }
+		}
+	}`;
+
+	const result = await execGh(pi, ctx, [
+		"api", "graphql",
+		"-f", `query=${mutation}`,
+		"-f", `parentId=${parentNodeId}`,
+		"-f", `subIssueId=${subIssueNodeId}`,
+	], { signal, timeout: 10000 });
+
+	if (result.code !== 0) {
+		return { success: false, error: result.stderr.trim() || "Failed to remove sub-issue" };
+	}
+
+	return { success: true };
+}
+
 // ============================================================================
 // Error helpers
 // ============================================================================