Skip to content
Draft
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ checkpoint, and status-only commits are intentionally omitted.

### Changed

- Large OpenClaw pull requests now reuse each paginated file-list page for bounded context and deterministic classification, accepting the complete sidecar only when filenames are unique and the PR base/head snapshot remains stable.
- Event-review artifact publication completes as a superseded no-op when the reviewed branch vanished upstream (force-push or deletion) instead of failing the run.
- Worker record requests now retry transient blank/invalid 2xx bodies from the edge within the bounded budget instead of failing hydration on the first occurrence.
- Exact reviews of items that closed after enqueue now complete as superseded no-ops, and GitHub-throttled reservations defer as held retries — neither spends the item's review-failure budget.
Expand Down
2 changes: 2 additions & 0 deletions src/clawsweeper-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { SchedulerDueCandidate } from "./scheduler-policy.js";

/** Shared ClawSweeper domain, review, scheduling, and dashboard shapes. */
export const completeActivityContextSymbol = Symbol("completeActivityContext");
export const completePullFilesSymbol = Symbol("completePullFiles");

export type ItemKind = "issue" | "pull_request";
export type ApplyKind = ItemKind | "all";
Expand Down Expand Up @@ -535,6 +536,7 @@ export interface CompleteActivityContext {

export interface ItemContext {
[completeActivityContextSymbol]?: CompleteActivityContext;
[completePullFilesSymbol]?: readonly unknown[];
issue: unknown;
comments: unknown[];
timeline: unknown[];
Expand Down
212 changes: 202 additions & 10 deletions src/clawsweeper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ import {
themedRatingName,
} from "./clawsweeper-rating.js";

import { completeActivityContextSymbol } from "./clawsweeper-types.js";
import { completeActivityContextSymbol, completePullFilesSymbol } from "./clawsweeper-types.js";
import type {
AcquiredReviewStartLease,
Action,
Expand Down Expand Up @@ -4473,6 +4473,163 @@ function compactSemanticPullFile(value: unknown): unknown {
};
}

type PullFileSnapshot = {
baseSha: string;
headSha: string;
};

const GITHUB_PULL_FILES_MAX = 3000;

function pullFileSnapshot(value: unknown): PullFileSnapshot | null {
const pull = asRecord(value);
const baseSha = stringOrUndefined(asRecord(pull.base).sha)?.toLowerCase() ?? "";
const headSha = stringOrUndefined(asRecord(pull.head).sha)?.toLowerCase() ?? "";
return /^[0-9a-f]{40}$/.test(baseSha) && /^[0-9a-f]{40}$/.test(headSha)
? { baseSha, headSha }
: null;
}

function completePullFilesForDeterministicReview(options: {
initialPullRequest: unknown;
repo: string;
total: number;
truncated: boolean;
fetchAll: () => unknown[];
fetchPullRequest: () => unknown;
}): unknown[] | null {
if (
options.repo !== "openclaw/openclaw" ||
!options.truncated ||
options.total > GITHUB_PULL_FILES_MAX
) {
return null;
}
try {
const initialSnapshot = pullFileSnapshot(options.initialPullRequest);
if (!initialSnapshot) return null;
const files = options.fetchAll();
if (files.length !== options.total) return null;
const filenames = files.map((file) => stringOrUndefined(asRecord(file).filename)?.trim() ?? "");
if (filenames.some((filename) => !filename) || new Set(filenames).size !== filenames.length) {
return null;
}
const refreshedSnapshot = pullFileSnapshot(options.fetchPullRequest());
if (
!refreshedSnapshot ||
refreshedSnapshot.baseSha !== initialSnapshot.baseSha ||
refreshedSnapshot.headSha !== initialSnapshot.headSha
) {
return null;
}
return files.map(compactSemanticPullFile);
} catch {
// Keep the bounded window usable; deterministic classifiers retain the unknown marker.
return null;
}
}

function pullFileReviewHydration(options: {
repo: string;
path: string;
total: unknown;
pullRequest: unknown;
fetchPage: (path: string, page: number) => unknown[];
fetchPullRequest: () => unknown;
}): { window: ContextHydration<unknown>; completeFiles: unknown[] | null } {
const pages = new Map<number, unknown[]>();
const readPage = (path: string, page: number): unknown[] => {
const cached = pages.get(page);
if (cached) return cached;
const files = options.fetchPage(path, page);
pages.set(page, files);
return files;
};
const window = ghPagedContextWindow<unknown>(options.path, options.total, 80, {
page: readPage,
});
const completeFiles = completePullFilesForDeterministicReview({
initialPullRequest: options.pullRequest,
repo: options.repo,
total: window.total,
truncated: window.truncated,
fetchAll: () => {
const files: unknown[] = [];
const pageCount = Math.ceil(window.total / 100);
for (let page = 1; page <= pageCount; page += 1) {
const pageFiles = readPage(options.path, page);
files.push(...pageFiles);
if (pageFiles.length < 100) break;
}
return files;
},
fetchPullRequest: options.fetchPullRequest,
});
return { window, completeFiles };
}

export function completePullFilesForDeterministicReviewForTest(options: {
initialBaseSha?: string;
initialHeadSha?: string;
refreshedBaseSha?: string;
refreshedHeadSha?: string;
repo?: string;
total: number;
truncated: boolean;
files: unknown[];
fetchError?: Error;
}): unknown[] | null {
const initialBaseSha = options.initialBaseSha ?? "a".repeat(40);
const initialHeadSha = options.initialHeadSha ?? "b".repeat(40);
return completePullFilesForDeterministicReview({
initialPullRequest: {
base: { sha: initialBaseSha },
head: { sha: initialHeadSha },
},
repo: options.repo ?? "openclaw/openclaw",
total: options.total,
truncated: options.truncated,
fetchAll: () => {
if (options.fetchError) throw options.fetchError;
return options.files;
},
fetchPullRequest: () => ({
base: { sha: options.refreshedBaseSha ?? initialBaseSha },
head: { sha: options.refreshedHeadSha ?? initialHeadSha },
}),
});
}

export function pullFileReviewHydrationForTest(options: {
total: number;
pages: readonly unknown[][];
}): {
completeFiles: unknown[] | null;
hydrated: number;
pageCalls: number[];
} {
const pageCalls: number[] = [];
const pullRequest = {
base: { sha: "a".repeat(40) },
head: { sha: "b".repeat(40) },
};
const result = pullFileReviewHydration({
repo: "openclaw/openclaw",
path: "repos/openclaw/openclaw/pulls/123/files",
total: options.total,
pullRequest,
fetchPage: (_path, page) => {
pageCalls.push(page);
return [...(options.pages[page - 1] ?? [])];
},
fetchPullRequest: () => pullRequest,
});
return {
completeFiles: result.completeFiles,
hydrated: result.window.hydrated,
pageCalls,
};
}

function normalizedPullFileStatus(value: unknown): string {
const status = typeof value === "string" ? value.trim().toLowerCase() : "";
if (status === "m" || status === "modified" || status === "changed") return "modified";
Expand Down Expand Up @@ -6563,12 +6720,19 @@ function collectItemContext(
if (item.kind === "pull_request") {
pullRequest = ghJson<unknown>(["api", `repos/${targetRepo()}/pulls/${item.number}`]);
const pullRecord = asRecord(pullRequest);
const pullFilesWindow = ghPagedContextWindow<unknown>(
`repos/${targetRepo()}/pulls/${item.number}/files`,
pullRecord.changed_files,
80,
);
const pullFilesPath = `repos/${targetRepo()}/pulls/${item.number}/files`;
const pullFileHydration = pullFileReviewHydration({
repo: targetRepo(),
path: pullFilesPath,
total: pullRecord.changed_files,
pullRequest,
fetchPage: ghPage,
fetchPullRequest: () =>
ghJson<unknown>(["api", `repos/${targetRepo()}/pulls/${item.number}`]),
});
const pullFilesWindow = pullFileHydration.window;
const pullFiles = pullFilesWindow.items;
const completePullFiles = pullFileHydration.completeFiles;
const pullCommitsWindow = ghPagedContextWindow<unknown>(
`repos/${targetRepo()}/pulls/${item.number}/commits`,
pullRecord.commits,
Expand Down Expand Up @@ -6599,6 +6763,12 @@ function collectItemContext(
fullPullReviewComments.length >= pullReviewCommentsWindow.total;
context.pullRequest = compactPullRequest(pullRequest);
context.pullFiles = compactMappedWindow(pullFiles, pullFilesWindow.total, 80, compactPullFile);
if (completePullFiles) {
Object.defineProperty(context, completePullFilesSymbol, {
value: completePullFiles,
enumerable: false,
});
}
context.semanticPullFiles =
options.reviewCacheDigest &&
options.reviewCacheGitDir &&
Expand Down Expand Up @@ -10724,13 +10894,28 @@ function pullRequestFilePathsFromReport(markdown: string): string[] {
return frontMatterStringArray(markdown, "pull_files");
}

function deterministicPullFilesFromContext(context: ItemContext): {
complete: boolean;
files: readonly unknown[];
} {
const completePullFiles = context[completePullFilesSymbol];
if (context.counts?.pullFilesTruncated && completePullFiles) {
return { complete: true, files: completePullFiles };
}
return {
complete: context.counts?.pullFilesTruncated !== true,
files: context.pullFiles ?? [],
};
}

function configSurfaceChangeFromContext(repo: string, context: ItemContext): ConfigSurfaceChange {
if (repo !== "openclaw/openclaw") {
return { change: false, keys: [] };
}

const keys = new Set<string>();
for (const entry of context.pullFiles ?? []) {
const deterministicPullFiles = deterministicPullFilesFromContext(context);
for (const entry of deterministicPullFiles.files) {
const file = asRecord(entry);
const path = typeof file.filename === "string" ? file.filename.trim() : "";
const previousPath =
Expand Down Expand Up @@ -10760,14 +10945,15 @@ function configSurfaceChangeFromContext(repo: string, context: ItemContext): Con
}
}

if (context.counts?.pullFilesTruncated) {
if (!deterministicPullFiles.complete) {
keys.add("unknown-truncated-pull-files");
}

return { change: keys.size > 0, keys: [...keys].sort() };
}

export function configSurfaceChangeFromPullFilesForTest(options: {
completePullFiles?: readonly unknown[];
repo?: string;
pullFiles?: unknown[];
pullFilesTruncated?: boolean;
Expand All @@ -10782,6 +10968,8 @@ export function configSurfaceChangeFromPullFilesForTest(options: {
counts,
};
if (options.pullFiles !== undefined) context.pullFiles = options.pullFiles;
if (options.completePullFiles !== undefined)
context[completePullFilesSymbol] = options.completePullFiles;
return configSurfaceChangeFromContext(options.repo ?? "openclaw/openclaw", context);
}

Expand All @@ -10791,7 +10979,8 @@ function dataModelChangeFromContext(repo: string, context: ItemContext): DataMod
}

const surfaces = new Set<string>();
for (const entry of context.pullFiles ?? []) {
const deterministicPullFiles = deterministicPullFilesFromContext(context);
for (const entry of deterministicPullFiles.files) {
const file = asRecord(entry);
const path = typeof file.filename === "string" ? file.filename.trim() : "";
const previousPath =
Expand Down Expand Up @@ -10823,14 +11012,15 @@ function dataModelChangeFromContext(repo: string, context: ItemContext): DataMod
}
}

if (context.counts?.pullFilesTruncated) {
if (!deterministicPullFiles.complete) {
surfaces.add("unknown-truncated-pull-files");
}

return { change: surfaces.size > 0, surfaces: [...surfaces].sort() };
}

export function dataModelChangeFromPullFilesForTest(options: {
completePullFiles?: readonly unknown[];
repo?: string;
pullFiles?: unknown[];
pullFilesTruncated?: boolean;
Expand All @@ -10845,6 +11035,8 @@ export function dataModelChangeFromPullFilesForTest(options: {
counts,
};
if (options.pullFiles !== undefined) context.pullFiles = options.pullFiles;
if (options.completePullFiles !== undefined)
context[completePullFilesSymbol] = options.completePullFiles;
return dataModelChangeFromContext(options.repo ?? "openclaw/openclaw", context);
}

Expand Down
Loading
Loading