Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/mcp/github-inline-comment-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { appendFileSync } from "fs";
import { z } from "zod";
import { createOctokit } from "../github/api/client";
import { sanitizeContent } from "../github/utils/sanitizer";
import { removeBufferedComment } from "./inline-comment-buffer";

// Get repository and PR information from environment variables
const REPO_OWNER = process.env.REPO_OWNER;
Expand Down Expand Up @@ -180,6 +181,16 @@ server.tool(

const result = await octokit.rest.pulls.createReviewComment(params);

// The comment is now live. Drop any buffered copy of it so the
// post-session replay step cannot post it a second time (the model often
// re-issues a buffered call with confirmed=true after the buffer reply).
if (CLASSIFY_ENABLED) {
removeBufferedComment(
{ path, line, startLine, body: sanitizedBody },
BUFFER_PATH,
);
}

return {
content: [
{
Expand Down
54 changes: 54 additions & 0 deletions src/mcp/inline-comment-buffer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { existsSync, readFileSync, writeFileSync } from "fs";

export type BufferedCommentMatch = {
path: string;
line?: number;
startLine?: number;
body: string;
};

/**
* Remove any buffered inline comment that matches an already-posted comment.
*
* When a comment is posted live (confirmed=true), an earlier buffered copy of
* the same comment must be dropped so the post-session replay step does not
* post it a second time. The model frequently re-issues a buffered call with
* confirmed=true after reading the "Set confirmed=true to post immediately"
* reply; previously the original buffered entry was left behind and replayed,
* producing duplicate inline comments.
*
* Entries are matched on path, line, startLine and body. Lines that cannot be
* parsed are kept untouched.
*/
export function removeBufferedComment(
match: BufferedCommentMatch,
bufferPath: string,
): void {
if (!existsSync(bufferPath)) {
return;
}

const remaining = readFileSync(bufferPath, "utf8")
.split("\n")
.filter((line) => line.trim() !== "")
.filter((line) => {
let entry: BufferedCommentMatch;
try {
entry = JSON.parse(line);
} catch {
// Keep anything we cannot parse rather than silently dropping it.
return true;
}
const isSameComment =
entry.path === match.path &&
entry.line === match.line &&
entry.startLine === match.startLine &&
entry.body === match.body;
return !isSameComment;
});

writeFileSync(
bufferPath,
remaining.length > 0 ? remaining.join("\n") + "\n" : "",
);
}
139 changes: 139 additions & 0 deletions test/inline-comment-buffer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { removeBufferedComment } from "../src/mcp/inline-comment-buffer";

describe("removeBufferedComment", () => {
let dir: string;
let bufferPath: string;

const entryA = {
ts: "2026-06-13T00:00:00.000Z",
path: "src/index.ts",
line: 10,
startLine: undefined,
side: "RIGHT",
body: "Comment A",
};
const entryB = {
ts: "2026-06-13T00:00:01.000Z",
path: "src/other.ts",
line: 20,
startLine: undefined,
side: "RIGHT",
body: "Comment B",
};

const writeBuffer = (entries: object[]): void => {
writeFileSync(
bufferPath,
entries.map((e) => JSON.stringify(e)).join("\n") + "\n",
);
};

const readBuffer = (): Array<{ body: string }> => {
if (!existsSync(bufferPath)) {
return [];
}
return readFileSync(bufferPath, "utf8")
.split("\n")
.filter((line) => line.trim() !== "")
.map((line) => JSON.parse(line));
};

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "inline-buffer-"));
bufferPath = join(dir, "buffer.jsonl");
});

afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});

it("removes the matching buffered entry and keeps the others", () => {
writeBuffer([entryA, entryB]);

removeBufferedComment(
{
path: "src/index.ts",
line: 10,
startLine: undefined,
body: "Comment A",
},
bufferPath,
);

const remaining = readBuffer();
expect(remaining.map((e) => e.body)).toEqual(["Comment B"]);
});

it("removes every copy when the same comment was buffered more than once", () => {
writeBuffer([entryA, entryA, entryB]);

removeBufferedComment(
{
path: "src/index.ts",
line: 10,
startLine: undefined,
body: "Comment A",
},
bufferPath,
);

expect(readBuffer().map((e) => e.body)).toEqual(["Comment B"]);
});

it("leaves the buffer untouched when nothing matches", () => {
writeBuffer([entryA, entryB]);

removeBufferedComment(
{
path: "src/index.ts",
line: 999,
startLine: undefined,
body: "Comment A",
},
bufferPath,
);

expect(readBuffer().map((e) => e.body)).toEqual(["Comment A", "Comment B"]);
});

it("does nothing when the buffer file does not exist", () => {
expect(() =>
removeBufferedComment(
{ path: "src/index.ts", line: 10, body: "Comment A" },
bufferPath,
),
).not.toThrow();
expect(existsSync(bufferPath)).toBe(false);
});

it("keeps lines that cannot be parsed as JSON", () => {
writeFileSync(
bufferPath,
["not json", JSON.stringify(entryA)].join("\n") + "\n",
);

removeBufferedComment(
{
path: "src/index.ts",
line: 10,
startLine: undefined,
body: "Comment A",
},
bufferPath,
);

const raw = readFileSync(bufferPath, "utf8");
expect(raw).toContain("not json");
expect(raw).not.toContain("Comment A");
});
});