From 50dd301cbeb14f4d6cbecc9f92648c7113c7e18c Mon Sep 17 00:00:00 2001 From: SudoMaggie <268191359+sudomaggie@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:17:13 +0800 Subject: [PATCH] feat(status-bar): trace branch PR and CI status Adds a CI menu to the workstation status bar, between the branch and the git sync control: the pull request open for the current branch plus the state of every check on its head commit, grouped failed / running / skipped / passed. It reuses the ports menu's trigger and portalled panel so the two neighbours read as one control family. Polling is bounded rather than periodic. `useBranchPullRequestStatus` gains an opt-in `poll` mode whose schedule comes from `nextBranchCiPollDelayMs`: 15s backing off to a 60s cap while checks run, three 30s retries when a PR has not reported any checks yet, and no timer at all once every check has a verdict, when there is no PR, or when the window is hidden. A new head SHA restarts the backoff so a push is picked up quickly. Opening the menu or the refresh button forces a read past the 45s cache TTL. The focused-chat rail keeps polling off and shares the same cache and in-flight coalescing, so nothing is fetched twice. Check-state derivation moves to `services/git/ciCheckState`, which `PrChecksTab` now shares instead of re-deriving it. "Settled" is structural (every run reported) rather than reading the server roll-up, which short-circuits to failure while other jobs are still in flight. Pre-commit hook ran. Total eslint: 0, total circular: 0 --- src/components/Dropdown/tokens.ts | 31 +- .../git/useBranchPullRequestStatus.test.ts | 91 +++- src/hooks/git/useBranchPullRequestStatus.ts | 92 ++++- src/i18n/locales/en/common.json | 12 + src/i18n/locales/zh/common.json | 12 + .../PullRequestContent/detail/PrChecksTab.tsx | 55 +-- .../shared/StatusBar/CiStatusMenu.tsx | 388 ++++++++++++++++++ .../shared/StatusBar/EditorStatusBar.tsx | 5 + .../WorkStation/shared/StatusBar/index.ts | 1 + .../git/branchPullRequestStatus.test.ts | 66 +++ src/services/git/branchPullRequestStatus.ts | 45 ++ src/services/git/ciCheckState.test.ts | 149 +++++++ src/services/git/ciCheckState.ts | 142 +++++++ 13 files changed, 1026 insertions(+), 63 deletions(-) create mode 100644 src/modules/WorkStation/shared/StatusBar/CiStatusMenu.tsx create mode 100644 src/services/git/ciCheckState.test.ts create mode 100644 src/services/git/ciCheckState.ts diff --git a/src/components/Dropdown/tokens.ts b/src/components/Dropdown/tokens.ts index 387c8dd1bb..26c4884984 100644 --- a/src/components/Dropdown/tokens.ts +++ b/src/components/Dropdown/tokens.ts @@ -174,6 +174,22 @@ export const DROPDOWN_SEARCH = { // Composite Class Strings (for easy use) // ============================================== +/** + * Sticky bordered header row above a panel's scrollable list. Shared by the + * search header and by header rows that carry a title plus actions instead. + */ +const PANEL_HEADER_ROW = [ + "flex", + "shrink-0", + "items-center", + DROPDOWN_ITEM.gapClass, + "px-3", + "py-1.5", + "border-b", + "border-solid", + "border-border-2", +].join(" "); + /** * Complete class string for dropdown panel container * Usage:
...
@@ -390,17 +406,10 @@ export const DROPDOWN_CLASSES = { ].join(" "), /** Search input container */ - searchContainer: [ - "flex", - "shrink-0", - "items-center", - DROPDOWN_ITEM.gapClass, - "px-3", - "py-1.5", - "border-b", - "border-solid", - "border-border-2", - ].join(" "), + searchContainer: PANEL_HEADER_ROW, + + /** Panel header row carrying a title and actions instead of a search input. */ + panelHeaderRow: PANEL_HEADER_ROW, /** Search input */ searchInput: [ diff --git a/src/hooks/git/useBranchPullRequestStatus.test.ts b/src/hooks/git/useBranchPullRequestStatus.test.ts index e6c03f76b4..a0cfbaa8b6 100644 --- a/src/hooks/git/useBranchPullRequestStatus.test.ts +++ b/src/hooks/git/useBranchPullRequestStatus.test.ts @@ -20,7 +20,11 @@ import { getGitCredentialForRemote, getPRLocal, } from "@src/api/tauri/github"; -import { clearBranchPullRequestStatusCache } from "@src/services/git/branchPullRequestStatus"; +import { + BRANCH_CI_POLL_BASE_MS, + BRANCH_CI_POLL_MAX_MS, + clearBranchPullRequestStatusCache, +} from "@src/services/git/branchPullRequestStatus"; import { type UseBranchPullRequestStatusOptions, @@ -142,9 +146,31 @@ describe("useBranchPullRequestStatus", () => { act(() => root.unmount()); container.remove(); Reflect.deleteProperty(document, "visibilityState"); + vi.useRealTimers(); vi.clearAllMocks(); }); + function runningChecks() { + return { + sha: "abc", + state: "pending", + check_runs: [ + { + id: 1, + name: "test", + status: "in_progress", + conclusion: null, + details_url: null, + started_at: null, + completed_at: null, + output_title: null, + app_name: "CI", + }, + ], + statuses: [], + }; + } + afterAll(() => { Reflect.deleteProperty(reactActEnvironment, "IS_REACT_ACT_ENVIRONMENT"); }); @@ -274,6 +300,69 @@ describe("useBranchPullRequestStatus", () => { expect(latest.compareUrl).toContain("feature-new"); }); + it("re-reads while checks run and stops once they settle", async () => { + vi.useFakeTimers(); + getChecksLocalMock + .mockResolvedValueOnce(runningChecks()) + .mockResolvedValueOnce(runningChecks()); + + await act(async () => { + root.render( + createElement(Probe, { + options: { + repoId: "repo-1", + repoPath: "/repo", + branchName: "feature", + poll: true, + }, + onValue: () => undefined, + }) + ); + }); + expect(getChecksLocalMock).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(BRANCH_CI_POLL_BASE_MS); + }); + expect(getChecksLocalMock).toHaveBeenCalledTimes(2); + + // Second poll returns the settled default (`success`), so the schedule ends + // even though far more than the max interval elapses afterwards. + await act(async () => { + await vi.advanceTimersByTimeAsync(BRANCH_CI_POLL_BASE_MS * 2); + }); + expect(getChecksLocalMock).toHaveBeenCalledTimes(3); + + await act(async () => { + await vi.advanceTimersByTimeAsync(BRANCH_CI_POLL_MAX_MS * 4); + }); + expect(getChecksLocalMock).toHaveBeenCalledTimes(3); + }); + + it("never schedules a poll when tracing is not requested", async () => { + vi.useFakeTimers(); + getChecksLocalMock.mockResolvedValue(runningChecks()); + + await act(async () => { + root.render( + createElement(Probe, { + options: { + repoId: "repo-1", + repoPath: "/repo", + branchName: "feature", + }, + onValue: () => undefined, + }) + ); + }); + expect(getChecksLocalMock).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(BRANCH_CI_POLL_MAX_MS * 4); + }); + expect(getChecksLocalMock).toHaveBeenCalledTimes(1); + }); + it("does not expose a closed PR or request checks for it", async () => { findPullRequestLocalMock.mockResolvedValue({ number: 12, diff --git a/src/hooks/git/useBranchPullRequestStatus.ts b/src/hooks/git/useBranchPullRequestStatus.ts index 21c88cbc4e..28b164907b 100644 --- a/src/hooks/git/useBranchPullRequestStatus.ts +++ b/src/hooks/git/useBranchPullRequestStatus.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { getGitDefaultBranch } from "@src/api/http/git/branches"; import { getGitRemotes } from "@src/api/http/git/remotes"; @@ -18,6 +18,7 @@ import { getCachedBranchPullRequestStatus, isBranchPullRequestStatusFresh, loadBranchPullRequestStatusCoalesced, + nextBranchCiPollDelayMs, resolveBranchCiStatus, setCachedBranchPullRequestStatus, } from "@src/services/git/branchPullRequestStatus"; @@ -50,6 +51,13 @@ export interface UseBranchPullRequestStatusOptions { branchName?: string; repoId?: string; repoPath?: string; + /** + * Keep re-reading CI while checks can still change, stopping once every run + * has reported. Off by default — only surfaces that visibly trace CI (the + * status-bar menu) should pay for the extra requests. See + * {@link nextBranchCiPollDelayMs} for the schedule. + */ + poll?: boolean; } export interface UseBranchPullRequestStatusResult extends Omit< @@ -57,6 +65,8 @@ export interface UseBranchPullRequestStatusResult extends Omit< "scopeKey" > { ciStatus: BranchCiStatus | null; + /** Re-reads PR + checks now, bypassing the status TTL. */ + refresh: () => void; } function isGitHubRemote(remoteUrl: string): boolean { @@ -111,9 +121,16 @@ export function useBranchPullRequestStatus({ branchName, repoId, repoPath, + poll = false, }: UseBranchPullRequestStatusOptions): UseBranchPullRequestStatusResult { const [state, setState] = useState(EMPTY_STATE); const generationRef = useRef(0); + const loadRef = useRef<((options?: { force?: boolean }) => void) | null>( + null + ); + const pollTimerRef = useRef(null); + const pollAttemptRef = useRef(0); + const pollHeadShaRef = useRef(null); const scopeKey = repoPath && branchName ? `${repoId ?? "default"}|${repoPath}|${branchName}` @@ -122,18 +139,56 @@ export function useBranchPullRequestStatus({ useEffect(() => { let disposed = false; + const clearPollTimer = () => { + if (pollTimerRef.current != null) { + window.clearTimeout(pollTimerRef.current); + pollTimerRef.current = null; + } + }; + + // A different repo/branch is a different CI timeline — restart the backoff. + pollAttemptRef.current = 0; + pollHeadShaRef.current = null; + if (!repoPath || !branchName) { generationRef.current += 1; + loadRef.current = null; + clearPollTimer(); return; } - const load = async () => { + // A new head commit restarts the backoff, so a push is picked up at the + // fast interval instead of inheriting the previous run's cooled-off delay. + const scheduleNextPoll = (snapshot: BranchPullRequestStatusSnapshot) => { + clearPollTimer(); + if (!poll || disposed) return; + + const headSha = snapshot.checks?.sha ?? null; + if (headSha !== pollHeadShaRef.current) { + pollHeadShaRef.current = headSha; + pollAttemptRef.current = 0; + } + + const delay = nextBranchCiPollDelayMs({ + ...snapshot, + attempt: pollAttemptRef.current, + }); + if (delay == null) return; + + pollAttemptRef.current += 1; + pollTimerRef.current = window.setTimeout(() => { + loadRef.current?.({ force: true }); + }, delay); + }; + + const load = async (options?: { force?: boolean }) => { if ( typeof document !== "undefined" && document.visibilityState === "hidden" ) { return; } + const force = options?.force === true; const generation = ++generationRef.current; const isCurrent = () => !disposed && generation === generationRef.current; @@ -156,12 +211,14 @@ export function useBranchPullRequestStatus({ (remote) => remote.name === "origin" ); if (!origin?.url || !isGitHubRemote(origin.url)) { + clearPollTimer(); setState(EMPTY_STATE); return; } const repoFullName = parseGithubRepoFullName(origin.url); if (!repoFullName) { + clearPollTimer(); setState(EMPTY_STATE); return; } @@ -183,7 +240,7 @@ export function useBranchPullRequestStatus({ repoFullName, }); const cached = getCachedBranchPullRequestStatus(cacheKey); - const cachedIsFresh = isBranchPullRequestStatusFresh(cached); + const cachedIsFresh = !force && isBranchPullRequestStatusFresh(cached); setState({ compareUrl, @@ -196,7 +253,10 @@ export function useBranchPullRequestStatus({ refreshing: Boolean(cached && !cachedIsFresh), scopeKey, }); - if (cachedIsFresh) return; + if (cachedIsFresh) { + if (cached) scheduleNextPoll(cached); + return; + } try { const snapshot = await loadBranchPullRequestStatusCoalesced( @@ -214,8 +274,12 @@ export function useBranchPullRequestStatus({ refreshing: false, scopeKey, }); + scheduleNextPoll(snapshot); } catch { if (!isCurrent()) return; + // Leave the poll timer cleared: a failed read is retried by an + // explicit refresh or the next visibility return, not by a timer. + clearPollTimer(); setState((current) => ({ ...current, loading: false, @@ -224,9 +288,16 @@ export function useBranchPullRequestStatus({ } }; + loadRef.current = (options) => { + void load(options); + }; + const handleVisibilityChange = () => { if (document.visibilityState === "visible") { void load(); + } else { + // Nothing to trace behind a hidden window; the return trip re-reads. + clearPollTimer(); } }; @@ -243,6 +314,8 @@ export function useBranchPullRequestStatus({ return () => { disposed = true; generationRef.current += 1; + loadRef.current = null; + clearPollTimer(); if (typeof document !== "undefined") { document.removeEventListener( "visibilitychange", @@ -250,7 +323,7 @@ export function useBranchPullRequestStatus({ ); } }; - }, [branchName, repoId, repoPath, scopeKey]); + }, [branchName, poll, repoId, repoPath, scopeKey]); const visibleState = state.scopeKey === scopeKey @@ -277,6 +350,13 @@ export function useBranchPullRequestStatus({ ] ); + const refresh = useCallback(() => { + // Asking by hand is a signal of interest: drop back to the fast interval + // instead of inheriting however far the backoff had already cooled. + pollAttemptRef.current = 0; + loadRef.current?.({ force: true }); + }, []); + const { scopeKey: _scopeKey, ...result } = visibleState; - return { ...result, ciStatus }; + return { ...result, ciStatus, refresh }; } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 380e1c323f..95790e78d2 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -1142,6 +1142,18 @@ "searchPlaceholder": "Search ports…", "noSearchResults": "No matching ports" }, + "ci": { + "openPullRequest": "Open pull request on GitHub", + "viewDetails": "View check details", + "refresh": "Refresh checks", + "unavailable": "Checks could not be read", + "sections": { + "failed": "Failed", + "running": "Running", + "skipped": "Skipped", + "passed": "Passed" + } + }, "noLanguageServicesActive": "No language services active", "linesSelected": "{{count}} lines selected", "charsSelected": "{{count}} selected", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index f594a5ca7d..c27681e291 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -1130,6 +1130,18 @@ "searchPlaceholder": "搜索端口…", "noSearchResults": "没有匹配的端口" }, + "ci": { + "openPullRequest": "在 GitHub 中打开 Pull Request", + "viewDetails": "查看检查详情", + "refresh": "刷新检查", + "unavailable": "无法读取检查状态", + "sections": { + "failed": "失败", + "running": "运行中", + "skipped": "已跳过", + "passed": "已通过" + } + }, "noLanguageServicesActive": "没有活跃的 Language Service", "linesSelected": "已选择 {{count}} 行", "charsSelected": "已选择 {{count}} 个字符", diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrChecksTab.tsx b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrChecksTab.tsx index dce1ff3fdf..8bb93231fc 100644 --- a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrChecksTab.tsx +++ b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrChecksTab.tsx @@ -14,52 +14,17 @@ import { import React from "react"; import { useTranslation } from "react-i18next"; -import type { - GitHubCheckRun, - GitHubChecksSummary, - GitHubStatusContext, -} from "@src/api/tauri/github"; +import type { GitHubChecksSummary } from "@src/api/tauri/github"; import { DETAIL_PANEL_TOKENS } from "@src/config/detailPanelTokens"; import { formatTimeAgo } from "@src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/workstationIssueHelpers"; import { Placeholder } from "@src/modules/shared/layouts/blocks"; +import { + type CiCheckState, + checkRunState, + statusContextState, +} from "@src/services/git/ciCheckState"; -type CheckState = "success" | "failure" | "pending" | "neutral"; - -function checkRunState(run: GitHubCheckRun): CheckState { - if (run.status !== "completed") return "pending"; - switch (run.conclusion) { - case "success": - return "success"; - case "failure": - case "timed_out": - case "action_required": - case "cancelled": - case "startup_failure": - return "failure"; - case "neutral": - case "skipped": - case "stale": - return "neutral"; - default: - return "pending"; - } -} - -function statusState(status: GitHubStatusContext): CheckState { - switch (status.state) { - case "success": - return "success"; - case "failure": - case "error": - return "failure"; - case "pending": - return "pending"; - default: - return "neutral"; - } -} - -function StateIcon({ state }: { state: CheckState }): React.ReactNode { +function StateIcon({ state }: { state: CiCheckState }): React.ReactNode { switch (state) { case "success": return ( @@ -83,7 +48,7 @@ function StateIcon({ state }: { state: CheckState }): React.ReactNode { } interface CheckRowProps { - state: CheckState; + state: CiCheckState; name: string; description?: string | null; meta?: string | null; @@ -165,7 +130,7 @@ export const PrChecksTab: React.FC = ({ ); } - const overall = (checks?.state ?? "pending") as CheckState; + const overall = (checks?.state ?? "pending") as CiCheckState; const summaryLabel = overall === "success" ? t("git.pr.checks.allPassed", "All checks passed") @@ -203,7 +168,7 @@ export const PrChecksTab: React.FC = ({ {statuses.map((status) => ( + ); + case "failure": + return ( + + ); + case "pending": + return ( + + ); + default: + return ( + + ); + } +} + +function BranchCiIcon({ status }: { status: BranchCiStatus }): React.ReactNode { + switch (status) { + case "success": + return ; + case "failure": + return ; + case "pending": + case "checking": + return ; + default: + return ( + + ); + } +} + +interface CheckRowProps { + item: CiCheckItem; + onOpenDetails: (url: string) => void; +} + +const CheckRow: React.FC = memo(({ item, onOpenDetails }) => { + const { t } = useTranslation(); + // Elapsed time only earns space while a check is still running — once it has + // a verdict, the verdict is the answer and "35m ago" is noise. + const meta = + item.state === "pending" && item.startedAt + ? formatRelativeTime(item.startedAt, "nano") + : null; + // The reporting app is the same for nearly every row, so it only earns the + // narrow status-bar width in the hover title, not in the label. + const title = [ + item.appName ? `${item.appName} / ${item.name}` : item.name, + item.description, + ] + .filter(Boolean) + .join("\n"); + + return ( +
+
+ + + + + {item.name} + + {meta && ( + {meta} + )} +
+ {/* + Actions stay painted rather than revealed on hover — same reasoning as + the ports rows: opacity transitions promote compositor layers and make + the centered icon jitter in Chromium. + */} +
+ {item.detailsUrl && ( + + )} +
+
+ ); +}); +CheckRow.displayName = "CheckRow"; + +export const CiStatusMenu: React.FC = memo( + ({ branchName }) => { + const { t } = useTranslation(); + const { repoId, repoPath } = useActiveRepoRef(); + + const { checks, ciStatus, pr, refresh, refreshing } = + useBranchPullRequestStatus({ + branchName, + repoId, + repoPath, + poll: true, + }); + + const { + close, + isOpen, + isPositioned, + panelPosition, + panelRef, + toggle, + triggerRef, + } = useDropdownEngine({ + align: "left", + gap: DROPDOWN_PANEL.triggerGap, + placement: "top", + }); + + const items = useMemo(() => flattenChecks(checks), [checks]); + const counts = useMemo(() => countCheckStates(items), [items]); + + const sections = useMemo( + () => + SECTION_ORDER.map((state) => ({ + state, + items: items.filter((item) => item.state === state), + })).filter((section) => section.items.length > 0), + [items] + ); + + const handleToggle = useCallback(() => { + // Opening is the user asking "where is it now?" — always re-read, even + // when the background schedule has already settled and stopped. + if (!isOpen) refresh(); + toggle(); + }, [isOpen, refresh, toggle]); + + const handleOpenDetails = useCallback( + (url: string) => { + void openExternalLink(url); + close(); + }, + [close] + ); + + const handleOpenPullRequest = useCallback(() => { + if (!pr) return; + void openExternalLink(pr.url); + close(); + }, [close, pr]); + + const statusLabel = useMemo(() => { + switch (ciStatus) { + case "success": + return t("git.pr.checks.passedShort"); + case "failure": + return t("git.pr.checks.failedShort"); + case "pending": + return t("git.pr.checks.runningShort"); + case "checking": + return t("git.pr.checks.checkingShort"); + case "none": + return t("git.pr.checks.noneShort"); + default: + return t("git.pr.checks.unavailableShort"); + } + }, [ciStatus, t]); + + const sectionLabelFor = useCallback( + (state: CiCheckState, count: number) => { + const label = + state === "failure" + ? t("workstation.ci.sections.failed") + : state === "pending" + ? t("workstation.ci.sections.running") + : state === "neutral" + ? t("workstation.ci.sections.skipped") + : t("workstation.ci.sections.passed"); + return `${label} · ${count}`; + }, + [t] + ); + + // Nothing to trace without an open pull request for this branch — the + // compare/create affordances live in the git sync menu next door. + if (!pr || !ciStatus) return null; + + const reportedCount = counts.total - counts.pending; + const triggerLabel = + counts.pending > 0 + ? `#${pr.number} · ${reportedCount}/${counts.total}` + : `#${pr.number}`; + const triggerTooltip = t("git.pr.checks.branchStatus", { + number: pr.number, + status: statusLabel, + }); + + return ( +
+ + + + + {triggerLabel} + + + + + {isOpen && + isPositioned && + createPortal( +
+ {/* + The header row token carries no font size — the search variant + gets it from the input instead. Set it here so the title and + status read at the same 13px as the check rows below rather + than inheriting the document default. + */} +
+ + + {statusLabel} + +
+ +
+ {sections.length === 0 ? ( +
+ {ciStatus === "unavailable" + ? t("workstation.ci.unavailable") + : ciStatus === "checking" + ? t("git.pr.checks.checkingShort") + : t("git.pr.checks.none")} +
+ ) : ( + sections.map((section) => ( + +
+ {sectionLabelFor(section.state, section.items.length)} +
+ {section.items.map((item) => ( + + ))} +
+ )) + )} +
+
, + document.body + )} +
+ ); + } +); +CiStatusMenu.displayName = "CiStatusMenu"; + +export default CiStatusMenu; diff --git a/src/modules/WorkStation/shared/StatusBar/EditorStatusBar.tsx b/src/modules/WorkStation/shared/StatusBar/EditorStatusBar.tsx index 7b4714b41d..f9f6db7072 100644 --- a/src/modules/WorkStation/shared/StatusBar/EditorStatusBar.tsx +++ b/src/modules/WorkStation/shared/StatusBar/EditorStatusBar.tsx @@ -44,6 +44,7 @@ import { } from "@src/store/workstation/codeEditor/search/indexingProgressAtom"; import { getViewportSize } from "@src/util/ui/window/viewport"; +import { CiStatusMenu } from "./CiStatusMenu"; import GitSyncStatusMenu from "./GitSyncStatusMenu"; import { PortsStatusMenu } from "./PortsStatusMenu"; import { @@ -310,6 +311,10 @@ export const EditorStatusBar: React.FC = memo( )} + {showGitControls && branchName && ( + + )} + {showGitControls && branchName && ( { vi.useRealTimers(); }); + it("stops polling when there is nothing left to trace", () => { + const base = { attempt: 0, checksUnavailable: false }; + + // No PR, unreadable checks, and settled checks all end the schedule. + expect( + nextBranchCiPollDelayMs({ ...base, pr: null, checks: checks("pending") }) + ).toBeNull(); + expect( + nextBranchCiPollDelayMs({ + ...base, + pr, + checks: null, + checksUnavailable: true, + }) + ).toBeNull(); + expect( + nextBranchCiPollDelayMs({ ...base, pr, checks: checks("success") }) + ).toBeNull(); + expect( + nextBranchCiPollDelayMs({ ...base, pr, checks: checks("failure") }) + ).toBeNull(); + }); + + it("backs off while checks run and caps the interval", () => { + const running = { checks: checks("pending"), checksUnavailable: false, pr }; + + expect(nextBranchCiPollDelayMs({ ...running, attempt: 0 })).toBe( + BRANCH_CI_POLL_BASE_MS + ); + expect(nextBranchCiPollDelayMs({ ...running, attempt: 1 })).toBe( + BRANCH_CI_POLL_BASE_MS * 2 + ); + expect(nextBranchCiPollDelayMs({ ...running, attempt: 9 })).toBe( + BRANCH_CI_POLL_MAX_MS + ); + }); + + it("gives an unreported PR a bounded grace period before giving up", () => { + const empty = { + checks: checks("pending", false), + checksUnavailable: false, + pr, + }; + + expect(nextBranchCiPollDelayMs({ ...empty, attempt: 0 })).toBe( + BRANCH_CI_EMPTY_POLL_MS + ); + expect( + nextBranchCiPollDelayMs({ + ...empty, + attempt: BRANCH_CI_EMPTY_POLL_MAX_ATTEMPTS - 1, + }) + ).toBe(BRANCH_CI_EMPTY_POLL_MS); + expect( + nextBranchCiPollDelayMs({ + ...empty, + attempt: BRANCH_CI_EMPTY_POLL_MAX_ATTEMPTS, + }) + ).toBeNull(); + }); + it("builds GitHub compare links and falls back to the compare picker", () => { expect(buildGitHubCompareUrl("acme/repo", "main", "feature/a")).toBe( "https://github.com/acme/repo/compare/main...feature%2Fa" diff --git a/src/services/git/branchPullRequestStatus.ts b/src/services/git/branchPullRequestStatus.ts index 4db98af8f9..b250e86116 100644 --- a/src/services/git/branchPullRequestStatus.ts +++ b/src/services/git/branchPullRequestStatus.ts @@ -2,10 +2,20 @@ import type { GitHubChecksSummary, LocalFindPRResponse, } from "@src/api/tauri/github"; +import { areChecksSettled } from "@src/services/git/ciCheckState"; export const BRANCH_PULL_REQUEST_STATUS_TTL_MS = 45_000; export const BRANCH_PULL_REQUEST_STATUS_CACHE_MAX_ENTRIES = 8; +/** First poll delay while checks are still running. */ +export const BRANCH_CI_POLL_BASE_MS = 15_000; +/** Ceiling for the running-checks backoff. */ +export const BRANCH_CI_POLL_MAX_MS = 60_000; +/** Delay between the bounded retries taken when a PR reports no checks yet. */ +export const BRANCH_CI_EMPTY_POLL_MS = 30_000; +/** How many times to re-ask before accepting that a PR simply has no CI. */ +export const BRANCH_CI_EMPTY_POLL_MAX_ATTEMPTS = 3; + export interface BranchPullRequestStatusSnapshot { pr: LocalFindPRResponse | null; checks: GitHubChecksSummary | null; @@ -142,6 +152,41 @@ export function resolveBranchCiStatus({ } } +/** + * Delay before the next branch-CI poll, or `null` to stop polling. + * + * Tracing a branch's CI the way GitHub Desktop does, without its polling cost: + * we only keep asking while something can still change. Once every run has + * reported — or there is no PR and no CI to watch at all — the schedule ends + * and the next read comes from an explicit trigger (opening the menu, + * switching branch, or the window becoming visible again). + * + * @param attempt Consecutive polls already scheduled for this head commit. + * Callers reset it whenever the head SHA changes, so a new push restarts at + * the fast interval. + */ +export function nextBranchCiPollDelayMs({ + attempt, + checks, + checksUnavailable, + pr, +}: BranchPullRequestStatusSnapshot & { attempt: number }): number | null { + // No PR to trace, or checks we couldn't read — nothing a timer would fix. + if (!pr || checksUnavailable || !checks) return null; + + if (checks.check_runs.length === 0 && checks.statuses.length === 0) { + // CI may not have registered its runs yet; give it a bounded grace period + // rather than polling an un-CI'd repository forever. + return attempt < BRANCH_CI_EMPTY_POLL_MAX_ATTEMPTS + ? BRANCH_CI_EMPTY_POLL_MS + : null; + } + + if (areChecksSettled(checks)) return null; + + return Math.min(BRANCH_CI_POLL_BASE_MS * 2 ** attempt, BRANCH_CI_POLL_MAX_MS); +} + export function branchPullRequestStatusCacheSize(): number { return statusCache.size; } diff --git a/src/services/git/ciCheckState.test.ts b/src/services/git/ciCheckState.test.ts new file mode 100644 index 0000000000..84dd8ce48a --- /dev/null +++ b/src/services/git/ciCheckState.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; + +import type { + GitHubCheckRun, + GitHubChecksSummary, + GitHubStatusContext, +} from "@src/api/tauri/github"; + +import { + areChecksSettled, + checkRunState, + countCheckStates, + flattenChecks, + statusContextState, +} from "./ciCheckState"; + +function run(overrides: Partial = {}): GitHubCheckRun { + return { + id: 1, + name: "build", + status: "completed", + conclusion: "success", + details_url: "https://github.com/acme/repo/runs/1", + started_at: null, + completed_at: null, + output_title: null, + app_name: null, + ...overrides, + }; +} + +function status( + overrides: Partial = {} +): GitHubStatusContext { + return { + context: "ci/legacy", + state: "success", + description: null, + target_url: null, + avatar_url: null, + ...overrides, + }; +} + +function summary( + check_runs: GitHubCheckRun[], + statuses: GitHubStatusContext[] = [] +): GitHubChecksSummary { + return { sha: "abc123", check_runs, statuses, state: "pending" }; +} + +describe("ciCheckState", () => { + it("treats an in-flight run and a completed-without-conclusion run as pending", () => { + expect(checkRunState(run({ status: "queued", conclusion: null }))).toBe( + "pending" + ); + expect( + checkRunState(run({ status: "in_progress", conclusion: null })) + ).toBe("pending"); + expect(checkRunState(run({ conclusion: null }))).toBe("pending"); + }); + + it("maps every failing conclusion to failure and non-verdicts to neutral", () => { + for (const conclusion of [ + "failure", + "timed_out", + "action_required", + "cancelled", + "startup_failure", + ]) { + expect(checkRunState(run({ conclusion }))).toBe("failure"); + } + for (const conclusion of ["neutral", "skipped", "stale"]) { + expect(checkRunState(run({ conclusion }))).toBe("neutral"); + } + expect(statusContextState(status({ state: "error" }))).toBe("failure"); + expect(statusContextState(status({ state: "pending" }))).toBe("pending"); + }); + + it("flattens runs before statuses and keeps the app name unmerged", () => { + const items = flattenChecks( + summary( + [run({ id: 7, name: "test", app_name: "GitHub Actions" })], + [status({ context: "ci/legacy" })] + ) + ); + + expect(items.map((item) => item.key)).toEqual([ + "run-7", + "status-ci/legacy", + ]); + // The reporting app stays a separate field so the label can drop it. + expect(items[0].name).toBe("test"); + expect(items[0].appName).toBe("GitHub Actions"); + expect(items[1].name).toBe("ci/legacy"); + expect(items[1].appName).toBeNull(); + }); + + it("counts states across runs and statuses", () => { + const counts = countCheckStates( + flattenChecks( + summary( + [ + run({ id: 1 }), + run({ id: 2, status: "in_progress", conclusion: null }), + run({ id: 3, conclusion: "failure" }), + run({ id: 4, conclusion: "skipped" }), + ], + [status({ state: "pending" })] + ) + ) + ); + + expect(counts).toEqual({ + total: 5, + success: 1, + failure: 1, + pending: 2, + neutral: 1, + }); + }); + + it("settles only once nothing is still pending", () => { + expect(areChecksSettled(null)).toBe(false); + // Empty is not settled — CI may not have registered its runs yet. + expect(areChecksSettled(summary([]))).toBe(false); + expect( + areChecksSettled( + summary([run({ status: "in_progress", conclusion: null })]) + ) + ).toBe(false); + // A decided failure alongside a still-running job is not settled: the rows + // keep moving even though the roll-up verdict cannot change. + expect( + areChecksSettled( + summary([ + run({ id: 1, conclusion: "failure" }), + run({ id: 2, status: "in_progress", conclusion: null }), + ]) + ) + ).toBe(false); + expect( + areChecksSettled(summary([run({ conclusion: "failure" })], [status()])) + ).toBe(true); + expect(areChecksSettled(summary([], [status({ state: "pending" })]))).toBe( + false + ); + }); +}); diff --git a/src/services/git/ciCheckState.ts b/src/services/git/ciCheckState.ts new file mode 100644 index 0000000000..ff14cc24b6 --- /dev/null +++ b/src/services/git/ciCheckState.ts @@ -0,0 +1,142 @@ +/** + * CI check-state derivation shared by every surface that renders GitHub + * checks — the PR detail panel and the status-bar CI menu. + * + * `github_get_checks` returns two shapes for the same head commit (modern + * check-runs and legacy commit statuses). Both surfaces need the same + * per-check verdict and the same notion of "everything has reported", so the + * mapping lives here instead of being re-derived per component. + */ +import type { + GitHubCheckRun, + GitHubChecksSummary, + GitHubStatusContext, +} from "@src/api/tauri/github"; + +export type CiCheckState = "success" | "failure" | "pending" | "neutral"; + +export function checkRunState(run: GitHubCheckRun): CiCheckState { + if (run.status !== "completed") return "pending"; + switch (run.conclusion) { + case "success": + return "success"; + case "failure": + case "timed_out": + case "action_required": + case "cancelled": + case "startup_failure": + return "failure"; + case "neutral": + case "skipped": + case "stale": + return "neutral"; + default: + // `completed` without a conclusion — GitHub still owes us a verdict. + return "pending"; + } +} + +export function statusContextState(status: GitHubStatusContext): CiCheckState { + switch (status.state) { + case "success": + return "success"; + case "failure": + case "error": + return "failure"; + case "pending": + return "pending"; + default: + return "neutral"; + } +} + +export interface CiCheckItem { + key: string; + /** Check name on its own — callers decide whether to qualify it. */ + name: string; + /** Reporting app ("GitHub Actions"), or null for legacy commit statuses. */ + appName: string | null; + description: string | null; + state: CiCheckState; + detailsUrl: string | null; + /** ISO timestamp, or null for legacy commit statuses (no timing reported). */ + startedAt: string | null; + completedAt: string | null; +} + +/** + * Flattens check-runs and commit statuses into one list, preserving the order + * GitHub returned them in (runs first, then statuses) so callers that render a + * flat list keep the API's ordering. + */ +export function flattenChecks( + summary: GitHubChecksSummary | null +): CiCheckItem[] { + if (!summary) return []; + + const items: CiCheckItem[] = summary.check_runs.map((run) => ({ + key: `run-${run.id}`, + name: run.name, + appName: run.app_name, + description: run.output_title, + state: checkRunState(run), + detailsUrl: run.details_url, + startedAt: run.started_at, + completedAt: run.completed_at, + })); + + for (const status of summary.statuses) { + items.push({ + key: `status-${status.context}`, + name: status.context, + appName: null, + description: status.description, + state: statusContextState(status), + detailsUrl: status.target_url, + startedAt: null, + completedAt: null, + }); + } + + return items; +} + +export interface CiCheckCounts { + total: number; + success: number; + failure: number; + pending: number; + neutral: number; +} + +export function countCheckStates(items: CiCheckItem[]): CiCheckCounts { + const counts: CiCheckCounts = { + total: items.length, + success: 0, + failure: 0, + pending: 0, + neutral: 0, + }; + for (const item of items) { + counts[item.state] += 1; + } + return counts; +} + +/** + * True once every reported check has a final verdict — nothing else can change + * without a new push. + * + * Deliberately structural rather than reading `summary.state`: the server-side + * roll-up short-circuits to `failure` while other runs are still in flight, and + * the CI menu keeps those rows live until they actually finish. + * + * An empty summary is *not* settled — CI may simply not have registered its + * runs yet, which the poll schedule handles with a bounded grace period. + */ +export function areChecksSettled(summary: GitHubChecksSummary | null): boolean { + if (!summary) return false; + const items = flattenChecks(summary); + if (items.length === 0) return false; + return items.every((item) => item.state !== "pending"); +}