Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
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: 2 additions & 0 deletions migrations_lockfile.txt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ notifications: 0002_notificationmessage_jsonfield

preprod: 0017_break_commit_fks

prevent: 0001_initial_prevent_ai_configuration

releases: 0001_release_models

replays: 0006_add_bulk_delete_job
Expand Down
6 changes: 0 additions & 6 deletions src/sentry/api/serializers/models/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@
from sentry.models.team import Team, TeamStatus
from sentry.organizations.absolute_url import generate_organization_url
from sentry.organizations.services.organization import RpcOrganizationSummary
from sentry.types.prevent_config import PREVENT_AI_CONFIG_GITHUB_DEFAULT
from sentry.users.models.user import User
from sentry.users.services.user.model import RpcUser
from sentry.users.services.user.service import user_service
Expand Down Expand Up @@ -555,7 +554,6 @@ class DetailedOrganizationSerializerResponse(_DetailedOrganizationSerializerResp
streamlineOnly: bool
defaultAutofixAutomationTuning: str
defaultSeerScannerAutomation: bool
preventAiConfigGithub: dict[str, Any]
enablePrReviewTestGeneration: bool
enableSeerEnhancedAlerts: bool
enableSeerCoding: bool
Expand Down Expand Up @@ -697,10 +695,6 @@ def serialize( # type: ignore[override]
"sentry:default_seer_scanner_automation",
DEFAULT_SEER_SCANNER_AUTOMATION_DEFAULT,
),
"preventAiConfigGithub": obj.get_option(
"sentry:prevent_ai_config_github",
PREVENT_AI_CONFIG_GITHUB_DEFAULT,
),
"enablePrReviewTestGeneration": bool(
obj.get_option(
"sentry:enable_pr_review_test_generation",
Expand Down
18 changes: 12 additions & 6 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,7 @@
from sentry.prevent.endpoints.organization_github_repos import (
OrganizationPreventGitHubReposEndpoint,
)
from sentry.prevent.endpoints.pr_review_config import OrganizationPreventGitHubConfigEndpoint
from sentry.releases.endpoints.organization_release_assemble import (
OrganizationReleaseAssembleEndpoint,
)
Expand Down Expand Up @@ -1160,6 +1161,17 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
SyncReposEndpoint.as_view(),
name="sentry-api-0-repositories-sync",
),
# Prevent AI endpoints
re_path(
r"^ai/github/config/(?P<git_organization_id>[^/]+)/$",
OrganizationPreventGitHubConfigEndpoint.as_view(),
name="sentry-api-0-organization-prevent-github-config",
),
re_path(
r"^ai/github/repos/$",
OrganizationPreventGitHubReposEndpoint.as_view(),
name="sentry-api-0-organization-prevent-github-repos",
),
]

USER_URLS = [
Expand Down Expand Up @@ -2143,12 +2155,6 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
OrganizationRepositoryCommitsEndpoint.as_view(),
name="sentry-api-0-organization-repository-commits",
),
# Prevent AI endpoints
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/prevent/github/repos/$",
OrganizationPreventGitHubReposEndpoint.as_view(),
name="sentry-api-0-organization-prevent-github-repos",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/plugins/$",
OrganizationPluginsEndpoint.as_view(),
Expand Down
9 changes: 8 additions & 1 deletion src/sentry/audit_log/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -627,7 +627,14 @@
template="disconnected detector {detector_name} from workflow {workflow_name}",
)
)

