Skip to content
Open
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
2 changes: 1 addition & 1 deletion src/sentry/integrations/bitbucket/issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]})
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/integrations/example/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 11 additions & 2 deletions src/sentry/integrations/github/issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/integrations/gitlab/issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/integrations/jira/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/integrations/jira_server/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions src/sentry/integrations/mixins/issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,15 +283,21 @@ 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.

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'],
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/integrations/vsts/issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/issues/endpoints/group_integration_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
70 changes: 70 additions & 0 deletions tests/sentry/integrations/github/test_issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading