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
4 changes: 2 additions & 2 deletions src/app/components/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { expireToken, user, onAuthCleared, DASHBOARD_STORAGE_KEY } from "../../s
import { updateRelaySnapshot } from "../../lib/mcp-relay";
import { pushNotification } from "../../lib/errors";
import { getClient, getGraphqlRateLimit, fetchRateLimitDetails } from "../../services/github";
import { formatCount, prSizeCategory } from "../../lib/format";
import { formatCount, prSizeCategory, rateLimitCssClass } from "../../lib/format";
import { setsEqual } from "../../lib/collections";
import { withScrollLock } from "../../lib/scroll";
import { Tooltip } from "../shared/Tooltip";
Expand Down Expand Up @@ -925,7 +925,7 @@ export default function DashboardPage() {
{(rl) => (
<div onPointerEnter={fetchAndSetRlDetail} onFocusIn={fetchAndSetRlDetail}>
<Tooltip content={rlDetail()} placement="left" focusable contentClass="whitespace-pre font-mono text-xs">
<span class={`tabular-nums ${rl().remaining < rl().limit * 0.1 ? "text-warning" : ""}`}>
<span class={`tabular-nums ${rateLimitCssClass(rl().remaining, rl().limit)}`}>
API RL: {rl().remaining.toLocaleString()}/{formatCount(rl().limit)}/hr
</span>
</Tooltip>
Expand Down
16 changes: 8 additions & 8 deletions src/app/components/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,7 @@ export default function SettingsPage() {
<tr>
<th>Source</th>
<th>Pool</th>
<th>Calls</th>
<th>Usage</th>
<th>Last Called</th>
</tr>
</thead>
Expand Down Expand Up @@ -468,7 +468,7 @@ export default function SettingsPage() {
onClick={() => resetUsageData()}
class="btn btn-xs btn-ghost"
>
Reset counts
Reset usage
</button>
</div>
</div>
Expand Down Expand Up @@ -514,7 +514,7 @@ export default function SettingsPage() {
</SettingRow>
</Section>

{/* Section 5: Notifications */}
{/* Section 6: Notifications */}
<Section title="Notifications">
<SettingRow
label="Enable notifications"
Expand Down Expand Up @@ -606,7 +606,7 @@ export default function SettingsPage() {
</SettingRow>
</Section>

{/* Section 6: Appearance */}
{/* Section 7: Appearance */}
<Section title="Appearance">
<div class="px-4 py-2 border-b border-base-300">
<p class="text-sm font-medium text-base-content mb-2">Theme</p>
Expand Down Expand Up @@ -634,7 +634,7 @@ export default function SettingsPage() {
</SettingRow>
</Section>

{/* Section 7: Tabs */}
{/* Section 8: Tabs */}
<Section title="Tabs">
<SettingRow
label="Default tab"
Expand Down Expand Up @@ -691,15 +691,15 @@ export default function SettingsPage() {
</SettingRow>
</Section>

{/* Section 8: Custom Tabs */}
{/* Section 9: Custom Tabs */}
<Section title="Custom Tabs" description="Create custom views with saved filters and scoping">
<CustomTabsSection
availableOrgs={[...new Set(config.selectedRepos.map((r) => r.owner))]}
availableRepos={config.selectedRepos}
/>
</Section>

{/* Section 9: MCP Server Relay */}
{/* Section 10: MCP Server Relay */}
<Section
title="MCP Server Relay"
description="Allow a local MCP server to read dashboard data. Enable this if you use Claude Code or another AI client with the GitHub Tracker MCP server."
Expand Down Expand Up @@ -754,7 +754,7 @@ export default function SettingsPage() {
</Show>
</Section>

{/* Section 10: Data */}
{/* Section 11: Data */}
<Section title="Data">
{/* Authentication method */}
<SettingRow
Expand Down
6 changes: 6 additions & 0 deletions src/app/lib/format.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
// Re-exports from shared/format for backward compat with existing importers.
export { relativeTime, shortRelativeTime, labelTextColor, formatDuration, prSizeCategory, deriveInvolvementRoles, formatCount, formatStarCount } from "../../shared/format";

export function rateLimitCssClass(remaining: number, limit: number): string {
if (remaining === 0) return "text-error";
if (remaining < limit * 0.1) return "text-warning";
return "";
}

/** Format scope counts as "N org(s), M repo(s)". When elideZero is true, omit zero-count segments. */
export function formatScopeSummary(orgCount: number, repoCount: number, elideZero = false): string {
if (orgCount === 0 && repoCount === 0) return "All repos";
Expand Down
2 changes: 1 addition & 1 deletion src/app/services/api-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ export function deriveSource(info: ApiRequestInfo): ApiCallSource {
onApiRequest((info) => {
const source = deriveSource(info);
const pool: ApiPool = info.isGraphql ? "graphql" : "core";
trackApiCall(source, pool);
trackApiCall(source, pool, info.graphqlCost ?? 1);
// Both pools tracked — Math.max keeps latest; may delay reset of the earlier pool's records
if (info.resetEpochMs !== null) updateResetAt(info.resetEpochMs);
});
36 changes: 27 additions & 9 deletions src/app/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type { Issue, PullRequest, WorkflowRun, RepoRef, RepoEntry, OrgEntry, Che
// ── Types ────────────────────────────────────────────────────────────────────

interface GraphQLRateLimit {
cost?: number;
limit: number;
remaining: number;
resetAt: string;
Expand Down Expand Up @@ -226,7 +227,7 @@ const ISSUES_SEARCH_QUERY = `
}
}
}
rateLimit { limit remaining resetAt }
rateLimit { cost limit remaining resetAt }
}
${LIGHT_ISSUE_FRAGMENT}
`;
Expand Down Expand Up @@ -287,7 +288,7 @@ const LIGHT_COMBINED_SEARCH_QUERY = `
}
}
}
rateLimit { limit remaining resetAt }
rateLimit { cost limit remaining resetAt }
}
${LIGHT_ISSUE_FRAGMENT}
${LIGHT_PR_FRAGMENT}
Expand Down Expand Up @@ -318,7 +319,7 @@ const UNFILTERED_SEARCH_QUERY = `
}
}
}
rateLimit { limit remaining resetAt }
rateLimit { cost limit remaining resetAt }
}
${LIGHT_ISSUE_FRAGMENT}
${LIGHT_PR_FRAGMENT}
Expand Down Expand Up @@ -350,7 +351,7 @@ const LIGHT_PR_SEARCH_QUERY = `
}
}
}
rateLimit { limit remaining resetAt }
rateLimit { cost limit remaining resetAt }
}
${LIGHT_PR_FRAGMENT}
`;
Expand Down Expand Up @@ -395,7 +396,7 @@ const HEAVY_PR_BACKFILL_QUERY = `
}
}
}
rateLimit { limit remaining resetAt }
rateLimit { cost limit remaining resetAt }
}
`;

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

Expand Down Expand Up @@ -673,7 +674,7 @@ async function runForkPRFallback(
);
}

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

try {
const forkResponse = await octokit.graphql<ForkQueryResponse>(forkQuery, { ...variables, request: { apiSource: "forkCheck" } });
Expand All @@ -688,9 +689,13 @@ async function runForkPRFallback(
if (pr) pr.checkStatus = mapCheckStatus(state);
}
} catch (err) {
const partialData = (err && typeof err === "object" && "data" in err && err.data && typeof err.data === "object")
? err.data as Record<string, ForkRepoResult | null | undefined>
const errDataRaw = (err && typeof err === "object" && "data" in err && err.data && typeof err.data === "object")
? err.data as Record<string, unknown>
: null;
if (errDataRaw?.rateLimit) {
updateGraphqlRateLimit(errDataRaw.rateLimit as GraphQLRateLimit);
}
const partialData = errDataRaw as Record<string, ForkRepoResult | null | undefined> | null;

if (partialData) {
for (let i = 0; i < forkChunk.length; i++) {
Expand Down Expand Up @@ -1130,6 +1135,12 @@ export async function fetchPREnrichment(
});
}
} catch (err) {
const partialErr = (err && typeof err === "object" && "data" in err && err.data && typeof err.data === "object")
? err.data as Partial<HeavyBackfillResponse>
: null;
if (partialErr?.rateLimit) {
updateGraphqlRateLimit(partialErr.rateLimit);
}
const { statusCode, message } = extractRejectionError(err);
errors.push({
repo: `backfill-batch-${batchIdx + 1}/${batches.length}`,
Expand Down Expand Up @@ -1686,6 +1697,13 @@ export async function fetchHotPRStatus(
hadErrors = true;
console.warn("[hot-poll] PR status batch failed:", s.reason);
Sentry.captureException(s.reason, { tags: { source: "hot-poll-pr-batch" } });
const reason = s.reason;
const partialErr = (reason && typeof reason === "object" && "data" in reason && reason.data && typeof reason.data === "object")
? reason.data as Partial<HotPRStatusResponse>
: null;
if (partialErr?.rateLimit) {
updateGraphqlRateLimit(partialErr.rateLimit);
}
}
}

Expand Down
46 changes: 40 additions & 6 deletions src/app/services/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export interface ApiRequestInfo {
apiSource?: string;
/** x-ratelimit-reset converted to ms, or null if unavailable */
resetEpochMs: number | null;
/** GraphQL query point cost from response body, or undefined for REST */
graphqlCost?: number;
}

const _requestCallbacks: Array<(info: ApiRequestInfo) => void> = [];
Expand Down Expand Up @@ -147,29 +149,57 @@ export function createGitHubClient(token: string): GitHubOctokitInstance {
// Fire callbacks even on errors — these are real API calls.
// Octokit's RequestError includes response.headers for HTTP errors (403, 404, etc.)
// so we can still extract x-ratelimit-reset when available.
const errResponse = (err as { response?: { headers?: Record<string, string> } }).response;
if (status > 0) {
let resetEpochMs: number | null = null;
const errResponse = (err as { response?: { headers?: Record<string, string> } }).response;
const errResetHeader = errResponse?.headers?.["x-ratelimit-reset"];
if (errResetHeader) resetEpochMs = parseInt(errResetHeader, 10) * 1000;
// Octokit errors store data in two shapes: err.data has the unwrapped GraphQL
// data object, while errResponse.data is the raw HTTP body (GraphQL envelope)
const errGraphqlCost = isGraphql
? ((err as { data?: { rateLimit?: { cost?: number } } }).data?.rateLimit?.cost
?? (errResponse as { data?: { data?: { rateLimit?: { cost?: number } } } } | undefined)?.data?.data?.rateLimit?.cost)
: undefined;
const info: ApiRequestInfo = {
url: options.url, method, status, isGraphql, apiSource, resetEpochMs,
url: options.url, method, status, isGraphql, apiSource, resetEpochMs, graphqlCost: errGraphqlCost,
};
for (const cb of _requestCallbacks) { try { cb(info); } catch { /* swallow */ } }
}
const errHeaders = errResponse?.headers as Record<string, string> | undefined;
if (errHeaders) {
try {
if (isGraphql) {
const remaining = errHeaders["x-ratelimit-remaining"];
const reset = errHeaders["x-ratelimit-reset"];
const limit = errHeaders["x-ratelimit-limit"];
if (remaining !== undefined && reset !== undefined) {
_setGraphqlRateLimit({
limit: safePositiveNumber(limit !== undefined ? parseInt(limit, 10) : NaN, _graphqlRateLimit()?.limit ?? 5000),
remaining: Number.isFinite(parseInt(remaining, 10)) ? parseInt(remaining, 10) : 0,
resetAt: new Date(parseInt(reset, 10) * 1000),
});
}
} else {
updateRateLimitFromHeaders(errHeaders);
}
} catch { /* never mask the original error */ }
}
throw err;
}

// Success path — fire callbacks (api-usage.ts registers at module scope) and update RL display
const headers = (response.headers ?? {}) as Record<string, string>;
const resetHeader = headers["x-ratelimit-reset"];
const resetEpochMs = resetHeader ? parseInt(resetHeader, 10) * 1000 : null;
const graphqlCost = isGraphql
? (response.data as { data?: { rateLimit?: { cost?: number } } })?.data?.rateLimit?.cost ?? undefined
: undefined;
const info: ApiRequestInfo = {
url: options.url, method, status, isGraphql, apiSource, resetEpochMs,
url: options.url, method, status, isGraphql, apiSource, resetEpochMs, graphqlCost,
};
for (const cb of _requestCallbacks) { try { cb(info); } catch { /* swallow */ } }

if (response.headers) {
if (response.headers && !isGraphql) {
updateRateLimitFromHeaders(response.headers as Record<string, string>);
}

Expand Down Expand Up @@ -283,13 +313,15 @@ let _lastFetchResult: { core: RateLimitInfo; graphql: RateLimitInfo } | null = n
* GET /rate_limit is free — not counted against rate limits by GitHub.
* Returns null if client unavailable or request fails.
*/
export async function fetchRateLimitDetails(): Promise<{ core: RateLimitInfo; graphql: RateLimitInfo } | null> {
export async function fetchRateLimitDetails(clientOverride?: GitHubOctokitInstance): Promise<{ core: RateLimitInfo; graphql: RateLimitInfo } | null> {
// Return cached result within 5-second staleness window
if (_lastFetchResult !== null && Date.now() - _lastFetchTime < 5000) {
_setCoreRateLimit(_lastFetchResult.core);
_setGraphqlRateLimit(_lastFetchResult.graphql);
return { ..._lastFetchResult };
}

const client = getClient();
const client = clientOverride ?? getClient();
if (!client) return null;

try {
Expand All @@ -310,6 +342,8 @@ export async function fetchRateLimitDetails(): Promise<{ core: RateLimitInfo; gr
};
_lastFetchTime = Date.now();
_lastFetchResult = result;
_setCoreRateLimit(result.core);
_setGraphqlRateLimit(result.graphql);
return { ...result };
} catch {
return null;
Expand Down
5 changes: 4 additions & 1 deletion src/app/services/poll.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createSignal, createEffect, createRoot, untrack, onCleanup } from "solid-js";
import * as Sentry from "@sentry/solid";
import { getClient } from "./github";
import { getClient, fetchRateLimitDetails } from "./github";
import { config } from "../stores/config";
import { user, onAuthCleared } from "../stores/auth";
import { checkAndResetIfExpired } from "./api-usage";
Expand Down Expand Up @@ -357,6 +357,9 @@ export function createPollCoordinator(
if (destroyed || isRefreshing()) return;
checkAndResetIfExpired();
setIsRefreshing(true);
// Fire-and-forget: seeds footer signals concurrently with fetchAll. If GET /rate_limit
// resolves after a GraphQL response, the footer briefly shows pre-query remaining (cosmetic).
void fetchRateLimitDetails();

// Snapshot sources of notifications from previous cycle (for reconciliation)
const previousSources = new Set(
Expand Down
24 changes: 24 additions & 0 deletions tests/components/dashboard/DashboardPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import { rateLimitCssClass } from "../../../src/app/lib/format";

describe("rateLimitCssClass", () => {
it("remaining: 0 gives text-error", () => {
expect(rateLimitCssClass(0, 5000)).toBe("text-error");
});

it("remaining < 10% of limit gives text-warning", () => {
expect(rateLimitCssClass(100, 5000)).toBe("text-warning");
});

it("remaining >= 10% of limit gives empty string", () => {
expect(rateLimitCssClass(3000, 5000)).toBe("");
});

it("remaining exactly at 10% threshold gives empty string (strict less-than)", () => {
expect(rateLimitCssClass(500, 5000)).toBe("");
});

it("remaining just below 10% threshold gives text-warning", () => {
expect(rateLimitCssClass(499, 5000)).toBe("text-warning");
});
});
8 changes: 4 additions & 4 deletions tests/components/settings/ApiUsageSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -274,18 +274,18 @@ describe("ApiUsageSection — reset button", () => {
mockGetUsageResetAt.mockReturnValue(null);
});

it("renders the 'Reset counts' button", () => {
it("renders the 'Reset usage' button", () => {
renderSettings();
expect(screen.getByText("Reset counts")).toBeTruthy();
expect(screen.getByText("Reset usage")).toBeTruthy();
});

it("calls resetUsageData() when 'Reset counts' button is clicked", () => {
it("calls resetUsageData() when 'Reset usage' button is clicked", () => {
// Wire the mock to clear snapshot on reset, simulating real behavior
mockResetUsageData.mockImplementation(() => {
mockGetUsageSnapshot.mockReturnValue([]);
});
renderSettings();
const btn = screen.getByText("Reset counts");
const btn = screen.getByText("Reset usage");
fireEvent.click(btn);
expect(mockResetUsageData).toHaveBeenCalledOnce();
});
Expand Down
Loading