Repository: getsentry/sentry

Issue:
Show unresolved issue breakdown by substatus in weekly reports
See the “Weekly Report” here: https://www.figma.com/file/BeUa34s0rfYAPWx0fY3JmG/Emails?node-id=301-527&t=vfEvmbIKPXCykUX6-0 



We can show a breakdown of all unresolved issues by substatus.


Discussion:

Resolving patch:
diff --git a/src/sentry/tasks/weekly_reports.py b/src/sentry/tasks/weekly_reports.py
--- a/src/sentry/tasks/weekly_reports.py
+++ b/src/sentry/tasks/weekly_reports.py
@@ -2,6 +2,7 @@
 import logging
 from datetime import timedelta
 from functools import partial, reduce
+from typing import MutableMapping
 
 import sentry_sdk
 from django.db.models import Count
@@ -16,6 +17,7 @@
 from snuba_sdk.orderby import Direction, OrderBy
 from snuba_sdk.query import Limit, Query
 
+from sentry import features
 from sentry.api.serializers.snuba import zerofill
 from sentry.constants import DataCategory
 from sentry.db.models.fields import PickledObjectField
@@ -24,6 +26,8 @@
     Group,
     GroupHistory,
     GroupHistoryStatus,
+    GroupInbox,
+    GroupInboxReason,
     GroupStatus,
     Organization,
     OrganizationMember,
@@ -54,8 +58,8 @@ def __init__(self, timestamp, duration, organization):
         self.start = to_datetime(timestamp - duration)
         self.end = to_datetime(timestamp)
 
-        self.organization = organization
-        self.projects = {}  # { project_id: ProjectContext }
+        self.organization: Organization = organization
+        self.projects: MutableMapping[str, ProjectContext] = {}  # { project_id: ProjectContext }
 
         self.project_ownership = {}  # { user_id: set<project_id> }
         for project in organization.project_set.all():
@@ -71,11 +75,19 @@ class ProjectContext:
     accepted_transaction_count = 0
     dropped_transaction_count = 0
 
+    # Removed after organizations:escalating-issues GA
     all_issue_count = 0
     existing_issue_count = 0
     reopened_issue_count = 0
     new_issue_count = 0
 
+    # For organizations:issue-states
+    new_inbox_count = 0
+    ongoing_inbox_count = 0
+    escalating_inbox_count = 0
+    regression_inbox_count = 0
+    total_inbox_count = 0
+
     def __init__(self, project):
         self.project = project
 
@@ -155,14 +167,20 @@ def prepare_organization_report(
     set_tag("org.slug", organization.slug)
     set_tag("org.id", organization_id)
     ctx = OrganizationReportContext(timestamp, duration, organization)
+    has_issue_states = features.has("organizations:issue-states", organization)
 
     # Run organization passes
     with sentry_sdk.start_span(op="weekly_reports.user_project_ownership"):
         user_project_ownership(ctx)
     with sentry_sdk.start_span(op="weekly_reports.project_event_counts_for_organization"):
         project_event_counts_for_organization(ctx)
-    with sentry_sdk.start_span(op="weekly_reports.organization_project_issue_summaries"):
-        organization_project_issue_summaries(ctx)
+
+    if has_issue_states:
+        with sentry_sdk.start_span(op="weekly_reports.organization_project_issue_inbox_summaries"):
+            organization_project_issue_inbox_summaries(ctx)
+    else:
+        with sentry_sdk.start_span(op="weekly_reports.organization_project_issue_summaries"):
+            organization_project_issue_summaries(ctx)
 
     with sentry_sdk.start_span(op="weekly_reports.project_passes"):
         # Run project passes
@@ -324,6 +342,32 @@ def organization_project_issue_summaries(ctx):
         )
 
 
