Skip to content

fix: case-insensitive channel join in health score v2 pipes (IN-1307) - #4626

Open
gaspergrom wants to merge 16 commits into
mainfrom
fix/IN-1307-health-score-channel-case
Open

gaspergrom wants to merge 16 commits into
mainfrom
fix/IN-1307-health-score-channel-case

Conversation

@gaspergrom

@gaspergrom gaspergrom commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Summary

hermes-agent (and other repos where GitHub's live org-name casing differs from repositories.url's lowercase-normalized form — e.g. NousResearch/hermes-agent vs nousresearch/hermes-agent) was missing most Health Score v2 signals and showing zero rows for PRs/issues/contributors despite having real activity. Root cause was a family of case-sensitivity and duplicate-row bugs across the Tinybird channel-based join/filter chain, found and fixed in stages as each fix surfaced the next:

  • v2 signal-detail joins (health_score_v2_maintainer, _development, _lifecycle, _raw_inputs_snapshot, _signal_detail, health_score_v2.pipe): joins against repos_channels_ds.channel used exact-string equality, silently dropping every PR/issue/commit row under the mixed-case channel. Wrapped both sides of the join in lower().
  • Activity/PR filtering pipes (activities_filtered*, pull_requests_filtered, activityTypes_by_project, activity_heatmap_by_weekday_and_2hours_blocks): same case-sensitive channel filter, same fix.
  • repositories case-only duplicates: some repos have two repositories rows differing only by URL casing. repos_channels_copy.pipe and repos_to_channels.pipe enumerated both, fanning out duplicate channel rows into everything downstream; health_score_v2.pipe, health_score_v2_signal_detail.pipe, repo_health_score_copy.pipe, project_insights_health_breakdown_copy.pipe deduped their base repo enumeration to match.
  • Repo-scoped v1 health-score pipes (11 repo_health_score_*.pipe files) and the two *_average_resolve_velocity.pipe files had the identical case-sensitivity bug, unfixed until this pass — normalized filter/GROUP BY/output to lower(channel), and array-membership checks to lower(channel) IN (arrayMap(x -> lower(x), ...)).
  • Fixing the repo-scoped pipes surfaced a ClickHouse NOT_AN_AGGREGATE error: GROUP BY lower(channel) fails once SELECT lower(channel) AS channel already aliases the same value. Fixed by grouping on the alias (GROUP BY channel) instead of re-deriving it.

No schema change, no ingestion change — all changes are inside services/libs/tinybird/pipes/.

Also fixed on the same investigation, but not part of this diff: project_insights_copy.pipe's COPY_SCHEDULE cron had gone stale and hadn't re-run since the v2 join fix landed — recovered with a manual copy-job re-run rather than a code change.

Validation

Already deployed directly to production via crowd-tinybird-manager (staging deploy blocked for some files by a pre-existing, unrelated staging drift — lfx_insights_stg.issues_analyzed missing a channel column, and repos_to_channels_excluded never synced to staging). Validated against live prod Tinybird data:

  • hermes-agent recovered real scores across all four health-score categories (contributor, popularity, development, and overall) plus the previously-zero PR/issue/contributor counts.
  • Spot-checked kubernetes/kubernetes (single-casing source data) for no regression — scores unchanged.

There's no automated test framework for .pipe SQL files in this repo (confirmed — only fixtures exist), so this validation against live data is the extent of verification possible here.

JIRA

IN-1307 — Investigate missing health signals for Hermes project on LFX Insights

…re v2 joins (IN-1307)

Signed-off-by: Gašper Grom <gasper.grom@gmail.com>
…(IN-1307)

Signed-off-by: Gašper Grom <gasper.grom@gmail.com>
Copilot AI balanced review requested due to automatic review settings September 14, 2026 12:41
@cursor

cursor Bot commented Sep 14, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Wide changes to analytics SQL that drive health scores, insights rollups, and activity filtering; incorrect casing or dedup logic could shift metrics for many repos, though the intent is to fix under-counting rather than change business rules.

Overview
Fixes missing Health Score v2 signals and zero activity counts when GitHub channel casing does not match lowercase-normalized repositories.url (e.g. NousResearch/hermes-agent vs nousresearch/hermes-agent).

Case-insensitive matching: Activity, PR, issue, vulnerability, and health-score pipes now compare channels with lower() on both sides (filters, repos_to_channels / repos_to_channels_excluded, and joins to repos_channels_ds). Repo-scoped v1 repo_health_score_* pipes emit and group on lowercase channel; velocity pipes lower-case the repos parameter array. project_insights_copy aggregates period metrics by lower(channel) and joins health metrics with lower(base.repoUrl).

Canonical repo per URL (IN-1307): repos_channels_copy and repos_to_channels (and excluded-repo expansion) dedupe repositories with GROUP BY lower(url) and argMax(..., updatedAt) so case-only duplicates do not fan out channels. The same dedup pattern is applied in v2 category pipes, repo_health_score_copy, and project health breakdown rollups; aggregate v2 pipes (health_score_v2, health_score_v2_signal_detail) take their base repo set from repos_channels_ds instead of raw repositories.