default_manager.add(
AuditLogEvent(
event_id=203,
name="PREVENT_CONFIG_EDIT",
api_name="prevent.config.edit",
template="prevent_ai.config.edit: {service} {git_organization}",
)
)
default_manager.add(
AuditLogEvent(
event_id=204,
Expand Down
1 change: 1 addition & 0 deletions src/sentry/conf/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,7 @@ def env(
"sentry.insights",
"sentry.preprod",
"sentry.releases",
"sentry.prevent",
)

# Silence internal hints from Django's system checks
Expand Down
19 changes: 0 additions & 19 deletions src/sentry/core/endpoints/organization_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from datetime import datetime, timedelta, timezone
from typing import TypedDict

from django import forms
from django.db import models, router, transaction
from django.db.models.query_utils import DeferredAttribute
from django.urls import reverse
Expand Down Expand Up @@ -104,10 +103,8 @@
OrganizationSlugCollisionException,
organization_provisioning_service,
)
from sentry.types.prevent_config import PREVENT_AI_CONFIG_GITHUB_DEFAULT, PREVENT_AI_CONFIG_SCHEMA
from sentry.users.services.user.serial import serialize_generic_user
from sentry.utils.audit import create_audit_entry
from sentry.workflow_engine.endpoints.validators.utils import validate_json_schema

ERR_DEFAULT_ORG = "You cannot remove the default organization."
ERR_NO_USER = "This request requires an authenticated user."
Expand Down Expand Up @@ -237,12 +234,6 @@
bool,
DEFAULT_SEER_SCANNER_AUTOMATION_DEFAULT,
),
(
"preventAiConfigGithub",
"sentry:prevent_ai_config_github",
dict,
PREVENT_AI_CONFIG_GITHUB_DEFAULT,
),
(
"enablePrReviewTestGeneration",
"sentry:enable_pr_review_test_generation",
Expand Down Expand Up @@ -348,7 +339,6 @@ class OrganizationSerializer(BaseOrganizationSerializer):
)
enablePrReviewTestGeneration = serializers.BooleanField(required=False)
enableSeerEnhancedAlerts = serializers.BooleanField(required=False)
preventAiConfigGithub = serializers.JSONField(required=False)
enableSeerCoding = serializers.BooleanField(required=False)
ingestThroughTrustedRelaysOnly = serializers.ChoiceField(
choices=[("enabled", "enabled"), ("disabled", "disabled")], required=False
Expand Down Expand Up @@ -459,14 +449,6 @@ def validate_samplingMode(self, value):

return value

def validate_preventAiConfigGithub(self, value):
"""Validate the structure using JSON Schema - generic error for invalid configs."""
try:
validate_json_schema(value, PREVENT_AI_CONFIG_SCHEMA)
except forms.ValidationError:
raise serializers.ValidationError("Prevent AI config option is invalid")
return value

def validate(self, attrs):
attrs = super().validate(attrs)
if attrs.get("avatarType") == "upload":
Expand Down Expand Up @@ -742,7 +724,6 @@ def create_console_platform_audit_log(
"genAIConsent",
"defaultAutofixAutomationTuning",
"defaultSeerScannerAutomation",
"preventAiConfigGithub",
"ingestThroughTrustedRelaysOnly",
"enabledConsolePlatforms",
]
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/overwatch/endpoints/overwatch_rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
)
from sentry.models.organization import Organization
from sentry.models.repository import Repository
from sentry.prevent.types.config import PREVENT_AI_CONFIG_GITHUB_DEFAULT
from sentry.silo.base import SiloMode
from sentry.types.prevent_config import PREVENT_AI_CONFIG_GITHUB_DEFAULT

logger = logging.getLogger(__name__)

Comment on lines 21 to 28
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: The RPC endpoint reads config from the old organization_options, while the new UI endpoint writes to the new PreventAIConfiguration model, with no data migration.
(Severity: Critical 0.85 | Confidence: 1.00)

🔍 Detailed Analysis

The PreventPrReviewResolvedConfigsEndpoint in overwatch_rpc.py reads configuration from the old organization.get_option storage. However, the new OrganizationPreventGitHubConfigEndpoint writes settings to the new PreventAIConfiguration model. There is no data migration to move existing settings from the old storage to the new model, nor is there any fallback logic for the read endpoint to check the new model. As a result, any configuration changes made through the new UI will be saved to the new model but will be completely ignored by the Overwatch service, which will continue to use old or default settings from the previous storage location.

💡 Suggested Fix

Update the PreventPrReviewResolvedConfigsEndpoint to read from the new PreventAIConfiguration model. Additionally, create a data migration to transfer existing configurations from organization_options to the PreventAIConfiguration table to ensure continuity for existing users.

🤖 Prompt for AI Agent
Fix this bug. In src/sentry/overwatch/endpoints/overwatch_rpc.py at lines 21-28: The
`PreventPrReviewResolvedConfigsEndpoint` in `overwatch_rpc.py` reads configuration from
the old `organization.get_option` storage. However, the new
`OrganizationPreventGitHubConfigEndpoint` writes settings to the new
`PreventAIConfiguration` model. There is no data migration to move existing settings
from the old storage to the new model, nor is there any fallback logic for the read
endpoint to check the new model. As a result, any configuration changes made through the
new UI will be saved to the new model but will be completely ignored by the Overwatch
service, which will continue to use old or default settings from the previous storage
location.