+def organization_project_issue_inbox_summaries(ctx: OrganizationReportContext):
+    inbox_counts = (
+        GroupInbox.objects.filter(
+            organization_id=ctx.organization.id,
+            reason__in=[
+                GroupInboxReason.NEW.value,
+                GroupInboxReason.ESCALATING.value,
+                GroupInboxReason.ONGOING.value,
+                GroupInboxReason.REGRESSION.value,
+            ],
+        )
+        .values("project_id", "reason")
+        .annotate(total=Count("reason"))
+    )
+    for count in inbox_counts:
+        if count["reason"] == GroupInboxReason.NEW.value:
+            ctx.projects[count["project_id"]].new_inbox_count = count["total"]
+        if count["reason"] == GroupInboxReason.ESCALATING.value:
+            ctx.projects[count["project_id"]].escalating_inbox_count = count["total"]
+        if count["reason"] == GroupInboxReason.ONGOING.value:
+            ctx.projects[count["project_id"]].ongoing_inbox_count = count["total"]
+        if count["reason"] == GroupInboxReason.REGRESSION.value:
+            ctx.projects[count["project_id"]].regression_inbox_count = count["total"]
+        ctx.projects[count["project_id"]].total_inbox_count += count["total"]
+
+
 # Project passes
 def project_key_errors(ctx, project):
     if not project.first_event:
@@ -368,15 +412,35 @@ def fetch_key_error_groups(ctx):
     for group in Group.objects.filter(id__in=all_key_error_group_ids).all():
         group_id_to_group[group.id] = group
 
-    group_history = (
-        GroupHistory.objects.filter(
-            group_id__in=all_key_error_group_ids, organization_id=ctx.organization.id
+    group_id_to_group_inbox = {}
+    group_id_to_group_history = {}
+    if features.has("organizations:issue-states", ctx.organization):
+        group_inbox = (
+            GroupInbox.objects.filter(
+                group_id__in=all_key_error_group_ids,
+                organization_id=ctx.organization.id,
+                reason__in=[
+                    GroupInboxReason.NEW.value,
+                    GroupInboxReason.ESCALATING.value,
+                    GroupInboxReason.ONGOING.value,
+                    GroupInboxReason.REGRESSION.value,
+                ],
+            )
+            .order_by("group_id", "-date_added")
+            .distinct("group_id")
+            .all()
         )
-        .order_by("group_id", "-date_added")
-        .distinct("group_id")
-        .all()
-    )
-    group_id_to_group_history = {g.group_id: g for g in group_history}
+        group_id_to_group_inbox = {g.group_id: g for g in group_inbox}
+    else:
+        group_history = (
+            GroupHistory.objects.filter(
+                group_id__in=all_key_error_group_ids, organization_id=ctx.organization.id
+            )
+            .order_by("group_id", "-date_added")
+            .distinct("group_id")
+            .all()
+        )
+        group_id_to_group_history = {g.group_id: g for g in group_history}
 
     for project_ctx in ctx.projects.values():
         # note Snuba might have groups that have since been deleted
@@ -388,6 +452,7 @@ def fetch_key_error_groups(ctx):
                     (
                         group_id_to_group.get(group_id),
                         group_id_to_group_history.get(group_id, None),
+                        group_id_to_group_inbox.get(group_id, None),
                         count,
                     )
                     for group_id, count in project_ctx.key_errors
@@ -618,10 +683,22 @@ def deliver_reports(ctx, dry_run=False, target_user=None, email_override=None):
     GroupHistoryStatus.REVIEWED: "#FAD473",
     GroupHistoryStatus.NEW: "#FAD473",
 }
+group_inbox_to_color = {
+    GroupInboxReason.NEW: "rgba(245, 176, 0, 0.08)",
+    GroupInboxReason.REGRESSION: "rgba(108, 95, 199, 0.08)",
+    GroupInboxReason.ESCALATING: "rgba(245, 84, 89, 0.09)",
+    GroupInboxReason.ONGOING: "rgba(219, 214, 225, 1)",
+}
+group_inbox_to_color_border = {
+    GroupInboxReason.NEW: "rgba(245, 176, 0, 0.55)",
+    GroupInboxReason.REGRESSION: "rgba(108, 95, 199, 0.5)",
+    GroupInboxReason.ESCALATING: "rgba(245, 84, 89, 0.5)",
+    GroupInboxReason.ONGOING: "rgba(219, 214, 225, 1)",
+}
 
 
-# Serialize ctx for template, and calculate view parameters (like graph bar heights)
 def render_template_context(ctx, user):
+    # Serialize ctx for template, and calculate view parameters (like graph bar heights)
     # Fetch the list of projects associated with the user.
     # Projects owned by teams that the user has membership of.
     if user and user.id in ctx.project_ownership:
