Skip to content

Commit

Permalink
Support creating issues for specific measurement entities. Closes #5955.
Browse files Browse the repository at this point in the history
  • Loading branch information
fniessink committed Dec 6, 2023
1 parent f7e05c2 commit 28207d6
Show file tree
Hide file tree
Showing 19 changed files with 375 additions and 207 deletions.
16 changes: 12 additions & 4 deletions components/api_server/src/routes/metric.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}

Expand Down
16 changes: 16 additions & 0 deletions components/api_server/tests/routes/test_metric.py
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,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."""
Expand All @@ -661,6 +667,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):
Expand Down
8 changes: 4 additions & 4 deletions components/collector/src/base_collectors/metric_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
5 changes: 3 additions & 2 deletions components/frontend/src/api/metric.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ 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) {
return fetch_server_api('post', `metric/${metric_uuid}/issue/new`, {metric_url: `${window.location}#${metric_uuid}`}).then((json) => {
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)
} else {
Expand Down
12 changes: 7 additions & 5 deletions components/frontend/src/issue/IssueStatus.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,29 +157,31 @@ issuePopupContent.propTypes = {
issueStatus: issueStatusPropType
}

function IssuesWithTracker({ metric, settings }) {
function IssuesWithTracker({ issueIds, metric, settings }) {
const issueStatuses = metric.issue_status || [];
return <>
{
issueStatuses.map((issueStatus) =>
issueStatuses.filter((issueStatus) => issueIds.indexOf(issueStatus.issue_id) > -1).map((issueStatus) =>
<IssueWithTracker key={issueStatus.issue_id} issueStatus={issueStatus} settings={settings} />
)
}
</>
}
IssuesWithTracker.propTypes = {
issueIds: stringsPropType,
metric: metricPropType,
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 <IssuesWithoutTracker issueIds={issueIds} />
}
return <IssuesWithTracker metric={metric} settings={settings} />
return <IssuesWithTracker issueIds={issueIds} metric={metric} settings={settings} />
}
IssueStatus.propTypes = {
entityKey: PropTypes.string,
issueTrackerMissing: PropTypes.bool,
metric: metricPropType,
settings: settingsPropType
Expand Down
162 changes: 162 additions & 0 deletions components/frontend/src/issue/IssuesRows.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Grid } from 'semantic-ui-react';
import { set_metric_attribute, add_metric_issue } from '../api/metric';
import { get_report_issue_tracker_suggestions } from '../api/report';
import { EDIT_REPORT_PERMISSION, ReadOnlyOrEditable } from '../context/Permissions';
import { MultipleChoiceInput } from '../fields/MultipleChoiceInput';
import { ActionButton } from '../widgets/Button';
import { LabelWithHelp } from '../widgets/LabelWithHelp';
import { ErrorMessage } from '../errorMessage';
import { getMetricIssueIds } from '../utils';
import { metricPropType, reportPropType } from '../sharedPropTypes';

function CreateIssueButton({ entityKey, issueTrackerConfigured, issueTrackerInstruction, metric_uuid, target, reload }) {
return (
<ActionButton
action='Create new'
disabled={!issueTrackerConfigured}
fluid
icon='plus'
item_type='issue'
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,
target: PropTypes.string,
reload: PropTypes.func
}

function IssueIdentifiers({ entityKey, issueTrackerInstruction, metric, metric_uuid, report_uuid, target, reload }) {
const issueStatusHelp = (
<>
<p>Identifiers of issues in the configured issue tracker that track the progress of fixing this {target}.</p>
<p>When the issues have all been resolved, or the technical debt end date has passed, whichever happens first, the technical debt should be resolved and the technical debt target is no longer evaluated.</p>
{issueTrackerInstruction}
</>
)
const [suggestions, setSuggestions] = useState([]);
const labelId = `issue-identifiers-label-${metric_uuid}`
const issue_ids = getMetricIssueIds(metric, entityKey);
return (
<MultipleChoiceInput
aria-labelledby={labelId}
allowAdditions
onSearchChange={(query) => {
if (query) {
get_report_issue_tracker_suggestions(report_uuid, query).then((suggestionsResponse) => {
const suggestionOptions = suggestionsResponse.suggestions.map((s) => ({ key: s.key, text: `${s.key}: ${s.text}`, value: s.key }))
setSuggestions(suggestionOptions)
})
} else {
setSuggestions([])
}
}}
requiredPermissions={[EDIT_REPORT_PERMISSION]}
label={<LabelWithHelp labelId={labelId} label="Issue identifiers" help={issueStatusHelp} />}
options={suggestions}
set_value={(value) => set_metric_attribute(metric_uuid, "issue_ids", value, reload)}
value={issue_ids}
key={issue_ids} // Make sure the multiple choice input is rerendered when the issue ids change
/>
)
}
IssueIdentifiers.propTypes = {
entityKey: PropTypes.string,
issueTrackerInstruction: PropTypes.node,
metric: metricPropType,
metric_uuid: PropTypes.string,
report_uuid: PropTypes.string,
target: PropTypes.string,
reload: PropTypes.func
}

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 : <p>Please configure an issue tracker by expanding the report title, selecting the 'Issue tracker' tab, and configuring an issue tracker.</p>;
const issueIdentifiersProps = {
entityKey: entityKey,
issueTrackerInstruction: issueTrackerInstruction,
metric: metric,
metric_uuid: metric_uuid,
report_uuid: report.report_uuid,
target: target ?? "metric",
reload: reload
}
return (
<>
<Grid.Row>
<ReadOnlyOrEditable
requiredPermissions={[EDIT_REPORT_PERMISSION]}
readOnlyComponent={
<Grid.Column width={16}>
<IssueIdentifiers {...issueIdentifiersProps} />
</Grid.Column>
}
editableComponent={
<>
<Grid.Column width={3} verticalAlign="bottom">
<CreateIssueButton
entityKey={entityKey}
issueTrackerConfigured={issueTrackerConfigured}
issueTrackerInstruction={issueTrackerInstruction}
metric_uuid={metric_uuid}
target={target ?? "metric"}
reload={reload}
/>
</Grid.Column>
<Grid.Column width={13}>
<IssueIdentifiers {...issueIdentifiersProps} />
</Grid.Column>
</>
}
/>
</Grid.Row>
{(getMetricIssueIds(metric, entityKey).length > 0 && !issueTrackerConfigured) &&
<Grid.Row>
<Grid.Column width={16}>
<ErrorMessage title="No issue tracker configured" message={issueTrackerInstruction} />
</Grid.Column>
</Grid.Row>
}
{(metric.issue_status ?? []).filter((issue_status => issue_status.connection_error)).map((issue_status) =>
<Grid.Row key={issue_status.issue_id}>
<Grid.Column width={16}>
<ErrorMessage
key={issue_status.issue_id}
title={"Connection error while retrieving " + issue_status.issue_id}
message={issue_status.connection_error}
/>
</Grid.Column>
</Grid.Row>
)}
{(metric.issue_status ?? []).filter((issue_status => issue_status.parse_error)).map((issue_status) =>
<Grid.Row key={issue_status.issue_id}>
<Grid.Column width={16}>
<ErrorMessage
key={issue_status.issue_id}
title={"Parse error while processing " + issue_status.issue_id}
message={issue_status.parse_error}
/>
</Grid.Column>
</Grid.Row>
)}
</>
)
}
IssuesRows.propTypes = {
entityKey: PropTypes.string,
metric: metricPropType,
metric_uuid: PropTypes.string,
reload: PropTypes.func,
report: reportPropType,
target: PropTypes.string,
}

0 comments on commit 28207d6

Please sign in to comment.