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
2 changes: 1 addition & 1 deletion e2e/settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ async function setupAuth(page: Page) {
json: {
data: {
search: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
rateLimit: { remaining: 5000, resetAt: new Date(Date.now() + 3600000).toISOString() },
rateLimit: { limit: 5000, remaining: 5000, resetAt: new Date(Date.now() + 3600000).toISOString() },
},
},
})
Expand Down
4 changes: 2 additions & 2 deletions e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ async function setupAuth(page: Page) {
json: {
data: {
search: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
rateLimit: { remaining: 5000, resetAt: new Date(Date.now() + 3600000).toISOString() },
rateLimit: { limit: 5000, remaining: 5000, resetAt: new Date(Date.now() + 3600000).toISOString() },
},
},
})
Expand Down Expand Up @@ -112,7 +112,7 @@ test("OAuth callback flow completes and redirects", async ({ page }) => {
json: {
data: {
search: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
rateLimit: { remaining: 5000, resetAt: new Date(Date.now() + 3600000).toISOString() },
rateLimit: { limit: 5000, remaining: 5000, resetAt: new Date(Date.now() + 3600000).toISOString() },
},
},
})
Expand Down
5 changes: 3 additions & 2 deletions src/app/components/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from "../../services/poll";
import { clearAuth, user, onAuthCleared, DASHBOARD_STORAGE_KEY } from "../../stores/auth";
import { getClient, getGraphqlRateLimit } from "../../services/github";
import { formatCount } from "../../lib/format";

// ── Shared dashboard store (module-level to survive navigation) ─────────────

Expand Down Expand Up @@ -377,8 +378,8 @@ export default function DashboardPage() {
<Show when={getGraphqlRateLimit()}>
{(rl) => (
<div class="tooltip tooltip-left" data-tip={`GraphQL API Rate Limits — resets at ${rl().resetAt.toLocaleTimeString()}`}>
<span class={`tabular-nums ${rl().remaining < 500 ? "text-warning" : ""}`}>
API RL: {rl().remaining.toLocaleString()}/5k/hr
<span class={`tabular-nums ${rl().remaining < rl().limit * 0.1 ? "text-warning" : ""}`}>
API RL: {rl().remaining.toLocaleString()}/{formatCount(rl().limit)}/hr
</span>
</div>
)}
Expand Down
38 changes: 19 additions & 19 deletions src/app/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ interface GraphQLIssueSearchResponse {
pageInfo: { hasNextPage: boolean; endCursor: string | null };
nodes: (GraphQLIssueNode | null)[];
};
rateLimit?: { remaining: number; resetAt: string };
rateLimit?: { limit: number; remaining: number; resetAt: string };
}

interface GraphQLPRNode {
Expand Down Expand Up @@ -310,7 +310,7 @@ interface GraphQLPRSearchResponse {
pageInfo: { hasNextPage: boolean; endCursor: string | null };
nodes: (GraphQLPRNode | null)[];
};
rateLimit?: { remaining: number; resetAt: string };
rateLimit?: { limit: number; remaining: number; resetAt: string };
}

