Skip to content

Commit 7a87e49

Browse files
fix(adapter-github): stable tiebreaker for commit-email canonical ranking (#19)
extractEmailsFromCommits sorted by (personal-vs-work, frequency desc) but ties fell back to Map insertion order, which mirrors the GitHub commits API response order. Since the first email becomes the canonical email used in identity resolution, a tie could flip canonicalId across runs — the same class of bug as #10 (selectTopRepos), one layer downstream. Add a final localeCompare tiebreaker so equal-class, equal-frequency emails sort deterministically by string. The test mirrors the H-10 pattern: two equal-frequency personal emails, shuffled via a deterministic LCG across 100 seeds, asserting the top-1 canonical email is identical every time. Verified RED (returned insertion-order 'zoe@gmail.com') before the fix, GREEN after. Verification: adapter-github 37/37, full suite 793 passing / 1 skipped, lint 14/14, typecheck 27/27, build 14/14. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 078ab51 commit 7a87e49

2 files changed

Lines changed: 50 additions & 1 deletion

File tree

packages/adapters/adapter-github/src/__tests__/github-adapter.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,52 @@ describe('selectTopRepos (H-10 stable sort)', () => {
403403
});
404404
});
405405

406+
describe('extractEmailsFromCommits (H-10 follow-up: stable tiebreaker)', () => {
407+
// LCG-based shuffle so test outcome is reproducible (mirrors the H-10 test).
408+
function deterministicShuffle<T>(arr: readonly T[], seed: number): T[] {
409+
const a = [...arr];
410+
let s = seed >>> 0;
411+
for (let i = a.length - 1; i > 0; i--) {
412+
s = (s * 1103515245 + 12345) >>> 0;
413+
const j = s % (i + 1);
414+
[a[i], a[j]] = [a[j], a[i]];
415+
}
416+
return a;
417+
}
418+
419+
function makeCommit(email: string): GitHubCommit {
420+
return {
421+
sha: `sha-${email}`,
422+
commit: {
423+
author: { name: 'Dev', email, date: '2026-01-01T00:00:00Z' },
424+
message: 'commit',
425+
},
426+
html_url: 'https://github.com/u/r/commit/sha',
427+
};
428+
}
429+
430+
it('produces identical top-1 canonical email across 100 shuffles when frequency ties', () => {
431+
// Both emails are personal (gmail) and tie at frequency 2. Without a string
432+
// tiebreaker, the commit API response order decides which becomes the
433+
// canonical email used in identity resolution — flipping canonicalId across runs.
434+
const commits: GitHubCommit[] = [
435+
makeCommit('zoe@gmail.com'),
436+
makeCommit('zoe@gmail.com'),
437+
makeCommit('amy@gmail.com'),
438+
makeCommit('amy@gmail.com'),
439+
];
440+
441+
const expected = extractEmailsFromCommits(commits);
442+
// localeCompare ascending → 'amy' sorts before 'zoe' deterministically.
443+
expect(expected[0]).toBe('amy@gmail.com');
444+
445+
for (let seed = 1; seed <= 100; seed++) {
446+
const shuffled = deterministicShuffle(commits, seed);
447+
expect(extractEmailsFromCommits(shuffled)[0]).toBe(expected[0]);
448+
}
449+
});
450+
});
451+
406452
describe('GitHubClient API contract validation (H-11)', () => {
407453
it('throws ApiContractError with the missing field path', async () => {
408454
mockFetch.mockImplementation(async () => ({

packages/adapters/adapter-github/src/parsers.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@ export function extractEmailsFromCommits(commits: GitHubCommit[]): string[] {
4646
const aPersonal = isPersonalEmail(a[0]) ? 0 : 1;
4747
const bPersonal = isPersonalEmail(b[0]) ? 0 : 1;
4848
if (aPersonal !== bPersonal) return aPersonal - bPersonal;
49-
return b[1] - a[1]; // higher frequency first
49+
if (b[1] !== a[1]) return b[1] - a[1]; // higher frequency first
50+
// Deterministic tiebreaker: equal-class, equal-frequency emails sort by
51+
// string so the canonical email can't flip with commit API order (#19).
52+
return a[0].localeCompare(b[0]);
5053
})
5154
.map(([email]) => email);
5255
}

0 commit comments

Comments
 (0)