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
275 changes: 253 additions & 22 deletions src/clawsweeper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,12 +306,20 @@ interface ItemContext {
pullReviewComments?: unknown[];
counts?: {
comments: number;
commentsHydrated?: number;
commentsTruncated?: boolean;
timeline: number;
closingPullRequests?: number;
relatedItems?: number;
pullFiles?: number;
pullFilesHydrated?: number;
pullFilesTruncated?: boolean;
pullCommits?: number;
pullCommitsHydrated?: number;
pullCommitsTruncated?: boolean;
pullReviewComments?: number;
pullReviewCommentsHydrated?: number;
pullReviewCommentsTruncated?: boolean;
};
}

Expand Down Expand Up @@ -1511,15 +1519,38 @@ export function compactMappedSlice<T>(
items: readonly T[],
limit: number,
mapper: (item: T) => unknown,
): unknown[] {
return compactMappedWindow(items, items.length, limit, mapper);
}

export function compactMappedWindow<T>(
items: readonly T[],
total: number,
limit: number,
mapper: (item: T) => unknown,
): unknown[] {
const boundedLimit = Math.max(0, Math.floor(limit));
if (items.length <= boundedLimit) return items.map(mapper);
const boundedTotal = Math.max(0, Math.floor(total));
if (boundedTotal <= boundedLimit && items.length <= boundedLimit) return items.map(mapper);
if (boundedLimit === 0) {
return boundedTotal > 0
? [{ omitted: boundedTotal, note: "middle entries omitted from prompt context" }]
: [];
}
const keepStart = Math.floor(boundedLimit / 2);
const keepEnd = Math.max(0, boundedLimit - keepStart);
const retained =
items.length > boundedLimit && boundedTotal === items.length
? items
: items.slice(0, boundedLimit);
const retainedStart = retained.slice(0, keepStart);
const retainedEnd =
keepEnd > 0 ? retained.slice(Math.max(keepStart, retained.length - keepEnd)) : [];
const omitted = Math.max(0, boundedTotal - retainedStart.length - retainedEnd.length);
return [
...items.slice(0, keepStart).map(mapper),
{ omitted: items.length - boundedLimit, note: "middle entries omitted from prompt context" },
...(keepEnd > 0 ? items.slice(items.length - keepEnd).map(mapper) : []),
...retainedStart.map(mapper),
...(omitted > 0 ? [{ omitted, note: "middle entries omitted from prompt context" }] : []),
...retainedEnd.map(mapper),
];
}

Expand Down Expand Up @@ -2045,12 +2076,119 @@ export function githubPaginatedPath(path: string): string {
return serialized ? `${base}?${serialized}` : base;
}

function githubPagePath(path: string, page: number, perPage = 100): string {
const [basePart, query = ""] = path.split("?", 2);
const base = basePart ?? path;
const params = new URLSearchParams(query);
params.set("per_page", String(Math.max(1, Math.floor(perPage))));
params.set("page", String(Math.max(1, Math.floor(page))));
const serialized = params.toString();
return serialized ? `${base}?${serialized}` : base;
}

function ghPaged<T>(path: string): T[] {
const pages = ghJson<unknown[]>(["api", githubPaginatedPath(path), "--paginate", "--slurp"]);
if (!Array.isArray(pages)) return [];
return pages.flatMap((page) => (Array.isArray(page) ? (page as T[]) : []));
}

export interface ContextHydration<T> {
items: T[];
total: number;
hydrated: number;
truncated: boolean;
}

function ghPage<T>(path: string, page: number): T[] {
const items = ghJson<unknown[]>(["api", githubPagePath(path, page)]);
return Array.isArray(items) ? (items as T[]) : [];
}

function githubCount(value: unknown): number | null {
const count =
typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
if (!Number.isFinite(count) || count < 0) return null;
return Math.floor(count);
}

interface GithubContextWindowPlan {
keepStart: number;
keepEnd: number;
tailFirstPageNumber: number;
lastPageNumber: number;
tailOffset: number;
}