No schema or ingestion changes—SQL-only updates under services/libs/tinybird/pipes/.

Reviewed by Cursor Bugbot for commit 228b843. Bugbot is set up for automated code reviews on this repo. Configure here.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Scopes Health Score v2 channel joins case-insensitively to recover GitHub signals affected by URL casing differences.

Changes:

  • Applies lower() to repository-channel joins across four health-score pipes.
  • Updates raw snapshots and maintainer, development, and lifecycle calculations.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
health_score_v2_raw_inputs_snapshot.pipe Normalizes raw-input channel joins.
health_score_v2_maintainer.pipe Normalizes maintainer-signal joins.
health_score_v2_lifecycle.pipe Normalizes lifecycle-signal joins.
health_score_v2_development.pipe Normalizes development-signal joins.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread services/libs/tinybird/pipes/health_score_v2_development.pipe
Comment thread services/libs/tinybird/pipes/health_score_v2_lifecycle.pipe
Comment thread services/libs/tinybird/pipes/health_score_v2_maintainer.pipe Outdated
…IN-1307)

Signed-off-by: Gašper Grom <gasper.grom@gmail.com>
Copilot AI review requested due to automatic review settings September 14, 2026 12:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 3 comments.

Suppressed comments (4)

services/libs/tinybird/pipes/health_score_v2_maintainer.pipe:203

  • repos_channels_ds also contains Gerrit /q/project: channels (repos_channels_copy.pipe:23-43), and Gerrit project names are case-sensitive. Lowercasing the complete URL can therefore make activity for one Gerrit project join a different repository whose path differs only by case, duplicating or misattributing every derived signal. Restrict case folding to GitHub/GitLab channels (ideally via a platform-aware normalized key) while preserving exact matching for Gerrit and other case-sensitive hosts.
                                    INNER JOIN repos_channels_ds ch ON lower(ar.channel) = lower(ch.channel)

services/libs/tinybird/pipes/health_score_v2_development.pipe:206

  • repos_channels_ds also contains Gerrit /q/project: channels (repos_channels_copy.pipe:23-43), and Gerrit project names are case-sensitive. Lowercasing the complete URL can therefore make activity for one Gerrit project join a different repository whose path differs only by case, duplicating or misattributing every derived signal. Restrict case folding to GitHub/GitLab channels (ideally via a platform-aware normalized key) while preserving exact matching for Gerrit and other case-sensitive hosts.
                            INNER JOIN repos_channels_ds ch ON lower(ar.channel) = lower(ch.channel)

services/libs/tinybird/pipes/health_score_v2_lifecycle.pipe:130

  • repos_channels_ds also contains Gerrit /q/project: channels (repos_channels_copy.pipe:23-43), and Gerrit project names are case-sensitive. Lowercasing the complete URL can therefore make activity for one Gerrit project join a different repository whose path differs only by case, duplicating or misattributing every derived signal. Restrict case folding to GitHub/GitLab channels (ideally via a platform-aware normalized key) while preserving exact matching for Gerrit and other case-sensitive hosts.
            INNER JOIN repos_channels_ds ch ON lower(ar.channel) = lower(ch.channel)

services/libs/tinybird/pipes/health_score_v2_raw_inputs_snapshot.pipe:88

  • repos_channels_ds also contains Gerrit /q/project: channels (repos_channels_copy.pipe:23-43), and Gerrit project names are case-sensitive. Lowercasing the complete URL can therefore make activity for one Gerrit project join a different repository whose path differs only by case, duplicating or misattributing every snapshotted signal. Restrict case folding to GitHub/GitLab channels (ideally via a platform-aware normalized key) while preserving exact matching for Gerrit and other case-sensitive hosts.
                    INNER JOIN repos_channels_ds ch ON lower(ar.channel) = lower(ch.channel)

Comment thread services/libs/tinybird/pipes/issues_average_resolve_velocity.pipe
Comment thread services/libs/tinybird/pipes/repo_health_score_stars.pipe
…1307)

Signed-off-by: Gašper Grom <gasper.grom@gmail.com>
Copilot AI review requested due to automatic review settings September 14, 2026 12:53

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread services/libs/tinybird/pipes/repos_channels_copy.pipe Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 36 out of 36 changed files in this pull request and generated 7 comments.

Suppressed comments (3)

