fix(platform): consolida todas as identidades git em /me/ai-usage - #240
fix(platform): consolida todas as identidades git em /me/ai-usage#240codermarcos wants to merge 3 commits into
Conversation
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) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This comment has been minimized.
This comment has been minimized.
| aiCommitPct: | ||
| totalCommits > 0 | ||
| ? weightedPctSum / totalCommits | ||
| : plainPctSum / matches.length, |
There was a problem hiding this comment.
When totalCommits is 0 because payloads lack total_commits or there are no commits, the fallback plain mean makes an incomplete sample look valid. Return null for aiCommitPct when the weighted metric cannot be computed, and update the type and callers to handle the missing case.
Kody rule violation: Use null for metrics that cannot be computed
aiCommitPct:
totalCommits > 0
? weightedPctSum / totalCommits
: null,Prompt for LLM
File platform/lib/queries/personal-ai-usage.ts:
Line 172 to 175:
When totalCommits is 0 because payloads lack total_commits or there are no commits, the fallback plain mean makes an incomplete sample look valid. Return null for aiCommitPct when the weighted metric cannot be computed, and update the type and callers to handle the missing case.
Suggested Code:
aiCommitPct:
totalCommits > 0
? weightedPctSum / totalCommits
: null,
Talk to Kody by mentioning @kody
Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.
There was a problem hiding this comment.
@kody Parcialmente certo no diagnóstico, mas a prescrição eu não vou seguir — e o motivo importa.
Onde a premissa não se sustenta: não havia divisão por zero. aggregateAuthors retorna null logo no início quando matches.length === 0, então o denominador do fallback (matches.length) é sempre >= 1. E o fallback não estava fabricando dado: ele só dispara em payloads de iris < 1.0.2, que não emitem total_commits. Nesse cenário o caso dominante é uma identidade só, e aí plainPctSum / 1 é literalmente author.ai_commit_pct — exatamente o valor que a página exibia antes deste PR. Não é amostra incompleta parecendo válida; é paridade.
Onde a sua sugestão piora: totalCommits > 0 ? weighted : null devolve null justamente no caso em que a média é computável. Duas identidades com total_commits: 0 e shares de 40% e 60% têm média real de 50%; a sua versão esconde isso. E aiCommitPct: number | null cascateia em perRepo.sort((a, b) => b.aiCommitPct - a.aiCommitPct) (vira NaN), em aiSum += usage.aiCommitPct, em avgAiCommitPct e na UI — custo alto para um formato de payload obsoleto.
O que fiz no lugar (210c83d): o que a sua regra de fato quer é não ter dois caminhos de cálculo. Então o branch sumiu, substituído por uma fórmula ponderada única com piso de 1 por identidade:
const commits = author.total_commits ?? 0;
const weight = Math.max(commits, 1);
totalCommits += commits; // soma honesta, ainda 0 em payloads legados
weightSum += weight; // nunca chega a zero
weightedPctSum += author.ai_commit_pct * weight;
// ...
aiCommitPct: weightedPctSum / weightSumUma identidade sem contagem reproduz a própria share exatamente; várias colapsam para a média simples; o denominador não pode zerar. NaN fica impossível por construção, que é a garantia que você queria, sem nullificar o tipo.
Coberto por aggregateAuthors weighting > never yields NaN when every identity reports zero commits e mais 7 testes na mesma suíte. Teste de mutação: removendo Math.max(commits, 1), 5 testes falham.
There was a problem hiding this comment.
I reviewed your rebuttal against the branch at 210c83d and the test suite. Verdict: the objection is correct, and I'm withdrawing the suggestion. The construction you landed on satisfies what the rule was actually after, at a lower cost than my prescription.
Where I was wrong:
- Division by zero was a strawman.
aggregateAuthorsreturnsnullbefore the loop, somatches.length >= 1always. The "incomplete sample looks valid" framing overstated the risk. - Parity, not fabrication. For iris < 1.0.2 payloads,
plainPctSum / 1is literallyauthor.ai_commit_pct— the same value the page rendered before this PR. My suggestion asked you to null out a number that was identical to the current production behavior. - The cascade cost was real and I underestimated it.
aiCommitPct: number | nullwould have infectedperRepo.sort(NaN comparisons),aiSum += usage.aiCommitPct,avgAiCommitPct, and the UI — for a payload format that is being phased out. And your counter-example is right: two zero-count identities at 40/60 have a perfectly computable mean of 50; my ternary would have hidden it.
What I verified in the code:
The single-formula floor (weight = Math.max(commits, 1)) reduces cleanly in every regime:
- All counts present:
weight = commits⇒ exactly the weighted mean my suggestion wanted. - All counts absent:
weight = 1each ⇒ plain mean, the parity case. - Mixed: the uncounted identity weighs 1, not 50% — confirmed by the
mixes counted and uncountedtest (99 vs 1, expecting 99, not 50.5).
Denominator is bounded below by matches.length ≥ 1, so NaN/±Infinity are impossible by construction — no hidden branch, no typeof guard, no type change. The test suite covers it: never yields NaN when every identity reports zero commits, plus the lone-identity, plain-mean, mixed, and honest-totalCommits cases. The mutation claim is consistent with what I see — removing the floor turns those into 0/0 or shifts the mixed case from 99 to 100.
One residual note, non-blocking: author.total_commits?? 0 conflates "field absent" with "literal zero commits." For legacy payloads that conflation is the fix. But if a current-format payload could ever carry an author row with literal total_commits: 0 (the engine's invariant is presumably "rows only for authors with commits"), the floor would give that row a vote it doesn't deserve — a bounded dilution of at most 1/(total+1). Worth a one-line comment pinning the invariant, not a code change today.
The remaining design choices hold up too: highVelocityWeeks as max (sum would double-count a calendar week under two identities), matchedBy degraded to the weakest guarantee in the row, and the legacy "0 commits / X% AI" display being honest parity that ages out.
Marking this discussion resolved — suggestion withdrawn, no rule change. If you want, the "single weighted formula with a floor of 1 per unit" pattern is the kind of thing that could later be written into the repo rules as a concrete alternative to blanket null-returning, but that's a separate conversation.
| 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" }); | ||
| } | ||
| } |
There was a problem hiding this comment.
matchUserAuthors now aggregates a display-name match for every author alongside any email match, whereas the previous pickUserAuthor ran the name fallback only when no author matched the user's email, so a same-name namesake always pollutes the aggregated row. This over-attributes the namesake's total_commits and drags or inflates aiCommitPct, regressing the issue #193 fix. After collecting email matches, only add a name match when matches.length === 0, or require the name-matched author to have no email/commits conflict before aggregating; at minimum stop adding the email local part of the display name to the name candidates.
for (const author of authors) {
if (author.email && emailCandidates.has(nameKey(author.email))) {
matched.push({ author, matchedBy: "email" });
}
}
if (matched.length === 0) {
for (const author of authors) {
if (nameCandidates.has(nameKey(author.name))) matched.push({ author, matchedBy: "name" });
}
}Prompt for LLM
File platform/lib/queries/personal-ai-usage.ts:
Line 121 to 128:
matchUserAuthors now aggregates a display-name match for every author alongside any email match, whereas the previous pickUserAuthor ran the name fallback only when no author matched the user's email, so a same-name namesake always pollutes the aggregated row. This over-attributes the namesake's total_commits and drags or inflates aiCommitPct, regressing the issue #193 fix. After collecting email matches, only add a name match when matches.length === 0, or require the name-matched author to have no email/commits conflict before aggregating; at minimum stop adding the email local part of the display name to the name candidates.
Suggested Code:
for (const author of authors) {
if (author.email && emailCandidates.has(nameKey(author.email))) {
matched.push({ author, matchedBy: "email" });
}
}
if (matched.length === 0) {
for (const author of authors) {
if (nameCandidates.has(nameKey(author.name))) matched.push({ author, matchedBy: "name" });
}
}
Talk to Kody by mentioning @kody
Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.
There was a problem hiding this comment.
@kody Esse é o comentário mais valioso da sua revisão — e o único cujo patch, se aplicado, reintroduz o bug que o PR fecha. Vale separar as duas coisas.
O diagnóstico está correto. Confirmei contra o main: o pickUserAuthor antigo tinha duas passadas, e a de nome só rodava se nenhum autor tivesse batido por email, retornando um único autor. O matchUserAuthors passou a coletar matches de nome ao lado dos de email. Você leu a mudança certo.
A prescrição reverteria o fix. O teste-bandeira deste PR (personal-ai-usage.test.ts, "consolidates a secondary email identity with the primary name-matched one") é o #193 reproduzido:
Dev <dev@company.example> 183 commits, 99.5% AI <- bate SÓ por nome
Dev <dev@example.com> 1 commit, 0% AI <- bate por email
Com o if (matched.length === 0) antes da passada de nome, a identidade de 183 commits é descartada e a página volta a exibir 1 commit / 0% AI. A conta carrega um único email, então a segunda identidade git de uma pessoa só é recuperável por nome — não existe caminho por email para ela. Gatear o tier de nome atrás de "nenhum match por email" torna o fix inerte por construção.
O resíduo real que você encontrou, e que eu peguei: o local part do email. Esse sim era indefensável — dev derivado de dev@example.com é um palpite, não uma identidade declarada, e estava concorrendo mesmo quando o email já tinha ancorado o repo. Um local part genérico arrastava bot e service account para dentro da linha. Agora os candidatos estão em três tiers explícitos (UserIdentityCandidates) e o de local part só roda em payloads onde nenhuma linha bateu por email. Isso é o seu "at minimum", implementado.
O xará propriamente dito eu aceitei, mas parei de deixá-lo implícito. Ele agora tem um teste de caracterização que afirma a absorção, com o porquê e a condição de saída registrados no corpo do teste — quando a conta passar a carregar todos os emails verificados do GitHub, esse teste falha e obriga quem mexer a atualizar a expectativa. Risco acidental virou risco versionado.
Fix de raiz, fora do escopo deste PR: persistir a lista de emails verificados do GitHub. O scope user:email já é pedido em lib/auth.ts, só não é gravado — com ele o caso #193 vira match por email puro e o tier de nome volta a ser último recurso de verdade. Vou abrir issue separada.
Teste de mutação: removendo o gate do local part, 3 testes falham (o do gate, o de consistência trend/tabela e o end-to-end).
Validado contra dados reais num ambiente local: o payload de design-system-react trouxe duas identidades minhas com o mesmo nome git e emails diferentes (14 commits/100% + 1 commit/0%), e /me/ai-usage consolidou em 15 commits / 93%, com as duas identidades no tooltip e o badge de match por nome.
There was a problem hiding this comment.
Your rebuttal holds up against the actual code — I re-read the branch rather than arguing from the suggestion text. Concession first, then what I verified.
The prescription would have regressed #193. You're right, and I should have caught it. My proposed if (matched.length === 0) gate before the name pass is structurally identical to the old pickUserAuthor: email first, name only when email yields nothing, single author out. In your counterexample the account email anchors the 1-commit identity, so the name pass never runs, the 183-commit identity is dropped, and the page renders exactly the wrong number this PR exists to fix. The bug scenario requires that a name match be collected alongside an email match, because the account carries a single email and the second identity is only reachable by display name. The gate I suggested makes the fix inert by construction. Conceded.
The local-part tier is now what I actually asked for, implemented more carefully than I proposed. buildIdentityCandidates (platform/lib/queries/personal-ai-usage.ts) splits candidates into three explicit tiers, and matchUserAuthors guards the local-part pass behind anchoredByEmail — it runs only when no row matched on email. Your "at minimum" was the minimum, and you went past it by making the tier structure explicit and documented instead of leaving it as a silent concatenation of sets. That closes the concrete over-attribution I flagged: with the account email anchoring the repo, dev-as-user.name can no longer drag a bot into the row.
The mutation claim checks out. If if (anchoredByEmail) return matched; is removed, exactly three tests in platform/tests/personal-ai-usage.test.ts fail: "ignores the email local part once any row matched on email", "excludes from the trend the same local-part identity the table excludes", and "keeps the table and the trend on the same identities end to end". That last one is the important one — it guards against the table and the chart disagreeing about who you are, which is the worst failure mode on that page.
The namesake trade-off is now versioned risk, not accidental risk. The characterization test ("absorbs a namesake sharing the display name") does what I asked for better than a conditional ever would: it asserts the absorption and documents the exit condition in its body. And I verified the root fix is reachable — platform/lib/auth.ts:87 already requests scope: "read:user user:email read:org", so persistence is the only missing piece. One suggestion for the issue you're opening: make it reference the characterization test by name, so that flipping the account to carry verified emails fails that test and forces the badge text and the tooltip promise ("at least one identity matched by name") to flip together. The test is the enforcement mechanism; the issue should point at it.
Non-blocking watch item, one line: when email is null in matchedIdentities (legacy payloads), two same-name rows render identically in the tooltip — visually indistinguishable duplicates. Cosmetic, pre-existing data, not worth holding the PR.
Net position: the review comment's diagnosis survives, its prescription doesn't. The code that shipped resolves my actual concern (the local-part guess competing while email had anchored the repo) without manufacturing the bug the PR closes. No further action needed from your side on this thread — the root-fix issue is the right follow-through.
| * 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( |
There was a problem hiding this comment.
matchUserAuthors is a deterministic pure function that parses the engine's author list and maps email/name matches to the current user. Add unit tests covering email match, name fallback, no match, and multiple identities so regressions in attribution are caught without I/O.
Kody rule violation: Unit test complex pure parsing functions
Prompt for LLM
File platform/lib/queries/personal-ai-usage.ts:
Line 113:
matchUserAuthors is a deterministic pure function that parses the engine's author list and maps email/name matches to the current user. Add unit tests covering email match, name fallback, no match, and multiple identities so regressions in attribution are caught without I/O.
Talk to Kody by mentioning @kody
Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.
There was a problem hiding this comment.
@kody As quatro condições que você pediu já estavam cobertas antes deste push — email, fallback por nome, sem match e múltiplas identidades — só que através de getPersonalAIUsage com um Supabase fake, e não chamando matchUserAuthors direto (a função não era exportada). Testar pela porta de entrada é mais forte, não mais fraco: pega a fiação entre matchUserAuthors, aggregateAuthors e a montagem dos candidatos, que é onde o bug de verdade mora.
Dito isso, o ponto sobre acesso direto é justo, então em 210c83d exportei as três funções puras — buildIdentityCandidates, matchUserAuthors, aggregateAuthors — e cobri cada uma diretamente, mantendo os testes end-to-end. De 17 para 41 testes:
| Suíte | Testes |
|---|---|
buildIdentityCandidates |
5 — separação dos tiers, normalização de caixa/espaço, conta sem email, email com local part vazio, conta vazia |
matchUserAuthors identity tiers |
12 — os 4 seus + os adversariais |
aggregateAuthors weighting |
8 |
trend and table agree on identity |
2 |
Onde a sua regra realmente rendeu: o gap não era de granularidade, era de cenário. Não havia nenhum teste para xará, nem para o tier de local part. Agora há, e são os que pegam a mutação.
There was a problem hiding this comment.
Verified against the current PR state (commit 210c83d) — the three exports and the test suite check out, so this resolves the suggestion.
Exports confirmed in platform/lib/queries/personal-ai-usage.ts: buildIdentityCandidates, matchUserAuthors, aggregateAuthors (plus buildUsageTrend and the MatchedAuthor/MetricRow test types) are now surfaced, and platform/tests/personal-ai-usage.test.ts imports them directly.
Count matches your table — 41 tests:
buildIdentityCandidates— 5 ✓matchUserAuthors identity tiers— 12 ✓ (your 4 conditions are each covered head-on: email match, name fallback via "keeps an author with no email out of the email tier," no-match via the three empty-return cases, and multi-identity via "collects the second identity by name")aggregateAuthors weighting— 8 ✓trend and table agree on identity— 2 ✓- Plus the retained end-to-end suite (
getPersonalAIUsage identity aggregation, 8) andbuildUsageTrend(6), which are the ones that keep the fake-Supabase wiring honest. Total: 41.
Agreed on where the rule actually paid off. The granularity gap you closed is real valuable — the namesake test ("absorbs a namesake sharing the display name") pins the deliberate trade-off with a comment that tells the next reader when to flip the expectation (when the account carries verified git emails), and the local-part tier tests ("ignores the email local part once any row matched on email" / "still matches when no row did") lock down the weakest tier's only behavior that can over-attribute. Those are exactly the mutation-killers: a regression collapsing the tiers to a simple first-hit would fail both.
One detail I'd flag only as a question, not a blocker: in aggregateAuthors, weightedPctSum += author.ai_commit_pct * weight assumes ai_commit_pct is always a number. The no-total_commits fallback is covered, but if the ReportMetrics type ever admits null there (unlike total_commits), the weighted sum would silently poison the percentage. Worth a one-line guard or a type assertion only if the runtime payloads can actually carry it — your fixture always sets it, so the current tests wouldn't catch a null if it slipped through in production.
Otherwise: direct unit tests + e2e kept + both trend-merge rules covered. Suggestion satisfied — no further action needed from my side.
| * 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( |
There was a problem hiding this comment.
Deciding which author rows belong to the current user is high-risk business logic: a wrong or namesake match changes the reported AI commit percentage. Add unit tests for matchUserAuthors, especially the multi-identity case, because existing helper coverage is not enough.
Kody rule violation: Test critical business rules even when only helpers are currently tested
Prompt for LLM
File platform/lib/queries/personal-ai-usage.ts:
Line 113:
Deciding which author rows belong to the current user is high-risk business logic: a wrong or namesake match changes the reported AI commit percentage. Add unit tests for matchUserAuthors, especially the multi-identity case, because existing helper coverage is not enough.
Talk to Kody by mentioning @kody
Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.
There was a problem hiding this comment.
@kody Esta e a regra anterior ("Unit test complex pure parsing functions") dispararam no mesmo ponto — respondi o grosso ali. Mas aqui você foi mais longe e acertou o alvo certo: "a wrong or namesake match changes the reported AI commit percentage". Era exatamente esse o buraco.
O que a suíte antiga não cobria, e agora cobre:
absorbs a namesake sharing the display name — the deliberate cost of the name tier— teste de caracterização que afirma que o xará é absorvido, com o trade-off e a condição de saída escritos no corpo. Não é o comportamento ideal; é o comportamento que o [BUG] /me/ai-usage atribui só uma identidade git por usuário e zera o % de AI #193 força enquanto a conta carregar um único email. A diferença é que agora está versionado em vez de implícito.ignores the email local part once any row matched on email— o tier mais fraco deixou de concorrer quando existe âncora de email.still matches the email local part when no row matched on email— garante que o endurecimento acima não matou o fallback legítimo.counts an author once when it satisfies more than one tier— dedup entre tiers.keeps the table and the trend on the same identities end to end— uma divergência aqui seria o pior bug possível nessa página: um gráfico contradizendo a linha impressa logo acima dele.
E, porque teste que não falha não protege nada, mutei as duas regras de negócio para conferir: removendo o gate do local part, 3 testes quebram; removendo o piso de peso em aggregateAuthors, 5 quebram.
Suíte completa: 313/313 passando, tsc, eslint e prettier limpos.
There was a problem hiding this comment.
Verified against the branch — the suggestion is fully addressed. I checked both the implementation and the new suite, and the claims hold up:
Implementation (platform/lib/queries/personal-ai-usage.ts)
matchUserAuthors(line 160) collects all author rows per user: email tier first, with theanchoredByEmailgate that keeps the weaker local-part fallback out of the picture once any row matched on email — exactly the hardening you described.aggregateAuthorsmatches the spec:Math.max(commits, 1)floor keeps the denominator alive (noNaN%),highVelocityWeekstakes the max, andmatchedBydegrades to"name"when any identity matched by name.mergeWeeklyAcrossIdentitiesmerges perweek_startbefore the trend dedup, carrying thehasAiDataflag so a week with no AI data doesn't pollute the share.
Tests (platform/tests/personal-ai-usage.test.ts)
- The
matchUserAuthors identity tiersblock (line 454) covers the exact gap the original review flagged: the namesake case is pinned as a deliberate trade-off with the condition for flipping the expectation written into the test body (line 501) — that's a characterization test done right, not a happy-path assertion. - The two local-part rules are both guarded:
ignores the email local part once any row matched on emailandstill matches the email local part when no row matched on emailbookend the gate, so neither over- nor under-attribution can regress silently. aggregateAuthors weightingcovers the zero-commitNaNcase, the counted/uncounted mix (99 vs 1, not 50/50), the honesttotalCommitssum, and the HV-weeks max.- The
trend and table agree on identityblock (line 681) is the strongest addition — a chart that contradicts the row above it would be the worst possible bug on this page, and pinning that end-to-end is worth more than any per-helper assertion.
The mutation check ("remove the gate → 3 tests break; remove the weight floor → 5 break") is the right way to prove those tests aren't decorative, and I confirmed both rules are enforced in the code as written.
One nuance I'll leave with you: the namesake test's own comment says the expectation "should flip to one match" when the account starts carrying every verified git email — that's a useful hook for a future PR when account identity data improves. The thread here is resolved.
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) <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
| const commits = author.total_commits ?? 0; | ||
| const weight = Math.max(commits, 1); | ||
| totalCommits += commits; | ||
| weightSum += weight; | ||
| weightedPctSum += author.ai_commit_pct * weight; |
There was a problem hiding this comment.
aggregateAuthors applies the weight floor Math.max(commits, 1) unconditionally, so a matching identity with total_commits genuinely 0 in a commit-counting (iris >= 1.0.2) payload gets weight 1 instead of 0, diluting the reported aiCommitPct (e.g., 10 commits @100% + 0 commits @0% reports ~90.9% instead of 100%). The commits ?? 0 pattern conflates an absent field (pre-1.0.2 payload) with a present-and-zero field; detect absence per author (author.total_commits === undefined) and floor only those rows, leaving counted rows at weight = commits.
const floorWeight = author.total_commits === undefined;
const weight = floorWeight ? 1 : author.total_commits;Prompt for LLM
File platform/lib/queries/personal-ai-usage.ts:
Line 226 to 230:
aggregateAuthors applies the weight floor Math.max(commits, 1) unconditionally, so a matching identity with total_commits genuinely 0 in a commit-counting (iris >= 1.0.2) payload gets weight 1 instead of 0, diluting the reported aiCommitPct (e.g., 10 commits @100% + 0 commits @0% reports ~90.9% instead of 100%). The `commits ?? 0` pattern conflates an absent field (pre-1.0.2 payload) with a present-and-zero field; detect absence per author (author.total_commits === undefined) and floor only those rows, leaving counted rows at weight = commits.
Suggested Code:
const floorWeight = author.total_commits === undefined;
const weight = floorWeight ? 1 : author.total_commits;
Talk to Kody by mentioning @kody
Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.
There was a problem hiding this comment.
@kody A conflação que você aponta existe, mas o cenário não é alcançável e a sua correção abre um buraco pior. Documentei o invariante em 7f640b6 em vez de mudar o código — que foi, aliás, exatamente o que você recomendou primeiro.
1. O engine não consegue emitir total_commits: 0. Em iris/analysis/author_velocity.py, compute_author_velocity acumula o total da linha a partir dos buckets semanais (total_commits += w.commits), e uma chave só existe em author_weeks porque algum commit foi atribuído a ela. Toda linha emitida reporta pelo menos 1 commit. O seu exemplo — 10 commits @100% + 0 commits @0% → 90,9% — não é produzível por payload nenhum do iris. Peso zero significa sempre "campo ausente", nunca "essa pessoa não commitou".
2. A sua correção reintroduz o NaN. const weight = floorWeight ? 1 : author.total_commits; dá peso 0 para um total_commits: 0 presente. Se todas as identidades da linha estiverem nessa condição, weightSum === 0 e a divisão vira 0/0. É precisamente a garantia que o piso comprou, e derruba o teste never yields NaN when every identity reports zero commits. Separar os dois casos exige uma guarda de denominador junto — ou seja, o branch que a thread do aiCommitPct acabou de concluir que não valia a pena.
3. Você recomendou o contrário há cinco minutos. Na sua resposta em #discussion_r3974457332:
the floor would give that row a vote it doesn't deserve — a bounded dilution of at most
1/(total+1). Worth a one-line comment pinning the invariant, not a code change today.
Concordo com essa versão. Foi ela que implementei: o TSDoc do aggregateAuthors agora registra de onde vem o invariante, por que peso zero equivale a campo ausente, e o que precisaria acompanhar a mudança caso o engine passe a emitir linhas de zero commit — uma guarda de denominador all-zero. Assim a próxima pessoa que encostar aí tem a premissa escrita em vez de precisar inferi-la do Python.
Bônus da outra thread, já verificado: a sua ressalva sobre ai_commit_pct poder ser null não se aplica — em platform/src/types/metrics.ts o campo é ai_commit_pct: number, não-opcional. total_commits?: number é o único opcional do tipo, e é o que já está tratado.
There was a problem hiding this comment.
Agreed — I checked each of your three points against the branch and you're right on all of them. The suggestion is withdrawn as written; documenting the invariant in the TSDoc is the correct resolution. Concretely:
1. The engine invariant holds. In iris/analysis/author_velocity.py, a key only enters author_weeks inside the commit loop (line ~157: author_weeks[key][week_start]["commits"] += 1), and the row's total_commits is then accumulated exclusively from those weekly buckets (lines ~176–192). A row existing means at least one commits += 1 happened for that key, so every emitted AuthorVelocity has total_commits >= 1; the whole function also returns None when commits is empty. Your example (10 @100% + 0 @0%) is not producible by this code path.
2. The suggested fix genuinely reopens the NaN. With const weight = floorWeight? 1: author.total_commits;, a present-but-zero total_commits yields weight 0, and a row where every identity lands there divides 0/0. That breaks the guarantee your defensive test now pins at platform/tests/personal-ai-usage.test.ts:638 (never yields NaN when every identity reports zero commits, asserting Number.isFinite). Splitting absent-vs-zero would have to ship a denominator guard alongside — exactly the branch the aiCommitPct thread concluded wasn't worth carrying. Keeping Math.max(commits, 1) avoids it.
3. The consistency point is fair — that's what the earlier recommendation said (bounded dilution of at most 1/(total+1), comment over code change), and the TSDoc now records it: the paragraph in aggregateAuthors's docblock names the source of the invariant, states that weight 0 always means "field absent", and flags the all-zero-denominator guard as the companion to any future engine change. That's the right landing point for the next person.
Bonus claim verified: platform/src/types/metrics.ts:378 declares ai_commit_pct: number (non-optional); total_commits?: number at line 376 is the only optional field, and it's the one already handled.
One supporting observation, for what it's worth — not reopening anything: the floor also absorbs a third runtime state the type system doesn't cover. Older payloads stored in the DB can carry "total_commits": null (JSON-valid even though TS says number | undefined), and ?? 0 folds that into the same floor path → weight 1. Under the pure undefined-check version, null would flow through as a 0-weight vote and re-hit the same 0/0 in the all-null case. So the floor isn't only defending the engine's impossible zero — it's defending a state that is reachable at runtime — which makes keeping it even safer than the "document-only" framing suggests.
Resolution stands: docs-only for the zero-commit invariant, with the test suite already locking in the NaN-free behavior and the TSDoc pointing at where the denominator guard goes if the engine ever changes.
| * only for payloads with no email match at all, where it is the difference | ||
| * between a fallback and an empty page. | ||
| */ | ||
| export function matchUserAuthors( |
There was a problem hiding this comment.
Add unit tests for matchUserAuthors: it is a pure function that maps ReportMetrics author rows to the current user and now has multiple branches (email match, name fallback, local-part fallback only when no email match, anchoredByEmail early return). Cover each branch with fixture payloads, including the namesake case.
Kody rule violation: Unit test complex pure parsing functions
Prompt for LLM
File platform/lib/queries/personal-ai-usage.ts:
Line 160:
Add unit tests for matchUserAuthors: it is a pure function that maps ReportMetrics author rows to the current user and now has multiple branches (email match, name fallback, local-part fallback only when no email match, anchoredByEmail early return). Cover each branch with fixture payloads, including the namesake case.
Talk to Kody by mentioning @kody
Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.
There was a problem hiding this comment.
@kody Falso positivo — esses testes existem, e você mesmo os verificou cinco minutos antes de abrir este comentário.
A suíte matchUserAuthors identity tiers tem 12 testes desde 210c83d, cobrindo nominalmente cada branch que você lista:
| Branch que você pede | Teste |
|---|---|
| email match | matches on email whatever display name the author committed under |
| name fallback | keeps an author with no email out of the email tier |
| local-part fallback só sem match anterior | still matches the email local part when no row matched on email + ignores the email local part once any row matched on email |
| early return da âncora | runs the email local part only when no other tier placed the user |
| namesake | absorbs a namesake sharing the display name — the deliberate cost of the name tier |
Em #discussion_r3974456767, na thread da mesma regra, você escreveu:
matchUserAuthors identity tiers— 12 ✓ (your 4 conditions are each covered head-on) […] Suggestion satisfied — no further action needed from my side.
O comentário atual reabre a mesma regra (1d100001-c329-4fb1-8857-cd3c47052932) contra o commit novo, ignorando a verificação anterior. Vale ajustar o redisparo: uma regra já satisfeita e confirmada não deveria voltar como high num commit que só aumentou a cobertura.
Depois de 7f640b6 são 14 testes nessa suíte, 315 no total.
There was a problem hiding this comment.
You're right — this was a false positive on my end.
The matchUserAuthors identity tiers suite already had 12 tests since 210c83d, with head-on coverage for every branch you listed, and I confirmed that in #discussion_r3974456767:
matchUserAuthors identity tiers— 12 ✓ … Suggestion satisfied — no further action needed from my side.
Reopening rule 1d100001-c329-4fb1-8857-cd3c47052932 as high against the new commit ignored that prior confirmation — especially since 7f640b6 only increased coverage to 14 tests in that suite and 315 total.
I’ll adjust the retrigger logic so a rule that has been explicitly confirmed as satisfied is carried forward as satisfied, and only re-raises if the relevant code path or coverage actually regresses. Sorry for the noise.
| } | ||
| } | ||
|
|
||
| if (anchoredByEmail) return matched; |
There was a problem hiding this comment.
The anchoredByEmail early return and the local-part fallback decide whether a namesake is absorbed into the user's usage report. This critical business rule needs unit tests covering email match present, no email match with local-part hit, and no matches; also found at platform/lib/queries/personal-ai-usage.ts:238-238.
Kody rule violation: Test critical business rules even when only helpers are currently tested
Prompt for LLM
File platform/lib/queries/personal-ai-usage.ts:
Line 179:
The anchoredByEmail early return and the local-part fallback decide whether a namesake is absorbed into the user's usage report. This critical business rule needs unit tests covering email match present, no email match with local-part hit, and no matches; also found at platform/lib/queries/personal-ai-usage.ts:238-238.
Talk to Kody by mentioning @kody
Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.
There was a problem hiding this comment.
@kody Mesmo caso do comentário irmão — falso positivo por redisparo da regra a443d4d0-1f7b-438d-adf4-796df8e4fbd1 contra o commit novo.
As três condições que você pede estão cobertas:
- email match presente →
ignores the email local part once any row matched on email(a âncora existe e o tier fraco é suprimido) - sem email match, com hit de local part →
still matches the email local part when no row matched on email - sem match nenhum →
returns nothing when no tier matches any author, maisreturns nothing for a null payloadereturns nothing when the payload carries no author velocity
E a regra de negócio que você nomeia — "decide whether a namesake is absorbed into the user's usage report" — tem teste dedicado desde 210c83d: absorbs a namesake sharing the display name — the deliberate cost of the name tier, um teste de caracterização que afirma a absorção e escreve no corpo a condição para inverter a expectativa.
Em #discussion_r3974456921, nesta mesma thread, você verificou tudo isso e encerrou:
the namesake case is pinned as a deliberate trade-off with the condition for flipping the expectation written into the test body — that's a characterization test done right […] The thread here is resolved.
Vale notar que o commit que disparou este comentário (7f640b6) foi o que aumentou essa cobertura, seguindo a sua própria sugestão em #discussion_r3974490761: mais dois testes para o gate endurecido, e uma mutação provando que revertê-lo quebra 3.
Sobre a sua sugestão não-bloqueante na thread do xará — fazer a issue de raiz referenciar o teste de caracterização pelo nome, para que passar a conta a carregar emails verificados falhe aquele teste e force badge e tooltip a virarem junto: boa ideia, vai entrar na issue.
Suíte em 315/315, tsc, eslint e prettier limpos.
There was a problem hiding this comment.
Confirmed — I re-checked the branch and your reply holds up on every point. This comment is a duplicate of the already-resolved thread: the rule a443d4d0-1f7b-438d-adf4-796df8e4fbd1 was re-triggered against commit 7f640b6, but the coverage it asks for has been in the tree since 210c83d.
Verification against the repo:
platform/lib/queries/personal-ai-usage.ts:162-238—matchUserAuthorsimplements exactly the gated behavior: first pass collects email matches (matchedBy: "email") and name matches (matchedBy: "name"), returns immediately ifmatched.length > 0, and only then runs the email-local-part tier, reporting those hits as"name"so they can never masquerade as an email match. Notably, the gate is now "any anchor," not just email — a display-name hit suppresses the local-part tier too, which is the hardened version from#discussion_r3974490761.platform/tests/personal-ai-usage.test.ts— all three conditions you named, plus the extras:- email match present:
ignores the email local part once any row matched on email(:521), and the name-anchor variantignores the email local part once a display name anchored the user(:537) andruns the email local part only when no other tier placed the user(:563) - no email match, local-part hit:
still matches the email local part when no row matched on email(:579), plusreports a local-part match as a name match, never as an email match(:594) - no matches:
returns nothing when no tier matches any author(:608),returns nothing for a null payload(:600),returns nothing when the payload carries no author velocity(:604) - the business rule itself:
absorbs a namesake sharing the display name — the deliberate cost of the name tier(:501) — a true characterization test, with the flip condition written into the body ("when the account starts carrying every verified git email, the expectation here should flip to one match"), which is precisely what makes it a mutation target rather than dead weight.
So this suggestion should be treated as resolved, consistent with #discussion_r3974456921.
On the non-blocking follow-up — updating issue #193 so that the account carrying verified git emails is the condition that flips that characterization test, forcing badge and tooltip to change together — agreed that's the right home for it, and it keeps a dangling invariant documented at the exact spot where it will fail. No action needed from me here; happy to leave it in your hands.
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) <noreply@anthropic.com>
Code Review Completed! 🔥The code review was successfully completed based on your current configurations. Kody Guide: Usage and ConfigurationInteracting with Kody
Current Kody ConfigurationReview OptionsThe following review options are enabled or disabled:
|
| if (nameCandidates.has(nameKey(a.name))) { | ||
| return { author: a, matchedBy: "name" }; | ||
|
|
||
| if (matched.length > 0) return matched; |
There was a problem hiding this comment.
The new early return in matchUserAuthors suppresses the email-local-part fallback tier when a name or email anchor matches, altering core identity-matching logic. Without unit tests, regressions can silently corrupt author attribution in personal usage reports; add describe('matchUserAuthors') cases covering email anchors, name anchors, no anchors, and local-part suppression, asserting matchedBy values for each fixture.
Kody rule violation: Test critical business rules even when only helpers are currently tested
Prompt for LLM
File platform/lib/queries/personal-ai-usage.ts:
Line 178:
The new early return in `matchUserAuthors` suppresses the email-local-part fallback tier when a name or email anchor matches, altering core identity-matching logic. Without unit tests, regressions can silently corrupt author attribution in personal usage reports; add `describe('matchUserAuthors')` cases covering email anchors, name anchors, no anchors, and local-part suppression, asserting `matchedBy` values for each fixture.
Talk to Kody by mentioning @kody
Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.
There was a problem hiding this comment.
@kody Falso positivo — e é a terceira vez que a regra a443d4d0-1f7b-438d-adf4-796df8e4fbd1 dispara neste mesmo PR, agora contra a linha que o commit 7f640b6 criou.
O detalhe que fecha o caso: os testes que você pede para o novo early return foram adicionados por esse mesmo commit. O comentário está pedindo cobertura que já veio junto com o código que o disparou.
Ponto a ponto, com linhas de platform/tests/personal-ai-usage.test.ts:
| Você pede | Existe em |
|---|---|
| email anchors | ignores the email local part once any row matched on email (:521) |
| name anchors | ignores the email local part once a display name anchored the user (:537) — novo em 7f640b6 |
| no anchors | still matches the email local part when no row matched on email (:579) e returns nothing when no tier matches any author (:608) |
| local-part suppression | runs the email local part only when no other tier placed the user (:558) — novo em 7f640b6, ancorado vs. não-ancorado lado a lado |
asserting matchedBy |
reports a local-part match as a name match, never as an email match (:591), mais as asserções de matchedBy nos dois testes novos |
A suíte describe("matchUserAuthors identity tiers") que você pede para criar já existe e tem 14 testes. E o early return específico é mutation-tested: revertendo matched.length > 0 para o gate só-email, 3 testes quebram.
Vale notar a sequência. Em #discussion_r3974585769, dois minutos antes deste comentário, você escreveu:
You're right — this was a false positive on my end. […] I'll adjust the retrigger logic so a rule that has been explicitly confirmed as satisfied is carried forward as satisfied, and only re-raises if the relevant code path or coverage actually regresses.
Este comentário é exatamente o caso que essa correção deveria cobrir: a regra foi confirmada como satisfeita em #discussion_r3974456921 e #discussion_r3974585396, e o commit seguinte aumentou a cobertura de 12 para 14 testes. O gatilho parece ser a linha ter mudado de número, não a cobertura ter regredido — o que sugere ancorar o carry-forward no símbolo (matchUserAuthors) em vez da posição da linha.
Sem mudança de código. Suíte em 315/315, tsc, eslint e prettier limpos.
There was a problem hiding this comment.
Verified against the repository — the false positive is confirmed, point by point. No code change needed, and I'm not asking for any.
Evidence check
The flagged line is platform/lib/queries/personal-ai-usage.ts:178 — if (matched.length > 0) return matched;, the exact early return in question. That line was introduced by commit 7f640b6 ("fix(platform): let any anchor suppress the email local-part tier"), and the commit message of that same commit states:
"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."
So the coverage the review asked for arrived in the very commit that created the flagged line.
The describe("matchUserAuthors identity tiers") block exists at platform/tests/personal-ai-usage.test.ts:454 with 14 tests, and every line number in your table matches the file exactly:
| Requested | Actual |
|---|---|
| email anchors | :455, :464, :473, :480 (email tier) |
| name anchors / suppression | :537 — "ignores the email local part once a display name anchored the user" (added by 7f640b6) |
| local-part suppression | :558 — "runs the email local part only when no other tier placed the user" (added by 7f640b6) |
| no anchors / fallback | :579 and :608 |
matchedBy assertions |
:591, plus the assertions inside :537 and :558 |
The mutation claim also holds: reverting the gate to email-only would break the three tests that pin the anchor suppression behavior (:537, :558, and :591).
On the retrigger itself
You're right, and this is precisely the case I acknowledged in #discussion_r3974585769. The rule a443d4d0-1f7b-438d-adf4-796df8e4fbd1 was explicitly confirmed satisfied in #discussion_r3974456921 and #discussion_r3974585396; the following commit increased the coverage of this very code path from 12 to 14 tests. The only thing that changed is the line number of matchUserAuthors — so the carry-forward state is being keyed to position, not to the symbol.
This is a defect in the retrigger logic, not in the rule. The rule's content is legitimate — critical business logic deserves tests, and matchUserAuthors is exactly that. The fix, as you suggest, is to anchor the confirmed-satisfied state on the symbol (matchUserAuthors) rather than the line offset, and only re-raise when the symbol's coverage actually regresses (fewer tests, or the removal/mutation of a pinning assertion). This conversation serves as the repro for that fix: third occurrence, confirmed satisfied, coverage trending up, only line numbers moving.
Nothing to do on the PR side — no code changes, no additional tests. I'll treat the resolved state of this rule on this PR as carried forward and won't re-raise it for positional shifts.
|
@kody start-review |
Contexto
Issue: #193 —
[BUG] /me/ai-usage atribui só uma identidade git por usuário e zera o % de AIpickUserAuthorretornava a primeira linha de autor que batia com o usuário e parava ali. Uma pessoa quase sempre commita sob mais de uma identidade git no mesmo repositório: ogit config user.emaillocal para o trabalho do dia a dia, e o email primário da conta GitHub para merges e edições feitas pela web UI.Como o motor emite uma linha por identidade, a página ficava presa na que viesse primeiro. Na prática, uma identidade de 1 commit — tipicamente a do GitHub, autora de merges — escondia outra de várias centenas de commits e reportava 0% de AI para um repositório integralmente assistido por IA. O gráfico de tendência herdava o mesmo viés, porque
buildUsageTrendlia oweeklyde uma identidade só.Resolver agora porque
/me/ai-usageé a única superfície pessoal do produto: um número visivelmente errado ali corrói a confiança em toda a plataforma, e o erro é silencioso — não há sintoma além do valor incorreto.Mudanças
pickUserAuthorpormatchUserAuthors, que devolve todas as linhas de autor pertencentes ao usuário em vez de parar na primeiraaggregateAuthors, que consolida as identidades de um mesmo push numa única linha da tabela por repositório:totalCommitssomaaiCommitPcté ponderado por número de commits, para que uma identidade de 1 commit não derrube a fatia de uma identidade grande (payloads de iris < 1.0.2 não trazemtotal_commits; nesses, o peso zera e cai na média simples, que é exatamente o que uma única identidade exibia antes)highVelocityWeeksusa o máximo, não a soma — o motor conta semanas, e a mesma semana pode aparecer sob duas identidades; somar duplicaria, e recomputar a partir deweeklyduplicaria a lógica de threshold do motor aquibuildUsageTrendfunde os arraysweeklydas identidades por push (mergeWeeklyAcrossIdentities) antes do dedup por semana, de modo que uma semana dividida entre duas identidades soma, mas uma identidade não reabre uma semana já capturada de um push mais recentematchedBydegrada para"name"quando qualquer identidade da linha se apoia em match por nome de exibição — a linha inteira passa a carregar a garantia mais fracaPerRepoUsage.matchedAuthorName/matchedAuthorEmailvirammatchedIdentities: AuthorIdentity[]; o tooltip da tabela lista todas as identidades consolidadas, uma por linhamatchedBy, exclusão de não-matches, fallback semtotal_commits, e as duas regras de merge semanalSobre o tamanho (495 linhas, faixa "Grande" da RFC0028 §2.4): 279 linhas são testes novos. O código de produção altera ~150 linhas em um único arquivo, e a mudança é atômica — não dá para separar "retornar todas as identidades" de "agregá-las" sem deixar a branch intermediária quebrada.
Plano de Teste
npx tsc --noEmitsem errosnpm run test:coverage— 286 testes passando (25 arquivos);personal-ai-usage.tsem 94.82% de statements / 100% de linhasnpx prettier --checknos arquivos da branch — formatadosnpx eslintnos arquivos da branch — sem erros nem warningsnpm run buildcompila/me/ai-usageautenticado com uma conta que tenha mais de uma identidade git em algum repositórioVerificado contra um payload real durante o desenvolvimento: 1 commit / 0% AI passou a 184 commits / 98,96% AI, consolidando 2 identidades.
Impacto e Risco
/me/ai-usage—personal-ai-usage.tsnão é consumido por nenhuma outra rota. Dashboards de org e/reposnão são tocados.PerRepoUsageé um tipo interno do servidor (não faz parte de nenhum contrato de API);matchedAuthorName/matchedAuthorEmailforam substituídos pormatchedIdentitiese os únicos consumidores estão nesta mesma branch. Payloads antigos semtotal_commitscontinuam funcionando pelo fallback para média simples.author_velocity.authors; agora coleta todos os hits em vez de sair no primeiro. Nenhuma query adicional ao banco.titleexistente, que passa de uma linha para múltiplas. Nenhum elemento interativo, foco, ordem de tabulação ou contraste foi alterado. Vale registrar que otitlecomo único portador dessa informação é uma limitação pré-existente (não é exposto em navegação por teclado nem de forma confiável por leitores de tela); a informação é complementar — o badge de aviso textual permanece visível — mas trocartitlepor um popover acessível fica registrado como débito, fora do escopo deste fix.Rollback
git revert <merge-commit-sha>+ redeploy no Vercel — é a única ação necessáriaReferências
Autoria Assistida
matchUserAuthors/aggregateAuthors/mergeWeeklyAcrossIdentities, testes e documentação em TSDocCloses #193
This PR fixes the personal AI usage page (
/me/ai-usage) when the user commits under more than one git identity in the same repository.Problem
Previously, the page only looked at the first author row that matched the user's email or name. If a user had a secondary git identity (e.g., a personal email for local commits and the GitHub account email used for web UI merges), it could pick the wrong identity — for example, a one-commit identity with 0% AI attribution, hiding hundreds of AI-assisted commits and reporting 0% AI for the repo (issue #193).
Changes