Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e5d8493
feat(api): adds nodeId to PullRequest and hot poll query functions
wgordon17 Mar 29, 2026
9497843
feat(config): adds hotPollInterval setting with 10-120s range
wgordon17 Mar 29, 2026
4087e55
feat(poll): adds hot poll coordinator with hot item set management
wgordon17 Mar 29, 2026
a391e79
feat(dashboard): integrates hot poll coordinator with store updates
wgordon17 Mar 29, 2026
b1fc4fb
test(hot-poll): adds hot poll and config validation tests
wgordon17 Mar 29, 2026
4f1479a
fix(hot-poll): addresses review findings and adds coverage
wgordon17 Mar 29, 2026
eb9cdc3
fix(poll): moves hot set cap check after qualification filter
wgordon17 Mar 29, 2026
cbbf130
fix(poll): guards eviction against stale generation
wgordon17 Mar 29, 2026
0561d7a
fix(hot-poll): logs batch and cycle errors instead of swallowing
wgordon17 Mar 29, 2026
af7f6c9
fix(hot-poll): adds failure backoff and missing test coverage
wgordon17 Mar 29, 2026
e23bfd3
chore(hot-poll): documents backoff design tradeoff
wgordon17 Mar 29, 2026
4ef20f2
fix(hot-poll): wires backoff to actual errors, clears hot sets on unm…
wgordon17 Mar 29, 2026
4289d50
fix(hot-poll): wires hadErrors through fetchHotPRStatus to backoff
wgordon17 Mar 29, 2026
a886ebe
fix(dashboard): copies nodeId in phaseOne enrichment merge
wgordon17 Mar 29, 2026
6b75091
fix(hot-poll): addresses all PR review findings
wgordon17 Mar 29, 2026
cb6ce9e
fix(hot-poll): addresses domain review findings from quality gate
wgordon17 Mar 29, 2026
ec6756a
fix(hot-poll): surfaces hadErrors via pushError for user visibility
wgordon17 Mar 29, 2026
2391424
fix(hot-poll): auto-dismisses error notification on recovery
wgordon17 Mar 29, 2026
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
56 changes: 55 additions & 1 deletion src/app/components/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,23 @@ import { config, setConfig } from "../../stores/config";
import { viewState, updateViewState } from "../../stores/view";
import type { Issue, PullRequest, WorkflowRun } from "../../services/api";
import { fetchOrgs } from "../../services/api";
import { createPollCoordinator, fetchAllData, type DashboardData } from "../../services/poll";
import {
createPollCoordinator,
createHotPollCoordinator,
rebuildHotSets,
clearHotSets,
getHotPollGeneration,
fetchAllData,
type DashboardData,
} from "../../services/poll";
import { clearAuth, user, onAuthCleared, DASHBOARD_STORAGE_KEY } from "../../stores/auth";
import { getClient, getGraphqlRateLimit } from "../../services/github";

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

// Bump only for breaking schema changes (renames, type changes). Additive optional
// fields (e.g., nodeId?: string) don't require a bump — missing fields deserialize
// as undefined, which consuming code handles gracefully.
const CACHE_VERSION = 2;

interface DashboardStore {
Expand Down Expand Up @@ -72,6 +83,11 @@ onAuthCleared(() => {
coord.destroy();
_setCoordinator(null);
}
const hotCoord = _hotCoordinator();
if (hotCoord) {
hotCoord.destroy();
_setHotCoordinator(null);
}
});

