mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-03 09:48:31 +08:00
When classify_inline_comments is enabled, create_inline_comment buffers calls without confirmed=true. The model frequently re-issues the call with confirmed=true after reading the buffered reply, which posts the comment live but leaves the original buffered entry behind. The post-session replay step then posts it again, so every inline comment lands twice. Reconcile the buffer on a live post: after a confirmed comment is created, remove any buffered entry matching the same path, line, startLine and body so it cannot be replayed. Extracts the reconciliation into src/mcp/inline-comment-buffer.ts (the MCP server module starts a server on import) and adds unit tests. Co-authored-by: archievi <13202986+archievi@users.noreply.github.com>
This commit is contained in:
parent
558b1d6cab
commit
a221ad2dd9
@ -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;
|
||||
@ -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: [
|
||||
{
|
||||
|
||||
54
src/mcp/inline-comment-buffer.ts
Normal file
54
src/mcp/inline-comment-buffer.ts
Normal file
@ -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
test/inline-comment-buffer.test.ts
Normal file
139
test/inline-comment-buffer.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user