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
10 changes: 5 additions & 5 deletions components/api_server/.vulture-whitelist.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
attrs # unused variable (src/model/issue_tracker.py:31)
text # unused variable (src/model/issue_tracker.py:39)
marks # unused variable (src/model/issue_tracker.py:40)
version # unused variable (src/model/issue_tracker.py:58)
text # unused variable (src/model/issue_tracker.py:66)
attrs # unused variable (src/model/issue_tracker.py:29)
text # unused variable (src/model/issue_tracker.py:37)
marks # unused variable (src/model/issue_tracker.py:38)
version # unused variable (src/model/issue_tracker.py:56)
text # unused variable (src/model/issue_tracker.py:64)
timestamp # unused variable (src/routes/changelog.py:20)
_.content_type # unused attribute (src/routes/pdf.py:26)
api # unused variable (src/routes/plugins/auth_plugin.py:23)
Expand Down
30 changes: 25 additions & 5 deletions components/api_server/src/model/issue_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ class IssueParameters:

JiraIssueSuggestionJSON = dict[str, list[dict[str, str | dict[str, str]]]]
JiraIssueTypesJSON = dict[str, list[dict[str, str]]]
JiraFieldsJSON = dict[str, list[dict[str, str]]]
JiraFieldsJSON = dict[str, list[dict[str, str | bool]]]
JiraEpicsJSON = dict[str, list[dict[str, str | dict[str, str]]]]
JiraProjectsJSON = list[dict[str, str]]
JiraJSON = JiraEpicsJSON | JiraIssueSuggestionJSON | JiraIssueTypesJSON | JiraFieldsJSON | JiraProjectsJSON
Expand All @@ -114,6 +114,7 @@ class Options(AsDictMixin):
projects: list[Option]
issue_types: list[Option]
fields: list[Option]
mandatory_fields: list[Option]
epic_links: list[Option]


Expand Down Expand Up @@ -191,9 +192,11 @@ def get_options(self) -> Options:
# for more information on how to use the Jira API to discover meta data needed to create issues
projects = self.__get_project_options()
issue_types = self.__get_issue_type_options(projects)
fields = self.__get_field_options(issue_types)
fields_json = self.__get_fields(issue_types)
fields = [Option(str(field["fieldId"]), str(field["name"])) for field in fields_json]
mandatory_fields = self.__get_mandatory_field_options(fields_json, fields)
epic_links = self.__get_epic_links(fields)
return Options(projects, issue_types, fields, epic_links)
return Options(projects, issue_types, fields, mandatory_fields, epic_links)

def get_suggestions(self, query: str) -> list[IssueSuggestion]:
"""Get a list of issue id suggestions based on the query string."""
Expand Down Expand Up @@ -277,7 +280,7 @@ def __get_issue_type_options(self, projects: list[Option]) -> list[Option]: # p
issue_types = [issue_type for issue_type in issue_types if not issue_type["subtask"]]
return [Option(str(issue_type["id"]), str(issue_type["name"])) for issue_type in issue_types]

def __get_field_options(self, issue_types: list[Option]) -> list[Option]: # pragma: no feature-test-cover
def __get_fields(self, issue_types: list[Option]) -> list[dict[str, str | bool]]: # pragma: no feature-test-cover
"""Return the issue tracker fields for the current project and issue type."""
if not (current_issue_type := self.__current_issue_type(issue_types)):
# Current issue type is not an option, maybe the project was changed? Anyhow, no use getting fields
Expand All @@ -289,7 +292,24 @@ def __get_field_options(self, issue_types: list[Option]) -> list[Option]: # pra
fields = cast(JiraFieldsJSON, self.__get_json(api_url))[fields_json_key]
except Exception as reason:
logger.warning("Getting issue tracker field options at %s failed: %s", api_url, reason)
return [Option(str(field["fieldId"]), str(field["name"])) for field in fields]
return fields

def __get_mandatory_field_options( # pragma: no feature-test-cover
self, fields_json: list[dict[str, str | bool]], fields: list[Option]
) -> list[Option]:
"""Return the mandatory fields that Quality-time does not fill when creating issues.

Creating issues fails if the issue type has mandatory fields without default value that are not filled.
"""
filled_field_ids = {"project", "issuetype", "summary", "description", "reporter"} # Jira defaults the reporter
if self.issue_parameters.issue_labels:
filled_field_ids.add("labels")
if self.issue_parameters.epic_link:
filled_field_ids.add(self.__epic_link_field_id(fields))
mandatory_field_ids = [
field["fieldId"] for field in fields_json if field.get("required") and not field.get("hasDefaultValue")
]
return [field for field in fields if field.key in mandatory_field_ids and field.key not in filled_field_ids]

