diff --git a/src/utils/__tests__/git-ci-rollup.test.ts b/src/utils/__tests__/git-ci-rollup.test.ts new file mode 100644 index 00000000..0d0d86f9 --- /dev/null +++ b/src/utils/__tests__/git-ci-rollup.test.ts @@ -0,0 +1,41 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { computeCiRollup } from '../git-review-cache'; + +const pass = { status: 'COMPLETED', conclusion: 'SUCCESS' }; +const fail = { status: 'COMPLETED', conclusion: 'FAILURE' }; +const running = { status: 'IN_PROGRESS', conclusion: '' }; +const neutral = { status: 'COMPLETED', conclusion: 'NEUTRAL' }; +const skipped = { status: 'COMPLETED', conclusion: 'SKIPPED' }; +const statusPass = { state: 'SUCCESS' }; +const statusFail = { state: 'FAILURE' }; +const statusPending = { state: 'PENDING' }; + +describe('computeCiRollup', () => { + it.each([ + ['all passing check runs', [pass, pass, pass], { state: 'passing', failing: 0, pending: 0, success: 3 }], + ['a failure makes it failing', [pass, fail, pass], { state: 'failing', failing: 1, pending: 0, success: 2 }], + ['a pending run makes it pending', [pass, running], { state: 'pending', failing: 0, pending: 1, success: 1 }], + ['failure takes precedence over pending', [fail, running, pass], { state: 'failing', failing: 1, pending: 1, success: 1 }], + ['neutral and skipped are ignored (not counted as success)', [pass, neutral, skipped], { state: 'passing', failing: 0, pending: 0, success: 1 }], + ['StatusContext success counts as success', [statusPass, statusPass], { state: 'passing', failing: 0, pending: 0, success: 2 }], + ['StatusContext failure counts as failing', [statusPass, statusFail], { state: 'failing', failing: 1, pending: 0, success: 1 }], + ['StatusContext pending counts as pending', [statusPass, statusPending], { state: 'pending', failing: 0, pending: 1, success: 1 }], + ['the screenshot mix (1 fail, 1 neutral, 1 pending, 2 skipped, 4 success)', [fail, neutral, running, skipped, skipped, pass, pass, pass, pass], { state: 'failing', failing: 1, pending: 1, success: 4 }] + ])('%s', (_label, rollup, expected) => { + expect(computeCiRollup(rollup)).toEqual(expected); + }); + + it.each([ + ['empty array', []], + ['non-array', null], + ['undefined', undefined], + ['string', 'nope'] + ])('returns null for %s', (_label, input) => { + expect(computeCiRollup(input)).toBeNull(); + }); +}); diff --git a/src/utils/__tests__/git-review-cache.test.ts b/src/utils/__tests__/git-review-cache.test.ts index c2ca3500..fc016686 100644 --- a/src/utils/__tests__/git-review-cache.test.ts +++ b/src/utils/__tests__/git-review-cache.test.ts @@ -160,11 +160,12 @@ describe('git-review-cache', () => { it('negative-caches failed gh PR lookups', () => { const harness = createHarness(); harness.ghResponses.push(new Error('no pull request found')); + harness.ghResponses.push(new Error('no pull request found')); expect(fetchGitReviewData('/tmp/repo', harness.deps)).toBeNull(); const ghCallsAfterFirstRender = harness.execCalls.filter(call => call.cmd === 'gh'); - expect(ghCallsAfterFirstRender).toHaveLength(2); + expect(ghCallsAfterFirstRender).toHaveLength(3); const cachedMissEntry = [...harness.cacheFiles.values()].at(0); expect(cachedMissEntry?.content).toBe(''); @@ -172,7 +173,7 @@ describe('git-review-cache', () => { expect(fetchGitReviewData('/tmp/repo', harness.deps)).toBeNull(); const ghCallsAfterSecondRender = harness.execCalls.filter(call => call.cmd === 'gh'); - expect(ghCallsAfterSecondRender).toHaveLength(2); + expect(ghCallsAfterSecondRender).toHaveLength(3); }); it('uses a different cache entry for each checked-out branch', () => { @@ -279,18 +280,28 @@ describe('git-review-cache', () => { expect(data?.state).toBe('MERGED'); }); - it('uses gh\'s default repo resolution when it succeeds (no --repo pin needed)', () => { + it('uses gh\'s default repo resolution and includes CI checks in one query', () => { const harness = createHarness(); harness.setOriginRemoteUrl('https://github.com/example-owner/example-repo.git'); harness.ghResponses.push(JSON.stringify({ number: 42, reviewDecision: '', state: 'OPEN', + statusCheckRollup: [ + { conclusion: 'SUCCESS', status: 'COMPLETED' }, + { conclusion: '', status: 'IN_PROGRESS' } + ], title: 'Standard PR', url: 'https://github.com/example-owner/example-repo/pull/42' })); expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({ + checks: { + failing: 0, + pending: 1, + state: 'pending', + success: 1 + }, number: 42, provider: 'gh', reviewDecision: '', @@ -304,6 +315,51 @@ describe('git-review-cache', () => { ); expect(ghPrCalls).toHaveLength(1); expect(ghPrCalls[0]?.args).not.toContain('--repo'); + expect(ghPrCalls[0]?.args.at(-1)).toBe( + 'url,number,title,state,reviewDecision,statusCheckRollup' + ); + }); + + it('retries metadata-only when gh cannot query CI checks', () => { + const harness = createHarness(); + harness.setOriginRemoteUrl('https://github.com/example-owner/example-repo.git'); + harness.ghResponses.push(new Error('statusCheckRollup is unavailable')); + harness.ghResponses.push(JSON.stringify({ + number: 42, + reviewDecision: 'APPROVED', + state: 'OPEN', + title: 'Restricted token PR', + url: 'https://github.com/example-owner/example-repo/pull/42' + })); + + const expected = { + number: 42, + provider: 'gh' as const, + reviewDecision: 'APPROVED', + state: 'OPEN', + title: 'Restricted token PR', + url: 'https://github.com/example-owner/example-repo/pull/42' + }; + expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual(expected); + + const ghPrCalls = harness.execCalls.filter( + call => call.cmd === 'gh' && call.args[0] === 'pr' + ); + expect(ghPrCalls).toHaveLength(2); + expect(ghPrCalls[0]?.args.slice(0, -2)).toEqual(ghPrCalls[1]?.args.slice(0, -2)); + expect(ghPrCalls[0]?.args.at(-1)).toBe( + 'url,number,title,state,reviewDecision,statusCheckRollup' + ); + expect(ghPrCalls[1]?.args.at(-1)).toBe('url,number,title,state,reviewDecision'); + + const cachedEntry = [...harness.cacheFiles.values()].at(0); + expect(JSON.parse(cachedEntry?.content ?? '')).toEqual(expected); + + expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual(expected); + const cachedGhPrCalls = harness.execCalls.filter( + call => call.cmd === 'gh' && call.args[0] === 'pr' + ); + expect(cachedGhPrCalls).toHaveLength(2); }); it('falls back to --repo for forked GitHub repos when gh\'s default resolves elsewhere', () => { @@ -337,6 +393,39 @@ describe('git-review-cache', () => { expect(ghPrCalls[1]?.args).toContain('feature/cache-a'); }); + it('reuses the pinned PR target for the metadata-only compatibility retry', () => { + const harness = createHarness(); + harness.setOriginRemoteUrl('https://github.com/fork-owner/example-repo.git'); + harness.ghResponses.push(''); + harness.ghResponses.push(new Error('statusCheckRollup is unavailable')); + harness.ghResponses.push(JSON.stringify({ + number: 1, + reviewDecision: '', + state: 'OPEN', + title: 'Forked PR', + url: 'https://github.com/fork-owner/example-repo/pull/1' + })); + + expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({ + number: 1, + provider: 'gh', + reviewDecision: '', + state: 'OPEN', + title: 'Forked PR', + url: 'https://github.com/fork-owner/example-repo/pull/1' + }); + + const ghPrCalls = harness.execCalls.filter( + call => call.cmd === 'gh' && call.args[0] === 'pr' + ); + expect(ghPrCalls).toHaveLength(3); + expect(ghPrCalls[1]?.args.slice(0, -2)).toEqual(ghPrCalls[2]?.args.slice(0, -2)); + expect(ghPrCalls[1]?.args).toContain('feature/cache-a'); + expect(ghPrCalls[1]?.args).toContain('--repo'); + expect(ghPrCalls[1]?.args).toContain('https://github.com/fork-owner/example-repo'); + expect(ghPrCalls[2]?.args.at(-1)).toBe('url,number,title,state,reviewDecision'); + }); + it('resolves SSH host aliases before selecting GitHub and pinning --repo', () => { const harness = createHarness(); harness.setOriginRemoteUrl('git@mygit:owner/repo.git'); diff --git a/src/utils/git-review-cache.ts b/src/utils/git-review-cache.ts index 0537951c..5ac13f26 100644 --- a/src/utils/git-review-cache.ts +++ b/src/utils/git-review-cache.ts @@ -14,6 +14,15 @@ import { parseRemoteUrl } from './git-remote'; export type GitReviewProvider = 'gh' | 'glab'; +export type GitCiState = 'passing' | 'failing' | 'pending'; + +export interface GitCiChecks { + state: GitCiState; + failing: number; + pending: number; + success: number; +} + export interface GitReviewData { number: number; url: string; @@ -21,11 +30,72 @@ export interface GitReviewData { state: string; reviewDecision: string; provider?: GitReviewProvider; + checks?: GitCiChecks; +} + +type CiCheckKind = 'success' | 'failed' | 'pending' | 'ignored'; + +function readField(entry: Record, key: string): string { + const value = entry[key]; + return typeof value === 'string' ? value.toUpperCase() : ''; +} + +// Classify a single gh statusCheckRollup entry. CheckRun entries carry a +// `status` (COMPLETED once done) plus a `conclusion`; older StatusContext +// entries carry only a `state`. NEUTRAL/SKIPPED are non-blocking noise and +// map to `ignored` so they drop out of the displayed counts. +function classifyCheck(entry: Record): CiCheckKind { + if (typeof entry.status === 'string') { + if (entry.status.toUpperCase() !== 'COMPLETED') + return 'pending'; + const conclusion = readField(entry, 'conclusion'); + if (conclusion === 'SUCCESS') + return 'success'; + if (conclusion === 'NEUTRAL' || conclusion === 'SKIPPED') + return 'ignored'; + return 'failed'; + } + const state = readField(entry, 'state'); + if (state === 'SUCCESS') + return 'success'; + if (state === 'PENDING' || state === 'EXPECTED') + return 'pending'; + return 'failed'; +} + +export function computeCiRollup(rollup: unknown): GitCiChecks | null { + if (!Array.isArray(rollup) || rollup.length === 0) + return null; + + let failing = 0; + let pending = 0; + let success = 0; + let seen = 0; + for (const entry of rollup) { + if (typeof entry !== 'object' || entry === null) + continue; + seen++; + const kind = classifyCheck(entry as Record); + if (kind === 'failed') + failing++; + else if (kind === 'pending') + pending++; + else if (kind === 'success') + success++; + } + + if (seen === 0) + return null; + + const state: GitCiState = failing > 0 ? 'failing' : pending > 0 ? 'pending' : 'passing'; + return { state, failing, pending, success }; } const GIT_REVIEW_CACHE_TTL = 30_000; const CLI_TIMEOUT = 5_000; const DEFAULT_TITLE_MAX_WIDTH = 30; +const GH_PR_METADATA_FIELDS = 'url,number,title,state,reviewDecision'; +const GH_PR_WITH_CHECKS_FIELDS = `${GH_PR_METADATA_FIELDS},statusCheckRollup`; export interface GitReviewCacheDeps { execFileSync: typeof execFileSync; @@ -268,21 +338,15 @@ function mapGlabState(state: string): string { return state.toUpperCase(); } -function fetchFromGh(cwd: string, repoRef: string | null, deps: GitReviewCacheDeps): GitReviewData | null { - const args = ['pr', 'view']; - if (repoRef) { - // `--repo` disables branch auto-resolution, so pass the branch explicitly. - const branch = getCurrentBranch(cwd, deps); - if (!branch) { - return null; - } - args.push(branch, '--repo', repoRef); - } - args.push('--json', 'url,number,title,state,reviewDecision'); - +function queryGhPr( + cwd: string, + args: string[], + fields: string, + deps: GitReviewCacheDeps +): Record | null { const output = deps.execFileSync( 'gh', - args, + [...args, '--json', fields], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], @@ -296,7 +360,30 @@ function fetchFromGh(cwd: string, repoRef: string | null, deps: GitReviewCacheDe return null; } - const parsed = JSON.parse(output) as Record; + return JSON.parse(output) as Record; +} + +function fetchFromGh(cwd: string, repoRef: string | null, deps: GitReviewCacheDeps): GitReviewData | null { + const args = ['pr', 'view']; + if (repoRef) { + // `--repo` disables branch auto-resolution, so pass the branch explicitly. + const branch = getCurrentBranch(cwd, deps); + if (!branch) { + return null; + } + args.push(branch, '--repo', repoRef); + } + + let parsed: Record | null; + try { + parsed = queryGhPr(cwd, args, GH_PR_WITH_CHECKS_FIELDS, deps); + } catch { + parsed = queryGhPr(cwd, args, GH_PR_METADATA_FIELDS, deps); + } + + if (!parsed) { + return null; + } if (typeof parsed.number !== 'number' || typeof parsed.url !== 'string') { return null; } @@ -306,7 +393,8 @@ function fetchFromGh(cwd: string, repoRef: string | null, deps: GitReviewCacheDe title: typeof parsed.title === 'string' ? parsed.title : '', state: typeof parsed.state === 'string' ? parsed.state : '', reviewDecision: typeof parsed.reviewDecision === 'string' ? parsed.reviewDecision : '', - provider: 'gh' + provider: 'gh', + checks: computeCiRollup(parsed.statusCheckRollup) ?? undefined }; } diff --git a/src/utils/widget-manifest.ts b/src/utils/widget-manifest.ts index 86cbad71..72cd76a4 100644 --- a/src/utils/widget-manifest.ts +++ b/src/utils/widget-manifest.ts @@ -29,6 +29,7 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [ { type: 'git-clean-status', create: () => new widgets.GitCleanStatusWidget() }, { type: 'git-root-dir', create: () => new widgets.GitRootDirWidget() }, { type: 'git-review', create: () => new widgets.GitPrWidget() }, + { type: 'git-ci-status', create: () => new widgets.GitCiStatusWidget() }, { type: 'git-worktree', create: () => new widgets.GitWorktreeWidget() }, { type: 'git-status', create: () => new widgets.GitStatusWidget() }, { type: 'git-staged', create: () => new widgets.GitStagedWidget() }, diff --git a/src/widgets/GitCiStatus.ts b/src/widgets/GitCiStatus.ts new file mode 100644 index 00000000..d591a4b3 --- /dev/null +++ b/src/widgets/GitCiStatus.ts @@ -0,0 +1,132 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + isInsideGitWorkTree, + resolveGitCwd +} from '../utils/git'; +import type { GitCiChecks } from '../utils/git-review-cache'; +import { fetchGitReviewData } from '../utils/git-review-cache'; + +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + +const NO_CHECKS = '-'; +const SYMBOLS = { + success: '✓', + passing: '✓', + failing: '✗', + pending: '●' +} as const; + +export interface GitCiStatusWidgetDeps { + fetchGitReviewData: typeof fetchGitReviewData; + getProcessCwd: typeof process.cwd; + isInsideGitWorkTree: typeof isInsideGitWorkTree; + resolveGitCwd: typeof resolveGitCwd; +} + +const DEFAULT_DEPS: GitCiStatusWidgetDeps = { + fetchGitReviewData, + getProcessCwd: () => process.cwd(), + isInsideGitWorkTree, + resolveGitCwd +}; + +const PREVIEW_CHECKS: GitCiChecks = { + state: 'failing', + failing: 1, + pending: 1, + success: 5 +}; + +function buildDisplay(checks: GitCiChecks, rawValue: boolean): string { + if (rawValue) + return checks.state; + + const parts: string[] = []; + if (checks.failing > 0) + parts.push(`${SYMBOLS.failing}${checks.failing}`); + if (checks.pending > 0) + parts.push(`${SYMBOLS.pending}${checks.pending}`); + if (checks.success > 0) + parts.push(`${SYMBOLS.success}${checks.success}`); + + return parts.length > 0 ? parts.join(' ') : `${SYMBOLS[checks.state]}0`; +} + +export class GitCiStatusWidget implements Widget { + constructor(private readonly deps: GitCiStatusWidgetDeps = DEFAULT_DEPS) {} + + getDefaultColor(): string { + return 'green'; + } + + getDescription(): string { + return 'Shows CI check status for the current branch\'s PR (GitHub only)'; + } + + getDisplayName(): string { + return 'Git CI Status'; + } + + getCategory(): string { + return 'Git'; + } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getHideNoGitModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoGitAction(action, item); + } + + render( + item: WidgetItem, + context: RenderContext, + _settings: Settings + ): string | null { + const rawValue = item.rawValue ?? false; + + if (context.isPreview) { + return buildDisplay(PREVIEW_CHECKS, rawValue); + } + + if (!this.deps.isInsideGitWorkTree(context)) { + return isHideNoGitEnabled(item) ? null : '(no git)'; + } + + const cwd = this.deps.resolveGitCwd(context) ?? this.deps.getProcessCwd(); + const checks = this.deps.fetchGitReviewData(cwd)?.checks; + if (!checks) { + return NO_CHECKS; + } + + return buildDisplay(checks, rawValue); + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoGitKeybinds(); + } + + supportsRawValue(): boolean { + return true; + } + + supportsColors(_item: WidgetItem): boolean { + return true; + } +} diff --git a/src/widgets/__tests__/GitCiStatus.test.ts b/src/widgets/__tests__/GitCiStatus.test.ts new file mode 100644 index 00000000..f0297223 --- /dev/null +++ b/src/widgets/__tests__/GitCiStatus.test.ts @@ -0,0 +1,112 @@ +import { + describe, + expect, + it, + vi +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import type { GitCiState } from '../../utils/git-review-cache'; +import { + GitCiStatusWidget, + type GitCiStatusWidgetDeps +} from '../GitCiStatus'; + +function prWithChecks(state: GitCiState, failing: number, pending: number, success: number) { + return { + number: 123, + reviewDecision: '', + state: 'OPEN', + title: 'Fix authentication bug', + url: 'https://github.com/owner/repo/pull/123', + checks: { state, failing, pending, success } + }; +} + +const PASSING_PR = prWithChecks('passing', 0, 0, 5); + +function createDeps(overrides: Partial = {}): GitCiStatusWidgetDeps { + return { + fetchGitReviewData: () => PASSING_PR, + getProcessCwd: () => '/tmp/process-cwd', + isInsideGitWorkTree: () => true, + resolveGitCwd: context => context.data?.cwd, + ...overrides + }; +} + +function render( + options: { cwd?: string; hideNoGit?: boolean; isPreview?: boolean; rawValue?: boolean } = {}, + depOverrides: Partial = {} +): string | null { + const widget = new GitCiStatusWidget(createDeps(depOverrides)); + const context: RenderContext = { + data: options.cwd ? { cwd: options.cwd } : undefined, + isPreview: options.isPreview + }; + const item: WidgetItem = { + id: 'git-ci-status', + metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined, + rawValue: options.rawValue, + type: 'git-ci-status' + }; + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('GitCiStatusWidget', () => { + it('renders preview', () => { + expect(render({ isPreview: true })).toBe('✗1 ●1 ✓5'); + }); + + it('renders preview rawValue as the state word', () => { + expect(render({ isPreview: true, rawValue: true })).toBe('failing'); + }); + + it.each([ + ['all green', prWithChecks('passing', 0, 0, 5), '✓5'], + ['failing only', prWithChecks('failing', 1, 0, 4), '✗1 ✓4'], + ['pending only', prWithChecks('pending', 0, 3, 2), '●3 ✓2'], + ['mixed', prWithChecks('failing', 1, 1, 97), '✗1 ●1 ✓97'], + ['zeros are hidden', prWithChecks('failing', 2, 0, 0), '✗2'] + ])('renders %s as non-zero glyph + count', (_label, pr, expected) => { + expect(render({ cwd: '/tmp/repo' }, { fetchGitReviewData: () => pr })).toBe(expected); + }); + + it('falls back to ✓0 when only skipped/neutral checks exist', () => { + const allIgnored = prWithChecks('passing', 0, 0, 0); + expect(render({ cwd: '/tmp/repo' }, { fetchGitReviewData: () => allIgnored })).toBe('✓0'); + }); + + it.each([ + ['passing', prWithChecks('passing', 0, 0, 5), 'passing'], + ['failing', prWithChecks('failing', 1, 0, 4), 'failing'], + ['pending', prWithChecks('pending', 0, 3, 2), 'pending'] + ])('renders rawValue %s as the state word', (_label, pr, expected) => { + expect(render({ cwd: '/tmp/repo', rawValue: true }, { fetchGitReviewData: () => pr })).toBe(expected); + }); + + it('renders "-" when no PR exists', () => { + expect(render({ cwd: '/tmp/repo' }, { fetchGitReviewData: () => null })).toBe('-'); + }); + + it('renders "-" when the PR has no checks', () => { + const noChecks = { ...PASSING_PR, checks: undefined }; + expect(render({ cwd: '/tmp/repo' }, { fetchGitReviewData: () => noChecks })).toBe('-'); + }); + + it('returns (no git) when not in a git repo', () => { + expect(render({ cwd: '/x' }, { isInsideGitWorkTree: () => false })).toBe('(no git)'); + }); + + it('returns null when hideNoGit and not in a git repo', () => { + expect(render({ cwd: '/x', hideNoGit: true }, { isInsideGitWorkTree: () => false })).toBeNull(); + }); + + it('uses process cwd when repo path is omitted', () => { + const fetchGitReviewData = vi.fn(() => PASSING_PR); + render({}, { fetchGitReviewData, resolveGitCwd: () => undefined }); + expect(fetchGitReviewData).toHaveBeenCalledWith('/tmp/process-cwd'); + }); +}); diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 518e3c1d..6f703289 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -7,6 +7,7 @@ export { GitDeletionsWidget } from './GitDeletions'; export { GitStagedFilesWidget } from './GitStagedFiles'; export { GitUnstagedFilesWidget } from './GitUnstagedFiles'; export { GitUntrackedFilesWidget } from './GitUntrackedFiles'; +export { GitCiStatusWidget } from './GitCiStatus'; export { GitCleanStatusWidget } from './GitCleanStatus'; export { GitRootDirWidget } from './GitRootDir'; export { GitPrWidget } from './GitPr';