-
Notifications
You must be signed in to change notification settings - Fork 1
fix(platform): consolida todas as identidades git em /me/ai-usage #240
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -15,15 +15,37 @@ import type { ReportMetrics } from "@/types/metrics"; | |||||||||||||||||||||||||
| // (typically one push per CI run), not on calendar days of history. | ||||||||||||||||||||||||||
| const HISTORY_DEPTH = 12; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export interface AuthorIdentity { | ||||||||||||||||||||||||||
| name: string; | ||||||||||||||||||||||||||
| email: string | null; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * The three tiers of evidence that an author row belongs to the current user, | ||||||||||||||||||||||||||
| * strongest first. | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * `emails` is the only tier git itself guarantees: it deduplicates authors by | ||||||||||||||||||||||||||
| * email and the engine preserves it on every row. `names` is the account's | ||||||||||||||||||||||||||
| * declared display name — good enough to catch a second git identity, but a | ||||||||||||||||||||||||||
| * namesake shares it. `emailLocalParts` is a guess derived from the account | ||||||||||||||||||||||||||
| * email (`dev` out of `dev@example.com`) and is the weakest of all: generic | ||||||||||||||||||||||||||
| * local parts collide with bots and service accounts, so it is consulted only | ||||||||||||||||||||||||||
| * for payloads where no row matched on email at all. | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
| export interface UserIdentityCandidates { | ||||||||||||||||||||||||||
| emails: Set<string>; | ||||||||||||||||||||||||||
| names: Set<string>; | ||||||||||||||||||||||||||
| emailLocalParts: Set<string>; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export interface PerRepoUsage { | ||||||||||||||||||||||||||
| organizationSlug: string; | ||||||||||||||||||||||||||
| organizationName: string; | ||||||||||||||||||||||||||
| repositoryName: string; | ||||||||||||||||||||||||||
| repositoryId: string; | ||||||||||||||||||||||||||
| aiCommitPct: number; | ||||||||||||||||||||||||||
| totalCommits: number; | ||||||||||||||||||||||||||
| matchedAuthorName: string; | ||||||||||||||||||||||||||
| matchedAuthorEmail: string | null; | ||||||||||||||||||||||||||
| matchedIdentities: AuthorIdentity[]; | ||||||||||||||||||||||||||
| matchedBy: "email" | "name"; | ||||||||||||||||||||||||||
| highVelocityWeeks: number; | ||||||||||||||||||||||||||
| lastSeenAt: string; | ||||||||||||||||||||||||||
|
|
@@ -64,38 +86,194 @@ interface RepoRow { | |||||||||||||||||||||||||
| organization_id: string; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| type PayloadAuthor = NonNullable< | ||||||||||||||||||||||||||
| ReportMetrics["author_velocity"] | ||||||||||||||||||||||||||
| >["authors"][number]; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export interface MatchedAuthor { | ||||||||||||||||||||||||||
| author: PayloadAuthor; | ||||||||||||||||||||||||||
| matchedBy: "email" | "name"; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export interface AggregatedUsage { | ||||||||||||||||||||||||||
| totalCommits: number; | ||||||||||||||||||||||||||
| aiCommitPct: number; | ||||||||||||||||||||||||||
| highVelocityWeeks: number; | ||||||||||||||||||||||||||
| matchedBy: "email" | "name"; | ||||||||||||||||||||||||||
| identities: AuthorIdentity[]; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| interface WeekTotals { | ||||||||||||||||||||||||||
| commits: number; | ||||||||||||||||||||||||||
| aiCommits: number; | ||||||||||||||||||||||||||
| hasAiData: boolean; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function nameKey(value: string): string { | ||||||||||||||||||||||||||
| return value.trim().toLowerCase(); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Match the current user against the engine's per-author rows. Email is the | ||||||||||||||||||||||||||
| // reliable identity — git deduplicates authors by email and the engine | ||||||||||||||||||||||||||
| // preserves it on every row. Name match remains as a fallback for older | ||||||||||||||||||||||||||
| // payloads (pre-email field) and for authors whose commits lack an email | ||||||||||||||||||||||||||
| // (rare, falls back to author string). Returning the matched method lets | ||||||||||||||||||||||||||
| // callers expose it in the UI so the user can sanity-check what attributed | ||||||||||||||||||||||||||
| // to them. | ||||||||||||||||||||||||||
| function pickUserAuthor( | ||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * Build the candidate sets for one account, split by how much each signal can | ||||||||||||||||||||||||||
| * be trusted. See {@link UserIdentityCandidates}. | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
| export function buildIdentityCandidates(user: { | ||||||||||||||||||||||||||
| name: string | null; | ||||||||||||||||||||||||||
| email: string | null; | ||||||||||||||||||||||||||
| }): UserIdentityCandidates { | ||||||||||||||||||||||||||
| const emails = new Set<string>(); | ||||||||||||||||||||||||||
| const names = new Set<string>(); | ||||||||||||||||||||||||||
| const emailLocalParts = new Set<string>(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (user.email) { | ||||||||||||||||||||||||||
| emails.add(nameKey(user.email)); | ||||||||||||||||||||||||||
| const localPart = user.email.split("@")[0]; | ||||||||||||||||||||||||||
| if (localPart) emailLocalParts.add(nameKey(localPart)); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| if (user.name) names.add(nameKey(user.name)); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return { emails, names, emailLocalParts }; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * Every author row in `payload` that belongs to the current user. | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * One person routinely commits under more than one git identity in the same | ||||||||||||||||||||||||||
| * repo: the local `git config user.email` for their own work, and the GitHub | ||||||||||||||||||||||||||
| * account's primary email for merges and edits made through the web UI. | ||||||||||||||||||||||||||
| * Returning only the first hit pins the user to whichever identity happens to | ||||||||||||||||||||||||||
| * come first, so a one-commit identity can hide a several-hundred-commit one | ||||||||||||||||||||||||||
| * and report 0% AI for an otherwise fully AI-assisted repo (issue #193). | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * Because the account carries a single email, that second identity can only | ||||||||||||||||||||||||||
| * ever be recovered by display name, so a name hit has to count alongside an | ||||||||||||||||||||||||||
| * email hit rather than only when the email finds nothing. The cost is that a | ||||||||||||||||||||||||||
| * true namesake in the same repo is absorbed into the user's row; the match is | ||||||||||||||||||||||||||
| * reported back as `"name"` so callers can warn about it. | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * The email local part does not get that latitude. It is a guess, not a | ||||||||||||||||||||||||||
| * declared identity, so it is a last resort in the strict sense: it runs only | ||||||||||||||||||||||||||
| * when neither tier above placed the user in this payload at all, where it is | ||||||||||||||||||||||||||
| * the difference between a fallback and an empty page. Any anchor suppresses | ||||||||||||||||||||||||||
| * it — a display-name hit proves the user is present in this repo just as an | ||||||||||||||||||||||||||
| * email hit does, and past that point the guess can only over-attribute a bot | ||||||||||||||||||||||||||
| * or a service account whose git name happens to be a generic local part. | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
| export function matchUserAuthors( | ||||||||||||||||||||||||||
| payload: ReportMetrics | null, | ||||||||||||||||||||||||||
| emailCandidates: Set<string>, | ||||||||||||||||||||||||||
| nameCandidates: Set<string>, | ||||||||||||||||||||||||||
| ): { | ||||||||||||||||||||||||||
| author: NonNullable<ReportMetrics["author_velocity"]>["authors"][number]; | ||||||||||||||||||||||||||
| matchedBy: "email" | "name"; | ||||||||||||||||||||||||||
| } | null { | ||||||||||||||||||||||||||
| candidates: UserIdentityCandidates, | ||||||||||||||||||||||||||
| ): MatchedAuthor[] { | ||||||||||||||||||||||||||
| const authors = payload?.author_velocity?.authors; | ||||||||||||||||||||||||||
| if (!authors) return null; | ||||||||||||||||||||||||||
| for (const a of authors) { | ||||||||||||||||||||||||||
| if (a.email && emailCandidates.has(nameKey(a.email))) { | ||||||||||||||||||||||||||
| return { author: a, matchedBy: "email" }; | ||||||||||||||||||||||||||
| if (!authors) return []; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const matched: MatchedAuthor[] = []; | ||||||||||||||||||||||||||
| for (const author of authors) { | ||||||||||||||||||||||||||
| if (author.email && candidates.emails.has(nameKey(author.email))) { | ||||||||||||||||||||||||||
| matched.push({ author, matchedBy: "email" }); | ||||||||||||||||||||||||||
| } else if (candidates.names.has(nameKey(author.name))) { | ||||||||||||||||||||||||||
| matched.push({ author, matchedBy: "name" }); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| for (const a of authors) { | ||||||||||||||||||||||||||
| if (nameCandidates.has(nameKey(a.name))) { | ||||||||||||||||||||||||||
| return { author: a, matchedBy: "name" }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (matched.length > 0) return matched; | ||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new early return in Kody rule violation: Test critical business rules even when only helpers are currently tested Prompt for LLMTalk to Kody by mentioning @kody Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @kody Falso positivo — e é a terceira vez que a regra 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
A suíte Vale notar a sequência. Em
Este comentário é exatamente o caso que essa correção deveria cobrir: a regra foi confirmada como satisfeita em Sem mudança de código. Suíte em 315/315, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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
So the coverage the review asked for arrived in the very commit that created the flagged line. The
The mutation claim also holds: reverting the gate to email-only would break the three tests that pin the anchor suppression behavior ( On the retrigger itself You're right, and this is precisely the case I acknowledged in This is a defect in the retrigger logic, not in the rule. The rule's content is legitimate — critical business logic deserves tests, and 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. |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| for (const author of authors) { | ||||||||||||||||||||||||||
| if (candidates.emailLocalParts.has(nameKey(author.name))) { | ||||||||||||||||||||||||||
| matched.push({ author, matchedBy: "name" }); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Comment on lines
+169
to
184
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 LLMTalk to Kody by mentioning @kody Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @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 A prescrição reverteria o fix. O teste-bandeira deste PR ( Com o O resíduo real que você encontrou, e que eu peguei: o local part do email. Esse sim era indefensável — 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 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 The local-part tier is now what I actually asked for, implemented more carefully than I proposed. The mutation claim checks out. If 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 — Non-blocking watch item, one line: when 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. |
||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return matched; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * Collapse the user's identities within one push into the single row the | ||||||||||||||||||||||||||
| * per-repo table shows. | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * `aiCommitPct` is weighted by commit count so a stray one-commit identity | ||||||||||||||||||||||||||
| * cannot drag the share of a large one down. Each identity weighs at least 1: | ||||||||||||||||||||||||||
| * payloads from iris < 1.0.2 carry no `total_commits`, and a floor keeps them | ||||||||||||||||||||||||||
| * on the same formula instead of a second code path — one such identity | ||||||||||||||||||||||||||
| * reproduces its own share exactly, several collapse to the plain mean, and | ||||||||||||||||||||||||||
| * the denominator can never reach zero. `totalCommits` stays the honest sum of | ||||||||||||||||||||||||||
| * what the payload actually reported, so those rows still show 0 commits. | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * The floor treats a missing `total_commits` and a reported zero alike, which | ||||||||||||||||||||||||||
| * is safe because the engine emits an author row only for an author it counted | ||||||||||||||||||||||||||
| * at least one commit for: `compute_author_velocity` sums the row's total from | ||||||||||||||||||||||||||
| * its weekly buckets, and a bucket exists only where a commit landed. A weight | ||||||||||||||||||||||||||
| * of zero therefore always means "field absent", never "this person committed | ||||||||||||||||||||||||||
| * nothing". Should that invariant ever change, splitting the two cases has to | ||||||||||||||||||||||||||
| * come with a guard for an all-zero denominator. | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * `highVelocityWeeks` takes the maximum rather than the sum: the engine counts | ||||||||||||||||||||||||||
| * weeks, and the same calendar week can appear under two identities. Summing | ||||||||||||||||||||||||||
| * would double-count it, and recomputing from `weekly` would duplicate the | ||||||||||||||||||||||||||
| * engine's threshold logic here. | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * `matchedBy` reports "email" only when every identity matched on email. If | ||||||||||||||||||||||||||
| * any part of the row rests on a display-name match, the whole row carries the | ||||||||||||||||||||||||||
| * weaker guarantee and the UI should say so. | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
| export function aggregateAuthors( | ||||||||||||||||||||||||||
| matches: MatchedAuthor[], | ||||||||||||||||||||||||||
| ): AggregatedUsage | null { | ||||||||||||||||||||||||||
| if (matches.length === 0) return null; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| let totalCommits = 0; | ||||||||||||||||||||||||||
| let weightSum = 0; | ||||||||||||||||||||||||||
| let weightedPctSum = 0; | ||||||||||||||||||||||||||
| let highVelocityWeeks = 0; | ||||||||||||||||||||||||||
| let everyMatchByEmail = true; | ||||||||||||||||||||||||||
| const identities: AuthorIdentity[] = []; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| for (const { author, matchedBy } of matches) { | ||||||||||||||||||||||||||
| const commits = author.total_commits ?? 0; | ||||||||||||||||||||||||||
| const weight = Math.max(commits, 1); | ||||||||||||||||||||||||||
| totalCommits += commits; | ||||||||||||||||||||||||||
| weightSum += weight; | ||||||||||||||||||||||||||
| weightedPctSum += author.ai_commit_pct * weight; | ||||||||||||||||||||||||||
|
Comment on lines
+231
to
+235
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 const floorWeight = author.total_commits === undefined;
const weight = floorWeight ? 1 : author.total_commits;Prompt for LLMTalk to Kody by mentioning @kody Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @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 2. A sua correção reintroduz o 3. Você recomendou o contrário há cinco minutos. Na sua resposta em
Concordo com essa versão. Foi ela que implementei: o TSDoc do Bônus da outra thread, já verificado: a sua ressalva sobre There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 2. The suggested fix genuinely reopens the NaN. With 3. The consistency point is fair — that's what the earlier recommendation said (bounded dilution of at most Bonus claim verified: 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 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. |
||||||||||||||||||||||||||
| highVelocityWeeks = Math.max(highVelocityWeeks, author.high_velocity_weeks); | ||||||||||||||||||||||||||
| if (matchedBy === "name") everyMatchByEmail = false; | ||||||||||||||||||||||||||
| identities.push({ name: author.name, email: author.email ?? null }); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||
| totalCommits, | ||||||||||||||||||||||||||
| aiCommitPct: weightedPctSum / weightSum, | ||||||||||||||||||||||||||
| highVelocityWeeks, | ||||||||||||||||||||||||||
| matchedBy: everyMatchByEmail ? "email" : "name", | ||||||||||||||||||||||||||
| identities, | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * Merge one push's weekly arrays across every identity the user commits under, | ||||||||||||||||||||||||||
| * so a week split between two identities becomes a single entry instead of two | ||||||||||||||||||||||||||
| * competing ones. | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
| function mergeWeeklyAcrossIdentities( | ||||||||||||||||||||||||||
| matches: MatchedAuthor[], | ||||||||||||||||||||||||||
| ): Map<string, WeekTotals> { | ||||||||||||||||||||||||||
| const merged = new Map<string, WeekTotals>(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| for (const { author } of matches) { | ||||||||||||||||||||||||||
| for (const week of author.weekly ?? []) { | ||||||||||||||||||||||||||
| const totals = merged.get(week.week_start) ?? { | ||||||||||||||||||||||||||
| commits: 0, | ||||||||||||||||||||||||||
| aiCommits: 0, | ||||||||||||||||||||||||||
| hasAiData: false, | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
| totals.commits += week.commits; | ||||||||||||||||||||||||||
| if (typeof week.ai_commits === "number") { | ||||||||||||||||||||||||||
| totals.aiCommits += week.ai_commits; | ||||||||||||||||||||||||||
| totals.hasAiData = true; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| merged.set(week.week_start, totals); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return merged; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Weekly AI commit share aggregated across each repo's full fetched history, | ||||||||||||||||||||||||||
|
|
@@ -111,8 +289,7 @@ function pickUserAuthor( | |||||||||||||||||||||||||
| // no AI share for those weeks. | ||||||||||||||||||||||||||
| export function buildUsageTrend( | ||||||||||||||||||||||||||
| rowsPerRepo: Map<string, MetricRow[]>, | ||||||||||||||||||||||||||
| emailCandidates: Set<string>, | ||||||||||||||||||||||||||
| nameCandidates: Set<string>, | ||||||||||||||||||||||||||
| candidates: UserIdentityCandidates, | ||||||||||||||||||||||||||
| ): UsageTrendPoint[] { | ||||||||||||||||||||||||||
| type WeekBucket = { | ||||||||||||||||||||||||||
| commits: number; | ||||||||||||||||||||||||||
|
|
@@ -129,29 +306,24 @@ export function buildUsageTrend( | |||||||||||||||||||||||||
| // skipped. | ||||||||||||||||||||||||||
| const seenWeeks = new Set<string>(); | ||||||||||||||||||||||||||
| for (const row of rows) { | ||||||||||||||||||||||||||
| const match = pickUserAuthor( | ||||||||||||||||||||||||||
| row.payload, | ||||||||||||||||||||||||||
| emailCandidates, | ||||||||||||||||||||||||||
| nameCandidates, | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| if (!match?.author.weekly) continue; | ||||||||||||||||||||||||||
| for (const w of match.author.weekly) { | ||||||||||||||||||||||||||
| if (seenWeeks.has(w.week_start)) continue; | ||||||||||||||||||||||||||
| seenWeeks.add(w.week_start); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const bucket: WeekBucket = weekly.get(w.week_start) ?? { | ||||||||||||||||||||||||||
| const matches = matchUserAuthors(row.payload, candidates); | ||||||||||||||||||||||||||
| if (matches.length === 0) continue; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| for (const [weekStart, totals] of mergeWeeklyAcrossIdentities(matches)) { | ||||||||||||||||||||||||||
| if (seenWeeks.has(weekStart)) continue; | ||||||||||||||||||||||||||
| seenWeeks.add(weekStart); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const bucket: WeekBucket = weekly.get(weekStart) ?? { | ||||||||||||||||||||||||||
| commits: 0, | ||||||||||||||||||||||||||
| aiCommits: 0, | ||||||||||||||||||||||||||
| repoIds: new Set(), | ||||||||||||||||||||||||||
| hasAiData: false, | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
| bucket.commits += w.commits; | ||||||||||||||||||||||||||
| if (typeof w.ai_commits === "number") { | ||||||||||||||||||||||||||
| bucket.aiCommits += w.ai_commits; | ||||||||||||||||||||||||||
| bucket.hasAiData = true; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| bucket.commits += totals.commits; | ||||||||||||||||||||||||||
| bucket.aiCommits += totals.aiCommits; | ||||||||||||||||||||||||||
| if (totals.hasAiData) bucket.hasAiData = true; | ||||||||||||||||||||||||||
| bucket.repoIds.add(repoId); | ||||||||||||||||||||||||||
| weekly.set(w.week_start, bucket); | ||||||||||||||||||||||||||
| weekly.set(weekStart, bucket); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
@@ -183,18 +355,14 @@ export async function getPersonalAIUsage( | |||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (orgs.length === 0) return empty; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Email match is the reliable identity. Name match is a fallback for | ||||||||||||||||||||||||||
| // older payloads (pre-email field) and unusual cases. | ||||||||||||||||||||||||||
| const emailCandidates = new Set<string>(); | ||||||||||||||||||||||||||
| if (user.email) emailCandidates.add(nameKey(user.email)); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const nameCandidates = new Set<string>(); | ||||||||||||||||||||||||||
| if (user.name) nameCandidates.add(nameKey(user.name)); | ||||||||||||||||||||||||||
| if (user.email) { | ||||||||||||||||||||||||||
| const localPart = user.email.split("@")[0]; | ||||||||||||||||||||||||||
| if (localPart) nameCandidates.add(nameKey(localPart)); | ||||||||||||||||||||||||||
| const candidates = buildIdentityCandidates(user); | ||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||
| candidates.emails.size === 0 && | ||||||||||||||||||||||||||
| candidates.names.size === 0 && | ||||||||||||||||||||||||||
| candidates.emailLocalParts.size === 0 | ||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||
| return empty; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| if (emailCandidates.size === 0 && nameCandidates.size === 0) return empty; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const orgIds = orgs.map((o) => o.id); | ||||||||||||||||||||||||||
| const orgIndex = new Map(orgs.map((o) => [o.id, o])); | ||||||||||||||||||||||||||
|
|
@@ -237,8 +405,8 @@ export async function getPersonalAIUsage( | |||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| for (const [repoId, rows] of rowsPerRepo) { | ||||||||||||||||||||||||||
| const row = rows[0]; // newest row — summary table shows current snapshot only. | ||||||||||||||||||||||||||
| const match = pickUserAuthor(row.payload, emailCandidates, nameCandidates); | ||||||||||||||||||||||||||
| if (!match) continue; | ||||||||||||||||||||||||||
| const usage = aggregateAuthors(matchUserAuthors(row.payload, candidates)); | ||||||||||||||||||||||||||
| if (!usage) continue; | ||||||||||||||||||||||||||
| const repo = repoIndex.get(repoId); | ||||||||||||||||||||||||||
| const org = orgIndex.get(row.organization_id); | ||||||||||||||||||||||||||
| if (!repo || !org) continue; | ||||||||||||||||||||||||||
|
|
@@ -248,21 +416,19 @@ export async function getPersonalAIUsage( | |||||||||||||||||||||||||
| organizationName: org.name, | ||||||||||||||||||||||||||
| repositoryName: repo.name, | ||||||||||||||||||||||||||
| repositoryId: repoId, | ||||||||||||||||||||||||||
| aiCommitPct: match.author.ai_commit_pct, | ||||||||||||||||||||||||||
| totalCommits: match.author.total_commits ?? 0, | ||||||||||||||||||||||||||
| matchedAuthorName: match.author.name, | ||||||||||||||||||||||||||
| matchedAuthorEmail: match.author.email ?? null, | ||||||||||||||||||||||||||
| matchedBy: match.matchedBy, | ||||||||||||||||||||||||||
| highVelocityWeeks: match.author.high_velocity_weeks, | ||||||||||||||||||||||||||
| aiCommitPct: usage.aiCommitPct, | ||||||||||||||||||||||||||
| totalCommits: usage.totalCommits, | ||||||||||||||||||||||||||
| matchedIdentities: usage.identities, | ||||||||||||||||||||||||||
| matchedBy: usage.matchedBy, | ||||||||||||||||||||||||||
| highVelocityWeeks: usage.highVelocityWeeks, | ||||||||||||||||||||||||||
| lastSeenAt: row.created_at, | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| aiSum += match.author.ai_commit_pct; | ||||||||||||||||||||||||||
| aiSum += usage.aiCommitPct; | ||||||||||||||||||||||||||
| aiCount += 1; | ||||||||||||||||||||||||||
| if (match.author.high_velocity_weeks > maxHv) | ||||||||||||||||||||||||||
| maxHv = match.author.high_velocity_weeks; | ||||||||||||||||||||||||||
| if (usage.highVelocityWeeks > maxHv) maxHv = usage.highVelocityWeeks; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const trend = buildUsageTrend(rowsPerRepo, emailCandidates, nameCandidates); | ||||||||||||||||||||||||||
| const trend = buildUsageTrend(rowsPerRepo, candidates); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| perRepo.sort((a, b) => b.aiCommitPct - a.aiCommitPct); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@kody Falso positivo — esses testes existem, e você mesmo os verificou cinco minutos antes de abrir este comentário.
A suíte
matchUserAuthors identity tierstem 12 testes desde 210c83d, cobrindo nominalmente cada branch que você lista:matches on email whatever display name the author committed underkeeps an author with no email out of the email tierstill matches the email local part when no row matched on email+ignores the email local part once any row matched on emailruns the email local part only when no other tier placed the userabsorbs a namesake sharing the display name — the deliberate cost of the name tierEm
#discussion_r3974456767, na thread da mesma regra, você escreveu: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 comohighnum 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're right — this was a false positive on my end.
The
matchUserAuthors identity tierssuite already had 12 tests since210c83d, with head-on coverage for every branch you listed, and I confirmed that in#discussion_r3974456767:Reopening rule
1d100001-c329-4fb1-8857-cd3c47052932ashighagainst the new commit ignored that prior confirmation — especially since7f640b6only 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.