diff --git a/platform/lib/queries/personal-ai-usage.ts b/platform/lib/queries/personal-ai-usage.ts index 5d656d6..7945392 100644 --- a/platform/lib/queries/personal-ai-usage.ts +++ b/platform/lib/queries/personal-ai-usage.ts @@ -15,6 +15,29 @@ import type { ReportMetrics } from "@/types/metrics"; // (typically one push per CI run), not on calendar days of history. const HISTORY_DEPTH = 12; +export interface AuthorIdentity { + name: string; + email: string | null; +} + +/** + * The three tiers of evidence that an author row belongs to the current user, + * strongest first. + * + * `emails` is the only tier git itself guarantees: it deduplicates authors by + * email and the engine preserves it on every row. `names` is the account's + * declared display name — good enough to catch a second git identity, but a + * namesake shares it. `emailLocalParts` is a guess derived from the account + * email (`dev` out of `dev@example.com`) and is the weakest of all: generic + * local parts collide with bots and service accounts, so it is consulted only + * for payloads where no row matched on email at all. + */ +export interface UserIdentityCandidates { + emails: Set; + names: Set; + emailLocalParts: Set; +} + export interface PerRepoUsage { organizationSlug: string; organizationName: string; @@ -22,8 +45,7 @@ export interface PerRepoUsage { repositoryId: string; aiCommitPct: number; totalCommits: number; - matchedAuthorName: string; - matchedAuthorEmail: string | null; + matchedIdentities: AuthorIdentity[]; matchedBy: "email" | "name"; highVelocityWeeks: number; lastSeenAt: string; @@ -64,38 +86,194 @@ interface RepoRow { organization_id: string; } +type PayloadAuthor = NonNullable< + ReportMetrics["author_velocity"] +>["authors"][number]; + +export interface MatchedAuthor { + author: PayloadAuthor; + matchedBy: "email" | "name"; +} + +export interface AggregatedUsage { + totalCommits: number; + aiCommitPct: number; + highVelocityWeeks: number; + matchedBy: "email" | "name"; + identities: AuthorIdentity[]; +} + +interface WeekTotals { + commits: number; + aiCommits: number; + hasAiData: boolean; +} + function nameKey(value: string): string { return value.trim().toLowerCase(); } -// Match the current user against the engine's per-author rows. Email is the -// reliable identity — git deduplicates authors by email and the engine -// preserves it on every row. Name match remains as a fallback for older -// payloads (pre-email field) and for authors whose commits lack an email -// (rare, falls back to author string). Returning the matched method lets -// callers expose it in the UI so the user can sanity-check what attributed -// to them. -function pickUserAuthor( +/** + * Build the candidate sets for one account, split by how much each signal can + * be trusted. See {@link UserIdentityCandidates}. + */ +export function buildIdentityCandidates(user: { + name: string | null; + email: string | null; +}): UserIdentityCandidates { + const emails = new Set(); + const names = new Set(); + const emailLocalParts = new Set(); + + if (user.email) { + emails.add(nameKey(user.email)); + const localPart = user.email.split("@")[0]; + if (localPart) emailLocalParts.add(nameKey(localPart)); + } + if (user.name) names.add(nameKey(user.name)); + + return { emails, names, emailLocalParts }; +} + +/** + * Every author row in `payload` that belongs to the current user. + * + * One person routinely commits under more than one git identity in the same + * repo: the local `git config user.email` for their own work, and the GitHub + * account's primary email for merges and edits made through the web UI. + * Returning only the first hit pins the user to whichever identity happens to + * come first, so a one-commit identity can hide a several-hundred-commit one + * and report 0% AI for an otherwise fully AI-assisted repo (issue #193). + * + * Because the account carries a single email, that second identity can only + * ever be recovered by display name, so a name hit has to count alongside an + * email hit rather than only when the email finds nothing. The cost is that a + * true namesake in the same repo is absorbed into the user's row; the match is + * reported back as `"name"` so callers can warn about it. + * + * The email local part does not get that latitude. It is a guess, not a + * declared identity, so it is a last resort in the strict sense: it runs only + * when neither tier above placed the user in this payload at all, where it is + * the difference between a fallback and an empty page. Any anchor suppresses + * it — a display-name hit proves the user is present in this repo just as an + * email hit does, and past that point the guess can only over-attribute a bot + * or a service account whose git name happens to be a generic local part. + */ +export function matchUserAuthors( payload: ReportMetrics | null, - emailCandidates: Set, - nameCandidates: Set, -): { - author: NonNullable["authors"][number]; - matchedBy: "email" | "name"; -} | null { + candidates: UserIdentityCandidates, +): MatchedAuthor[] { const authors = payload?.author_velocity?.authors; - if (!authors) return null; - for (const a of authors) { - if (a.email && emailCandidates.has(nameKey(a.email))) { - return { author: a, matchedBy: "email" }; + if (!authors) return []; + + const matched: MatchedAuthor[] = []; + for (const author of authors) { + if (author.email && candidates.emails.has(nameKey(author.email))) { + matched.push({ author, matchedBy: "email" }); + } else if (candidates.names.has(nameKey(author.name))) { + matched.push({ author, matchedBy: "name" }); } } - for (const a of authors) { - if (nameCandidates.has(nameKey(a.name))) { - return { author: a, matchedBy: "name" }; + + if (matched.length > 0) return matched; + + for (const author of authors) { + if (candidates.emailLocalParts.has(nameKey(author.name))) { + matched.push({ author, matchedBy: "name" }); } } - return null; + + return matched; +} + +/** + * Collapse the user's identities within one push into the single row the + * per-repo table shows. + * + * `aiCommitPct` is weighted by commit count so a stray one-commit identity + * cannot drag the share of a large one down. Each identity weighs at least 1: + * payloads from iris < 1.0.2 carry no `total_commits`, and a floor keeps them + * on the same formula instead of a second code path — one such identity + * reproduces its own share exactly, several collapse to the plain mean, and + * the denominator can never reach zero. `totalCommits` stays the honest sum of + * what the payload actually reported, so those rows still show 0 commits. + * + * The floor treats a missing `total_commits` and a reported zero alike, which + * is safe because the engine emits an author row only for an author it counted + * at least one commit for: `compute_author_velocity` sums the row's total from + * its weekly buckets, and a bucket exists only where a commit landed. A weight + * of zero therefore always means "field absent", never "this person committed + * nothing". Should that invariant ever change, splitting the two cases has to + * come with a guard for an all-zero denominator. + * + * `highVelocityWeeks` takes the maximum rather than the sum: the engine counts + * weeks, and the same calendar week can appear under two identities. Summing + * would double-count it, and recomputing from `weekly` would duplicate the + * engine's threshold logic here. + * + * `matchedBy` reports "email" only when every identity matched on email. If + * any part of the row rests on a display-name match, the whole row carries the + * weaker guarantee and the UI should say so. + */ +export function aggregateAuthors( + matches: MatchedAuthor[], +): AggregatedUsage | null { + if (matches.length === 0) return null; + + let totalCommits = 0; + let weightSum = 0; + let weightedPctSum = 0; + let highVelocityWeeks = 0; + let everyMatchByEmail = true; + const identities: AuthorIdentity[] = []; + + for (const { author, matchedBy } of matches) { + const commits = author.total_commits ?? 0; + const weight = Math.max(commits, 1); + totalCommits += commits; + weightSum += weight; + weightedPctSum += author.ai_commit_pct * weight; + highVelocityWeeks = Math.max(highVelocityWeeks, author.high_velocity_weeks); + if (matchedBy === "name") everyMatchByEmail = false; + identities.push({ name: author.name, email: author.email ?? null }); + } + + return { + totalCommits, + aiCommitPct: weightedPctSum / weightSum, + highVelocityWeeks, + matchedBy: everyMatchByEmail ? "email" : "name", + identities, + }; +} + +/** + * Merge one push's weekly arrays across every identity the user commits under, + * so a week split between two identities becomes a single entry instead of two + * competing ones. + */ +function mergeWeeklyAcrossIdentities( + matches: MatchedAuthor[], +): Map { + const merged = new Map(); + + for (const { author } of matches) { + for (const week of author.weekly ?? []) { + const totals = merged.get(week.week_start) ?? { + commits: 0, + aiCommits: 0, + hasAiData: false, + }; + totals.commits += week.commits; + if (typeof week.ai_commits === "number") { + totals.aiCommits += week.ai_commits; + totals.hasAiData = true; + } + merged.set(week.week_start, totals); + } + } + + return merged; } // Weekly AI commit share aggregated across each repo's full fetched history, @@ -111,8 +289,7 @@ function pickUserAuthor( // no AI share for those weeks. export function buildUsageTrend( rowsPerRepo: Map, - emailCandidates: Set, - nameCandidates: Set, + candidates: UserIdentityCandidates, ): UsageTrendPoint[] { type WeekBucket = { commits: number; @@ -129,29 +306,24 @@ export function buildUsageTrend( // skipped. const seenWeeks = new Set(); for (const row of rows) { - const match = pickUserAuthor( - row.payload, - emailCandidates, - nameCandidates, - ); - if (!match?.author.weekly) continue; - for (const w of match.author.weekly) { - if (seenWeeks.has(w.week_start)) continue; - seenWeeks.add(w.week_start); - - const bucket: WeekBucket = weekly.get(w.week_start) ?? { + const matches = matchUserAuthors(row.payload, candidates); + if (matches.length === 0) continue; + + for (const [weekStart, totals] of mergeWeeklyAcrossIdentities(matches)) { + if (seenWeeks.has(weekStart)) continue; + seenWeeks.add(weekStart); + + const bucket: WeekBucket = weekly.get(weekStart) ?? { commits: 0, aiCommits: 0, repoIds: new Set(), hasAiData: false, }; - bucket.commits += w.commits; - if (typeof w.ai_commits === "number") { - bucket.aiCommits += w.ai_commits; - bucket.hasAiData = true; - } + bucket.commits += totals.commits; + bucket.aiCommits += totals.aiCommits; + if (totals.hasAiData) bucket.hasAiData = true; bucket.repoIds.add(repoId); - weekly.set(w.week_start, bucket); + weekly.set(weekStart, bucket); } } } @@ -183,18 +355,14 @@ export async function getPersonalAIUsage( if (orgs.length === 0) return empty; - // Email match is the reliable identity. Name match is a fallback for - // older payloads (pre-email field) and unusual cases. - const emailCandidates = new Set(); - if (user.email) emailCandidates.add(nameKey(user.email)); - - const nameCandidates = new Set(); - if (user.name) nameCandidates.add(nameKey(user.name)); - if (user.email) { - const localPart = user.email.split("@")[0]; - if (localPart) nameCandidates.add(nameKey(localPart)); + const candidates = buildIdentityCandidates(user); + if ( + candidates.emails.size === 0 && + candidates.names.size === 0 && + candidates.emailLocalParts.size === 0 + ) { + return empty; } - if (emailCandidates.size === 0 && nameCandidates.size === 0) return empty; const orgIds = orgs.map((o) => o.id); const orgIndex = new Map(orgs.map((o) => [o.id, o])); @@ -237,8 +405,8 @@ export async function getPersonalAIUsage( for (const [repoId, rows] of rowsPerRepo) { const row = rows[0]; // newest row — summary table shows current snapshot only. - const match = pickUserAuthor(row.payload, emailCandidates, nameCandidates); - if (!match) continue; + const usage = aggregateAuthors(matchUserAuthors(row.payload, candidates)); + if (!usage) continue; const repo = repoIndex.get(repoId); const org = orgIndex.get(row.organization_id); if (!repo || !org) continue; @@ -248,21 +416,19 @@ export async function getPersonalAIUsage( organizationName: org.name, repositoryName: repo.name, repositoryId: repoId, - aiCommitPct: match.author.ai_commit_pct, - totalCommits: match.author.total_commits ?? 0, - matchedAuthorName: match.author.name, - matchedAuthorEmail: match.author.email ?? null, - matchedBy: match.matchedBy, - highVelocityWeeks: match.author.high_velocity_weeks, + aiCommitPct: usage.aiCommitPct, + totalCommits: usage.totalCommits, + matchedIdentities: usage.identities, + matchedBy: usage.matchedBy, + highVelocityWeeks: usage.highVelocityWeeks, lastSeenAt: row.created_at, }); - aiSum += match.author.ai_commit_pct; + aiSum += usage.aiCommitPct; aiCount += 1; - if (match.author.high_velocity_weeks > maxHv) - maxHv = match.author.high_velocity_weeks; + if (usage.highVelocityWeeks > maxHv) maxHv = usage.highVelocityWeeks; } - const trend = buildUsageTrend(rowsPerRepo, emailCandidates, nameCandidates); + const trend = buildUsageTrend(rowsPerRepo, candidates); perRepo.sort((a, b) => b.aiCommitPct - a.aiCommitPct); diff --git a/platform/lib/translations.ts b/platform/lib/translations.ts index 940c10d..0d7fd3f 100644 --- a/platform/lib/translations.ts +++ b/platform/lib/translations.ts @@ -255,7 +255,7 @@ export const translations = { lastSeen: "Last seen", nameMatchBadge: "name-only", nameMatchWarning: - "Matched by author name, not email — verify this is really you.", + "At least one git identity in this row was matched by author name, not email — verify this is really you.", }, coverageNote: "Tool-level and intent-level breakdowns per author aren't in the engine yet. When they are, this page will pick them up automatically.", @@ -1657,7 +1657,7 @@ export const translations = { lastSeen: "Última vez visto", nameMatchBadge: "só por nome", nameMatchWarning: - "Atribuído pelo nome do autor, sem email — vale conferir se é você mesmo.", + "Pelo menos uma identidade git desta linha foi atribuída pelo nome do autor, sem email — vale conferir se é você mesmo.", }, coverageNote: "Detalhamento por ferramenta e por intenção, no nível de autor, ainda não está no motor. Quando estiver, esta página vai puxar automaticamente.", diff --git a/platform/src/app/me/ai-usage/page.tsx b/platform/src/app/me/ai-usage/page.tsx index 6b4d606..0feda6c 100644 --- a/platform/src/app/me/ai-usage/page.tsx +++ b/platform/src/app/me/ai-usage/page.tsx @@ -166,11 +166,14 @@ export default async function PersonalAIUsagePage({ > ` - : `Matched via ${row.matchedBy}: ${row.matchedAuthorName}` - } + title={[ + `Matched via ${row.matchedBy}:`, + ...row.matchedIdentities.map((identity) => + identity.email + ? `${identity.name} <${identity.email}>` + : identity.name, + ), + ].join("\n")} > {row.repositoryName} {row.matchedBy === "name" && ( diff --git a/platform/tests/personal-ai-usage.test.ts b/platform/tests/personal-ai-usage.test.ts index 6ddd221..13a17ae 100644 --- a/platform/tests/personal-ai-usage.test.ts +++ b/platform/tests/personal-ai-usage.test.ts @@ -1,19 +1,31 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; import { describe, expect, it } from "vitest"; import { + aggregateAuthors, + buildIdentityCandidates, buildUsageTrend, + getPersonalAIUsage, + matchUserAuthors, + type MatchedAuthor, type MetricRow, } from "@/lib/queries/personal-ai-usage"; import type { ReportMetrics } from "@/types/metrics"; -const EMAIL = new Set(["dev@example.com"]); -const NAME = new Set(); +const ACCOUNT = { name: "Dev", email: "dev@example.com" }; +const CANDIDATES = buildIdentityCandidates(ACCOUNT); -function row( - createdAt: string, - weekly: Array<{ week_start: string; commits: number; ai_commits?: number }>, -): MetricRow { - const payload: ReportMetrics = { +interface AuthorInput { + name: string; + email?: string; + total_commits?: number; + high_velocity_weeks?: number; + ai_commit_pct?: number; + weekly?: Array<{ week_start: string; commits: number; ai_commits?: number }>; +} + +function payloadWith(authors: AuthorInput[]): ReportMetrics { + return { commits_total: 0, commits_revert: 0, revert_rate: 0, @@ -23,32 +35,213 @@ function row( files_stabilized: 0, stabilization_ratio: 0, author_velocity: { - authors: [ - { - name: "Dev", - email: "dev@example.com", - high_velocity_weeks: 0, - ai_commit_pct: 0, - weekly: weekly.map((w) => ({ - week_start: w.week_start, - commits: w.commits, - lines_added: 0, - lines_removed: 0, - ai_commits: w.ai_commits, - })), - }, - ], + authors: authors.map((a) => ({ + name: a.name, + email: a.email, + total_commits: a.total_commits, + high_velocity_weeks: a.high_velocity_weeks ?? 0, + ai_commit_pct: a.ai_commit_pct ?? 0, + weekly: (a.weekly ?? []).map((w) => ({ + week_start: w.week_start, + commits: w.commits, + lines_added: 0, + lines_removed: 0, + ai_commits: w.ai_commits, + })), + })), }, } as ReportMetrics; +} +function row( + createdAt: string, + weekly: Array<{ week_start: string; commits: number; ai_commits?: number }>, +): MetricRow { return { repository_id: "repo-1", - payload, + payload: payloadWith([{ name: "Dev", email: "dev@example.com", weekly }]), created_at: createdAt, organization_id: "org-1", }; } +function multiIdentityRow( + authors: AuthorInput[], + createdAt = "2026-08-01T00:00:00Z", +): MetricRow { + return { + repository_id: "repo-1", + payload: payloadWith(authors), + created_at: createdAt, + organization_id: "org-1", + }; +} + +function fakeSupabase(tables: Record): SupabaseClient { + return { + from(table: string) { + const result = { data: tables[table] ?? [] }; + const chain = { + select: () => chain, + in: () => chain, + eq: () => chain, + order: () => chain, + limit: () => chain, + then: (resolve: (value: unknown) => unknown) => + Promise.resolve(result).then(resolve), + }; + return chain; + }, + } as unknown as SupabaseClient; +} + +const ORGS = [{ id: "org-1", slug: "acme", name: "Acme" }]; + +function authorsOf(authors: AuthorInput[]) { + return payloadWith(authors).author_velocity!.authors; +} + +function matchesFor( + authors: AuthorInput[], + account: { name: string | null; email: string | null } = ACCOUNT, +) { + return matchUserAuthors( + payloadWith(authors), + buildIdentityCandidates(account), + ); +} + +function matchOf( + author: AuthorInput, + matchedBy: "email" | "name" = "email", +): MatchedAuthor { + return { author: authorsOf([author])[0], matchedBy }; +} + +async function usageFor( + authors: AuthorInput[], + account: { name: string | null; email: string | null } = ACCOUNT, +) { + const supabase = fakeSupabase({ + repositories: [{ id: "repo-1", name: "repo-a", organization_id: "org-1" }], + metrics: [ + { + repository_id: "repo-1", + payload: payloadWith(authors), + created_at: "2026-08-01T00:00:00Z", + organization_id: "org-1", + }, + ], + }); + + return getPersonalAIUsage(supabase, account, ORGS); +} + +describe("getPersonalAIUsage identity aggregation", () => { + it("consolidates a secondary email identity with the primary name-matched one", async () => { + // The reported bug (#193): the account email covers only the GitHub web-UI + // identity, which contributed one non-merge commit with no AI attribution, + // while the real work sits under a different git email. + const usage = await usageFor([ + { + name: "Dev", + email: "dev@company.example", + total_commits: 183, + ai_commit_pct: 99.5, + high_velocity_weeks: 4, + }, + { + name: "Dev", + email: "dev@example.com", + total_commits: 1, + ai_commit_pct: 0, + high_velocity_weeks: 0, + }, + ]); + + expect(usage.perRepo).toHaveLength(1); + expect(usage.perRepo[0].totalCommits).toBe(184); + expect(usage.perRepo[0].aiCommitPct).toBeCloseTo(98.96, 1); + expect(usage.perRepo[0].matchedIdentities).toHaveLength(2); + }); + + it("weights the AI share by commits so a one-commit identity cannot drag it down", async () => { + const usage = await usageFor([ + { name: "Dev", total_commits: 99, ai_commit_pct: 100 }, + { + name: "Dev", + email: "dev@example.com", + total_commits: 1, + ai_commit_pct: 0, + }, + ]); + + // A plain mean would report 50%. + expect(usage.perRepo[0].aiCommitPct).toBeCloseTo(99, 5); + }); + + it("takes the maximum high-velocity weeks rather than the sum", async () => { + const usage = await usageFor([ + { name: "Dev", total_commits: 10, high_velocity_weeks: 3 }, + { + name: "Dev", + email: "dev@example.com", + total_commits: 5, + high_velocity_weeks: 2, + }, + ]); + + expect(usage.perRepo[0].highVelocityWeeks).toBe(3); + expect(usage.maxHighVelocityWeeks).toBe(3); + }); + + it("degrades the match to name when any identity matched on name alone", async () => { + const usage = await usageFor([ + { name: "Dev", total_commits: 183, ai_commit_pct: 99.5 }, + { name: "Dev", email: "dev@example.com", total_commits: 1 }, + ]); + + expect(usage.perRepo[0].matchedBy).toBe("name"); + }); + + it("keeps the match at email when every identity matched on email", async () => { + const usage = await usageFor([ + { name: "Someone Else", email: "dev@example.com", total_commits: 12 }, + ]); + + expect(usage.perRepo[0].matchedBy).toBe("email"); + expect(usage.perRepo[0].matchedIdentities).toHaveLength(1); + }); + + it("excludes authors that match neither the email nor the name", async () => { + const usage = await usageFor([ + { name: "Other Dev", email: "other@example.com", total_commits: 500 }, + { name: "Dev", email: "dev@example.com", total_commits: 7 }, + ]); + + expect(usage.perRepo[0].totalCommits).toBe(7); + expect(usage.perRepo[0].matchedIdentities).toHaveLength(1); + }); + + it("falls back to the plain mean when payloads carry no commit counts", async () => { + const usage = await usageFor([ + { name: "Dev", email: "dev@example.com", ai_commit_pct: 40 }, + ]); + + expect(usage.perRepo[0].totalCommits).toBe(0); + expect(usage.perRepo[0].aiCommitPct).toBeCloseTo(40, 5); + }); + + it("reports no match when the user owns none of the author rows", async () => { + const usage = await usageFor([ + { name: "Other Dev", email: "other@example.com", total_commits: 500 }, + ]); + + expect(usage.matched).toBe(false); + expect(usage.perRepo).toHaveLength(0); + }); +}); + describe("buildUsageTrend", () => { it("merges weeks across multiple historical rows for the same repo", () => { // Two non-overlapping pushes, each covering its own analysis window — @@ -67,7 +260,7 @@ describe("buildUsageTrend", () => { ], ]); - const trend = buildUsageTrend(rowsPerRepo, EMAIL, NAME); + const trend = buildUsageTrend(rowsPerRepo, CANDIDATES); expect(trend.map((t) => t.date)).toEqual(["2026-05-25", "2026-07-27"]); expect(trend[0].aiCommitPct).toBeCloseTo(25); @@ -90,7 +283,7 @@ describe("buildUsageTrend", () => { ], ]); - const trend = buildUsageTrend(rowsPerRepo, EMAIL, NAME); + const trend = buildUsageTrend(rowsPerRepo, CANDIDATES); expect(trend).toHaveLength(1); expect(trend[0].aiCommitPct).toBeCloseTo(90); @@ -116,7 +309,7 @@ describe("buildUsageTrend", () => { ], ]); - const trend = buildUsageTrend(rowsPerRepo, EMAIL, NAME); + const trend = buildUsageTrend(rowsPerRepo, CANDIDATES); expect(trend).toHaveLength(1); expect(trend[0].repos).toBe(2); @@ -135,8 +328,422 @@ describe("buildUsageTrend", () => { ], ]); - const trend = buildUsageTrend(rowsPerRepo, EMAIL, NAME); + const trend = buildUsageTrend(rowsPerRepo, CANDIDATES); expect(trend[0].aiCommitPct).toBeNull(); }); + + it("sums both identities when one week is split across them in the same push", () => { + const rowsPerRepo = new Map([ + [ + "repo-1", + [ + multiIdentityRow([ + { + name: "Dev", + email: "dev@company.example", + weekly: [ + { week_start: "2026-07-27", commits: 18, ai_commits: 18 }, + ], + }, + { + name: "Dev", + email: "dev@example.com", + weekly: [{ week_start: "2026-07-27", commits: 2, ai_commits: 0 }], + }, + ]), + ], + ], + ]); + + const trend = buildUsageTrend(rowsPerRepo, CANDIDATES); + + expect(trend).toHaveLength(1); + expect(trend[0].aiCommitPct).toBeCloseTo(90); + }); + + it("does not let a second identity re-open a week already taken from a newer push", () => { + const rowsPerRepo = new Map([ + [ + "repo-1", + [ + multiIdentityRow( + [ + { + name: "Dev", + email: "dev@example.com", + weekly: [ + { week_start: "2026-07-27", commits: 10, ai_commits: 10 }, + ], + }, + ], + "2026-08-01T00:00:00Z", + ), + multiIdentityRow( + [ + { + name: "Dev", + email: "dev@company.example", + weekly: [ + { week_start: "2026-07-27", commits: 90, ai_commits: 0 }, + ], + }, + ], + "2026-07-01T00:00:00Z", + ), + ], + ], + ]); + + const trend = buildUsageTrend(rowsPerRepo, CANDIDATES); + + expect(trend).toHaveLength(1); + expect(trend[0].aiCommitPct).toBeCloseTo(100); + }); +}); + +describe("buildIdentityCandidates", () => { + it("splits the account email into an exact tier and a weaker local-part tier", () => { + const candidates = buildIdentityCandidates({ + name: "Dev Real Name", + email: "dev@example.com", + }); + + expect([...candidates.emails]).toEqual(["dev@example.com"]); + expect([...candidates.names]).toEqual(["dev real name"]); + expect([...candidates.emailLocalParts]).toEqual(["dev"]); + }); + + it("normalizes case and surrounding whitespace on every tier", () => { + const candidates = buildIdentityCandidates({ + name: " Dev Real Name ", + email: " DEV@Example.COM ", + }); + + expect(candidates.emails.has("dev@example.com")).toBe(true); + expect(candidates.names.has("dev real name")).toBe(true); + expect(candidates.emailLocalParts.has("dev")).toBe(true); + }); + + it("omits the local-part tier for an account with no email", () => { + const candidates = buildIdentityCandidates({ name: "Dev", email: null }); + + expect(candidates.emails.size).toBe(0); + expect(candidates.emailLocalParts.size).toBe(0); + expect([...candidates.names]).toEqual(["dev"]); + }); + + it("derives no local part from an email with an empty local part", () => { + const candidates = buildIdentityCandidates({ + name: null, + email: "@example.com", + }); + + expect(candidates.emailLocalParts.size).toBe(0); + }); + + it("yields three empty tiers for an account with neither name nor email", () => { + const candidates = buildIdentityCandidates({ name: null, email: null }); + + expect(candidates.emails.size).toBe(0); + expect(candidates.names.size).toBe(0); + expect(candidates.emailLocalParts.size).toBe(0); + }); +}); + +describe("matchUserAuthors identity tiers", () => { + it("matches on email whatever display name the author committed under", () => { + const matches = matchesFor([ + { name: "codermarcos", email: "dev@example.com", total_commits: 12 }, + ]); + + expect(matches).toHaveLength(1); + expect(matches[0].matchedBy).toBe("email"); + }); + + it("matches emails case-insensitively and ignoring whitespace", () => { + const matches = matchesFor([ + { name: "Someone Else", email: " DEV@Example.COM ", total_commits: 3 }, + ]); + + expect(matches).toHaveLength(1); + expect(matches[0].matchedBy).toBe("email"); + }); + + it("keeps an author with no email out of the email tier", () => { + const matches = matchesFor([{ name: "Dev", total_commits: 5 }]); + + expect(matches).toHaveLength(1); + expect(matches[0].matchedBy).toBe("name"); + }); + + it("collects the second identity by name alongside the email match (issue #193)", () => { + const matches = matchesFor([ + { name: "Dev", email: "dev@company.example", total_commits: 183 }, + { name: "Dev", email: "dev@example.com", total_commits: 1 }, + ]); + + expect(matches).toHaveLength(2); + expect(matches.map((entry) => entry.matchedBy)).toEqual(["name", "email"]); + }); + + it("counts an author once when it satisfies more than one tier", () => { + // The account name and the account email local part are both "dev" here, + // and the row also carries the account email — three ways in, one row out. + const matches = matchesFor([ + { name: "Dev", email: "dev@example.com", total_commits: 9 }, + ]); + + expect(matches).toHaveLength(1); + expect(matches[0].matchedBy).toBe("email"); + }); + + it("absorbs a namesake sharing the display name — the deliberate cost of the name tier", () => { + // Issue #193 forces this: the account carries a single email, so a user's + // second git identity is only ever recoverable by display name, and a real + // namesake is indistinguishable from it. The row is reported as a "name" + // match so the UI can warn. This test pins the trade-off rather than + // pretending it does not exist — when the account starts carrying every + // verified git email, the expectation here should flip to one match. + const matches = matchesFor([ + { name: "Dev", email: "dev@example.com", total_commits: 40 }, + { + name: "Dev", + email: "a-different-person@example.com", + total_commits: 900, + }, + ]); + + expect(matches).toHaveLength(2); + expect(matches[1].matchedBy).toBe("name"); + }); + + it("ignores the email local part once any row matched on email", () => { + // "dev" as a git user.name is a coin flip between the account holder and a + // deploy bot. With the account email already anchoring this repo, the guess + // buys nothing and can only over-attribute. + const matches = matchesFor( + [ + { name: "Dev Real Name", email: "dev@example.com", total_commits: 20 }, + { name: "dev", email: "ci-bot@example.com", total_commits: 5000 }, + ], + { name: "Dev Real Name", email: "dev@example.com" }, + ); + + expect(matches).toHaveLength(1); + expect(matches[0].author.email).toBe("dev@example.com"); + }); + + it("ignores the email local part once a display name anchored the user", () => { + // An email anchor is not the only kind. A display-name hit already proves + // the user is present in this repo, so the weakest tier has nothing left + // to contribute and can only pull the bot in. + const matches = matchesFor( + [ + { + name: "Dev Real Name", + email: "personal@example.com", + total_commits: 20, + }, + { name: "dev", email: "ci-bot@example.com", total_commits: 5000 }, + ], + { name: "Dev Real Name", email: "dev@example.com" }, + ); + + expect(matches).toHaveLength(1); + expect(matches[0].author.email).toBe("personal@example.com"); + expect(matches[0].matchedBy).toBe("name"); + }); + + it("runs the email local part only when no other tier placed the user", () => { + // The strict reading of "last resort": the tier is reachable only from an + // otherwise empty match set. + const anchored = matchesFor( + [ + { name: "Dev Real Name", total_commits: 1 }, + { name: "dev", total_commits: 5000 }, + ], + { name: "Dev Real Name", email: "dev@example.com" }, + ); + const unanchored = matchesFor([{ name: "dev", total_commits: 5000 }], { + name: "Dev Real Name", + email: "dev@example.com", + }); + + expect(anchored).toHaveLength(1); + expect(anchored[0].author.name).toBe("Dev Real Name"); + expect(unanchored).toHaveLength(1); + expect(unanchored[0].author.name).toBe("dev"); + }); + + it("still matches the email local part when no row matched on email", () => { + // Without an anchor the guess is the difference between a fallback and an + // empty page, which is the case it was added for. + const matches = matchesFor( + [{ name: "dev", email: "legacy@example.com", total_commits: 30 }], + { name: "Dev Real Name", email: "dev@example.com" }, + ); + + expect(matches).toHaveLength(1); + expect(matches[0].matchedBy).toBe("name"); + }); + + it("reports a local-part match as a name match, never as an email match", () => { + const matches = matchesFor([{ name: "dev", total_commits: 30 }], { + name: "Dev Real Name", + email: "dev@example.com", + }); + + expect(matches[0].matchedBy).toBe("name"); + }); + + it("returns nothing for a null payload", () => { + expect(matchUserAuthors(null, CANDIDATES)).toEqual([]); + }); + + it("returns nothing when the payload carries no author velocity", () => { + expect(matchUserAuthors({} as ReportMetrics, CANDIDATES)).toEqual([]); + }); + + it("returns nothing when no tier matches any author", () => { + expect( + matchesFor([{ name: "Other Dev", email: "other@example.com" }]), + ).toEqual([]); + }); +}); + +describe("aggregateAuthors weighting", () => { + it("returns null for an empty match list", () => { + expect(aggregateAuthors([])).toBeNull(); + }); + + it("reproduces a lone identity's share exactly when it has no commit count", () => { + const usage = aggregateAuthors([ + matchOf({ name: "Dev", ai_commit_pct: 40 }), + ]); + + expect(usage!.aiCommitPct).toBeCloseTo(40, 10); + expect(usage!.totalCommits).toBe(0); + }); + + it("collapses several uncounted identities to the plain mean", () => { + const usage = aggregateAuthors([ + matchOf({ name: "Dev", ai_commit_pct: 40 }), + matchOf({ name: "Dev", ai_commit_pct: 60 }, "name"), + ]); + + expect(usage!.aiCommitPct).toBeCloseTo(50, 10); + }); + + it("never yields NaN when every identity reports zero commits", () => { + // The floor of 1 per identity is what keeps the denominator alive here. + // A bare weighted mean would divide 0 by 0 and render "NaN%" to the user. + const usage = aggregateAuthors([ + matchOf({ name: "Dev", total_commits: 0, ai_commit_pct: 40 }), + matchOf({ name: "Dev", total_commits: 0, ai_commit_pct: 60 }, "name"), + ]); + + expect(Number.isFinite(usage!.aiCommitPct)).toBe(true); + expect(usage!.aiCommitPct).toBeCloseTo(50, 10); + expect(usage!.totalCommits).toBe(0); + }); + + it("mixes counted and uncounted identities without losing the counted one", () => { + const usage = aggregateAuthors([ + matchOf({ name: "Dev", total_commits: 99, ai_commit_pct: 100 }), + matchOf({ name: "Dev", ai_commit_pct: 0 }, "name"), + ]); + + // The uncounted identity weighs 1 against 99, not 50/50. + expect(usage!.aiCommitPct).toBeCloseTo(99, 10); + expect(usage!.totalCommits).toBe(99); + }); + + it("keeps totalCommits as the reported sum, not the floored weights", () => { + const usage = aggregateAuthors([ + matchOf({ name: "Dev", ai_commit_pct: 10 }), + matchOf({ name: "Dev", ai_commit_pct: 20 }, "name"), + matchOf({ name: "Dev", ai_commit_pct: 30 }, "name"), + ]); + + expect(usage!.totalCommits).toBe(0); + }); + + it("takes the maximum high-velocity weeks and degrades matchedBy to name", () => { + const usage = aggregateAuthors([ + matchOf({ name: "Dev", total_commits: 10, high_velocity_weeks: 3 }), + matchOf( + { name: "Dev", total_commits: 5, high_velocity_weeks: 2 }, + "name", + ), + ]); + + expect(usage!.highVelocityWeeks).toBe(3); + expect(usage!.matchedBy).toBe("name"); + }); + + it("records a missing author email as null in the identity list", () => { + const usage = aggregateAuthors([matchOf({ name: "Dev" }, "name")]); + + expect(usage!.identities).toEqual([{ name: "Dev", email: null }]); + }); +}); + +describe("trend and table agree on identity", () => { + it("excludes from the trend the same local-part identity the table excludes", () => { + // A divergence here would be the worst kind of bug on this page: a chart + // that disagrees with the row printed right above it. + const authors: AuthorInput[] = [ + { + name: "Dev Real Name", + email: "dev@example.com", + total_commits: 10, + ai_commit_pct: 100, + weekly: [{ week_start: "2026-07-27", commits: 10, ai_commits: 10 }], + }, + { + name: "dev", + email: "ci-bot@example.com", + total_commits: 90, + ai_commit_pct: 0, + weekly: [{ week_start: "2026-07-27", commits: 90, ai_commits: 0 }], + }, + ]; + const account = { name: "Dev Real Name", email: "dev@example.com" }; + + const trend = buildUsageTrend( + new Map([["repo-1", [multiIdentityRow(authors)]]]), + buildIdentityCandidates(account), + ); + + expect(trend).toHaveLength(1); + expect(trend[0].aiCommitPct).toBeCloseTo(100); + }); + + it("keeps the table and the trend on the same identities end to end", async () => { + const usage = await usageFor( + [ + { + name: "Dev Real Name", + email: "dev@example.com", + total_commits: 10, + ai_commit_pct: 100, + weekly: [{ week_start: "2026-07-27", commits: 10, ai_commits: 10 }], + }, + { + name: "dev", + email: "ci-bot@example.com", + total_commits: 90, + ai_commit_pct: 0, + weekly: [{ week_start: "2026-07-27", commits: 90, ai_commits: 0 }], + }, + ], + { name: "Dev Real Name", email: "dev@example.com" }, + ); + + expect(usage.perRepo[0].totalCommits).toBe(10); + expect(usage.perRepo[0].aiCommitPct).toBeCloseTo(100); + expect(usage.perRepo[0].matchedBy).toBe("email"); + expect(usage.trend[0].aiCommitPct).toBeCloseTo(100); + }); });