services/libs/tinybird/pipes/repos_to_channels.pipe:28

  • Grouping every repository by lower(url) can collapse distinct repositories on providers whose path casing is significant; this pipe explicitly supports Gerrit. The repository's existing normalization guard is deliberately GitHub-only for this reason (backend/src/osspckgs/migrations/V1784718694__repos_github_lower_url_guard.sql:8-12). Restrict case-folding to integrations known to have case-insensitive repository paths rather than applying it globally.
    services/libs/tinybird/pipes/repos_channels_copy.pipe:24
  • This case-insensitive grouping applies to all integrations and can merge two legitimate case-sensitive Gerrit/other-host repository paths into one channel mapping. Existing repository normalization is intentionally GitHub-only because casing may be significant elsewhere (backend/src/osspckgs/migrations/V1784718694__repos_github_lower_url_guard.sql:8-12). Use a platform-aware canonical key and preserve the original path for other hosts.
    services/libs/tinybird/pipes/repos_to_channels.pipe:28
  • The soft-delete filter runs before choosing the canonical case-insensitive row. If a repository URL changed casing and the newest variant was later deleted, that row is removed first and an older live-looking variant becomes canonical, so deleted repositories remain selectable. Aggregate the full row across all variants first, then filter on the selected row's deletedAt.

Comment thread services/libs/tinybird/pipes/health_score_v2_development.pipe
Comment thread services/libs/tinybird/pipes/health_score_v2_lifecycle.pipe
Comment thread services/libs/tinybird/pipes/health_score_v2_maintainer.pipe Outdated
Comment thread services/libs/tinybird/pipes/repos_channels_copy.pipe Outdated
Comment thread services/libs/tinybird/pipes/repos_to_channels.pipe Outdated
Comment thread services/libs/tinybird/pipes/activities_filtered.pipe Outdated
Signed-off-by: Gašper Grom <gasper.grom@gmail.com>
Copilot AI review requested due to automatic review settings September 14, 2026 13:56
Signed-off-by: Gašper Grom <gasper.grom@gmail.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread services/libs/tinybird/pipes/health_score_v2.pipe
Comment thread services/libs/tinybird/pipes/repo_health_score_copy.pipe Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 40 out of 40 changed files in this pull request and generated 4 comments.

Suppressed comments (1)

services/libs/tinybird/pipes/health_score_v2_raw_inputs_snapshot.pipe:88

  • The activity subqueries now aggregate under the canonical ch.repoUrl, but allRepos at line 76 still emits every case-only duplicate from repositories. Consequently, the monthly append writes one populated snapshot row plus an all-NULL activity row for the same logical repository, and both survive because the datasource key is case-sensitive. Source allRepos and excluded from a deduplicated canonical repository set as the other updated v2 pipes do.
                    INNER JOIN repos_channels_ds ch ON lower(ar.channel) = lower(ch.channel)

Comment thread services/libs/tinybird/pipes/project_insights_health_breakdown_copy.pipe Outdated
Comment thread services/libs/tinybird/pipes/repos_channels_copy.pipe Outdated
Comment thread services/libs/tinybird/pipes/repos_to_channels.pipe Outdated
Comment thread services/libs/tinybird/pipes/repo_health_score_copy.pipe Outdated
Copilot AI review requested due to automatic review settings September 14, 2026 14:02
argMax(url, updatedAt) AS url combined with GROUP BY lower(url) let
ClickHouse substitute the SELECT alias into the GROUP BY expression,
producing an illegal nested aggregation (ILLEGAL_AGGREGATION) that
tb check didn't catch but a real push to production did. Renamed the
alias to canonicalUrl, matching the existing safe pattern in
repo_health_score_copy.pipe.

Signed-off-by: Gašper Grom <gasper.grom@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 40 out of 40 changed files in this pull request and generated 1 comment.

Suppressed comments (5)

services/libs/tinybird/pipes/repos_channels_copy.pipe:27

  • This groups URLs case-insensitively for every provider, although the shared normalizer only lowercases paths for GitHub/GitLab (services/libs/data-access-layer/src/integrations/index.ts:20-23) and this pipe explicitly handles Gerrit. Distinct repositories on a case-sensitive host can therefore collapse into one mapping and have one repository's activities attributed to the other. Use a provider-aware canonical key and preserve exact path casing elsewhere.
    services/libs/tinybird/pipes/health_score_v2_maintainer.pipe:204
  • This comparison case-folds every integration, not just GitHub. For case-sensitive Gerrit or custom-host paths, two distinct channels differing only by case will match the same mapping and corrupt both repositories' aggregates. Restrict case-insensitive matching to providers whose paths are normalized as case-insensitive, and retain exact matching for the others.
                                    INNER JOIN
                                        repos_channels_ds ch ON lower(ar.channel) = lower(ch.channel)

services/libs/tinybird/pipes/health_score_v2_raw_inputs_snapshot.pipe:88

  • These aggregates now emit only the canonical ch.repoUrl, but allRepos at line 76 still enumerates raw case variants. A duplicate pair therefore produces one populated snapshot row and one all-NULL row, violating the datasource's logical one-row-per-repository snapshot invariant. Source the outer base from the already deduplicated channel mapping too.
                    INNER JOIN repos_channels_ds ch ON lower(ar.channel) = lower(ch.channel)

