Skip to content

Commit b01bc0b

Browse files
fix(ui): page skipped-PR audit feed by offset instead of growing limit
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent fa67da4 commit b01bc0b

12 files changed

Lines changed: 261 additions & 43 deletions

File tree

apps/loopover-ui/public/openapi.json

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11061,11 +11061,16 @@
1106111061
"remediation"
1106211062
]
1106311063
}
11064+
},
11065+
"offset": {
11066+
"type": "integer",
11067+
"minimum": 0
1106411068
}
1106511069
},
1106611070
"required": [
1106711071
"generatedAt",
1106811072
"limit",
11073+
"offset",
1106911074
"hasMore",
1107011075
"filters",
1107111076
"items"
@@ -17896,6 +17901,16 @@
1789617901
"name": "limit",
1789717902
"in": "query"
1789817903
},
17904+
{
17905+
"schema": {
17906+
"type": "string",
17907+
"example": "0"
17908+
},
17909+
"required": false,
17910+
"description": "Number of parsed skip events to skip before returning rows (non-negative).",
17911+
"name": "offset",
17912+
"in": "query"
17913+
},
1789917914
{
1790017915
"schema": {
1790117916
"type": "string",
@@ -17909,6 +17924,7 @@
1790917924
{
1791017925
"schema": {
1791117926
"type": "string",
17927+
"example": "not_official_gittensor_miner",
1791217928
"enum": [
1791317929
"surface_off",
1791417930
"missing_author",
@@ -17917,8 +17933,7 @@
1791717933
"maintainer_author",
1791817934
"miner_detection_unavailable",
1791917935
"not_official_gittensor_miner"
17920-
],
17921-
"example": "not_official_gittensor_miner"
17936+
]
1792217937
},
1792317938
"required": false,
1792417939
"description": "Optional PR skip reason filter.",

apps/loopover-ui/src/components/site/audit-feed-model.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type SkippedPrAuditItem = {
1717
export type SkippedPrAuditExport = {
1818
generatedAt: string;
1919
limit: number;
20+
offset: number;
2021
hasMore: boolean;
2122
filters: {
2223
repoFullName: string | null;
@@ -38,12 +39,14 @@ export const SKIP_REASON_OPTIONS: Array<{ value: "" | SkippedPrAuditReason; labe
3839

3940
export function buildSkippedPrAuditPath(options: {
4041
limit: number;
42+
offset?: number;
4143
repoFullName?: string;
4244
reason?: SkippedPrAuditReason;
4345
since?: string;
4446
}): string {
4547
const params = new URLSearchParams();
4648
params.set("limit", String(options.limit));
49+
params.set("offset", String(Math.max(0, options.offset ?? 0)));
4750
if (options.repoFullName?.trim()) params.set("repoFullName", options.repoFullName.trim());
4851
if (options.reason) params.set("reason", options.reason);
4952
if (options.since?.trim()) params.set("since", options.since.trim());
@@ -83,6 +86,7 @@ export function normalizeSkippedPrAuditExport(data: unknown): SkippedPrAuditExpo
8386
return {
8487
generatedAt: raw.generatedAt,
8588
limit: typeof raw.limit === "number" ? raw.limit : items.length,
89+
offset: typeof raw.offset === "number" && Number.isFinite(raw.offset) ? Math.max(0, raw.offset) : 0,
8690
hasMore: Boolean(raw.hasMore),
8791
filters: {
8892
repoFullName:

apps/loopover-ui/src/components/site/audit-feed.test.tsx

Lines changed: 98 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { AuditFeed } from "@/components/site/audit-feed";
1717
const SAMPLE: {
1818
generatedAt: string;
1919
limit: number;
20+
offset: number;
2021
hasMore: boolean;
2122
filters: { repoFullName: null; reason: null; since: null };
2223
items: Array<{
@@ -29,6 +30,7 @@ const SAMPLE: {
2930
} = {
3031
generatedAt: "2026-05-28T00:00:05.000Z",
3132
limit: 50,
33+
offset: 0,
3234
hasMore: false,
3335
filters: { repoFullName: null, reason: null, since: null },
3436
items: [
@@ -44,16 +46,17 @@ const SAMPLE: {
4446

4547
describe("audit feed helpers", () => {
4648
it("builds query paths for skipped PR audit filters", () => {
47-
expect(buildSkippedPrAuditPath({ limit: 25 })).toBe("/v1/app/skipped-pr-audit?limit=25");
49+
expect(buildSkippedPrAuditPath({ limit: 25 })).toBe("/v1/app/skipped-pr-audit?limit=25&offset=0");
4850
expect(
4951
buildSkippedPrAuditPath({
5052
limit: 50,
53+
offset: 50,
5154
repoFullName: "repo-owner/owned-repo",
5255
reason: "bot_author",
5356
since: "2026-05-28T00:00:00.000Z",
5457
}),
5558
).toBe(
56-
"/v1/app/skipped-pr-audit?limit=50&repoFullName=repo-owner%2Fowned-repo&reason=bot_author&since=2026-05-28T00%3A00%3A00.000Z",
59+
"/v1/app/skipped-pr-audit?limit=50&offset=50&repoFullName=repo-owner%2Fowned-repo&reason=bot_author&since=2026-05-28T00%3A00%3A00.000Z",
5760
);
5861
});
5962

@@ -111,7 +114,7 @@ describe("AuditFeed", () => {
111114
"https://github.com/repo-owner/owned-repo/pull/6",
112115
);
113116
expect(apiFetch).toHaveBeenCalledWith(
114-
"https://api.test/v1/app/skipped-pr-audit?limit=50",
117+
"https://api.test/v1/app/skipped-pr-audit?limit=50&offset=0",
115118
expect.objectContaining({ credentials: "include" }),
116119
);
117120
});
@@ -194,24 +197,110 @@ describe("AuditFeed", () => {
194197
expect(apiFetch).not.toHaveBeenCalled();
195198
});
196199

197-
it("loads more rows until the maximum page size", async () => {
198-
apiFetch.mockResolvedValue({ ok: true, data: { ...SAMPLE, hasMore: true } });
200+
it("appends the next offset page without replacing already-visible rows (#7438)", async () => {
201+
const firstPage = {
202+
...SAMPLE,
203+
hasMore: true,
204+
offset: 0,
205+
items: [
206+
{
207+
repoFullName: "repo-owner/owned-repo",
208+
pullNumber: 6,
209+
reason: "surface_off",
210+
timestamp: "2026-05-28T00:00:04.000Z",
211+
remediation: "Enable a PR public surface in repository settings.",
212+
},
213+
],
214+
};
215+
const secondPage = {
216+
...SAMPLE,
217+
hasMore: false,
218+
offset: 1,
219+
items: [
220+
{
221+
repoFullName: "repo-owner/owned-repo",
222+
pullNumber: 5,
223+
reason: "bot_author",
224+
timestamp: "2026-05-28T00:00:03.000Z",
225+
remediation: "Bot authors are excluded from public PR surfaces.",
226+
},
227+
],
228+
};
229+
apiFetch.mockResolvedValue({ ok: true, data: firstPage });
199230
render(<AuditFeed />);
200-
await screen.findByText("repo-owner/owned-repo");
231+
expect(await screen.findByText("#6")).toBeTruthy();
201232
apiFetch.mockClear();
202-
apiFetch.mockResolvedValue({ ok: true, data: { ...SAMPLE, hasMore: true, limit: 100 } });
233+
apiFetch.mockResolvedValue({ ok: true, data: secondPage });
203234

204235
fireEvent.click(screen.getByRole("button", { name: /load more/i }));
205236

206237
await waitFor(() =>
207238
expect(apiFetch).toHaveBeenCalledWith(
208-
"https://api.test/v1/app/skipped-pr-audit?limit=100",
239+
"https://api.test/v1/app/skipped-pr-audit?limit=50&offset=1",
209240
expect.any(Object),
210241
),
211242
);
212243

213-
expect(screen.getByText(/maximum page size \(100\)/i)).toBeTruthy();
244+
expect(screen.getByText("#6")).toBeTruthy();
245+
expect(await screen.findByText("#5")).toBeTruthy();
246+
expect(screen.getByText("2 event(s)")).toBeTruthy();
214247
expect(screen.queryByRole("button", { name: /load more/i })).toBeNull();
248+
expect(screen.queryByText(/maximum page size/i)).toBeNull();
249+
});
250+
251+
it("resets to offset 0 when filters are applied after load-more (#7438)", async () => {
252+
apiFetch.mockResolvedValue({
253+
ok: true,
254+
data: {
255+
...SAMPLE,
256+
hasMore: true,
257+
items: [
258+
{
259+
repoFullName: "repo-owner/owned-repo",
260+
pullNumber: 6,
261+
reason: "surface_off",
262+
timestamp: "2026-05-28T00:00:04.000Z",
263+
remediation: "Enable a PR public surface in repository settings.",
264+
},
265+
],
266+
},
267+
});
268+
render(<AuditFeed />);
269+
await screen.findByText("#6");
270+
apiFetch.mockClear();
271+
apiFetch.mockResolvedValue({
272+
ok: true,
273+
data: {
274+
...SAMPLE,
275+
hasMore: false,
276+
offset: 1,
277+
items: [
278+
{
279+
repoFullName: "repo-owner/owned-repo",
280+
pullNumber: 5,
281+
reason: "bot_author",
282+
timestamp: "2026-05-28T00:00:03.000Z",
283+
remediation: "Bot authors are excluded from public PR surfaces.",
284+
},
285+
],
286+
},
287+
});
288+
fireEvent.click(screen.getByRole("button", { name: /load more/i }));
289+
await screen.findByText("#5");
290+
291+
apiFetch.mockClear();
292+
apiFetch.mockResolvedValue({ ok: true, data: SAMPLE });
293+
fireEvent.change(screen.getByPlaceholderText("owner/repo"), {
294+
target: { value: "repo-owner/owned-repo" },
295+
});
296+
fireEvent.click(screen.getByRole("button", { name: /apply filters/i }));
297+
298+
await waitFor(() =>
299+
expect(apiFetch).toHaveBeenCalledWith(
300+
"https://api.test/v1/app/skipped-pr-audit?limit=50&offset=0&repoFullName=repo-owner%2Fowned-repo",
301+
expect.any(Object),
302+
),
303+
);
215304
});
216305

217306
it("shows an error state when the audit response is malformed", async () => {

apps/loopover-ui/src/components/site/audit-feed.tsx

Lines changed: 49 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ const fieldClass =
2929
"mt-1 w-full rounded-token border border-border bg-background/40 px-3 py-2 text-token-sm text-foreground focus-ring";
3030

3131
const DEFAULT_LIMIT = 50;
32-
const MAX_LIMIT = 100;
3332

3433
type AuditFeedProps = {
3534
enabled?: boolean;
@@ -41,20 +40,21 @@ export function AuditFeed({ enabled = true }: AuditFeedProps) {
4140
const [repoFullName, setRepoFullName] = useState("");
4241
const [sinceInput, setSinceInput] = useState("");
4342
const [sinceIso, setSinceIso] = useState("");
44-
const [limit, setLimit] = useState(DEFAULT_LIMIT);
4543
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
4644
const [error, setError] = useState<string | null>(null);
4745
const [data, setData] = useState<SkippedPrAuditExport | null>(null);
46+
const [loadingMore, setLoadingMore] = useState(false);
4847

49-
const queryPath = useMemo(
48+
const filterPath = useMemo(
5049
() =>
5150
buildSkippedPrAuditPath({
52-
limit,
51+
limit: DEFAULT_LIMIT,
52+
offset: 0,
5353
repoFullName: repoFullName || undefined,
5454
reason: reason || undefined,
5555
since: sinceIso || undefined,
5656
}),
57-
[limit, reason, repoFullName, sinceIso],
57+
[reason, repoFullName, sinceIso],
5858
);
5959

6060
const load = useCallback(async () => {
@@ -67,7 +67,7 @@ export function AuditFeed({ enabled = true }: AuditFeedProps) {
6767
setStatus("loading");
6868
setError(null);
6969
const origin = getApiOrigin().replace(/\/$/, "");
70-
const result = await apiFetch<SkippedPrAuditExport>(`${origin}${queryPath}`, {
70+
const result = await apiFetch<SkippedPrAuditExport>(`${origin}${filterPath}`, {
7171
label: "Skipped PR audit",
7272
credentials: "include",
7373
headers: { Accept: "application/json" },
@@ -87,7 +87,7 @@ export function AuditFeed({ enabled = true }: AuditFeedProps) {
8787
setData(null);
8888
setError(result.message);
8989
setStatus("error");
90-
}, [enabled, queryPath]);
90+
}, [enabled, filterPath]);
9191

9292
useEffect(() => {
9393
void load();
@@ -96,7 +96,6 @@ export function AuditFeed({ enabled = true }: AuditFeedProps) {
9696
const applyFilters = () => {
9797
setSinceIso(normalizeSinceInput(sinceInput));
9898
setRepoFullName(repoDraft.trim());
99-
setLimit(DEFAULT_LIMIT);
10099
};
101100

102101
const resetFilters = () => {
@@ -105,11 +104,43 @@ export function AuditFeed({ enabled = true }: AuditFeedProps) {
105104
setRepoFullName("");
106105
setSinceInput("");
107106
setSinceIso("");
108-
setLimit(DEFAULT_LIMIT);
109107
};
110108

111-
const loadMore = () => {
112-
setLimit((current) => Math.min(current + DEFAULT_LIMIT, MAX_LIMIT));
109+
const loadMore = async () => {
110+
if (!enabled || !data?.hasMore || loadingMore) return;
111+
setLoadingMore(true);
112+
setError(null);
113+
const nextOffset = data.items.length;
114+
const origin = getApiOrigin().replace(/\/$/, "");
115+
const path = buildSkippedPrAuditPath({
116+
limit: DEFAULT_LIMIT,
117+
offset: nextOffset,
118+
repoFullName: repoFullName || undefined,
119+
reason: reason || undefined,
120+
since: sinceIso || undefined,
121+
});
122+
const result = await apiFetch<SkippedPrAuditExport>(`${origin}${path}`, {
123+
label: "Skipped PR audit",
124+
credentials: "include",
125+
headers: { Accept: "application/json" },
126+
});
127+
setLoadingMore(false);
128+
if (!result.ok) {
129+
setError(result.message);
130+
return;
131+
}
132+
const normalized = normalizeSkippedPrAuditExport(result.data);
133+
if (!normalized) {
134+
setError("The skipped PR audit endpoint returned an unexpected response.");
135+
return;
136+
}
137+
// Append-not-replace (#7438): keep already-rendered rows; only grow with the next page.
138+
setData({
139+
...normalized,
140+
items: [...data.items, ...normalized.items],
141+
// Surface the next page's paging cursor so subsequent Load more advances correctly.
142+
offset: nextOffset,
143+
});
113144
};
114145

115146
if (status === "loading" && !data) {
@@ -138,10 +169,7 @@ export function AuditFeed({ enabled = true }: AuditFeedProps) {
138169
reason={reason}
139170
repoDraft={repoDraft}
140171
sinceInput={sinceInput}
141-
onReasonChange={(value) => {
142-
setReason(value);
143-
setLimit(DEFAULT_LIMIT);
144-
}}
172+
onReasonChange={setReason}
145173
onRepoDraftChange={setRepoDraft}
146174
onSinceInputChange={setSinceInput}
147175
onApply={applyFilters}
@@ -175,10 +203,7 @@ export function AuditFeed({ enabled = true }: AuditFeedProps) {
175203
reason={reason}
176204
repoDraft={repoDraft}
177205
sinceInput={sinceInput}
178-
onReasonChange={(value) => {
179-
setReason(value);
180-
setLimit(DEFAULT_LIMIT);
181-
}}
206+
onReasonChange={setReason}
182207
onRepoDraftChange={setRepoDraft}
183208
onSinceInputChange={setSinceInput}
184209
onApply={applyFilters}
@@ -249,14 +274,12 @@ export function AuditFeed({ enabled = true }: AuditFeedProps) {
249274
</TableScroll>
250275

251276
<div className="flex flex-wrap items-center gap-3">
252-
{data.hasMore && limit < MAX_LIMIT ? (
253-
<StateActionButton onClick={loadMore}>Load more</StateActionButton>
254-
) : null}
255-
{data.hasMore && limit >= MAX_LIMIT ? (
256-
<p className="text-token-xs text-muted-foreground">
257-
Showing the maximum page size ({MAX_LIMIT}). Narrow filters to inspect older events.
258-
</p>
277+
{data.hasMore ? (
278+
<StateActionButton onClick={() => void loadMore()} disabled={loadingMore}>
279+
{loadingMore ? "Loading…" : "Load more"}
280+
</StateActionButton>
259281
) : null}
282+
{error ? <p className="text-token-xs text-destructive">{error}</p> : null}
260283
<StateActionButton onClick={() => void load()}>Refresh</StateActionButton>
261284
</div>
262285
</div>

0 commit comments

Comments
 (0)