def __get_epic_links(self, fields: list[Option]) -> list[Option]: # pragma: no feature-test-cover
"""Return the possible epic links for the current project."""
Expand Down
41 changes: 40 additions & 1 deletion components/api_server/tests/routes/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ def test_get_issue_tracker_options_without_configured_report(self, requests_get)
"projects": [{"key": "FOO", "name": "Foo"}],
"issue_types": [],
"fields": [],
"mandatory_fields": [],
"epic_links": [],
}
self.assertEqual(expected_options, get_report_issue_tracker_options(self.database, REPORT_ID))
Expand All @@ -262,6 +263,7 @@ def test_get_issue_tracker_options_without_configured_issue_type(self, requests_
"projects": [{"key": "FOO", "name": "Foo"}],
"issue_types": [{"key": "1", "name": "Bug"}],
"fields": [],
"mandatory_fields": [],
"epic_links": [],
}
self.assertEqual(expected_options, get_report_issue_tracker_options(self.database, REPORT_ID))
Expand All @@ -283,10 +285,37 @@ def test_get_issue_tracker_options_with_configured_issue_type(self, requests_get
{"key": "labels", "name": "Labels"},
{"key": "epic_field_id", "name": self.epic_link_field_name},
],
"mandatory_fields": [],
"epic_links": [],
}
self.assertEqual(expected_options, get_report_issue_tracker_options(self.database, REPORT_ID))

@patch("requests.get")
def test_get_issue_tracker_options_with_mandatory_fields(self, requests_get):
"""Test that the mandatory fields that Quality-time does not fill are retrieved from the issue tracker."""
self.report["issue_tracker"] = {
"type": "jira",
"parameters": {
"url": self.ISSUE_TRACKER_URL,
"project_key": "FOO",
"issue_type": "Bug",
"issue_labels": ["label"],
},
}
self.fields_response.json.return_value = {
"values": [
{"fieldId": "summary", "name": "Summary", "required": True, "hasDefaultValue": False},
{"fieldId": "reporter", "name": "Reporter", "required": True, "hasDefaultValue": False},
{"fieldId": "labels", "name": "Labels", "required": True, "hasDefaultValue": False},
{"fieldId": "priority", "name": "Priority", "required": True, "hasDefaultValue": True},
{"fieldId": "customfield_1", "name": "Team", "required": True, "hasDefaultValue": False},
{"fieldId": "customfield_2", "name": "Sprint", "required": False, "hasDefaultValue": False},
],
}
requests_get.side_effect = [self.project_response, self.issue_types_response, self.fields_response]
options = get_report_issue_tracker_options(self.database, REPORT_ID)
self.assertEqual([{"key": "customfield_1", "name": "Team"}], options["mandatory_fields"])

@patch("requests.get")
@disable_logging
def test_get_issue_tracker_project_options_error(self, requests_get):
Expand All @@ -296,7 +325,14 @@ def test_get_issue_tracker_project_options_error(self, requests_get):
"parameters": {"url": self.ISSUE_TRACKER_URL, "project_key": "FOO", "issue_type": "Bug"},
}
requests_get.side_effect = RuntimeError("yo")
expected_options = {"ok": True, "projects": [], "issue_types": [], "fields": [], "epic_links": []}
expected_options = {
"ok": True,
"projects": [],
"issue_types": [],
"fields": [],
"mandatory_fields": [],
"epic_links": [],
}
self.assertEqual(expected_options, get_report_issue_tracker_options(self.database, REPORT_ID))