async function pollFetch(): Promise<DashboardData> {
Expand Down Expand Up @@ -140,6 +156,7 @@ async function pollFetch(): Promise<DashboardData> {
pr.reviewThreads = e.reviewThreads;
pr.totalReviewCount = e.totalReviewCount;
pr.enriched = e.enriched;
pr.nodeId = e.nodeId;
}
} else {
state.pullRequests = data.pullRequests;
Expand All @@ -157,6 +174,7 @@ async function pollFetch(): Promise<DashboardData> {
lastRefreshedAt: now,
});
}
rebuildHotSets(data);
// Persist for stale-while-revalidate on full page reload.
// Errors are transient and not persisted. Deferred to avoid blocking paint.
const cachePayload = {
Expand Down Expand Up @@ -198,6 +216,7 @@ async function pollFetch(): Promise<DashboardData> {
}

const [_coordinator, _setCoordinator] = createSignal<ReturnType<typeof createPollCoordinator> | null>(null);
const [_hotCoordinator, _setHotCoordinator] = createSignal<{ destroy: () => void } | null>(null);

export default function DashboardPage() {

Expand All @@ -220,6 +239,38 @@ export default function DashboardPage() {
_setCoordinator(createPollCoordinator(() => config.refreshInterval, pollFetch));
}

if (!_hotCoordinator()) {
_setHotCoordinator(createHotPollCoordinator(
() => config.hotPollInterval,
(prUpdates, runUpdates, fetchGeneration) => {
// Guard against stale hot poll results overlapping with a full refresh.
// fetchGeneration was captured BEFORE fetchHotData() started its async work.
// If a full refresh completed during the fetch, _hotPollGeneration will have
// been incremented by rebuildHotSets(), and fetchGeneration will be stale.
if (fetchGeneration !== getHotPollGeneration()) return; // stale, discard
setDashboardData(produce((state) => {
// Apply PR status updates
for (const pr of state.pullRequests) {
const update = prUpdates.get(pr.id);
if (!update) continue;
pr.state = update.state; // detect closed/merged quickly
pr.checkStatus = update.checkStatus;
pr.reviewDecision = update.reviewDecision;
}
// Apply workflow run updates
for (const run of state.workflowRuns) {
const update = runUpdates.get(run.id);
if (!update) continue;
run.status = update.status;
run.conclusion = update.conclusion;
run.updatedAt = update.updatedAt;
run.completedAt = update.completedAt;
}
}));
}
));
}

// Auto-sync orgs on dashboard load — picks up newly accessible orgs
// after re-auth, scope changes, or org policy updates.
// Only adds orgs to the filter list — repos are user-selected via Settings.
Expand All @@ -242,6 +293,9 @@ export default function DashboardPage() {
onCleanup(() => {
_coordinator()?.destroy();
_setCoordinator(null);
_hotCoordinator()?.destroy();
_setHotCoordinator(null);
clearHotSets();
});
});

Expand Down
19 changes: 19 additions & 0 deletions src/app/components/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export default function SettingsPage() {
selectedOrgs: config.selectedOrgs,
selectedRepos: config.selectedRepos,
refreshInterval: config.refreshInterval,
hotPollInterval: config.hotPollInterval,
maxWorkflowsPerRepo: config.maxWorkflowsPerRepo,
maxRunsPerWorkflow: config.maxRunsPerWorkflow,
notifications: config.notifications,
Expand Down Expand Up @@ -332,6 +333,24 @@ export default function SettingsPage() {
))}
</select>
</SettingRow>
<SettingRow
label="CI status refresh"
description="How often to re-check in-flight CI checks and workflow runs (10-120s)"
>
<input
type="number"
min={10}
max={120}
value={config.hotPollInterval}
onInput={(e) => {
const val = parseInt(e.currentTarget.value, 10);
if (!isNaN(val) && val >= 10 && val <= 120) {
saveWithFeedback({ hotPollInterval: val });
}
}}
class="input input-sm w-20"
/>
</SettingRow>
</Section>

{/* Section 3: GitHub Actions */}
Expand Down
133 changes: 131 additions & 2 deletions src/app/services/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getClient, cachedRequest, updateGraphqlRateLimit } from "./github";
import { getClient, cachedRequest, updateGraphqlRateLimit, updateRateLimitFromHeaders } from "./github";
import { pushNotification } from "../lib/errors";

// ── Types ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -67,6 +67,8 @@ export interface PullRequest {
totalReviewCount: number;
/** False when only light fields are loaded (phase 1); true/undefined when fully enriched */
enriched?: boolean;
/** GraphQL global node ID — used for hot-poll status updates */
nodeId?: string;
}

