Skip to content
Merged
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
68 changes: 57 additions & 11 deletions src/clawsweeper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13348,11 +13348,12 @@ function coveringPrCloseCoveragePullRequestView(
): PrCloseCoverageProofPullRequestView {
const pull = asRecord(ghJson<unknown>(["api", `repos/${targetRepo()}/pulls/${number}`]));
const issue = asRecord(ghJson<unknown>(["api", `repos/${targetRepo()}/issues/${number}`]));
const commentsWindow = ghPagedContextWindow<unknown>(
`repos/${targetRepo()}/issues/${number}/comments`,
numberOrUndefined(issue.comments),
40,
);
const commentsPath = `repos/${targetRepo()}/issues/${number}/comments`;
const commentsCount = numberOrUndefined(issue.comments);
const commentsWindow =
commentsCount === undefined
? ghPagedLinkHeaderContextWindow<unknown>(commentsPath, 40)
: ghPagedContextWindow<unknown>(commentsPath, commentsCount, 40);
const filteredComments = filterReviewContextComments(commentsWindow.items, number);
return {
number,
Expand Down Expand Up @@ -13409,13 +13410,21 @@ function prCloseCoverageProofGateResult(options: {
if (candidateRefs.length === 0) return null;

const source = sourcePrCloseCoveragePullRequestView(options.item, options.context);
const coveringViews = new Map<number, PrCloseCoverageProofPullRequestView>();
const coveringView = (number: number): PrCloseCoverageProofPullRequestView => {
const cached = coveringViews.get(number);
if (cached) return cached;
const view = coveringPrCloseCoveragePullRequestView(number);
coveringViews.set(number, view);
return view;
};
let firstKeepOpenBlock: PrCloseCoverageProofGateBlock | null = null;
let checkedPullRequestCandidate = false;
for (const candidateRef of candidateRefs) {
const linkedNumber = candidateRef.number;
let covering: PrCloseCoverageProofPullRequestView;
try {
covering = coveringPrCloseCoveragePullRequestView(linkedNumber);
covering = coveringView(linkedNumber);
} catch (error) {
if (candidateRef.kind !== "pull_url" && shorthandRefIsIssue(linkedNumber)) continue;
return {
Expand Down Expand Up @@ -15369,6 +15378,19 @@ function issueReviewComment(
return codexComments.find(canPatchReviewComment) ?? codexComments[0];
}

function issueReviewCommentWithBody(
number: number,
body: string,
): Record<string, unknown> | undefined {
const expected = body.trim();
if (!expected) return undefined;
const comments = ghPaged<unknown>(`repos/${targetRepo()}/issues/${number}/comments`).map(
asRecord,
);
const exactComments = comments.filter((candidate) => commentBody(candidate)?.trim() === expected);
return exactComments.find(canPatchReviewComment) ?? exactComments[0];
}

function commentUpdatedAt(comment: Record<string, unknown> | undefined): string | undefined {
const updatedAt = comment?.updated_at;
if (typeof updatedAt === "string") return updatedAt;
Expand Down Expand Up @@ -15518,26 +15540,50 @@ function upsertReviewComment(
const markedBody = markedReviewCommentBody(number, body);
const id = commentId(existing);
const payload = writeCommentPayload(number, markedBody);
let args: string[];
if (id !== null && canPatchReviewComment(existing)) {
ghWithRetry([
args = [
"api",
`repos/${targetRepo()}/issues/comments/${id}`,
"--method",
"PATCH",
"--input",
payload,
]);
];
} else {
ghWithRetry([
args = [
"api",
`repos/${targetRepo()}/issues/${number}/comments`,
"--method",
"POST",
"--input",
payload,
]);
];
}
const response = ghWithRetry(args);
const written = reviewCommentFromMutationResponse(response, args);
if (written) return written;
const fallback = issueReviewCommentWithBody(number, markedBody);
if (fallback) return fallback;
throw new Error(
`GitHub comment mutation for #${number} did not return or expose the synced review comment`,
);
}

function reviewCommentFromMutationResponse(
response: string,
args: readonly string[],
): Record<string, unknown> | undefined {
if (!response.trim()) return undefined;
try {
const comment = asRecord(parseGhJson<unknown>(response, args));
if (commentId(comment) !== null || commentUrl(comment)) {
return comment;
}
} catch {
return undefined;
}
return issueReviewComment(number, [markedBody]);
return undefined;
}

function issueCommentWithMarker(
Expand Down
99 changes: 96 additions & 3 deletions src/repair/apply-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1262,9 +1262,21 @@ function fetchPrCloseCoverageProofCommentWindow(
): { comments: JsonValue[]; total: number } {
const apiPath = `repos/${repo}/issues/${number}/comments`;
const total = nonNegativeIntegerFromUnknown(commentsCount);
if (total === null || total <= limit) {
const comments = ghPaged(apiPath);
return { comments, total: total ?? comments.length };
if (total === null) {
return fetchPrCloseCoverageProofCommentWindowWithoutCount(apiPath, limit);
}
if (total === 0 || limit <= 0) {
return { comments: [], total };
}
if (total <= limit) {
return {
comments: fetchPrCloseCoverageProofCommentPage(
apiPath,
Math.min(total, GITHUB_MAX_PAGE_SIZE),
1,
),
total,
};
}

if (total <= GITHUB_MAX_PAGE_SIZE) {
Expand All @@ -1281,6 +1293,46 @@ function fetchPrCloseCoverageProofCommentWindow(
return { comments: [...first, ...last], total };
}

function fetchPrCloseCoverageProofCommentWindowWithoutCount(
apiPath: string,
limit: number,
): { comments: JsonValue[]; total: number } {
const boundedLimit = Math.max(0, Math.floor(limit));
if (boundedLimit <= 0) return { comments: [], total: 0 };
const first = fetchPrCloseCoverageProofCommentPageWithHeaders(apiPath, GITHUB_MAX_PAGE_SIZE, 1);
const lastPage =
first.lastPageNumber ?? (first.comments.length < GITHUB_MAX_PAGE_SIZE ? 1 : null);
if (lastPage === null) {
const comments = ghPaged(apiPath);
return { comments, total: comments.length };
}
if (lastPage <= 1) {
return {
comments: first.comments,
total: first.comments.length,
};
}

const last = fetchPrCloseCoverageProofCommentPageWithHeaders(
apiPath,
GITHUB_MAX_PAGE_SIZE,
lastPage,
).comments;
const total = Math.max(0, (lastPage - 1) * GITHUB_MAX_PAGE_SIZE + last.length);
const keepStart = Math.floor(boundedLimit / 2);
const keepEnd = Math.max(0, boundedLimit - keepStart);
const head = first.comments.slice(0, keepStart);
let tailSource = last;
if (last.length < keepEnd && lastPage > 1) {
const previous =
lastPage - 1 === 1
? first.comments
: fetchPrCloseCoverageProofCommentPage(apiPath, GITHUB_MAX_PAGE_SIZE, lastPage - 1);
tailSource = [...previous, ...last];
}
return { comments: [...head, ...tailSource.slice(-keepEnd)], total };
}

function fetchLastPrCloseCoverageProofComments(
apiPath: string,
total: number,
Expand Down Expand Up @@ -1313,6 +1365,47 @@ function fetchPrCloseCoverageProofCommentPage(
return Array.isArray(entries) ? entries : [];
}

function fetchPrCloseCoverageProofCommentPageWithHeaders(
apiPath: string,
perPage: number,
page: number,
): { comments: JsonValue[]; lastPageNumber: number | null } {
const limitedPath = githubLimitedPagePath(apiPath, perPage, page);
const output = ghWithRetry(["api", "-i", limitedPath]);
const { body, headers } = splitGithubResponse(output);
const entries = JSON.parse(body || "[]") as JsonValue;
return {
comments: Array.isArray(entries) ? entries : [],
lastPageNumber: githubLastPageNumber(headers),
};
}

function splitGithubResponse(output: string): { headers: string; body: string } {
const normalized = output.replace(/\r\n/g, "\n");
const separator = normalized.lastIndexOf("\n\n");
if (separator < 0) return { headers: "", body: normalized };
return {
headers: normalized.slice(0, separator),
body: normalized.slice(separator + 2),
};
}

function githubLastPageNumber(headers: string): number | null {
for (const line of headers.split("\n")) {
const delimiter = line.indexOf(":");
if (delimiter <= 0) continue;
if (line.slice(0, delimiter).trim().toLowerCase() !== "link") continue;
for (const part of line.slice(delimiter + 1).split(",")) {
if (!part.includes('rel="last"')) continue;
const page = part.match(/[?&]page=(\d+)/)?.[1];
if (!page) continue;
const value = Number(page);
if (Number.isSafeInteger(value) && value > 0) return value;
}
}
return null;
}

function compactPrCloseCoverageProofCommentWindow(
comments: JsonValue[],
total: number,
Expand Down
7 changes: 2 additions & 5 deletions test/apply-pr-duplicate-proof.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,15 +336,12 @@ test("apply-decisions keeps existing duplicate PR close proposals open when cove
retryReport.some((entry) => entry.action === "closed"),
false,
);
assert.equal(
retryReport.find((entry) => entry.number === 348)?.action,
"review_comment_synced",
);
assert.equal(retryReport.find((entry) => entry.number === 348)?.action, "kept_open");
assert.equal(
retryReport.some((entry) => /proof should not rerun/.test(entry.reason)),
false,
);
assert.match(readFileSync(commentWriteLogPath, "utf8"), /issues\/comments\/9348/);
assert.equal(readFileSync(commentWriteLogPath, "utf8"), "");
assert.equal(existsSync(join(closedDir, "348.md")), false);
} finally {
rmSync(root, { recursive: true, force: true });
Expand Down
7 changes: 7 additions & 0 deletions test/clawsweeper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,13 @@ if (args[0] === "api" && /\\/issues\\/comments\\/\\d+$/.test(path)) {
calls.some((args) => args.some((arg) => arg.includes("/issues/322"))),
false,
);
const reviewCommentListFetches = calls.filter(
(args) =>
args[0] === "api" &&
(args[1] ?? "").includes("/issues/321/comments") &&
args.includes("--paginate"),
);
assert.equal(reviewCommentListFetches.length, 1);
assert.deepEqual(JSON.parse(readFileSync(reportPath, "utf8")), [
{
number: 321,
Expand Down
37 changes: 32 additions & 5 deletions test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,8 @@ export function promotionGhMock(options: {
const linkedPulls = options.linkedPulls ?? {};
const linkedIssues = options.linkedIssues ?? {};
return `
const { appendFileSync, existsSync } = require("fs");
const { appendFileSync, existsSync, readFileSync, writeFileSync } = require("fs");
const { join } = require("path");
const rawArgs = process.argv.slice(2);
const args = rawArgs[0] === "--repo" ? rawArgs.slice(2) : rawArgs;
const path = args[1] || "";
Expand All @@ -466,6 +467,31 @@ export function promotionGhMock(options: {
const commentWriteLogPath = ${JSON.stringify(options.commentWriteLogPath ?? "")};
const closeAppliedBodyLogPath = ${JSON.stringify(options.closeAppliedBodyLogPath ?? "")};
const number = ${options.number};
const commentStatePath = join(__dirname, "..", "comment-state-" + number + ".json");
const mutationComment = (id, body) => ({
id,
html_url: "https://github.com/openclaw/openclaw/pull/" + number + "#issuecomment-" + id,
created_at: "2026-05-01T01:00:00Z",
updated_at: "2026-05-01T02:00:00Z",
user: { login: "clawsweeper[bot]" },
body
});
const writeMutationComment = () => {
const input = args[args.indexOf("--input") + 1];
const body = JSON.parse(readFileSync(input, "utf8")).body;
const idMatch = path.match(/\\/issues\\/comments\\/(\\d+)$/);
const id = idMatch ? Number(idMatch[1]) : 9000 + number;
const comment = mutationComment(id, body);
writeFileSync(commentStatePath, JSON.stringify(comment), "utf8");
return comment;
};
const liveComments = () => {
if (!existsSync(commentStatePath)) return comments;
const written = JSON.parse(readFileSync(commentStatePath, "utf8"));
const existingIndex = comments.findIndex((comment) => comment && comment.id === written.id);
if (existingIndex < 0) return [...comments, written];
return comments.map((comment, index) => index === existingIndex ? { ...comment, ...written } : comment);
};
const title = ${JSON.stringify(title)};
const labels = ${JSON.stringify(options.labels ?? ["status: 📣 needs proof"])};
const itemCreatedAt = ${JSON.stringify(itemCreatedAt)};
Expand Down Expand Up @@ -503,14 +529,15 @@ export function promotionGhMock(options: {
if (commentWriteLogPath) appendFileSync(commentWriteLogPath, args.join(" ") + "\\n");
if (closeAppliedBodyLogPath) {
const input = args[args.indexOf("--input") + 1];
appendFileSync(closeAppliedBodyLogPath, JSON.parse(require("fs").readFileSync(input, "utf8")).body + "\\n---body---\\n");
appendFileSync(closeAppliedBodyLogPath, JSON.parse(readFileSync(input, "utf8")).body + "\\n---body---\\n");
}
console.log("");
console.log(JSON.stringify(writeMutationComment()));
} else if (args[0] === "api" && new RegExp("/issues/comments/\\\\d+$").test(path) && args.includes("--method")) {
if (commentWriteLogPath) appendFileSync(commentWriteLogPath, args.join(" ") + "\\n");
console.log("");
console.log(JSON.stringify(writeMutationComment()));
} else if (args[0] === "api" && new RegExp("/issues/" + number + "/comments(?:\\\\?|$)").test(path)) {
console.log(JSON.stringify(slurp ? [comments] : comments));
const currentComments = liveComments();
console.log(JSON.stringify(slurp ? [currentComments] : currentComments));
} else if (args[0] === "api" && new RegExp("/issues/" + number + "/timeline(?:\\\\?|$)").test(path)) {
console.log(JSON.stringify(slurp ? [timeline] : timeline));
} else if (args[0] === "api" && new RegExp("/issues/" + number + "$").test(path)) {
Expand Down
Loading