Skip to content

Commit bdcd6db

Browse files
authored
feat: adds hot poll for in-flight CI and workflow status (#30)
* feat(api): adds nodeId to PullRequest and hot poll query functions * feat(config): adds hotPollInterval setting with 10-120s range * feat(poll): adds hot poll coordinator with hot item set management * feat(dashboard): integrates hot poll coordinator with store updates * test(hot-poll): adds hot poll and config validation tests * fix(hot-poll): addresses review findings and adds coverage * fix(poll): moves hot set cap check after qualification filter * fix(poll): guards eviction against stale generation * fix(hot-poll): logs batch and cycle errors instead of swallowing * fix(hot-poll): adds failure backoff and missing test coverage * chore(hot-poll): documents backoff design tradeoff * fix(hot-poll): wires backoff to actual errors, clears hot sets on unmount * fix(hot-poll): wires hadErrors through fetchHotPRStatus to backoff * fix(dashboard): copies nodeId in phaseOne enrichment merge * fix(hot-poll): addresses all PR review findings * fix(hot-poll): addresses domain review findings from quality gate * fix(hot-poll): surfaces hadErrors via pushError for user visibility * fix(hot-poll): auto-dismisses error notification on recovery
1 parent 73e401f commit bdcd6db

File tree

10 files changed

+1683
-29
lines changed

10 files changed

+1683
-29
lines changed

src/app/components/dashboard/DashboardPage.tsx

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,23 @@ import { config, setConfig } from "../../stores/config";
1010
import { viewState, updateViewState } from "../../stores/view";
1111
import type { Issue, PullRequest, WorkflowRun } from "../../services/api";
1212
import { fetchOrgs } from "../../services/api";
13-
import { createPollCoordinator, fetchAllData, type DashboardData } from "../../services/poll";
13+
import {
14+
createPollCoordinator,
15+
createHotPollCoordinator,
16+
rebuildHotSets,
17+
clearHotSets,
18+
getHotPollGeneration,
19+
fetchAllData,
20+
type DashboardData,
21+
} from "../../services/poll";
1422
import { clearAuth, user, onAuthCleared, DASHBOARD_STORAGE_KEY } from "../../stores/auth";
1523
import { getClient, getGraphqlRateLimit } from "../../services/github";
1624

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

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

2132
interface DashboardStore {
@@ -72,6 +83,11 @@ onAuthCleared(() => {
7283
coord.destroy();
7384
_setCoordinator(null);
7485
}
86+
const hotCoord = _hotCoordinator();
87+
if (hotCoord) {
88+
hotCoord.destroy();
89+
_setHotCoordinator(null);
90+
}
7591
});
7692

7793
async function pollFetch(): Promise<DashboardData> {
@@ -140,6 +156,7 @@ async function pollFetch(): Promise<DashboardData> {
140156
pr.reviewThreads = e.reviewThreads;
141157
pr.totalReviewCount = e.totalReviewCount;
142158
pr.enriched = e.enriched;
159+
pr.nodeId = e.nodeId;
143160
}
144161
} else {
145162
state.pullRequests = data.pullRequests;
@@ -157,6 +174,7 @@ async function pollFetch(): Promise<DashboardData> {
157174
lastRefreshedAt: now,
158175
});
159176
}
177+
rebuildHotSets(data);
160178
// Persist for stale-while-revalidate on full page reload.
161179
// Errors are transient and not persisted. Deferred to avoid blocking paint.
162180
const cachePayload = {
@@ -198,6 +216,7 @@ async function pollFetch(): Promise<DashboardData> {
198216
}
199217

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

202221
export default function DashboardPage() {
203222

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

242+
if (!_hotCoordinator()) {
243+
_setHotCoordinator(createHotPollCoordinator(
244+
() => config.hotPollInterval,
245+
(prUpdates, runUpdates, fetchGeneration) => {
246+
// Guard against stale hot poll results overlapping with a full refresh.
247+
// fetchGeneration was captured BEFORE fetchHotData() started its async work.
248+
// If a full refresh completed during the fetch, _hotPollGeneration will have
249+
// been incremented by rebuildHotSets(), and fetchGeneration will be stale.
250+
if (fetchGeneration !== getHotPollGeneration()) return; // stale, discard
251+
setDashboardData(produce((state) => {
252+
// Apply PR status updates
253+
for (const pr of state.pullRequests) {
254+
const update = prUpdates.get(pr.id);
255+
if (!update) continue;
256+
pr.state = update.state; // detect closed/merged quickly
257+
pr.checkStatus = update.checkStatus;
258+
pr.reviewDecision = update.reviewDecision;
259+
}
260+
// Apply workflow run updates
261+
for (const run of state.workflowRuns) {
262+
const update = runUpdates.get(run.id);
263+
if (!update) continue;
264+
run.status = update.status;
265+
run.conclusion = update.conclusion;
266+
run.updatedAt = update.updatedAt;
267+
run.completedAt = update.completedAt;
268+
}
269+
}));
270+
}
271+
));
272+
}
273+
223274
// Auto-sync orgs on dashboard load — picks up newly accessible orgs
224275
// after re-auth, scope changes, or org policy updates.
225276
// Only adds orgs to the filter list — repos are user-selected via Settings.
@@ -242,6 +293,9 @@ export default function DashboardPage() {
242293
onCleanup(() => {
243294
_coordinator()?.destroy();
244295
_setCoordinator(null);
296+
_hotCoordinator()?.destroy();
297+
_setHotCoordinator(null);
298+
clearHotSets();
245299
});
246300
});
247301

src/app/components/settings/SettingsPage.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export default function SettingsPage() {
135135
selectedOrgs: config.selectedOrgs,
136136
selectedRepos: config.selectedRepos,
137137
refreshInterval: config.refreshInterval,
138+
hotPollInterval: config.hotPollInterval,
138139
maxWorkflowsPerRepo: config.maxWorkflowsPerRepo,
139140
maxRunsPerWorkflow: config.maxRunsPerWorkflow,
140141
notifications: config.notifications,
@@ -332,6 +333,24 @@ export default function SettingsPage() {
332333
))}
333334
</select>
334335
</SettingRow>
336+
<SettingRow
337+
label="CI status refresh"
338+
description="How often to re-check in-flight CI checks and workflow runs (10-120s)"
339+
>
340+
<input
341+
type="number"
342+
min={10}
343+
max={120}
344+
value={config.hotPollInterval}
345+
onInput={(e) => {
346+
const val = parseInt(e.currentTarget.value, 10);
347+
if (!isNaN(val) && val >= 10 && val <= 120) {
348+
saveWithFeedback({ hotPollInterval: val });
349+
}
350+
}}
351+
class="input input-sm w-20"
352+
/>
353+
</SettingRow>
335354
</Section>
336355

