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
8 changes: 8 additions & 0 deletions src/app/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ export function resetEmptyActionRepos(): void {
* Normalizes a Promise.allSettled rejection reason into a structured error shape.
* Handles both Octokit RequestError (has `.status`) and plain Error objects.
*/
export function isUnauthorizedError(err: unknown): boolean {
if (typeof err !== "object" || err === null) return false;
const e = err as { status?: unknown; response?: { status?: unknown } };
return e.status === 401 || e.response?.status === 401;
}

function extractRejectionError(reason: unknown): { statusCode: number | null; message: string } {
const statusCode =
typeof reason === "object" &&
Expand Down Expand Up @@ -1804,6 +1810,8 @@ export async function fetchHotPRStatus(

for (const s of settled) {
if (s.status === "rejected") {
// Token is invalid — let the caller trigger re-auth instead of retrying forever.
if (isUnauthorizedError(s.reason)) throw s.reason;
hadErrors = true;
console.warn("[hot-poll] PR status batch failed:", s.reason);
Sentry.captureException(s.reason, { tags: { source: "hot-poll-pr-batch" } });
Expand Down
16 changes: 14 additions & 2 deletions src/app/services/poll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import { createSignal, createEffect, createRoot, untrack, onCleanup } from "soli
import * as Sentry from "@sentry/solid";
import { getClient, fetchRateLimitDetails } from "./github";
import { config } from "../stores/config";
import { user, onAuthCleared } from "../stores/auth";
import { user, onAuthCleared, expireToken } from "../stores/auth";
import { checkAndResetIfExpired } from "./api-usage";
import {
fetchIssuesAndPullRequests,
fetchWorkflowRuns,
fetchHotPRStatus,
fetchWorkflowRunById,
pooledAllSettled,
isUnauthorizedError,
type Issue,
type PullRequest,
type WorkflowRun,
Expand Down Expand Up @@ -228,7 +229,7 @@ export async function fetchAllData(
if (result.status === "rejected") {
const err = result.reason;
// Propagate 401 to outer handler for re-auth (don't absorb as generic error)
if (err?.status === 401 || err?.response?.status === 401) throw err;
if (isUnauthorizedError(err)) throw err;
const statusCode = typeof err === "object" && err !== null && typeof (err as Record<string, unknown>).status === "number"
? (err as Record<string, unknown>).status as number
: null;
Expand Down Expand Up @@ -493,6 +494,7 @@ export async function fetchHotData(): Promise<{
prUpdates.set(id, update);
}
} catch (err) {
if (isUnauthorizedError(err)) throw err;
hadErrors = true;
console.warn("[hot-poll] PR status fetch failed:", err);
Sentry.captureException(err, { tags: { source: "hot-poll-pr-fetch" } });
Expand All @@ -509,6 +511,7 @@ export async function fetchHotData(): Promise<{
if (result.status === "fulfilled") {
runUpdates.set(result.value.id, result.value);
} else {
if (isUnauthorizedError(result.reason)) throw result.reason;
hadErrors = true;
console.warn("[hot-poll] Workflow run fetch failed:", result.reason);
Sentry.captureException(result.reason, { tags: { source: "hot-poll-run-fetch" } });
Expand Down Expand Up @@ -639,6 +642,15 @@ export function createHotPollCoordinator(
onHotData(prUpdates, runUpdates, generation);
}
} catch (err) {
if (isUnauthorizedError(err)) {
// Token invalid — same re-auth path as the full poll (poll.ts throw at
// fetchAllData) and cross-tab sync (auth.ts storage listener): clear the
// token and hard-redirect. Don't schedule another cycle — page is navigating.
Sentry.captureException(err, { tags: { source: "hot-poll-auth-expired" } });
expireToken();
window.location.replace("/login");
return;
}
consecutiveFailures++;
const message = err instanceof Error ? err.message : "Unknown hot-poll error";
pushError("hot-poll", message, true);
Expand Down
121 changes: 121 additions & 0 deletions tests/services/hot-poll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ import { makePullRequest, makeWorkflowRun } from "../helpers/index";

// ── Mocks ─────────────────────────────────────────────────────────────────────

const mockLocationReplace = vi.fn();
// createHotPollCoordinator hard-redirects to /login on 401, mirroring the
// full-poll and cross-tab-sync auth paths. Stub window.location so we can
// assert on the redirect without actually navigating.
Object.defineProperty(window, "location", {
configurable: true,
writable: true,
value: { replace: mockLocationReplace, href: "" },
});

// Mock github module so getClient returns our fake octokit
const mockGetClient = vi.fn();
vi.mock("../../src/app/services/github", () => ({
Expand Down Expand Up @@ -49,6 +59,7 @@ vi.mock("../../src/app/stores/config", () => ({
vi.mock("../../src/app/stores/auth", () => ({
user: vi.fn(() => null),
onAuthCleared: vi.fn(),
expireToken: vi.fn(),
}));

// Import AFTER mocks are set up
Expand All @@ -65,6 +76,7 @@ import {
import {
fetchHotPRStatus,
fetchWorkflowRunById,
isUnauthorizedError,
} from "../../src/app/services/api";

// ── Helpers ───────────────────────────────────────────────────────────────────
Expand All @@ -89,6 +101,28 @@ const emptyData: DashboardData = {

// ── Tests ─────────────────────────────────────────────────────────────────────

describe("isUnauthorizedError", () => {
it("returns true for a top-level status 401", () => {
expect(isUnauthorizedError({ status: 401 })).toBe(true);
});

it("returns true for a nested response.status 401", () => {
expect(isUnauthorizedError({ response: { status: 401 } })).toBe(true);
});

it("returns false for other status codes", () => {
expect(isUnauthorizedError({ status: 500 })).toBe(false);
expect(isUnauthorizedError({ response: { status: 403 } })).toBe(false);
});

it("returns false for non-object and null values", () => {
expect(isUnauthorizedError(null)).toBe(false);
expect(isUnauthorizedError(undefined)).toBe(false);
expect(isUnauthorizedError("Bad credentials")).toBe(false);
expect(isUnauthorizedError(new Error("network failure"))).toBe(false);
});
});

describe("fetchHotPRStatus", () => {
it("returns empty map for empty nodeIds", async () => {
const octokit = makeOctokit();
Expand Down Expand Up @@ -800,6 +834,60 @@ describe("createHotPollCoordinator", () => {
});
});

it("redirects to /login via expireToken on 401 instead of generic backoff", async () => {
const onHotData = vi.fn();
const octokit = makeOctokit(undefined, () =>
Promise.reject(Object.assign(new Error("Bad credentials"), { status: 401 })));
mockGetClient.mockReturnValue(octokit);

rebuildHotSets({
...emptyData,
pullRequests: [makePullRequest({ id: 1, checkStatus: "pending", enriched: true, nodeId: "PR_a" })],
});

const { expireToken } = await import("../../src/app/stores/auth");
const { pushError } = await import("../../src/app/lib/errors");
(expireToken as ReturnType<typeof vi.fn>).mockClear();
(pushError as ReturnType<typeof vi.fn>).mockClear();
mockLocationReplace.mockClear();

await createRoot(async (dispose) => {
createHotPollCoordinator(() => 10, onHotData);
await vi.advanceTimersByTimeAsync(10_000);

expect(expireToken).toHaveBeenCalledOnce();
expect(mockLocationReplace).toHaveBeenCalledWith("/login");
// Not treated as a generic retryable failure
expect(pushError).not.toHaveBeenCalledWith("hot-poll", expect.any(String), true);
dispose();
});
});

it("does not schedule another cycle after a 401 redirect", async () => {
const onHotData = vi.fn();
const octokit = makeOctokit(undefined, () =>
Promise.reject(Object.assign(new Error("Bad credentials"), { status: 401 })));
mockGetClient.mockReturnValue(octokit);

rebuildHotSets({
...emptyData,
pullRequests: [makePullRequest({ id: 1, checkStatus: "pending", enriched: true, nodeId: "PR_a" })],
});

const graphqlFn = octokit.graphql as ReturnType<typeof vi.fn>;

await createRoot(async (dispose) => {
createHotPollCoordinator(() => 10, onHotData);
await vi.advanceTimersByTimeAsync(10_000);
expect(graphqlFn).toHaveBeenCalledTimes(1);

// Advance well past several would-be cycles — no further fetch attempts
await vi.advanceTimersByTimeAsync(60_000);
expect(graphqlFn).toHaveBeenCalledTimes(1);
dispose();
});
});

it("does not schedule when interval is 0", async () => {
const onHotData = vi.fn();
mockGetClient.mockReturnValue(makeOctokit());
Expand Down Expand Up @@ -898,6 +986,13 @@ describe("fetchHotPRStatus edge cases", () => {
expect(results.get(1)).toBeDefined();
expect(hadErrors).toBe(true);
});

it("throws (does not swallow into hadErrors) when a batch rejects with 401", async () => {
const octokit = makeOctokit(undefined, () =>
Promise.reject(Object.assign(new Error("Bad credentials"), { status: 401 })));

await expect(fetchHotPRStatus(octokit as never, ["PR_a"])).rejects.toMatchObject({ status: 401 });
});
});

describe("rebuildHotSets caps", () => {
Expand Down Expand Up @@ -1137,6 +1232,32 @@ describe("fetchHotData hadErrors", () => {
expect(hadErrors).toBe(true);
expect(runUpdates.size).toBe(0); // failed, no results
});

it("propagates (throws) a 401 from the PR fetch instead of returning hadErrors", async () => {
const octokit = makeOctokit(undefined, () =>
Promise.reject(Object.assign(new Error("Bad credentials"), { status: 401 })));
mockGetClient.mockReturnValue(octokit);

rebuildHotSets({
...emptyData,
pullRequests: [makePullRequest({ id: 1, checkStatus: "pending", enriched: true, nodeId: "PR_a" })],
});

await expect(fetchHotData()).rejects.toMatchObject({ status: 401 });
});

it("propagates (throws) a 401 from a workflow run fetch instead of returning hadErrors", async () => {
const octokit = makeOctokit(() =>
Promise.reject(Object.assign(new Error("Bad credentials"), { status: 401 })));
mockGetClient.mockReturnValue(octokit);

rebuildHotSets({
...emptyData,
workflowRuns: [makeWorkflowRun({ id: 10, status: "in_progress", conclusion: null, repoFullName: "o/r" })],
});

await expect(fetchHotData()).rejects.toMatchObject({ status: 401 });
});
});

describe("fetchHotPRStatus updateGraphqlRateLimit", () => {
Expand Down
5 changes: 5 additions & 0 deletions tests/services/poll-fetchAllData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ vi.mock("../../src/app/services/api", () => ({
fetchIssuesAndPullRequests: vi.fn(),
fetchWorkflowRuns: vi.fn(),
resetEmptyActionRepos: vi.fn(),
isUnauthorizedError: (err: unknown) => {
if (typeof err !== "object" || err === null) return false;
const e = err as { status?: unknown; response?: { status?: unknown } };
return e.status === 401 || e.response?.status === 401;
},
}));

// Mock notifications
Expand Down