import { afterEach, beforeEach, describe, expect, mock, test, vi } from 'bun:test'; const listMock = mock(async () => ({ data: [] })); mock.module('../git/index.js', () => ({ getRemotes: async () => [], getStatus: async () => null, })); mock.module('./repo/index.js', () => ({ resolveGitHubRepoFromDirectory: async () => null, })); mock.module('./rate-limit.js', () => ({ noteIfGitHubRateLimit: () => {}, })); const { findBranchPrCandidates, invalidateRepoPullsCache } = await import('./pr-status.js'); const openPr = { number: 15, state: 'open', head: { ref: 'feature', label: 'acme:feature', user: { login: 'acme' }, repo: { owner: { login: 'acme' }, name: 'app' }, }, }; const mergedPr = { number: 12, state: 'closed', merged_at: '2026-01-01T00:00:00Z', head: { ref: 'feature', label: 'acme:feature', user: { login: 'acme' }, repo: { owner: { login: 'acme' }, name: 'app' }, }, }; const olderMergedPr = { ...mergedPr, number: 7, merged_at: '2025-11-01T00:00:00Z', }; const call = (overrides = {}) => findBranchPrCandidates({ octokit: { rest: { pulls: { list: listMock } } }, target: { repo: { owner: 'acme', repo: 'app' }, remoteName: 'origin' }, branch: 'feature', sourceCandidates: [{ repo: { owner: 'acme', repo: 'app' } }], force: true, includeHistory: true, ...overrides, }); describe('findBranchPrCandidates', () => { beforeEach(() => { listMock.mockReset(); invalidateRepoPullsCache('acme', 'app'); }); afterEach(() => { vi.useRealTimers(); }); test('an open PR wins and no history lookup is spent', async () => { listMock.mockImplementation(async ({ state }) => ( state === 'open' ? { data: [openPr] } : { data: [mergedPr] } )); const { open, historical } = await call(); expect(open?.number).toBe(15); expect(historical).toBeNull(); expect(listMock.mock.calls.every((entry) => entry[0]?.state === 'open')).toBe(true); }); test('an open PR still wins when the shared open list missed it', async () => { // A repo with more than one page of open PRs: the shared list is incomplete, // so the per-head query is the one that must find the open PR. listMock.mockImplementation(async ({ head }) => ( head ? { data: [mergedPr, openPr] } : { data: new Array(100).fill(null).map((_, index) => ({ number: index, state: 'open', head: { ref: 'other' } })) } )); const { open, historical } = await call(); expect(open?.number).toBe(15); expect(historical).toBeNull(); }); test('returns the branch history when no open PR exists', async () => { listMock.mockImplementation(async ({ head }) => ( head ? { data: [olderMergedPr, mergedPr] } : { data: [] } )); const { open, historical } = await call(); expect(open).toBeNull(); // The newest past PR for the head is the relevant record. expect(historical?.number).toBe(12); }); test('returns no history for a branch that never had a PR', async () => { listMock.mockImplementation(async () => ({ data: [] })); const { open, historical } = await call(); expect(open).toBeNull(); expect(historical).toBeNull(); expect(listMock.mock.calls.some((entry) => entry[0]?.state === 'all')).toBe(true); }); test('spends no call on history for a secondary target', async () => { listMock.mockImplementation(async ({ head }) => ( head ? { data: [mergedPr] } : { data: [] } )); const { open, historical } = await call({ includeHistory: false }); expect(open).toBeNull(); expect(historical).toBeNull(); // The complete open list already answered the only question that matters // for a secondary repo in the fork network. expect(listMock.mock.calls).toHaveLength(1); expect(listMock.mock.calls[0]?.[0]?.state).toBe('open'); }); test('reuses the cached history instead of re-querying every poll', async () => { listMock.mockImplementation(async ({ head }) => ( head ? { data: [mergedPr] } : { data: [] } )); await call(); const callsAfterFirst = listMock.mock.calls.length; // A non-forced poll is answered entirely from the shared open list cache // plus the remembered history — no extra GitHub call. const { open, historical } = await call({ force: false }); expect(open).toBeNull(); expect(historical?.number).toBe(12); expect(listMock.mock.calls.length).toBe(callsAfterFirst); }); test('a found record outlives the shorter "no history" window', async () => { const startedAt = Date.now(); listMock.mockImplementation(async ({ head }) => ( head ? { data: [mergedPr] } : { data: [] } )); await call(); const callsAfterFirst = listMock.mock.calls.length; // Past the "no history" expiry, but far short of the found-record one. The // shared open list is re-fetched; the history answer is not re-queried. vi.useFakeTimers(); vi.setSystemTime(new Date(startedAt + 30 * 60 * 1000)); const { historical } = await call({ force: false }); expect(historical?.number).toBe(12); expect(listMock.mock.calls.length).toBe(callsAfterFirst + 1); expect(listMock.mock.calls.at(-1)?.[0]?.state).toBe('open'); }); test('re-queries a branch with no history once its shorter window passes', async () => { const startedAt = Date.now(); listMock.mockImplementation(async () => ({ data: [] })); await call(); const callsAfterFirst = listMock.mock.calls.length; vi.useFakeTimers(); vi.setSystemTime(new Date(startedAt + 30 * 60 * 1000)); await call({ force: false }); expect(listMock.mock.calls.some((entry) => entry[0]?.state === 'all')).toBe(true); expect(listMock.mock.calls.length).toBeGreaterThan(callsAfterFirst + 1); }); });