services/libs/tinybird/pipes/repo_health_score_stars.pipe:20

  • The PR description says the change is limited to four Health Score v2 COPY pipes, but this modifies 40 pipes, including legacy health-score and general activity-filtering endpoints. That materially broadens both behavior and regression risk beyond the documented validation. Update the description and validation scope, or split/remove the unrelated endpoint changes.
    services/libs/tinybird/pipes/repo_health_score_copy.pipe:12
  • The canonical base channel is still joined with exact USING (channel) comparisons below, while each repo_health_score_* node groups and emits the original activity channel casing. For the reported lowercase repository/mixed-case activity scenario, this deduplication can remove the mixed-case base row and make all legacy repo metrics miss. Normalize the downstream join keys or map each metric through the canonical channel before deduplicating the base.

Comment thread services/libs/tinybird/pipes/health_score_active_contributors.pipe
…d velocity pipes (IN-1307)

Signed-off-by: Gašper Grom <gasper.grom@gmail.com>
Copilot AI review requested due to automatic review settings September 14, 2026 16:04

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 40 out of 40 changed files in this pull request and generated 2 comments.

Suppressed comments (6)

Previously missed (1) — in code that hasn't changed since the last review.

services/libs/tinybird/pipes/activities_filtered.pipe:55

  • The PR description states that only four Health Score v2 COPY pipes are changed, but this modifies the generic activity filtering API and the diff also changes legacy health-score and repository-selection pipes. Either narrow the patch to the stated scope or update the description and validation evidence to cover these user-facing query changes.

This issue also appears on line 54 of the same file.

services/libs/tinybird/pipes/repos_to_channels.pipe:31

  • This generic repository expansion now collapses URLs case-insensitively for every platform. Because Gerrit project paths can be case-sensitive, /Foo and /foo may represent different repositories but only the most recently updated one will remain selectable. Use a platform-aware canonical key rather than unconditional lower(url).
    services/libs/tinybird/pipes/health_score_v2_raw_inputs_snapshot.pipe:88
  • This join maps all case variants to the single canonical ch.repoUrl, but the outer allRepos base still contains every case-distinct repository row and joins these aggregates by exact repoUrl. The monthly snapshot will therefore persist one populated canonical row plus empty/partial rows for the duplicate casings, and its case-sensitive sorting key will retain all of them. Deduplicate the base with the same canonical mapping before appending the snapshot.
                    INNER JOIN repos_channels_ds ch ON lower(ar.channel) = lower(ch.channel)

services/libs/tinybird/pipes/activities_filtered.pipe:54

  • This makes repository selection case-insensitive for every integration, not just the GitHub case mismatch addressed by IN-1307. On case-sensitive hosts such as Gerrit, selecting one repository can now also return activities for a distinct repository whose path differs only by case. Preserve exact matching for case-sensitive platforms or compare against a platform-aware canonical channel key.
            AND lower(a.channel) IN (SELECT lower(channel) FROM repos_to_channels)

services/libs/tinybird/pipes/project_insights_health_breakdown_copy.pipe:157

  • This repeats the unconditional case-insensitive deduplication for project rollups. It can merge distinct repositories on case-sensitive hosts and move the surviving repository to whichever project's row has the latest updatedAt, dropping the other project association. Use the same platform-aware canonical key as the repository-channel mapping.
            GROUP BY lower(url)

services/libs/tinybird/pipes/repo_health_score_copy.pipe:18

  • This copy independently applies the same global lower(url) deduplication even though repository path case semantics vary by platform. A case-sensitive repository can be removed from the V1 health-score population, and the latest duplicate's project association wins. Reuse a platform-aware canonical repository set instead of grouping every URL by lowercase.

Comment thread services/libs/tinybird/pipes/repo_health_score_copy.pipe Outdated
Comment thread services/libs/tinybird/pipes/repos_channels_copy.pipe

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 44 out of 44 changed files in this pull request and generated 2 comments.

Suppressed comments (4)

services/libs/tinybird/pipes/repos_channels_copy.pipe:27

  • This deduplication case-folds URLs for every provider, but repository identity is only canonicalized case-insensitively for GitHub; non-GitHub lookups remain exact (services/libs/data-access-layer/src/repositories/index.ts:205-229,448-477). Because this pipe explicitly supports Gerrit, two valid case-sensitive repository paths can collapse into one row and the new lower(channel) joins will attribute both repositories' activity to whichever row wins. Use a provider-aware canonical key (case-fold GitHub only) consistently across this deduplication and the downstream comparisons.
    services/libs/tinybird/pipes/repo_health_score_copy.pipe:8
  • The repo metric nodes modified in this PR now emit lower(channel), but this new base still emits canonicalUrl with its original casing and lines 57-67 join them with exact USING (channel). If the most recently updated repository row has mixed-case casing, every activity metric misses and is coalesced to zero—the same symptom this PR is fixing. Preserve the canonical output URL, but join the metric nodes through a consistently normalized key.
    services/libs/tinybird/pipes/project_insights_health_breakdown_copy.pipe:157
  • This independent lower(url) grouping can also collapse distinct non-GitHub repositories whose paths differ only by case, assigning the retained row's score breakdown to one project and dropping the other. Keep this rollup aligned with a provider-aware repository identity rather than globally lowercasing URL paths.
            FROM repositories FINAL
            WHERE deletedAt IS NULL
            GROUP BY lower(url)

