From 1cc6d6beb385cb84f1e85ddb97e40a8247116f88 Mon Sep 17 00:00:00 2001 From: Frank Niessink Date: Fri, 4 Aug 2023 13:44:43 +0200 Subject: [PATCH] Support creating issues for specific measurement entities. Closes #5955. --- components/api_server/src/routes/metric.py | 16 ++++++++++++---- .../api_server/tests/routes/test_metric.py | 16 ++++++++++++++++ .../src/base_collectors/metric_collector.py | 8 ++++---- .../source_collectors/jira/test_issue_status.py | 10 ++++++++++ components/frontend/src/api/metric.js | 4 ++-- components/frontend/src/issue/IssueStatus.js | 5 +++-- components/frontend/src/issue/IssuesRows.js | 13 ++++++++----- components/frontend/src/metric/MetricDetails.js | 7 ++++--- components/frontend/src/source/SourceEntities.js | 10 ++++++++-- components/frontend/src/source/SourceEntity.js | 11 ++++++++++- .../frontend/src/source/SourceEntity.test.js | 2 ++ .../frontend/src/source/SourceEntityDetails.js | 6 +++++- .../src/source/SourceEntityDetails.test.js | 2 ++ components/frontend/src/subject/SubjectTable.js | 1 + components/frontend/src/utils.js | 11 +++++++++-- docs/src/changelog.md | 1 + 16 files changed, 97 insertions(+), 26 deletions(-) diff --git a/components/api_server/src/routes/metric.py b/components/api_server/src/routes/metric.py index 443068c145..54b98da1e0 100644 --- a/components/api_server/src/routes/metric.py +++ b/components/api_server/src/routes/metric.py @@ -187,18 +187,26 @@ def add_metric_issue(metric_uuid: MetricId, database: Database): metric, subject = report.instance_and_parents_for_uuid(metric_uuid=metric_uuid) last_measurement = latest_successful_measurement(database, metric) measured_value = last_measurement.value() if last_measurement else "missing" + entity_key = dict(bottle.request.json).get("entity_key") issue_tracker = report.issue_tracker() issue_key, error = issue_tracker.create_issue(*create_issue_text(metric, measured_value)) if error: return {"ok": False, "error": error} else: # pragma: no feature-test-cover # noqa: RET505 - old_issue_ids = metric.get("issue_ids") or [] - new_issue_ids = sorted([issue_key, *old_issue_ids]) + new_issue_ids: list[str] | dict[str, list[str]] + if entity_key: + old_issue_ids = metric.get("entity_issue_ids", {}) + new_issue_ids = {**old_issue_ids} + new_issue_ids.setdefault(entity_key, []).append(issue_key) + report["subjects"][subject.uuid]["metrics"][metric_uuid]["entity_issue_ids"] = new_issue_ids + else: + old_issue_ids = metric.get("issue_ids") or [] + new_issue_ids = sorted([issue_key, *old_issue_ids]) + report["subjects"][subject.uuid]["metrics"][metric_uuid]["issue_ids"] = new_issue_ids description = ( - f"{{user}} changed the issue_ids of metric '{metric.name}' of subject " + f"{{user}} changed the {'entity_' if entity_key else ''}issue_ids of metric '{metric.name}' of subject " f"'{subject.name}' in report '{report.name}' from '{old_issue_ids}' to '{new_issue_ids}'." ) - report["subjects"][subject.uuid]["metrics"][metric_uuid]["issue_ids"] = new_issue_ids insert_new_report(database, description, [report.uuid, subject.uuid, metric.uuid], report) return {"ok": True, "issue_url": issue_tracker.browse_url(issue_key)} diff --git a/components/api_server/tests/routes/test_metric.py b/components/api_server/tests/routes/test_metric.py index 205e82d143..8664a4f9f7 100644 --- a/components/api_server/tests/routes/test_metric.py +++ b/components/api_server/tests/routes/test_metric.py @@ -582,6 +582,12 @@ def assert_issue_inserted(self): inserted_issue_ids = inserted_report["subjects"][SUBJECT_ID]["metrics"][METRIC_ID]["issue_ids"] self.assertEqual(["FOO-42"], inserted_issue_ids) + def assert_entity_issue_inserted(self): + """Check that the entity issue is inserted in the database.""" + inserted_report = self.database.reports.insert_one.call_args_list[0][0][0] + inserted_entity_issue_ids = inserted_report["subjects"][SUBJECT_ID]["metrics"][METRIC_ID]["entity_issue_ids"] + self.assertEqual({"key": ["FOO-42"]}, inserted_entity_issue_ids) + @patch("bottle.request", Mock(json={"metric_url": METRIC_URL})) def test_add_metric_issue(self, requests_post): """Test that an issue can be added to the issue tracker.""" @@ -592,6 +598,16 @@ def test_add_metric_issue(self, requests_post): self.assert_issue_posted(requests_post) self.assert_issue_inserted() + @patch("bottle.request", Mock(json={"entity_key": "key", "metric_url": METRIC_URL})) + def test_add_metric_entity_issue(self, requests_post): + """Test that an issue can be added to the issue tracker and linked to a measurement entity.""" + response = Mock() + response.json.return_value = {"key": "FOO-42"} + requests_post.return_value = response + self.assertEqual({"ok": True, "issue_url": self.ISSUE_URL}, add_metric_issue(METRIC_ID, self.database)) + self.assert_issue_posted(requests_post) + self.assert_entity_issue_inserted() + @patch("bottle.request", Mock(json={"metric_url": METRIC_URL})) @patch("model.issue_tracker.requests.get") def test_add_metric_issue_with_labels(self, requests_get, requests_post): diff --git a/components/collector/src/base_collectors/metric_collector.py b/components/collector/src/base_collectors/metric_collector.py index c492caee10..6f96e74b01 100644 --- a/components/collector/src/base_collectors/metric_collector.py +++ b/components/collector/src/base_collectors/metric_collector.py @@ -70,10 +70,10 @@ def __issue_status_collectors(self) -> list[Coroutine]: tracker_type = tracker.get("type") has_tracker = bool(tracker_type and tracker.get("parameters", {}).get("url")) if has_tracker and (collector_class := SourceCollector.get_subclass(tracker_type, "issue_status")): - return [ - collector_class(self.__session, tracker).collect_issue_status(issue_id) - for issue_id in self._metric.get("issue_ids", []) - ] + issue_ids = self._metric.get("issue_ids", []) + for entity_issue_id_list in self._metric.get("entity_issue_ids", {}).values(): + issue_ids.extend(entity_issue_id_list) + return [collector_class(self.__session, tracker).collect_issue_status(issue_id) for issue_id in issue_ids] return [] def __has_all_mandatory_parameters(self, source) -> bool: diff --git a/components/collector/tests/source_collectors/jira/test_issue_status.py b/components/collector/tests/source_collectors/jira/test_issue_status.py index dbad29d323..e2aa99474d 100644 --- a/components/collector/tests/source_collectors/jira/test_issue_status.py +++ b/components/collector/tests/source_collectors/jira/test_issue_status.py @@ -84,6 +84,16 @@ async def test_issue_status(self): response = await self.collect(get_request_json_return_value=issue_status_json) self.assert_issue_status(response) + async def test_entity_issue_status(self): + """Test that the issue status is returned for a measurement entity.""" + self.metric["issue_ids"] = [] + self.metric["entity_issue_ids"] = {"entity key": ["FOO-42"]} + issue_status_json = { + "fields": {"status": {"name": self.ISSUE_NAME, "statusCategory": {"key": "new"}}, "created": self.CREATED}, + } + response = await self.collect(get_request_json_return_value=issue_status_json) + self.assert_issue_status(response) + async def test_issue_status_doing(self): """Test that the issue status is returned.""" issue_status_json = { diff --git a/components/frontend/src/api/metric.js b/components/frontend/src/api/metric.js index bf15dd6e9d..57030c1508 100644 --- a/components/frontend/src/api/metric.js +++ b/components/frontend/src/api/metric.js @@ -25,8 +25,8 @@ export function set_metric_debt(metric_uuid, value, reload) { fetch_server_api('post', `metric/${metric_uuid}/debt`, { "accept_debt": value }).then(reload) } -export function add_metric_issue(metric_uuid, reload) { - const payload = {metric_url: `${window.location}#${metric_uuid}`} +export function add_metric_issue(entityKey, metric_uuid, reload) { + const payload = {metric_url: `${window.location}#${metric_uuid}`, entity_key: entityKey} return fetch_server_api('post', `metric/${metric_uuid}/issue/new`, payload).then((json) => { if (json.ok) { window.open(json.issue_url) diff --git a/components/frontend/src/issue/IssueStatus.js b/components/frontend/src/issue/IssueStatus.js index b671deb4c9..9a6099e13f 100644 --- a/components/frontend/src/issue/IssueStatus.js +++ b/components/frontend/src/issue/IssueStatus.js @@ -173,14 +173,15 @@ IssuesWithTracker.propTypes = { settings: settingsPropType } -export function IssueStatus({ metric, issueTrackerMissing, settings }) { - const issueIds = getMetricIssueIds(metric) +export function IssueStatus({ entityKey, metric, issueTrackerMissing, settings }) { + const issueIds = getMetricIssueIds(metric, entityKey) if (issueTrackerMissing && issueIds.length > 0) { return } return } IssueStatus.propTypes = { + entityKey: PropTypes.string, issueTrackerMissing: PropTypes.bool, metric: metricPropType, settings: settingsPropType diff --git a/components/frontend/src/issue/IssuesRows.js b/components/frontend/src/issue/IssuesRows.js index 4b95f86162..4be8905f28 100644 --- a/components/frontend/src/issue/IssuesRows.js +++ b/components/frontend/src/issue/IssuesRows.js @@ -11,7 +11,7 @@ import { ErrorMessage } from '../errorMessage'; import { getMetricIssueIds } from '../utils'; import { metricPropType, reportPropType } from '../sharedPropTypes'; -function CreateIssueButton({ issueTrackerConfigured, issueTrackerInstruction, metric_uuid, target, reload }) { +function CreateIssueButton({ entityKey, issueTrackerConfigured, issueTrackerInstruction, metric_uuid, target, reload }) { return ( add_metric_issue(metric_uuid, reload)} + onClick={() => add_metric_issue(entityKey, metric_uuid, reload)} popup={<>Create a new issue for this {target} in the configured issue tracker and add its identifier to the tracked issue identifiers.{issueTrackerInstruction}} position='top center' /> ) } CreateIssueButton.propTypes = { + entityKey: PropTypes.string, issueTrackerConfigured: PropTypes.bool, issueTrackerInstruction: PropTypes.node, metric_uuid: PropTypes.string, @@ -77,11 +78,12 @@ IssueIdentifiers.propTypes = { reload: PropTypes.func } -export function IssuesRows({ metric, metric_uuid, reload, report, target }) { +export function IssuesRows({ entityKey, metric, metric_uuid, reload, report, target }) { const parameters = report?.issue_tracker?.parameters; const issueTrackerConfigured = Boolean(report?.issue_tracker?.type && parameters?.url && parameters?.project_key && parameters?.issue_type); const issueTrackerInstruction = issueTrackerConfigured ? null :

