Add confirmation gate to GitHub tools + update license to MIT
- All outward-facing GitHub tool actions now require user confirmation via a themed yes/no dialog before executing (create, update, close, reopen issues; create/update PRs; add comments; submit reviews; request reviewers; create labels/milestones) - New shared confirm-ui.ts component using the shared UI design system - Read-only actions (list, view, search, diff, files, checks) ungated - License changed from BUSL-1.1 to MIT
This commit is contained in:
parent
d1370f74d5
commit
86a6456aef
3 changed files with 176 additions and 2 deletions
|
|
@ -2,7 +2,7 @@
|
|||
"name": "gsd-pi",
|
||||
"version": "0.1.5",
|
||||
"description": "GSD — Get Stuff Done coding agent",
|
||||
"license": "BUSL-1.1",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/glittercowboy/gsd-pi.git"
|
||||
|
|
|
|||
|
|
@ -28,8 +28,9 @@
|
|||
import { Type } from "@sinclair/typebox";
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { truncateHead, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@mariozechner/pi-coding-agent";
|
||||
import { showConfirm } from "../shared/confirm-ui.js";
|
||||
|
||||
import {
|
||||
isAuthenticated,
|
||||
|
|
@ -102,6 +103,29 @@ function textResult(text: string, details?: Record<string, unknown>) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmation gate for outward-facing GitHub actions.
|
||||
* Shows a themed yes/no confirmation in interactive mode.
|
||||
* In non-interactive mode (no UI), blocks the action.
|
||||
* Returns the rejected textResult if denied, or undefined if confirmed.
|
||||
*/
|
||||
async function confirmAction(
|
||||
ctx: ExtensionContext,
|
||||
action: string,
|
||||
): Promise<ReturnType<typeof textResult> | undefined> {
|
||||
if (!ctx.hasUI) {
|
||||
return textResult(`Blocked: "${action}" requires user confirmation but no UI is available.`);
|
||||
}
|
||||
const confirmed = await showConfirm(ctx, {
|
||||
title: "GitHub",
|
||||
message: action,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return textResult(`Cancelled: user declined "${action}".`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ─── Extension ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
|
|
@ -116,6 +140,7 @@ export default function (pi: ExtensionAPI) {
|
|||
"Use github_issues to interact with GitHub issues instead of running `gh` CLI commands directly.",
|
||||
"When listing issues, default to state='open' and include relevant filters like labels or assignee.",
|
||||
"When searching, use GitHub search syntax in the query (e.g., 'is:open label:bug').",
|
||||
"Mutating actions (create, update, close, reopen) require user confirmation before executing.",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
action: StringEnum(["list", "view", "create", "update", "close", "reopen", "search"] as const),
|
||||
|
|
@ -161,6 +186,8 @@ export default function (pi: ExtensionAPI) {
|
|||
}
|
||||
case "create": {
|
||||
if (!params.title) return textResult("Error: 'title' is required for create action.");
|
||||
const createGate = await confirmAction(ctx, `Create issue "${params.title}"?`);
|
||||
if (createGate) return createGate;
|
||||
const newIssue = await createIssue(repo, {
|
||||
title: params.title,
|
||||
body: params.body,
|
||||
|
|
@ -174,6 +201,8 @@ export default function (pi: ExtensionAPI) {
|
|||
}
|
||||
case "update": {
|
||||
if (!params.number) return textResult("Error: 'number' is required for update action.");
|
||||
const updateGate = await confirmAction(ctx, `Update issue #${params.number}?`);
|
||||
if (updateGate) return updateGate;
|
||||
const updated = await updateIssue(repo, params.number, {
|
||||
title: params.title,
|
||||
body: params.body,
|
||||
|
|
@ -187,11 +216,15 @@ export default function (pi: ExtensionAPI) {
|
|||
}
|
||||
case "close": {
|
||||
if (!params.number) return textResult("Error: 'number' is required for close action.");
|
||||
const closeGate = await confirmAction(ctx, `Close issue #${params.number}?`);
|
||||
if (closeGate) return closeGate;
|
||||
const closed = await updateIssue(repo, params.number, { state: "closed" });
|
||||
return textResult(`Closed issue #${closed.number}: ${closed.title}`, { issue: { number: closed.number } });
|
||||
}
|
||||
case "reopen": {
|
||||
if (!params.number) return textResult("Error: 'number' is required for reopen action.");
|
||||
const reopenGate = await confirmAction(ctx, `Reopen issue #${params.number}?`);
|
||||
if (reopenGate) return reopenGate;
|
||||
const reopened = await updateIssue(repo, params.number, { state: "open" });
|
||||
return textResult(`Reopened issue #${reopened.number}: ${reopened.title}`, { issue: { number: reopened.number } });
|
||||
}
|
||||
|
|
@ -242,6 +275,7 @@ export default function (pi: ExtensionAPI) {
|
|||
"Use action='diff' to see the actual code changes in a PR.",
|
||||
"Use action='files' for a summary of changed files without the full diff.",
|
||||
"Use action='checks' to see CI/CD status for a PR.",
|
||||
"Mutating actions (create, update) require user confirmation before executing.",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
action: StringEnum(["list", "view", "create", "update", "diff", "files", "checks"] as const),
|
||||
|
|
@ -285,6 +319,8 @@ export default function (pi: ExtensionAPI) {
|
|||
const head = params.head ?? getCurrentBranch(ctx.cwd);
|
||||
if (!head) return textResult("Error: Could not determine current branch. Provide 'head' parameter.");
|
||||
const base = params.base ?? getDefaultBranch(ctx.cwd);
|
||||
const createPRGate = await confirmAction(ctx, `Create PR "${params.title}" (${head} → ${base})?`);
|
||||
if (createPRGate) return createPRGate;
|
||||
const newPR = await createPullRequest(repo, {
|
||||
title: params.title,
|
||||
body: params.body,
|
||||
|
|
@ -299,6 +335,8 @@ export default function (pi: ExtensionAPI) {
|
|||
}
|
||||
case "update": {
|
||||
if (!params.number) return textResult("Error: 'number' is required for update action.");
|
||||
const updatePRGate = await confirmAction(ctx, `Update PR #${params.number}?`);
|
||||
if (updatePRGate) return updatePRGate;
|
||||
const updated = await updatePullRequest(repo, params.number, {
|
||||
title: params.title,
|
||||
body: params.body,
|
||||
|
|
@ -389,6 +427,8 @@ export default function (pi: ExtensionAPI) {
|
|||
}
|
||||
case "add": {
|
||||
if (!params.body) return textResult("Error: 'body' is required for add action.");
|
||||
const addGate = await confirmAction(ctx, `Add comment on #${params.number}?`);
|
||||
if (addGate) return addGate;
|
||||
const comment = await addComment(repo, params.number, params.body);
|
||||
return textResult(
|
||||
`Added comment on #${params.number}: ${comment.html_url}`,
|
||||
|
|
@ -451,6 +491,8 @@ export default function (pi: ExtensionAPI) {
|
|||
}
|
||||
case "submit": {
|
||||
if (!params.event) return textResult("Error: 'event' is required for submit action (APPROVE, REQUEST_CHANGES, or COMMENT).");
|
||||
const submitGate = await confirmAction(ctx, `Submit ${params.event} review on PR #${params.number}?`);
|
||||
if (submitGate) return submitGate;
|
||||
const review = await createReview(repo, params.number, {
|
||||
body: params.body,
|
||||
event: params.event,
|
||||
|
|
@ -463,6 +505,8 @@ export default function (pi: ExtensionAPI) {
|
|||
case "request_reviewers": {
|
||||
if (!params.reviewers) return textResult("Error: 'reviewers' is required for request_reviewers action.");
|
||||
const reviewerList = params.reviewers.split(",").map((r) => r.trim());
|
||||
const reviewersGate = await confirmAction(ctx, `Request reviewers on PR #${params.number}: ${reviewerList.join(", ")}?`);
|
||||
if (reviewersGate) return reviewersGate;
|
||||
await requestReviewers(repo, params.number, reviewerList);
|
||||
return textResult(
|
||||
`Requested reviewers on PR #${params.number}: ${reviewerList.join(", ")}`,
|
||||
|
|
@ -519,6 +563,8 @@ export default function (pi: ExtensionAPI) {
|
|||
}
|
||||
case "create_label": {
|
||||
if (!params.name) return textResult("Error: 'name' is required for create_label.");
|
||||
const labelGate = await confirmAction(ctx, `Create label "${params.name}"?`);
|
||||
if (labelGate) return labelGate;
|
||||
const label = await createLabel(repo, {
|
||||
name: params.name,
|
||||
color: params.color ?? "ededed",
|
||||
|
|
@ -532,6 +578,8 @@ export default function (pi: ExtensionAPI) {
|
|||
}
|
||||
case "create_milestone": {
|
||||
if (!params.name) return textResult("Error: 'name' is required for create_milestone.");
|
||||
const milestoneGate = await confirmAction(ctx, `Create milestone "${params.name}"?`);
|
||||
if (milestoneGate) return milestoneGate;
|
||||
const ms = await createMilestone(repo, {
|
||||
title: params.name,
|
||||
description: params.description,
|
||||
|
|
|
|||
126
src/resources/extensions/shared/confirm-ui.ts
Normal file
126
src/resources/extensions/shared/confirm-ui.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* Themed yes/no confirmation dialog.
|
||||
*
|
||||
* Uses the shared UI design system for consistent styling.
|
||||
* Returns true if confirmed, false if declined.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* import { showConfirm } from "./shared/confirm-ui.js";
|
||||
*
|
||||
* const confirmed = await showConfirm(ctx, {
|
||||
* title: "GitHub Action",
|
||||
* message: 'Close issue #42?',
|
||||
* });
|
||||
* if (!confirmed) return textResult("Cancelled.");
|
||||
*/
|
||||
|
||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { type Theme } from "@mariozechner/pi-coding-agent";
|
||||
import { Key, matchesKey, truncateToWidth, type TUI } from "@mariozechner/pi-tui";
|
||||
import { makeUI, GLYPH } from "./ui.js";
|
||||
|
||||
export interface ConfirmOptions {
|
||||
/** Title shown at the top of the dialog */
|
||||
title: string;
|
||||
/** Descriptive message — what the user is confirming */
|
||||
message: string;
|
||||
/** Label for the confirm option. Default: "Yes" */
|
||||
confirmLabel?: string;
|
||||
/** Label for the decline option. Default: "No" */
|
||||
declineLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a themed yes/no confirmation dialog.
|
||||
* Returns true if confirmed, false if declined or UI unavailable.
|
||||
*/
|
||||
export async function showConfirm(
|
||||
ctx: ExtensionContext,
|
||||
opts: ConfirmOptions,
|
||||
): Promise<boolean> {
|
||||
if (!ctx.hasUI) return false;
|
||||
|
||||
return ctx.ui.custom<boolean>((tui: TUI, theme: Theme, _kb, done) => {
|
||||
let cursor = 0; // 0 = yes (confirm), 1 = no (decline)
|
||||
let cachedLines: string[] | undefined;
|
||||
|
||||
const yesLabel = opts.confirmLabel ?? "Yes";
|
||||
const noLabel = opts.declineLabel ?? "No";
|
||||
|
||||
function refresh() {
|
||||
cachedLines = undefined;
|
||||
tui.requestRender();
|
||||
}
|
||||
|
||||
function handleInput(data: string) {
|
||||
if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
|
||||
cursor = cursor === 0 ? 1 : 0;
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
// Quick-select: 1 = yes, 2 = no
|
||||
if (data === "1") { done(true); return; }
|
||||
if (data === "2") { done(false); return; }
|
||||
|
||||
// y/n shortcuts
|
||||
if (data === "y" || data === "Y") { done(true); return; }
|
||||
if (data === "n" || data === "N") { done(false); return; }
|
||||
|
||||
if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) {
|
||||
done(cursor === 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape = decline
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
done(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function render(width: number): string[] {
|
||||
if (cachedLines) return cachedLines;
|
||||
|
||||
const ui = makeUI(theme, width);
|
||||
const lines: string[] = [];
|
||||
const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); };
|
||||
|
||||
push(
|
||||
ui.bar(),
|
||||
ui.blank(),
|
||||
ui.header(` ${opts.title}`),
|
||||
ui.blank(),
|
||||
ui.subtitle(` ${opts.message}`),
|
||||
ui.blank(),
|
||||
);
|
||||
|
||||
const add = (s: string) => truncateToWidth(s, width);
|
||||
const option = (num: number, label: string, selected: boolean) => {
|
||||
if (selected) {
|
||||
return add(` ${theme.fg("accent", GLYPH.cursor)} ${theme.fg("accent", `${num}. ${label}`)}`);
|
||||
}
|
||||
return add(` ${theme.fg("text", `${num}. ${label}`)}`);
|
||||
};
|
||||
|
||||
lines.push(option(1, yesLabel, cursor === 0));
|
||||
lines.push(option(2, noLabel, cursor === 1));
|
||||
|
||||
push(
|
||||
ui.blank(),
|
||||
ui.hints(["↑/↓ to choose", "y/n to quick-select", "enter to confirm"]),
|
||||
ui.bar(),
|
||||
);
|
||||
|
||||
cachedLines = lines;
|
||||
return lines;
|
||||
}
|
||||
|
||||
return {
|
||||
render,
|
||||
invalidate: () => { cachedLines = undefined; },
|
||||
handleInput,
|
||||
};
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue