Add "Fix it" link for body-only PR reviews (#338)

* Add "Fix it" link for body-only PR reviews

When a PR review has only body-level feedback (no inline comments),
the footer now includes a "Fix it" link that triggers the fix flow.

Also fetches the review body in the fix action's prompt so the agent
can address body-level feedback even when there are no inline comments.

* Move review body fetching into `get_review_comments` tool

Instead of fetching the review body in the trigger page and appending
it to the prompt, the `get_review_comments` MCP tool now fetches the
review body via the GitHub API and includes it in its markdown output
under a "Review Body" section. This keeps the trigger page simple and
lets the tool provide all review context in one place.

* fetch body early

* get reviewer from a better place

* cleanup structure to reuse more in test

* simplify

* simplify

* typecheck

* fetch review body via REST API; skip listFiles for body-only reviews

* update snapshot

* formatting

* cleanup

* fix line counting with `countNewlines` utility using `indexOf` loop

* rename `countNewlines` to `countLines` with 1-based line counting

* suppress biome lint warning for assignment in while condition

* remove unused `body` field from GraphQL review query and type

* add `approved` parameter to `create_pull_request_review` and skip fix links for approvals

* vibe instructions

* tighten up prompting

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com>
This commit is contained in:
pullfrog[bot]
2026-02-27 12:18:51 +00:00
committed by pullfrog[bot]
parent e0bd984975
commit c0fd69560f
6 changed files with 310 additions and 182 deletions
+22 -39
View File
@@ -1,60 +1,43 @@
import { Octokit } from "@octokit/rest";
import { describe, expect, it } from "vitest";
import { acquireNewToken } from "../utils/github.ts";
import {
buildThreadBlocks,
formatReviewThreads,
type ParsedHunk,
parseFilePatches,
REVIEW_THREADS_QUERY,
type ReviewThread,
type ReviewThreadsQueryResponse,
} from "./reviewComments.ts";
import { getReviewData } from "./reviewComments.ts";
async function getToken(): Promise<string> {
// prefer explicit GH_TOKEN, fall back to acquiring one via GitHub App credentials
if (process.env.GH_TOKEN) return process.env.GH_TOKEN;
return await acquireNewToken();
}
describe("formatReviewThreads", () => {
describe("getFormattedReviewThreads", () => {
it("formats thread blocks with TOC and correct line numbers", { timeout: 30000 }, async () => {
const token = await getToken();
const octokit = new Octokit({ auth: token });
const pullNumber = 49;
const reviewId = 3485940013;
// fetch review threads via GraphQL
const response = await octokit.graphql<ReviewThreadsQueryResponse>(REVIEW_THREADS_QUERY, {
const { formatted } = (await getReviewData({
octokit,
owner: "pullfrog",
name: "scratch",
prNumber: pullNumber,
});
pullNumber: 49,
reviewId: 3485940013,
}))!;
const allThreads = response.repository?.pullRequest?.reviewThreads?.nodes ?? [];
const threadsForReview = allThreads.filter((thread): thread is ReviewThread => {
if (!thread?.comments?.nodes) return false;
return thread.comments.nodes.some((c) => c?.pullRequestReview?.databaseId === reviewId);
});
expect(formatted.toc).toMatchSnapshot("toc");
expect(formatted.content).toMatchSnapshot("content");
});
// fetch file patches
const prFilesResponse = await octokit.rest.pulls.listFiles({
it("formats body-only review", { timeout: 30000 }, async () => {
const token = await getToken();
const octokit = new Octokit({ auth: token });
const { formatted } = (await getReviewData({
octokit,
owner: "pullfrog",
repo: "scratch",
pull_number: pullNumber,
});
const filePatchMap = new Map<string, ParsedHunk[]>();
for (const file of prFilesResponse.data) {
if (file.patch) {
filePatchMap.set(file.filename, parseFilePatches(file.patch));
}
}
name: "scratch",
pullNumber: 64,
reviewId: 3531000326,
}))!;
// build and format
const { threadBlocks, reviewer } = buildThreadBlocks(threadsForReview, filePatchMap, reviewId);
const result = formatReviewThreads(threadBlocks, { pullNumber, reviewId, reviewer });
expect(result.toc).toMatchSnapshot("toc");
expect(result.content).toMatchSnapshot("content");
expect(formatted.toc).toMatchSnapshot("toc");
expect(formatted.content).toMatchSnapshot("content");
});
});