Commit d70051ccb288
Changed files (5)
dots
pi
agent
extensions
github
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
// ============================================================================