export function githubContextWindowPlan(
total: number,
promptLimit: number,
perPage = 100,
): GithubContextWindowPlan {
const boundedTotal = Math.max(0, Math.floor(total));
const boundedLimit = Math.max(0, Math.floor(promptLimit));
const boundedPerPage = Math.max(1, Math.floor(perPage));
const keepStart = Math.floor(boundedLimit / 2);
const keepEnd = Math.max(0, boundedLimit - keepStart);
const tailStartIndex = Math.max(0, boundedTotal - keepEnd);
const tailFirstPageNumber = Math.floor(tailStartIndex / boundedPerPage) + 1;
return {
keepStart,
keepEnd,
tailFirstPageNumber,
lastPageNumber: Math.max(1, Math.ceil(boundedTotal / boundedPerPage)),
tailOffset: tailStartIndex - (tailFirstPageNumber - 1) * boundedPerPage,
};
}

export function ghPagedContextWindow<T>(
path: string,
totalCount: unknown,
promptLimit: number,
fetchers: {
page?: (path: string, page: number) => T[];
paged?: (path: string) => T[];
} = {},
): ContextHydration<T> {
const fetchPage = fetchers.page ?? ghPage<T>;
const fetchPaged = fetchers.paged ?? ghPaged<T>;
const total = githubCount(totalCount);
const boundedLimit = Math.max(0, Math.floor(promptLimit));
if (total === null) {
const items = fetchPaged(path);
return { items, total: items.length, hydrated: items.length, truncated: false };
}
if (total === 0 || boundedLimit === 0) {
return { items: [], total, hydrated: 0, truncated: total > 0 };
}
if (total <= boundedLimit) {
const items = total <= 100 ? fetchPage(path, 1) : fetchPaged(path);
return {
items,
total: Math.max(total, items.length),
hydrated: items.length,
truncated: false,
};
}

const plan = githubContextWindowPlan(total, boundedLimit);
const firstPage = plan.keepStart > 0 ? fetchPage(path, 1) : [];
const headItems = firstPage.slice(0, plan.keepStart);
const tailPages: T[] = [];
if (plan.keepEnd > 0) {
for (let page = plan.tailFirstPageNumber; page <= plan.lastPageNumber; page += 1) {
tailPages.push(...(page === 1 && plan.keepStart > 0 ? firstPage : fetchPage(path, page)));
}
}
const tailItems = tailPages.slice(plan.tailOffset, plan.tailOffset + plan.keepEnd);
const items = [...headItems, ...tailItems];
return {
items,
total,
hydrated: items.length,
truncated: total > items.length,
};
}