@patch("requests.get")
Expand All @@ -313,6 +349,7 @@ def test_get_issue_tracker_issue_type_options_error(self, requests_get):
"projects": [{"key": "FOO", "name": "Foo"}],
"issue_types": [],
"fields": [],
"mandatory_fields": [],
"epic_links": [],
}
self.assertEqual(expected_options, get_report_issue_tracker_options(self.database, REPORT_ID))
Expand All @@ -331,6 +368,7 @@ def test_get_issue_tracker_field_options_error(self, requests_get):
"projects": [{"key": "FOO", "name": "Foo"}],
"issue_types": [{"key": "1", "name": "Bug"}],
"fields": [],
"mandatory_fields": [],
"epic_links": [],
}
self.assertEqual(expected_options, get_report_issue_tracker_options(self.database, REPORT_ID))
Expand Down Expand Up @@ -362,6 +400,7 @@ def test_get_issue_tracker_epic_links_error(self, requests_get):
{"key": "labels", "name": "Labels"},
{"key": "epic_field_id", "name": self.epic_link_field_name},
],
"mandatory_fields": [],
"epic_links": [],
}
self.assertEqual(expected_options, get_report_issue_tracker_options(self.database, REPORT_ID))
Expand Down
1 change: 0 additions & 1 deletion components/frontend/src/dashboard/DashboardCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ export function DashboardCard({ children, onClick, selected, title, titleFirst }
const truncated = Boolean(el) && el.scrollWidth > el.clientWidth
// Measuring truncation requires reading the laid-out DOM and updating state; the guard keeps it to a single
// extra render, only when the truncation actually changes.
// eslint-disable-next-line @eslint-react/set-state-in-effect -- intentional measure-then-set, guarded above
setIsTruncated((wasTruncated) => (wasTruncated === truncated ? wasTruncated : truncated))
}, [])
// Recompute after each render (e.g. when the title changes) and whenever the window is resized.
Expand Down
57 changes: 33 additions & 24 deletions components/frontend/src/report/IssueTracker.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function IssueTracker({ report, reload }) {
const [projectValid, setProjectValid] = useState(true) // Is the current project a possible project?
const [issueTypeOptions, setIssueTypeOptions] = useState([]) // Possible issue types for new issues in the current project
const [issueTypeValid, setIssueTypeValid] = useState(true) // Is the current issue type a possible issue type?
const [mandatoryFields, setMandatoryFields] = useState([]) // Mandatory fields of the current issue type that Quality-time can't fill
const [labelFieldSupported, setLabelFieldSupported] = useState(false) // Does the current issue type support labels?
const [issueEpicOptions, setIssueEpicOptions] = useState([]) // Possible epic links for new issues in the current project
const [issueEpicFieldSupported, setIssueEpicFieldSupported] = useState(false) // Does the current project and issue type support epic links?
Expand Down Expand Up @@ -55,10 +56,9 @@ export function IssueTracker({ report, reload }) {
})),
)
const currentIssueType = report.issue_tracker?.parameters?.issue_type
setIssueTypeValid(
json.issue_types.some(({ key, name }) => [key, name].includes(currentIssueType)),
)
setIssueTypeValid(json.issue_types.some(({ key, name }) => [key, name].includes(currentIssueType)))
setIssueEpicOptions(json.epic_links.map(({ key, name }) => ({ key: key, value: key, text: name })))
setMandatoryFields(json.mandatory_fields.map((field) => field.name))
const fieldKeys = json.fields.map((field) => field.key)
setLabelFieldSupported(fieldKeys.includes("labels"))
const fieldNames = json.fields.map((field) => field.name.toLowerCase())
Expand Down Expand Up @@ -97,6 +97,7 @@ export function IssueTracker({ report, reload }) {
const issueTypeValue = issueTypeIsId
? issueType
: (issueTypeOptions.find((option) => option.text === issueType)?.value ?? issueType)
const issueTypeName = issueTypeOptions.find((option) => option.value === issueTypeValue)?.text ?? issueType
const epicLink = report.issue_tracker?.parameters?.epic_link
const apiVersion = report.issue_tracker
? dataModel.sources[report.issue_tracker?.type]?.parameters?.api_version
Expand Down Expand Up @@ -206,25 +207,33 @@ export function IssueTracker({ report, reload }) {
</TextField>
</Grid>
<Grid size={{ xs: 1, sm: 1, md: 1 }}>
<TextField
disabled={issueTrackerFieldDisabled}
error={!!report.issue_tracker?.type && issueType && !issueTypeValid}
helperText="The issue types available for new issues are determined by the selected project"
id="tracker-issue-type"
key={issueTypeValue} // Remount when the issue type name has been resolved to the issue type id
label="Issue type for new issues"
onChange={(value) => setReportIssueTrackerAttribute(reportUuid, "issue_type", value, reload)}
placeholder="None"
required={!!report.issue_tracker?.type}
select
value={issueTypeValue}
>
{issueTypeOptions.map((option) => (
<MenuItem key={option.key} value={option.value}>
{option.text}
</MenuItem>
))}
</TextField>
<Stack spacing={2}>
<TextField
disabled={issueTrackerFieldDisabled}
error={!!report.issue_tracker?.type && issueType && !issueTypeValid}
helperText="The issue types available for new issues are determined by the selected project"
id="tracker-issue-type"
key={issueTypeValue} // Remount when the issue type name has been resolved to the issue type id
label="Issue type for new issues"
onChange={(value) => setReportIssueTrackerAttribute(reportUuid, "issue_type", value, reload)}
placeholder="None"
required={!!report.issue_tracker?.type}
select
value={issueTypeValue}
>
{issueTypeOptions.map((option) => (
<MenuItem key={option.key} value={option.value}>
{option.text}
</MenuItem>
))}
</TextField>
<WarningMessage
showIf={Boolean(projectKey && issueType && mandatoryFields.length > 0)}
title="Mandatory fields not supported"
>
{`The issue type '${issueTypeName}' in project '${projectKey}' has mandatory fields that Quality-time cannot fill: ${mandatoryFields.join(", ")}. Creating issues will fail. Please select an issue type without mandatory fields.`}
</WarningMessage>
</Stack>
</Grid>
<Grid size={{ xs: 1, sm: 1, md: 1 }}>
<Stack spacing={2}>
Expand All @@ -248,7 +257,7 @@ export function IssueTracker({ report, reload }) {
showIf={Boolean(projectKey && issueType && !issueEpicFieldSupported)}
title="Epic links not supported"
>
{`The issue type '${issueType}' in project '${projectKey}' does not support adding epic links when creating issues, so no epic link will be added to new issues.`}
{`The issue type '${issueTypeName}' in project '${projectKey}' does not support adding epic links when creating issues, so no epic link will be added to new issues.`}
</WarningMessage>
</Stack>
</Grid>
Expand All @@ -268,7 +277,7 @@ export function IssueTracker({ report, reload }) {
showIf={Boolean(projectKey && issueType && !labelFieldSupported)}
title="Labels not supported"
>
{`The issue type '${issueType}' in project '${projectKey}' does not support adding labels when creating issues, so no labels will be added to new issues.`}
{`The issue type '${issueTypeName}' in project '${projectKey}' does not support adding labels when creating issues, so no labels will be added to new issues.`}
</WarningMessage>
</Stack>
</Grid>
Expand Down
39 changes: 39 additions & 0 deletions components/frontend/src/report/IssueTracker.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ beforeEach(() => {
projects: [{ key: "PRJ", name: "Project name" }],
issue_types: [{ key: "10001", name: "Bug" }],
fields: [{ key: "labels", name: "Labels" }],
mandatory_fields: [],
epic_links: [{ key: "FOO-420", name: "FOO-420" }],
}),
)
Expand Down Expand Up @@ -289,6 +290,7 @@ it("does show the issue labels warning with issue type that does not support lab
projects: [{ key: "PRJ", name: "Project name" }],
issue_types: [{ key: "Bug", name: "Bug" }],
fields: [],
mandatory_fields: [],
epic_links: [],
}),
)
Expand All @@ -305,6 +307,41 @@ it("does show the issue labels warning with issue type that does not support lab
expectText(/Labels not supported/)
})