Did we get this right? 👍 / 👎 to inform future reviews.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Feature is not live, don't need to make migration.

Expand Down
90 changes: 90 additions & 0 deletions src/sentry/prevent/endpoints/pr_review_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from copy import deepcopy
from typing import Any

from jsonschema import validate
from rest_framework.request import Request
from rest_framework.response import Response

from sentry import audit_log
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import region_silo_endpoint
from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission
from sentry.models.organization import Organization
from sentry.prevent.models import PreventAIConfiguration
from sentry.prevent.types.config import ORG_CONFIG_SCHEMA, PREVENT_AI_CONFIG_GITHUB_DEFAULT


class PreventAIConfigPermission(OrganizationPermission):
scope_map = {
"GET": ["org:read", "org:write", "org:admin"],
# allow any organization member to update the PR review config
"PUT": ["org:read", "org:write", "org:admin"],
}


@region_silo_endpoint
class OrganizationPreventGitHubConfigEndpoint(OrganizationEndpoint):
"""
Get and set the GitHub PR review config for a Sentry organization
"""

owner = ApiOwner.CODECOV
publish_status = {
"GET": ApiPublishStatus.EXPERIMENTAL,
"PUT": ApiPublishStatus.EXPERIMENTAL,
}
permission_classes = (PreventAIConfigPermission,)

def get(
self, request: Request, organization: Organization, git_organization_id: str
) -> Response:
"""
Get the Prevent AI GitHub configuration for a specific git organization.
"""
response_data: dict[str, Any] = deepcopy(PREVENT_AI_CONFIG_GITHUB_DEFAULT)

config = PreventAIConfiguration.objects.filter(
organization_id=organization.id,
provider="github",
git_organization_id=git_organization_id,
).first()

if config:
response_data["github_organization"][git_organization_id] = config.data

return Response(response_data, status=200)

def put(
self, request: Request, organization: Organization, git_organization_id: str
) -> Response:
"""
Update the Prevent AI GitHub configuration for an organization.
"""
try:
validate(request.data, ORG_CONFIG_SCHEMA)
except Exception:
return Response({"detail": "Invalid config"}, status=400)

PreventAIConfiguration.objects.update_or_create(
organization_id=organization.id,
provider="github",
git_organization_id=git_organization_id,
defaults={"data": request.data},
)

self.create_audit_entry(
request=request,
organization=organization,
target_object=organization.id,
event=audit_log.get_event_id("PREVENT_CONFIG_EDIT"),
data={
"git_organization": git_organization_id,
"provider": "github",
},
)
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: Audit Log Template Mismatch Causes Incorrect Rendering

The PREVENT_CONFIG_EDIT audit log template expects a {service} placeholder, but the audit entry data in the OrganizationPreventGitHubConfigEndpoint's put method provides a "provider" key. This mismatch causes the audit log message to render incorrectly, displaying {service} literally instead of the actual provider value.

Additional Locations (1)

Fix in Cursor Fix in Web


response_data: dict[str, Any] = deepcopy(PREVENT_AI_CONFIG_GITHUB_DEFAULT)
response_data["github_organization"][git_organization_id] = request.data

return Response(response_data, status=200)
Comment on lines +87 to +90
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: A key in the default config object was renamed from github_organizations to github_organization, but the frontend code was not updated to match.
(Severity: High 0.75 | Confidence: 1.00)

🔍 Detailed Analysis

The default configuration object, PREVENT_AI_CONFIG_GITHUB_DEFAULT, has been modified, renaming the key github_organizations to github_organization. However, multiple frontend components, such as useUpdatePreventAIFeature.tsx and manageReposPanel.tsx, were not updated and still reference the old plural key github_organizations. When the frontend receives a configuration object from the backend using the new singular key, attempts to access config.github_organizations will result in undefined, breaking the UI for managing Prevent AI configurations.

💡 Suggested Fix

Update all frontend references from github_organizations to github_organization to match the new schema defined in the PREVENT_AI_CONFIG_GITHUB_DEFAULT constant. This includes updating type definitions in .tsx files and accessors in hooks and components.