Please configure an issue tracker by expanding the report title, selecting the 'Issue tracker' tab, and configuring an issue tracker.

; const issueIdentifiersProps = { + entityKey: entityKey, issueTrackerInstruction: issueTrackerInstruction, metric: metric, metric_uuid: metric_uuid, @@ -103,6 +105,7 @@ export function IssuesRows({ metric, metric_uuid, reload, report, target }) { <> - {(getMetricIssueIds(metric).length > 0 && !issueTrackerConfigured) && + {(getMetricIssueIds(metric, entityKey).length > 0 && !issueTrackerConfigured) && @@ -156,4 +159,4 @@ IssuesRows.propTypes = { reload: PropTypes.func, report: reportPropType, target: PropTypes.string, -} \ No newline at end of file +} diff --git a/components/frontend/src/metric/MetricDetails.js b/components/frontend/src/metric/MetricDetails.js index d7f96d6202..f1db9e33fd 100644 --- a/components/frontend/src/metric/MetricDetails.js +++ b/components/frontend/src/metric/MetricDetails.js @@ -18,7 +18,7 @@ import { MetricConfigurationParameters } from './MetricConfigurationParameters'; import { MetricDebtParameters } from './MetricDebtParameters'; import { MetricTypeHeader } from './MetricTypeHeader'; import { TrendGraph } from './TrendGraph'; -import { datePropType, reportPropType, reportsPropType, stringsPropType, stringsURLSearchQueryPropType} from '../sharedPropTypes'; +import { datePropType, reportPropType, reportsPropType, settingsPropType, stringsPropType, stringsURLSearchQueryPropType } from '../sharedPropTypes'; function Buttons({ isFirstMetric, isLastMetric, metric_uuid, reload, stopFilteringAndSorting }) { return ( @@ -54,7 +54,6 @@ fetchMeasurements.propTypes = { setMeasurements: PropTypes.func } - export function MetricDetails({ changed_fields, isFirstMetric, @@ -64,6 +63,7 @@ export function MetricDetails({ report_date, reports, report, + settings, stopFilteringAndSorting, subject_uuid, expandedItems, @@ -142,7 +142,7 @@ export function MetricDetails({ const source_name = get_source_name(report_source, dataModel); panes.push({ menuItem: {source_name}, - render: () => + render: () => }); }); } @@ -174,6 +174,7 @@ MetricDetails.propTypes = { report_date: datePropType, reports: reportsPropType, report: reportPropType, + settings: settingsPropType, stopFilteringAndSorting: PropTypes.func, subject_uuid: PropTypes.string, expandedItems: stringsURLSearchQueryPropType diff --git a/components/frontend/src/source/SourceEntities.js b/components/frontend/src/source/SourceEntities.js index 6597e6ccfc..d621e42d0d 100644 --- a/components/frontend/src/source/SourceEntities.js +++ b/components/frontend/src/source/SourceEntities.js @@ -4,7 +4,7 @@ import { Button, Icon, Popup, Table } from '../semantic_ui_react_wrappers'; import { SourceEntity } from './SourceEntity'; import { capitalize } from '../utils'; import { DataModel } from '../context/DataModel'; -import { metricPropType, reportPropType, sourcePropType } from '../sharedPropTypes'; +import { metricPropType, reportPropType, settingsPropType, sourcePropType } from '../sharedPropTypes'; export function alignment(attributeType, attributeAlignment) { if (attributeAlignment === "left" || attributeAlignment === "right") { @@ -18,7 +18,7 @@ alignment.propTypes = { attributeAligment: PropTypes.string } -export function SourceEntities({ metric, metric_uuid, reload, report, source }) { +export function SourceEntities({ metric, metric_uuid, reload, report, settings, source }) { const dataModel = useContext(DataModel) const [hideIgnoredEntities, setHideIgnoredEntities] = useState(false); const [sortColumn, setSortColumn] = useState(null); @@ -73,6 +73,9 @@ export function SourceEntities({ metric, metric_uuid, reload, report, source }) > {capitalize(entity_name)} first seen + + Issues + {entity_attributes.map((entity_attribute) => {status === "unconfirmed" ? "" : status_end_date} {status === "unconfirmed" ? "" : rationale} {entity.first_seen ? : ""} + + + {entity_attributes.map((entity_attribute) => diff --git a/components/frontend/src/source/SourceEntityDetails.js b/components/frontend/src/source/SourceEntityDetails.js index 0a4eee5a47..5c229159eb 100644 --- a/components/frontend/src/source/SourceEntityDetails.js +++ b/components/frontend/src/source/SourceEntityDetails.js @@ -9,7 +9,8 @@ import { capitalize } from '../utils'; import { source_entity_status_name as status_name } from './source_entity_status'; import { EDIT_ENTITY_PERMISSION } from '../context/Permissions'; import { LabelWithDate } from '../widgets/LabelWithDate'; -import { entityPropType, entityStatusPropType, reportPropType } from '../sharedPropTypes'; +import { IssuesRows } from '../issue/IssuesRows'; +import { entityPropType, entityStatusPropType, metricPropType, reportPropType } from '../sharedPropTypes'; function entityStatusOption(status, text, content, subheader) { return { @@ -46,6 +47,7 @@ entityStatusOptions.propTypes = { export function SourceEntityDetails( { entity, + metric, metric_uuid, name, rationale, @@ -96,11 +98,13 @@ export function SourceEntityDetails( /> + ); } SourceEntityDetails.propTypes = { entity: entityPropType, + metric: metricPropType, metric_uuid: PropTypes.string, name: PropTypes.string, rationale: PropTypes.string, diff --git a/components/frontend/src/source/SourceEntityDetails.test.js b/components/frontend/src/source/SourceEntityDetails.test.js index 20d7558949..9ab8e9bff9 100644 --- a/components/frontend/src/source/SourceEntityDetails.test.js +++ b/components/frontend/src/source/SourceEntityDetails.test.js @@ -15,9 +15,11 @@ function renderSourceEntityDetails() { render( { handleSort(null) settings.hiddenTags.reset() diff --git a/components/frontend/src/utils.js b/components/frontend/src/utils.js index f1c2a18811..0c24e0bdce 100644 --- a/components/frontend/src/utils.js +++ b/components/frontend/src/utils.js @@ -243,8 +243,15 @@ nrMetricsInReport.propTypes = { reports: reportsPropType } -export function getMetricIssueIds(metric) { - let issueIds = metric.issue_ids ?? []; +export function getMetricIssueIds(metric, entityKey) { + let issueIds = []; + if (entityKey) { + issueIds = metric.entity_issue_ids?.[entityKey] ?? []; + } else { + issueIds = metric.issue_ids ?? []; + Object.values(metric?.entity_issue_ids ?? {}).forEach((entityIssueIds) => issueIds.push(...entityIssueIds)) + } + issueIds = [...new Set(issueIds)]; sortWithLocaleCompare(issueIds); return issueIds } diff --git a/docs/src/changelog.md b/docs/src/changelog.md index c8ce9eacb2..62d84d8d24 100644 --- a/docs/src/changelog.md +++ b/docs/src/changelog.md @@ -58,6 +58,7 @@ If your currently installed *Quality-time* version is v4.10.0 or older, please r ### Added - Add a metric for tracking todo and fixme comments in source code. Closes [#5630](https://github.com/ICTU/quality-time/issues/5630). +- Support creating issues for specific measurement entities. Closes [#5955](https://github.com/ICTU/quality-time/issues/5955). - Support version 4.0 of the OWASP Dependency Check DTD (OWASP Dependency Check version 9). Closes [#7655](https://github.com/ICTU/quality-time/issues/7655). ### Changed