@@ -784,7 +861,7 @@ def all_key_errors():
                             "project_id": project_ctx.project.id,
                         },
                     )
-                for group, group_history, count in project_ctx.key_errors:
+                for group, group_history, group_inbox, count in project_ctx.key_errors:
                     if ctx.organization.slug == "sentry":
                         logger.info(
                             "render_template_context.all_key_errors.found_error",
@@ -794,6 +871,10 @@ def all_key_errors():
                                 "project_id": project_ctx.project.id,
                             },
                         )
+
+                    group_inbox_reason = (
+                        GroupInboxReason(group_inbox.reason) if group_inbox else None
+                    )
                     yield {
                         "count": count,
                         "group": group,
@@ -803,6 +884,15 @@ def all_key_errors():
                         "status_color": group_status_to_color[group_history.status]
                         if group_history
                         else group_status_to_color[GroupHistoryStatus.NEW],
+                        "inbox_reason": group_inbox_reason.name.capitalize()
+                        if group_inbox_reason
+                        else None,
+                        "inbox_color": group_inbox_to_color[group_inbox_reason]
+                        if group_inbox_reason
+                        else group_inbox_to_color[GroupInboxReason.NEW],
+                        "inbox_color_border": group_inbox_to_color_border[group_inbox_reason]
+                        if group_inbox_reason
+                        else group_inbox_to_color_border[GroupInboxReason.NEW],
                     }
 
         return heapq.nlargest(3, all_key_errors(), lambda d: d["count"])
@@ -849,16 +939,31 @@ def issue_summary():
         existing_issue_count = 0
         reopened_issue_count = 0
         new_issue_count = 0
+        new_inbox_count = 0
+        escalating_inbox_count = 0
+        ongoing_inbox_count = 0
+        regression_inbox_count = 0
+        total_inbox_count = 0
         for project_ctx in user_projects:
             all_issue_count += project_ctx.all_issue_count
             existing_issue_count += project_ctx.existing_issue_count
             reopened_issue_count += project_ctx.reopened_issue_count
             new_issue_count += project_ctx.new_issue_count