🤖 Prompt for AI Agent
Fix this bug. In src/sentry/prevent/endpoints/pr_review_config.py at lines 87-90: The
default configuration object, `PREVENT_AI_CONFIG_GITHUB_DEFAULT`, has been modified,
renaming the key `github_organizations` to `github_organization`. However, multiple
frontend components, such as `useUpdatePreventAIFeature.tsx` and `manageReposPanel.tsx`,
were not updated and still reference the old plural key `github_organizations`. When the
frontend receives a configuration object from the backend using the new singular key,
attempts to access `config.github_organizations` will result in `undefined`, breaking
the UI for managing Prevent AI configurations.

Did we get this right? 👍 / 👎 to inform future reviews.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Frontend code is not live yet, it will be updated next.

Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Generated by Django 5.2.1 on 2025-10-22 21:03

from django.db import migrations, models

import sentry.db.models.fields.bounded
from sentry.new_migrations.migrations import CheckedMigration


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 = False

initial = True

dependencies = []

operations = [
migrations.CreateModel(
name="PreventAIConfiguration",
fields=[
(
"id",
sentry.db.models.fields.bounded.BoundedBigAutoField(
primary_key=True, serialize=False
),
),
("date_updated", models.DateTimeField(auto_now=True)),
("date_added", models.DateTimeField(auto_now_add=True)),
(
"organization_id",
sentry.db.models.fields.bounded.BoundedBigIntegerField(db_index=True),
),
("provider", models.CharField(max_length=64)),
("git_organization_id", models.CharField(max_length=255)),
("data", models.JSONField(default=dict)),
],
options={
"db_table": "sentry_preventaiconfiguration",
"unique_together": {("organization_id", "provider", "git_organization_id")},
},
),
]
Empty file.
29 changes: 29 additions & 0 deletions src/sentry/prevent/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from __future__ import annotations

from django.db import models

from sentry.backup.scopes import RelocationScope
from sentry.db.models.base import DefaultFieldsModel, region_silo_model
from sentry.db.models.fields.bounded import BoundedBigIntegerField


@region_silo_model
class PreventAIConfiguration(DefaultFieldsModel):
"""
Configuration for Prevent AI features for git organizations per Sentry organization.
This model stores configurations for Prevent AI functionality,
allowing different settings for different git organizations and repos.
"""

__relocation_scope__ = RelocationScope.Excluded

organization_id = BoundedBigIntegerField(db_index=True)
Copy link
Member

Choose a reason for hiding this comment

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

Let's use a proper foreign key here

Copy link
Member

Choose a reason for hiding this comment

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

On a similar note, should we consider which silo this data should live in? I could see a case being made for either region or control, depending on where this is used the most.

provider = models.CharField(max_length=64)
git_organization_id = models.CharField(max_length=255)
Copy link
Member

Choose a reason for hiding this comment

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

Can we link to the integration instead?

data = models.JSONField(default=dict)
Copy link
Member

Choose a reason for hiding this comment

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

Can we use more explicit fields here, instead of a json blob? What kind of data are we putting here?


class Meta:
app_label = "prevent"
db_table = "sentry_preventaiconfiguration"
Copy link
Member

Choose a reason for hiding this comment

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

Since this is in your own app, you can feel free to call this prevent_*

unique_together = (("organization_id", "provider", "git_organization_id"),)
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -78,31 +78,14 @@
"additionalProperties": False,
}

_ORG_CONFIG_SCHEMA = {
ORG_CONFIG_SCHEMA = {
"type": "object",
"properties": {
"org_defaults": _ORG_DEFAULTS_SCHEMA,
"repo_overrides": _REPO_OVERRIDES_SCHEMA,
},
"required": ["org_defaults", "repo_overrides"],
"additionalProperties": False,
}


PREVENT_AI_CONFIG_SCHEMA = {
"type": "object",
"properties": {
"schema_version": {"type": "string", "enum": ["v1"]},
"default_org_config": _ORG_CONFIG_SCHEMA,
"github_organizations": {
"type": "object",
"patternProperties": {
".*": _ORG_CONFIG_SCHEMA,
},
"additionalProperties": False,
},
},
"required": ["schema_version", "default_org_config", "github_organizations"],
"required": ["org_defaults", "repo_overrides"],
"additionalProperties": False,
}

Expand Down Expand Up @@ -136,5 +119,5 @@
},
"repo_overrides": {},
},
"github_organizations": {},
"github_organization": {},
}
Loading
Loading