From 7a5629e7cfa918a321c95fafe7cff05a839082b8 Mon Sep 17 00:00:00 2001 From: codermarcos Date: Thu, 27 Aug 2026 11:58:30 -0300 Subject: [PATCH 1/3] fix(platform): consolidate every git identity in /me/ai-usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pickUserAuthor returned the first author row matching the user and stopped, so a person with more than one git identity in a repo was pinned to whichever came first. A one-commit identity — typically the GitHub account's primary email, author of merges and web-UI edits — hid a several-hundred-commit one and reported 0% AI for an otherwise fully AI-assisted repo. Replace it with matchUserAuthors (every matching row) plus aggregateAuthors, which sums commits, weights ai_commit_pct by commit count, takes the max of high_velocity_weeks, and exposes the identities it consolidated. buildUsageTrend merges each push's weekly arrays across identities before the per-week dedup, so the trend chart no longer inherits the same bias. matchedBy now degrades to "name" when any identity rests on a display-name match, so the name-only badge warns whenever part of the row carries the weaker guarantee. Verified against a real payload: 1 commit / 0% AI became 184 commits / 98.96% AI across 2 identities. Closes #193 Co-Authored-By: Claude Opus 5 (1M context) --- platform/lib/queries/personal-ai-usage.ts | 199 +++++++++++---- platform/lib/translations.ts | 4 +- platform/src/app/me/ai-usage/page.tsx | 13 +- platform/tests/personal-ai-usage.test.ts | 279 ++++++++++++++++++++-- 4 files changed, 422 insertions(+), 73 deletions(-) diff --git a/platform/lib/queries/personal-ai-usage.ts b/platform/lib/queries/personal-ai-usage.ts index 5d656d6..2b77641 100644 --- a/platform/lib/queries/personal-ai-usage.ts +++ b/platform/lib/queries/personal-ai-usage.ts @@ -15,6 +15,11 @@ 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; +} + export interface PerRepoUsage { organizationSlug: string; organizationName: string; @@ -22,8 +27,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 +68,144 @@ interface RepoRow { organization_id: string; } +type PayloadAuthor = NonNullable< + ReportMetrics["author_velocity"] +>["authors"][number]; + +interface MatchedAuthor { + author: PayloadAuthor; + matchedBy: "email" | "name"; +} + +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( +/** + * 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). + * + * Email is the reliable signal — git deduplicates authors by email and the + * engine preserves it on every row. A display-name hit still counts, so users + * whose account email covers none of their git identities are not left empty, + * but it is reported back so callers can warn that it may be a namesake. + */ +function matchUserAuthors( payload: ReportMetrics | null, emailCandidates: Set, nameCandidates: Set, -): { - author: NonNullable["authors"][number]; - matchedBy: "email" | "name"; -} | null { +): 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 && emailCandidates.has(nameKey(author.email))) { + matched.push({ author, matchedBy: "email" }); + } else if (nameCandidates.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" }; + 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. Payloads from iris < 1.0.2 carry + * no `total_commits`, which leaves every weight at zero — those fall back to + * the plain mean, matching what a single-author match used to display. + * + * `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. + */ +function aggregateAuthors(matches: MatchedAuthor[]): AggregatedUsage | null { + if (matches.length === 0) return null; + + let totalCommits = 0; + let weightedPctSum = 0; + let plainPctSum = 0; + let highVelocityWeeks = 0; + let everyMatchByEmail = true; + const identities: AuthorIdentity[] = []; + + for (const { author, matchedBy } of matches) { + const commits = author.total_commits ?? 0; + totalCommits += commits; + weightedPctSum += author.ai_commit_pct * commits; + plainPctSum += author.ai_commit_pct; + 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: + totalCommits > 0 + ? weightedPctSum / totalCommits + : plainPctSum / matches.length, + 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 null; + + return merged; } // Weekly AI commit share aggregated across each repo's full fetched history, @@ -129,29 +239,28 @@ export function buildUsageTrend( // skipped. const seenWeeks = new Set(); for (const row of rows) { - const match = pickUserAuthor( + const matches = matchUserAuthors( 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); + 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(w.week_start) ?? { + 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); } } } @@ -237,8 +346,10 @@ 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, emailCandidates, nameCandidates), + ); + if (!usage) continue; const repo = repoIndex.get(repoId); const org = orgIndex.get(row.organization_id); if (!repo || !org) continue; @@ -248,18 +359,16 @@ 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); 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..be4fcb5 100644 --- a/platform/tests/personal-ai-usage.test.ts +++ b/platform/tests/personal-ai-usage.test.ts @@ -1,7 +1,9 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; import { describe, expect, it } from "vitest"; import { buildUsageTrend, + getPersonalAIUsage, type MetricRow, } from "@/lib/queries/personal-ai-usage"; import type { ReportMetrics } from "@/types/metrics"; @@ -9,11 +11,17 @@ import type { ReportMetrics } from "@/types/metrics"; const EMAIL = new Set(["dev@example.com"]); const NAME = new Set(); -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 +31,193 @@ 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" }]; + +async function usageFor(authors: AuthorInput[]) { + 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, + { name: "Dev", email: "dev@example.com" }, + 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 — @@ -139,4 +308,72 @@ describe("buildUsageTrend", () => { 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, EMAIL, new Set(["dev"])); + + 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, EMAIL, new Set(["dev"])); + + expect(trend).toHaveLength(1); + expect(trend[0].aiCommitPct).toBeCloseTo(100); + }); }); From 210c83d5876f3e7cccc6bcfa891c8f63f7086c97 Mon Sep 17 00:00:00 2001 From: codermarcos Date: Wed, 9 Sep 2026 21:47:16 -0300 Subject: [PATCH 2/3] fix(platform): tier identity candidates and drop the weighting branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Kody's review on #240. Split the identity candidates into three explicit tiers behind `UserIdentityCandidates`: the account email, the account display name, and the email local part. The local part is a guess, not a declared identity — once any author row in a payload has matched on email we have a real anchor for the user in that repo, so the local-part tier now runs only for payloads with no email match at all. A generic local part can no longer pull a bot or a service account into someone's row. The display-name tier keeps running alongside the email match. That is what issue #193 requires: the account carries a single email, so a user's second git identity is only ever recoverable by name. Gating it behind "no email match" would drop the large identity again and reproduce the bug. Replace the `totalCommits > 0 ? weighted : plain mean` branch in `aggregateAuthors` with a single weighted formula where every identity weighs at least 1. One identity without `total_commits` reproduces its own share exactly, several collapse to the plain mean, and the denominator can never reach zero — so the metric cannot render as NaN for payloads that report no commit counts. `totalCommits` stays the honest sum of what was reported. Export `buildIdentityCandidates`, `matchUserAuthors` and `aggregateAuthors` and cover them directly: 17 tests to 41. The new cases include a characterization test pinning the namesake trade-off the name tier accepts, the local-part gate in both directions, the zero-commit denominator, and a pair asserting the table and the trend chart resolve the same identities. Both behaviours were mutation-checked: removing the local-part gate fails 3 tests, removing the weight floor fails 5. Verified end to end against a local platform: iris CLI pushed design-system-react, whose payload carries two real identities under the same git name, and /me/ai-usage consolidated them into 15 commits at 93% with both identities in the tooltip and the name-match badge shown. Co-Authored-By: Claude Opus 5 (1M context) --- platform/lib/queries/personal-ai-usage.ts | 140 ++++++--- platform/tests/personal-ai-usage.test.ts | 356 +++++++++++++++++++++- 2 files changed, 438 insertions(+), 58 deletions(-) diff --git a/platform/lib/queries/personal-ai-usage.ts b/platform/lib/queries/personal-ai-usage.ts index 2b77641..e0109d6 100644 --- a/platform/lib/queries/personal-ai-usage.ts +++ b/platform/lib/queries/personal-ai-usage.ts @@ -20,6 +20,24 @@ export interface AuthorIdentity { 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; @@ -72,12 +90,12 @@ type PayloadAuthor = NonNullable< ReportMetrics["author_velocity"] >["authors"][number]; -interface MatchedAuthor { +export interface MatchedAuthor { author: PayloadAuthor; matchedBy: "email" | "name"; } -interface AggregatedUsage { +export interface AggregatedUsage { totalCommits: number; aiCommitPct: number; highVelocityWeeks: number; @@ -95,6 +113,28 @@ function nameKey(value: string): string { return value.trim().toLowerCase(); } +/** + * 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. * @@ -105,27 +145,47 @@ function nameKey(value: string): string { * 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). * - * Email is the reliable signal — git deduplicates authors by email and the - * engine preserves it on every row. A display-name hit still counts, so users - * whose account email covers none of their git identities are not left empty, - * but it is reported back so callers can warn that it may be a namesake. + * 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, and once some row in this payload has matched on email we + * have a real anchor for the user in this repo — so the local-part tier runs + * only for payloads with no email match at all, where it is the difference + * between a fallback and an empty page. */ -function matchUserAuthors( +export function matchUserAuthors( payload: ReportMetrics | null, - emailCandidates: Set, - nameCandidates: Set, + candidates: UserIdentityCandidates, ): MatchedAuthor[] { const authors = payload?.author_velocity?.authors; if (!authors) return []; const matched: MatchedAuthor[] = []; + let anchoredByEmail = false; + for (const author of authors) { - if (author.email && emailCandidates.has(nameKey(author.email))) { + if (author.email && candidates.emails.has(nameKey(author.email))) { matched.push({ author, matchedBy: "email" }); - } else if (nameCandidates.has(nameKey(author.name))) { + anchoredByEmail = true; + } else if (candidates.names.has(nameKey(author.name))) { + matched.push({ author, matchedBy: "name" }); + } + } + + if (anchoredByEmail) return matched; + + const alreadyMatched = new Set(matched.map((entry) => entry.author)); + for (const author of authors) { + if (alreadyMatched.has(author)) continue; + if (candidates.emailLocalParts.has(nameKey(author.name))) { matched.push({ author, matchedBy: "name" }); } } + return matched; } @@ -134,9 +194,12 @@ function matchUserAuthors( * 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. Payloads from iris < 1.0.2 carry - * no `total_commits`, which leaves every weight at zero — those fall back to - * the plain mean, matching what a single-author match used to display. + * 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. * * `highVelocityWeeks` takes the maximum rather than the sum: the engine counts * weeks, and the same calendar week can appear under two identities. Summing @@ -147,21 +210,24 @@ function matchUserAuthors( * any part of the row rests on a display-name match, the whole row carries the * weaker guarantee and the UI should say so. */ -function aggregateAuthors(matches: MatchedAuthor[]): AggregatedUsage | null { +export function aggregateAuthors( + matches: MatchedAuthor[], +): AggregatedUsage | null { if (matches.length === 0) return null; let totalCommits = 0; + let weightSum = 0; let weightedPctSum = 0; - let plainPctSum = 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; - weightedPctSum += author.ai_commit_pct * commits; - plainPctSum += author.ai_commit_pct; + 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 }); @@ -169,10 +235,7 @@ function aggregateAuthors(matches: MatchedAuthor[]): AggregatedUsage | null { return { totalCommits, - aiCommitPct: - totalCommits > 0 - ? weightedPctSum / totalCommits - : plainPctSum / matches.length, + aiCommitPct: weightedPctSum / weightSum, highVelocityWeeks, matchedBy: everyMatchByEmail ? "email" : "name", identities, @@ -221,8 +284,7 @@ function mergeWeeklyAcrossIdentities( // no AI share for those weeks. export function buildUsageTrend( rowsPerRepo: Map, - emailCandidates: Set, - nameCandidates: Set, + candidates: UserIdentityCandidates, ): UsageTrendPoint[] { type WeekBucket = { commits: number; @@ -239,11 +301,7 @@ export function buildUsageTrend( // skipped. const seenWeeks = new Set(); for (const row of rows) { - const matches = matchUserAuthors( - row.payload, - emailCandidates, - nameCandidates, - ); + const matches = matchUserAuthors(row.payload, candidates); if (matches.length === 0) continue; for (const [weekStart, totals] of mergeWeeklyAcrossIdentities(matches)) { @@ -292,18 +350,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])); @@ -346,9 +400,7 @@ export async function getPersonalAIUsage( for (const [repoId, rows] of rowsPerRepo) { const row = rows[0]; // newest row — summary table shows current snapshot only. - const usage = aggregateAuthors( - matchUserAuthors(row.payload, emailCandidates, nameCandidates), - ); + const usage = aggregateAuthors(matchUserAuthors(row.payload, candidates)); if (!usage) continue; const repo = repoIndex.get(repoId); const org = orgIndex.get(row.organization_id); @@ -371,7 +423,7 @@ export async function getPersonalAIUsage( 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/tests/personal-ai-usage.test.ts b/platform/tests/personal-ai-usage.test.ts index be4fcb5..61a66fa 100644 --- a/platform/tests/personal-ai-usage.test.ts +++ b/platform/tests/personal-ai-usage.test.ts @@ -2,14 +2,18 @@ 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); interface AuthorInput { name: string; @@ -93,7 +97,31 @@ function fakeSupabase(tables: Record): SupabaseClient { const ORGS = [{ id: "org-1", slug: "acme", name: "Acme" }]; -async function usageFor(authors: AuthorInput[]) { +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: [ @@ -106,11 +134,7 @@ async function usageFor(authors: AuthorInput[]) { ], }); - return getPersonalAIUsage( - supabase, - { name: "Dev", email: "dev@example.com" }, - ORGS, - ); + return getPersonalAIUsage(supabase, account, ORGS); } describe("getPersonalAIUsage identity aggregation", () => { @@ -236,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); @@ -259,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); @@ -285,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); @@ -304,7 +328,7 @@ describe("buildUsageTrend", () => { ], ]); - const trend = buildUsageTrend(rowsPerRepo, EMAIL, NAME); + const trend = buildUsageTrend(rowsPerRepo, CANDIDATES); expect(trend[0].aiCommitPct).toBeNull(); }); @@ -332,7 +356,7 @@ describe("buildUsageTrend", () => { ], ]); - const trend = buildUsageTrend(rowsPerRepo, EMAIL, new Set(["dev"])); + const trend = buildUsageTrend(rowsPerRepo, CANDIDATES); expect(trend).toHaveLength(1); expect(trend[0].aiCommitPct).toBeCloseTo(90); @@ -371,9 +395,313 @@ describe("buildUsageTrend", () => { ], ]); - const trend = buildUsageTrend(rowsPerRepo, EMAIL, new Set(["dev"])); + 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("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); + }); }); From 7f640b63f851a6a7e6b038bba4b64888e2e6cf3b Mon Sep 17 00:00:00 2001 From: codermarcos Date: Wed, 9 Sep 2026 22:10:11 -0300 Subject: [PATCH 3/3] fix(platform): let any anchor suppress the email local-part tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to Kody's second review pass on #240. The local-part tier was documented as a last resort but implemented as a last resort only against email: `anchoredByEmail` suppressed it when a row matched the account email, while a display-name match left it running. A display-name hit already proves the user is present in this repo, so past that point the guess can only over-attribute — a CI account whose git user.name happens to equal a generic local part was still absorbed into the row. Gating on `matched.length > 0` instead applies the rule the TSDoc already claimed, and collapses the implementation: the `anchoredByEmail` flag and the `alreadyMatched` set both become dead code, because the local-part pass is now reachable only from an empty match set and has nothing to deduplicate against. Two tests cover the tightened rule — a display-name anchor suppressing a bot whose name is the local part, and the pair asserting the tier is reachable only when no other tier placed the user. Reverting the gate to email-only fails 3 tests. Also pin the engine invariant behind the weight floor in `aggregateAuthors`. `compute_author_velocity` sums an author's total from its weekly buckets and a bucket exists only where a commit landed, so an emitted row always reports at least one commit and a zero weight always means "field absent" rather than "committed nothing". Splitting the two cases would need an all-zero denominator guard, so the invariant is documented rather than coded around. Co-Authored-By: Claude Opus 5 (1M context) --- platform/lib/queries/personal-ai-usage.ts | 25 ++++++++------ platform/tests/personal-ai-usage.test.ts | 42 +++++++++++++++++++++++ 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/platform/lib/queries/personal-ai-usage.ts b/platform/lib/queries/personal-ai-usage.ts index e0109d6..7945392 100644 --- a/platform/lib/queries/personal-ai-usage.ts +++ b/platform/lib/queries/personal-ai-usage.ts @@ -152,10 +152,12 @@ export function buildIdentityCandidates(user: { * 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, and once some row in this payload has matched on email we - * have a real anchor for the user in this repo — so the local-part tier runs - * only for payloads with no email match at all, where it is the difference - * between a fallback and an empty page. + * 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, @@ -165,22 +167,17 @@ export function matchUserAuthors( if (!authors) return []; const matched: MatchedAuthor[] = []; - let anchoredByEmail = false; - for (const author of authors) { if (author.email && candidates.emails.has(nameKey(author.email))) { matched.push({ author, matchedBy: "email" }); - anchoredByEmail = true; } else if (candidates.names.has(nameKey(author.name))) { matched.push({ author, matchedBy: "name" }); } } - if (anchoredByEmail) return matched; + if (matched.length > 0) return matched; - const alreadyMatched = new Set(matched.map((entry) => entry.author)); for (const author of authors) { - if (alreadyMatched.has(author)) continue; if (candidates.emailLocalParts.has(nameKey(author.name))) { matched.push({ author, matchedBy: "name" }); } @@ -201,6 +198,14 @@ export function matchUserAuthors( * 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 diff --git a/platform/tests/personal-ai-usage.test.ts b/platform/tests/personal-ai-usage.test.ts index 61a66fa..13a17ae 100644 --- a/platform/tests/personal-ai-usage.test.ts +++ b/platform/tests/personal-ai-usage.test.ts @@ -534,6 +534,48 @@ describe("matchUserAuthors identity tiers", () => { 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.