Commit a0ee5043f9a2
Changed files (6)
dots/pi/agent/extensions/github/actions/pr.ts
@@ -7,6 +7,8 @@ import type { GhDetails, GhLineComment } from "../types";
import {
parsePRList,
parsePRItem,
+ parseReviewComments,
+ parseReviewSummaries,
getErrorMessage,
extractPRNumber,
extractPRUrl,
@@ -16,8 +18,12 @@ import {
buildCommentConfirmation,
buildLineCommentConfirmation,
buildReviewWithCommentsConfirmation,
+ buildReviewEditConfirmation,
+ buildReviewCommentEditConfirmation,
+ buildReviewCommentDeleteConfirmation,
truncate,
getReviewDecisionText,
+ formatRelativeDate,
} from "../utils";
const PR_LIST_FIELDS = "number,title,state,author,headRefName,baseRefName,url,isDraft,labels,reviewDecision,additions,deletions,changedFiles,createdAt,updatedAt";
@@ -887,3 +893,346 @@ export async function handlePRReviewWithComments(
} as GhDetails,
};
}
+
+// ============================================================================
+// Review listing & editing
+// ============================================================================
+
+/**
+ * List reviews on a PR (top-level review summaries with IDs)
+ */
+export async function handlePRReviewsList(
+ pi: ExtensionAPI,
+ params: any,
+ signal: AbortSignal | undefined,
+ onUpdate: any,
+ _ctx: ExtensionContext,
+): Promise<any> {
+ if (!params.number) {
+ return {
+ content: [{ type: "text", text: "Error: 'number' parameter is required" }],
+ details: { action: "pr-reviews-list", error: "missing_number" } as GhDetails,
+ isError: true,
+ };
+ }
+
+ onUpdate?.({ content: [{ type: "text", text: `Fetching reviews for PR #${params.number}...` }] });
+
+ const result = await pi.exec(
+ "gh",
+ ["api", `repos/{owner}/{repo}/pulls/${params.number}/reviews`, "--paginate"],
+ { signal, timeout: 15000 },
+ );
+
+ if (result.code !== 0) {
+ return {
+ content: [{ type: "text", text: getErrorMessage(result.stderr, "List reviews") }],
+ details: { action: "pr-reviews-list", error: result.stderr, prNumber: params.number } as GhDetails,
+ isError: true,
+ };
+ }
+
+ const reviews = parseReviewSummaries(result.stdout);
+
+ if (reviews.length === 0) {
+ return {
+ content: [{ type: "text", text: `No reviews found on PR #${params.number}` }],
+ details: { action: "pr-reviews-list", output: "empty", prNumber: params.number } as GhDetails,
+ };
+ }
+
+ let text = `Reviews on PR #${params.number}:\n\n`;
+ for (const r of reviews) {
+ const body = r.body ? `\n ${truncate(r.body, 120)}` : "";
+ text += `• [${r.id}] ${r.state} by @${r.author} (${formatRelativeDate(r.submittedAt)})${body}\n`;
+ if (r.htmlUrl) text += ` ${r.htmlUrl}\n`;
+ }
+
+ return {
+ content: [{ type: "text", text }],
+ details: { action: "pr-reviews-list", output: `${reviews.length} reviews`, prNumber: params.number } as GhDetails,
+ };
+}
+
+/**
+ * Edit a review body (the top-level review comment, not inline comments)
+ * Uses: PUT /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}
+ */
+export async function handlePRReviewEdit(
+ pi: ExtensionAPI,
+ params: any,
+ signal: AbortSignal | undefined,
+ onUpdate: any,
+ ctx: ExtensionContext,
+): Promise<any> {
+ if (!params.number) {
+ return {
+ content: [{ type: "text", text: "Error: 'number' parameter is required" }],
+ details: { action: "pr-review-edit", error: "missing_number" } as GhDetails,
+ isError: true,
+ };
+ }
+ if (!params.reviewId) {
+ return {
+ content: [{ type: "text", text: "Error: 'reviewId' parameter is required (use pr-reviews-list to find IDs)" }],
+ details: { action: "pr-review-edit", error: "missing_review_id" } as GhDetails,
+ isError: true,
+ };
+ }
+ if (!params.body) {
+ return {
+ content: [{ type: "text", text: "Error: 'body' parameter is required (new review body text)" }],
+ details: { action: "pr-review-edit", error: "missing_body" } as GhDetails,
+ isError: true,
+ };
+ }
+
+ // 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,
+ };
+ }
+ }
+
+ onUpdate?.({ content: [{ type: "text", text: `Editing review ${params.reviewId}...` }] });
+
+ const payload = JSON.stringify({ body: params.body });
+ const tmpFile = `/tmp/gh-review-edit-${Date.now()}.json`;
+ await pi.exec("sh", ["-c", `cat > ${tmpFile} << 'GHEOF'\n${payload}\nGHEOF`], { signal });
+
+ const result = await pi.exec(
+ "gh",
+ [
+ "api",
+ `repos/{owner}/{repo}/pulls/${params.number}/reviews/${params.reviewId}`,
+ "--method", "PUT",
+ "--input", tmpFile,
+ ],
+ { signal, timeout: 20000 },
+ );
+
+ await pi.exec("rm", ["-f", tmpFile], { signal });
+
+ if (result.code !== 0) {
+ return {
+ content: [{ type: "text", text: getErrorMessage(result.stderr, "Edit review") }],
+ details: { action: "pr-review-edit", error: result.stderr, prNumber: params.number, reviewId: params.reviewId } as GhDetails,
+ isError: true,
+ };
+ }
+
+ let url = "";
+ try {
+ const data = JSON.parse(result.stdout);
+ url = data.html_url || "";
+ } catch { /* ok */ }
+
+ return {
+ content: [{ type: "text", text: `Updated review ${params.reviewId} on PR #${params.number}${url ? "\n" + url : ""}` }],
+ details: { action: "pr-review-edit", prNumber: params.number, reviewId: params.reviewId } as GhDetails,
+ };
+}
+
+// ============================================================================
+// Inline review comment listing, editing, deleting
+// ============================================================================
+
+/**
+ * List inline review comments on a PR (with IDs for editing/deleting)
+ * Uses: GET /repos/{owner}/{repo}/pulls/{pull_number}/comments
+ */
+export async function handlePRReviewCommentsList(
+ pi: ExtensionAPI,
+ params: any,
+ signal: AbortSignal | undefined,
+ onUpdate: any,
+ _ctx: ExtensionContext,
+): Promise<any> {
+ if (!params.number) {
+ return {
+ content: [{ type: "text", text: "Error: 'number' parameter is required" }],
+ details: { action: "pr-review-comments-list", error: "missing_number" } as GhDetails,
+ isError: true,
+ };
+ }
+
+ onUpdate?.({ content: [{ type: "text", text: `Fetching review comments for PR #${params.number}...` }] });
+
+ const result = await pi.exec(
+ "gh",
+ ["api", `repos/{owner}/{repo}/pulls/${params.number}/comments`, "--paginate"],
+ { signal, timeout: 15000 },
+ );
+
+ if (result.code !== 0) {
+ return {
+ content: [{ type: "text", text: getErrorMessage(result.stderr, "List review comments") }],
+ details: { action: "pr-review-comments-list", error: result.stderr, prNumber: params.number } as GhDetails,
+ isError: true,
+ };
+ }
+
+ const comments = parseReviewComments(result.stdout);
+
+ if (comments.length === 0) {
+ return {
+ content: [{ type: "text", text: `No inline review comments on PR #${params.number}` }],
+ details: { action: "pr-review-comments-list", output: "empty", prNumber: params.number } as GhDetails,
+ };
+ }
+
+ let text = `Inline review comments on PR #${params.number}:\n\n`;
+ for (const c of comments) {
+ const reply = c.inReplyToId ? ` (reply to ${c.inReplyToId})` : "";
+ text += `• [${c.id}] ${c.path}:${c.line} by @${c.author} (${formatRelativeDate(c.createdAt)})${reply}\n`;
+ text += ` ${truncate(c.body, 120)}\n`;
+ if (c.htmlUrl) text += ` ${c.htmlUrl}\n`;
+ }
+
+ return {
+ content: [{ type: "text", text }],
+ details: { action: "pr-review-comments-list", output: `${comments.length} comments`, prNumber: params.number } as GhDetails,
+ };
+}
+
+/**
+ * Edit an inline review comment by ID
+ * Uses: PATCH /repos/{owner}/{repo}/pulls/comments/{comment_id}
+ */
+export async function handlePRReviewCommentEdit(
+ pi: ExtensionAPI,
+ params: any,
+ signal: AbortSignal | undefined,
+ onUpdate: any,
+ ctx: ExtensionContext,
+): Promise<any> {
+ if (!params.commentId) {
+ return {
+ content: [{ type: "text", text: "Error: 'commentId' parameter is required (use pr-review-comments-list to find IDs)" }],
+ details: { action: "pr-review-comment-edit", error: "missing_comment_id" } as GhDetails,
+ isError: true,
+ };
+ }
+ if (!params.body) {
+ return {
+ content: [{ type: "text", text: "Error: 'body' parameter is required (new comment text)" }],
+ details: { action: "pr-review-comment-edit", error: "missing_body" } as GhDetails,
+ isError: true,
+ };
+ }
+
+ // 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,
+ };
+ }
+ }
+
+ onUpdate?.({ content: [{ type: "text", text: `Editing comment ${params.commentId}...` }] });
+
+ const payload = JSON.stringify({ body: params.body });
+ const tmpFile = `/tmp/gh-comment-edit-${Date.now()}.json`;
+ await pi.exec("sh", ["-c", `cat > ${tmpFile} << 'GHEOF'\n${payload}\nGHEOF`], { signal });
+
+ const result = await pi.exec(
+ "gh",
+ [
+ "api",
+ `repos/{owner}/{repo}/pulls/comments/${params.commentId}`,
+ "--method", "PATCH",
+ "--input", tmpFile,
+ ],
+ { signal, timeout: 20000 },
+ );
+
+ await pi.exec("rm", ["-f", tmpFile], { signal });
+
+ if (result.code !== 0) {
+ return {
+ content: [{ type: "text", text: getErrorMessage(result.stderr, "Edit review comment") }],
+ details: { action: "pr-review-comment-edit", error: result.stderr, commentId: params.commentId } as GhDetails,
+ isError: true,
+ };
+ }
+
+ let url = "";
+ try {
+ const data = JSON.parse(result.stdout);
+ url = data.html_url || "";
+ } catch { /* ok */ }
+
+ return {
+ content: [{ type: "text", text: `Updated review comment ${params.commentId}${url ? "\n" + url : ""}` }],
+ details: { action: "pr-review-comment-edit", commentId: params.commentId } as GhDetails,
+ };
+}
+
+/**
+ * Delete an inline review comment by ID
+ * Uses: DELETE /repos/{owner}/{repo}/pulls/comments/{comment_id}
+ */
+export async function handlePRReviewCommentDelete(
+ pi: ExtensionAPI,
+ params: any,
+ signal: AbortSignal | undefined,
+ onUpdate: any,
+ ctx: ExtensionContext,
+): Promise<any> {
+ if (!params.commentId) {
+ return {
+ content: [{ type: "text", text: "Error: 'commentId' parameter is required (use pr-review-comments-list to find IDs)" }],
+ details: { action: "pr-review-comment-delete", error: "missing_comment_id" } as GhDetails,
+ isError: true,
+ };
+ }
+
+ // 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,
+ };
+ }
+ }
+
+ onUpdate?.({ content: [{ type: "text", text: `Deleting comment ${params.commentId}...` }] });
+
+ const result = await pi.exec(
+ "gh",
+ [
+ "api",
+ `repos/{owner}/{repo}/pulls/comments/${params.commentId}`,
+ "--method", "DELETE",
+ ],
+ { signal, timeout: 20000 },
+ );
+
+ if (result.code !== 0) {
+ return {
+ content: [{ type: "text", text: getErrorMessage(result.stderr, "Delete review comment") }],
+ details: { action: "pr-review-comment-delete", error: result.stderr, commentId: params.commentId } as GhDetails,
+ isError: true,
+ };
+ }
+
+ return {
+ content: [{ type: "text", text: `Deleted review comment ${params.commentId}` }],
+ details: { action: "pr-review-comment-delete", commentId: params.commentId } as GhDetails,
+ };
+}
dots/pi/agent/extensions/github/github.test.ts
@@ -13,6 +13,8 @@ import {
parseChecks,
parseRunList,
parseReviews,
+ parseReviewComments,
+ parseReviewSummaries,
parseReleaseList,
parseRepo,
truncate,
@@ -29,6 +31,9 @@ import {
buildCommentConfirmation,
buildLineCommentConfirmation,
buildReviewWithCommentsConfirmation,
+ buildReviewEditConfirmation,
+ buildReviewCommentEditConfirmation,
+ buildReviewCommentDeleteConfirmation,
isAuthError,
isNotFoundError,
isRepoError,
@@ -311,6 +316,91 @@ describe("Review Parsing", () => {
});
});
+describe("Review Summary Parsing", () => {
+ test("parseReviewSummaries parses API response", () => {
+ const json = JSON.stringify([
+ {
+ id: 123456,
+ user: { login: "alice" },
+ state: "APPROVED",
+ body: "LGTM",
+ submitted_at: "2025-01-01T00:00:00Z",
+ html_url: "https://github.com/owner/repo/pull/1#pullrequestreview-123456",
+ commit_id: "abc123",
+ },
+ {
+ id: 789012,
+ user: { login: "bob" },
+ state: "CHANGES_REQUESTED",
+ body: "Fix this",
+ submitted_at: "2025-01-02T00:00:00Z",
+ html_url: "https://github.com/owner/repo/pull/1#pullrequestreview-789012",
+ commit_id: "def456",
+ },
+ ]);
+
+ const summaries = parseReviewSummaries(json);
+ expect(summaries.length).toBe(2);
+ expect(summaries[0].id).toBe(123456);
+ expect(summaries[0].author).toBe("alice");
+ expect(summaries[0].state).toBe("APPROVED");
+ expect(summaries[0].htmlUrl).toContain("pullrequestreview");
+ expect(summaries[1].id).toBe(789012);
+ expect(summaries[1].state).toBe("CHANGES_REQUESTED");
+ });
+
+ test("parseReviewSummaries handles empty", () => {
+ expect(parseReviewSummaries("[]")).toEqual([]);
+ expect(parseReviewSummaries("{}")).toEqual([]);
+ });
+});
+
+describe("Review Comment Parsing (with IDs)", () => {
+ test("parseReviewComments parses API response with IDs", () => {
+ const json = JSON.stringify([
+ {
+ id: 2788725648,
+ user: { login: "vdemeester" },
+ body: "The function only checks...",
+ path: "pkg/pod/status.go",
+ line: 779,
+ created_at: "2026-02-10T15:41:54Z",
+ updated_at: "2026-02-10T15:41:54Z",
+ html_url: "https://github.com/tektoncd/pipeline/pull/9368#discussion_r2788725648",
+ },
+ ]);
+
+ const comments = parseReviewComments(json);
+ expect(comments.length).toBe(1);
+ expect(comments[0].id).toBe(2788725648);
+ expect(comments[0].author).toBe("vdemeester");
+ expect(comments[0].path).toBe("pkg/pod/status.go");
+ expect(comments[0].line).toBe(779);
+ expect(comments[0].htmlUrl).toContain("discussion_r");
+ });
+
+ test("parseReviewComments handles in_reply_to_id", () => {
+ const json = JSON.stringify([
+ {
+ id: 100,
+ user: { login: "alice" },
+ body: "reply",
+ path: "main.go",
+ line: 10,
+ created_at: "2025-01-01T00:00:00Z",
+ in_reply_to_id: 99,
+ },
+ ]);
+
+ const comments = parseReviewComments(json);
+ expect(comments[0].inReplyToId).toBe(99);
+ });
+
+ test("parseReviewComments handles empty", () => {
+ expect(parseReviewComments("[]")).toEqual([]);
+ });
+});
+
describe("Release Parsing", () => {
test("parseReleaseList parses JSON", () => {
const json = JSON.stringify([
@@ -572,6 +662,27 @@ describe("Confirmation Builders", () => {
expect(msg).toContain("1");
expect(msg).not.toContain("Review body:");
});
+
+ test("buildReviewEditConfirmation includes all fields", () => {
+ const msg = buildReviewEditConfirmation(42, 123456, "Updated review body");
+ expect(msg).toContain("#42");
+ expect(msg).toContain("123456");
+ expect(msg).toContain("Updated review body");
+ expect(msg).toContain("update the review body");
+ });
+
+ test("buildReviewCommentEditConfirmation includes fields", () => {
+ const msg = buildReviewCommentEditConfirmation(999, "New comment text");
+ expect(msg).toContain("999");
+ expect(msg).toContain("New comment text");
+ expect(msg).toContain("update the inline review comment");
+ });
+
+ test("buildReviewCommentDeleteConfirmation includes warning", () => {
+ const msg = buildReviewCommentDeleteConfirmation(888);
+ expect(msg).toContain("888");
+ expect(msg).toContain("permanently delete");
+ });
});
// ============================================================================
dots/pi/agent/extensions/github/index.ts
@@ -33,6 +33,11 @@ import {
handlePRClose,
handlePRLineComment,
handlePRReviewWithComments,
+ handlePRReviewsList,
+ handlePRReviewEdit,
+ handlePRReviewCommentsList,
+ handlePRReviewCommentEdit,
+ handlePRReviewCommentDelete,
} from "./actions/pr";
import {
handleChecks,
@@ -139,13 +144,17 @@ export default function (pi: ExtensionAPI) {
description:
"Manage GitHub PRs, issues, checks, and runs via gh CLI. " +
"Actions: pr-list, pr-view, pr-diff, pr-create, pr-checkout, pr-merge, " +
- "pr-review, pr-comment, pr-line-comment, pr-review-comments, pr-ready, pr-close, " +
+ "pr-review, pr-comment, pr-line-comment, pr-review-comments, " +
+ "pr-reviews-list, pr-review-edit, pr-review-comments-list, pr-review-comment-edit, pr-review-comment-delete, " +
+ "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, " +
"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. " +
- "Write operations (create, merge, review, comment, close, restart, edit) require user approval.",
+ "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. " +
+ "Write operations (create, merge, review, comment, close, restart, edit, delete) require user approval.",
parameters: Type.Object({
action: StringEnum([
@@ -160,6 +169,11 @@ export default function (pi: ExtensionAPI) {
"pr-ready",
"pr-line-comment",
"pr-review-comments",
+ "pr-reviews-list",
+ "pr-review-edit",
+ "pr-review-comments-list",
+ "pr-review-comment-edit",
+ "pr-review-comment-delete",
"pr-close",
"checks",
"checks-log",
@@ -199,6 +213,8 @@ export default function (pi: ExtensionAPI) {
// Review
reviewAction: Type.Optional(Type.String({ description: "Review action: approve, request-changes, comment" })),
+ reviewId: Type.Optional(Type.Number({ description: "Review ID (from pr-reviews-list, for pr-review-edit)" })),
+ commentId: Type.Optional(Type.Number({ description: "Review comment ID (from pr-review-comments-list, for edit/delete)" })),
// Line comments (pr-line-comment, pr-review-comments)
path: Type.Optional(Type.String({ description: "File path in the diff for inline comment" })),
@@ -265,6 +281,16 @@ export default function (pi: ExtensionAPI) {
return await handlePRLineComment(pi, params, signal, onUpdate, ctx);
case "pr-review-comments":
return await handlePRReviewWithComments(pi, params, signal, onUpdate, ctx);
+ case "pr-reviews-list":
+ return await handlePRReviewsList(pi, params, signal, onUpdate, ctx);
+ case "pr-review-edit":
+ return await handlePRReviewEdit(pi, params, signal, onUpdate, ctx);
+ case "pr-review-comments-list":
+ return await handlePRReviewCommentsList(pi, params, signal, onUpdate, ctx);
+ case "pr-review-comment-edit":
+ return await handlePRReviewCommentEdit(pi, params, signal, onUpdate, ctx);
+ case "pr-review-comment-delete":
+ return await handlePRReviewCommentDelete(pi, params, signal, onUpdate, ctx);
case "pr-close":
return await handlePRClose(pi, params, signal, onUpdate, ctx);
@@ -399,6 +425,44 @@ export default function (pi: ExtensionAPI) {
0,
0,
);
+ case "pr-reviews-list":
+ return new Text(
+ theme.fg("success", "Reviews ") +
+ theme.fg("accent", `#${details.prNumber}`) +
+ theme.fg("muted", ` (${details.output})`),
+ 0,
+ 0,
+ );
+ case "pr-review-edit":
+ return new Text(
+ theme.fg("success", "✓ Edited review ") +
+ theme.fg("accent", `${details.reviewId}`) +
+ theme.fg("muted", ` on #${details.prNumber}`),
+ 0,
+ 0,
+ );
+ case "pr-review-comments-list":
+ return new Text(
+ theme.fg("success", "Review comments ") +
+ theme.fg("accent", `#${details.prNumber}`) +
+ theme.fg("muted", ` (${details.output})`),
+ 0,
+ 0,
+ );
+ case "pr-review-comment-edit":
+ return new Text(
+ theme.fg("success", "✓ Edited comment ") +
+ theme.fg("accent", `${details.commentId}`),
+ 0,
+ 0,
+ );
+ case "pr-review-comment-delete":
+ return new Text(
+ theme.fg("error", "✗ Deleted comment ") +
+ theme.fg("accent", `${details.commentId}`),
+ 0,
+ 0,
+ );
case "pr-close":
return new Text(theme.fg("success", "✓ Closed ") + theme.fg("accent", `#${details.prNumber}`), 0, 0);
dots/pi/agent/extensions/github/README.md
@@ -25,6 +25,11 @@ Manage GitHub PRs, issues, CI checks, and workflow runs directly from pi using t
- **`pr-comment`** - Comment on PR
- **`pr-line-comment`** - Post an inline comment on a specific file/line in the diff
- **`pr-review-comments`** - Submit a review (approve/request-changes/comment) with multiple inline comments
+- **`pr-reviews-list`** - List reviews on a PR (with IDs for editing)
+- **`pr-review-edit`** - Edit a review body by review ID
+- **`pr-review-comments-list`** - List inline review comments (with IDs)
+- **`pr-review-comment-edit`** - Edit an inline review comment by ID
+- **`pr-review-comment-delete`** - Delete an inline review comment by ID
- **`pr-ready`** - Mark draft as ready for review
- **`pr-close`** - Close PR without merging
- **`checks-restart`** - Restart failed workflow runs
@@ -118,6 +123,27 @@ https://github.com/org/repo/issues/42
→ github pr-line-comment, number=456, path=src/config.ts, line=20, startLine=15, body="..."
```
+### Managing Reviews & Review Comments
+
+```
+"List all reviews on PR 9368"
+→ github pr-reviews-list, number=9368
+ Shows review IDs, state, author, body preview
+
+"Edit my CHANGES_REQUESTED review on PR 9368 to update the body"
+→ github pr-review-edit, number=9368, reviewId=3779821940, body="Updated text..."
+
+"List all inline review comments on PR 9368"
+→ github pr-review-comments-list, number=9368
+ Shows comment IDs, file:line, author, body preview
+
+"Edit inline review comment 2788725648"
+→ github pr-review-comment-edit, commentId=2788725648, body="Updated comment..."
+
+"Delete inline review comment 2788725648"
+→ github pr-review-comment-delete, commentId=2788725648
+```
+
### CI/Checks
```
@@ -175,7 +201,7 @@ github/
│ └── repo.ts # Repo/release action handlers
├── types.ts # TypeScript type definitions
├── utils.ts # Parsing, formatting, error helpers
-├── github.test.ts # Tests (67 tests)
+├── github.test.ts # Tests (75 tests)
├── package.json # Package config
├── Makefile # Test runner
└── README.md # This file
dots/pi/agent/extensions/github/types.ts
@@ -25,8 +25,10 @@ export interface GhDetails {
reviewAction?: string;
// Merge
mergeMethod?: string;
- // Line comment
+ // Line comment / review
commentCount?: number;
+ reviewId?: number;
+ commentId?: number;
// Generic
field?: string;
newValue?: string;
@@ -110,11 +112,25 @@ export interface GhReview {
}
export interface GhReviewComment {
+ id: number;
author: string;
body: string;
path: string;
line: number;
createdAt: string;
+ updatedAt: string;
+ inReplyToId?: number;
+ htmlUrl: string;
+}
+
+export interface GhReviewSummary {
+ id: number;
+ author: string;
+ state: string;
+ body: string;
+ submittedAt: string;
+ htmlUrl: string;
+ commitId: string;
}
export interface GhRelease {
dots/pi/agent/extensions/github/utils.ts
@@ -3,7 +3,7 @@
*/
import type { Theme } from "@mariozechner/pi-coding-agent";
-import type { GhPR, GhIssue, GhCheck, GhRun, GhReview, GhReviewComment, GhRelease, GhRepo } from "./types";
+import type { GhPR, GhIssue, GhCheck, GhRun, GhReview, GhReviewComment, GhReviewSummary, GhRelease, GhRepo } from "./types";
// ============================================================================
// Parsing: gh CLI JSON output → typed objects
@@ -125,16 +125,37 @@ export function parseReviews(json: string): GhReview[] {
export function parseReviewComments(json: string): GhReviewComment[] {
try {
const data = JSON.parse(json);
- // gh pr view --json reviewComments isn't directly available,
- // but gh api returns array of comments
- const comments = data.comments ?? data;
+ const comments = Array.isArray(data) ? data : (data.comments ?? []);
if (!Array.isArray(comments)) return [];
return comments.map((item: any) => ({
+ id: item.id ?? 0,
author: item.author?.login ?? item.user?.login ?? "",
body: item.body ?? "",
path: item.path ?? "",
line: item.line ?? item.original_line ?? 0,
createdAt: item.createdAt ?? item.created_at ?? "",
+ updatedAt: item.updatedAt ?? item.updated_at ?? "",
+ inReplyToId: item.in_reply_to_id ?? undefined,
+ htmlUrl: item.html_url ?? "",
+ }));
+ } catch {
+ return [];
+ }
+}
+
+export function parseReviewSummaries(json: string): GhReviewSummary[] {
+ try {
+ const data = JSON.parse(json);
+ const reviews = Array.isArray(data) ? data : (data.reviews ?? []);
+ if (!Array.isArray(reviews)) return [];
+ return reviews.map((item: any) => ({
+ id: item.id ?? 0,
+ author: item.author?.login ?? item.user?.login ?? "",
+ state: item.state ?? "",
+ body: item.body ?? "",
+ submittedAt: item.submittedAt ?? item.submitted_at ?? "",
+ htmlUrl: item.html_url ?? "",
+ commitId: item.commit_id ?? "",
}));
} catch {
return [];
@@ -351,6 +372,29 @@ export function buildReviewWithCommentsConfirmation(number: number, reviewAction
return msg;
}
+export function buildReviewEditConfirmation(number: number, reviewId: number, body: string): string {
+ const preview = body.length > 200 ? body.slice(0, 197) + "..." : body;
+ let msg = `PR: #${number}\n`;
+ msg += `Review ID: ${reviewId}\n\n`;
+ msg += `New body:\n"${preview}"\n\n`;
+ msg += "This will update the review body on the PR.";
+ return msg;
+}
+
+export function buildReviewCommentEditConfirmation(commentId: number, body: string): string {
+ const preview = body.length > 200 ? body.slice(0, 197) + "..." : body;
+ let msg = `Comment ID: ${commentId}\n\n`;
+ msg += `New body:\n"${preview}"\n\n`;
+ msg += "This will update the inline review comment.";
+ return msg;
+}
+
+export function buildReviewCommentDeleteConfirmation(commentId: number): string {
+ let msg = `Comment ID: ${commentId}\n\n`;
+ msg += "This will permanently delete the inline review comment.";
+ 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`;