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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ checkpoint, and status-only commits are intentionally omitted.
immediate duplicate capacity probe in the dispatch loop.
- Cached comment-router open-label issue lookups per run so repair-loop comment
discovery and command synthesis do not repeat identical GitHub searches.
- Cached comment-router issue comment lookups per run so targeted command routing
and replay/status checks do not repeat identical comment pagination.
- Retried Codex edit workers after TPM/rate-limit exits and collapsed JSONL failure transcripts into concise repair status reasons.
- Added deterministic merged closing-PR provenance to issue close reports and
public close comments when GitHub exposes a high-confidence closing PR.
Expand Down
42 changes: 42 additions & 0 deletions src/repair/comment-router-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,48 @@ export function createCachedLabelNumberLookup(fetchNumbers: (label: string) => J
};
}

export function createCachedIssueCommentsLookup<T = JsonValue>(
fetchComments: (number: number) => T[],
cache = new Map<number, T[]>(),
) {
return (number: JsonValue): T[] => {
const key = Number(number);
if (!Number.isInteger(key) || key <= 0) return [];
const cached = cache.get(key);
if (cached) return [...cached];
const comments = fetchComments(key);
if (!Array.isArray(comments)) return [];
cache.set(key, comments);
return [...comments];
};
}

export function createCachedIssueCommentsLookupAsync<T = JsonValue>(
fetchComments: (number: number) => Promise<T[]>,
cache = new Map<number, T[]>(),
) {
const inFlight = new Map<number, Promise<T[]>>();
return async (number: JsonValue): Promise<T[]> => {
const key = Number(number);
if (!Number.isInteger(key) || key <= 0) return [];
const cached = cache.get(key);
if (cached) return [...cached];
const pending = inFlight.get(key);
if (pending) return [...(await pending)];
const next = fetchComments(key)
.then((comments) => {
if (!Array.isArray(comments)) return [];
cache.set(key, comments);
return comments;
})
.finally(() => {
inFlight.delete(key);
});
inFlight.set(key, next);
return [...(await next)];
};
}