services/libs/tinybird/pipes/health_score_v2_raw_inputs_snapshot.pipe:88

  • The activity join now emits only the canonical ch.repoUrl, but allRepos still enumerates case-sensitive raw repository URLs. A case-only duplicate therefore produces one populated snapshot row plus a second mostly empty row, contradicting this append-only snapshot's one-row-per-repo contract and skewing audit analyses. Source the base from the already deduplicated channel mapping.
                    INNER JOIN repos_channels_ds ch ON lower(ar.channel) = lower(ch.channel)

Comment thread services/libs/tinybird/pipes/repo_health_score_copy.pipe
Comment thread services/libs/tinybird/pipes/repos_to_channels.pipe
Health score v2 category pipes (development/lifecycle/maintainer/raw_inputs_snapshot)
and repo_health_score_copy/repos_channels_copy/repos_to_channels(_excluded) built their
base repository lists from raw, undeduped repositories rows, letting a case-only-duplicate
row leak through with missing signals or a stale excluded/archived/deletedAt flag win over
the canonical row. Dedupe via argMax(..., updatedAt) GROUP BY lower(url), filtering
deletedAt/archived/excluded AFTER aggregation instead of before, and lowercase
repo_health_score_copy_repos.channel to match the lower(channel) every sibling metric pipe
emits (project_insights_copy's join to it lowers base.repoUrl to match).

Signed-off-by: Gašper Grom <gasper.grom@gmail.com>
Copilot AI review requested due to automatic review settings September 15, 2026 08:15
Signed-off-by: Gašper Grom <gasper.grom@gmail.com>
@themarolt
themarolt self-requested a review September 15, 2026 08:20
themarolt
themarolt previously approved these changes Sep 15, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread services/libs/tinybird/pipes/health_score_v2_development.pipe

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 46 out of 46 changed files in this pull request and generated 10 comments.

Suppressed comments (3)

Previously missed (2) — in code that hasn't changed since the last review.

services/libs/tinybird/pipes/project_insights_health_breakdown_copy.pipe:158

  • Filtering deleted rows before grouping resurrects an older active case variant when the newest canonical row is soft-deleted. This differs from the canonicalization rule used by the other updated pipes and can keep a deleted repository in the project rollup; aggregate deletedAt first, then filter outside.
    services/libs/tinybird/pipes/repos_to_channels.pipe:65
  • The deduplicated URL can differ from a caller-supplied repos value only by case, but gerrit_repos still checks them with exact equality at line 73. In that case the /q/project: Gerrit channel is never generated, so Gerrit activity remains missing despite the case-insensitive downstream filters.

services/libs/tinybird/pipes/health_score_v2_raw_inputs_snapshot.pipe:100

  • This second dedup has the same nullable-argMax problem: active rows (deletedAt = NULL) are skipped when any older deleted case variant exists, so rf can vanish while allRepos should remain active. Preserve the newest row's nullable value via a tuple.
                    GROUP BY lower(url)

Comment thread services/libs/tinybird/pipes/health_score_v2_development.pipe Outdated
Comment thread services/libs/tinybird/pipes/health_score_v2_lifecycle.pipe Outdated
Comment thread services/libs/tinybird/pipes/health_score_v2_maintainer.pipe Outdated
Comment thread services/libs/tinybird/pipes/health_score_v2_raw_inputs_snapshot.pipe Outdated
Comment thread services/libs/tinybird/pipes/project_insights_copy.pipe
Comment thread services/libs/tinybird/pipes/repo_health_score_copy.pipe
Comment thread services/libs/tinybird/pipes/repo_health_score_copy.pipe Outdated
Comment thread services/libs/tinybird/pipes/repos_channels_copy.pipe Outdated
Comment thread services/libs/tinybird/pipes/repos_to_channels.pipe Outdated
Comment thread services/libs/tinybird/pipes/repos_to_channels_excluded.pipe Outdated
Copilot AI review requested due to automatic review settings September 15, 2026 08:23

@epipav epipav left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggesting fixing the source data instead of the queries

I measured the scope in Tinybird first:

Repos in the health-score population 31,590
Match only case-insensitively (the bug) 18
Case-only duplicate rows in repositories 5

Adding lower() across 44 pipes to repair 18 repos is expensive:

  • Per-row lower() on every request and nightly job over ~750M activity rows and 511M contribution rows.
  • channel is a sorting-key column on pull_requests_analyzed and issues_analyzed. lower(channel) defeats index pruning there.
  • Blocking bug: repo_health_score_* pipes now output lower(channel), but repo_health_score_copy.pipe joins them USING (channel) against the original-cased argMax(url). 5,810 repos have mixed-case URLs and would get all-NULL scores on the first nightly run.

Proposal

Use Tinybird only to find the discrepancies, then fix them in Postgres, the source: normalize the affected repositories.url rows to the casing the activities carry, and merge the 5 case-only duplicates. Once the data is consistent, the existing exact-match joins are correct with no query changes.

Discrepancy query:

SELECT repoUrl, arrayDistinct(groupArray(p.channel)) AS activityChannels
FROM
    (
        SELECT
            argMax(url, updatedAt) AS repoUrl,
            argMax(enabled, updatedAt) AS en,
            argMax(excluded, updatedAt) AS ex,
            argMax(insightsProjectId, updatedAt) AS ip
        FROM repositories FINAL
        WHERE isNull(deletedAt)
        GROUP BY lower(url)
    ) r
INNER JOIN (SELECT DISTINCT channel FROM pull_requests_analyzed) p
    ON lower(p.channel) = lower(r.repoUrl)
WHERE en = true AND ex = false AND ip != ''
    AND repoUrl NOT IN (SELECT DISTINCT channel FROM pull_requests_analyzed)
GROUP BY repoUrl

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 46 out of 46 changed files in this pull request and generated 2 comments.

Suppressed comments (10)

Previously missed (1) — in code that hasn't changed since the last review.

services/libs/tinybird/pipes/project_insights_health_breakdown_copy.pipe:158

  • Filtering deletedAt IS NULL before grouping resurrects an older active casing when the newest duplicate is soft-deleted, so the deleted repository still contributes to the project rollup. Select the complete latest row first, including deletedAt, then filter the aggregated result.

services/libs/tinybird/pipes/repos_channels_copy.pipe:27

  • deletedAt is nullable, and ClickHouse argMax skips rows where its argument is NULL. Therefore this does not necessarily read deletion state from the row that supplied canonicalUrl: if an older case variant is deleted and a newer variant is active, the stale deletion timestamp wins and the active repository is omitted. Select the complete latest row as a tuple so nullable state and the other fields remain aligned.
    services/libs/tinybird/pipes/repos_to_channels.pipe:31
  • Because deletedAt is nullable, ClickHouse argMax(deletedAt, updatedAt) ignores active rows whose value is NULL. A newer active case variant can consequently be discarded in favor of an older deleted variant, contrary to this node's latest-row contract. Use one tuple-valued argMax and unpack every field from that same row.
    services/libs/tinybird/pipes/repos_to_channels_excluded.pipe:24
  • argMax skips nullable arguments, so an active latest row (deletedAt = NULL) is ignored whenever the group also contains an older soft-deleted row. That makes the exclusion list omit the active canonical repository. Select the complete latest row as a tuple before testing its deletion and exclusion state.
    services/libs/tinybird/pipes/repo_health_score_copy.pipe:19
  • ClickHouse ignores rows with a NULL argument in argMax, so this can take deletedAt from an older deleted duplicate while the other values come from a newer active row. The active repository is then removed from the v1 health-score COPY. Use a tuple-valued argMax to preserve the complete latest row, including a nullable deletion value.
    services/libs/tinybird/pipes/health_score_v2_maintainer.pipe:199
  • argMax(deletedAt, updatedAt) skips active rows because their nullable argument is NULL. Thus an older deleted case variant can cause a newer active repository to be filtered from maintainer scoring, while the other independently aggregated fields come from another row. Select all fields in one tuple-valued argMax.
                                        argMax(deletedAt, updatedAt) AS deletedAt

services/libs/tinybird/pipes/health_score_v2_lifecycle.pipe:120

  • The latest active row has deletedAt = NULL, which ClickHouse skips in this argMax; an older soft-deleted case variant can therefore make lifecycle scoring omit the active repository. Select the whole latest row as a tuple and filter on the tuple's nullable deletion field.
                                argMax(deletedAt, updatedAt) AS deletedAt

services/libs/tinybird/pipes/health_score_v2_development.pipe:165

  • This nullable argMax can return an older deletion timestamp because rows with deletedAt = NULL are skipped. A newer active case variant is then wrongly excluded from development scoring. Use one tuple-valued argMax so all state comes from the actual latest row.
                                                argMax(deletedAt, updatedAt) AS deletedAt

services/libs/tinybird/pipes/project_insights_copy.pipe:402

  • Only the health-score join is normalized here. project_insights_copy_repo_period_metrics still groups by the original activity channel, and the next join remains exact, so the described lowercase repository/mixed-case activity case still yields zero 365-day stars, forks, contributors, and organizations in this snapshot. Aggregate rm.channel by the canonical lowercase key and join on that key as well.
    LEFT JOIN repo_health_score_copy_ds AS hs ON lower(base.repoUrl) = hs.channel

services/libs/tinybird/pipes/repo_health_score_copy.pipe:11

  • This lowercases the base join key, but repo_health_score_security.pipe:43-45 still emits and groups the original repo casing, and line 71 joins it with exact USING (channel). Existing mixed-case security evaluations therefore stop matching and securityPercentage becomes NULL. Normalize the security pipe's output/filter to the same key, or make that join case-insensitive without creating duplicate matches.

Copilot AI review requested due to automatic review settings September 15, 2026 09:36

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread services/libs/tinybird/pipes/repo_health_score_copy.pipe
Comment thread services/libs/tinybird/pipes/project_insights_copy.pipe
Comment thread services/libs/tinybird/pipes/project_insights_health_breakdown_copy.pipe Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 46 out of 46 changed files in this pull request and generated 1 comment.

Suppressed comments (10)

services/libs/tinybird/pipes/repos_channels_copy.pipe:27

  • argMax skips rows where its argument is NULL. Because active rows have deletedAt = NULL, an older deleted casing can supply deletedAt while the newer active casing supplies the other fields, causing this filter to drop the active repository. Preserve the NULL inside a tuple so all fields reflect the row with the maximum updatedAt.
    services/libs/tinybird/pipes/repos_to_channels.pipe:31
  • argMax skips rows where its argument is NULL. Because active rows have deletedAt = NULL, an older deleted casing can supply deletedAt while the newer active casing supplies the other fields, causing this filter to drop the active repository. Preserve the NULL inside a tuple so all fields reflect the row with the maximum updatedAt.
    services/libs/tinybird/pipes/repos_to_channels_excluded.pipe:24
  • argMax skips rows where its argument is NULL. If a stale casing is soft-deleted and the newer canonical row is active, this aggregate takes the stale non-NULL deletion timestamp and excludes the active repository. Wrap the nullable value in a tuple so the latest row's NULL is retained.
    services/libs/tinybird/pipes/health_score_v2_raw_inputs_snapshot.pipe:84
  • argMax(deletedAt, updatedAt) ignores active rows because their argument is NULL. A group with an older deleted casing and a newer active casing is therefore removed from allRepos, contrary to the intended latest-row selection. Preserve the NULL via a tuple.
                    SELECT argMax(url, updatedAt) AS repoUrl, argMax(deletedAt, updatedAt) AS deletedAt

services/libs/tinybird/pipes/health_score_v2_raw_inputs_snapshot.pipe:98

  • This nullable argMax skips active rows, so an older soft-deleted casing can make rf appear deleted even when the newest canonical row is active. Preserve the winning row's NULL by aggregating a tuple.
                        argMax(deletedAt, updatedAt) AS deletedAt

services/libs/tinybird/pipes/health_score_v2_development.pipe:165

  • argMax skips NULL arguments, so this does not actually select the latest row's deletion state. When an older casing is deleted and a newer casing is active, the stale non-NULL deletedAt wins and the active repository is removed from development scoring. Preserve NULL inside a tuple.
                                                argMax(deletedAt, updatedAt) AS deletedAt

services/libs/tinybird/pipes/health_score_v2_lifecycle.pipe:120

  • argMax skips NULL arguments, so an active latest row cannot win this aggregate when an older case-duplicate has a non-NULL deletedAt. The subsequent filter then removes the active repository from lifecycle scoring. Preserve the latest row's NULL via a tuple.
                                argMax(deletedAt, updatedAt) AS deletedAt

services/libs/tinybird/pipes/health_score_v2_maintainer.pipe:199

  • Because argMax skips a NULL argument, this can combine the newest active row's URL/state with an older deleted casing's non-NULL deletedAt, after which the repository is filtered out. Aggregate a tuple to retain NULL from the row with maximum updatedAt.
                                        argMax(deletedAt, updatedAt) AS deletedAt

services/libs/tinybird/pipes/repo_health_score_copy.pipe:19

  • argMax(deletedAt, updatedAt) skips active rows because deletedAt is NULL. If an older case-duplicate is deleted and the newest canonical row is active, this returns the stale deletion timestamp and excludes the repository from the v1 health-score COPY. Preserve NULL inside a tuple.
    services/libs/tinybird/pipes/repo_health_score_copy.pipe:11
  • The lowercased base key does not match every sibling metric: repo_health_score_security.pipe:43-45 still emits and groups by the original repo casing. Mixed-case security evaluations therefore no longer join, making securityPercentage NULL and changing the overall-score divisor from four categories to three. Normalize the security pipe's output and single-repo filter to the same key.

…g joins (IN-1307)

Signed-off-by: Gašper Grom <gasper.grom@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 47 out of 47 changed files in this pull request and generated 3 comments.

Suppressed comments (1)

services/libs/tinybird/pipes/project_insights_health_breakdown_copy.pipe:156

  • Filtering deletedAt before argMax resurrects an older case variant when the newest canonical row is soft-deleted, so that stale repository still contributes to the project rollup. Select the complete latest tuple first and apply the deletion filter afterward, as the other deduplication nodes in this PR do.
            WHERE deletedAt IS NULL

Comment thread services/libs/tinybird/pipes/health_score_v2.pipe
Comment thread services/libs/tinybird/pipes/health_score_v2_signal_detail.pipe
Comment thread services/libs/tinybird/pipes/project_insights_copy.pipe
@gaspergrom

Copy link
Copy Markdown
Contributor Author

Re: @epipav's review — thanks for measuring the actual scope, that's useful data.

On the blocking bug: I checked this against the current code and it isn't there. repo_health_score_copy_repos already emits lower(canonicalUrl) AS channel (added in e6cc519, 08:15 UTC — ahead of this review), and every sibling repo_health_score_* pipe it joins USING (channel) emits lower(channel) AS channel too, so both sides of those 11 joins are consistently lowercased. This cycle's commit (88501c8) additionally fixed a separate bug in the same dedup subquery — argMax(url, updatedAt) was silently dropping rows with a NULL deletedAt, which could pick a stale non-canonical URL — but the lowering itself was already correct going into this review. So the 5,810-repo all-NULL scenario shouldn't occur on the next nightly run; happy to dig into a specific repo if you're still seeing it.

On the broader proposal — fixing the 5 duplicate/mismatched repositories rows in Postgres is worth doing regardless, but it doesn't replace the query-side fix here: new mixed-case URLs can land again the next time a repo is added or an integration reports a differently-cased URL, and the join would silently drop that repo's scores until someone notices and cleans it up again. The lower() pattern here matches what the rest of this codebase already does for the same class of bug (e.g. repos_channels_copy.pipe, repos_to_channels.pipe), so it's consistent rather than a one-off. The sorting-key/index-pruning cost on pull_requests_analyzed/issues_analyzed is a fair point — that's scoped to the specific joins that need it in this PR, not applied blanket across all 44 pipes. If it shows up as a measurable regression I'm glad to look at it separately, but I'd rather not hold this correctness fix on a data-normalization project that's a good idea on its own merits, independent of this PR.

…N-1307)