337356
{/* Section 3: GitHub Actions */}

src/app/services/api.ts

Lines changed: 131 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getClient, cachedRequest, updateGraphqlRateLimit } from "./github";
1+
import { getClient, cachedRequest, updateGraphqlRateLimit, updateRateLimitFromHeaders } from "./github";
22
import { pushNotification } from "../lib/errors";
33

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

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

548+
/** Hot-poll query: fetches current status fields for a batch of PR node IDs. */
549+
const HOT_PR_STATUS_QUERY = `
550+
query($ids: [ID!]!) {
551+
nodes(ids: $ids) {
552+
... on PullRequest {
553+
databaseId
554+
state
555+
mergeStateStatus
556+
reviewDecision
557+
commits(last: 1) {
558+
nodes {
559+
commit {
560+
statusCheckRollup { state }
561+
}
562+
}
563+
}
564+
}
565+
}
566+
rateLimit { remaining resetAt }
567+
}
568+
`;
569+
570+
interface HotPRStatusNode {
571+
databaseId: number;
572+
state: string;
573+
mergeStateStatus: string;
574+
reviewDecision: string | null;
575+
commits: { nodes: { commit: { statusCheckRollup: { state: string } | null } }[] };
576+
}
577+
578+
interface HotPRStatusResponse {
579+
nodes: (HotPRStatusNode | null)[];
580+
rateLimit?: { remaining: number; resetAt: string };
581+
}
582+
546583
interface GraphQLLightPRNode {
547584
id: string; // GraphQL global node ID
548585
databaseId: number;
@@ -870,6 +907,7 @@ function processLightPRNode(
870907
reviewDecision: mapReviewDecision(node.reviewDecision),
871908
totalReviewCount: 0,
872909
enriched: false,
910+
nodeId: node.id,
873911
});
874912
return true;
875913
}
@@ -1715,3 +1753,94 @@ export async function fetchWorkflowRuns(
17151753

17161754
return { workflowRuns: allRuns, errors: allErrors };
17171755
}
1756+
1757+
// ── Hot poll: targeted status updates ────────────────────────────────────────
1758+
1759+
export interface HotPRStatusUpdate {
1760+
state: string;
1761+
checkStatus: CheckStatus["status"];
1762+
mergeStateStatus: string;
1763+
reviewDecision: PullRequest["reviewDecision"];
1764+
}
1765+
1766+
/**
1767+
* Fetches current status fields (check status, review decision, state) for a
1768+
* batch of PR node IDs using the nodes() GraphQL query. Returns a map keyed
1769+
* by databaseId. Uses Promise.allSettled per batch for error resilience.
1770+
*/
1771+
export async function fetchHotPRStatus(
1772+
octokit: GitHubOctokit,
1773+
nodeIds: string[]
1774+
): Promise<{ results: Map<number, HotPRStatusUpdate>; hadErrors: boolean }> {
1775+
const results = new Map<number, HotPRStatusUpdate>();
1776+
if (nodeIds.length === 0) return { results, hadErrors: false };
1777+
1778+
const batches = chunkArray(nodeIds, NODES_BATCH_SIZE);
1779+
let hadErrors = false;
1780+
const settled = await Promise.allSettled(batches.map(async (batch) => {
1781+
const response = await octokit.graphql<HotPRStatusResponse>(HOT_PR_STATUS_QUERY, { ids: batch });
1782+
if (response.rateLimit) updateGraphqlRateLimit(response.rateLimit);
1783+
1784+
for (const node of response.nodes) {
1785+
if (!node || node.databaseId == null) continue;
1786+
1787+
let checkStatus = mapCheckStatus(node.commits.nodes[0]?.commit?.statusCheckRollup?.state ?? null);
1788+
const mss = node.mergeStateStatus;
1789+
if (mss === "DIRTY" || mss === "BEHIND") {
1790+
checkStatus = "conflict";
1791+
} else if (mss === "UNSTABLE") {
1792+
checkStatus = "failure";
1793+
}
1794+
1795+
results.set(node.databaseId, {
1796+
state: node.state,
1797+
checkStatus,
1798+
mergeStateStatus: node.mergeStateStatus,
1799+
reviewDecision: mapReviewDecision(node.reviewDecision),
1800+
});
1801+
}
1802+
}));
1803+
1804+
for (const s of settled) {
1805+
if (s.status === "rejected") {
1806+
hadErrors = true;
1807+
console.warn("[hot-poll] PR status batch failed:", s.reason);
1808+
}
1809+
}
1810+
1811+
return { results, hadErrors };
1812+
}
1813+
1814+
export interface HotWorkflowRunUpdate {
1815+
id: number;
1816+
status: string;
1817+
conclusion: string | null;
1818+
updatedAt: string;
1819+
completedAt: string | null;
1820+
}
1821+
1822+
/**
1823+
* Fetches current status for a single workflow run by ID.
1824+
* Used by hot-poll to refresh in-progress runs without a full re-fetch.
1825+
*/
1826+
export async function fetchWorkflowRunById(
1827+
octokit: GitHubOctokit,
1828+
descriptor: { id: number; owner: string; repo: string }
1829+
): Promise<HotWorkflowRunUpdate> {
1830+
const { id, owner, repo } = descriptor;
1831+
const response = await octokit.request("GET /repos/{owner}/{repo}/actions/runs/{run_id}", {
1832+
owner,
1833+
repo,
1834+
run_id: id,
1835+
});
1836+
updateRateLimitFromHeaders(response.headers as Record<string, string>);
1837+
// Octokit's generated type for this endpoint omits completed_at; cast to our full raw shape
1838+
const run = response.data as unknown as RawWorkflowRun;
1839+
return {
1840+
id: run.id,
1841+
status: run.status ?? "",
1842+
conclusion: run.conclusion ?? null,
1843+
updatedAt: run.updated_at,
1844+
completedAt: run.completed_at ?? null,
1845+
};
1846+
}

0 commit comments

Comments
 (0)