function uniquePositiveIntegers(values: JsonValue): number[] {
if (!Array.isArray(values)) return [];
return [
Expand Down
29 changes: 14 additions & 15 deletions src/repair/comment-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import {
automergeTransientWaitConfig,
buildAutomergeMergeArgs,
commandHasAction,
createCachedIssueCommentsLookup,
createCachedIssueCommentsLookupAsync,
createCachedLabelNumberLookup,
hasCommandResponseMarker,
commandStatusMarker,
Expand Down Expand Up @@ -133,6 +135,14 @@ const collaboratorPermissionCache = new Map();
const activeRepairRunsByPrefix = new Map<string, LooseRecord[]>();
const liveTargetCache = new Map<number, LooseRecord>();
const issueCommentsCache = new Map<number, JsonValue[]>();
const cachedIssueComments = createCachedIssueCommentsLookup(
(number) => ghPaged<JsonValue>(`repos/${targetRepo}/issues/${number}/comments?per_page=100`),
issueCommentsCache,
);
const cachedIssueCommentsAsync = createCachedIssueCommentsLookupAsync(
(number) => ghPagedAsync<JsonValue>(`repos/${targetRepo}/issues/${number}/comments?per_page=100`),
issueCommentsCache,
);
const openIssueNumbersByLabel = createCachedLabelNumberLookup((label) =>
ghPaged<JsonValue>(
`repos/${targetRepo}/issues?state=open&labels=${encodeURIComponent(label)}&per_page=100`,
Expand Down Expand Up @@ -306,7 +316,7 @@ async function prehydrateCommandLookups(commands: LooseRecord[]) {
liveTargetCache.set(number, await fetchLiveTargetAsync(number));
}),
mapLimit(issueNumbers, lookupConcurrency, async (number) => {
issueCommentsCache.set(number, await fetchIssueCommentsAsync(number));
await cachedIssueCommentsAsync(number);
}),
]);
}
Expand Down Expand Up @@ -2317,10 +2327,7 @@ function linesFromMarkdownSection(section: JsonValue): string[] {
}

function issueCommentsFor(number: JsonValue): JsonValue[] {
return (
issueCommentsCache.get(Number(number)) ??
ghPaged<JsonValue>(`repos/${targetRepo}/issues/${number}/comments?per_page=100`)
);
return cachedIssueComments(number);
}

function listRepairLoopReviewComments() {
Expand Down Expand Up @@ -2586,9 +2593,7 @@ function hasExistingResponse(
intent: JsonValue,
headSha: JsonValue,
) {
const comments =
issueCommentsCache.get(Number(number)) ??
ghPaged(`repos/${targetRepo}/issues/${number}/comments?per_page=100`);
const comments = cachedIssueComments(number);
return comments.some((comment: JsonValue) => {
const body = String(comment.body ?? "");
if (!hasCommandResponseMarker(body, { commentId, intent, headSha, matchAnyHead: true })) {
Expand All @@ -2611,20 +2616,14 @@ function hasExistingResponse(

function hasExistingModeStatusResponse(number: JsonValue, intent: JsonValue) {
const markerPrefix = commandStatusMarkerPrefix({ issue_number: number, intent });
const comments =
issueCommentsCache.get(Number(number)) ??
ghPaged(`repos/${targetRepo}/issues/${number}/comments?per_page=100`);
const comments = cachedIssueComments(number);
return comments.some((comment: JsonValue) => {
if (!isTrustedStatusComment(comment)) return false;
const body = String(comment.body ?? "");
return body.includes(markerPrefix) && !body.includes("could not enable");
});
}

async function fetchIssueCommentsAsync(number: JsonValue) {
return ghPagedAsync<JsonValue>(`repos/${targetRepo}/issues/${number}/comments?per_page=100`);
}

function postComment(command: LooseRecord, body: string) {
const existing = findExistingCommandStatusComment(command);
const nextBody = usesSharedAutomergeStatus(command)
Expand Down
65 changes: 65 additions & 0 deletions test/repair/comment-router-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
automergeTransientWaitConfig,
buildAutomergeMergeArgs,
commandHasAction,
createCachedIssueCommentsLookup,
createCachedIssueCommentsLookupAsync,
commandResponseMarker,
commandResponseMarkerPrefix,
commandStatusMarkerPrefix,
Expand Down Expand Up @@ -205,6 +207,69 @@ test("cached label number lookup fetches each label once and returns stable copi
assert.deepEqual(calls, ["clawsweeper:autofix", "clawsweeper:automerge"]);
});

test("cached issue comments lookup fetches each issue once and returns stable copies", () => {
const calls: number[] = [];
const lookup = createCachedIssueCommentsLookup((number) => {
calls.push(number);
return [{ id: number * 10 }, { id: number * 10 + 1 }];
});

const first = lookup(12);
first.push({ id: 999 });

assert.deepEqual(first, [{ id: 120 }, { id: 121 }, { id: 999 }]);
assert.deepEqual(lookup("12"), [{ id: 120 }, { id: 121 }]);
assert.deepEqual(lookup(13), [{ id: 130 }, { id: 131 }]);
assert.deepEqual(lookup(0), []);
assert.deepEqual(calls, [12, 13]);
});

test("cached async issue comments lookup shares cache and in-flight fetches", async () => {
const cache = new Map<number, { id: number }[]>();
const calls: number[] = [];
const asyncLookup = createCachedIssueCommentsLookupAsync(async (number) => {
calls.push(number);
await new Promise((resolve) => setTimeout(resolve, 5));
return [{ id: number * 10 }];
}, cache);
const syncLookup = createCachedIssueCommentsLookup((number) => {
calls.push(number);
return [{ id: number * 100 }];
}, cache);

const [first, second] = await Promise.all([asyncLookup(12), asyncLookup("12")]);
first.push({ id: 999 });

assert.deepEqual(first, [{ id: 120 }, { id: 999 }]);
assert.deepEqual(second, [{ id: 120 }]);
assert.deepEqual(syncLookup(12), [{ id: 120 }]);
assert.deepEqual(await asyncLookup(0), []);
assert.deepEqual(calls, [12]);
});

test("cached issue comments lookup does not cache malformed fetch results", async () => {
const cache = new Map<number, { id: number }[]>();
let syncCalls = 0;
const syncLookup = createCachedIssueCommentsLookup(() => {
syncCalls += 1;
return "bad" as never;
}, cache);

assert.deepEqual(syncLookup(12), []);
assert.deepEqual(syncLookup(12), []);
assert.equal(syncCalls, 2);

let asyncCalls = 0;
const asyncLookup = createCachedIssueCommentsLookupAsync(async () => {
asyncCalls += 1;
return "bad" as never;
}, cache);

assert.deepEqual(await asyncLookup(12), []);
assert.deepEqual(await asyncLookup(12), []);
assert.equal(asyncCalls, 2);
});

test("autoclose reason parser preserves maintainer wording", () => {
assert.equal(
autocloseReasonFromCommand("autoclose We don't want this feature"),
Expand Down
Loading