|
| 1 | +/** |
| 2 | + * Tests for the PR lookup second pass logic. |
| 3 | + * We test the core algorithm in isolation since fetchUserActivity is tightly coupled to GitHub API. |
| 4 | + */ |
| 5 | + |
| 6 | +describe('PR lookup second pass', () => { |
| 7 | + // Simulate the commit filtering logic |
| 8 | + function getUnmatchedCommits(commits: Array<{ sha: string; prNumber: number | null }>) { |
| 9 | + return commits.filter(c => c.prNumber === null); |
| 10 | + } |
| 11 | + |
| 12 | + it('identifies commits without PR association', () => { |
| 13 | + const commits = [ |
| 14 | + { sha: 'a1', prNumber: 1 }, |
| 15 | + { sha: 'a2', prNumber: null }, |
| 16 | + { sha: 'a3', prNumber: 2 }, |
| 17 | + { sha: 'a4', prNumber: null }, |
| 18 | + ]; |
| 19 | + const unmatched = getUnmatchedCommits(commits); |
| 20 | + expect(unmatched).toHaveLength(2); |
| 21 | + expect(unmatched.map(c => c.sha)).toEqual(['a2', 'a4']); |
| 22 | + }); |
| 23 | + |
| 24 | + it('returns empty when all commits have PRs', () => { |
| 25 | + const commits = [ |
| 26 | + { sha: 'a1', prNumber: 1 }, |
| 27 | + { sha: 'a2', prNumber: 2 }, |
| 28 | + ]; |
| 29 | + expect(getUnmatchedCommits(commits)).toHaveLength(0); |
| 30 | + }); |
| 31 | + |
| 32 | + it('returns all when no commits have PRs', () => { |
| 33 | + const commits = [ |
| 34 | + { sha: 'a1', prNumber: null }, |
| 35 | + { sha: 'a2', prNumber: null }, |
| 36 | + ]; |
| 37 | + expect(getUnmatchedCommits(commits)).toHaveLength(2); |
| 38 | + }); |
| 39 | + |
| 40 | + // Simulate the limit guard |
| 41 | + function shouldRunLookup(unmatchedCount: number, limit = 200): boolean { |
| 42 | + return unmatchedCount > 0 && unmatchedCount <= limit; |
| 43 | + } |
| 44 | + |
| 45 | + it('runs lookup when unmatched count is within limit', () => { |
| 46 | + expect(shouldRunLookup(50)).toBe(true); |
| 47 | + expect(shouldRunLookup(200)).toBe(true); |
| 48 | + expect(shouldRunLookup(1)).toBe(true); |
| 49 | + }); |
| 50 | + |
| 51 | + it('skips lookup when unmatched count exceeds limit', () => { |
| 52 | + expect(shouldRunLookup(201)).toBe(false); |
| 53 | + expect(shouldRunLookup(500)).toBe(false); |
| 54 | + }); |
| 55 | + |
| 56 | + it('skips lookup when no unmatched commits', () => { |
| 57 | + expect(shouldRunLookup(0)).toBe(false); |
| 58 | + }); |
| 59 | + |
| 60 | + // Simulate the PR matching from API response |
| 61 | + function applyPrLookupResult( |
| 62 | + commit: { sha: string; prNumber: number | null; prTitle: string | null }, |
| 63 | + apiResponse: Array<{ number: number; title: string }>, |
| 64 | + ) { |
| 65 | + if (apiResponse.length > 0) { |
| 66 | + commit.prNumber = apiResponse[0].number; |
| 67 | + commit.prTitle = apiResponse[0].title; |
| 68 | + } |
| 69 | + } |
| 70 | + |
| 71 | + it('updates commit with PR info from API response', () => { |
| 72 | + const commit = { sha: 'a1', prNumber: null as number | null, prTitle: null as string | null }; |
| 73 | + applyPrLookupResult(commit, [{ number: 8, title: 'feat: scheduling' }]); |
| 74 | + expect(commit.prNumber).toBe(8); |
| 75 | + expect(commit.prTitle).toBe('feat: scheduling'); |
| 76 | + }); |
| 77 | + |
| 78 | + it('leaves commit unchanged when API returns empty', () => { |
| 79 | + const commit = { sha: 'a1', prNumber: null as number | null, prTitle: null as string | null }; |
| 80 | + applyPrLookupResult(commit, []); |
| 81 | + expect(commit.prNumber).toBeNull(); |
| 82 | + expect(commit.prTitle).toBeNull(); |
| 83 | + }); |
| 84 | + |
| 85 | + it('uses first PR when API returns multiple', () => { |
| 86 | + const commit = { sha: 'a1', prNumber: null as number | null, prTitle: null as string | null }; |
| 87 | + applyPrLookupResult(commit, [ |
| 88 | + { number: 8, title: 'first PR' }, |
| 89 | + { number: 9, title: 'second PR' }, |
| 90 | + ]); |
| 91 | + expect(commit.prNumber).toBe(8); |
| 92 | + expect(commit.prTitle).toBe('first PR'); |
| 93 | + }); |
| 94 | + |
| 95 | + // Simulate the PR message pattern matching (first pass) |
| 96 | + function matchPrFromMessage(message: string): number | null { |
| 97 | + const match = message.match(/\(#(\d+)\)/) || message.match(/^Merge pull request #(\d+)/); |
| 98 | + return match ? Number(match[1]) : null; |
| 99 | + } |
| 100 | + |
| 101 | + it('matches PR from squash merge message pattern', () => { |
| 102 | + expect(matchPrFromMessage('feat: add feature (#42)')).toBe(42); |
| 103 | + }); |
| 104 | + |
| 105 | + it('matches PR from merge commit message pattern', () => { |
| 106 | + expect(matchPrFromMessage('Merge pull request #7 from org/branch')).toBe(7); |
| 107 | + }); |
| 108 | + |
| 109 | + it('returns null for commits without PR reference', () => { |
| 110 | + expect(matchPrFromMessage('feat: add feature')).toBeNull(); |
| 111 | + expect(matchPrFromMessage('Updated todo')).toBeNull(); |
| 112 | + expect(matchPrFromMessage('fix(scheduling): add sidebar')).toBeNull(); |
| 113 | + }); |
| 114 | + |
| 115 | + it('returns null for empty message', () => { |
| 116 | + expect(matchPrFromMessage('')).toBeNull(); |
| 117 | + }); |
| 118 | + |
| 119 | + // End-to-end simulation |
| 120 | + it('full flow: first pass misses, second pass catches', () => { |
| 121 | + const commits = [ |
| 122 | + { sha: 'a1', message: 'feat: add auth (#5)', prNumber: null as number | null, prTitle: null as string | null, repo: 'r' }, |
| 123 | + { sha: 'a2', message: 'Updated todo', prNumber: null as number | null, prTitle: null as string | null, repo: 'r' }, |
| 124 | + { sha: 'a3', message: 'feat(scheduling): add sidebar', prNumber: null as number | null, prTitle: null as string | null, repo: 'r' }, |
| 125 | + ]; |
| 126 | + |
| 127 | + // First pass: message matching |
| 128 | + for (const c of commits) { |
| 129 | + const pr = matchPrFromMessage(c.message); |
| 130 | + if (pr) { c.prNumber = pr; c.prTitle = `PR #${pr}`; } |
| 131 | + } |
| 132 | + |
| 133 | + expect(commits[0].prNumber).toBe(5); // matched by message |
| 134 | + expect(commits[1].prNumber).toBeNull(); // no match |
| 135 | + expect(commits[2].prNumber).toBeNull(); // no match |
| 136 | + |
| 137 | + // Second pass: API lookup (simulated) |
| 138 | + const unmatched = commits.filter(c => c.prNumber === null); |
| 139 | + expect(unmatched).toHaveLength(2); |
| 140 | + |
| 141 | + const mockApiResults: Record<string, Array<{ number: number; title: string }>> = { |
| 142 | + 'a2': [{ number: 8, title: 'scheduling feature' }], |
| 143 | + 'a3': [{ number: 8, title: 'scheduling feature' }], |
| 144 | + }; |
| 145 | + |
| 146 | + for (const c of unmatched) { |
| 147 | + applyPrLookupResult(c, mockApiResults[c.sha] || []); |
| 148 | + } |
| 149 | + |
| 150 | + expect(commits[1].prNumber).toBe(8); // now matched |
| 151 | + expect(commits[2].prNumber).toBe(8); // now matched |
| 152 | + expect(commits.every(c => c.prNumber !== null)).toBe(true); |
| 153 | + }); |
| 154 | +}); |
0 commit comments