Commit e5eebf0d9e29
Changed files (6)
dots/pi/agent/extensions/github/actions/pr.ts
@@ -3,7 +3,7 @@
*/
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
-import type { GhDetails } from "../types";
+import type { GhDetails, GhLineComment } from "../types";
import {
parsePRList,
parsePRItem,
@@ -14,6 +14,8 @@ import {
buildPRMergeConfirmation,
buildReviewConfirmation,
buildCommentConfirmation,
+ buildLineCommentConfirmation,
+ buildReviewWithCommentsConfirmation,
truncate,
getReviewDecisionText,
} from "../utils";
@@ -608,3 +610,280 @@ export async function handlePRClose(
details: { action: "pr-close", output: result.stdout.trim(), prNumber: params.number } as GhDetails,
};
}
+
+/**
+ * Helper: get the HEAD commit SHA for a PR (needed for line comments API)
+ */
+async function getPRHeadSha(
+ pi: ExtensionAPI,
+ prNumber: number,
+ signal?: AbortSignal,
+): Promise<string | null> {
+ const result = await pi.exec(
+ "gh",
+ ["pr", "view", String(prNumber), "--json", "headRefOid", "--jq", ".headRefOid"],
+ { signal, timeout: 15000 },
+ );
+ if (result.code !== 0 || !result.stdout.trim()) return null;
+ return result.stdout.trim();
+}
+
+/**
+ * Post an inline comment on a PR diff (requires approval)
+ *
+ * Uses: POST /repos/{owner}/{repo}/pulls/{pull_number}/comments
+ */
+export async function handlePRLineComment(
+ 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 for pr-line-comment action" }],
+ details: { action: "pr-line-comment", error: "missing_number" } as GhDetails,
+ isError: true,
+ };
+ }
+ if (!params.path) {
+ return {
+ content: [{ type: "text", text: "Error: 'path' parameter is required (file path in the diff)" }],
+ details: { action: "pr-line-comment", error: "missing_path" } as GhDetails,
+ isError: true,
+ };
+ }
+ if (!params.line) {
+ return {
+ content: [{ type: "text", text: "Error: 'line' parameter is required (line number in the diff)" }],
+ details: { action: "pr-line-comment", error: "missing_line" } as GhDetails,
+ isError: true,
+ };
+ }
+ if (!params.body) {
+ return {
+ content: [{ type: "text", text: "Error: 'body' parameter is required (comment text)" }],
+ details: { action: "pr-line-comment", error: "missing_body" } as GhDetails,
+ isError: true,
+ };
+ }
+
+ // 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,
+ };
+ }
+ }
+
+ onUpdate?.({ content: [{ type: "text", text: `Fetching PR #${params.number} HEAD SHA...` }] });
+
+ const commitId = await getPRHeadSha(pi, params.number, signal);
+ if (!commitId) {
+ return {
+ content: [{ type: "text", text: `Error: Could not get HEAD commit SHA for PR #${params.number}` }],
+ details: { action: "pr-line-comment", error: "no_head_sha", prNumber: params.number } as GhDetails,
+ isError: true,
+ };
+ }
+
+ // Build API payload
+ const payload: Record<string, any> = {
+ body: params.body,
+ commit_id: commitId,
+ path: params.path,
+ line: params.line,
+ side: params.side || "RIGHT",
+ };
+
+ if (params.startLine) {
+ payload.start_line = params.startLine;
+ payload.start_side = params.startSide || params.side || "RIGHT";
+ }
+
+ onUpdate?.({ content: [{ type: "text", text: `Posting inline comment on ${params.path}:${params.line}...` }] });
+
+ // Write payload to temp file (pi.exec doesn't support stdin)
+ const tmpFile = `/tmp/gh-line-comment-${Date.now()}.json`;
+ await pi.exec("sh", ["-c", `cat > ${tmpFile} << 'GHEOF'\n${JSON.stringify(payload)}\nGHEOF`], { signal });
+
+ const result = await pi.exec(
+ "gh",
+ [
+ "api",
+ "repos/{owner}/{repo}/pulls/" + params.number + "/comments",
+ "--method", "POST",
+ "--input", tmpFile,
+ ],
+ { signal, timeout: 20000 },
+ );
+
+ // Clean up
+ await pi.exec("rm", ["-f", tmpFile], { signal });
+
+ if (result.code !== 0) {
+ return {
+ content: [{ type: "text", text: getErrorMessage(result.stderr, "Post inline comment") }],
+ details: { action: "pr-line-comment", error: result.stderr, prNumber: params.number } as GhDetails,
+ isError: true,
+ };
+ }
+
+ let commentUrl = "";
+ try {
+ const data = JSON.parse(result.stdout);
+ commentUrl = data.html_url || "";
+ } catch {
+ // ok
+ }
+
+ const range = params.startLine ? `${params.path}:${params.startLine}-${params.line}` : `${params.path}:${params.line}`;
+
+ return {
+ content: [{ type: "text", text: `Posted inline comment on PR #${params.number} at ${range}${commentUrl ? "\n" + commentUrl : ""}` }],
+ details: { action: "pr-line-comment", output: range, prNumber: params.number } as GhDetails,
+ };
+}
+
+/**
+ * Submit a review with inline comments (requires approval)
+ *
+ * Uses: POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews
+ * This is for submitting a batch of inline comments as part of a review.
+ */
+export async function handlePRReviewWithComments(
+ 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", error: "missing_number" } as GhDetails,
+ isError: true,
+ };
+ }
+ if (!params.reviewAction) {
+ return {
+ content: [{ type: "text", text: "Error: 'reviewAction' parameter is required (approve, request-changes, comment)" }],
+ details: { action: "pr-review-comments", error: "missing_review_action" } as GhDetails,
+ isError: true,
+ };
+ }
+ if (!params.comments || !Array.isArray(params.comments) || params.comments.length === 0) {
+ return {
+ content: [{ type: "text", text: "Error: 'comments' array is required with at least one inline comment" }],
+ details: { action: "pr-review-comments", error: "missing_comments" } as GhDetails,
+ isError: true,
+ };
+ }
+
+ // 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,
+ };
+ }
+ }
+
+ onUpdate?.({ content: [{ type: "text", text: `Fetching PR #${params.number} HEAD SHA...` }] });
+
+ const commitId = await getPRHeadSha(pi, params.number, signal);
+ if (!commitId) {
+ return {
+ content: [{ type: "text", text: `Error: Could not get HEAD commit SHA for PR #${params.number}` }],
+ details: { action: "pr-review-comments", error: "no_head_sha", prNumber: params.number } as GhDetails,
+ isError: true,
+ };
+ }
+
+ // Map review action to API event
+ const eventMap: Record<string, string> = {
+ "approve": "APPROVE",
+ "request-changes": "REQUEST_CHANGES",
+ "comment": "COMMENT",
+ };
+ const event = eventMap[params.reviewAction] || "COMMENT";
+
+ // Build comments array for the API
+ const apiComments = (params.comments as GhLineComment[]).map((c) => {
+ const comment: Record<string, any> = {
+ path: c.path,
+ body: c.body,
+ line: c.line,
+ side: c.side || "RIGHT",
+ };
+ if (c.startLine) {
+ comment.start_line = c.startLine;
+ comment.start_side = c.startSide || c.side || "RIGHT";
+ }
+ return comment;
+ });
+
+ const payload: Record<string, any> = {
+ commit_id: commitId,
+ event,
+ comments: apiComments,
+ };
+ if (params.body) payload.body = params.body;
+
+ onUpdate?.({ content: [{ type: "text", text: `Submitting review with ${apiComments.length} comment(s)...` }] });
+
+ // Write payload to temp file (pi.exec doesn't support stdin)
+ const tmpFile = `/tmp/gh-review-${Date.now()}.json`;
+ await pi.exec("sh", ["-c", `cat > ${tmpFile} << 'GHEOF'\n${JSON.stringify(payload)}\nGHEOF`], { signal });
+
+ const result = await pi.exec(
+ "gh",
+ [
+ "api",
+ "repos/{owner}/{repo}/pulls/" + params.number + "/reviews",
+ "--method", "POST",
+ "--input", tmpFile,
+ ],
+ { signal, timeout: 30000 },
+ );
+
+ // Clean up
+ await pi.exec("rm", ["-f", tmpFile], { signal });
+
+ if (result.code !== 0) {
+ return {
+ content: [{ type: "text", text: getErrorMessage(result.stderr, "Submit review with comments") }],
+ details: { action: "pr-review-comments", error: result.stderr, prNumber: params.number } as GhDetails,
+ isError: true,
+ };
+ }
+
+ let reviewUrl = "";
+ try {
+ const data = JSON.parse(result.stdout);
+ reviewUrl = data.html_url || "";
+ } catch {
+ // ok
+ }
+
+ return {
+ content: [{ type: "text", text: `Submitted ${params.reviewAction} review on PR #${params.number} with ${apiComments.length} inline comment(s)${reviewUrl ? "\n" + reviewUrl : ""}` }],
+ details: {
+ action: "pr-review-comments",
+ output: `${params.reviewAction} with ${apiComments.length} comments`,
+ prNumber: params.number,
+ reviewAction: params.reviewAction,
+ commentCount: apiComments.length,
+ } as GhDetails,
+ };
+}
dots/pi/agent/extensions/github/github.test.ts
@@ -27,6 +27,8 @@ import {
buildReviewConfirmation,
buildIssueCreateConfirmation,
buildCommentConfirmation,
+ buildLineCommentConfirmation,
+ buildReviewWithCommentsConfirmation,
isAuthError,
isNotFoundError,
isRepoError,
@@ -534,6 +536,42 @@ describe("Confirmation Builders", () => {
const msg = buildCommentConfirmation("Issue", 42, "a".repeat(300));
expect(msg).toContain("...");
});
+
+ test("buildLineCommentConfirmation includes file and line", () => {
+ const msg = buildLineCommentConfirmation(123, "src/main.ts", 42, "This needs fixing");
+ expect(msg).toContain("#123");
+ expect(msg).toContain("src/main.ts");
+ expect(msg).toContain("line 42");
+ expect(msg).toContain("This needs fixing");
+ expect(msg).toContain("inline comment");
+ });
+
+ test("buildLineCommentConfirmation shows range for multi-line", () => {
+ const msg = buildLineCommentConfirmation(123, "src/main.ts", 50, "Bad range", 42);
+ expect(msg).toContain("lines 42-50");
+ });
+
+ test("buildLineCommentConfirmation truncates long body", () => {
+ const msg = buildLineCommentConfirmation(1, "f.ts", 1, "a".repeat(300));
+ expect(msg).toContain("...");
+ });
+
+ test("buildReviewWithCommentsConfirmation includes all fields", () => {
+ const msg = buildReviewWithCommentsConfirmation(456, "request-changes", "Please fix", 3);
+ expect(msg).toContain("#456");
+ expect(msg).toContain("request-changes");
+ expect(msg).toContain("3");
+ expect(msg).toContain("Please fix");
+ expect(msg).toContain("inline comments");
+ });
+
+ test("buildReviewWithCommentsConfirmation works without body", () => {
+ const msg = buildReviewWithCommentsConfirmation(789, "approve", undefined, 1);
+ expect(msg).toContain("#789");
+ expect(msg).toContain("approve");
+ expect(msg).toContain("1");
+ expect(msg).not.toContain("Review body:");
+ });
});
// ============================================================================
dots/pi/agent/extensions/github/index.ts
@@ -31,6 +31,8 @@ import {
handlePRComment,
handlePRReady,
handlePRClose,
+ handlePRLineComment,
+ handlePRReviewWithComments,
} from "./actions/pr";
import {
handleChecks,
@@ -137,10 +139,12 @@ 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-ready, pr-close, " +
+ "pr-review, pr-comment, pr-line-comment, pr-review-comments, 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.",
parameters: Type.Object({
@@ -154,6 +158,8 @@ export default function (pi: ExtensionAPI) {
"pr-review",
"pr-comment",
"pr-ready",
+ "pr-line-comment",
+ "pr-review-comments",
"pr-close",
"checks",
"checks-log",
@@ -194,6 +200,24 @@ export default function (pi: ExtensionAPI) {
// Review
reviewAction: Type.Optional(Type.String({ description: "Review action: approve, request-changes, comment" })),
+ // Line comments (pr-line-comment, pr-review-comments)
+ path: Type.Optional(Type.String({ description: "File path in the diff for inline comment" })),
+ line: Type.Optional(Type.Number({ description: "Line number in the diff for inline comment" })),
+ side: Type.Optional(Type.String({ description: "Diff side: RIGHT (additions, default) or LEFT (deletions)" })),
+ startLine: Type.Optional(Type.Number({ description: "Start line for multi-line comment range" })),
+ startSide: Type.Optional(Type.String({ description: "Diff side for start line" })),
+ comments: Type.Optional(Type.Array(
+ Type.Object({
+ path: Type.String({ description: "File path" }),
+ body: Type.String({ description: "Comment text" }),
+ line: Type.Number({ description: "Line number" }),
+ side: Type.Optional(Type.String({ description: "RIGHT or LEFT" })),
+ startLine: Type.Optional(Type.Number({ description: "Start line for range" })),
+ startSide: Type.Optional(Type.String({ description: "Start side for range" })),
+ }),
+ { description: "Array of inline comments for pr-review-comments action" },
+ )),
+
// Checks/Runs
runId: Type.Optional(Type.Number({ description: "Workflow run ID" })),
failedOnly: Type.Optional(Type.Boolean({ description: "Restart only failed jobs (default true)" })),
@@ -237,6 +261,10 @@ export default function (pi: ExtensionAPI) {
return await handlePRComment(pi, params, signal, onUpdate, ctx);
case "pr-ready":
return await handlePRReady(pi, params, signal, onUpdate, ctx);
+ case "pr-line-comment":
+ return await handlePRLineComment(pi, params, signal, onUpdate, ctx);
+ case "pr-review-comments":
+ return await handlePRReviewWithComments(pi, params, signal, onUpdate, ctx);
case "pr-close":
return await handlePRClose(pi, params, signal, onUpdate, ctx);
@@ -355,6 +383,22 @@ export default function (pi: ExtensionAPI) {
return new Text(theme.fg("success", "✓ Comment added to ") + theme.fg("accent", `#${details.prNumber}`), 0, 0);
case "pr-ready":
return new Text(theme.fg("success", "✓ PR ready for review: ") + theme.fg("accent", `#${details.prNumber}`), 0, 0);
+ case "pr-line-comment":
+ return new Text(
+ theme.fg("success", "✓ Inline comment on ") +
+ theme.fg("accent", `#${details.prNumber}`) +
+ theme.fg("muted", ` (${details.output || ""})`),
+ 0,
+ 0,
+ );
+ case "pr-review-comments":
+ return new Text(
+ theme.fg("success", `✓ ${details.reviewAction} `) +
+ theme.fg("accent", `#${details.prNumber}`) +
+ theme.fg("muted", ` (${details.commentCount} inline comment${details.commentCount === 1 ? "" : "s"})`),
+ 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
@@ -23,6 +23,8 @@ Manage GitHub PRs, issues, CI checks, and workflow runs directly from pi using t
- **`pr-merge`** - Merge PR (merge, squash, or rebase)
- **`pr-review`** - Submit review (approve, request-changes, comment)
- **`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-ready`** - Mark draft as ready for review
- **`pr-close`** - Close PR without merging
- **`checks-restart`** - Restart failed workflow runs
@@ -101,6 +103,21 @@ https://github.com/org/repo/issues/42
→ github pr-review, number=123, reviewAction=approve, body="LGTM" (approval dialog)
```
+### Inline / Line Comments
+
+```
+"Add an inline comment on PR 123 at src/main.ts line 42 saying the error handling is missing"
+→ github pr-line-comment, number=123, path=src/main.ts, line=42, body="..."
+
+"Submit a review with changes requested on PR 123 with comments on specific lines"
+→ github pr-review-comments, number=123, reviewAction=request-changes,
+ comments=[{path: "src/main.ts", line: 42, body: "Missing error handling"},
+ {path: "src/utils.ts", line: 10, body: "Unused import"}]
+
+"Comment on lines 15-20 of src/config.ts in PR 456"
+→ github pr-line-comment, number=456, path=src/config.ts, line=20, startLine=15, body="..."
+```
+
### CI/Checks
```
@@ -158,7 +175,7 @@ github/
│ └── repo.ts # Repo/release action handlers
├── types.ts # TypeScript type definitions
├── utils.ts # Parsing, formatting, error helpers
-├── github.test.ts # Tests (62 tests)
+├── github.test.ts # Tests (67 tests)
├── package.json # Package config
├── Makefile # Test runner
└── README.md # This file
dots/pi/agent/extensions/github/types.ts
@@ -25,11 +25,25 @@ export interface GhDetails {
reviewAction?: string;
// Merge
mergeMethod?: string;
+ // Line comment
+ commentCount?: number;
// Generic
field?: string;
newValue?: string;
}
+/**
+ * Inline review comment for pr-review with line comments
+ */
+export interface GhLineComment {
+ path: string;
+ body: string;
+ line: number;
+ side?: "LEFT" | "RIGHT";
+ startLine?: number;
+ startSide?: "LEFT" | "RIGHT";
+}
+
// ============================================================================
// Parsed Data Types (from gh CLI JSON output)
// ============================================================================
dots/pi/agent/extensions/github/utils.ts
@@ -329,6 +329,28 @@ export function buildIssueCreateConfirmation(params: any): string {
return msg;
}
+export function buildLineCommentConfirmation(number: number, path: string, line: number, body: string, startLine?: number): string {
+ const preview = body.length > 200 ? body.slice(0, 197) + "..." : body;
+ const range = startLine ? `lines ${startLine}-${line}` : `line ${line}`;
+ let msg = `PR: #${number}\n`;
+ msg += `File: ${path}:${range}\n\n`;
+ msg += `Comment:\n"${preview}"\n\n`;
+ msg += "This will post an inline comment on the PR diff.";
+ return msg;
+}
+
+export function buildReviewWithCommentsConfirmation(number: number, reviewAction: string, body: string | undefined, commentsCount: number): string {
+ let msg = `PR: #${number}\n`;
+ msg += `Action: ${reviewAction}\n`;
+ msg += `Inline comments: ${commentsCount}\n`;
+ if (body) {
+ const preview = body.length > 200 ? body.slice(0, 197) + "..." : body;
+ msg += `Review body: ${preview}\n`;
+ }
+ msg += "\nThis will submit a review with inline comments on the PR.";
+ 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`;