Skip to content

impr(jira): switch to paginated project endpoint for jira issue config #89707

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Apr 18, 2025
6 changes: 6 additions & 0 deletions src/sentry/integrations/jira/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class JiraCloudClient(ApiClient):
ISSUE_URL = "/rest/api/2/issue/%s"
META_URL = "/rest/api/2/issue/createmeta"
PRIORITIES_URL = "/rest/api/2/priority"
PROJECTS_PAGINATED_URL = "/rest/api/2/project/search"
PROJECT_URL = "/rest/api/2/project"
SEARCH_URL = "/rest/api/2/search/"
VERSIONS_URL = "/rest/api/2/project/%s/versions"
Expand Down Expand Up @@ -122,7 +123,12 @@ def create_comment(self, issue_key, comment):
def update_comment(self, issue_key, comment_id, comment):
return self.put(self.COMMENT_URL % (issue_key, comment_id), data={"body": comment})

def get_projects_paginated(self, params: dict[str, Any] | None = None):
response = self.get(self.PROJECTS_PAGINATED_URL, params=params)
return response

def get_projects_list(self):
"""deprecated - please use paginated projects endpoint"""
return self.get_cached(self.PROJECT_URL)

def get_project_key_for_id(self, project_id) -> str:
Expand Down
14 changes: 14 additions & 0 deletions src/sentry/integrations/jira/endpoints/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from rest_framework.request import Request
from rest_framework.response import Response