it("does not show the mandatory fields warning with issue type without mandatory fields", async () => {
await renderIssueTracker({
report: {
report_uuid: "report_uuid",
title: "Report",
issue_tracker: { type: "jira", parameters: { project_key: "PRJ", issue_type: "10001" } },
},
})
expectNoText(/Mandatory fields not supported/)
})

it("shows the mandatory fields warning with issue type with mandatory fields", async () => {
reportApi.getReportIssueTrackerOptions.mockImplementation(() =>
Promise.resolve({
projects: [{ key: "PRJ", name: "Project name" }],
issue_types: [{ key: "10001", name: "Bug" }],
fields: [],
mandatory_fields: [
{ key: "customfield_1", name: "Team" },
{ key: "customfield_2", name: "Sprint" },
],
epic_links: [],
}),
)
await renderIssueTracker({
report: {
report_uuid: "report_uuid",
title: "Report",
issue_tracker: { type: "jira", parameters: { project_key: "PRJ", issue_type: "10001" } },
},
})
expectText(/Mandatory fields not supported/)
expectText(/The issue type 'Bug' in project 'PRJ' has mandatory fields that Quality-time cannot fill: Team, Sprint/)
})

it("ignores stale fetch responses when props change", async () => {
let resolveStale
reportApi.getReportIssueTrackerOptions
Expand All @@ -317,6 +354,7 @@ it("ignores stale fetch responses when props change", async () => {
],
issue_types: [],
fields: [],
mandatory_fields: [],
epic_links: [],
}),
)
Expand All @@ -335,6 +373,7 @@ it("ignores stale fetch responses when props change", async () => {
projects: [{ key: "STALE", name: "Stale project" }],
issue_types: [],
fields: [],
mandatory_fields: [],
epic_links: [],
}),
)
Expand Down
Loading
Loading