+            new_inbox_count += project_ctx.new_inbox_count
+            escalating_inbox_count += project_ctx.escalating_inbox_count
+            ongoing_inbox_count += project_ctx.ongoing_inbox_count
+            regression_inbox_count += project_ctx.regression_inbox_count
+            total_inbox_count += project_ctx.total_inbox_count
         return {
             "all_issue_count": all_issue_count,
             "existing_issue_count": existing_issue_count,
             "reopened_issue_count": reopened_issue_count,
             "new_issue_count": new_issue_count,
+            "new_inbox_count": new_inbox_count,
+            "escalating_inbox_count": escalating_inbox_count,
+            "ongoing_inbox_count": ongoing_inbox_count,
+            "regression_inbox_count": regression_inbox_count,
+            "total_inbox_count": total_inbox_count,
         }
 
     return {
diff --git a/src/sentry/web/frontend/debug/debug_weekly_report.py b/src/sentry/web/frontend/debug/debug_weekly_report.py
--- a/src/sentry/web/frontend/debug/debug_weekly_report.py
+++ b/src/sentry/web/frontend/debug/debug_weekly_report.py
@@ -8,6 +8,8 @@
 from sentry.models import Group, Organization, Project
 from sentry.tasks.weekly_reports import (
     ONE_DAY,
+    GroupInbox,
+    GroupInboxReason,
     OrganizationReportContext,
     ProjectContext,
     render_template_context,
@@ -17,6 +19,8 @@
 
 from .mail import MailPreviewView
 
+DEBUG_ESCALATING_ISSUES = False
+
 
 def get_random(request):
     seed = request.GET.get("seed", str(time.time()))
@@ -76,17 +80,36 @@ def get_context(self, request):
             project_context.dropped_transaction_count = int(
                 random.weibullvariate(5, 1) * random.paretovariate(0.2)
             )
+            group_inbox = (
+                GroupInbox(reason=GroupInboxReason.ESCALATING) if DEBUG_ESCALATING_ISSUES else None
+            )
             project_context.key_errors = [
-                (g, None, random.randint(0, 1000)) for g in Group.objects.all()[:3]
+                (g, None, group_inbox, random.randint(0, 1000)) for g in Group.objects.all()[:3]
             ]
-            project_context.existing_issue_count = random.randint(0, 10000)
-            project_context.reopened_issue_count = random.randint(0, 1000)
-            project_context.new_issue_count = random.randint(0, 1000)
-            project_context.all_issue_count = (
-                project_context.existing_issue_count
-                + project_context.reopened_issue_count
-                + project_context.new_issue_count
-            )
+
+            if DEBUG_ESCALATING_ISSUES:
+                # For organizations:issue-states
+                project_context.new_inbox_count = random.randint(5, 200)
+                project_context.escalating_inbox_count = random.randint(5, 200)
+                project_context.regression_inbox_count = random.randint(5, 200)
+                project_context.ongoing_inbox_count = random.randint(20, 3000)
+                project_context.total_inbox_count = (
+                    project_context.new_inbox_count
+                    + project_context.escalating_inbox_count
+                    + project_context.regression_inbox_count
+                    + project_context.ongoing_inbox_count
+                )
+            else:
+                # Removed after organizations:issue-states GA
+                project_context.existing_issue_count = random.randint(0, 10000)
+                project_context.reopened_issue_count = random.randint(0, 1000)
+                project_context.new_issue_count = random.randint(0, 1000)
+                project_context.all_issue_count = (
+                    project_context.existing_issue_count
+                    + project_context.reopened_issue_count
+                    + project_context.new_issue_count
+                )
+
             # Array of (transaction_name, count_this_week, p95_this_week, count_last_week, p95_last_week)
             project_context.key_transactions = [
                 (


Test patch:
diff --git a/tests/sentry/tasks/test_weekly_reports.py b/tests/sentry/tasks/test_weekly_reports.py
--- a/tests/sentry/tasks/test_weekly_reports.py
+++ b/tests/sentry/tasks/test_weekly_reports.py
@@ -11,11 +11,19 @@
 
 from sentry.constants import DataCategory
 from sentry.db.postgres.roles import in_test_psql_role_override
-from sentry.models import GroupStatus, OrganizationMember, Project, UserOption
+from sentry.models import (
+    GroupInboxReason,
+    GroupStatus,
+    OrganizationMember,
+    Project,
+    UserOption,
+    add_group_to_inbox,
+)
 from sentry.tasks.weekly_reports import (
     ONE_DAY,
     OrganizationReportContext,
     deliver_reports,
+    organization_project_issue_inbox_summaries,
     organization_project_issue_summaries,
     prepare_organization_report,
     schedule_organizations,
@@ -168,6 +176,49 @@ def test_organization_project_issue_summaries(self):
         assert project_ctx.existing_issue_count == 0
         assert project_ctx.all_issue_count == 2
 
+    @with_feature("organizations:issue-states")
+    def test_organization_project_issue_inbox_summaries(self):
+        self.login_as(user=self.user)
+
+        now = timezone.now()
+        min_ago = iso_format(now - timedelta(minutes=1))
+
+        event1 = self.store_event(
+            data={
+                "event_id": "a" * 32,
+                "message": "message",
+                "timestamp": min_ago,
+                "stacktrace": copy.deepcopy(DEFAULT_EVENT_DATA["stacktrace"]),
+                "fingerprint": ["group-1"],
+            },
+            project_id=self.project.id,
+        )
+        add_group_to_inbox(event1.group, GroupInboxReason.NEW)
+
+        event2 = self.store_event(
+            data={
+                "event_id": "b" * 32,
+                "message": "message",
+                "timestamp": min_ago,
+                "stacktrace": copy.deepcopy(DEFAULT_EVENT_DATA["stacktrace"]),
+                "fingerprint": ["group-2"],
+            },
+            project_id=self.project.id,
+        )
+        add_group_to_inbox(event2.group, GroupInboxReason.ONGOING)
+        timestamp = to_timestamp(now)
+
+        ctx = OrganizationReportContext(timestamp, ONE_DAY * 7, self.organization)
+        organization_project_issue_inbox_summaries(ctx)
+
+        project_ctx = ctx.projects[self.project.id]
+
+        assert project_ctx.new_inbox_count == 1
+        assert project_ctx.escalating_inbox_count == 0
+        assert project_ctx.ongoing_inbox_count == 1
+        assert project_ctx.regression_inbox_count == 0
+        assert project_ctx.total_inbox_count == 2
+
     @mock.patch("sentry.tasks.weekly_reports.MessageBuilder")
     def test_message_builder_simple(self, message_builder):
         now = timezone.now()
@@ -252,6 +303,110 @@ def test_message_builder_simple(self, message_builder):
                 "existing_issue_count": 0,
                 "new_issue_count": 2,
                 "reopened_issue_count": 0,
+                # New escalating-issues
+                "escalating_inbox_count": 0,
+                "new_inbox_count": 0,
+                "ongoing_inbox_count": 0,
+                "regression_inbox_count": 0,
+                "total_inbox_count": 0,
+            }
+            assert len(context["key_errors"]) == 2
+            assert context["trends"]["total_error_count"] == 2
+            assert context["trends"]["total_transaction_count"] == 10
+            assert "Weekly Report for" in message_params["subject"]
+
+    @mock.patch("sentry.tasks.weekly_reports.MessageBuilder")
+    @with_feature("organizations:issue-states")
+    def test_message_builder_inbox_simple(self, message_builder):
+        now = timezone.now()
+
+        two_days_ago = now - timedelta(days=2)
+        three_days_ago = now - timedelta(days=3)
+
+        self.create_member(
+            teams=[self.team], user=self.create_user(), organization=self.organization
+        )
+
+        event1 = self.store_event(
+            data={
+                "event_id": "a" * 32,
+                "message": "message",
+                "timestamp": iso_format(three_days_ago),
+                "stacktrace": copy.deepcopy(DEFAULT_EVENT_DATA["stacktrace"]),
+                "fingerprint": ["group-1"],
+            },
+            project_id=self.project.id,
+        )
+        add_group_to_inbox(event1.group, GroupInboxReason.NEW)
+
+        event2 = self.store_event(
+            data={
+                "event_id": "b" * 32,
+                "message": "message",
+                "timestamp": iso_format(three_days_ago),
+                "stacktrace": copy.deepcopy(DEFAULT_EVENT_DATA["stacktrace"]),
+                "fingerprint": ["group-2"],
+            },
+            project_id=self.project.id,
+        )
+        self.store_outcomes(
+            {
+                "org_id": self.organization.id,
+                "project_id": self.project.id,
+                "outcome": Outcome.ACCEPTED,
+                "category": DataCategory.ERROR,
+                "timestamp": three_days_ago,
+                "key_id": 1,
+            },
+            num_times=2,
+        )
+        add_group_to_inbox(event2.group, GroupInboxReason.ONGOING)
+
+        self.store_outcomes(
+            {
+                "org_id": self.organization.id,
+                "project_id": self.project.id,
+                "outcome": Outcome.ACCEPTED,
+                "category": DataCategory.TRANSACTION,
+                "timestamp": three_days_ago,
+                "key_id": 1,
+            },
+            num_times=10,
+        )
+
+        group1 = event1.group
+        group2 = event2.group
+
+        group1.status = GroupStatus.RESOLVED
+        group1.substatus = None
+        group1.resolved_at = two_days_ago
+        group1.save()
+
+        group2.status = GroupStatus.RESOLVED
+        group2.substatus = None
+        group2.resolved_at = two_days_ago
+        group2.save()
+
+        prepare_organization_report(to_timestamp(now), ONE_DAY * 7, self.organization.id)
+
+        for call_args in message_builder.call_args_list:
+            message_params = call_args.kwargs
+            context = message_params["context"]
+
+            assert message_params["template"] == "sentry/emails/reports/body.txt"
+            assert message_params["html_template"] == "sentry/emails/reports/body.html"
+
+            assert context["organization"] == self.organization
+            assert context["issue_summary"] == {
+                "all_issue_count": 0,
+                "existing_issue_count": 0,
+                "new_issue_count": 0,
+                "reopened_issue_count": 0,
+                "escalating_inbox_count": 0,
+                "new_inbox_count": 1,
+                "ongoing_inbox_count": 1,
+                "regression_inbox_count": 0,
+                "total_inbox_count": 2,
             }
             assert len(context["key_errors"]) == 2
             assert context["trends"]["total_error_count"] == 2