Commit 9f9c8ea8ea6a
Changed files (7)
dots/pi/agent/extensions/github/actions/checks.ts
@@ -4,7 +4,7 @@
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import type { GhDetails } from "../types";
-import { parseChecks, parseRunList, getErrorMessage, getCheckIcon, getRunStatusIcon, execGh } from "../utils";
+import { parseChecks, parseRunList, getErrorMessage, getCheckIcon, getRunStatusIcon, execGh, approvalGate, buildModifyResult, buildRejectResult } from "../utils";
/**
* Show check status for a PR
@@ -192,16 +192,18 @@ export async function handleChecksRestart(
// APPROVAL GATE
if (ctx.hasUI) {
- const confirmed = await ctx.ui.confirm(
+ const approval = await approvalGate(
+ ctx,
`Restart run ${params.runId}?`,
`This will rerun${params.failedOnly !== false ? " failed jobs in" : ""} workflow run ${params.runId}.`,
);
- if (!confirmed) {
- ctx.ui.notify("Restart cancelled", "info");
- return {
- content: [{ type: "text", text: "Restart cancelled by user" }],
- details: { action: "checks-restart", cancelled: true, runId: params.runId } as GhDetails,
- };
+ if (approval.outcome === "modify") {
+ ctx.ui.notify("Restart paused for modifications", "info");
+ return buildModifyResult("restart run", { action: "checks-restart", runId: params.runId });
+ }
+ if (approval.outcome === "rejected") {
+ ctx.ui.notify("Restart rejected", "info");
+ return buildRejectResult("restart run", { action: "checks-restart", runId: params.runId });
}
}
dots/pi/agent/extensions/github/actions/issue.ts
@@ -17,6 +17,9 @@ import {
resolveIssueNodeId,
addSubIssue,
removeSubIssue,
+ approvalGate,
+ buildModifyResult,
+ buildRejectResult,
} from "../utils";
const ISSUE_LIST_FIELDS = "number,title,state,author,url,labels,assignees,createdAt,updatedAt,body,comments";
@@ -191,47 +194,49 @@ export async function handleIssueCreate(
// If body is long, offer to preview full content
if (params.body && params.body.length > 200) {
- confirmMessage += `\n\n๐ Body: ${params.body.length} characters (truncated in preview)`;
+ confirmMessage += `\n\n๐ Body: ${params.body.length} characters (truncated in preview)`;
const choice = await ctx.ui.select(
- "Create Issue?",
- ["Create issue", "Preview body first", "Cancel"]
+ `Create Issue?\n\n${confirmMessage}`,
+ ["โ Accept", "๐ Preview body first", "โ Modify", "โ Reject"]
);
- if (choice === undefined || choice === "Cancel") {
- ctx.ui.notify("Issue creation cancelled", "info");
- return {
- content: [{ type: "text", text: "Issue creation cancelled by user" }],
- details: { action: "issue-create", cancelled: true } as GhDetails,
- };
+ if (choice === undefined || choice === "โ Reject") {
+ ctx.ui.notify("Issue creation rejected", "info");
+ return buildRejectResult("issue creation", { action: "issue-create" });
}
- if (choice === "Preview body first") {
- // Show full body in editor (read-only preview via input with initial value)
+ if (choice === "โ Modify") {
+ ctx.ui.notify("Issue creation paused for modifications", "info");
+ return buildModifyResult("issue creation", { action: "issue-create" });
+ }
+
+ if (choice === "๐ Preview body first") {
await ctx.ui.editor(
`Issue Body Preview (${params.body.length} chars):\n\nTitle: ${params.title}\n\n---\n\n`,
params.body
);
// Ask again after preview
- const finalConfirm = await ctx.ui.confirm("Create Issue?", confirmMessage);
- if (!finalConfirm) {
- ctx.ui.notify("Issue creation cancelled", "info");
- return {
- content: [{ type: "text", text: "Issue creation cancelled by user" }],
- details: { action: "issue-create", cancelled: true } as GhDetails,
- };
+ const approval = await approvalGate(ctx, "Create Issue?", confirmMessage);
+ if (approval.outcome === "modify") {
+ ctx.ui.notify("Issue creation paused for modifications", "info");
+ return buildModifyResult("issue creation", { action: "issue-create" });
+ }
+ if (approval.outcome === "rejected") {
+ ctx.ui.notify("Issue creation rejected", "info");
+ return buildRejectResult("issue creation", { action: "issue-create" });
}
}
} else {
- // Short body or no body - simple confirm
- const confirmed = await ctx.ui.confirm("Create Issue?", confirmMessage);
- if (!confirmed) {
- ctx.ui.notify("Issue creation cancelled", "info");
- return {
- content: [{ type: "text", text: "Issue creation cancelled by user" }],
- details: { action: "issue-create", cancelled: true } as GhDetails,
- };
+ const approval = await approvalGate(ctx, "Create Issue?", confirmMessage);
+ if (approval.outcome === "modify") {
+ ctx.ui.notify("Issue creation paused for modifications", "info");
+ return buildModifyResult("issue creation", { action: "issue-create" });
+ }
+ if (approval.outcome === "rejected") {
+ ctx.ui.notify("Issue creation rejected", "info");
+ return buildRejectResult("issue creation", { action: "issue-create" });
}
}
}
@@ -325,16 +330,14 @@ export async function handleIssueClose(
// APPROVAL GATE
if (ctx.hasUI) {
const reason = params.reason || "completed";
- const confirmed = await ctx.ui.confirm(
- `Close issue #${params.number}?`,
- `This will close the issue as "${reason}".`,
- );
- if (!confirmed) {
- ctx.ui.notify("Close cancelled", "info");
- return {
- content: [{ type: "text", text: "Close cancelled by user" }],
- details: { action: "issue-close", cancelled: true, issueNumber: params.number } as GhDetails,
- };
+ const approval = await approvalGate(ctx, `Close issue #${params.number}?`, `This will close the issue as "${reason}".`);
+ if (approval.outcome === "modify") {
+ ctx.ui.notify("Close paused for modifications", "info");
+ return buildModifyResult("issue close", { action: "issue-close", issueNumber: params.number });
+ }
+ if (approval.outcome === "rejected") {
+ ctx.ui.notify("Close rejected", "info");
+ return buildRejectResult("issue close", { action: "issue-close", issueNumber: params.number });
}
}
@@ -386,13 +389,14 @@ export async function handleIssueComment(
// APPROVAL GATE
if (ctx.hasUI) {
const confirmMessage = buildCommentConfirmation("Issue", params.number, params.body);
- const confirmed = await ctx.ui.confirm(`Comment on issue #${params.number}?`, confirmMessage);
- if (!confirmed) {
- ctx.ui.notify("Comment cancelled", "info");
- return {
- content: [{ type: "text", text: "Comment cancelled by user" }],
- details: { action: "issue-comment", cancelled: true, issueNumber: params.number } as GhDetails,
- };
+ const approval = await approvalGate(ctx, `Comment on issue #${params.number}?`, confirmMessage);
+ if (approval.outcome === "modify") {
+ ctx.ui.notify("Comment paused for modifications", "info");
+ return buildModifyResult("comment", { action: "issue-comment", issueNumber: params.number });
+ }
+ if (approval.outcome === "rejected") {
+ ctx.ui.notify("Comment rejected", "info");
+ return buildRejectResult("comment", { action: "issue-comment", issueNumber: params.number });
}
}
@@ -456,13 +460,14 @@ export async function handleIssueEdit(
// APPROVAL GATE
if (ctx.hasUI) {
const confirmMessage = `Issue: #${params.number}\n\nChanges:\n${changes.join("\n")}\n\nThis will modify the issue.`;
- const confirmed = await ctx.ui.confirm(`Edit issue #${params.number}?`, confirmMessage);
- if (!confirmed) {
- ctx.ui.notify("Edit cancelled", "info");
- return {
- content: [{ type: "text", text: "Edit cancelled by user" }],
- details: { action: "issue-edit", cancelled: true, issueNumber: params.number } as GhDetails,
- };
+ const approval = await approvalGate(ctx, `Edit issue #${params.number}?`, confirmMessage);
+ if (approval.outcome === "modify") {
+ ctx.ui.notify("Edit paused for modifications", "info");
+ return buildModifyResult("issue edit", { action: "issue-edit", issueNumber: params.number });
+ }
+ if (approval.outcome === "rejected") {
+ ctx.ui.notify("Edit rejected", "info");
+ return buildRejectResult("issue edit", { action: "issue-edit", issueNumber: params.number });
}
}
@@ -535,13 +540,14 @@ export async function handleIssueAddSubIssue(
// 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,
- };
+ const approval = await approvalGate(ctx, `Add sub-issue #${params.subIssueNumber} to #${params.number}?`, confirmMessage);
+ if (approval.outcome === "modify") {
+ ctx.ui.notify("Add sub-issue paused for modifications", "info");
+ return buildModifyResult("add sub-issue", { action: "issue-add-sub-issue", parentNumber: params.number, subIssueNumber: params.subIssueNumber });
+ }
+ if (approval.outcome === "rejected") {
+ ctx.ui.notify("Add sub-issue rejected", "info");
+ return buildRejectResult("add sub-issue", { action: "issue-add-sub-issue", parentNumber: params.number, subIssueNumber: params.subIssueNumber });
}
}
@@ -614,13 +620,14 @@ export async function handleIssueRemoveSubIssue(
// 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,
- };
+ const approval = await approvalGate(ctx, `Remove sub-issue #${params.subIssueNumber} from #${params.number}?`, confirmMessage);
+ if (approval.outcome === "modify") {
+ ctx.ui.notify("Remove sub-issue paused for modifications", "info");
+ return buildModifyResult("remove sub-issue", { action: "issue-remove-sub-issue", parentNumber: params.number, subIssueNumber: params.subIssueNumber });
+ }
+ if (approval.outcome === "rejected") {
+ ctx.ui.notify("Remove sub-issue rejected", "info");
+ return buildRejectResult("remove sub-issue", { action: "issue-remove-sub-issue", parentNumber: params.number, subIssueNumber: params.subIssueNumber });
}
}
dots/pi/agent/extensions/github/actions/pr.ts
@@ -27,6 +27,9 @@ import {
execGh,
resolveGitCwd,
findPRTemplate,
+ approvalGate,
+ buildModifyResult,
+ buildRejectResult,
} from "../utils";
const PR_LIST_FIELDS = "number,title,state,author,headRefName,baseRefName,url,isDraft,labels,reviewDecision,additions,deletions,changedFiles,createdAt,updatedAt";
@@ -254,48 +257,49 @@ export async function handlePRCreate(
// If body is long, offer to preview full content
if (params.body && params.body.length > 200) {
- confirmMessage += `\n\n๐ Body: ${params.body.length} characters (truncated in preview)`;
- confirmMessage += `\n Press 'y' to create, 'p' to preview full body, 'n' to cancel`;
+ confirmMessage += `\n\n๐ Body: ${params.body.length} characters (truncated in preview)`;
const choice = await ctx.ui.select(
- "Create Pull Request?",
- ["Create PR", "Preview body first", "Cancel"]
+ `Create Pull Request?\n\n${confirmMessage}`,
+ ["โ Accept", "๐ Preview body first", "โ Modify", "โ Reject"]
);
- if (choice === undefined || choice === "Cancel") {
- ctx.ui.notify("PR creation cancelled", "info");
- return {
- content: [{ type: "text", text: "PR creation cancelled by user" }],
- details: { action: "pr-create", cancelled: true } as GhDetails,
- };
+ if (choice === undefined || choice === "โ Reject") {
+ ctx.ui.notify("PR creation rejected", "info");
+ return buildRejectResult("PR creation", { action: "pr-create" });
}
- if (choice === "Preview body first") {
- // Show full body in editor (read-only preview via input with initial value)
+ if (choice === "โ Modify") {
+ ctx.ui.notify("PR creation paused for modifications", "info");
+ return buildModifyResult("PR creation", { action: "pr-create" });
+ }
+
+ if (choice === "๐ Preview body first") {
await ctx.ui.editor(
`PR Body Preview (${params.body.length} chars):\n\nTitle: ${params.title}\n\n---\n\n`,
params.body
);
// Ask again after preview
- const finalConfirm = await ctx.ui.confirm("Create Pull Request?", confirmMessage);
- if (!finalConfirm) {
- ctx.ui.notify("PR creation cancelled", "info");
- return {
- content: [{ type: "text", text: "PR creation cancelled by user" }],
- details: { action: "pr-create", cancelled: true } as GhDetails,
- };
+ const approval = await approvalGate(ctx, "Create Pull Request?", confirmMessage);
+ if (approval.outcome === "modify") {
+ ctx.ui.notify("PR creation paused for modifications", "info");
+ return buildModifyResult("PR creation", { action: "pr-create" });
+ }
+ if (approval.outcome === "rejected") {
+ ctx.ui.notify("PR creation rejected", "info");
+ return buildRejectResult("PR creation", { action: "pr-create" });
}
}
} else {
- // Short body or no body - simple confirm
- const confirmed = await ctx.ui.confirm("Create Pull Request?", confirmMessage);
- if (!confirmed) {
- ctx.ui.notify("PR creation cancelled", "info");
- return {
- content: [{ type: "text", text: "PR creation cancelled by user" }],
- details: { action: "pr-create", cancelled: true } as GhDetails,
- };
+ const approval = await approvalGate(ctx, "Create Pull Request?", confirmMessage);
+ if (approval.outcome === "modify") {
+ ctx.ui.notify("PR creation paused for modifications", "info");
+ return buildModifyResult("PR creation", { action: "pr-create" });
+ }
+ if (approval.outcome === "rejected") {
+ ctx.ui.notify("PR creation rejected", "info");
+ return buildRejectResult("PR creation", { action: "pr-create" });
}
}
}
@@ -452,13 +456,14 @@ export async function handlePRMerge(
// APPROVAL GATE
if (ctx.hasUI) {
const confirmMessage = buildPRMergeConfirmation(params);
- const confirmed = await ctx.ui.confirm(`Merge PR #${params.number}?`, confirmMessage);
- if (!confirmed) {
- ctx.ui.notify("Merge cancelled", "info");
- return {
- content: [{ type: "text", text: "Merge cancelled by user" }],
- details: { action: "pr-merge", cancelled: true, prNumber: params.number } as GhDetails,
- };
+ const approval = await approvalGate(ctx, `Merge PR #${params.number}?`, confirmMessage);
+ if (approval.outcome === "modify") {
+ ctx.ui.notify("Merge paused for modifications", "info");
+ return buildModifyResult("merge", { action: "pr-merge", prNumber: params.number });
+ }
+ if (approval.outcome === "rejected") {
+ ctx.ui.notify("Merge rejected", "info");
+ return buildRejectResult("merge", { action: "pr-merge", prNumber: params.number });
}
}
@@ -518,13 +523,14 @@ export async function handlePRReview(
// APPROVAL GATE
if (ctx.hasUI) {
const confirmMessage = buildReviewConfirmation(params);
- const confirmed = await ctx.ui.confirm(`Submit review on PR #${params.number}?`, confirmMessage);
- if (!confirmed) {
- ctx.ui.notify("Review cancelled", "info");
- return {
- content: [{ type: "text", text: "Review cancelled by user" }],
- details: { action: "pr-review", cancelled: true, prNumber: params.number } as GhDetails,
- };
+ const approval = await approvalGate(ctx, `Submit review on PR #${params.number}?`, confirmMessage);
+ if (approval.outcome === "modify") {
+ ctx.ui.notify("Review paused for modifications", "info");
+ return buildModifyResult("review", { action: "pr-review", prNumber: params.number });
+ }
+ if (approval.outcome === "rejected") {
+ ctx.ui.notify("Review rejected", "info");
+ return buildRejectResult("review", { action: "pr-review", prNumber: params.number });
}
}
@@ -596,13 +602,14 @@ export async function handlePRComment(
// APPROVAL GATE
if (ctx.hasUI) {
const confirmMessage = buildCommentConfirmation("PR", params.number, params.body);
- const confirmed = await ctx.ui.confirm(`Comment on PR #${params.number}?`, confirmMessage);
- if (!confirmed) {
- ctx.ui.notify("Comment cancelled", "info");
- return {
- content: [{ type: "text", text: "Comment cancelled by user" }],
- details: { action: "pr-comment", cancelled: true, prNumber: params.number } as GhDetails,
- };
+ const approval = await approvalGate(ctx, `Comment on PR #${params.number}?`, confirmMessage);
+ if (approval.outcome === "modify") {
+ ctx.ui.notify("Comment paused for modifications", "info");
+ return buildModifyResult("comment", { action: "pr-comment", prNumber: params.number });
+ }
+ if (approval.outcome === "rejected") {
+ ctx.ui.notify("Comment rejected", "info");
+ return buildRejectResult("comment", { action: "pr-comment", prNumber: params.number });
}
}
@@ -651,16 +658,14 @@ export async function handlePRReady(
const description = title
? `"${truncate(title, 80)}"\n\nThis will mark the draft PR as ready for review.`
: "This will mark the draft PR as ready for review.";
- const confirmed = await ctx.ui.confirm(
- `Mark PR #${params.number} as ready?`,
- description,
- );
- if (!confirmed) {
- ctx.ui.notify("Cancelled", "info");
- return {
- content: [{ type: "text", text: "Operation cancelled by user" }],
- details: { action: "pr-ready", cancelled: true, prNumber: params.number } as GhDetails,
- };
+ const approval = await approvalGate(ctx, `Mark PR #${params.number} as ready?`, description);
+ if (approval.outcome === "modify") {
+ ctx.ui.notify("Paused for modifications", "info");
+ return buildModifyResult("mark-ready", { action: "pr-ready", prNumber: params.number });
+ }
+ if (approval.outcome === "rejected") {
+ ctx.ui.notify("Rejected", "info");
+ return buildRejectResult("mark-ready", { action: "pr-ready", prNumber: params.number });
}
}
@@ -706,16 +711,14 @@ export async function handlePRClose(
const description = title
? `"${truncate(title, 80)}"\n\nThis will close the pull request without merging.`
: "This will close the pull request without merging.";
- const confirmed = await ctx.ui.confirm(
- `Close PR #${params.number}?`,
- description,
- );
- if (!confirmed) {
- ctx.ui.notify("Cancelled", "info");
- return {
- content: [{ type: "text", text: "Close cancelled by user" }],
- details: { action: "pr-close", cancelled: true, prNumber: params.number } as GhDetails,
- };
+ const approval = await approvalGate(ctx, `Close PR #${params.number}?`, description);
+ if (approval.outcome === "modify") {
+ ctx.ui.notify("Paused for modifications", "info");
+ return buildModifyResult("close PR", { action: "pr-close", prNumber: params.number });
+ }
+ if (approval.outcome === "rejected") {
+ ctx.ui.notify("Rejected", "info");
+ return buildRejectResult("close PR", { action: "pr-close", prNumber: params.number });
}
}
@@ -813,13 +816,14 @@ export async function handlePRLineComment(
// APPROVAL GATE
if (ctx.hasUI) {
const confirmMessage = buildLineCommentConfirmation(params.number, params.path, params.line, params.body, params.startLine);
- const confirmed = await ctx.ui.confirm(`Add inline comment on PR #${params.number}?`, confirmMessage);
- if (!confirmed) {
- ctx.ui.notify("Comment cancelled", "info");
- return {
- content: [{ type: "text", text: "Inline comment cancelled by user" }],
- details: { action: "pr-line-comment", cancelled: true, prNumber: params.number } as GhDetails,
- };
+ const approval = await approvalGate(ctx, `Add inline comment on PR #${params.number}?`, confirmMessage);
+ if (approval.outcome === "modify") {
+ ctx.ui.notify("Inline comment paused for modifications", "info");
+ return buildModifyResult("inline comment", { action: "pr-line-comment", prNumber: params.number });
+ }
+ if (approval.outcome === "rejected") {
+ ctx.ui.notify("Inline comment rejected", "info");
+ return buildRejectResult("inline comment", { action: "pr-line-comment", prNumber: params.number });
}
}
@@ -929,13 +933,14 @@ export async function handlePRReviewWithComments(
// APPROVAL GATE
if (ctx.hasUI) {
const confirmMessage = buildReviewWithCommentsConfirmation(params.number, params.reviewAction, params.body, params.comments.length);
- const confirmed = await ctx.ui.confirm(`Submit review with ${params.comments.length} inline comment(s) on PR #${params.number}?`, confirmMessage);
- if (!confirmed) {
- ctx.ui.notify("Review cancelled", "info");
- return {
- content: [{ type: "text", text: "Review with comments cancelled by user" }],
- details: { action: "pr-review-comments", cancelled: true, prNumber: params.number } as GhDetails,
- };
+ const approval = await approvalGate(ctx, `Submit review with ${params.comments.length} inline comment(s) on PR #${params.number}?`, confirmMessage);
+ if (approval.outcome === "modify") {
+ ctx.ui.notify("Review paused for modifications", "info");
+ return buildModifyResult("review with comments", { action: "pr-review-comments", prNumber: params.number });
+ }
+ if (approval.outcome === "rejected") {
+ ctx.ui.notify("Review rejected", "info");
+ return buildRejectResult("review with comments", { action: "pr-review-comments", prNumber: params.number });
}
}
@@ -1122,13 +1127,14 @@ export async function handlePRReviewEdit(
// APPROVAL GATE
if (ctx.hasUI) {
const confirmMessage = buildReviewEditConfirmation(params.number, params.reviewId, params.body);
- const confirmed = await ctx.ui.confirm(`Edit review ${params.reviewId} on PR #${params.number}?`, confirmMessage);
- if (!confirmed) {
- ctx.ui.notify("Review edit cancelled", "info");
- return {
- content: [{ type: "text", text: "Review edit cancelled by user" }],
- details: { action: "pr-review-edit", cancelled: true, prNumber: params.number } as GhDetails,
- };
+ const approval = await approvalGate(ctx, `Edit review ${params.reviewId} on PR #${params.number}?`, confirmMessage);
+ if (approval.outcome === "modify") {
+ ctx.ui.notify("Review edit paused for modifications", "info");
+ return buildModifyResult("review edit", { action: "pr-review-edit", prNumber: params.number, reviewId: params.reviewId });
+ }
+ if (approval.outcome === "rejected") {
+ ctx.ui.notify("Review edit rejected", "info");
+ return buildRejectResult("review edit", { action: "pr-review-edit", prNumber: params.number, reviewId: params.reviewId });
}
}
@@ -1260,13 +1266,14 @@ export async function handlePRReviewCommentEdit(
// APPROVAL GATE
if (ctx.hasUI) {
const confirmMessage = buildReviewCommentEditConfirmation(params.commentId, params.body);
- const confirmed = await ctx.ui.confirm(`Edit review comment ${params.commentId}?`, confirmMessage);
- if (!confirmed) {
- ctx.ui.notify("Edit cancelled", "info");
- return {
- content: [{ type: "text", text: "Review comment edit cancelled by user" }],
- details: { action: "pr-review-comment-edit", cancelled: true, commentId: params.commentId } as GhDetails,
- };
+ const approval = await approvalGate(ctx, `Edit review comment ${params.commentId}?`, confirmMessage);
+ if (approval.outcome === "modify") {
+ ctx.ui.notify("Comment edit paused for modifications", "info");
+ return buildModifyResult("review comment edit", { action: "pr-review-comment-edit", commentId: params.commentId });
+ }
+ if (approval.outcome === "rejected") {
+ ctx.ui.notify("Comment edit rejected", "info");
+ return buildRejectResult("review comment edit", { action: "pr-review-comment-edit", commentId: params.commentId });
}
}
@@ -1330,13 +1337,14 @@ export async function handlePRReviewCommentDelete(
// APPROVAL GATE
if (ctx.hasUI) {
const confirmMessage = buildReviewCommentDeleteConfirmation(params.commentId);
- const confirmed = await ctx.ui.confirm(`Delete review comment ${params.commentId}?`, confirmMessage);
- if (!confirmed) {
- ctx.ui.notify("Delete cancelled", "info");
- return {
- content: [{ type: "text", text: "Review comment delete cancelled by user" }],
- details: { action: "pr-review-comment-delete", cancelled: true, commentId: params.commentId } as GhDetails,
- };
+ const approval = await approvalGate(ctx, `Delete review comment ${params.commentId}?`, confirmMessage);
+ if (approval.outcome === "modify") {
+ ctx.ui.notify("Delete paused for modifications", "info");
+ return buildModifyResult("review comment delete", { action: "pr-review-comment-delete", commentId: params.commentId });
+ }
+ if (approval.outcome === "rejected") {
+ ctx.ui.notify("Delete rejected", "info");
+ return buildRejectResult("review comment delete", { action: "pr-review-comment-delete", commentId: params.commentId });
}
}
dots/pi/agent/extensions/github/github.test.ts
@@ -43,7 +43,11 @@ import {
extractIssueNumber,
extractPRUrl,
extractIssueUrl,
+ approvalGate,
+ buildModifyResult,
+ buildRejectResult,
} from "./utils";
+import type { GhDetails } from "./types";
// ============================================================================
// Parsing Tests
@@ -826,3 +830,106 @@ describe("Auto-detection Patterns", () => {
expect("https://github.com/org/repo/issues/".match(pattern)).toBeNull();
});
});
+
+// ============================================================================
+// Approval Gate Tests
+// ============================================================================
+
+describe("Approval Gate", () => {
+ function mockCtx(selectReturn: string | undefined) {
+ return {
+ ui: {
+ select: async (_title: string, _options: string[]) => selectReturn,
+ notify: (_msg: string, _level: string) => {},
+ },
+ } as any;
+ }
+
+ test("approvalGate returns accepted when user selects Accept", async () => {
+ const result = await approvalGate(mockCtx("โ Accept"), "Test?", "Description");
+ expect(result.outcome).toBe("accepted");
+ });
+
+ test("approvalGate returns modify when user selects Modify", async () => {
+ const result = await approvalGate(mockCtx("โ Modify"), "Test?", "Description");
+ expect(result.outcome).toBe("modify");
+ });
+
+ test("approvalGate returns rejected when user selects Reject", async () => {
+ const result = await approvalGate(mockCtx("โ Reject"), "Test?", "Description");
+ expect(result.outcome).toBe("rejected");
+ });
+
+ test("approvalGate returns rejected when user presses Escape (undefined)", async () => {
+ const result = await approvalGate(mockCtx(undefined), "Test?", "Description");
+ expect(result.outcome).toBe("rejected");
+ });
+
+ test("approvalGate passes title with description to select", async () => {
+ let capturedTitle = "";
+ let capturedOptions: string[] = [];
+ const ctx = {
+ ui: {
+ select: async (title: string, options: string[]) => {
+ capturedTitle = title;
+ capturedOptions = options;
+ return "โ Accept";
+ },
+ notify: () => {},
+ },
+ } as any;
+
+ await approvalGate(ctx, "Create PR?", "Title: fix stuff\nBase: main");
+ expect(capturedTitle).toContain("Create PR?");
+ expect(capturedTitle).toContain("Title: fix stuff");
+ expect(capturedTitle).toContain("Base: main");
+ expect(capturedOptions).toEqual(["โ Accept", "โ Modify", "โ Reject"]);
+ });
+});
+
+describe("buildModifyResult", () => {
+ test("includes modify message telling LLM to ask user", () => {
+ const result = buildModifyResult("PR creation", { action: "pr-create" });
+ const text = result.content[0].text;
+ expect(text).toContain("modify");
+ expect(text).toContain("Ask the user");
+ expect(text).toContain("retry");
+ });
+
+ test("sets modifyRequested in details", () => {
+ const result = buildModifyResult("PR creation", { action: "pr-create", prNumber: 123 });
+ const details = result.details as GhDetails;
+ expect(details.modifyRequested).toBe(true);
+ expect(details.cancelled).toBeUndefined();
+ expect(details.action).toBe("pr-create");
+ expect(details.prNumber).toBe(123);
+ });
+
+ test("does not set isError", () => {
+ const result = buildModifyResult("comment", { action: "pr-comment" });
+ expect(result.isError).toBeUndefined();
+ });
+});
+
+describe("buildRejectResult", () => {
+ test("includes reject message telling LLM NOT to retry", () => {
+ const result = buildRejectResult("PR creation", { action: "pr-create" });
+ const text = result.content[0].text;
+ expect(text).toContain("rejected");
+ expect(text).toContain("Do NOT retry");
+ });
+
+ test("sets cancelled in details", () => {
+ const result = buildRejectResult("PR creation", { action: "pr-create", prNumber: 42 });
+ const details = result.details as GhDetails;
+ expect(details.cancelled).toBe(true);
+ expect(details.modifyRequested).toBeUndefined();
+ expect(details.action).toBe("pr-create");
+ expect(details.prNumber).toBe(42);
+ });
+
+ test("does not set isError", () => {
+ const result = buildRejectResult("merge", { action: "pr-merge" });
+ expect(result.isError).toBeUndefined();
+ });
+});
dots/pi/agent/extensions/github/index.ts
@@ -383,8 +383,12 @@ export default function (pi: ExtensionAPI) {
return new Text(theme.fg("error", `โ Error: ${details.error}`), 0, 0);
}
+ if (details.modifyRequested) {
+ return new Text(theme.fg("warning", "โ User requested modifications"), 0, 0);
+ }
+
if (details.cancelled) {
- return new Text(theme.fg("warning", "โ Cancelled by user"), 0, 0);
+ return new Text(theme.fg("error", "โ Rejected by user"), 0, 0);
}
switch (details.action) {
dots/pi/agent/extensions/github/types.ts
@@ -10,6 +10,7 @@ export interface GhDetails {
action: string;
output?: string;
cancelled?: boolean;
+ modifyRequested?: boolean;
error?: string;
// PR-specific
prNumber?: number;
dots/pi/agent/extensions/github/utils.ts
@@ -3,7 +3,7 @@
*/
import type { Theme, ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
-import type { GhPR, GhIssue, GhCheck, GhRun, GhReview, GhReviewComment, GhReviewSummary, GhRelease, GhRepo } from "./types";
+import type { GhDetails, GhPR, GhIssue, GhCheck, GhRun, GhReview, GhReviewComment, GhReviewSummary, GhRelease, GhRepo } from "./types";
// ============================================================================
// Git Repository Detection (shared with handlers)
@@ -510,6 +510,65 @@ export function getReviewDecisionText(decision: string): string {
}
}
+// ============================================================================
+// Approval gate helper
+// ============================================================================
+
+export type ApprovalResult =
+ | { outcome: "accepted" }
+ | { outcome: "modify" }
+ | { outcome: "rejected" };
+
+/**
+ * Three-way approval gate: Accept / Modify / Reject.
+ *
+ * - "Accept" proceeds with execution.
+ * - "Modify" tells the LLM the user wants to iterate on the content before
+ * submitting. The LLM should ask the user what to change.
+ * - "Reject" means the user does not want this action at all.
+ *
+ * Returns the user's choice so the caller can build the appropriate response.
+ */
+export async function approvalGate(
+ ctx: ExtensionContext,
+ title: string,
+ description: string,
+): Promise<ApprovalResult> {
+ const prompt = description ? `${title}\n\n${description}` : title;
+ const choice = await ctx.ui.select(prompt, [
+ "โ Accept",
+ "โ Modify",
+ "โ Reject",
+ ]);
+
+ if (choice === "โ Accept") return { outcome: "accepted" };
+ if (choice === "โ Modify") return { outcome: "modify" };
+ // undefined (Escape) or "โ Reject"
+ return { outcome: "rejected" };
+}
+
+/**
+ * Build a tool result for when the user wants modifications.
+ * The message clearly tells the LLM to ask the user what they want changed.
+ */
+export function buildModifyResult(action: string, details: Partial<GhDetails>): any {
+ return {
+ content: [{ type: "text", text: `User wants to modify the ${action} before submitting. Ask the user what they would like to change, then retry with the updated parameters.` }],
+ details: { action: details.action, modifyRequested: true, ...details } as GhDetails,
+ };
+}
+
+/**
+ * Build a tool result for when the user rejects the action entirely.
+ * The message explicitly tells the LLM NOT to retry.
+ */
+export function buildRejectResult(action: string, details: Partial<GhDetails>): any {
+ return {
+ content: [{ type: "text", text: `User rejected this ${action}. Do NOT retry or attempt this action again.` }],
+ details: { action: details.action, cancelled: true, ...details } as GhDetails,
+ };
+}
+
// ============================================================================
// Confirmation builders
// ============================================================================