Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion migrations_lockfile.txt
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ releases: 0004_cleanup_failed_safe_deletes

replays: 0007_organizationmember_replay_access

sentry: 1015_backfill_self_hosted_sentry_app_emails
sentry: 1016_backfill_on_command_phrase_trigger

social_auth: 0003_social_auth_json_field

Expand Down
10 changes: 10 additions & 0 deletions src/sentry/core/endpoints/organization_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
from sentry.models.organization import Organization, OrganizationStatus
from sentry.models.organizationmember import OrganizationMember
from sentry.models.project import Project
from sentry.models.repositorysettings import CodeReviewTrigger
from sentry.organizations.services.organization import organization_service
from sentry.organizations.services.organization.model import (
RpcOrganization,
Expand Down Expand Up @@ -401,6 +402,15 @@ def validate_safeFields(self, value):
raise serializers.ValidationError("Empty values are not allowed.")
return validate_pii_selectors(value)

def validate_defaultCodeReviewTriggers(self, value):
Copy link
Member

Choose a reason for hiding this comment

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

Are we planning to remove this at some point? With this logic as written you're no longer able to turn off the ON_COMMAND_PHRASE trigger right? So presumably we're going to remove this trigger completely (make it no longer configurable), at which point this validation can go away

Copy link
Member

Choose a reason for hiding this comment

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

Left a more broad note in this regard as a whole PR review comment.

# Ensure ON_COMMAND_PHRASE is always a default code review trigger
if value is not None:
triggers = list(value)
if CodeReviewTrigger.ON_COMMAND_PHRASE not in triggers:
triggers.append(CodeReviewTrigger.ON_COMMAND_PHRASE)
value = triggers
return value

def validate_attachmentsRole(self, value):
try:
roles.get(value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ def put(self, request: Request, organization: Organization) -> Response:
if updated_code_review_triggers is not None:
update_fields.append("code_review_triggers")

# Ensure ON_COMMAND_PHRASE is always a trigger
if CodeReviewTrigger.ON_COMMAND_PHRASE.value not in updated_code_review_triggers:
updated_code_review_triggers.append(CodeReviewTrigger.ON_COMMAND_PHRASE.value)

repositories = list(
Repository.objects.filter(
id__in=repository_ids,
Expand All @@ -93,7 +97,9 @@ def put(self, request: Request, organization: Organization) -> Response:

settings_to_upsert = []
for repo in repositories:
setting = existing_settings.get(repo.id) or RepositorySettings(repository=repo)
setting = existing_settings.get(repo.id) or RepositorySettings(
repository=repo, code_review_triggers=[CodeReviewTrigger.ON_COMMAND_PHRASE.value]
)

if updated_enabled_code_review is not None:
setting.enabled_code_review = updated_enabled_code_review
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Generated by Django 5.2.8 on 2025-12-19 23:11

from django.db import migrations
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.migrations.state import StateApps

from sentry.new_migrations.migrations import CheckedMigration


def backfill_on_command_phrase_trigger(
apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
) -> None:
"""Backfill on_command_phrase code review trigger for all existing RepositorySettings and Organization defaultCodeReviewTriggers."""
RepositorySettings = apps.get_model("sentry", "RepositorySettings")
OrganizationOption = apps.get_model("sentry", "OrganizationOption")

# Backfill RepositorySettings
settings_to_update = []

for setting in RepositorySettings.objects.all():
triggers = list(setting.code_review_triggers or [])
if "on_command_phrase" not in triggers:
setting.code_review_triggers = triggers + ["on_command_phrase"]
settings_to_update.append(setting)

if settings_to_update:
RepositorySettings.objects.bulk_update(settings_to_update, ["code_review_triggers"])

# Backfill Organization defaultCodeReviewTriggers
org_options_to_update = []

for org_option in OrganizationOption.objects.filter(key="sentry:default_code_review_triggers"):
triggers = list(org_option.value or [])
if "on_command_phrase" not in triggers:
org_option.value = triggers + ["on_command_phrase"]
org_options_to_update.append(org_option)

if org_options_to_update:
OrganizationOption.objects.bulk_update(org_options_to_update, ["value"])


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", "1015_backfill_self_hosted_sentry_app_emails"),
]

operations = [
migrations.RunPython(
backfill_on_command_phrase_trigger,
reverse_code=migrations.RunPython.noop,
hints={"tables": ["sentry_repositorysettings", "sentry_organizationoptions"]},
),
]
10 changes: 10 additions & 0 deletions src/sentry/models/repositorysettings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from dataclasses import dataclass
from enum import StrEnum
from typing import Any

from django.contrib.postgres.fields.array import ArrayField
from django.db import models
Expand Down Expand Up @@ -51,6 +52,15 @@ class Meta:

__repr__ = sane_repr("repository_id", "enabled_code_review")

def save(self, *args: Any, **kwargs: Any) -> None:
# Ensure ON_COMMAND_PHRASE is always a trigger
triggers = list(self.code_review_triggers or [])
if CodeReviewTrigger.ON_COMMAND_PHRASE.value not in triggers:
triggers.append(CodeReviewTrigger.ON_COMMAND_PHRASE.value)
self.code_review_triggers = triggers

super().save(*args, **kwargs)
Comment on lines +57 to +62
Copy link
Member

Choose a reason for hiding this comment

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

How frequent are writes to this table? There will be a small time window (a few minutes) between your backfill running and this code being deployed to all servers. Any rows created in that time window will not have on_command_phrase stored. If you have a low volume of writes you could get lucky. If you want to completely avoid missed writes you'd need to make the migration a post_deploy instead.

Copy link
Member Author

Choose a reason for hiding this comment

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

Ok, I expect write freq to be low but if there's no downside to doing it post-deploy, I'll just do so for peace of mind!


def get_code_review_settings(self) -> CodeReviewSettings:
"""Return code review settings for this repository."""
triggers = [CodeReviewTrigger(t) for t in self.code_review_triggers]
Expand Down
28 changes: 28 additions & 0 deletions tests/sentry/core/endpoints/test_organization_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from sentry.models.organization import Organization, OrganizationStatus
from sentry.models.organizationmapping import OrganizationMapping
from sentry.models.organizationslugreservation import OrganizationSlugReservation
from sentry.models.repositorysettings import CodeReviewTrigger
from sentry.replays.models import OrganizationMemberReplayAccess
from sentry.signals import project_created
from sentry.silo.safety import unguarded_write
Expand Down Expand Up @@ -1309,6 +1310,33 @@ def test_default_seer_scanner_automation(self) -> None:
self.get_success_response(self.organization.slug, **data)
assert self.organization.get_option("sentry:default_seer_scanner_automation") is True

def test_default_code_review_triggers_adds_on_command_phrase(self) -> None:
data: dict[str, list[CodeReviewTrigger]] = {"defaultCodeReviewTriggers": []}
self.get_success_response(self.organization.slug, **data)
assert self.organization.get_option("sentry:default_code_review_triggers") == [
CodeReviewTrigger.ON_COMMAND_PHRASE
]

data = {"defaultCodeReviewTriggers": [CodeReviewTrigger.ON_READY_FOR_REVIEW]}
self.get_success_response(self.organization.slug, **data)
assert self.organization.get_option("sentry:default_code_review_triggers") == [
CodeReviewTrigger.ON_READY_FOR_REVIEW,
CodeReviewTrigger.ON_COMMAND_PHRASE,
]

def test_default_code_review_triggers_preserves_on_command_phrase(self) -> None:
data = {
"defaultCodeReviewTriggers": [
CodeReviewTrigger.ON_COMMAND_PHRASE,
CodeReviewTrigger.ON_READY_FOR_REVIEW,
]
}
self.get_success_response(self.organization.slug, **data)
assert self.organization.get_option("sentry:default_code_review_triggers") == [
CodeReviewTrigger.ON_COMMAND_PHRASE,
CodeReviewTrigger.ON_READY_FOR_REVIEW,
]

def test_enabled_console_platforms_present_in_response(self) -> None:
response = self.get_success_response(self.organization.slug)
assert "enabledConsolePlatforms" in response.data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ def test_expand_settings_with_settings(self) -> None:
assert response.data[0]["settings"]["codeReviewTriggers"] == [
"on_new_commit",
"on_ready_for_review",
"on_command_phrase",
]

def test_expand_settings_without_settings(self) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ def test_get_repository_expand_settings(self) -> None:
assert response.data["settings"]["codeReviewTriggers"] == [
"on_new_commit",
"on_ready_for_review",
"on_command_phrase",
]

def test_get_repository_expand_settings_no_settings_exist(self) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ def test_bulk_create_settings(self) -> None:
"enabledCodeReview": True,
"codeReviewTriggers": [
CodeReviewTrigger.ON_NEW_COMMIT,
CodeReviewTrigger.ON_READY_FOR_REVIEW,
],
},
format="json",
Expand All @@ -38,11 +37,16 @@ def test_bulk_create_settings(self) -> None:

settings1 = RepositorySettings.objects.get(repository=repo1)
assert settings1.enabled_code_review is True
assert settings1.code_review_triggers == ["on_new_commit", "on_ready_for_review"]

assert settings1.code_review_triggers == [
CodeReviewTrigger.ON_NEW_COMMIT,
CodeReviewTrigger.ON_COMMAND_PHRASE,
]
settings2 = RepositorySettings.objects.get(repository=repo2)
assert settings2.enabled_code_review is True
assert settings2.code_review_triggers == ["on_new_commit", "on_ready_for_review"]
assert settings2.code_review_triggers == [
CodeReviewTrigger.ON_NEW_COMMIT,
CodeReviewTrigger.ON_COMMAND_PHRASE,
]

for repo_data in response.data:
assert "settings" in repo_data
Expand All @@ -55,7 +59,7 @@ def test_bulk_update_existing_settings(self) -> None:
self.create_repository_settings(
repository=repo1,
enabled_code_review=False,
code_review_triggers=[CodeReviewTrigger.ON_COMMAND_PHRASE],
code_review_triggers=[CodeReviewTrigger.ON_READY_FOR_REVIEW],
)
self.create_repository_settings(
repository=repo2,
Expand All @@ -77,11 +81,17 @@ def test_bulk_update_existing_settings(self) -> None:

settings1 = RepositorySettings.objects.get(repository=repo1)
assert settings1.enabled_code_review is True
assert settings1.code_review_triggers == ["on_new_commit"]
assert settings1.code_review_triggers == [
CodeReviewTrigger.ON_NEW_COMMIT,
CodeReviewTrigger.ON_COMMAND_PHRASE,
]

settings2 = RepositorySettings.objects.get(repository=repo2)
assert settings2.enabled_code_review is True
assert settings2.code_review_triggers == ["on_new_commit"]
assert settings2.code_review_triggers == [
CodeReviewTrigger.ON_NEW_COMMIT,
CodeReviewTrigger.ON_COMMAND_PHRASE,
]

def test_repository_not_found(self) -> None:
response = self.client.put(
Expand Down Expand Up @@ -168,7 +178,7 @@ def test_partial_bulk_update_enabled_code_review_preserves_triggers(self) -> Non
repository=repo1,
enabled_code_review=False,
code_review_triggers=[
CodeReviewTrigger.ON_COMMAND_PHRASE,
CodeReviewTrigger.ON_READY_FOR_REVIEW,
CodeReviewTrigger.ON_NEW_COMMIT,
],
)
Expand All @@ -191,11 +201,15 @@ def test_partial_bulk_update_enabled_code_review_preserves_triggers(self) -> Non

settings1 = RepositorySettings.objects.get(repository=repo1)
assert settings1.enabled_code_review is True
assert settings1.code_review_triggers == ["on_command_phrase", "on_new_commit"]
assert settings1.code_review_triggers == [
CodeReviewTrigger.ON_READY_FOR_REVIEW,
CodeReviewTrigger.ON_NEW_COMMIT,
CodeReviewTrigger.ON_COMMAND_PHRASE,
]

settings2 = RepositorySettings.objects.get(repository=repo2)
assert settings2.enabled_code_review is True
assert settings2.code_review_triggers == []
assert settings2.code_review_triggers == [CodeReviewTrigger.ON_COMMAND_PHRASE]

def test_partial_bulk_update_code_review_triggers_preserves_enabled(self) -> None:
repo1 = Repository.objects.create(name="repo1", organization_id=self.org.id)
Expand All @@ -204,7 +218,7 @@ def test_partial_bulk_update_code_review_triggers_preserves_enabled(self) -> Non
self.create_repository_settings(
repository=repo1,
enabled_code_review=True,
code_review_triggers=[CodeReviewTrigger.ON_COMMAND_PHRASE],
code_review_triggers=[CodeReviewTrigger.ON_NEW_COMMIT],
)
self.create_repository_settings(
repository=repo2,
Expand All @@ -228,13 +242,21 @@ def test_partial_bulk_update_code_review_triggers_preserves_enabled(self) -> Non

settings1 = RepositorySettings.objects.get(repository=repo1)
assert settings1.enabled_code_review is True
assert settings1.code_review_triggers == ["on_new_commit", "on_ready_for_review"]
assert settings1.code_review_triggers == [
CodeReviewTrigger.ON_NEW_COMMIT,
CodeReviewTrigger.ON_READY_FOR_REVIEW,
CodeReviewTrigger.ON_COMMAND_PHRASE,
]

settings2 = RepositorySettings.objects.get(repository=repo2)
assert settings2.enabled_code_review is False
assert settings2.code_review_triggers == ["on_new_commit", "on_ready_for_review"]
assert settings2.code_review_triggers == [
CodeReviewTrigger.ON_NEW_COMMIT,
CodeReviewTrigger.ON_READY_FOR_REVIEW,
CodeReviewTrigger.ON_COMMAND_PHRASE,
]

def test_partial_bulk_create_enabled_code_review_uses_default(self) -> None:
def test_partial_bulk_create_code_review_triggers_uses_default(self) -> None:
repo1 = Repository.objects.create(name="repo1", organization_id=self.org.id)
repo2 = Repository.objects.create(name="repo2", organization_id=self.org.id)

Expand All @@ -251,13 +273,13 @@ def test_partial_bulk_create_enabled_code_review_uses_default(self) -> None:

settings1 = RepositorySettings.objects.get(repository=repo1)
assert settings1.enabled_code_review is True
assert settings1.code_review_triggers == []
assert settings1.code_review_triggers == [CodeReviewTrigger.ON_COMMAND_PHRASE]

settings2 = RepositorySettings.objects.get(repository=repo2)
assert settings2.enabled_code_review is True
assert settings2.code_review_triggers == []
assert settings2.code_review_triggers == [CodeReviewTrigger.ON_COMMAND_PHRASE]

def test_partial_bulk_create_code_review_triggers_uses_default(self) -> None:
def test_partial_bulk_create_enabled_code_review_uses_default(self) -> None:
repo1 = Repository.objects.create(name="repo1", organization_id=self.org.id)
repo2 = Repository.objects.create(name="repo2", organization_id=self.org.id)

Expand All @@ -277,11 +299,19 @@ def test_partial_bulk_create_code_review_triggers_uses_default(self) -> None:

settings1 = RepositorySettings.objects.get(repository=repo1)
assert settings1.enabled_code_review is False
assert settings1.code_review_triggers == ["on_new_commit", "on_ready_for_review"]
assert settings1.code_review_triggers == [
CodeReviewTrigger.ON_NEW_COMMIT,
CodeReviewTrigger.ON_READY_FOR_REVIEW,
CodeReviewTrigger.ON_COMMAND_PHRASE,
]

settings2 = RepositorySettings.objects.get(repository=repo2)
assert settings2.enabled_code_review is False
assert settings2.code_review_triggers == ["on_new_commit", "on_ready_for_review"]
assert settings2.code_review_triggers == [
CodeReviewTrigger.ON_NEW_COMMIT,
CodeReviewTrigger.ON_READY_FOR_REVIEW,
CodeReviewTrigger.ON_COMMAND_PHRASE,
]

def test_audit_log_created_on_update(self) -> None:
repo1 = Repository.objects.create(name="repo1", organization_id=self.org.id)
Expand Down Expand Up @@ -309,4 +339,7 @@ def test_audit_log_created_on_update(self) -> None:
assert audit_log.data["repository_count"] == 2
assert set(audit_log.data["repository_ids"]) == {repo1.id, repo2.id}
assert audit_log.data["enabled_code_review"] is True
assert audit_log.data["code_review_triggers"] == [CodeReviewTrigger.ON_NEW_COMMIT]
assert audit_log.data["code_review_triggers"] == [
CodeReviewTrigger.ON_NEW_COMMIT,
CodeReviewTrigger.ON_COMMAND_PHRASE,
]
Loading
Loading