From 32f62e7bed3ab95bb4149fd80981b054b18683aa Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 1 Jun 2026 16:17:19 -0700 Subject: [PATCH 1/6] ref(flags): Graduate organizations:transaction-widget-deprecation-explore-view GA flag rolled out to 100%. Remove the flag and make the gated behavior unconditional. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../api/serializers/models/dashboard.py | 14 +- src/sentry/features/temporary.py | 2 - .../dashboards/widgetCard/index.spec.tsx | 3 +- .../widgetCard/widgetCardContextMenu.tsx | 1 - .../test_organization_dashboard_details.py | 294 +++++++++--------- 5 files changed, 149 insertions(+), 165 deletions(-) diff --git a/src/sentry/api/serializers/models/dashboard.py b/src/sentry/api/serializers/models/dashboard.py index 40d144d943b4..554cf9344196 100644 --- a/src/sentry/api/serializers/models/dashboard.py +++ b/src/sentry/api/serializers/models/dashboard.py @@ -7,7 +7,6 @@ from django.db.models import prefetch_related_objects -from sentry import features from sentry.api.serializers import Serializer, register, serialize from sentry.discover.arithmetic import get_equation_alias_index, is_equation, is_equation_alias from sentry.models.dashboard import Dashboard, DashboardFavoriteUser, DashboardRevision @@ -318,16 +317,9 @@ def serialize(self, obj, attrs, user, **kwargs) -> DashboardWidgetResponse: widget_type = DashboardWidgetTypes.get_type_name(obj.discover_widget_split) explore_urls = None - if ( - obj.widget_type == DashboardWidgetTypes.TRANSACTION_LIKE - or ( - obj.widget_type == DashboardWidgetTypes.DISCOVER - and obj.discover_widget_split == DashboardWidgetTypes.TRANSACTION_LIKE - ) - ) and features.has( - "organizations:transaction-widget-deprecation-explore-view", - organization=obj.dashboard.organization, - actor=user, + if obj.widget_type == DashboardWidgetTypes.TRANSACTION_LIKE or ( + obj.widget_type == DashboardWidgetTypes.DISCOVER + and obj.discover_widget_split == DashboardWidgetTypes.TRANSACTION_LIKE ): try: explore_urls = self.get_explore_urls(obj, attrs) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 79f9b5b8b87b..d0508320b611 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -357,8 +357,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:trace-spans-format", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Extraction metrics for transactions during ingestion. manager.add("organizations:transaction-metrics-extraction", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False) - # Enable feature to load explore link for transaction widgets - manager.add("organizations:transaction-widget-deprecation-explore-view", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Normalize URL transaction names during ingestion. manager.add("organizations:transaction-name-normalize", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, default=True, api_expose=False) # Enables view hierarchy attachment scrubbing diff --git a/static/app/views/dashboards/widgetCard/index.spec.tsx b/static/app/views/dashboards/widgetCard/index.spec.tsx index 7e358daf6a3b..ddb431883ede 100644 --- a/static/app/views/dashboards/widgetCard/index.spec.tsx +++ b/static/app/views/dashboards/widgetCard/index.spec.tsx @@ -672,8 +672,7 @@ describe('Dashboards > WidgetCard', () => { widgetLimitReached={false} isPreview widgetLegendState={widgetLegendState} - />, - ['transaction-widget-deprecation-explore-view'] + /> ); expect(await screen.findByLabelText('Widget warnings')).toBeInTheDocument(); diff --git a/static/app/views/dashboards/widgetCard/widgetCardContextMenu.tsx b/static/app/views/dashboards/widgetCard/widgetCardContextMenu.tsx index 613300a24ebb..e014e18c169c 100644 --- a/static/app/views/dashboards/widgetCard/widgetCardContextMenu.tsx +++ b/static/app/views/dashboards/widgetCard/widgetCardContextMenu.tsx @@ -57,7 +57,6 @@ export const useTransactionsDeprecationWarning = ({ // memoize the URL to avoid recalculating it on every render const exploreUrl = useMemo(() => { if ( - !organization.features.includes('transaction-widget-deprecation-explore-view') || widget.widgetType !== WidgetType.TRANSACTIONS || !widget.exploreUrls || widget.exploreUrls.length === 0 diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py b/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py index 70c9d0e98140..5f3068cc3865 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py @@ -440,169 +440,165 @@ def test_get_favorite_status_no_dashboard_edit_access(self) -> None: assert response.data["isFavorited"] is True def test_explore_url_for_transaction_widget(self) -> None: - with self.feature("organizations:transaction-widget-deprecation-explore-view"): - dashboard_deprecation = Dashboard.objects.create( - title="Dashboard With Transaction Widget", - created_by_id=self.user.id, - organization=self.organization, - ) - widget_deprecation = DashboardWidget.objects.create( - dashboard=dashboard_deprecation, - title="transaction widget", - display_type=DashboardWidgetDisplayTypes.LINE_CHART, - widget_type=DashboardWidgetTypes.TRANSACTION_LIKE, - interval="1d", - detail={"layout": {"x": 0, "y": 0, "w": 1, "h": 1, "minH": 2}}, - ) + dashboard_deprecation = Dashboard.objects.create( + title="Dashboard With Transaction Widget", + created_by_id=self.user.id, + organization=self.organization, + ) + widget_deprecation = DashboardWidget.objects.create( + dashboard=dashboard_deprecation, + title="transaction widget", + display_type=DashboardWidgetDisplayTypes.LINE_CHART, + widget_type=DashboardWidgetTypes.TRANSACTION_LIKE, + interval="1d", + detail={"layout": {"x": 0, "y": 0, "w": 1, "h": 1, "minH": 2}}, + ) - DashboardWidgetQuery.objects.create( - widget=widget_deprecation, - fields=["count()", "transaction"], - columns=["transaction"], - aggregates=["count()"], - conditions="count():>50", - orderby="-count", - order=0, - ) - response = self.do_request("get", self.url(dashboard_deprecation.id)) - assert response.status_code == 200 - explore_url = response.data["widgets"][0]["exploreUrls"][0] - assert "http://testserver/explore/traces/" in explore_url - - params = dict(parse_qs(urlsplit(response.data["widgets"][0]["exploreUrls"][0]).query)) - assert params["query"] == ["(count(span.duration):>50) AND is_transaction:1"] - assert params["sort"] == ["-count(span.duration)"] - assert params["mode"] == ["aggregate"] - assert params["aggregateField"] == [ - '{"groupBy":"transaction"}', - '{"yAxes":["count(span.duration)"],"chartType":1}', - ] + DashboardWidgetQuery.objects.create( + widget=widget_deprecation, + fields=["count()", "transaction"], + columns=["transaction"], + aggregates=["count()"], + conditions="count():>50", + orderby="-count", + order=0, + ) + response = self.do_request("get", self.url(dashboard_deprecation.id)) + assert response.status_code == 200 + explore_url = response.data["widgets"][0]["exploreUrls"][0] + assert "http://testserver/explore/traces/" in explore_url + + params = dict(parse_qs(urlsplit(response.data["widgets"][0]["exploreUrls"][0]).query)) + assert params["query"] == ["(count(span.duration):>50) AND is_transaction:1"] + assert params["sort"] == ["-count(span.duration)"] + assert params["mode"] == ["aggregate"] + assert params["aggregateField"] == [ + '{"groupBy":"transaction"}', + '{"yAxes":["count(span.duration)"],"chartType":1}', + ] def test_explore_url_for_table_widget(self) -> None: - with self.feature("organizations:transaction-widget-deprecation-explore-view"): - dashboard_deprecation = Dashboard.objects.create( - title="Dashboard With Transaction Widget", - created_by_id=self.user.id, - organization=self.organization, - ) - widget_deprecation = DashboardWidget.objects.create( - dashboard=dashboard_deprecation, - title="table widget", - display_type=DashboardWidgetDisplayTypes.TABLE, - widget_type=DashboardWidgetTypes.TRANSACTION_LIKE, - interval="1d", - detail={"layout": {"x": 0, "y": 0, "w": 1, "h": 1, "minH": 2}}, - ) + dashboard_deprecation = Dashboard.objects.create( + title="Dashboard With Transaction Widget", + created_by_id=self.user.id, + organization=self.organization, + ) + widget_deprecation = DashboardWidget.objects.create( + dashboard=dashboard_deprecation, + title="table widget", + display_type=DashboardWidgetDisplayTypes.TABLE, + widget_type=DashboardWidgetTypes.TRANSACTION_LIKE, + interval="1d", + detail={"layout": {"x": 0, "y": 0, "w": 1, "h": 1, "minH": 2}}, + ) - DashboardWidgetQuery.objects.create( - widget=widget_deprecation, - fields=["id", "title"], - columns=["id", "title"], - aggregates=[], - order=0, - ) + DashboardWidgetQuery.objects.create( + widget=widget_deprecation, + fields=["id", "title"], + columns=["id", "title"], + aggregates=[], + order=0, + ) - response = self.do_request("get", self.url(dashboard_deprecation.id)) - assert response.status_code == 200 - explore_url = response.data["widgets"][0]["exploreUrls"][0] - assert "http://testserver/explore/traces/" in explore_url + response = self.do_request("get", self.url(dashboard_deprecation.id)) + assert response.status_code == 200 + explore_url = response.data["widgets"][0]["exploreUrls"][0] + assert "http://testserver/explore/traces/" in explore_url - params = dict(parse_qs(urlsplit(response.data["widgets"][0]["exploreUrls"][0]).query)) - assert params["query"] == ["is_transaction:1"] - assert "sort" not in params - assert params["mode"] == ["samples"] - # need to sort because fields order is not guaranteed - assert params["field"].sort() == ["id", "transaction"].sort() - assert "aggregateField" not in params + params = dict(parse_qs(urlsplit(response.data["widgets"][0]["exploreUrls"][0]).query)) + assert params["query"] == ["is_transaction:1"] + assert "sort" not in params + assert params["mode"] == ["samples"] + # need to sort because fields order is not guaranteed + assert params["field"].sort() == ["id", "transaction"].sort() + assert "aggregateField" not in params def test_explore_url_for_widget_with_discover_split_param(self) -> None: - with self.feature("organizations:transaction-widget-deprecation-explore-view"): - dashboard_deprecation = Dashboard.objects.create( - title="Dashboard With Transaction Widget", - created_by_id=self.user.id, - organization=self.organization, - filters={ - "release": ["1.0.0", "2.0.0"], - }, - ) - widget_deprecation = DashboardWidget.objects.create( - dashboard=dashboard_deprecation, - title="transaction widget", - display_type=DashboardWidgetDisplayTypes.LINE_CHART, - widget_type=DashboardWidgetTypes.DISCOVER, - discover_widget_split=DashboardWidgetTypes.TRANSACTION_LIKE, - interval="1d", - detail={"layout": {"x": 0, "y": 0, "w": 1, "h": 1, "minH": 2}}, - ) + dashboard_deprecation = Dashboard.objects.create( + title="Dashboard With Transaction Widget", + created_by_id=self.user.id, + organization=self.organization, + filters={ + "release": ["1.0.0", "2.0.0"], + }, + ) + widget_deprecation = DashboardWidget.objects.create( + dashboard=dashboard_deprecation, + title="transaction widget", + display_type=DashboardWidgetDisplayTypes.LINE_CHART, + widget_type=DashboardWidgetTypes.DISCOVER, + discover_widget_split=DashboardWidgetTypes.TRANSACTION_LIKE, + interval="1d", + detail={"layout": {"x": 0, "y": 0, "w": 1, "h": 1, "minH": 2}}, + ) - DashboardWidgetQuery.objects.create( - widget=widget_deprecation, - fields=["count()", "transaction"], - columns=["transaction"], - aggregates=["count()"], - conditions="count():>50", - orderby="-count", - order=0, - ) + DashboardWidgetQuery.objects.create( + widget=widget_deprecation, + fields=["count()", "transaction"], + columns=["transaction"], + aggregates=["count()"], + conditions="count():>50", + orderby="-count", + order=0, + ) - response = self.do_request("get", self.url(dashboard_deprecation.id)) - assert response.status_code == 200 - explore_url = response.data["widgets"][0]["exploreUrls"][0] - assert "http://testserver/explore/traces/" in explore_url - - params = dict(parse_qs(urlsplit(response.data["widgets"][0]["exploreUrls"][0]).query)) - assert params["query"] == [ - "(count(span.duration):>50) AND is_transaction:1 AND release:1.0.0,2.0.0" - ] - assert params["sort"] == ["-count(span.duration)"] - assert params["mode"] == ["aggregate"] - assert params["aggregateField"] == [ - '{"groupBy":"transaction"}', - '{"yAxes":["count(span.duration)"],"chartType":1}', - ] + response = self.do_request("get", self.url(dashboard_deprecation.id)) + assert response.status_code == 200 + explore_url = response.data["widgets"][0]["exploreUrls"][0] + assert "http://testserver/explore/traces/" in explore_url + + params = dict(parse_qs(urlsplit(response.data["widgets"][0]["exploreUrls"][0]).query)) + assert params["query"] == [ + "(count(span.duration):>50) AND is_transaction:1 AND release:1.0.0,2.0.0" + ] + assert params["sort"] == ["-count(span.duration)"] + assert params["mode"] == ["aggregate"] + assert params["aggregateField"] == [ + '{"groupBy":"transaction"}', + '{"yAxes":["count(span.duration)"],"chartType":1}', + ] def test_explore_url_for_deformed_widget(self) -> None: - with self.feature("organizations:transaction-widget-deprecation-explore-view"): - dashboard_deprecation = Dashboard.objects.create( - title="Dashboard With Transaction Widget", - created_by_id=self.user.id, - organization=self.organization, - ) - widget_deprecation = DashboardWidget.objects.create( - dashboard=dashboard_deprecation, - title="line widget", - display_type=DashboardWidgetDisplayTypes.LINE_CHART, - widget_type=DashboardWidgetTypes.TRANSACTION_LIKE, - interval="1d", - detail={"layout": {"x": 0, "y": 0, "w": 1, "h": 1, "minH": 2}}, - ) + dashboard_deprecation = Dashboard.objects.create( + title="Dashboard With Transaction Widget", + created_by_id=self.user.id, + organization=self.organization, + ) + widget_deprecation = DashboardWidget.objects.create( + dashboard=dashboard_deprecation, + title="line widget", + display_type=DashboardWidgetDisplayTypes.LINE_CHART, + widget_type=DashboardWidgetTypes.TRANSACTION_LIKE, + interval="1d", + detail={"layout": {"x": 0, "y": 0, "w": 1, "h": 1, "minH": 2}}, + ) - DashboardWidgetQuery.objects.create( - widget=widget_deprecation, - fields=["query.dataset"], - columns=["query.dataset"], - aggregates=["p95(transaction.duration)"], - orderby="-p95(transaction.duration)", - conditions="transaction:/api/0/organizations/{organization_id_or_slug}/events/", - order=0, - ) + DashboardWidgetQuery.objects.create( + widget=widget_deprecation, + fields=["query.dataset"], + columns=["query.dataset"], + aggregates=["p95(transaction.duration)"], + orderby="-p95(transaction.duration)", + conditions="transaction:/api/0/organizations/{organization_id_or_slug}/events/", + order=0, + ) - response = self.do_request("get", self.url(dashboard_deprecation.id)) - assert response.status_code == 200 - explore_url = response.data["widgets"][0]["exploreUrls"][0] - assert "http://testserver/explore/traces/" in explore_url - - params = dict(parse_qs(urlsplit(response.data["widgets"][0]["exploreUrls"][0]).query)) - assert params["query"] == [ - "(transaction:/api/0/organizations/{organization_id_or_slug}/events/) AND is_transaction:1" - ] - assert params["sort"] == ["-p95(span.duration)"] - assert params["mode"] == ["aggregate"] - assert params["field"].sort() == ["query.dataset", "span.duration"].sort() - assert params["aggregateField"] == [ - '{"groupBy":"query.dataset"}', - '{"yAxes":["p95(span.duration)"],"chartType":1}', - ] + response = self.do_request("get", self.url(dashboard_deprecation.id)) + assert response.status_code == 200 + explore_url = response.data["widgets"][0]["exploreUrls"][0] + assert "http://testserver/explore/traces/" in explore_url + + params = dict(parse_qs(urlsplit(response.data["widgets"][0]["exploreUrls"][0]).query)) + assert params["query"] == [ + "(transaction:/api/0/organizations/{organization_id_or_slug}/events/) AND is_transaction:1" + ] + assert params["sort"] == ["-p95(span.duration)"] + assert params["mode"] == ["aggregate"] + assert params["field"].sort() == ["query.dataset", "span.duration"].sort() + assert params["aggregateField"] == [ + '{"groupBy":"query.dataset"}', + '{"yAxes":["p95(span.duration)"],"chartType":1}', + ] def test_changed_reason_response(self) -> None: response = self.do_request("get", self.url(self.dashboard.id)) From a83de607c4529be500a717bb6c2de2c7ae6dc925 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 29 May 2026 12:24:15 -0700 Subject: [PATCH 2/6] feat(repositories): Backfill auto-link repos by name matching This backfill migration that iterates all orgs and matches unlinked repo basenames to project slugs, creating ProjectRepository rows with source=AUTO_NAME_MATCH. Writes here are behind an option, so we can perform a dry run first. --- .../1102_backfill_auto_link_repos_by_name.py | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 src/sentry/migrations/1102_backfill_auto_link_repos_by_name.py diff --git a/src/sentry/migrations/1102_backfill_auto_link_repos_by_name.py b/src/sentry/migrations/1102_backfill_auto_link_repos_by_name.py new file mode 100644 index 000000000000..291cfb17bbf5 --- /dev/null +++ b/src/sentry/migrations/1102_backfill_auto_link_repos_by_name.py @@ -0,0 +1,159 @@ +""" +Backfill ProjectRepository rows by matching repo name suffix to project slug. + +This is a one-time backfill for the auto-link-repos-by-name feature. It +iterates all active organizations, and for each one matches unlinked repos +to unlinked projects by name. Respects the dry-run option +(repository.auto-link-by-name-dry-run) read directly from sentry_option. + +Safe to re-run: uses get_or_create and skips already-linked pairs. +""" + +import logging + +from django.db import migrations +from django.db.models import Exists, OuterRef +from django.utils.text import slugify + +from sentry.new_migrations.migrations import CheckedMigration +from sentry.utils.query import RangeQuerySetWrapperWithProgressBar + +logger = logging.getLogger(__name__) + +# source enum: AUTO_NAME_MATCH = 3 +AUTO_NAME_MATCH = 3 +ACTIVE = 0 + + +def _get_dry_run(apps): + Option = apps.get_model("sentry", "Option") + try: + opt = Option.objects.get(key="repository.auto-link-by-name-dry-run") + return bool(opt.value) + except Option.DoesNotExist: + return True + + +def _get_repo_name_candidates(repo_name): + parts = [slugify(part.strip()) for part in repo_name.split("/") if part.strip()] + parts = [p for p in parts if p] + if not parts: + return [] + candidates = [parts[-1]] + if len(parts) > 1: + candidates.append("-".join(parts)) + return candidates + + +def backfill_auto_link_repos(apps, schema_editor): + Organization = apps.get_model("sentry", "Organization") + Project = apps.get_model("sentry", "Project") + Repository = apps.get_model("sentry", "Repository") + ProjectRepository = apps.get_model("sentry", "ProjectRepository") + + dry_run = _get_dry_run(apps) + total_matched = 0 + total_created = 0 + + for org in RangeQuerySetWrapperWithProgressBar(Organization.objects.filter(status=ACTIVE)): + repos = Repository.objects.filter( + organization_id=org.id, + status=ACTIVE, + ).exclude(Exists(ProjectRepository.objects.filter(repository_id=OuterRef("id")))) + if not repos.exists(): + continue + + unlinked_projects_by_slug = {} + for project_id, slug in ( + Project.objects.filter( + organization_id=org.id, + status=ACTIVE, + ) + .exclude(Exists(ProjectRepository.objects.filter(project_id=OuterRef("id")))) + .values_list("id", "slug") + ): + unlinked_projects_by_slug[slug] = (project_id, slug) + + if not unlinked_projects_by_slug: + continue + + for repo in repos: + project_id = None + project_slug = None + for candidate in _get_repo_name_candidates(repo.name): + if candidate in unlinked_projects_by_slug: + project_id, project_slug = unlinked_projects_by_slug.pop(candidate) + break + if project_id is None: + continue + + total_matched += 1 + + if dry_run: + logger.info( + "backfill_auto_link_repos.dry_run_match", + extra={ + "organization_id": org.id, + "repository_id": repo.id, + "repository_name": repo.name, + "project_id": project_id, + "project_slug": project_slug, + }, + ) + else: + _, was_created = ProjectRepository.objects.get_or_create( + project_id=project_id, + repository=repo, + defaults={"source": AUTO_NAME_MATCH}, + ) + if was_created: + total_created += 1 + logger.info( + "backfill_auto_link_repos.linked", + extra={ + "organization_id": org.id, + "repository_id": repo.id, + "repository_name": repo.name, + "project_id": project_id, + "project_slug": project_slug, + }, + ) + + if dry_run: + logger.info( + "backfill_auto_link_repos.dry_run_complete", + extra={"total_matched": total_matched}, + ) + else: + logger.info( + "backfill_auto_link_repos.complete", + extra={"total_matched": total_matched, "total_created": total_created}, + ) + + +class Migration(CheckedMigration): + # This flag is used to mark that a migration shouldn't be automatically run in production. + # This should only be used for operations where it's safe to run the migration after your + # code has deployed. So this should not be used for most operations that alter the schema + # of a table. + # Here are some things that make sense to mark as post deployment: + # - Large data migrations. Typically we want these to be run manually so that they can be + # monitored and not block the deploy for a long period of time while they run. + # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to + # run this outside deployments so that we don't block them. Note that while adding an index + # is a schema change, it's completely safe to run the operation after the code has deployed. + # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment + + is_post_deployment = True + + dependencies = [ + ("sentry", "1101_remove_email_model_pending"), + ] + + operations = [ + migrations.RunPython( + backfill_auto_link_repos, + migrations.RunPython.noop, + hints={"tables": ["sentry_projectrepository"]}, + ), + ] From ef02580c6f40b3716ee99776565c30a067da52d3 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 29 May 2026 16:33:39 -0700 Subject: [PATCH 3/6] fix(repositories): Add type annotations to backfill migration functions Co-Authored-By: Claude Opus 4 --- .../migrations/1102_backfill_auto_link_repos_by_name.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/sentry/migrations/1102_backfill_auto_link_repos_by_name.py b/src/sentry/migrations/1102_backfill_auto_link_repos_by_name.py index 291cfb17bbf5..b40f0cebf4e4 100644 --- a/src/sentry/migrations/1102_backfill_auto_link_repos_by_name.py +++ b/src/sentry/migrations/1102_backfill_auto_link_repos_by_name.py @@ -10,6 +10,7 @@ """ import logging +from typing import Any from django.db import migrations from django.db.models import Exists, OuterRef @@ -25,7 +26,7 @@ ACTIVE = 0 -def _get_dry_run(apps): +def _get_dry_run(apps: Any) -> bool: Option = apps.get_model("sentry", "Option") try: opt = Option.objects.get(key="repository.auto-link-by-name-dry-run") @@ -34,7 +35,7 @@ def _get_dry_run(apps): return True -def _get_repo_name_candidates(repo_name): +def _get_repo_name_candidates(repo_name: str) -> list[str]: parts = [slugify(part.strip()) for part in repo_name.split("/") if part.strip()] parts = [p for p in parts if p] if not parts: @@ -45,7 +46,7 @@ def _get_repo_name_candidates(repo_name): return candidates -def backfill_auto_link_repos(apps, schema_editor): +def backfill_auto_link_repos(apps: Any, schema_editor: Any) -> None: Organization = apps.get_model("sentry", "Organization") Project = apps.get_model("sentry", "Project") Repository = apps.get_model("sentry", "Repository") From 40e27a443131d4de749736bc962de8f425e7124d Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 1 Jun 2026 13:06:01 -0700 Subject: [PATCH 4/6] add test --- ...t_1102_backfill_auto_link_repos_by_name.py | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 tests/sentry/migrations/test_1102_backfill_auto_link_repos_by_name.py diff --git a/tests/sentry/migrations/test_1102_backfill_auto_link_repos_by_name.py b/tests/sentry/migrations/test_1102_backfill_auto_link_repos_by_name.py new file mode 100644 index 000000000000..e7d64d922aab --- /dev/null +++ b/tests/sentry/migrations/test_1102_backfill_auto_link_repos_by_name.py @@ -0,0 +1,119 @@ +from sentry.models.projectrepository import ProjectRepository, ProjectRepositorySource +from sentry.models.repository import Repository +from sentry.testutils.cases import TestMigrations + + +class BackfillAutoLinkReposByNameTest(TestMigrations): + migrate_from = "1101_remove_email_model_pending" + migrate_to = "1102_backfill_auto_link_repos_by_name" + + def setup_before_migration(self, apps: object) -> None: + Option = apps.get_model("sentry", "Option") # type: ignore[union-attr] + + # Set dry-run to False so the migration actually creates rows + Option.objects.create(key="repository.auto-link-by-name-dry-run", value=False) + + self.org = self.create_organization() + + # Case 1: exact match — repo basename matches project slug + self.project_match = self.create_project(organization=self.org, slug="sentry") + self.repo_match = Repository.objects.create( + organization_id=self.org.id, + name="getsentry/sentry", + provider="integrations:github", + external_id="1", + ) + + # Case 2: no match — repo basename doesn't match any project slug + self.project_nomatch = self.create_project(organization=self.org, slug="frontend") + self.repo_nomatch = Repository.objects.create( + organization_id=self.org.id, + name="getsentry/relay", + provider="integrations:github", + external_id="2", + ) + + # Case 3: repo already linked — should be skipped + self.project_already_linked = self.create_project( + organization=self.org, slug="already-linked" + ) + self.repo_already_linked = Repository.objects.create( + organization_id=self.org.id, + name="getsentry/already-linked", + provider="integrations:github", + external_id="3", + ) + ProjectRepository.objects.create( + project=self.project_already_linked, + repository=self.repo_already_linked, + source=ProjectRepositorySource.MANUAL, + ) + + # Case 4: project already has a link — should be skipped + self.project_has_link = self.create_project(organization=self.org, slug="has-link") + other_repo = Repository.objects.create( + organization_id=self.org.id, + name="getsentry/other", + provider="integrations:github", + external_id="4", + ) + ProjectRepository.objects.create( + project=self.project_has_link, + repository=other_repo, + source=ProjectRepositorySource.MANUAL, + ) + self.repo_for_has_link = Repository.objects.create( + organization_id=self.org.id, + name="getsentry/has-link", + provider="integrations:github", + external_id="5", + ) + + # Case 5: GitLab-style name with spaces — should still match + self.project_gitlab = self.create_project(organization=self.org, slug="my-project") + self.repo_gitlab = Repository.objects.create( + organization_id=self.org.id, + name="My Group / My Project", + provider="integrations:gitlab", + external_id="6", + ) + + def test(self) -> None: + # Case 1: exact match created + assert ProjectRepository.objects.filter( + project=self.project_match, + repository=self.repo_match, + source=ProjectRepositorySource.AUTO_NAME_MATCH, + ).exists() + + # Case 2: no match — no link created for either + assert not ProjectRepository.objects.filter( + project=self.project_nomatch, repository=self.repo_nomatch + ).exists() + + # Case 3: already-linked repo — no duplicate created + assert ( + ProjectRepository.objects.filter( + project=self.project_already_linked, + repository=self.repo_already_linked, + ).count() + == 1 + ) + # Source should still be MANUAL (not overwritten) + pr = ProjectRepository.objects.get( + project=self.project_already_linked, + repository=self.repo_already_linked, + ) + assert pr.source == ProjectRepositorySource.MANUAL + + # Case 4: project already has a link — no new link created + assert not ProjectRepository.objects.filter( + project=self.project_has_link, repository=self.repo_for_has_link + ).exists() + + # Case 5: GitLab-style name matched via slugify + assert ProjectRepository.objects.filter( + project=self.project_gitlab, + repository=self.repo_gitlab, + source=ProjectRepositorySource.AUTO_NAME_MATCH, + ).exists() From ee9b0722c433161365c13ef58542ccd1ae8fba41 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 1 Jun 2026 13:08:41 -0700 Subject: [PATCH 5/6] rebase --- migrations_lockfile.txt | 2 +- ...os_by_name.py => 1103_backfill_auto_link_repos_by_name.py} | 2 +- ..._name.py => test_1103_backfill_auto_link_repos_by_name.py} | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) rename src/sentry/migrations/{1102_backfill_auto_link_repos_by_name.py => 1103_backfill_auto_link_repos_by_name.py} (99%) rename tests/sentry/migrations/{test_1102_backfill_auto_link_repos_by_name.py => test_1103_backfill_auto_link_repos_by_name.py} (97%) diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 4ad31dbc6ebb..8c8f54dd4e92 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -31,7 +31,7 @@ replays: 0007_organizationmember_replay_access seer: 0017_drop_old_fk_columns -sentry: 1102_activity_project_type_index +sentry: 1103_backfill_auto_link_repos_by_name social_auth: 0003_social_auth_json_field diff --git a/src/sentry/migrations/1102_backfill_auto_link_repos_by_name.py b/src/sentry/migrations/1103_backfill_auto_link_repos_by_name.py similarity index 99% rename from src/sentry/migrations/1102_backfill_auto_link_repos_by_name.py rename to src/sentry/migrations/1103_backfill_auto_link_repos_by_name.py index b40f0cebf4e4..1a6bc070a798 100644 --- a/src/sentry/migrations/1102_backfill_auto_link_repos_by_name.py +++ b/src/sentry/migrations/1103_backfill_auto_link_repos_by_name.py @@ -148,7 +148,7 @@ class Migration(CheckedMigration): is_post_deployment = True dependencies = [ - ("sentry", "1101_remove_email_model_pending"), + ("sentry", "1102_activity_project_type_index"), ] operations = [ diff --git a/tests/sentry/migrations/test_1102_backfill_auto_link_repos_by_name.py b/tests/sentry/migrations/test_1103_backfill_auto_link_repos_by_name.py similarity index 97% rename from tests/sentry/migrations/test_1102_backfill_auto_link_repos_by_name.py rename to tests/sentry/migrations/test_1103_backfill_auto_link_repos_by_name.py index e7d64d922aab..c7bc5b7dbd16 100644 --- a/tests/sentry/migrations/test_1102_backfill_auto_link_repos_by_name.py +++ b/tests/sentry/migrations/test_1103_backfill_auto_link_repos_by_name.py @@ -4,8 +4,8 @@ class BackfillAutoLinkReposByNameTest(TestMigrations): - migrate_from = "1101_remove_email_model_pending" - migrate_to = "1102_backfill_auto_link_repos_by_name" + migrate_from = "1102_activity_project_type_index" + migrate_to = "1103_backfill_auto_link_repos_by_name" def setup_before_migration(self, apps: object) -> None: Option = apps.get_model("sentry", "Option") # type: ignore[union-attr] From 3fc0bb621a7439332c8bc2eb365c72b4f75509ac Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 1 Jun 2026 16:47:32 -0700 Subject: [PATCH 6/6] mypy --- .../migrations/test_1103_backfill_auto_link_repos_by_name.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/sentry/migrations/test_1103_backfill_auto_link_repos_by_name.py b/tests/sentry/migrations/test_1103_backfill_auto_link_repos_by_name.py index c7bc5b7dbd16..1c83312a2457 100644 --- a/tests/sentry/migrations/test_1103_backfill_auto_link_repos_by_name.py +++ b/tests/sentry/migrations/test_1103_backfill_auto_link_repos_by_name.py @@ -7,8 +7,8 @@ class BackfillAutoLinkReposByNameTest(TestMigrations): migrate_from = "1102_activity_project_type_index" migrate_to = "1103_backfill_auto_link_repos_by_name" - def setup_before_migration(self, apps: object) -> None: - Option = apps.get_model("sentry", "Option") # type: ignore[union-attr] + def setup_before_migration(self, apps): + Option = apps.get_model("sentry", "Option") # Set dry-run to False so the migration actually creates rows Option.objects.create(key="repository.auto-link-by-name-dry-run", value=False)