Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 1 addition & 7 deletions src/sentry/api/endpoints/group_integration_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 3 additions & 6 deletions src/sentry/api/serializers/models/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
)

Expand Down
192 changes: 96 additions & 96 deletions src/sentry/integrations/slack/message_builder/issues.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -10,10 +21,8 @@
from sentry.models import (
ActorTuple,
Group,
GroupAssignee,
GroupStatus,
Identity,
OrganizationMember,
Project,
ReleaseProject,
Rule,
Expand All @@ -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):
Expand All @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down
35 changes: 18 additions & 17 deletions src/sentry/integrations/utils/sync.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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.
Expand All @@ -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 []
Expand All @@ -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(
Expand All @@ -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(
Expand Down
Loading