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
6 changes: 3 additions & 3 deletions src/sentry/integrations/msteams/card_builder/block.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,11 @@ class ContainerBlock(TypedDict):

class InputChoice(TypedDict):
title: str
value: object
value: str


class _InputChoiceSetBlockNotRequired(TypedDict, total=False):
value: object
value: str


class InputChoiceSetBlock(_InputChoiceSetBlockNotRequired):
Expand Down Expand Up @@ -240,7 +240,7 @@ def create_container_block(*items: Block) -> ContainerBlock:


def create_input_choice_set_block(
id: str, choices: Sequence[tuple[str, object]], default_choice: object
id: str, choices: Sequence[tuple[str, str]], default_choice: str | None
) -> InputChoiceSetBlock:
default_choice_arg: _InputChoiceSetBlockNotRequired
default_choice_arg = {"value": default_choice} if default_choice else {}
Expand Down
11 changes: 5 additions & 6 deletions src/sentry/integrations/msteams/card_builder/issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ class MSTeamsIssueMessageBuilder(MSTeamsMessageBuilder):
def __init__(
self,
group: Group,
event: Event | GroupEvent,
event: Event | GroupEvent | None,
rules: Sequence[Rule],
integration: RpcIntegration,
):
Expand All @@ -67,12 +67,11 @@ def __init__(

def generate_action_payload(self, action_type: ACTION_TYPE) -> Any:
# we need nested data or else Teams won't handle the payload correctly
assert self.event.group is not None
return {
"payload": {
"actionType": action_type,
"groupId": self.event.group.id,
"eventId": self.event.event_id,
"groupId": self.group.id,
"eventId": self.event.event_id if self.event else None,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can this ever be true? I wonder if we should guard against this case rather than just assuming it can be None.

"rules": [rule.id for rule in self.rules],
"integrationId": self.integration.id,
}
Expand Down Expand Up @@ -156,8 +155,8 @@ def build_input_choice_card(
card_title: str,
input_id: str,
submit_button_title: str,
choices: Sequence[tuple[str, Any]],
default_choice: Any = None,
choices: Sequence[tuple[str, str]],
default_choice: str | None = None,
) -> AdaptiveCard:
return MSTeamsMessageBuilder().build(
title=create_text_block(card_title, weight=TextWeight.BOLDER),
Expand Down
12 changes: 6 additions & 6 deletions src/sentry/integrations/msteams/card_builder/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,12 @@ class IssueConstants:
ARCHIVE_INPUT_ID = "archiveInput"
ARCHIVE_INPUT_TITLE = "Archive until this happens again..."
ARCHIVE_INPUT_CHOICES = [
("Archive forever", -1),
("1 time", 1),
("10 times", 10),
("100 times", 100),
("1,000 times", 1000),
("10,000 times", 10000),
("Archive forever", "-1"),
("1 time", "1"),
("10 times", "10"),
("100 times", "100"),
("1,000 times", "1000"),
("10,000 times", "10000"),
]
UNARCHIVE = "Unarchive"

Expand Down
29 changes: 16 additions & 13 deletions src/sentry/integrations/msteams/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -634,19 +634,22 @@ def _handle_action_submitted(self, request: Request) -> Response:
rules = tuple(Rule.objects.filter(id__in=payload["rules"]))

# pull the event based off our payload
event = eventstore.backend.get_event_by_id(group.project_id, payload["eventId"])
if event is None:
logger.info(
"msteams.action.event-missing",
extra={
"team_id": team_id,
"integration_id": integration.id,
"organization_id": group.organization.id,
"event_id": payload["eventId"],
"project_id": group.project_id,
},
)
return self.respond(status=404)
event = None
event_id = payload.get("eventId")
if event_id:
event = eventstore.backend.get_event_by_id(group.project_id, event_id)
if event is None:
logger.info(
"msteams.action.event-missing",
extra={
"team_id": team_id,
"integration_id": integration.id,
"organization_id": group.organization.id,
"event_id": event_id,
"project_id": group.project_id,
},
)
return self.respond(status=404)

# refresh issue and update card
group.refresh_from_db()
Expand Down
137 changes: 131 additions & 6 deletions src/sentry/notifications/platform/msteams/renderers/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

from collections.abc import Sequence
from datetime import datetime
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

from sentry import eventstore
from sentry.integrations.types import IntegrationProviderSlug
from sentry.models.group import Group
from sentry.models.group import Group, GroupStatus
from sentry.models.project import Project
from sentry.models.rule import Rule
from sentry.notifications.platform.msteams.provider import MSTeamsRenderable
Expand All @@ -28,8 +28,10 @@
Action,
Block,
ColumnSetBlock,
ContainerBlock,
TextBlock,
)
from sentry.integrations.msteams.utils import ACTION_TYPE


@renderer_registry.register(NotificationProviderKey.MSTEAMS, sources=[NotificationSource.ISSUE])
Expand Down Expand Up @@ -70,12 +72,12 @@ def render[DataT: NotificationData](
cls.build_description(group=group, event=event),
cls.build_footer(group=group, event=event, rules=rules),
cls.build_assignee_note(group),
cls.build_actions(group=group, data=data, rules=rules),
Comment thread
hobzcalvin marked this conversation as resolved.
]

return MSTeamsMessageBuilder().build(
title=cls.build_title(group=group, issue_url=issue_url),
fields=fields,
actions=cls.build_actions(issue_url=issue_url),
)

@classmethod
Expand Down Expand Up @@ -183,7 +185,130 @@ def build_assignee_note(cls, group: Group) -> TextBlock | None:
return None

@classmethod
def build_actions(cls, *, issue_url: str) -> list[Action]:
from sentry.integrations.msteams.card_builder.block import ActionType, OpenUrlAction
def build_action_payload(
cls, *, action_type: ACTION_TYPE, data: IssueNotificationData, rules: Sequence[Rule]
) -> dict[str, Any]:
# Teams posts this back to the webhook when the action is used, and only handles it
# correctly when the contents are nested under a `payload` key.
return {
"payload": {
"actionType": action_type,
"groupId": data.group_id,
"eventId": data.event_id,
"rules": [rule.id for rule in rules],
}
}

return [OpenUrlAction(type=ActionType.OPEN_URL, title="View Issue", url=issue_url)]
@classmethod
def build_action(
cls,
*,
toggled: bool,
action: ACTION_TYPE,
action_title: str,
reverse_action: ACTION_TYPE,
reverse_action_title: str,
data: IssueNotificationData,
rules: Sequence[Rule],
**card_kwargs: Any,
) -> Action:
"""
Build the action for a state the issue is not currently in. An issue which is not resolved
gets a Resolve button revealing a card of options, and a resolved one gets an Unresolve
button which submits directly.
"""
from sentry.integrations.msteams.card_builder.block import (
ActionType,
ShowCardAction,
SubmitAction,
)
from sentry.integrations.msteams.card_builder.issues import MSTeamsIssueMessageBuilder

if toggled:
return SubmitAction(
type=ActionType.SUBMIT,
title=reverse_action_title,
data=cls.build_action_payload(action_type=reverse_action, data=data, rules=rules),
)

card = MSTeamsIssueMessageBuilder.build_input_choice_card(
data=cls.build_action_payload(action_type=action, data=data, rules=rules),
**card_kwargs,
)
return ShowCardAction(type=ActionType.SHOW_CARD, title=action_title, card=card)

@classmethod
def build_assignee_choices(cls, group: Group) -> Sequence[tuple[str, str]]:
from sentry.integrations.messaging.message_builder import format_actor_options_non_slack
from sentry.integrations.msteams.card_builder import ME

teams = group.project.teams.all().order_by("slug")
return [("Me", ME)] + [
(team["text"], team["value"]) for team in format_actor_options_non_slack(teams)
]

@classmethod
def build_actions(
cls, *, group: Group, data: IssueNotificationData, rules: Sequence[Rule]
) -> ContainerBlock:
from sentry.integrations.msteams.card_builder import ME
from sentry.integrations.msteams.card_builder.block import (
create_action_set_block,
create_container_block,
)
from sentry.integrations.msteams.card_builder.utils import IssueConstants
from sentry.integrations.msteams.utils import ACTION_TYPE

status = group.get_status()

resolve_action = cls.build_action(
toggled=GroupStatus.RESOLVED == status,
action=ACTION_TYPE.RESOLVE,
action_title=IssueConstants.RESOLVE,
reverse_action=ACTION_TYPE.UNRESOLVE,
reverse_action_title=IssueConstants.UNRESOLVE,
data=data,
rules=rules,
card_title=IssueConstants.RESOLVE,
submit_button_title=IssueConstants.RESOLVE,
input_id=IssueConstants.RESOLVE_INPUT_ID,
choices=IssueConstants.RESOLVE_INPUT_CHOICES,
)

archive_action = cls.build_action(
toggled=GroupStatus.IGNORED == status,
action=ACTION_TYPE.ARCHIVE,
action_title=IssueConstants.ARCHIVE,
reverse_action=ACTION_TYPE.UNRESOLVE,
reverse_action_title=IssueConstants.UNARCHIVE,
data=data,
rules=rules,
card_title=IssueConstants.ARCHIVE_INPUT_TITLE,
submit_button_title=IssueConstants.ARCHIVE,
input_id=IssueConstants.ARCHIVE_INPUT_ID,
choices=IssueConstants.ARCHIVE_INPUT_CHOICES,
)

try:
assignee = group.get_assignee()
except Actor.InvalidActor:
assignee = None

assign_action = cls.build_action(
toggled=assignee is not None,
action=ACTION_TYPE.ASSIGN,
action_title=IssueConstants.ASSIGN,
reverse_action=ACTION_TYPE.UNASSIGN,
reverse_action_title=IssueConstants.UNASSIGN,
data=data,
rules=rules,
card_title=IssueConstants.ASSIGN_INPUT_TITLE,
submit_button_title=IssueConstants.ASSIGN,
input_id=IssueConstants.ASSIGN_INPUT_ID,
choices=cls.build_assignee_choices(group),
default_choice=ME,
)

return create_container_block(
create_action_set_block(resolve_action, archive_action, assign_action)
)
17 changes: 16 additions & 1 deletion tests/sentry/integrations/msteams/test_action_state_change.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ def post_webhook(
archive_input: str | None = None,
assign_input: str | None = None,
include_integration_id: bool = True,
include_event_id: bool = True,
) -> Response:
replyToId = "12345"

Expand All @@ -106,7 +107,7 @@ def post_webhook(

action_payload: dict[str, Any] = {
"groupId": group_id or self.group1.id,
"eventId": self.event1.event_id,
"eventId": self.event1.event_id if include_event_id else None,
"actionType": action_type,
"rules": [],
}
Expand Down Expand Up @@ -441,6 +442,20 @@ def test_resolve_issue_without_integration_id_in_personal_chat(self, verify: Mag
assert resp.status_code == 200, resp.content
assert self.group1.get_status() == GroupStatus.RESOLVED

@responses.activate
@patch("sentry.integrations.msteams.webhook.verify_signature", return_value=True)
def test_resolve_issue_without_event_id(self, verify: MagicMock) -> None:
resp = self.post_webhook(
action_type=ACTION_TYPE.RESOLVE,
resolve_input="resolved",
include_event_id=False,
)
self.group1 = Group.objects.get(id=self.group1.id)

assert resp.status_code == 200, resp.content
assert self.group1.get_status() == GroupStatus.RESOLVED
assert b"Unresolve" in responses.calls[0].request.body

@responses.activate
@patch("sentry.integrations.msteams.webhook.verify_signature", return_value=True)
def test_no_resolve_input(self, verify: MagicMock) -> None:
Expand Down
Loading
Loading