@@ -177,7 +196,7 @@ export function AuditFeed({ enabled = true }: AuditFeedProps) {
sinceInput={sinceInput}
onReasonChange={(value) => {
setReason(value);
- setLimit(DEFAULT_LIMIT);
+ resetPaging();
}}
onRepoDraftChange={setRepoDraft}
onSinceInputChange={setSinceInput}
@@ -249,15 +268,14 @@ export function AuditFeed({ enabled = true }: AuditFeedProps) {
- {data.hasMore && limit < MAX_LIMIT ? (
-
Load more
- ) : null}
- {data.hasMore && limit >= MAX_LIMIT ? (
+ {canLoadMore ?
Load more : null}
+ {data.hasMore && data.items.length >= AUDIT_FEED_MAX_ITEMS ? (
- Showing the maximum page size ({MAX_LIMIT}). Narrow filters to inspect older events.
+ Showing the maximum accumulated page size ({AUDIT_FEED_MAX_ITEMS}). Narrow filters to
+ inspect older events.
) : null}
-
void load()}>Refresh
+
Refresh
);
diff --git a/src/api/routes.ts b/src/api/routes.ts
index 0eda0c41a8..1fac60d0e7 100644
--- a/src/api/routes.ts
+++ b/src/api/routes.ts
@@ -672,6 +672,7 @@ const selfhostDeadLetterQueueQuerySchema = z
const skippedPrAuditQuerySchema = z
.object({
limit: z.coerce.number().int().optional(),
+ offset: z.coerce.number().int().optional(),
repoFullName: z.string().trim().min(3).max(200).optional(),
reason: z.enum(PUBLIC_SURFACE_SKIP_REASONS).optional(),
since: z.string().trim().min(1).max(64).optional(),
@@ -1725,6 +1726,7 @@ export function createApp() {
if (repoFullNames instanceof Response) return repoFullNames;
const page = await listPrVisibilitySkipAuditEvents(c.env, {
limit: clampInteger(parsed.data.limit ?? 50, 1, 100),
+ offset: Math.max(0, parsed.data.offset ?? 0),
repoFullNames,
reason: parsed.data.reason,
sinceIso,
@@ -1732,6 +1734,8 @@ export function createApp() {
return c.json({
generatedAt: nowIso(),
limit: page.limit,
+ offset: page.offset,
+ total: page.total,
hasMore: page.hasMore,
filters: {
repoFullName: requestedRepo ?? null,
diff --git a/src/db/repositories.ts b/src/db/repositories.ts
index 0aa1f4fb4f..c8a0512498 100644
--- a/src/db/repositories.ts
+++ b/src/db/repositories.ts
@@ -3191,7 +3191,9 @@ export type PrVisibilitySkipAuditEvent = {
export type PrVisibilitySkipAuditPage = {
limit: number;
+ offset: number;
hasMore: boolean;
+ total: number;
items: PrVisibilitySkipAuditEvent[];
};
@@ -3199,14 +3201,20 @@ export async function listPrVisibilitySkipAuditEvents(
env: Env,
options: {
limit?: number | undefined;
+ offset?: number | undefined;
repoFullNames?: string[] | undefined;
reason?: string | undefined;
sinceIso?: string | undefined;
} = {},
): Promise
{
const limit = clampInteger(options.limit ?? 50, 1, 100);
+ // #7438: real offset pagination (not a growing limit). Floor at 0 so a negative query param cannot
+ // rewind into undefined territory; the UI advances by page size and appends.
+ const offset = Math.max(0, Math.floor(options.offset ?? 0));
const scopedRepoNames = options.repoFullNames === undefined ? undefined : uniqueRepoNames(options.repoFullNames.map((name) => name.trim()).filter(Boolean));
- if (scopedRepoNames !== undefined && scopedRepoNames.length === 0) return { limit, hasMore: false, items: [] };
+ if (scopedRepoNames !== undefined && scopedRepoNames.length === 0) {
+ return { limit, offset, hasMore: false, total: 0, items: [] };
+ }
const conditions: SQL[] = [eq(auditEvents.eventType, "github_app.pr_visibility_skipped")];
if (options.reason) conditions.push(eq(auditEvents.detail, options.reason));
@@ -3221,7 +3229,11 @@ export async function listPrVisibilitySkipAuditEvents(
if (repoFilter) conditions.push(repoFilter);
}
- const rowLimit = Math.min(500, limit * 5 + 20);
+ const whereClause = and(...conditions);
+
+ // Over-fetch past offset+limit so unparsable targetKey rows (dropped below) don't shrink the page, and so
+ // we can tell whether another page exists after slicing.
+ const rowLimit = Math.min(500, (offset + limit) * 5 + 20);
const rows = await getDb(env.DB)
.select({
targetKey: auditEvents.targetKey,
@@ -3230,7 +3242,7 @@ export async function listPrVisibilitySkipAuditEvents(
createdAt: auditEvents.createdAt,
})
.from(auditEvents)
- .where(and(...conditions))
+ .where(whereClause)
.orderBy(desc(auditEvents.createdAt), desc(auditEvents.id))
.limit(rowLimit);
const items = rows.flatMap((row) => {
@@ -3246,7 +3258,13 @@ export async function listPrVisibilitySkipAuditEvents(
},
];
});
- return { limit, hasMore: items.length > limit, items: items.slice(0, limit) };
+ const pageItems = items.slice(offset, offset + limit);
+ // hasMore is based on the parseable over-fetch window (not a raw audit-event COUNT, which can include
+ // unparsable targetKey rows and would leave Load more stuck on forever).
+ const hasMore = items.length > offset + limit;
+ // Lower-bound total when another page exists; exact when this is the last page (#7438 / DLQ-shaped response).
+ const total = hasMore ? offset + pageItems.length + 1 : offset + pageItems.length;
+ return { limit, offset, hasMore, total, items: pageItems };
}
/** Repo-scoped rollups of gate-outcome audit rows for the maintainer dashboard (#2203). Counts only
diff --git a/src/mcp/server.ts b/src/mcp/server.ts
index e5b1237821..5c0586333e 100644
--- a/src/mcp/server.ts
+++ b/src/mcp/server.ts
@@ -946,11 +946,14 @@ const skippedPrAuditShape = {
reason: z.enum(PUBLIC_SURFACE_SKIP_REASONS).optional(),
since: z.string().trim().min(1).max(64).optional(),
limit: z.number().int().positive().optional(),
+ offset: z.number().int().min(0).optional(),
};
const skippedPrAuditOutputSchema = {
generatedAt: z.string().optional(),
limit: z.number().optional(),
+ offset: z.number().optional(),
+ total: z.number().optional(),
hasMore: z.boolean().optional(),
filters: z.unknown().optional(),
items: z.array(z.unknown()).optional(),
@@ -3449,6 +3452,7 @@ export class LoopoverMcp {
reason?: PublicSurfaceSkipReason | undefined;
since?: string | undefined;
limit?: number | undefined;
+ offset?: number | undefined;
}): Promise {
const repoFullNames = await this.requireSkippedPrAuditAccess(input.repoFullName);
let sinceIso: string | undefined;
@@ -3457,12 +3461,20 @@ export class LoopoverMcp {
if (!Number.isFinite(timestamp)) throw new Error(`Invalid since: "${input.since}" is not a parseable date.`);
sinceIso = new Date(timestamp).toISOString();
}
- const page = await listPrVisibilitySkipAuditEvents(this.env, { limit: input.limit, repoFullNames, reason: input.reason, sinceIso });
+ const page = await listPrVisibilitySkipAuditEvents(this.env, {
+ limit: input.limit,
+ offset: input.offset,
+ repoFullNames,
+ reason: input.reason,
+ sinceIso,
+ });
return {
- summary: `LoopOver skipped-PR audit: ${page.items.length} event(s) (limit ${page.limit}${page.hasMore ? ", more available" : ""}).`,
+ summary: `LoopOver skipped-PR audit: ${page.items.length} event(s) (limit ${page.limit}, offset ${page.offset}${page.hasMore ? ", more available" : ""}).`,
data: {
generatedAt: nowIso(),
limit: page.limit,
+ offset: page.offset,
+ total: page.total,
hasMore: page.hasMore,
filters: {
repoFullName: input.repoFullName ?? null,
diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts
index daf774cd7d..fc83eb3cd0 100644
--- a/src/openapi/schemas.ts
+++ b/src/openapi/schemas.ts
@@ -1132,6 +1132,8 @@ export const SkippedPrAuditExportSchema = z
.object({
generatedAt: z.string(),
limit: z.number().int().min(1).max(100),
+ offset: z.number().int().min(0),
+ total: z.number().int().min(0),
hasMore: z.boolean(),
filters: z.object({
repoFullName: z.string().nullable(),
diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts
index 574e765773..4ff7e7c353 100644
--- a/src/openapi/spec.ts
+++ b/src/openapi/spec.ts
@@ -1199,9 +1199,13 @@ export function buildOpenApiSpec() {
request: {
query: z.object({
limit: z.string().optional().openapi({
- param: { description: "Maximum rows to return, clamped from 1 to 100." },
+ param: { description: "Maximum rows to return per page, clamped from 1 to 100." },
example: "50",
}),
+ offset: z.string().optional().openapi({
+ param: { description: "Zero-based row offset for pagination. Advance by `limit` (or the previous page's item count) and append; do not grow `limit` to page." },
+ example: "0",
+ }),
repoFullName: z.string().optional().openapi({
param: { description: "Optional repository filter. Browser sessions must have control-panel access to this repo." },
example: "JSONbored/loopover",
diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts
index cf1b2be154..6f84c2bfe7 100644
--- a/test/integration/api.test.ts
+++ b/test/integration/api.test.ts
@@ -4476,11 +4476,15 @@ describe("api routes", () => {
expect(bounded.status).toBe(200);
const boundedBody = (await bounded.json()) as {
limit: number;
+ offset: number;
+ total: number;
hasMore: boolean;
items: Array<{ repoFullName: string; pullNumber: number; reason: string; timestamp: string; remediation: string }>;
};
expect(boundedBody.limit).toBe(3);
+ expect(boundedBody.offset).toBe(0);
expect(boundedBody.hasMore).toBe(true);
+ expect(boundedBody.total).toBeGreaterThan(3);
expect(boundedBody.items).toEqual([
expect.objectContaining({ repoFullName: "victim-org/secret-repo", pullNumber: 7, reason: "maintainer_author" }),
expect.objectContaining({ repoFullName: "repo-owner/owned-repo", pullNumber: 6, reason: "surface_off" }),
@@ -4489,6 +4493,23 @@ describe("api routes", () => {
expect(boundedBody.items[1]?.remediation).toContain("repository settings");
expect(JSON.stringify(boundedBody)).not.toMatch(/private-author|bot-secret|detector-secret|surface-secret|victim-secret|delivery-secret|github_pat|wallet|hotkey|raw trust/i);
+ // #7438: offset pages into older rows without re-fetching the head of the feed.
+ const page2 = await app.request("/v1/app/skipped-pr-audit?limit=3&offset=3", { headers: apiHeaders(env) }, env);
+ expect(page2.status).toBe(200);
+ const page2Body = (await page2.json()) as {
+ offset: number;
+ hasMore: boolean;
+ items: Array<{ pullNumber: number }>;
+ };
+ expect(page2Body.offset).toBe(3);
+ expect(page2Body.items).toHaveLength(3);
+ expect(page2Body.items.map((item) => item.pullNumber)).not.toEqual(
+ boundedBody.items.map((item) => item.pullNumber),
+ );
+ const omittedOffset = await app.request("/v1/app/skipped-pr-audit?limit=1", { headers: apiHeaders(env) }, env);
+ expect(omittedOffset.status).toBe(200);
+ await expect(omittedOffset.json()).resolves.toMatchObject({ offset: 0, limit: 1 });
+
const reasonFiltered = await app.request("/v1/app/skipped-pr-audit?reason=bot_author&limit=500", { headers: apiHeaders(env) }, env);
expect(reasonFiltered.status).toBe(200);
const reasonFilteredBody = (await reasonFiltered.json()) as { limit: number; hasMore: boolean; items: Array<{ reason: string; pullNumber: number }> };
diff --git a/test/unit/mcp-skipped-pr-audit.test.ts b/test/unit/mcp-skipped-pr-audit.test.ts
index bc111319f1..3f6d163d58 100644
--- a/test/unit/mcp-skipped-pr-audit.test.ts
+++ b/test/unit/mcp-skipped-pr-audit.test.ts
@@ -31,6 +31,8 @@ async function seedOwnedRepo(env: Env, installationId: number, owner: string, na
type SkippedPrAuditData = {
generatedAt: string;
limit: number;
+ offset: number;
+ total: number;
hasMore: boolean;
filters: { repoFullName: string | null; reason: string | null; since: string | null };
items: Array<{ repoFullName: string; pullNumber: number; reason: string; timestamp: string; remediation: string }>;
@@ -68,6 +70,7 @@ describe("MCP loopover_get_skipped_pr_audit (#5825)", () => {
expect(data.items).toEqual([expect.objectContaining({ repoFullName: "repo-owner/owned-repo", pullNumber: 4, reason: "bot_author" })]);
expect(data.items[0]?.remediation).toContain("intentionally kept quiet");
expect(data.limit).toBe(50);
+ expect(data.offset).toBe(0);
expect(data.hasMore).toBe(false);
expect(data.filters).toEqual({ repoFullName: null, reason: null, since: null });
expect(JSON.stringify(result.content)).not.toContain("github_pat_should_not_export");
diff --git a/test/unit/skipped-pr-audit.test.ts b/test/unit/skipped-pr-audit.test.ts
index 82bbdb6434..38ba70aa59 100644
--- a/test/unit/skipped-pr-audit.test.ts
+++ b/test/unit/skipped-pr-audit.test.ts
@@ -49,13 +49,14 @@ describe("skipped PR audit repository export", () => {
});
const emptyScope = await listPrVisibilitySkipAuditEvents(env, { repoFullNames: [] });
- expect(emptyScope).toMatchObject({ limit: 50, hasMore: false, items: [] });
+ expect(emptyScope).toMatchObject({ limit: 50, offset: 0, hasMore: false, total: 0, items: [] });
const scoped = await listPrVisibilitySkipAuditEvents(env, {
limit: Number.NaN,
repoFullNames: ["owner/re_po", "OWNER/re_po"],
});
expect(scoped.limit).toBe(1);
+ expect(scoped.offset).toBe(0);
expect(scoped.items).toEqual([
{
repoFullName: "owner/re_po",
@@ -68,6 +69,28 @@ describe("skipped PR audit repository export", () => {
const unscoped = await listPrVisibilitySkipAuditEvents(env);
expect(unscoped.limit).toBe(50);
+ expect(unscoped.offset).toBe(0);
expect(unscoped.items.map((item) => item.pullNumber)).toEqual([8, 7]);
+ expect(unscoped.total).toBeGreaterThanOrEqual(2);
+
+ // #7438: offset advances into the next page instead of growing limit from the start.
+ const page0 = await listPrVisibilitySkipAuditEvents(env, { limit: 1, offset: 0 });
+ const page1 = await listPrVisibilitySkipAuditEvents(env, { limit: 1, offset: 1 });
+ expect(page0.items).toHaveLength(1);
+ expect(page0.hasMore).toBe(true);
+ expect(page0.offset).toBe(0);
+ expect(page1.items).toHaveLength(1);
+ expect(page1.offset).toBe(1);
+ expect(page1.items[0]?.pullNumber).not.toBe(page0.items[0]?.pullNumber);
+ expect(page1.items[0]?.pullNumber).toBe(7);
+
+ const pastEnd = await listPrVisibilitySkipAuditEvents(env, { limit: 10, offset: 50 });
+ expect(pastEnd.items).toEqual([]);
+ expect(pastEnd.hasMore).toBe(false);
+ expect(pastEnd.offset).toBe(50);
+
+ const negativeOffset = await listPrVisibilitySkipAuditEvents(env, { limit: 10, offset: -5 });
+ expect(negativeOffset.offset).toBe(0);
+ expect(negativeOffset.items.map((item) => item.pullNumber)).toEqual([8, 7]);
});
});
diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts
index 792adf4199..6541bb5eaf 100644
--- a/test/unit/support/mcp-cli-harness.ts
+++ b/test/unit/support/mcp-cli-harness.ts
@@ -771,7 +771,7 @@ export async function startFixtureServer(
response.end(JSON.stringify({ error: "skipped_pr_audit_unavailable" }));
return;
}
- response.end(JSON.stringify({ generatedAt: "2026-05-30T00:00:00.000Z", limit: 20, hasMore: false, items: [{ prNumber: 7, reason: "duplicate", remediation: "link the canonical PR" }] }));
+ response.end(JSON.stringify({ generatedAt: "2026-05-30T00:00:00.000Z", limit: 20, offset: 0, total: 1, hasMore: false, items: [{ prNumber: 7, reason: "duplicate", remediation: "link the canonical PR" }] }));
return;
}
// #6750: the boundary-tests lint mirror proxies here; echo a fired finding + spec.