from sentry import features
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import control_silo_endpoint
Expand Down Expand Up @@ -84,6 +85,19 @@ def get(
users = [{"value": user_id, "label": display} for user_id, display in user_tuples]
return Response(users)

if field == "project" and features.has(
"organizations:jira-paginated-projects", organization, actor=request.user
):
try:
response = jira_client.get_projects_paginated(params={"query": query})
projects = [
{"label": f"{p["key"]} - {p["name"]}", "value": p["id"]}
for p in response.get("values", [])
]
except (ApiUnauthorized, ApiError):
return Response({"detail": "Unable to fetch projects from Jira"}, status=400)
return Response(projects)

try:
response = jira_client.get_field_autocomplete(name=field, value=query)
except (ApiUnauthorized, ApiError):
Expand Down
32 changes: 23 additions & 9 deletions src/sentry/integrations/jira/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -789,7 +789,13 @@ def get_create_issue_config(self, group: Group | None, user: RpcUser, **kwargs):
project_id = params.get("project", defaults.get("project"))
client = self.get_client()
try:
jira_projects = client.get_projects_list()
jira_projects = (
client.get_projects_paginated({"maxResults": MAX_PER_PROJECT_QUERIES})["values"]
if features.has(
"organizations:jira-paginated-projects", group.organization, actor=user
)
else client.get_projects_list()
)
except ApiError as e:
logger.info(
"jira.get-create-issue-config.no-projects",
Expand Down Expand Up @@ -822,15 +828,23 @@ def get_create_issue_config(self, group: Group | None, user: RpcUser, **kwargs):
if not any(c for c in issue_type_choices if c[0] == issue_type):
issue_type = issue_type_meta["id"]

projects_form_field = {
"name": "project",
"label": "Jira Project",
"choices": [(p["id"], f"{p["key"]} - {p["name"]}") for p in jira_projects],
"default": meta["id"],
"type": "select",
"updatesForm": True,
"required": True,
}
Comment on lines +831 to +839
Copy link
Member

Choose a reason for hiding this comment

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

Part of the "leaving code better than we found it" mantra, it'd be nice if we could create a typeddict or dataclass for this in the future.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

do you mind if I pull out the FormField typing to another PR ? It's going to add a lot of changes unrelated to the original PRs goal.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

moving formfield typing to another PR

if features.has("organizations:jira-paginated-projects", group.organization, actor=user):
paginated_projects_url = reverse(
"sentry-extensions-jira-search", args=[self.organization.slug, self.model.id]
)
projects_form_field["url"] = paginated_projects_url

fields = [
{
"name": "project",
"label": "Jira Project",
"choices": [(p["id"], p["key"]) for p in jira_projects],
"default": meta["id"],
"type": "select",
"updatesForm": True,
},
projects_form_field,
*fields,
{
"name": "issuetype",
Expand Down
87 changes: 83 additions & 4 deletions tests/sentry/integrations/jira/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,10 @@ def test_get_create_issue_config(self):
"name": "project",
"default": "10000",
"updatesForm": True,
"choices": [("10000", "EX"), ("10001", "ABC")],
"choices": [("10000", "EX - Example"), ("10001", "ABC - Alphabetical")],
"label": "Jira Project",
"type": "select",
"required": True,
},
{
"default": "message",
Expand Down Expand Up @@ -208,6 +209,81 @@ def test_get_create_issue_config(self):
},
]

@responses.activate
@with_feature("organizations:jira-paginated-projects")
def test_get_create_issue_config_paginated_projects(self):
"""Test that projects are fetched using pagination when the feature flag is enabled"""
event = self.store_event(
data={
"event_id": "a" * 32,
"message": "message",
"timestamp": self.min_ago,
},
project_id=self.project.id,
default_event_type=EventType.DEFAULT,
)
group = event.group
assert group is not None

# Mock the paginated projects response
responses.add(
responses.GET,
"https://example.atlassian.net/rest/api/2/project/search",
json={
"values": [
{"id": "10000", "key": "PROJ1", "name": "Project 1"},
{"id": "10001", "key": "PROJ2", "name": "Project 2"},
],
"total": 2,
},
)

# Mock the create issue metadata endpoint
responses.add(
responses.GET,
"https://example.atlassian.net/rest/api/2/issue/createmeta",
json={
"projects": [
{
"id": "10000",
"key": "PROJ1",
"name": "Project 1",
"issuetypes": [
{
"description": "An error in the code",
"fields": {
"issuetype": {
"key": "issuetype",
"name": "Issue Type",
"required": True,
}
},
"id": "bug1",
"name": "Bug",
}
],
}
]
},
)

installation = self.integration.get_installation(self.organization.id)
fields = installation.get_create_issue_config(group, self.user)

# Find the project field in the config
project_field = next(field for field in fields if field["name"] == "project")

# Verify the project field is configured correctly
assert (
project_field["url"]
== f"/extensions/jira/search/{self.organization.slug}/{self.integration.id}/"
)
assert project_field["choices"] == [
("10000", "PROJ1 - Project 1"),
("10001", "PROJ2 - Project 2"),
]
assert project_field["type"] == "select"

def test_get_create_issue_config_customer_domain(self):
event = self.store_event(
data={
Expand Down Expand Up @@ -365,11 +441,12 @@ def test_get_create_issue_config_with_default_and_param(self):

assert project_field == {
"default": "10000",
"choices": [("10000", "EX"), ("10001", "ABC")],
"choices": [("10000", "EX - Example"), ("10001", "ABC - Alphabetical")],
"type": "select",
"name": "project",
"label": "Jira Project",
"updatesForm": True,
"required": True,
}

def test_get_create_issue_config_with_default(self):
Expand Down Expand Up @@ -398,11 +475,12 @@ def test_get_create_issue_config_with_default(self):

assert project_field == {
"default": "10001",
"choices": [("10000", "EX"), ("10001", "ABC")],
"choices": [("10000", "EX - Example"), ("10001", "ABC - Alphabetical")],
"type": "select",
"name": "project",
"label": "Jira Project",
"updatesForm": True,
"required": True,
}

@patch("sentry.integrations.jira.integration.JiraIntegration.fetch_issue_create_meta")
Expand Down Expand Up @@ -447,11 +525,12 @@ def test_get_create_issue_config_with_default_project_deleted(

assert project_field == {
"default": "10001",
"choices": [("10000", "EX"), ("10001", "ABC")],
"choices": [("10000", "EX - Example"), ("10001", "ABC - Alphabetical")],
"type": "select",
"name": "project",
"label": "Jira Project",
"updatesForm": True,
"required": True,
}

def test_get_create_issue_config_with_label_default(self):
Expand Down
47 changes: 47 additions & 0 deletions tests/sentry/integrations/jira/test_search_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from fixtures.integrations.stub_service import StubService
from sentry.testutils.cases import APITestCase
from sentry.testutils.helpers.features import with_feature
from sentry.testutils.silo import control_silo_test


Expand Down Expand Up @@ -181,3 +182,49 @@ def test_customfield_search_error(self):
assert resp.data == {
"detail": "Unable to fetch autocomplete for customfield_0123 from Jira"
}

@responses.activate
@with_feature("organizations:jira-paginated-projects")
def test_project_search_with_pagination(self):
responses.add(
responses.GET,
"https://example.atlassian.net/rest/api/2/project/search",
json={
"values": [
{"id": "10000", "key": "EX", "name": "Example"},
],
"total": 2,
},
)

self.login_as(self.user)

path = reverse(
"sentry-extensions-jira-search", args=[self.organization.slug, self.integration.id]
)

resp = self.client.get(f"{path}?field=project&query=example")
assert resp.status_code == 200
assert resp.data == [
{"label": "EX - Example", "value": "10000"},
]

@responses.activate
@with_feature("organizations:jira-paginated-projects")
def test_project_search_error_with_pagination(self):
responses.add(
responses.GET,
"https://example.atlassian.net/rest/api/2/project/search",
status=500,
body="susge",
)

self.login_as(self.user)

path = reverse(
"sentry-extensions-jira-search", args=[self.organization.slug, self.integration.id]
)

resp = self.client.get(f"{path}?field=project&query=example")
assert resp.status_code == 400
assert resp.data == {"detail": "Unable to fetch projects from Jira"}
Loading