mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
The rename runs on the way in and nothing checked it afterwards, so paths and content drifted apart in three places, and each one broke something quietly. The script language had three names at once. Files on disk carried `.iql`/`.iqle`, the source matchers said `.script`, and the bytecode matchers said `.hoge`, so the test harness globbed zero files and the STL compiler regenerated an empty table. It is `script` now, in one place: `.script` source, `.scriptc` bytecode, `bin/script` to run one and `bin/scriptc` to compile one. 190 TypeScript specifiers named files that had been renamed away, which is the whole CDP consumer graph -- the event worker, not the web boot. `--check` only ever read Python imports; it reads both module systems now, and repoints both. `-hog-` became `-mascot-` in paths while content said `script`, so the code review product's skill directories stopped matching the prefix that discovers them and it ran with no perspectives at all. PATH_REGEX is CONTENT_REGEX -- the same list, not a copy -- so a rule can no longer land in one and miss the other, and the four disagreeing `hog` path rules collapse into the one word rule content already had. `HOGLI_*` becomes `INSIGHTSCLI_*` at every reader and writer, with no alias: an env var read under two names is two names. The seven casings of `hogql` and five of `hogvm` collapse into one rule per prefix casing, which leaves the suffix alone because `QL` is SQL's and `VM` is a virtual machine's. `--check` now refuses every retired spelling, in content and in paths, and draws the line the rules draw: `insights_hogfunction` is a Postgres table so it is left alone, and a migration filename is its identity in `django_migrations` so it is frozen rather than failed. Third-party names we cannot rename are reported as pending, never exempted.
562 lines
22 KiB
Python
562 lines
22 KiB
Python
"""Tests for deny-list pattern matching in gates.py."""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from gates import (
|
|
DEPENDENCY_ECOSYSTEMS,
|
|
DISMISS_TIME_LOCKFILES,
|
|
build_ownership,
|
|
dependency_manifests_without_lockfile,
|
|
detect_deny_categories,
|
|
detect_ownership,
|
|
detect_title_scrutiny_flags,
|
|
has_dependency_changes,
|
|
is_size_exempt,
|
|
substantive_size,
|
|
)
|
|
from policy import OwnershipSource
|
|
|
|
# ── False positives that should NOT trigger ──────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"files",
|
|
[
|
|
pytest.param(
|
|
[
|
|
"frontend/src/queries/nodes/InsightViz/EditorFilters/SessionAnalysisWarning.tsx",
|
|
"frontend/src/queries/nodes/InsightViz/EditorFilters/SuggestionBanner.tsx",
|
|
"frontend/src/queries/nodes/InsightViz/EditorFilters/EditorFilterItems.tsx",
|
|
],
|
|
id="session-analysis-warning-component",
|
|
),
|
|
pytest.param(
|
|
["frontend/src/lib/components/TaxonomicFilter/recentTaxonomicFiltersLogic.ts"],
|
|
id="localstorage-recents-logic",
|
|
),
|
|
pytest.param(
|
|
["frontend/src/lib/utils/tokenizer.ts"],
|
|
id="tokenizer-not-auth-token",
|
|
),
|
|
pytest.param(
|
|
["frontend/src/scenes/session-recordings/SessionRecordingPlayer.tsx"],
|
|
id="session-recording-not-auth",
|
|
),
|
|
pytest.param(
|
|
["frontend/src/lib/components/KeyboardShortcut.tsx"],
|
|
id="keyboard-not-crypto-key",
|
|
),
|
|
pytest.param(
|
|
["frontend/src/lib/hooks/useRoutingLogic.ts"],
|
|
id="routing-logic-not-infra",
|
|
),
|
|
pytest.param(
|
|
["products/signals/backend/temporal/backfill_error_tracking.py"],
|
|
id="temporal-backfill-workflow-not-migration",
|
|
),
|
|
pytest.param(
|
|
["insights/management/commands/backfill_distinct_id_overrides.py"],
|
|
id="backfill-management-command-not-migration",
|
|
),
|
|
pytest.param(
|
|
["insights/dags/backfill_materialized_column.py"],
|
|
id="dagster-backfill-dag-not-migration",
|
|
),
|
|
pytest.param(
|
|
["insights/management/commands/migrate_team.py"],
|
|
id="migrate-operator-command-not-migration",
|
|
),
|
|
pytest.param(
|
|
["ee/api/subscription.py", "ee/api/test/test_subscription.py"],
|
|
id="insight-subscriptions-not-billing",
|
|
),
|
|
pytest.param(
|
|
["insights/api/routing.py"],
|
|
id="drf-routers-not-infra",
|
|
),
|
|
pytest.param(
|
|
["docs/internal/checking-deploy-timing.md"],
|
|
id="deploy-timing-docs-not-infra",
|
|
),
|
|
pytest.param(
|
|
["products/conversations/backend/api/tests/test_slack_message_routing.py"],
|
|
id="message-routing-test-not-infra",
|
|
),
|
|
pytest.param(
|
|
["products/web_analytics/backend/temporal/health_checks/authorized_urls.py"],
|
|
id="authorized-urls-domain-config-not-auth",
|
|
),
|
|
pytest.param(
|
|
["dustbin/deploy.py"],
|
|
id="bin-deploy-needs-path-anchor",
|
|
),
|
|
pytest.param(
|
|
["products/warehouse_sources/backend/temporal/data_imports/sources/stripe/source.py"],
|
|
id="stripe-connector-not-billing",
|
|
),
|
|
pytest.param(
|
|
["frontend/package.json"],
|
|
id="manifest-without-lockfile-not-deps",
|
|
),
|
|
pytest.param(
|
|
["pyproject.toml"],
|
|
id="pyproject-without-lockfile-not-deps",
|
|
),
|
|
pytest.param(
|
|
["common/esbuilder/tsconfig.json"],
|
|
id="tsconfig-not-deps",
|
|
),
|
|
],
|
|
)
|
|
def test_no_false_positive(files: list[str]) -> None:
|
|
assert detect_deny_categories(files) == []
|
|
|
|
|
|
# ── True positives that SHOULD trigger ───────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"files, expected_category",
|
|
[
|
|
pytest.param(
|
|
["insights/api/authentication.py"],
|
|
"auth",
|
|
id="authentication-api-file",
|
|
),
|
|
pytest.param(
|
|
["frontend/src/scenes/authentication/passwordResetLogic.ts"],
|
|
"auth",
|
|
id="authentication-scene-tree",
|
|
),
|
|
pytest.param(
|
|
["insights/api/login.py"],
|
|
"auth",
|
|
id="login-endpoint",
|
|
),
|
|
pytest.param(
|
|
["insights/models/oauth_config.py"],
|
|
"auth",
|
|
id="oauth-config",
|
|
),
|
|
pytest.param(
|
|
["insights/api/auth/session_token.py"],
|
|
"auth",
|
|
id="auth-session-token-path",
|
|
),
|
|
pytest.param(
|
|
["insights/crypto/encrypt.py"],
|
|
"crypto_secrets",
|
|
id="encryption-file",
|
|
),
|
|
pytest.param(
|
|
["insights/settings/.env.example"],
|
|
"crypto_secrets",
|
|
id="dot-env-file",
|
|
),
|
|
pytest.param(
|
|
["insights/api/api_key.py"],
|
|
"crypto_secrets",
|
|
id="api-key-file",
|
|
),
|
|
pytest.param(
|
|
["insights/models/secret_key_store.py"],
|
|
"crypto_secrets",
|
|
id="secret-key-file",
|
|
),
|
|
pytest.param(
|
|
["insights/migrations/0400_add_column.py"],
|
|
"migrations",
|
|
id="migration-file",
|
|
),
|
|
pytest.param(
|
|
["rust/persons_migrations/20260206000001_add_last_seen_at.sql"],
|
|
"migrations",
|
|
id="rust-sqlx-migration",
|
|
),
|
|
pytest.param(
|
|
[".github/workflows/ci.yml"],
|
|
"infra_cicd",
|
|
id="github-workflow",
|
|
),
|
|
pytest.param(
|
|
["bin/deploy-hobby"],
|
|
"infra_cicd",
|
|
id="deploy-hobby-script",
|
|
),
|
|
pytest.param(
|
|
["livestream/deploy.sh"],
|
|
"infra_cicd",
|
|
id="deploy-sh-script",
|
|
),
|
|
pytest.param(
|
|
[".github/pr-deploy/values.yaml.tmpl"],
|
|
"infra_cicd",
|
|
id="pr-deploy-directory",
|
|
),
|
|
pytest.param(
|
|
["insights/models/two_factor_auth.py"],
|
|
"auth",
|
|
id="two-factor-auth-file",
|
|
),
|
|
pytest.param(
|
|
["insights/billing/stripe_webhook.py"],
|
|
"billing",
|
|
id="billing-file",
|
|
),
|
|
pytest.param(
|
|
["pnpm-lock.yaml"],
|
|
"deps_toolchain",
|
|
id="pnpm-lockfile",
|
|
),
|
|
pytest.param(
|
|
["rust/Cargo.lock"],
|
|
"deps_toolchain",
|
|
id="cargo-lockfile",
|
|
),
|
|
pytest.param(
|
|
["composer.lock"],
|
|
"deps_toolchain",
|
|
id="composer-lockfile",
|
|
),
|
|
pytest.param(
|
|
["requirements.txt"],
|
|
"deps_toolchain",
|
|
id="requirements-pins-directly",
|
|
),
|
|
pytest.param(
|
|
["common/ingestion/requirements-dev.txt"],
|
|
"deps_toolchain",
|
|
id="requirements-variant-pins-too",
|
|
),
|
|
pytest.param(
|
|
["frontend/package.json", "pnpm-lock.yaml"],
|
|
"deps_toolchain",
|
|
id="manifest-with-lockfile",
|
|
),
|
|
],
|
|
)
|
|
def test_true_positive(files: list[str], expected_category: str) -> None:
|
|
result = detect_deny_categories(files)
|
|
assert expected_category in result, f"Expected '{expected_category}' in {result}"
|
|
|
|
|
|
# ── Title scrutiny flags (titles never hard-deny) ────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"subject, expected_flags",
|
|
[
|
|
pytest.param("fix: oauth login redirect", ["auth"], id="auth-keywords"),
|
|
pytest.param("fix: stripe invoice pagination", ["billing"], id="billing-keywords"),
|
|
pytest.param("feat(subscriptions): raise hourly org cap", [], id="insight-subscription-not-billing"),
|
|
pytest.param("chore: migrate helm chart to terraform", ["infra_cicd"], id="infra-keywords"),
|
|
pytest.param("fix: authorized urls health check", ["auth"], id="title-only-past-participle"),
|
|
pytest.param("fix(insights): trend legend overlap", [], id="neutral-title"),
|
|
pytest.param("fix: authentication flow", ["auth"], id="authentication-long-form"),
|
|
pytest.param("feat: stripe oauth billing sync", ["auth", "billing"], id="two-category-title"),
|
|
],
|
|
)
|
|
def test_title_scrutiny_flags(subject: str, expected_flags: list[str]) -> None:
|
|
# Titles flag for LLM scrutiny but never deny — a title-only keyword
|
|
# must not put the PR in T2-never (56-83% of those merged unchanged).
|
|
assert detect_title_scrutiny_flags(subject) == expected_flags
|
|
|
|
|
|
# ── Deny-list bypass via ignored_files ───────────────────────────
|
|
|
|
|
|
def test_ignored_files_bypass_deny_list() -> None:
|
|
files = [
|
|
"insights/migrations/1117_alter_integration_kind.py",
|
|
"insights/migrations/max_migration.txt",
|
|
]
|
|
ignored = set(files)
|
|
|
|
assert detect_deny_categories(files, ignored_files=ignored) == []
|
|
|
|
|
|
def test_ignored_files_does_not_bypass_other_deny_list_files() -> None:
|
|
files = [
|
|
"insights/migrations/1117_alter_integration_kind.py",
|
|
"insights/migrations/1118_add_column.py",
|
|
]
|
|
ignored = {"insights/migrations/1117_alter_integration_kind.py"}
|
|
|
|
assert detect_deny_categories(files, ignored_files=ignored) == ["migrations"]
|
|
|
|
|
|
# ── Data warehouse connector exemption (auth + billing) ──────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"files, exempt_category",
|
|
[
|
|
pytest.param(
|
|
["products/warehouse_sources/backend/temporal/data_imports/sources/stripe/auth.py"],
|
|
"auth",
|
|
id="dwh-source-auth-file",
|
|
),
|
|
pytest.param(
|
|
[
|
|
"products/warehouse_sources/backend/temporal/data_imports/sources/salesforce/source.py",
|
|
"products/warehouse_sources/backend/temporal/data_imports/sources/salesforce/settings.py",
|
|
],
|
|
"auth",
|
|
id="dwh-source-multi-file",
|
|
),
|
|
pytest.param(
|
|
["products/warehouse_sources/backend/temporal/data_imports/sources/stripe/stripe_billing.py"],
|
|
"billing",
|
|
id="dwh-source-billing-file",
|
|
),
|
|
],
|
|
)
|
|
def test_dwh_source_exempt(files: list[str], exempt_category: str) -> None:
|
|
assert exempt_category not in detect_deny_categories(files)
|
|
|
|
|
|
def test_dwh_source_still_denies_non_exempt_categories() -> None:
|
|
# Only auth/billing are exempted — crypto/secrets still applies to
|
|
# connector files that handle stored customer API keys.
|
|
files = ["products/warehouse_sources/backend/temporal/data_imports/sources/stripe/api_key_store.py"]
|
|
result = detect_deny_categories(files)
|
|
assert "auth" not in result
|
|
assert "crypto_secrets" in result
|
|
|
|
|
|
def test_dwh_source_mixed_still_denies() -> None:
|
|
# The exemption is per-path, not per-PR: a real auth file alongside
|
|
# connector files must still deny.
|
|
files = [
|
|
"products/warehouse_sources/backend/temporal/data_imports/sources/stripe/source.py",
|
|
"insights/api/authentication.py",
|
|
]
|
|
assert "auth" in detect_deny_categories(files)
|
|
|
|
|
|
# ── Size-ceiling exemptions ──────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"path, exempt",
|
|
[
|
|
pytest.param("docs/internal/monorepo-layout.md", True, id="markdown"),
|
|
pytest.param(".agents/skills/foo/SKILL.md", True, id="skill-markdown"),
|
|
pytest.param("docs/example-snippet.ts", True, id="docs-dir-artifact-extension"),
|
|
pytest.param("insights/api/test/__snapshots__/test_api.ambr", True, id="ambr-snapshot"),
|
|
pytest.param("frontend/__snapshots__/scene.storyshot", True, id="storyshot-extension"),
|
|
# Outside a test dir, an executable under a snapshots dir still counts
|
|
# (extension-only snapshot exemption). Inside a test dir it is exempt
|
|
# like any test file: test-only PRs already gate-trust the same content
|
|
# via T0, and the LLM still reads it in the diff.
|
|
pytest.param("frontend/__snapshots__/helper.py", False, id="executable-under-snapshots-counted"),
|
|
pytest.param("insights/test/__snapshots__/helper.py", True, id="snapshots-inside-test-dir-exempt"),
|
|
pytest.param("frontend/src/generated/core/api.schemas.ts", True, id="generated-dir"),
|
|
pytest.param("products/tasks/frontend/generated/api.ts", True, id="product-generated-dir"),
|
|
pytest.param("services/mcp/src/api/generated.ts", True, id="mcp-client-generated-ts"),
|
|
# Pinned to the one path CI regenerates and diffs. A file merely named generated.ts
|
|
# elsewhere has no drift check behind it, so it stays reviewable.
|
|
pytest.param("products/growth/frontend/generated.ts", False, id="generated-ts-off-the-checked-path"),
|
|
pytest.param("frontend/src/scenes/generatedReport.ts", False, id="generated-prefix-counted"),
|
|
pytest.param("services/mcp/src/api/generated_helpers.ts", False, id="generated-underscore-counted"),
|
|
pytest.param("frontend/src/queries/schema/schema-general.ts", True, id="queries-schema"),
|
|
pytest.param("frontend/src/types.gen.ts", True, id="dot-gen-suffix"),
|
|
pytest.param("pnpm-lock.yaml", False, id="lockfile-yaml-counted"),
|
|
pytest.param("uv.lock", True, id="lockfile-lock-ext"),
|
|
pytest.param("insights/api/insight.py", False, id="python-code"),
|
|
pytest.param("frontend/src/scenes/insights/Insight.tsx", False, id="frontend-code"),
|
|
pytest.param("insights/settings/web.py", False, id="settings-code"),
|
|
pytest.param("docker-compose.dev.yml", False, id="yaml-config"),
|
|
pytest.param("package.json", False, id="json-config"),
|
|
pytest.param("regenerated_totals.py", False, id="generated-substring-not-dir"),
|
|
pytest.param("frontend/src/generated/core/evil.py", False, id="generated-dir-executable-py"),
|
|
pytest.param("frontend/src/generated/core/build.sh", False, id="generated-dir-executable-sh"),
|
|
pytest.param("docs/generate_sidebar.py", False, id="docs-dir-executable-py"),
|
|
pytest.param("insights/api/test/test_insight.py", True, id="test-dir-exempt"),
|
|
pytest.param("frontend/src/scenes/insights/Insight.test.tsx", True, id="dot-test-exempt"),
|
|
pytest.param("insights/personinsights_client/test_interceptor.py", True, id="bare-pytest-file-exempt"),
|
|
pytest.param(
|
|
"common/ingestion/acceptance_tests/test_basic_capture.py", True, id="test-file-in-loose-test-tree-exempt"
|
|
),
|
|
pytest.param("nodejs/src/cdp/_tests/helpers.ts", True, id="underscore-tests-dir-exempt"),
|
|
pytest.param("insights/tasks/protest.py", False, id="test-suffix-substring-counted"),
|
|
pytest.param("insights/latest/models.py", False, id="latest-dir-counted"),
|
|
# Runtime packages that merely end in "_test(s)" must not classify as
|
|
# tests: via the shared predicate that would open the T0 auto-approve
|
|
# path, not just the size exemption.
|
|
pytest.param(
|
|
"products/batch_exports/backend/api/destination_tests/delta.py", False, id="runtime-dir-test-suffix-counted"
|
|
),
|
|
pytest.param(
|
|
"insights/temporal/ingestion_acceptance_test/worker.py", False, id="runtime-worker-test-suffix-counted"
|
|
),
|
|
],
|
|
)
|
|
def test_is_size_exempt(path: str, exempt: bool) -> None:
|
|
assert is_size_exempt(path) is exempt
|
|
|
|
|
|
def test_substantive_size_counts_only_non_exempt_files() -> None:
|
|
# A docs- or test-heavy PR must not be size-denied for its prose or its
|
|
# tests, and a code-heavy PR must not slip under the ceiling by padding
|
|
# with either.
|
|
files = [
|
|
{"filename": "docs/big-rewrite.md", "additions": 2000, "deletions": 500},
|
|
{"filename": "frontend/src/generated/core/api.ts", "additions": 900, "deletions": 900},
|
|
{"filename": "insights/api/insight.py", "additions": 30, "deletions": 10},
|
|
{"filename": "insights/api/test/test_insight.py", "additions": 50, "deletions": 0},
|
|
]
|
|
|
|
lines, file_count = substantive_size(files)
|
|
|
|
assert lines == 40
|
|
assert file_count == 1
|
|
|
|
|
|
# ── Dependency manifests without a lockfile ──────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"files, expected",
|
|
[
|
|
pytest.param(
|
|
["frontend/package.json", "frontend/src/app.ts"],
|
|
["frontend/package.json"],
|
|
id="manifest-only-flagged",
|
|
),
|
|
pytest.param(
|
|
["frontend/package.json", "pnpm-lock.yaml"],
|
|
[],
|
|
id="lockfile-present-hard-denies-instead",
|
|
),
|
|
pytest.param(
|
|
["common/esbuilder/tsconfig.json"],
|
|
["common/esbuilder/tsconfig.json"],
|
|
id="tsconfig-flagged",
|
|
),
|
|
pytest.param(
|
|
["insights/api/insight.py"],
|
|
[],
|
|
id="no-manifests",
|
|
),
|
|
pytest.param(
|
|
["frontend/package.json", "rust/Cargo.lock"],
|
|
["frontend/package.json"],
|
|
id="unrelated-ecosystem-lockfile-does-not-suppress",
|
|
),
|
|
pytest.param(
|
|
["common/esbuilder/tsconfig.json", "pnpm-lock.yaml"],
|
|
["common/esbuilder/tsconfig.json"],
|
|
id="tsconfig-has-no-lockfile-always-flagged",
|
|
),
|
|
pytest.param(
|
|
["composer.json"],
|
|
["composer.json"],
|
|
id="composer-manifest-flagged",
|
|
),
|
|
pytest.param(
|
|
["docs/some_tsconfig_notes.md"],
|
|
[],
|
|
id="tsconfig-substring-not-manifest",
|
|
),
|
|
],
|
|
)
|
|
def test_dependency_manifests_without_lockfile(files: list[str], expected: list[str]) -> None:
|
|
# The deny-list no longer blocks manifest-only changes, so this helper is
|
|
# what routes them to the reviewer's scripts/hooks guard — if it breaks,
|
|
# a package.json scripts edit sails through with no scrutiny at all.
|
|
assert dependency_manifests_without_lockfile(files) == expected
|
|
|
|
|
|
@pytest.mark.parametrize("ecosystem", list(DEPENDENCY_ECOSYSTEMS))
|
|
def test_dependency_ecosystem_names_are_lowercase(ecosystem: str) -> None:
|
|
# Call sites match against Path(...).name.lower(); a mixed-case entry
|
|
# here would silently never match and go unrecognized in a security gate.
|
|
spec = DEPENDENCY_ECOSYSTEMS[ecosystem]
|
|
for name in (*spec.manifests, *spec.lockfiles):
|
|
assert name == name.lower()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"path",
|
|
[
|
|
pytest.param("common/esbuilder/tsconfig.json", id="tsconfig"),
|
|
pytest.param("setup.py", id="setup-py"),
|
|
pytest.param("setup.cfg", id="setup-cfg"),
|
|
pytest.param("pipfile", id="pipfile-manifest"),
|
|
pytest.param("gemfile", id="gemfile-manifest"),
|
|
pytest.param("composer.json", id="composer-manifest"),
|
|
pytest.param("pipfile.lock", id="pipfile-lock"),
|
|
pytest.param("gemfile.lock", id="gemfile-lock"),
|
|
pytest.param("composer.lock", id="composer-lock"),
|
|
],
|
|
)
|
|
def test_has_dependency_changes_recognizes_uncurated_manifests_and_lockfiles(path: str) -> None:
|
|
# Pins the members that DEPENDENCY_ECOSYSTEMS added beyond the old
|
|
# curated set — a future narrowing of the table should fail here rather
|
|
# than silently stop flagging these as dependency changes.
|
|
assert has_dependency_changes([path]) is True
|
|
|
|
|
|
def test_dismiss_time_trust_is_opt_in_per_ecosystem() -> None:
|
|
# Dismiss-time trust must be an explicit per-ecosystem decision: go.sum
|
|
# (trusted_at_dismiss unset) stays out even though it is a deny-listed
|
|
# lockfile, while node's lockfiles — including npm-shrinkwrap.json — opt
|
|
# in. Catches a revert to deriving trust from the whole lockfile set.
|
|
assert "go.sum" not in DISMISS_TIME_LOCKFILES
|
|
assert "pnpm-lock.yaml" in DISMISS_TIME_LOCKFILES
|
|
assert "npm-shrinkwrap.json" in DISMISS_TIME_LOCKFILES
|
|
|
|
|
|
# ── Ownership sources ────────────────────────────────────────────
|
|
|
|
_INSIGHTSCLI_RESOLVER = (OwnershipSource(format="insightscli-resolver", path="."),)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"owners_yaml, expected_teams, expected_individuals, expected_owned",
|
|
[
|
|
pytest.param("owners:\n - team-devex\n", ["@Insights/team-devex"], [], 1, id="plain-slug-prefixed"),
|
|
# Individuals count as owned but stay out of `teams`, which feeds
|
|
# team-membership checks and the cross-team calculus downstream.
|
|
pytest.param("owners:\n - '@handle'\n", [], ["@handle"], 1, id="individual-own-bucket"),
|
|
pytest.param("name: foo\n", [], [], 0, id="ownerless"),
|
|
],
|
|
)
|
|
def test_resolver_owner_normalization(
|
|
tmp_path: Path,
|
|
owners_yaml: str,
|
|
expected_teams: list[str],
|
|
expected_individuals: list[str],
|
|
expected_owned: int,
|
|
) -> None:
|
|
product_dir = tmp_path / "products" / "foo"
|
|
product_dir.mkdir(parents=True)
|
|
(product_dir / "product.yaml").write_text(owners_yaml)
|
|
|
|
resolvers = build_ownership(tmp_path, _INSIGHTSCLI_RESOLVER)
|
|
ownership = detect_ownership(["products/foo/backend/models.py"], resolvers)
|
|
|
|
assert ownership["teams"] == expected_teams
|
|
assert ownership["individuals"] == expected_individuals
|
|
assert ownership["owned_files"] == expected_owned
|
|
assert ownership["cross_team"] is False
|
|
|
|
|
|
def test_ownership_cross_team_and_unowned(tmp_path: Path) -> None:
|
|
product_dir = tmp_path / "products" / "foo"
|
|
product_dir.mkdir(parents=True)
|
|
(product_dir / "product.yaml").write_text("owners:\n - team-a\n - team-b\n")
|
|
|
|
resolvers = build_ownership(tmp_path, _INSIGHTSCLI_RESOLVER)
|
|
|
|
owned = detect_ownership(["products/foo/sub/x.py"], resolvers)
|
|
assert owned["teams"] == ["@Insights/team-a", "@Insights/team-b"]
|
|
assert owned["cross_team"] is True
|
|
|
|
outside = detect_ownership(["insights/models/x.py"], resolvers)
|
|
assert outside["teams"] == []
|
|
assert outside["unowned_files"] == 1
|