interface ForkCandidate {
Expand All @@ -325,8 +325,8 @@ interface ForkRepoResult {
}

interface ForkQueryResponse {
rateLimit?: { remaining: number; resetAt: string };
[key: string]: ForkRepoResult | { remaining: number; resetAt: string } | undefined | null;
rateLimit?: { limit: number; remaining: number; resetAt: string };
[key: string]: ForkRepoResult | { limit: number; remaining: number; resetAt: string } | undefined | null;
}

// ── GraphQL search query constants ───────────────────────────────────────────
Expand All @@ -353,7 +353,7 @@ const ISSUES_SEARCH_QUERY = `
}
}
}
rateLimit { remaining resetAt }
rateLimit { limit remaining resetAt }
}
`;

Expand Down Expand Up @@ -406,7 +406,7 @@ const PR_SEARCH_QUERY = `
}
}
}
rateLimit { remaining resetAt }
rateLimit { limit remaining resetAt }
}
`;

Expand Down Expand Up @@ -479,7 +479,7 @@ const LIGHT_COMBINED_SEARCH_QUERY = `
}
}
}
rateLimit { remaining resetAt }
rateLimit { limit remaining resetAt }
}
${LIGHT_PR_FRAGMENT}
`;
Expand All @@ -496,7 +496,7 @@ const LIGHT_PR_SEARCH_QUERY = `
}
}
}
rateLimit { remaining resetAt }
rateLimit { limit remaining resetAt }
}
${LIGHT_PR_FRAGMENT}
`;
Expand All @@ -507,7 +507,7 @@ interface LightPRSearchResponse {
pageInfo: { hasNextPage: boolean; endCursor: string | null };
nodes: (GraphQLLightPRNode | null)[];
};
rateLimit?: { remaining: number; resetAt: string };
rateLimit?: { limit: number; remaining: number; resetAt: string };
}

/** Phase 2 backfill query: enriches PRs with heavy fields using node IDs. */
Expand Down Expand Up @@ -541,7 +541,7 @@ const HEAVY_PR_BACKFILL_QUERY = `
}
}
}
rateLimit { remaining resetAt }
rateLimit { limit remaining resetAt }
}
`;

Expand All @@ -563,7 +563,7 @@ const HOT_PR_STATUS_QUERY = `
}
}
}
rateLimit { remaining resetAt }
rateLimit { limit remaining resetAt }
}
`;