export interface WorkflowRun {
Expand Down Expand Up @@ -212,7 +214,7 @@ type GitHubOctokit = NonNullable<ReturnType<typeof getClient>>;
* Unlike chunked Promise.allSettled, tasks start immediately as slots free up
* rather than waiting for an entire chunk to finish.
*/
async function pooledAllSettled<T>(
export async function pooledAllSettled<T>(
tasks: (() => Promise<T>)[],
concurrency: number
): Promise<PromiseSettledResult<T>[]> {
Expand Down Expand Up @@ -543,6 +545,41 @@ const HEAVY_PR_BACKFILL_QUERY = `
}
`;

/** Hot-poll query: fetches current status fields for a batch of PR node IDs. */
const HOT_PR_STATUS_QUERY = `
query($ids: [ID!]!) {
nodes(ids: $ids) {
... on PullRequest {
databaseId
state
mergeStateStatus
reviewDecision
commits(last: 1) {
nodes {
commit {
statusCheckRollup { state }
}
}
}
}
}
rateLimit { remaining resetAt }
}
`;

interface HotPRStatusNode {
databaseId: number;
state: string;
mergeStateStatus: string;
reviewDecision: string | null;
commits: { nodes: { commit: { statusCheckRollup: { state: string } | null } }[] };
}

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

interface GraphQLLightPRNode {
id: string; // GraphQL global node ID
databaseId: number;
Expand Down Expand Up @@ -870,6 +907,7 @@ function processLightPRNode(
reviewDecision: mapReviewDecision(node.reviewDecision),
totalReviewCount: 0,
enriched: false,
nodeId: node.id,
});
return true;
}
Expand Down Expand Up @@ -1715,3 +1753,94 @@ export async function fetchWorkflowRuns(

return { workflowRuns: allRuns, errors: allErrors };
}

// ── Hot poll: targeted status updates ────────────────────────────────────────

export interface HotPRStatusUpdate {
state: string;
checkStatus: CheckStatus["status"];
mergeStateStatus: string;
reviewDecision: PullRequest["reviewDecision"];
}

/**
* Fetches current status fields (check status, review decision, state) for a
* batch of PR node IDs using the nodes() GraphQL query. Returns a map keyed
* by databaseId. Uses Promise.allSettled per batch for error resilience.
*/
export async function fetchHotPRStatus(
octokit: GitHubOctokit,
nodeIds: string[]
): Promise<{ results: Map<number, HotPRStatusUpdate>; hadErrors: boolean }> {
const results = new Map<number, HotPRStatusUpdate>();
if (nodeIds.length === 0) return { results, hadErrors: false };

const batches = chunkArray(nodeIds, NODES_BATCH_SIZE);
let hadErrors = false;
const settled = await Promise.allSettled(batches.map(async (batch) => {
const response = await octokit.graphql<HotPRStatusResponse>(HOT_PR_STATUS_QUERY, { ids: batch });
if (response.rateLimit) updateGraphqlRateLimit(response.rateLimit);

for (const node of response.nodes) {
if (!node || node.databaseId == null) continue;

let checkStatus = mapCheckStatus(node.commits.nodes[0]?.commit?.statusCheckRollup?.state ?? null);
const mss = node.mergeStateStatus;
if (mss === "DIRTY" || mss === "BEHIND") {
checkStatus = "conflict";
} else if (mss === "UNSTABLE") {
checkStatus = "failure";
}

results.set(node.databaseId, {
state: node.state,
checkStatus,
mergeStateStatus: node.mergeStateStatus,
reviewDecision: mapReviewDecision(node.reviewDecision),
});
}
}));

for (const s of settled) {
if (s.status === "rejected") {
hadErrors = true;
console.warn("[hot-poll] PR status batch failed:", s.reason);
}
}

return { results, hadErrors };
}

export interface HotWorkflowRunUpdate {
id: number;
status: string;
conclusion: string | null;
updatedAt: string;
completedAt: string | null;
}

/**
* Fetches current status for a single workflow run by ID.
* Used by hot-poll to refresh in-progress runs without a full re-fetch.
*/
export async function fetchWorkflowRunById(
octokit: GitHubOctokit,
descriptor: { id: number; owner: string; repo: string }
): Promise<HotWorkflowRunUpdate> {
const { id, owner, repo } = descriptor;
const response = await octokit.request("GET /repos/{owner}/{repo}/actions/runs/{run_id}", {
owner,
repo,
run_id: id,
});
updateRateLimitFromHeaders(response.headers as Record<string, string>);
// Octokit's generated type for this endpoint omits completed_at; cast to our full raw shape
const run = response.data as unknown as RawWorkflowRun;
return {
id: run.id,
status: run.status ?? "",
conclusion: run.conclusion ?? null,
updatedAt: run.updated_at,
completedAt: run.completed_at ?? null,
};
}
Loading
Loading