diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 3f2520d5..13718441 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -319,13 +319,21 @@ The hot poll pauses automatically when the browser tab is hidden (since visual f Hover the rate limit display in the dashboard footer to see detailed remaining counts for the Core and GraphQL API pools, plus the reset time. +### Events Poll + +A lightweight poll runs every **60 seconds** to detect new activity across all your tracked repos. Instead of re-fetching all issues, PRs, and workflow runs, it checks your recent GitHub events (pushes, PR updates, issue comments, etc.) and compares event IDs against the last seen ID. + +When new events are detected, the dashboard immediately runs **targeted refreshes** for only the affected repos — so a push to `my-org/api-server` refreshes just that repo's data, not everything. + +The events poll uses about **1 REST request per cycle** (~1% of your hourly rate-limit budget). If you have a high volume of recent activity (100+ events per cycle), it automatically fetches up to 3 pages to avoid missing older events. + ### Tab Visibility Behavior When the tab is hidden: - The **hot poll always pauses** (it provides only visual feedback). - The **full refresh pauses** in background tabs — GraphQL requests have no 304 shortcut and every poll consumes real rate-limit budget. -- The **events poll continues in background** — it uses ETag conditional requests (`If-None-Match`) that return 304 when nothing has changed, costing zero rate-limit points. When changes are detected, targeted per-repo refreshes run immediately. +- The **events poll continues in background** — it is lightweight enough to run even when the tab is hidden, giving you near-real-time change detection without the cost of a full refresh. When you return to a tab that has been hidden for more than 2 minutes, a catch-up full refresh fires immediately regardless of where the timer is in its cycle. diff --git a/src/app/services/events.ts b/src/app/services/events.ts index e7489d4f..d34bed9d 100644 --- a/src/app/services/events.ts +++ b/src/app/services/events.ts @@ -32,15 +32,18 @@ export const ACTIONABLE_EVENT_TYPES = [ "PushEvent", ] as const; -// ── Module-level ETag state ─────────────────────────────────────────────────── +// ── Module-level state ─────────────────────────────────────────────────────── + +function eventIdNum(id: string): number { + const n = parseInt(id, 10); + return Number.isNaN(n) ? 0 : n; +} -let _eventsETag: string | null = null; let _lastEventId: string | null = null; // ── Auth cleanup ────────────────────────────────────────────────────────────── export function resetEventsState(): void { - _eventsETag = null; _lastEventId = null; } @@ -60,58 +63,76 @@ export async function fetchUserEvents( return { events: [], changed: false }; } - const headers: Record = {}; - if (_eventsETag) { - headers["If-None-Match"] = _eventsETag; - } - try { + // GitHub docs suggest per_page is capped at 30 for Events API, but empirical + // testing (2026-05-03) confirmed per_page: 100 returns 100 events successfully. const response = await octokit.request("GET /users/{username}/events", { username, per_page: 100, - headers, }); - // Store ETag for next conditional request - const etag = (response.headers as Record)["etag"]; - if (etag) { - _eventsETag = etag; + let allEvents = (response.data as GitHubEvent[]); + + // Paginate if page is full — older events on subsequent pages would be + // permanently missed since _lastEventId advances past them. + let page = 2; + let lastPageEvents = allEvents; + while (lastPageEvents.length === 100 && page <= 3) { + if (_lastEventId !== null) { + const threshold = eventIdNum(_lastEventId); + if (lastPageEvents.some((e) => eventIdNum(e.id) <= threshold)) break; + } + try { + const next = await octokit.request("GET /users/{username}/events", { + username, + per_page: 100, + page, + }); + const nextEvents = (next.data as GitHubEvent[]); + if (nextEvents.length === 0) break; + allEvents = [...allEvents, ...nextEvents]; + lastPageEvents = nextEvents; + page++; + } catch (err) { + console.warn(`[events] pagination error on page ${page}:`, err instanceof Error ? err.message : String(err)); + break; + } } - const allEvents = (response.data as GitHubEvent[]); + const maxId = allEvents.reduce( + (max, e) => Math.max(max, eventIdNum(e.id)), + 0, + ); - // First call: no ID filter — seed _lastEventId and return all events + // First call: seed _lastEventId and return all events if (_lastEventId === null) { - if (allEvents.length > 0) { - _lastEventId = allEvents[0].id; // events are newest-first + if (maxId > 0) { + _lastEventId = String(maxId); } return { events: allEvents, changed: allEvents.length > 0 }; } // Subsequent calls: filter to only events newer than _lastEventId - // Use numeric comparison — event IDs are numeric strings; lexicographic - // comparison would break for IDs of different lengths (e.g. "9" > "10"). - const lastIdNum = parseInt(_lastEventId, 10); + const lastIdNum = eventIdNum(_lastEventId); const newEvents = allEvents.filter( - (e) => parseInt(e.id, 10) > lastIdNum, + (e) => eventIdNum(e.id) > lastIdNum, ); - if (newEvents.length > 0) { - _lastEventId = allEvents[0].id; // newest event is always first + if (maxId > lastIdNum) { + _lastEventId = String(maxId); } return { events: newEvents, changed: newEvents.length > 0 }; } catch (err) { - // Octokit throws RequestError on 304 — same pattern as hasNotificationChanges() - if ( - typeof err === "object" && - err !== null && - (err as { status?: number }).status === 304 - ) { - return { events: [], changed: false }; + const status = + err && typeof err === "object" && "status" in err + ? (err as { status: number }).status + : null; + if (status === 304) { + console.warn("[events] unexpected 304 from proxy/CDN; events suppressed this cycle"); + } else { + console.warn("[events] fetchUserEvents error:", err instanceof Error ? err.message : String(err)); } - // Silent fallback for all other errors — full refresh handles reconciliation - console.warn("[events] fetchUserEvents error:", err instanceof Error ? err.message : String(err)); return { events: [], changed: false }; } } diff --git a/src/app/services/poll.ts b/src/app/services/poll.ts index bbd7a9b0..5f74b21d 100644 --- a/src/app/services/poll.ts +++ b/src/app/services/poll.ts @@ -866,7 +866,9 @@ export function createEventsPollCoordinator( schedule(myGeneration, EVENTS_POLL_INTERVAL_MS); } - // First cycle fires immediately (delay=0) to establish ETag baseline + // First cycle fires immediately (delay=0) to seed _lastEventId baseline. + // May trigger a targeted refresh if tracked repos have recent events — the + // isFullRefreshing() guard prevents doubling up with the initial full poll. const gen = chainGeneration; timeoutId = setTimeout(() => void cycle(gen), 0); diff --git a/tests/services/events.test.ts b/tests/services/events.test.ts index 2e673711..4a84fa3d 100644 --- a/tests/services/events.test.ts +++ b/tests/services/events.test.ts @@ -52,7 +52,7 @@ describe("fetchUserEvents", () => { const octokit = makeOctokit(() => Promise.resolve({ data: [event], - headers: { etag: '"abc123"' }, + headers: {}, }) ); @@ -63,7 +63,7 @@ describe("fetchUserEvents", () => { expect(result.events[0].id).toBe("500"); }); - it("returns empty events and changed=false on 304", async () => { + it("returns empty events and changed=false on proxy 304 without throwing", async () => { const octokit = makeOctokit(() => Promise.reject({ status: 304 })); const result = await fetchUserEvents(octokit as never, "someuser"); @@ -81,33 +81,19 @@ describe("fetchUserEvents", () => { expect(result.events).toHaveLength(0); }); - it("sends If-None-Match header on second call after ETag received", async () => { + it("does not send If-None-Match header on subsequent calls", async () => { const octokit = makeOctokit(() => Promise.resolve({ data: [makeEvent({ id: "200" })], - headers: { etag: '"etag-value"' }, + headers: {}, }) ); - // First call — seeds ETag await fetchUserEvents(octokit as never, "someuser"); - - // Second call — ETag should be sent await fetchUserEvents(octokit as never, "someuser"); const secondCallHeaders = (octokit.request.mock.calls[1][1] as { headers?: Record }).headers ?? {}; - expect(secondCallHeaders["If-None-Match"]).toBe('"etag-value"'); - }); - - it("does NOT send If-None-Match on first call", async () => { - const octokit = makeOctokit(() => - Promise.resolve({ data: [], headers: {} }) - ); - - await fetchUserEvents(octokit as never, "someuser"); - - const firstCallHeaders = (octokit.request.mock.calls[0][1] as { headers?: Record }).headers ?? {}; - expect(firstCallHeaders["If-None-Match"]).toBeUndefined(); + expect(secondCallHeaders["If-None-Match"]).toBeUndefined(); }); it("returns all events on first call (no ID filter)", async () => { @@ -190,6 +176,35 @@ describe("fetchUserEvents", () => { expect(result.events).toHaveLength(0); }); + it("advances _lastEventId to max ID even when highest ID is not first in response", async () => { + const firstOctokit = makeOctokit(() => + Promise.resolve({ data: [makeEvent({ id: "100" })], headers: {} }) + ); + await fetchUserEvents(firstOctokit as never, "someuser"); + + // Response with out-of-order IDs: highest (305) is not first + const secondOctokit = makeOctokit(() => + Promise.resolve({ + data: [makeEvent({ id: "301" }), makeEvent({ id: "305" }), makeEvent({ id: "302" })], + headers: {}, + }) + ); + await fetchUserEvents(secondOctokit as never, "someuser"); + + // Third call: only events > 305 should appear (not > 301) + const thirdOctokit = makeOctokit(() => + Promise.resolve({ + data: [makeEvent({ id: "306" }), makeEvent({ id: "304" }), makeEvent({ id: "303" })], + headers: {}, + }) + ); + const result = await fetchUserEvents(thirdOctokit as never, "someuser"); + + expect(result.events).toHaveLength(1); + expect(result.events[0].id).toBe("306"); + expect(result.changed).toBe(true); + }); + it("returns empty events and changed=false for empty username (SEC-IMPL-001)", async () => { const octokit = makeOctokit(() => Promise.resolve({ data: [], headers: {} })); @@ -201,6 +216,155 @@ describe("fetchUserEvents", () => { }); }); +describe("fetchUserEvents — pagination", () => { + beforeEach(() => { + resetEventsState(); + vi.clearAllMocks(); + }); + + it("fetches page 2 when page 1 returns exactly 100 events and merges results", async () => { + const page1Events = Array.from({ length: 100 }, (_, i) => + makeEvent({ id: String(1000 + i) }) + ); + const page2Events = [makeEvent({ id: "900" }), makeEvent({ id: "901" })]; + + const octokit = makeOctokit(() => Promise.resolve({ data: [], headers: {} })); + octokit.request + .mockImplementationOnce(() => Promise.resolve({ data: page1Events, headers: {} })) + .mockImplementationOnce(() => Promise.resolve({ data: page2Events, headers: {} })); + + const result = await fetchUserEvents(octokit as never, "someuser"); + + expect(octokit.request).toHaveBeenCalledTimes(2); + expect(result.events).toHaveLength(102); + expect(result.changed).toBe(true); + }); + + it("does not fetch page 2 when page 1 returns fewer than 100 events", async () => { + const page1Events = [makeEvent({ id: "500" }), makeEvent({ id: "501" })]; + + const octokit = makeOctokit(() => + Promise.resolve({ data: page1Events, headers: {} }) + ); + + await fetchUserEvents(octokit as never, "someuser"); + + expect(octokit.request).toHaveBeenCalledTimes(1); + }); + + it("fetches up to page 3 when pages 1 and 2 are both full, but not page 4", async () => { + const page1Events = Array.from({ length: 100 }, (_, i) => + makeEvent({ id: String(3000 + i) }) + ); + const page2Events = Array.from({ length: 100 }, (_, i) => + makeEvent({ id: String(2000 + i) }) + ); + const page3Events = [makeEvent({ id: "1000" }), makeEvent({ id: "1001" })]; + + const octokit = makeOctokit(() => Promise.resolve({ data: [], headers: {} })); + octokit.request + .mockImplementationOnce(() => Promise.resolve({ data: page1Events, headers: {} })) + .mockImplementationOnce(() => Promise.resolve({ data: page2Events, headers: {} })) + .mockImplementationOnce(() => Promise.resolve({ data: page3Events, headers: {} })); + + const result = await fetchUserEvents(octokit as never, "someuser"); + + expect(octokit.request).toHaveBeenCalledTimes(3); + expect(result.events).toHaveLength(202); + expect(result.changed).toBe(true); + }); + + it("skips page 2 when _lastEventId is set and all page 1 events are not newer", async () => { + // Seed _lastEventId = "500" + const seedOctokit = makeOctokit(() => + Promise.resolve({ data: [makeEvent({ id: "500" })], headers: {} }) + ); + await fetchUserEvents(seedOctokit as never, "someuser"); + + const page1Events = Array.from({ length: 100 }, (_, i) => + makeEvent({ id: String(400 + i) }) // IDs 400–499, all <= 500 + ); + const octokit = makeOctokit(() => Promise.resolve({ data: [], headers: {} })); + octokit.request.mockImplementationOnce(() => + Promise.resolve({ data: page1Events, headers: {} }) + ); + + const result = await fetchUserEvents(octokit as never, "someuser"); + + expect(octokit.request).toHaveBeenCalledTimes(1); + expect(result.changed).toBe(false); + expect(result.events).toHaveLength(0); + }); + + it("returns page 1 results and logs warning when page 2 request fails", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const page1Events = Array.from({ length: 100 }, (_, i) => + makeEvent({ id: String(1000 + i) }) + ); + + const octokit = makeOctokit(() => Promise.resolve({ data: [], headers: {} })); + octokit.request + .mockImplementationOnce(() => Promise.resolve({ data: page1Events, headers: {} })) + .mockImplementationOnce(() => Promise.reject(new Error("rate limited"))); + + const result = await fetchUserEvents(octokit as never, "someuser"); + + expect(octokit.request).toHaveBeenCalledTimes(2); + expect(result.events).toHaveLength(100); + expect(result.changed).toBe(true); + expect(warnSpy).toHaveBeenCalledWith( + "[events] pagination error on page 2:", + "rate limited", + ); + + warnSpy.mockRestore(); + }); + + it("stops pagination when subsequent page returns zero events", async () => { + const page1Events = Array.from({ length: 100 }, (_, i) => + makeEvent({ id: String(1000 + i) }) + ); + + const octokit = makeOctokit(() => Promise.resolve({ data: [], headers: {} })); + octokit.request + .mockImplementationOnce(() => Promise.resolve({ data: page1Events, headers: {} })) + .mockImplementationOnce(() => Promise.resolve({ data: [], headers: {} })); + + const result = await fetchUserEvents(octokit as never, "someuser"); + + expect(octokit.request).toHaveBeenCalledTimes(2); + expect(result.events).toHaveLength(100); + expect(result.changed).toBe(true); + }); + + it("triggers early-exit when page has mix of new and old events", async () => { + // Seed _lastEventId = "500" + const seedOctokit = makeOctokit(() => + Promise.resolve({ data: [makeEvent({ id: "500" })], headers: {} }) + ); + await fetchUserEvents(seedOctokit as never, "someuser"); + + // Page 1: 100 events straddling the threshold — IDs 450–549 + // Events 501–549 are new (above 500), events 450–500 are old + const page1Events = Array.from({ length: 100 }, (_, i) => + makeEvent({ id: String(450 + i) }) + ); + const octokit = makeOctokit(() => Promise.resolve({ data: [], headers: {} })); + octokit.request.mockImplementationOnce(() => + Promise.resolve({ data: page1Events, headers: {} }) + ); + + const result = await fetchUserEvents(octokit as never, "someuser"); + + // Early-exit fires: page has old events (450–500), so page 2 is not fetched + expect(octokit.request).toHaveBeenCalledTimes(1); + // New events (501–549) are still returned from page 1 + expect(result.events).toHaveLength(49); + expect(result.changed).toBe(true); + }); +}); + // ── parseRepoEvents ─────────────────────────────────────────────────────────── describe("parseRepoEvents", () => { @@ -316,27 +480,6 @@ describe("parseRepoEvents", () => { // ── resetEventsState ────────────────────────────────────────────────────────── describe("resetEventsState", () => { - it("clears ETag so next call sends no If-None-Match header", async () => { - const octokit = makeOctokit(() => - Promise.resolve({ - data: [makeEvent({ id: "100" })], - headers: { etag: '"etag-123"' }, - }) - ); - - // First call — seeds ETag - await fetchUserEvents(octokit as never, "someuser"); - - // Reset - resetEventsState(); - - // Next call should have no If-None-Match - await fetchUserEvents(octokit as never, "someuser"); - - const thirdCallHeaders = (octokit.request.mock.calls[1][1] as { headers?: Record }).headers ?? {}; - expect(thirdCallHeaders["If-None-Match"]).toBeUndefined(); - }); - it("clears lastEventId so next call returns all events (first-call semantics)", async () => { // First call: seed lastEventId = "100" const firstOctokit = makeOctokit(() =>