diff --git a/src/sentry/api/endpoints/group_integration_details.py b/src/sentry/api/endpoints/group_integration_details.py index 7fe4ba757795c5..89272754f05ceb 100644 --- a/src/sentry/api/endpoints/group_integration_details.py +++ b/src/sentry/api/endpoints/group_integration_details.py @@ -270,13 +270,7 @@ def delete(self, request, group, integration_id): return Response(status=404) with transaction.atomic(): - GroupLink.objects.filter( - group_id=group.id, - project_id=group.project_id, - linked_type=GroupLink.LinkedType.issue, - linked_id=external_issue_id, - relationship=GroupLink.Relationship.references, - ).delete() + GroupLink.objects.get_group_issues(group, external_issue_id).delete() # check if other groups reference this external issue # and delete if not diff --git a/src/sentry/api/serializers/models/integration.py b/src/sentry/api/serializers/models/integration.py index 883583f7982061..456e575678c778 100644 --- a/src/sentry/api/serializers/models/integration.py +++ b/src/sentry/api/serializers/models/integration.py @@ -206,12 +206,9 @@ def get_attrs( self, item_list: Sequence[Integration], user: User, **kwargs: Any ) -> MutableMapping[Integration, MutableMapping[str, Any]]: external_issues = ExternalIssue.objects.filter( - id__in=GroupLink.objects.filter( - group_id=self.group.id, - project_id=self.group.project_id, - linked_type=GroupLink.LinkedType.issue, - relationship=GroupLink.Relationship.references, - ).values_list("linked_id", flat=True), + id__in=GroupLink.objects.get_group_issues(self.group).values_list( + "linked_id", flat=True + ), integration_id__in=[i.id for i in item_list], ) diff --git a/src/sentry/integrations/slack/message_builder/issues.py b/src/sentry/integrations/slack/message_builder/issues.py index b7fbc1ad8e63c7..33d931387aea11 100644 --- a/src/sentry/integrations/slack/message_builder/issues.py +++ b/src/sentry/integrations/slack/message_builder/issues.py @@ -1,5 +1,16 @@ import re -from typing import Any, List, Mapping, Optional, Sequence, Set, Tuple, Union +from typing import ( + Any, + Callable, + List, + Mapping, + MutableMapping, + Optional, + Sequence, + Set, + Tuple, + Union, +) from django.core.cache import cache @@ -10,10 +21,8 @@ from sentry.models import ( ActorTuple, Group, - GroupAssignee, GroupStatus, Identity, - OrganizationMember, Project, ReleaseProject, Rule, @@ -28,6 +37,13 @@ from ..utils import build_notification_footer +STATUSES = {"resolved": "resolved", "ignored": "ignored", "unresolved": "re-opened"} + + +def format_actor_options(actors: Sequence[Union["Team", "User"]]) -> Sequence[Mapping[str, str]]: + sort_func: Callable[[Mapping[str, str]], Any] = lambda actor: actor["text"] + return sorted((format_actor_option(actor) for actor in actors), key=sort_func) + def format_actor_option(actor: Union["Team", "User"]) -> Mapping[str, str]: if isinstance(actor, User): @@ -38,38 +54,6 @@ def format_actor_option(actor: Union["Team", "User"]) -> Mapping[str, str]: raise NotImplementedError -def get_member_assignees(group: Group) -> Sequence[Mapping[str, str]]: - queryset = ( - OrganizationMember.objects.filter( - user__is_active=True, - organization=group.organization, - teams__in=group.project.teams.all(), - ) - .distinct() - .select_related("user") - ) - - members = sorted(queryset, key=lambda u: u.user.get_display_name()) # type: ignore - - return [format_actor_option(u.user) for u in members] - - -def get_team_assignees(group: Group) -> Sequence[Mapping[str, str]]: - return [format_actor_option(u) for u in group.project.teams.all()] - - -def get_assignee(group: Group) -> Optional[Mapping[str, str]]: - try: - assigned_actor = GroupAssignee.objects.get(group=group).assigned_actor() - except GroupAssignee.DoesNotExist: - return None - - try: - return format_actor_option(assigned_actor.resolve()) - except assigned_actor.type.DoesNotExist: - return None - - def build_attachment_title(obj: Union[Group, Event]) -> str: ev_metadata = obj.get_event_metadata() ev_type = obj.get_event_type() @@ -124,22 +108,18 @@ def build_assigned_text(identity: Identity, assignee: str) -> Optional[str]: return f"*Issue assigned to {assignee_text} by <@{identity.external_id}>*" -def build_action_text(group: Group, identity: Identity, action: Mapping[str, Any]) -> Optional[str]: +def build_action_text(identity: Identity, action: Mapping[str, Any]) -> Optional[str]: if action["name"] == "assign": return build_assigned_text(identity, action["selected_options"][0]["value"]) - statuses = {"resolved": "resolved", "ignored": "ignored", "unresolved": "re-opened"} - # Resolve actions have additional 'parameters' after ':' status = action["value"].split(":", 1)[0] # Action has no valid action text, ignore - if status not in statuses: + if status not in STATUSES: return None - return "*Issue {status} by <@{user_id}>*".format( - status=statuses[status], user_id=identity.external_id - ) + return f"*Issue {STATUSES[status]} by <@{identity.external_id}>*" def build_rule_url(rule: Any, group: Group, project: Project) -> str: @@ -186,6 +166,50 @@ def build_tag_fields( return fields +def get_option_groups(group: Group) -> Sequence[Mapping[str, Any]]: + members = User.objects.get_from_group(group).distinct() + teams = group.project.teams.all() + + option_groups = [] + if teams: + option_groups.append({"text": "Teams", "options": format_actor_options(teams)}) + + if members: + option_groups.append({"text": "People", "options": format_actor_options(members)}) + + return option_groups + + +def has_releases(project: Project) -> bool: + cache_key = f"has_releases:2:{project.id}" + has_releases_option: Optional[bool] = cache.get(cache_key) + if has_releases_option is None: + has_releases_option = ReleaseProject.objects.filter(project_id=project.id).exists() + if has_releases_option: + cache.set(cache_key, True, 3600) + else: + cache.set(cache_key, False, 60) + return has_releases_option + + +def get_action_text( + text: str, + actions: Sequence[Any], + identity: Optional[Identity] = None, +) -> str: + return ( + text + + "\n" + + "\n".join( + [ + action_text + for action_text in [build_action_text(identity, action) for action in actions] + if action_text + ] + ) + ) + + def build_actions( group: Group, project: Project, @@ -194,73 +218,49 @@ def build_actions( actions: Optional[Sequence[Any]] = None, identity: Optional[Identity] = None, ) -> Tuple[Sequence[Any], str, str]: - """ - Having actions means a button will be shown on the Slack message e.g. ignore, resolve, assign - """ - status = group.get_status() - members = get_member_assignees(group) - teams = get_team_assignees(group) - - if actions is None: - actions = [] - - assignee = get_assignee(group) + """Having actions means a button will be shown on the Slack message e.g. ignore, resolve, assign.""" + if actions: + text += get_action_text(text, actions, identity) + return [], text, "_actioned_issue" - resolve_button = { - "name": "resolve_dialog", - "value": "resolve_dialog", + ASSIGN_BUTTON: MutableMapping[str, Any] = { + "name": "assign", + "text": "Select Assignee...", + "type": "select", + } + IGNORE_BUTTON = { + "name": "status", "type": "button", + "text": "Ignore", + "value": "ignored", + } + RESOLVE_BUTTON = { + "name": "resolve_dialog", "text": "Resolve...", + "type": "button", + "value": "resolve_dialog", } - ignore_button = {"name": "status", "value": "ignored", "type": "button", "text": "Ignore"} - - cache_key = f"has_releases:2:{project.id}" - has_releases = cache.get(cache_key) - if has_releases is None: - has_releases = ReleaseProject.objects.filter(project_id=project.id).exists() - if has_releases: - cache.set(cache_key, True, 3600) - else: - cache.set(cache_key, False, 60) + status = group.get_status() - if not has_releases: - resolve_button.update({"name": "status", "text": "Resolve", "value": "resolved"}) + if not has_releases(project): + RESOLVE_BUTTON.update({"name": "status", "text": "Resolve", "value": "resolved"}) if status == GroupStatus.RESOLVED: - resolve_button.update({"name": "status", "text": "Unresolve", "value": "unresolved"}) + RESOLVE_BUTTON.update({"name": "status", "text": "Unresolve", "value": "unresolved"}) if status == GroupStatus.IGNORED: - ignore_button.update({"text": "Stop Ignoring", "value": "unresolved"}) - - option_groups = [] + IGNORE_BUTTON.update({"text": "Stop Ignoring", "value": "unresolved"}) - if teams: - option_groups.append({"text": "Teams", "options": teams}) - - if members: - option_groups.append({"text": "People", "options": members}) - - payload_actions = [ - resolve_button, - ignore_button, + assignee = group.get_assignee() + ASSIGN_BUTTON.update( { - "name": "assign", - "text": "Select Assignee...", - "type": "select", - "selected_options": [assignee], - "option_groups": option_groups, - }, - ] - - if actions: - action_texts = [_f for _f in [build_action_text(group, identity, a) for a in actions] if _f] - text += "\n" + "\n".join(action_texts) - - color = "_actioned_issue" - payload_actions = [] + "selected_options": format_actor_options([assignee]) if assignee else [], + "option_groups": get_option_groups(group), + } + ) - return payload_actions, text, color + return [RESOLVE_BUTTON, IGNORE_BUTTON, ASSIGN_BUTTON], text, color def get_title_link( diff --git a/src/sentry/integrations/utils/sync.py b/src/sentry/integrations/utils/sync.py index 9ec0ba7ae013a3..db5cafe0a38502 100644 --- a/src/sentry/integrations/utils/sync.py +++ b/src/sentry/integrations/utils/sync.py @@ -1,5 +1,7 @@ +from __future__ import annotations + import logging -from typing import TYPE_CHECKING, Mapping, Optional, Sequence +from typing import TYPE_CHECKING, Mapping, Sequence from sentry import features from sentry.models import GroupAssignee @@ -10,10 +12,10 @@ def where_should_sync( - integration: "Integration", + integration: Integration, key: str, - organization_id: Optional[int] = None, -) -> Sequence["Organization"]: + organization_id: int | None = None, +) -> Sequence[Organization]: """ Given an integration, get the list of organizations where the sync type in `key` is enabled. If an optional `organization_id` is passed, then only @@ -31,7 +33,7 @@ def where_should_sync( ] -def get_user_id(projects_by_user: Mapping[int, Sequence[int]], group: "Group") -> Optional[int]: +def get_user_id(projects_by_user: Mapping[int, Sequence[int]], group: Group) -> int | None: user_ids = [ user_id for user_id, project_ids in projects_by_user.items() @@ -44,11 +46,11 @@ def get_user_id(projects_by_user: Mapping[int, Sequence[int]], group: "Group") - def sync_group_assignee_inbound( - integration: "Integration", - email: Optional[str], + integration: Integration, + email: str | None, external_issue_key: str, assign: bool = True, -) -> Sequence["Group"]: +) -> Sequence[Group]: """ Given an integration, user email address and an external issue key, assign linked groups to matching users. Checks project membership. @@ -59,10 +61,10 @@ def sync_group_assignee_inbound( logger = logging.getLogger(f"sentry.integrations.{integration.provider}") orgs_with_sync_enabled = where_should_sync(integration, "inbound_assignee") - affected_groups = ( - Group.objects.get_groups_by_external_issue(integration, external_issue_key) - .filter(project__organization__in=orgs_with_sync_enabled) - .select_related("project") + affected_groups = Group.objects.get_groups_by_external_issue( + integration, + orgs_with_sync_enabled, + external_issue_key, ) if not affected_groups: return [] @@ -79,8 +81,9 @@ def sync_group_assignee_inbound( groups_assigned = [] for group in affected_groups: user_id = get_user_id(projects_by_user, group) - if user_id: - GroupAssignee.objects.assign(group, users_by_id.get(user_id)) + user = users_by_id.get(user_id) + if user: + GroupAssignee.objects.assign(group, user) groups_assigned.append(group) else: logger.info( @@ -94,9 +97,7 @@ def sync_group_assignee_inbound( return groups_assigned -def sync_group_assignee_outbound( - group: "Group", user_id: Optional[int], assign: bool = True -) -> None: +def sync_group_assignee_outbound(group: Group, user_id: int | None, assign: bool = True) -> None: from sentry.models import GroupLink external_issue_ids = GroupLink.objects.filter( diff --git a/src/sentry/models/externalissue.py b/src/sentry/models/externalissue.py index a0baf3c6f0c652..56698ef4937457 100644 --- a/src/sentry/models/externalissue.py +++ b/src/sentry/models/externalissue.py @@ -1,9 +1,31 @@ -from typing import Any +from __future__ import annotations + +from typing import TYPE_CHECKING, Any from django.db import models +from django.db.models import QuerySet from django.utils import timezone -from sentry.db.models import BoundedPositiveIntegerField, JSONField, Model, sane_repr +from sentry.db.models import BaseManager, BoundedPositiveIntegerField, JSONField, Model, sane_repr + +if TYPE_CHECKING: + from sentry.models import Integration + + +class ExternalIssueManager(BaseManager): + def get_for_integration( + self, integration: Integration, external_issue_key: str | None = None + ) -> QuerySet: + """TODO(mgaeta): Migrate the model to use FlexibleForeignKey.""" + kwargs = dict( + integration_id=integration.id, + organization_id__in={_.id for _ in integration.organizations.all()}, + ) + + if external_issue_key is not None: + kwargs["key"] = external_issue_key + + return self.filter(**kwargs) class ExternalIssue(Model): @@ -17,6 +39,8 @@ class ExternalIssue(Model): description = models.TextField(null=True) metadata = JSONField(null=True) + objects = ExternalIssueManager() + class Meta: app_label = "sentry" db_table = "sentry_externalissue" diff --git a/src/sentry/models/group.py b/src/sentry/models/group.py index 59ee8e22917022..cf250353ef1370 100644 --- a/src/sentry/models/group.py +++ b/src/sentry/models/group.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging import math import re @@ -7,7 +9,7 @@ from enum import Enum from functools import reduce from operator import or_ -from typing import TYPE_CHECKING, List, Mapping, Optional, Sequence, Set, Union +from typing import TYPE_CHECKING, Mapping, Sequence from django.core.cache import cache from django.db import models @@ -36,7 +38,7 @@ from sentry.utils.strings import strip, truncatechars if TYPE_CHECKING: - from sentry.models import Integration, Team, User + from sentry.models import Integration, Organization, Team, User logger = logging.getLogger(__name__) @@ -180,7 +182,7 @@ class EventOrdering(Enum): def get_oldest_or_latest_event_for_environments( ordering, environments=(), issue_id=None, project_id=None -) -> Optional[Event]: +) -> Event | None: conditions = [] if len(environments) > 0: @@ -207,7 +209,7 @@ class GroupManager(BaseManager): def by_qualified_short_id(self, organization_id: int, short_id: str): return self.by_qualified_short_id_bulk(organization_id, [short_id])[0] - def by_qualified_short_id_bulk(self, organization_id: int, short_ids: List[str]): + def by_qualified_short_id_bulk(self, organization_id: int, short_ids: list[str]): short_ids = [parse_short_id(short_id) for short_id in short_ids] if not short_ids or any(short_id is None for short_id in short_ids): raise Group.DoesNotExist() @@ -224,7 +226,7 @@ def by_qualified_short_id_bulk(self, organization_id: int, short_ids: List[str]) ], ) - groups: List[Group] = list( + groups: list[Group] = list( Group.objects.exclude( status__in=[ GroupStatus.PENDING_DELETION, @@ -233,7 +235,7 @@ def by_qualified_short_id_bulk(self, organization_id: int, short_ids: List[str]) ] ).filter(short_id_lookup, project__organization=organization_id) ) - group_lookup: Set[int] = {group.short_id for group in groups} + group_lookup: set[int] = {group.short_id for group in groups} for short_id in short_ids: if short_id.short_id not in group_lookup: raise Group.DoesNotExist() @@ -288,24 +290,28 @@ def filter_by_event_id(self, project_ids, event_id): def get_groups_by_external_issue( self, - integration: "Integration", + integration: Integration, + organizations: Sequence[Organization], external_issue_key: str, ) -> QuerySet: from sentry.models import ExternalIssue, GroupLink + external_issue_subquery = ExternalIssue.objects.get_for_integration( + integration, external_issue_key + ).values_list("id", flat=True) + + group_link_subquery = GroupLink.objects.filter( + linked_id__in=external_issue_subquery + ).values_list("group_id", flat=True) + return self.filter( - id__in=GroupLink.objects.filter( - linked_id__in=ExternalIssue.objects.filter( - key=external_issue_key, - integration_id=integration.id, - organization_id__in=integration.organizations.values_list("id", flat=True), - ).values_list("id", flat=True) - ).values_list("group_id", flat=True), - project__organization_id__in=integration.organizations.values_list("id", flat=True), - ) + id__in=group_link_subquery, + project__organization__in=organizations, + project__organization__organizationintegration__integration=integration, + ).select_related("project") def update_group_status( - self, groups: Sequence["Group"], status: GroupStatus, activity_type: ActivityType + self, groups: Sequence[Group], status: GroupStatus, activity_type: ActivityType ) -> None: """For each groups, update status to `status` and create an Activity.""" from sentry.models import Activity @@ -318,7 +324,7 @@ def update_group_status( Activity.objects.create_group_activity(group, activity_type) record_group_history_from_activity_type(group, activity_type) - def from_share_id(self, share_id: str) -> "Group": + def from_share_id(self, share_id: str) -> Group: if not share_id or len(share_id) != 32: raise Group.DoesNotExist @@ -417,9 +423,9 @@ def save(self, *args, **kwargs): def get_absolute_url( self, - params: Optional[Mapping[str, str]] = None, - event_id: Optional[int] = None, - organization_slug: Optional[str] = None, + params: Mapping[str, str] | None = None, + event_id: int | None = None, + organization_slug: str | None = None, ) -> str: # Built manually in preference to django.urls.reverse, # because reverse has a measured performance impact. @@ -489,7 +495,7 @@ def get_share_id(self): def get_score(self): return type(self).calculate_score(self.times_seen, self.last_seen) - def get_latest_event(self) -> Optional[Event]: + def get_latest_event(self) -> Event | None: if not hasattr(self, "_latest_event"): self._latest_event = self.get_latest_event_for_environments() @@ -612,7 +618,7 @@ def issues_mapping(group_ids, project_ids, organization): ) } - def get_assignee(self) -> Optional[Union["Team", "User"]]: + def get_assignee(self) -> Team | User | None: from sentry.models import GroupAssignee try: diff --git a/src/sentry/models/grouplink.py b/src/sentry/models/grouplink.py index 8df3946b34ef0d..15e7166ddddb46 100644 --- a/src/sentry/models/grouplink.py +++ b/src/sentry/models/grouplink.py @@ -1,8 +1,14 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from django.db import models +from django.db.models import QuerySet from django.utils import timezone from django.utils.translation import ugettext_lazy as _ from sentry.db.models import ( + BaseManager, BoundedBigIntegerField, BoundedPositiveIntegerField, JSONField, @@ -10,6 +16,24 @@ sane_repr, ) +if TYPE_CHECKING: + from sentry.models import Group + + +class GroupLinkManager(BaseManager): + def get_group_issues(self, group: Group, external_issue_id: str | None = None) -> QuerySet: + """TODO(mgaeta): Migrate the model to use FlexibleForeignKey.""" + kwargs = dict( + group_id=group.id, + project_id=group.project_id, + linked_type=GroupLink.LinkedType.issue, + relationship=GroupLink.Relationship.references, + ) + + if external_issue_id is not None: + kwargs["linked_id"] = external_issue_id + return self.filter(**kwargs) + class GroupLink(Model): """ @@ -47,6 +71,8 @@ class LinkedType: data = JSONField() datetime = models.DateTimeField(default=timezone.now, db_index=True) + objects = GroupLinkManager() + class Meta: app_label = "sentry" db_table = "sentry_grouplink" diff --git a/src/sentry/tasks/integrations/sync_status_inbound.py b/src/sentry/tasks/integrations/sync_status_inbound.py index 6213b2a1ab63a7..1d638ea2a33cb3 100644 --- a/src/sentry/tasks/integrations/sync_status_inbound.py +++ b/src/sentry/tasks/integrations/sync_status_inbound.py @@ -1,6 +1,6 @@ from typing import Any, Mapping -from sentry.models import Group, GroupStatus, Integration +from sentry.models import Group, GroupStatus, Integration, Organization from sentry.tasks.base import instrumented_task, retry, track_group_async_operation from sentry.types.activity import ActivityType @@ -19,12 +19,10 @@ def sync_status_inbound( from sentry.integrations.issues import ResolveSyncAction integration = Integration.objects.get(id=integration_id) - affected_groups = list( - Group.objects.get_groups_by_external_issue(integration, issue_key) - .filter(project__organization_id=organization_id) - .select_related("project") + organizations = Organization.objects.filter(id=organization_id) + affected_groups = Group.objects.get_groups_by_external_issue( + integration, organizations, issue_key ) - if not affected_groups: return diff --git a/tests/sentry/integrations/slack/test_message_builder.py b/tests/sentry/integrations/slack/test_message_builder.py index d5ced44a42aeee..8bfb7c0d007203 100644 --- a/tests/sentry/integrations/slack/test_message_builder.py +++ b/tests/sentry/integrations/slack/test_message_builder.py @@ -2,8 +2,8 @@ from sentry.incidents.logic import CRITICAL_TRIGGER_LABEL from sentry.integrations.slack.message_builder import LEVEL_TO_COLOR -from sentry.integrations.slack.message_builder.incidents import build_incident_attachment -from sentry.integrations.slack.message_builder.issues import build_group_attachment +from sentry.integrations.slack.message_builder.incidents import SlackIncidentsMessageBuilder +from sentry.integrations.slack.message_builder.issues import SlackIssuesMessageBuilder from sentry.testutils import TestCase from sentry.utils.assets import get_asset_url from sentry.utils.dates import to_timestamp @@ -25,7 +25,7 @@ def test_simple(self): to_timestamp(incident.date_started), "{date_pretty}", "{time}" ) ) - assert build_incident_attachment(action, incident) == { + assert SlackIncidentsMessageBuilder(incident, action).build() == { "fallback": title, "title": title, "title_link": absolute_uri( @@ -50,7 +50,9 @@ def test_metric_value(self): logo_url = absolute_uri(get_asset_url("sentry", "images/sentry-email-avatar.png")) alert_rule = self.create_alert_rule() incident = self.create_incident(alert_rule=alert_rule, status=2) - title = f"Critical: {alert_rule.name}" # This test will use the action/method and not the incident to build status + + # This test will use the action/method and not the incident to build status + title = f"Critical: {alert_rule.name}" metric_value = 5000 trigger = self.create_alert_rule_trigger(alert_rule, CRITICAL_TRIGGER_LABEL, 100) action = self.create_alert_rule_trigger_action( @@ -62,9 +64,9 @@ def test_metric_value(self): ) ) # This should fail because it pulls status from `action` instead of `incident` - assert build_incident_attachment( - action, incident, metric_value=metric_value, method="fire" - ) == { + assert SlackIncidentsMessageBuilder( + incident, action, metric_value=metric_value, method="fire" + ).build() == { "fallback": title, "title": title, "title_link": absolute_uri( @@ -95,7 +97,7 @@ def test_build_group_attachment(self): self.create_member(user=self.user, organization=self.org, role="owner", teams=[self.team]) group = self.create_group(project=self.project) ts = group.last_seen - assert build_group_attachment(group) == { + assert SlackIssuesMessageBuilder(group).build() == { "text": "", "color": "#E03E2F", "actions": [ @@ -123,7 +125,7 @@ def test_build_group_attachment(self): }, ], "text": "Select Assignee...", - "selected_options": [None], + "selected_options": [], "type": "select", "name": "assign", }, @@ -142,7 +144,7 @@ def test_build_group_attachment(self): } event = self.store_event(data={}, project_id=self.project.id) ts = event.datetime - assert build_group_attachment(group, event) == { + assert SlackIssuesMessageBuilder(group, event).build() == { "color": "#E03E2F", "text": "", "actions": [ @@ -170,7 +172,7 @@ def test_build_group_attachment(self): }, ], "text": "Select Assignee...", - "selected_options": [None], + "selected_options": [], "type": "select", "name": "assign", }, @@ -188,7 +190,7 @@ def test_build_group_attachment(self): "footer_icon": "http://testserver/_static/{version}/sentry/images/sentry-email-avatar.png", } - assert build_group_attachment(group, event, link_to_event=True) == { + assert SlackIssuesMessageBuilder(group, event, link_to_event=True).build() == { "color": "#E03E2F", "text": "", "actions": [ @@ -216,7 +218,7 @@ def test_build_group_attachment(self): }, ], "text": "Select Assignee...", - "selected_options": [None], + "selected_options": [], "type": "select", "name": "assign", }, @@ -235,19 +237,25 @@ def test_build_group_attachment(self): def test_build_group_attachment_issue_alert(self): issue_alert_group = self.create_group(project=self.project) - assert build_group_attachment(issue_alert_group, issue_details=True)["actions"] == [] + assert ( + SlackIssuesMessageBuilder(issue_alert_group, issue_details=True).build()["actions"] + == [] + ) def test_build_group_attachment_color_no_event_error_fallback(self): group_with_no_events = self.create_group(project=self.project) - assert build_group_attachment(group_with_no_events)["color"] == "#E03E2F" + assert SlackIssuesMessageBuilder(group_with_no_events).build()["color"] == "#E03E2F" def test_build_group_attachment_color_unexpected_level_error_fallback(self): unexpected_level_event = self.store_event( data={"level": "trace"}, project_id=self.project.id, assert_no_errors=False ) - assert build_group_attachment(unexpected_level_event.group)["color"] == "#E03E2F" + assert SlackIssuesMessageBuilder(unexpected_level_event.group).build()["color"] == "#E03E2F" def test_build_group_attachment_color_warning(self): warning_event = self.store_event(data={"level": "warning"}, project_id=self.project.id) - assert build_group_attachment(warning_event.group)["color"] == "#FFC227" - assert build_group_attachment(warning_event.group, warning_event)["color"] == "#FFC227" + assert SlackIssuesMessageBuilder(warning_event.group).build()["color"] == "#FFC227" + assert ( + SlackIssuesMessageBuilder(warning_event.group, warning_event).build()["color"] + == "#FFC227" + ) diff --git a/tests/sentry/manager/test_group_manager.py b/tests/sentry/manager/test_group_manager.py new file mode 100644 index 00000000000000..36a52999c18143 --- /dev/null +++ b/tests/sentry/manager/test_group_manager.py @@ -0,0 +1,45 @@ +from sentry.models import Group, Integration +from sentry.testutils import TestCase + + +class SentryManagerTest(TestCase): + def test_valid_only_message(self): + event = Group.objects.from_kwargs(1, message="foo") + self.assertEqual(event.group.last_seen, event.datetime) + self.assertEqual(event.message, "foo") + self.assertEqual(event.project_id, 1) + + def test_get_groups_by_external_issue(self): + external_issue_key = "api-123" + group = self.create_group() + integration = Integration.objects.create( + provider="jira", + external_id="some_id", + name="Hello world", + metadata={"base_url": "https://example.com"}, + ) + integration.add_organization(group.organization, self.user) + self.create_integration_external_issue( + group=group, integration=integration, key=external_issue_key + ) + + affected_groups_no_orgs = Group.objects.get_groups_by_external_issue( + integration, + {}, + external_issue_key, + ) + assert set(affected_groups_no_orgs) == set() + + affected_groups_wrong_key = Group.objects.get_groups_by_external_issue( + integration, + {group.organization}, + "invalid", + ) + assert set(affected_groups_wrong_key) == set() + + affected_groups = Group.objects.get_groups_by_external_issue( + integration, + {group.organization}, + external_issue_key, + ) + assert set(affected_groups) == {group} diff --git a/tests/sentry/manager/tests.py b/tests/sentry/manager/test_team_manager.py similarity index 86% rename from tests/sentry/manager/tests.py rename to tests/sentry/manager/test_team_manager.py index 41e231f8333d9f..900c916d9b4602 100644 --- a/tests/sentry/manager/tests.py +++ b/tests/sentry/manager/test_team_manager.py @@ -1,15 +1,7 @@ -from sentry.models import Group, Team, User +from sentry.models import Team, User from sentry.testutils import TestCase -class SentryManagerTest(TestCase): - def test_valid_only_message(self): - event = Group.objects.from_kwargs(1, message="foo") - self.assertEqual(event.group.last_seen, event.datetime) - self.assertEqual(event.message, "foo") - self.assertEqual(event.project_id, 1) - - class TeamManagerTest(TestCase): def test_simple(self): user = User.objects.create(username="foo")