diff --git a/src/sentry/integrations/bitbucket/issues.py b/src/sentry/integrations/bitbucket/issues.py index f3fb124d20de..089e429a9809 100644 --- a/src/sentry/integrations/bitbucket/issues.py +++ b/src/sentry/integrations/bitbucket/issues.py @@ -154,7 +154,7 @@ def raise_error(self, exc: Exception, identity: Identity | None = None) -> NoRet raise IntegrationConfigurationError(message) super().raise_error(exc, identity) - def create_issue(self, data, **kwargs): + def create_issue(self, data, user=None, **kwargs): client = self.get_client() if not data.get("repo"): raise IntegrationFormError({"repo": ["Repository is required"]}) diff --git a/src/sentry/integrations/example/integration.py b/src/sentry/integrations/example/integration.py index 296d84e37085..97bc08811984 100644 --- a/src/sentry/integrations/example/integration.py +++ b/src/sentry/integrations/example/integration.py @@ -145,7 +145,7 @@ def get_link_issue_config(self, group, **kwargs): example_project_field = self.generate_example_project_field(default) return fields + [example_project_field] - def create_issue(self, data, **kwargs): + def create_issue(self, data, user=None, **kwargs): if "assignee" not in data: raise IntegrationError("Assignee is required") return { diff --git a/src/sentry/integrations/github/issues.py b/src/sentry/integrations/github/issues.py index 1564dcf71b21..90e8e82bf336 100644 --- a/src/sentry/integrations/github/issues.py +++ b/src/sentry/integrations/github/issues.py @@ -243,7 +243,9 @@ def get_create_issue_config( return [repo_field, *fields, assignee_field, label_field] - def create_issue(self, data: Mapping[str, Any], **kwargs: Any) -> Mapping[str, Any]: + def create_issue( + self, data: Mapping[str, Any], user: User | RpcUser | None = None, **kwargs: Any + ) -> Mapping[str, Any]: client = self.get_client() repo = data.get("repo") if not repo: @@ -267,9 +269,16 @@ def create_issue(self, data: Mapping[str, Any], **kwargs: Any) -> Mapping[str, A if not data.get("description"): raise IntegrationFormError({"description": "Description is required"}) + body = data["description"] + if user is not None: + # Attribution: let external readers know which Sentry user + # created the issue so they know who to follow up with. + display_name = user.get_display_name() or user.get_label() + body += f"\n\n*Created by {display_name}*" + issue_data = { "title": data["title"], - "body": data["description"], + "body": body, } # Only include optional fields if they have valid values diff --git a/src/sentry/integrations/gitlab/issues.py b/src/sentry/integrations/gitlab/issues.py index b16d28e7bd57..2d5309e70c84 100644 --- a/src/sentry/integrations/gitlab/issues.py +++ b/src/sentry/integrations/gitlab/issues.py @@ -90,7 +90,7 @@ def get_create_issue_config( *fields, ] - def create_issue(self, data, **kwargs): + def create_issue(self, data, user=None, **kwargs): client = self.get_client() project_id = data.get("project") diff --git a/src/sentry/integrations/jira/integration.py b/src/sentry/integrations/jira/integration.py index 916423b89c4f..f4bb23f7a75c 100644 --- a/src/sentry/integrations/jira/integration.py +++ b/src/sentry/integrations/jira/integration.py @@ -1298,7 +1298,7 @@ def _clean_and_transform_issue_data( ) return transformed_data - def create_issue(self, data, **kwargs): + def create_issue(self, data, user=None, **kwargs): client = self.get_client() # protect against mis-configured integration submitting a form without an # issuetype assigned. diff --git a/src/sentry/integrations/jira_server/integration.py b/src/sentry/integrations/jira_server/integration.py index 0ed0eb25b34d..2bb53787277e 100644 --- a/src/sentry/integrations/jira_server/integration.py +++ b/src/sentry/integrations/jira_server/integration.py @@ -975,7 +975,7 @@ def get_create_issue_config(self, group: Group | None, user: User | RpcUser, **k return fields - def create_issue(self, data, **kwargs): + def create_issue(self, data, user=None, **kwargs): """ Get the (cached) "createmeta" from Jira to use as a "schema". Clean up the Jira issue by removing all fields that aren't enumerated by this diff --git a/src/sentry/integrations/mixins/issues.py b/src/sentry/integrations/mixins/issues.py index aa8e2c6f8c09..a03f1e01176d 100644 --- a/src/sentry/integrations/mixins/issues.py +++ b/src/sentry/integrations/mixins/issues.py @@ -283,7 +283,7 @@ def get_defaults(self, project: Project, user: User | RpcUser): return {**project_defaults, **user_defaults} @abstractmethod - def create_issue(self, data, **kwargs): + def create_issue(self, data, user: User | RpcUser | None = None, **kwargs): """ Create an issue via the provider's API and return the issue key, title and description. @@ -291,7 +291,13 @@ def create_issue(self, data, **kwargs): Should also handle API client exceptions and reraise as an IntegrationError (using the `message_from_error` helper). - >>> def create_issue(self, data, **kwargs): + :param data: The form data submitted by the user. + :param user: The Sentry user creating the issue, when known. Providers + may use this for attribution (e.g. appending a "Created by" line + to the issue body). May be None for automated (rule-triggered) + creation. + + >>> def create_issue(self, data, user=None, **kwargs): >>> resp = self.get_client().create_issue(data) >>> return { >>> 'key': resp['id'], diff --git a/src/sentry/integrations/vsts/issues.py b/src/sentry/integrations/vsts/issues.py index bba407aa930c..65d6fb723911 100644 --- a/src/sentry/integrations/vsts/issues.py +++ b/src/sentry/integrations/vsts/issues.py @@ -211,7 +211,7 @@ def get_link_issue_config(self, group: Group, **kwargs: Any) -> Sequence[Mapping def get_issue_url(self, key: str) -> str: return f"{self.instance}_workitems/edit/{key}" - def create_issue(self, data: Mapping[str, str], **kwargs: Any) -> Mapping[str, Any]: + def create_issue(self, data: Mapping[str, str], user=None, **kwargs: Any) -> Mapping[str, Any]: """ Creates the issue on the remote service and returns an issue ID. """ diff --git a/src/sentry/issues/endpoints/group_integration_details.py b/src/sentry/issues/endpoints/group_integration_details.py index d668ca5cec46..c451465aecb4 100644 --- a/src/sentry/issues/endpoints/group_integration_details.py +++ b/src/sentry/issues/endpoints/group_integration_details.py @@ -294,7 +294,7 @@ def post( ) try: - data = installation.create_issue(request.data) + data = installation.create_issue(request.data, user=request.user) except IntegrationConfigurationError as exc: lifecycle.record_halt(exc) return Response({"non_field_errors": [str(exc)]}, status=400) diff --git a/tests/sentry/integrations/github/test_issues.py b/tests/sentry/integrations/github/test_issues.py index b189b9ca5d10..7ef00e3653b7 100644 --- a/tests/sentry/integrations/github/test_issues.py +++ b/tests/sentry/integrations/github/test_issues.py @@ -482,6 +482,76 @@ def test_create_issue_with_valid_repo_ownership(self) -> None: "repo": "getsentry/sentry", } + @responses.activate + def test_create_issue_with_user_attribution(self) -> None: + with assume_test_silo_mode(SiloMode.CELL): + Repository.objects.create( + name="getsentry/sentry", + provider="integrations:github", + organization_id=self.organization.id, + integration_id=self.integration.id, + ) + + responses.add( + responses.POST, + "https://api.github.com/repos/getsentry/sentry/issues", + json={ + "number": 321, + "title": "hello", + "body": "This is the description", + "html_url": "https://github.com/getsentry/sentry/issues/321", + }, + ) + + form_data = { + "repo": "getsentry/sentry", + "title": "hello", + "description": "This is the description", + } + + result = self.install.create_issue(form_data, user=self.user) + + assert result["key"] == 321 + + request = responses.calls[-1].request + payload = orjson.loads(request.body) + assert payload["body"] == ( + f"This is the description\n\n*Created by {self.user.get_display_name()}*" + ) + + @responses.activate + def test_create_issue_without_user_no_attribution(self) -> None: + with assume_test_silo_mode(SiloMode.CELL): + Repository.objects.create( + name="getsentry/sentry", + provider="integrations:github", + organization_id=self.organization.id, + integration_id=self.integration.id, + ) + + responses.add( + responses.POST, + "https://api.github.com/repos/getsentry/sentry/issues", + json={ + "number": 321, + "title": "hello", + "body": "This is the description", + "html_url": "https://github.com/getsentry/sentry/issues/321", + }, + ) + + form_data = { + "repo": "getsentry/sentry", + "title": "hello", + "description": "This is the description", + } + + self.install.create_issue(form_data) + + request = responses.calls[-1].request + payload = orjson.loads(request.body) + assert payload["body"] == "This is the description" + def test_get_issue_with_repo_not_belonging_to_integration(self) -> None: with assume_test_silo_mode(SiloMode.CELL): Repository.objects.create(