Commit e5eebf0d9e29

Vincent Demeester <vincent@sbr.pm>
2026-02-10 16:36:07
feat: add inline line commenting to GitHub ext
Added pr-line-comment for posting inline comments on specific file/line in PR diffs, and pr-review-comments for submitting reviews with multiple inline comments at once. Both use the GitHub REST API via gh api with approval gates.
1 parent 3d954fe
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`;