Same nullable-argMax tuple pattern applied to project_insights_health_breakdown_copy's
repo dedup; health_score_v2_security's repoUrl is now case-deduped to match the
canonical base used by health_score_v2/signal_detail; project_insights_copy's
period-metrics activity aggregation now groups by lower(channel) to avoid
fanning out duplicate rows when both casing variants of a channel exist.

Signed-off-by: Gašper Grom <gasper.grom@gmail.com>
Copilot AI review requested due to automatic review settings September 15, 2026 09:53

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 228b843. Configure here.

FROM repositories FINAL
GROUP BY lower(url)
)
WHERE isNull (deletedAt) AND archived = false AND excluded = false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security joins miss mixed-case URLs

High Severity

allRepos now emits the mixed-case canonical repositories.url, but the LEFT JOINs onto repos (scorecard, security practices, packages) still use exact url equality. repos.url is always lowercase-canonicalized for GitHub/GitLab, so those joins miss for mixed-case repos like hermes-agent and the security category stays empty. Sibling development/lifecycle pipes already join repos on lower(url).

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 228b843. Configure here.

FROM activityRelations_deduplicated_cleaned_bucket_union
WHERE timestamp >= now() - INTERVAL 730 DAY AND timestamp <= now()
GROUP BY channel
GROUP BY lower(channel)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aliased GROUP BY will fail at runtime

