From d81557181960ea3e5eaf336ddb6980f04fedc060 Mon Sep 17 00:00:00 2001 From: Rodrigo Matos Date: Wed, 26 Aug 2026 18:53:08 -0300 Subject: [PATCH] fix(platform): group Hyper Engineers by identity, not display name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: the same person shows up as several separate entries (e.g. "Renato Guimaraes", "renatoguimaraescb", "Renato Guimarães (Bahia)") because commit-author display names vary across repos/time, and computeHyperEngineers grouped purely by name.lowercase(). Both author_velocity (email per author) and active_users (github username per person, already resolved server-side by iris/cli.py's push-time identity resolution) had everything needed — the payload data was already there, this was a pure grouping-key bug, entirely fixable on the platform side: - getOrgActiveContributors: userMap now keys by resolved github username when known, falling back to name only when no github was resolved for that entry — fixes the "active contributors" count itself double-counting the same person under different names. Also returns a new nameToGithub map (every distinct name -> its github, not deduped) so a caller holding only a name can resolve it. - computeHyperEngineers: groups author_velocity authors by, in priority order, resolved github username (via nameToGithub) -> normalized email (stripping a GitHub noreply email's numeric-ID prefix, mirroring iris/analysis/author_velocity.py's own _normalize_author) -> display name as a last resort. No engine/CLI changes, no new payload fields, no migration — every field this needed was already flowing through end-to-end. Co-Authored-By: claude-code_2-1-238_agent --- platform/lib/queries/org-summary.ts | 83 +++++++++++-- .../dashboard/panels/HyperEngineersPanel.tsx | 6 +- platform/tests/org-summary.test.ts | 110 ++++++++++++++++++ 3 files changed, 187 insertions(+), 12 deletions(-) diff --git a/platform/lib/queries/org-summary.ts b/platform/lib/queries/org-summary.ts index 393d37c..9cfc879 100644 --- a/platform/lib/queries/org-summary.ts +++ b/platform/lib/queries/org-summary.ts @@ -61,8 +61,24 @@ export async function getOrgLatestPayloads( export interface OrgContributorInfo { count: number; - /** Map from lowercase name → { name, github? } for avatar lookup. */ + /** + * Map from resolved identity → { name, github? }, for avatar lookup. + * Keyed by GitHub username (lowercased) when known — that's the stable + * identity `iris/cli.py`'s push-time resolution already computed — and + * only falls back to the lowercase display name when no github was + * resolved for that entry. Two name variants that both resolved to the + * same github collapse into one entry here. + */ userMap: Map; + /** + * Map from every distinct lowercase display name seen → its resolved + * github username, NOT deduped by identity (multiple names can point at + * the same github). Lets a caller holding only a name (e.g. an + * author_velocity entry, which has no github field of its own) look up + * the identity that name resolved to, even when userMap's canonical + * entry for that identity settled on a different display name. + */ + nameToGithub: Map; } export async function getOrgActiveContributors( @@ -78,11 +94,14 @@ export async function getOrgActiveContributors( .order("created_at", { ascending: false }) .limit(200); - if (!data || data.length === 0) return { count: 0, userMap: new Map() }; + if (!data || data.length === 0) { + return { count: 0, userMap: new Map(), nameToGithub: new Map() }; + } // Keep only the latest run per repo const seen = new Set(); const userMap = new Map(); + const nameToGithub = new Map(); for (const row of data) { if (seen.has(row.repository_id)) continue; @@ -93,15 +112,18 @@ export async function getOrgActiveContributors( >; for (const u of users) { const parsed = typeof u === "string" ? { name: u } : u; - const key = parsed.name.toLowerCase(); + const key = parsed.github?.toLowerCase() ?? parsed.name.toLowerCase(); // Keep entry with github if available if (!userMap.has(key) || (parsed.github && !userMap.get(key)?.github)) { userMap.set(key, parsed); } + if (parsed.github) { + nameToGithub.set(parsed.name.toLowerCase(), parsed.github); + } } } - return { count: userMap.size, userMap }; + return { count: userMap.size, userMap, nameToGithub }; } // --------------------------------------------------------------------------- @@ -1100,15 +1122,37 @@ export function isHyperEngineer(author: { return author.high_velocity_weeks > 0 || author.ai_commit_pct >= 80; } +/** + * Normalize a GitHub noreply commit email to its username, mirroring + * `_normalize_author` in `iris/analysis/author_velocity.py` — the same + * per-person key the engine already uses to dedupe an author's commits + * within one repo. Applying it here too means two name variants that + * share a noreply email (extremely common: it's stable per GitHub account + * regardless of local git config) collapse into one entry even when + * neither has a resolved `github` field. + */ +function normalizeEmailIdentity(email: string | undefined): string | null { + if (!email) return null; + const m = /^(?:\d+\+)?(.+)@users\.noreply\.github\.com$/i.exec(email.trim()); + return (m ? m[1] : email.trim()).toLowerCase() || null; +} + export function computeHyperEngineers( payloads: Map, userMap: Map, + nameToGithub: Map, ): HyperEngineer[] { - // Accumulate per-author stats across repos + // Accumulate per-author stats across repos. Grouped by the most + // authoritative identity available, in order: resolved GitHub username + // (iris/cli.py already resolved this at push time, including real API + // lookups for non-noreply emails) -> normalized email -> display name as + // a last resort. Without this, the same person shows up once per distinct + // commit-author name they've ever used. const authors = new Map< string, { - name: string; + names: Set; + github?: string; repos: number; hvWeeks: number; aiPct: number; @@ -1121,16 +1165,25 @@ export function computeHyperEngineers( const av = p.author_velocity; for (const a of av.authors) { - const key = a.name.toLowerCase(); if (!isHyperEngineer(a)) continue; + const nameLower = a.name.toLowerCase(); + const github = nameToGithub.get(nameLower); + const key = github ?? normalizeEmailIdentity(a.email) ?? nameLower; + const existing = authors.get(key) ?? { - name: a.name, + names: new Set(), + github: undefined, repos: 0, hvWeeks: 0, aiPct: 0, aiCount: 0, }; + existing.names.add(a.name); + // Carry the resolved github forward directly — don't rely solely on + // userMap having a matching entry, since nameToGithub can know a + // mapping userMap's own dedup pass didn't happen to retain. + if (github) existing.github = github; existing.repos++; existing.hvWeeks = Math.max(existing.hvWeeks, a.high_velocity_weeks); existing.aiPct += a.ai_commit_pct; @@ -1141,10 +1194,18 @@ export function computeHyperEngineers( return [...authors.entries()] .map(([key, a]) => { - const userInfo = userMap.get(key); + // userMap is keyed by github-when-known, so this hits directly when + // `key` is already a github username. Otherwise, fall back to trying + // every name variant we saw for this person, in case userMap's own + // fallback (name-keyed) entry matches one of them. + const userInfo = + userMap.get(key) ?? + [...a.names].map((n) => userMap.get(n.toLowerCase())).find(Boolean); + const displayName = + userInfo?.name ?? [...a.names].sort((x, y) => y.length - x.length)[0]; return { - name: userInfo?.name ?? a.name, - github: userInfo?.github, + name: displayName, + github: a.github ?? userInfo?.github, repos: a.repos, highVelocityWeeks: a.hvWeeks, aiCommitPct: a.aiCount > 0 ? a.aiPct / a.aiCount : 0, diff --git a/platform/src/app/[tenant]/dashboard/panels/HyperEngineersPanel.tsx b/platform/src/app/[tenant]/dashboard/panels/HyperEngineersPanel.tsx index 617afb1..0074176 100644 --- a/platform/src/app/[tenant]/dashboard/panels/HyperEngineersPanel.tsx +++ b/platform/src/app/[tenant]/dashboard/panels/HyperEngineersPanel.tsx @@ -18,7 +18,11 @@ export async function HyperEngineersPanel({ return ( ); } diff --git a/platform/tests/org-summary.test.ts b/platform/tests/org-summary.test.ts index f88a960..23af816 100644 --- a/platform/tests/org-summary.test.ts +++ b/platform/tests/org-summary.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { computeAIvsHuman, computeDeliveryQuality, + computeHyperEngineers, computeOrgPulse, computePreviousTotals, computePRHealth, @@ -344,3 +345,112 @@ describe("isHyperEngineer — shared threshold", () => { ); }); }); + +describe("computeHyperEngineers — dedupes the same person across name variants", () => { + function hyperAuthor(over: { + name: string; + email?: string; + high_velocity_weeks?: number; + }) { + return { + name: over.name, + email: over.email, + high_velocity_weeks: over.high_velocity_weeks ?? 1, + ai_commit_pct: 50, + }; + } + + it("merges two display-name variants that resolved to the same github username", () => { + const payloads = new Map([ + [ + "repo-a", + payload({ + author_velocity: { + authors: [hyperAuthor({ name: "Renato Guimaraes" })], + }, + }), + ], + [ + "repo-b", + payload({ + author_velocity: { + authors: [hyperAuthor({ name: "Renato Guimarães (Bahia)" })], + }, + }), + ], + ]); + const nameToGithub = new Map([ + ["renato guimaraes", "renatoguimaraescb"], + ["renato guimarães (bahia)", "renatoguimaraescb"], + ]); + + const result = computeHyperEngineers(payloads, new Map(), nameToGithub); + + expect(result).toHaveLength(1); + expect(result[0].github).toBe("renatoguimaraescb"); + expect(result[0].repos).toBe(2); + }); + + it("merges two display-name variants sharing a GitHub noreply email", () => { + const payloads = new Map([ + [ + "repo-a", + payload({ + author_velocity: { + authors: [ + hyperAuthor({ + name: "Renato Guimaraes", + email: "123+renatoguimaraescb@users.noreply.github.com", + }), + ], + }, + }), + ], + [ + "repo-b", + payload({ + author_velocity: { + authors: [ + hyperAuthor({ + name: "renatoguimaraescb", + email: "123+renatoguimaraescb@users.noreply.github.com", + }), + ], + }, + }), + ], + ]); + + // No github resolved anywhere — only the shared noreply email ties + // the two name variants together. + const result = computeHyperEngineers(payloads, new Map(), new Map()); + + expect(result).toHaveLength(1); + expect(result[0].repos).toBe(2); + }); + + it("keeps genuinely different people separate", () => { + const payloads = new Map([ + [ + "repo-a", + payload({ + author_velocity: { + authors: [hyperAuthor({ name: "Alice", email: "alice@corp.com" })], + }, + }), + ], + [ + "repo-b", + payload({ + author_velocity: { + authors: [hyperAuthor({ name: "Bob", email: "bob@corp.com" })], + }, + }), + ], + ]); + + const result = computeHyperEngineers(payloads, new Map(), new Map()); + + expect(result).toHaveLength(2); + }); +});