function ensureDir(path: string): void {
mkdirSync(path, { recursive: true });
}
Expand Down Expand Up @@ -3161,14 +3299,22 @@ function planCandidates(options: {

function collectItemContext(item: Item): ItemContext {
const issue = ghJson<unknown>(["api", `repos/${targetRepo()}/issues/${item.number}`]);
const comments = ghPaged<unknown>(`repos/${targetRepo()}/issues/${item.number}/comments`);
const issueRecord = asRecord(issue);
const commentsWindow = ghPagedContextWindow<unknown>(
`repos/${targetRepo()}/issues/${item.number}/comments`,
issueRecord.comments,
24,
);
const comments = commentsWindow.items;
const timeline = ghPaged<unknown>(`repos/${targetRepo()}/issues/${item.number}/timeline`);
const context: ItemContext = {
issue: compactIssue(issue),
comments: compactMappedSlice(comments, 24, compactComment),
comments: compactMappedWindow(comments, commentsWindow.total, 24, compactComment),
timeline: compactMappedSlice(timeline, 80, compactTimelineEvent),
counts: {
comments: comments.length,
comments: commentsWindow.total,
commentsHydrated: commentsWindow.hydrated,
commentsTruncated: commentsWindow.truncated,
timeline: timeline.length,
},
};
Expand All @@ -3180,28 +3326,64 @@ function collectItemContext(item: Item): ItemContext {
context.closingPullRequests = compactMappedSlice(closingPullRequests, 12, compactPullRequest);
context.counts = {
...context.counts,
comments: comments.length,
comments: commentsWindow.total,
commentsHydrated: commentsWindow.hydrated,
commentsTruncated: commentsWindow.truncated,
timeline: timeline.length,
closingPullRequests: closingPullRequests.length,
};
}
}
if (item.kind === "pull_request") {
pullRequest = ghJson<unknown>(["api", `repos/${targetRepo()}/pulls/${item.number}`]);
const pullFiles = ghPaged<unknown>(`repos/${targetRepo()}/pulls/${item.number}/files`);
const pullCommits = ghPaged<unknown>(`repos/${targetRepo()}/pulls/${item.number}/commits`);
pullReviewComments = ghPaged<unknown>(`repos/${targetRepo()}/pulls/${item.number}/comments`);
const pullRecord = asRecord(pullRequest);
const pullFilesWindow = ghPagedContextWindow<unknown>(
`repos/${targetRepo()}/pulls/${item.number}/files`,
pullRecord.changed_files,
80,
);
const pullFiles = pullFilesWindow.items;
const pullCommitsWindow = ghPagedContextWindow<unknown>(
`repos/${targetRepo()}/pulls/${item.number}/commits`,
pullRecord.commits,
80,
);
const pullCommits = pullCommitsWindow.items;
const pullReviewCommentsWindow = ghPagedContextWindow<unknown>(
`repos/${targetRepo()}/pulls/${item.number}/comments`,
pullRecord.review_comments,
40,
);
pullReviewComments = pullReviewCommentsWindow.items;
context.pullRequest = compactPullRequest(pullRequest);
context.pullFiles = compactMappedSlice(pullFiles, 80, compactPullFile);
context.pullCommits = compactMappedSlice(pullCommits, 80, compactPullCommit);
context.pullReviewComments = compactMappedSlice(pullReviewComments, 40, compactComment);
context.pullFiles = compactMappedWindow(pullFiles, pullFilesWindow.total, 80, compactPullFile);
context.pullCommits = compactMappedWindow(
pullCommits,
pullCommitsWindow.total,
80,
compactPullCommit,
);
context.pullReviewComments = compactMappedWindow(
pullReviewComments,
pullReviewCommentsWindow.total,
40,
compactComment,
);
context.counts = {
...context.counts,
comments: comments.length,
comments: commentsWindow.total,
commentsHydrated: commentsWindow.hydrated,
commentsTruncated: commentsWindow.truncated,
timeline: timeline.length,
pullFiles: pullFiles.length,
pullCommits: pullCommits.length,
pullReviewComments: pullReviewComments.length,
pullFiles: pullFilesWindow.total,
pullFilesHydrated: pullFilesWindow.hydrated,
pullFilesTruncated: pullFilesWindow.truncated,
pullCommits: pullCommitsWindow.total,
pullCommitsHydrated: pullCommitsWindow.hydrated,
pullCommitsTruncated: pullCommitsWindow.truncated,
pullReviewComments: pullReviewCommentsWindow.total,
pullReviewCommentsHydrated: pullReviewCommentsWindow.hydrated,
pullReviewCommentsTruncated: pullReviewCommentsWindow.truncated,
};
}
const relatedOptions: Parameters<typeof relatedItemsContext>[0] = {
Expand All @@ -3216,14 +3398,28 @@ function collectItemContext(item: Item): ItemContext {
if (relatedItems.length) {
context.relatedItems = relatedItems;
const counts: NonNullable<ItemContext["counts"]> = {
comments: context.counts?.comments ?? comments.length,
comments: context.counts?.comments ?? commentsWindow.total,
commentsHydrated: context.counts?.commentsHydrated ?? commentsWindow.hydrated,
commentsTruncated: context.counts?.commentsTruncated ?? commentsWindow.truncated,
timeline: context.counts?.timeline ?? timeline.length,
relatedItems: relatedItems.length,
};
if (context.counts?.pullFiles !== undefined) counts.pullFiles = context.counts.pullFiles;
if (context.counts?.pullFilesHydrated !== undefined)
counts.pullFilesHydrated = context.counts.pullFilesHydrated;
if (context.counts?.pullFilesTruncated !== undefined)
counts.pullFilesTruncated = context.counts.pullFilesTruncated;
if (context.counts?.pullCommits !== undefined) counts.pullCommits = context.counts.pullCommits;
if (context.counts?.pullCommitsHydrated !== undefined)
counts.pullCommitsHydrated = context.counts.pullCommitsHydrated;
if (context.counts?.pullCommitsTruncated !== undefined)
counts.pullCommitsTruncated = context.counts.pullCommitsTruncated;
if (context.counts?.pullReviewComments !== undefined)
counts.pullReviewComments = context.counts.pullReviewComments;
if (context.counts?.pullReviewCommentsHydrated !== undefined)
counts.pullReviewCommentsHydrated = context.counts.pullReviewCommentsHydrated;
if (context.counts?.pullReviewCommentsTruncated !== undefined)
counts.pullReviewCommentsTruncated = context.counts.pullReviewCommentsTruncated;
if (context.counts?.closingPullRequests !== undefined)
counts.closingPullRequests = context.counts.closingPullRequests;
context.counts = counts;
Expand Down Expand Up @@ -4888,6 +5084,20 @@ function reviewTelemetryNumber(value: number | undefined): string {
return String(Math.max(0, Math.round(value)));
}

function contextCountText(
total: number | undefined,
fallback: number,
hydrated?: number,
truncated?: boolean,
): string {
const displayTotal =
total === undefined || !Number.isFinite(total) ? Math.max(0, fallback) : Math.max(0, total);
if (hydrated === undefined || !Number.isFinite(hydrated)) return String(displayTotal);
const displayHydrated = Math.max(0, Math.round(hydrated));
if (!truncated && displayHydrated >= displayTotal) return String(displayTotal);
return `${displayTotal} (hydrated ${displayHydrated}${truncated ? ", truncated" : ""})`;
}

function runtimeReviewTextFromReport(markdown: string): string {
return runtimeReviewText({
model: frontMatterValue(markdown, "review_model") ?? "",
Expand Down Expand Up @@ -6075,11 +6285,32 @@ ${options.action.closeComment ? options.action.closeComment : "_No close comment

## GitHub Snapshot

- comments: ${options.context.counts?.comments ?? options.context.comments.length}
- comments: ${contextCountText(
options.context.counts?.comments,
options.context.comments.length,
options.context.counts?.commentsHydrated,
options.context.counts?.commentsTruncated,
)}
- timeline events: ${options.context.counts?.timeline ?? options.context.timeline.length}
- related items: ${options.context.counts?.relatedItems ?? options.context.relatedItems?.length ?? 0}
- PR files: ${options.context.counts?.pullFiles ?? options.context.pullFiles?.length ?? 0}
- PR commits: ${options.context.counts?.pullCommits ?? options.context.pullCommits?.length ?? 0}
- PR files: ${contextCountText(
options.context.counts?.pullFiles,
options.context.pullFiles?.length ?? 0,
options.context.counts?.pullFilesHydrated,
options.context.counts?.pullFilesTruncated,
)}
- PR commits: ${contextCountText(
options.context.counts?.pullCommits,
options.context.pullCommits?.length ?? 0,
options.context.counts?.pullCommitsHydrated,
options.context.counts?.pullCommitsTruncated,
)}
- PR review comments: ${contextCountText(
options.context.counts?.pullReviewComments,
options.context.pullReviewComments?.length ?? 0,
options.context.counts?.pullReviewCommentsHydrated,
options.context.counts?.pullReviewCommentsTruncated,
)}

## Review Telemetry

Expand Down
Loading
Loading