Expand All @@ -577,7 +577,7 @@ interface HotPRStatusNode {

interface HotPRStatusResponse {
nodes: (HotPRStatusNode | null)[];
rateLimit?: { remaining: number; resetAt: string };
rateLimit?: { limit: number; remaining: number; resetAt: string };
}

interface GraphQLLightPRNode {
Expand Down Expand Up @@ -639,12 +639,12 @@ interface LightCombinedSearchResponse {
pageInfo: { hasNextPage: boolean; endCursor: string | null };
nodes: (GraphQLLightPRNode | null)[];
};
rateLimit?: { remaining: number; resetAt: string };
rateLimit?: { limit: number; remaining: number; resetAt: string };
}

interface HeavyBackfillResponse {
nodes: (GraphQLHeavyPRNode | null)[];
rateLimit?: { remaining: number; resetAt: string };
rateLimit?: { limit: number; remaining: number; resetAt: string };
}

// Max node IDs per nodes() query (GitHub limit)
Expand All @@ -663,7 +663,7 @@ interface SearchPageResult<T> {
* caller-provided `processNode` callback. Handles partial errors, cap enforcement,
* and rate limit tracking. Returns the count of items added by processNode.
*/
async function paginateGraphQLSearch<TResponse extends { search: SearchPageResult<TNode>; rateLimit?: { remaining: number; resetAt: string } }, TNode>(
async function paginateGraphQLSearch<TResponse extends { search: SearchPageResult<TNode>; rateLimit?: { limit: number; remaining: number; resetAt: string } }, TNode>(
octokit: GitHubOctokit,
query: string,
queryString: string,
Expand Down Expand Up @@ -815,11 +815,11 @@ async function runForkPRFallback(
);
}

const forkQuery = `query(${varDefs.join(", ")}) {\n${fragments.join("\n")}\nrateLimit { remaining resetAt }\n}`;
const forkQuery = `query(${varDefs.join(", ")}) {\n${fragments.join("\n")}\nrateLimit { limit remaining resetAt }\n}`;

try {
const forkResponse = await octokit.graphql<ForkQueryResponse>(forkQuery, variables);
if (forkResponse.rateLimit) updateGraphqlRateLimit(forkResponse.rateLimit as { remaining: number; resetAt: string });
if (forkResponse.rateLimit) updateGraphqlRateLimit(forkResponse.rateLimit as { limit: number; remaining: number; resetAt: string });

for (let i = 0; i < forkChunk.length; i++) {
const data = forkResponse[`fork${i}`] as ForkRepoResult | null | undefined;
Expand Down Expand Up @@ -1475,11 +1475,11 @@ async function graphqlSearchPRs(
);
}

const forkQuery = `query(${varDefs.join(", ")}) {\n${fragments.join("\n")}\nrateLimit { remaining resetAt }\n}`;
const forkQuery = `query(${varDefs.join(", ")}) {\n${fragments.join("\n")}\nrateLimit { limit remaining resetAt }\n}`;

try {
const forkResponse = await octokit.graphql<ForkQueryResponse>(forkQuery, variables);
if (forkResponse.rateLimit) updateGraphqlRateLimit(forkResponse.rateLimit as { remaining: number; resetAt: string });
if (forkResponse.rateLimit) updateGraphqlRateLimit(forkResponse.rateLimit as { limit: number; remaining: number; resetAt: string });

for (let i = 0; i < forkChunk.length; i++) {
const data = forkResponse[`fork${i}`] as ForkRepoResult | null | undefined;
Expand Down
11 changes: 10 additions & 1 deletion src/app/services/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const GitHubOctokit = Octokit.plugin(throttling, retry, paginateRest);
type GitHubOctokitInstance = InstanceType<typeof GitHubOctokit>;

interface RateLimitInfo {
limit: number;
remaining: number;
resetAt: Date;
}
Expand All @@ -33,8 +34,13 @@ export function getGraphqlRateLimit(): RateLimitInfo | null {
return _graphqlRateLimit();
}

export function updateGraphqlRateLimit(rateLimit: { remaining: number; resetAt: string }): void {
function safePositiveInt(raw: number | undefined, fallback: number): number {
return raw != null && Number.isFinite(raw) && raw > 0 ? raw : fallback;
}

export function updateGraphqlRateLimit(rateLimit: { limit: number; remaining: number; resetAt: string }): void {
_setGraphqlRateLimit({
limit: safePositiveInt(rateLimit.limit, _graphqlRateLimit()?.limit ?? 5000),
remaining: rateLimit.remaining,
resetAt: new Date(rateLimit.resetAt), // ISO 8601 string → Date
});
Expand All @@ -43,8 +49,11 @@ export function updateGraphqlRateLimit(rateLimit: { remaining: number; resetAt:
export function updateRateLimitFromHeaders(headers: Record<string, string>): void {
const remaining = headers["x-ratelimit-remaining"];
const reset = headers["x-ratelimit-reset"];
const limit = headers["x-ratelimit-limit"];
if (remaining !== undefined && reset !== undefined) {
const parsedLimit = limit !== undefined ? parseInt(limit, 10) : NaN;
_setCoreRateLimit({
limit: safePositiveInt(parsedLimit, 5000),
remaining: parseInt(remaining, 10),
resetAt: new Date(parseInt(reset, 10) * 1000),
});
Expand Down
10 changes: 8 additions & 2 deletions src/app/stores/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,16 @@ export const [config, setConfig] = createStore<Config>(loadConfig());

export function updateConfig(partial: Partial<Config>): void {
const validated = ConfigSchema.partial().safeParse(partial);
if (!validated.success) return; // reject invalid updates
if (!validated.success) return;
// Only merge keys the caller actually provided: Zod .partial().safeParse()
// still applies per-field .default() values for absent keys, inflating
// validated.data with defaults that would overwrite live state.
const filtered = Object.fromEntries(
(Object.keys(partial) as (keyof Config)[]).map((k) => [k, validated.data[k]])
);
setConfig(
produce((draft) => {
Object.assign(draft, validated.data);
Object.assign(draft, filtered);
})
);
}
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/data-pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ function makeGraphqlSearchResponse(nodes = [graphqlIssueNode]) {
pageInfo: { hasNextPage: false, endCursor: null },
nodes,
},
rateLimit: { remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
rateLimit: { limit: 5000, remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
};
}

Expand Down
2 changes: 1 addition & 1 deletion tests/services/api-optimization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ function makeHeavyPRNode(databaseId: number, _nodeId?: string) {
};
}

const rateLimit = { remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() };
const rateLimit = { limit: 5000, remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() };

function makeSearchResponse<T>(nodes: T[], hasNextPage = false) {
return {
Expand Down
18 changes: 9 additions & 9 deletions tests/services/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ function makeGraphqlIssueResponse(nodes = [graphqlIssueNode], hasNextPage = fals
pageInfo: { hasNextPage, endCursor: hasNextPage ? "cursor1" : null },
nodes,
},
rateLimit: { remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
rateLimit: { limit: 5000, remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
};
}

Expand Down Expand Up @@ -371,7 +371,7 @@ describe("fetchIssues", () => {
pageInfo: { hasNextPage: false, endCursor: null },
nodes: [graphqlIssueNode, null, { ...graphqlIssueNode, databaseId: 999 }],
},
rateLimit: { remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
rateLimit: { limit: 5000, remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
}));

const result = await fetchIssues(
Expand Down Expand Up @@ -427,7 +427,7 @@ describe("fetchIssues", () => {
pageInfo: { hasNextPage: true, endCursor: "cursor-partial" },
nodes: [{ ...graphqlIssueNode, databaseId: 42 }, null],
},
rateLimit: { remaining: 4990, resetAt: new Date(Date.now() + 3600000).toISOString() },
rateLimit: { limit: 5000, remaining: 4990, resetAt: new Date(Date.now() + 3600000).toISOString() },
},
});
const octokit = makeIssueOctokit(async () => {
Expand Down Expand Up @@ -563,7 +563,7 @@ function makeGraphqlPRResponse(nodes = [graphqlPRNode], hasNextPage = false, iss
pageInfo: { hasNextPage, endCursor: hasNextPage ? "cursor1" : null },
nodes,
},
rateLimit: { remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
rateLimit: { limit: 5000, remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
};
}

Expand Down Expand Up @@ -714,7 +714,7 @@ describe("fetchPullRequests", () => {
// Fork fallback query
return {
fork0: { object: { statusCheckRollup: { state: "SUCCESS" } } },
rateLimit: { remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
rateLimit: { limit: 5000, remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
};
});

Expand Down Expand Up @@ -788,7 +788,7 @@ describe("fetchPullRequests", () => {
pageInfo: { hasNextPage: true, endCursor: null }, // degenerate response
nodes: [{ ...graphqlPRNode, databaseId: callCount }],
},
rateLimit: { remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
rateLimit: { limit: 5000, remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
};
});

Expand Down Expand Up @@ -874,7 +874,7 @@ describe("fetchPullRequests", () => {
data: {
fork0: { object: { statusCheckRollup: { state: "SUCCESS" } } },
// fork1 is missing — that fork repo was deleted/inaccessible
rateLimit: { remaining: 4990, resetAt: new Date(Date.now() + 3600000).toISOString() },
rateLimit: { limit: 5000, remaining: 4990, resetAt: new Date(Date.now() + 3600000).toISOString() },
},
});
});
Expand Down Expand Up @@ -951,7 +951,7 @@ describe("fetchPullRequests", () => {
pageInfo: { hasNextPage: true, endCursor: "cursor-partial" },
nodes: [{ ...graphqlPRNode, databaseId: 77 }],
},
rateLimit: { remaining: 4990, resetAt: new Date(Date.now() + 3600000).toISOString() },
rateLimit: { limit: 5000, remaining: 4990, resetAt: new Date(Date.now() + 3600000).toISOString() },
},
});
const octokit = makePROctokit(async (_query, variables) => {
Expand Down Expand Up @@ -1038,7 +1038,7 @@ describe("fetchPullRequests", () => {
}
// Fork fallback queries
const response: Record<string, unknown> = {
rateLimit: { remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
rateLimit: { limit: 5000, remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
};
const indices = Object.keys(variables as Record<string, unknown>)
.filter((k) => k.startsWith("owner"))
Expand Down
Loading
Loading