High Severity

project_insights_copy_repo_period_metrics selects lower(channel) AS channel and then GROUP BY lower(channel). ClickHouse treats channel in the GROUP BY expression as that alias, so the grouping key no longer matches the select list and the COPY pipe fails with NOT_AN_AGGREGATE. The repo-scoped health-score pipes in this PR already group on the alias (GROUP BY channel) for that reason.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 228b843. Configure here.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 48 out of 48 changed files in this pull request and generated 2 comments.

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

services/libs/tinybird/pipes/health_score_v2.pipe:97

  • This base now retains one canonical casing, but health_score_v2_impact.pipe still enumerates exact-case repository rows and joins repos with r.url = base.url. If package data is attached to the discarded casing, the impact producer emits that casing only and the exact downstream join drops impactScoreRaw. Canonicalize and aggregate the impact producer with the same repository key before reducing this base to one row.
    services/libs/tinybird/pipes/repo_health_score_security.pipe:43
  • The single-repository filter earlier in this pipe still compares repo and repoUrl case-sensitively. Lowercasing only the final output means a mixed-case security row is filtered out before this aggregation, so direct repoUrl calls still return no score. Normalize the predicate as well.

FROM activityRelations_deduplicated_cleaned_bucket_union
WHERE timestamp >= now() - INTERVAL 730 DAY AND timestamp <= now()
GROUP BY channel
GROUP BY lower(channel)
latest .3 AS excluded,
latest .4 AS deletedAt
FROM repositories FINAL
GROUP BY lower(url)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants