From e3bc1602fb7b78b4936faf2c5eeba92454397a4e Mon Sep 17 00:00:00 2001 From: Frank Niessink Date: Sat, 1 Jun 2024 16:48:32 +0200 Subject: [PATCH] Add more subject types. Closes #3130. --- components/api_server/src/model/actions.py | 2 +- components/api_server/src/model/defaults.py | 2 +- .../source_collectors/quality_time/metrics.py | 2 +- .../quality_time/missing_metrics.py | 8 +- .../quality_time/test_missing_metrics.py | 42 ++-- components/frontend/src/metric/MetricType.js | 5 +- components/frontend/src/sharedPropTypes.js | 10 + .../frontend/src/subject/SubjectTitle.js | 4 +- .../frontend/src/subject/SubjectType.js | 22 +- components/frontend/src/utils.js | 54 ++++- components/frontend/src/utils.test.js | 57 ++++++ .../frontend/src/widgets/menu_options.js | 4 +- .../src/models/metric_notification_data.py | 2 +- .../src/shared_data_model/meta/data_model.py | 9 +- .../src/shared_data_model/meta/subject.py | 33 ++- .../src/shared_data_model/subjects.py | 190 ++++++++++++------ .../shared_data_model/meta/test_subject.py | 28 +++ docs/src/changelog.md | 8 +- docs/src/conf.py | 2 +- docs/src/create_reference_md.py | 16 +- docs/src/usage.md | 10 +- 21 files changed, 394 insertions(+), 116 deletions(-) create mode 100644 components/shared_code/tests/shared_data_model/meta/test_subject.py diff --git a/components/api_server/src/model/actions.py b/components/api_server/src/model/actions.py index b4654d83d6..1637045167 100644 --- a/components/api_server/src/model/actions.py +++ b/components/api_server/src/model/actions.py @@ -46,7 +46,7 @@ def copy_subject(subject, change_name: bool = True): "metrics": {uuid(): copy_metric(metric, change_name=False) for metric in subject["metrics"].values()}, } if change_name: - kwargs["name"] = f"{subject.get('name') or DATA_MODEL.subjects[subject['type']].name} (copy)" + kwargs["name"] = f"{subject.get('name') or DATA_MODEL.all_subjects[subject['type']].name} (copy)" return copy_item(subject, **kwargs) diff --git a/components/api_server/src/model/defaults.py b/components/api_server/src/model/defaults.py index 43f0c26249..3ffb8636d2 100644 --- a/components/api_server/src/model/defaults.py +++ b/components/api_server/src/model/defaults.py @@ -37,5 +37,5 @@ def default_report_attributes() -> dict[str, str | dict]: def default_subject_attributes(subject_type: str) -> dict[str, Any]: """Return the default attributes with their default values for the specified subject type.""" - subject = DATA_MODEL.subjects[subject_type] + subject = DATA_MODEL.all_subjects[subject_type] return {"type": subject_type, "name": None, "description": subject.description, "metrics": {}} diff --git a/components/collector/src/source_collectors/quality_time/metrics.py b/components/collector/src/source_collectors/quality_time/metrics.py index 2b89eb9a68..9ee3dd2584 100644 --- a/components/collector/src/source_collectors/quality_time/metrics.py +++ b/components/collector/src/source_collectors/quality_time/metrics.py @@ -61,7 +61,7 @@ async def __get_metrics_and_entities(self, response: Response) -> list[tuple[dic if self.__metric_is_to_be_measured(metric, metric_types, source_types, tags): metric["report_uuid"] = report["report_uuid"] metric["subject_uuid"] = subject_uuid - subject_name = str(subject.get("name") or DATA_MODEL.subjects[subject["type"]].name) + subject_name = str(subject.get("name") or DATA_MODEL.all_subjects[subject["type"]].name) entity = Entity(key=metric_uuid, report=report["title"], subject=subject_name) metrics_and_entities.append((metric, entity)) return metrics_and_entities diff --git a/components/collector/src/source_collectors/quality_time/missing_metrics.py b/components/collector/src/source_collectors/quality_time/missing_metrics.py index fc0aff08bc..7300f0bd76 100644 --- a/components/collector/src/source_collectors/quality_time/missing_metrics.py +++ b/components/collector/src/source_collectors/quality_time/missing_metrics.py @@ -1,7 +1,5 @@ """Quality-time missing metrics collector.""" -from typing import cast - from collector_utilities.functions import match_string_or_regular_expression from collector_utilities.type import URL from model import SourceMeasurement, SourceResponses @@ -103,4 +101,8 @@ def __subject_missing_metric_types(self, data_model: dict, subject: dict) -> lis @staticmethod def __subject_possible_metric_types(data_model: dict, subject: dict) -> list[str]: """Return the subject's possible metric types.""" - return cast(list[str], data_model["subjects"][subject["type"]]["metrics"]) + subject_type = data_model["subjects"][subject["type"]] + metric_types = set(subject_type.get("metrics", [])) + for child_subject in subject_type.get("subjects", {}).values(): + metric_types |= set(child_subject.get("metrics", [])) + return sorted(metric_types) diff --git a/components/collector/tests/source_collectors/quality_time/test_missing_metrics.py b/components/collector/tests/source_collectors/quality_time/test_missing_metrics.py index f7299d1dff..ba55505f29 100644 --- a/components/collector/tests/source_collectors/quality_time/test_missing_metrics.py +++ b/components/collector/tests/source_collectors/quality_time/test_missing_metrics.py @@ -17,8 +17,7 @@ def setUp(self): """Set up test data.""" super().setUp() self.data_model = json.loads(DATA_MODEL_JSON) - self.set_source_parameter("reports", ["r1", "r3"]) - self.expected_software_metrics = str(2 * len(self.data_model["subjects"]["software"]["metrics"])) + self.expected_software_metrics = str(2 * len(self.subject_metrics(self.data_model["subjects"]["software"]))) self.reports["reports"].append( { "title": "R3", @@ -48,25 +47,36 @@ def setUp(self): }, ) self.entities = [] + metric_types = self.subject_metrics(self.data_model["subjects"]["software"]) for report in self.reports["reports"]: for subject_uuid, subject in report.get("subjects", {}).items(): - for metric_type in self.data_model["subjects"]["software"]["metrics"]: + for metric_type in metric_types: if metric_type not in ["violations", "loc"]: - self.entities.append( - { - "key": f"{report['report_uuid']}:{subject_uuid}:{metric_type}", - "report": report["title"], - "report_url": f"https://quality_time/{report['report_uuid']}", - "subject": subject["name"], - "subject_url": f"https://quality_time/{report['report_uuid']}#{subject_uuid}", - "subject_uuid": f"{subject_uuid}", - "subject_type": self.data_model["subjects"][subject["type"]]["name"], - "metric_type": self.data_model["metrics"][metric_type]["name"], - }, - ) + self.entities.append(self.create_entity(report, subject_uuid, subject, metric_type)) + + def subject_metrics(self, subject_type) -> list[str]: + """Return the metric types supported by the subject type.""" + metric_types = set(subject_type.get("metrics", [])) + for child_subject_type in subject_type.get("subjects", {}).values(): + metric_types |= set(child_subject_type.get("metrics", [])) + return sorted(metric_types) + + def create_entity(self, report, subject_uuid: str, subject, metric_type: str) -> dict[str, str]: + """Create a missing metric entity.""" + return { + "key": f"{report['report_uuid']}:{subject_uuid}:{metric_type}", + "report": report["title"], + "report_url": f"https://quality_time/{report['report_uuid']}", + "subject": subject["name"], + "subject_url": f"https://quality_time/{report['report_uuid']}#{subject_uuid}", + "subject_uuid": f"{subject_uuid}", + "subject_type": self.data_model["subjects"][subject["type"]]["name"], + "metric_type": self.data_model["metrics"][metric_type]["name"], + } async def test_nr_of_metrics(self): """Test that the number of missing metrics is returned.""" + self.set_source_parameter("reports", ["r1", "r3"]) response = await self.collect(get_request_json_side_effect=[self.data_model, self.reports]) self.assert_measurement( response, @@ -94,12 +104,14 @@ async def test_nr_of_missing_metrics_without_correct_report(self): async def test_subjects_to_ignore_by_name(self): """Test that the number of non-ignored missing metrics is returned when filtered by name.""" + self.set_source_parameter("reports", ["r1", "r3"]) self.set_source_parameter("subjects_to_ignore", ["S2"]) response = await self.collect(get_request_json_side_effect=[self.data_model, self.reports]) self.assert_measurement(response, value=str(int(len(self.entities) / 2)), total=self.expected_software_metrics) async def test_subjects_to_ignore_by_uuid(self): """Test that the number of non-ignored missing metrics is returned when filtered by uuid.""" + self.set_source_parameter("reports", ["r1", "r3"]) first_subject_uuid = first(first(self.reports["reports"])["subjects"].keys()) self.set_source_parameter("subjects_to_ignore", [first_subject_uuid]) response = await self.collect(get_request_json_side_effect=[self.data_model, self.reports]) diff --git a/components/frontend/src/metric/MetricType.js b/components/frontend/src/metric/MetricType.js index 97a7d8332d..6ae6a57a47 100644 --- a/components/frontend/src/metric/MetricType.js +++ b/components/frontend/src/metric/MetricType.js @@ -6,6 +6,7 @@ import { DataModel } from "../context/DataModel" import { EDIT_REPORT_PERMISSION } from "../context/Permissions" import { SingleChoiceInput } from "../fields/SingleChoiceInput" import { Header } from "../semantic_ui_react_wrappers" +import { getSubjectTypeMetrics } from "../utils" export function metricTypeOption(key, metricType) { return { @@ -18,7 +19,9 @@ export function metricTypeOption(key, metricType) { export function metricTypeOptions(dataModel, subjectType) { // Return menu options for all metric that support the subject type - return dataModel.subjects[subjectType].metrics.map((key) => metricTypeOption(key, dataModel.metrics[key])) + return getSubjectTypeMetrics(subjectType, dataModel.subjects).map((key) => + metricTypeOption(key, dataModel.metrics[key]), + ) } export function allMetricTypeOptions(dataModel) { diff --git a/components/frontend/src/sharedPropTypes.js b/components/frontend/src/sharedPropTypes.js index 1cbcc85e9c..e657f23199 100644 --- a/components/frontend/src/sharedPropTypes.js +++ b/components/frontend/src/sharedPropTypes.js @@ -184,9 +184,19 @@ export const metricTypePropType = shape({ name: string, }) +// Construct a recursive prop type for the subject type +const subjectTypeShape = { + description: string, + metrics: stringsPropType, + name: string, +} +subjectTypeShape.subjects = arrayOf(shape(subjectTypeShape)) +export const subjectTypePropType = shape(subjectTypeShape) + export const dataModelPropType = shape({ metrics: objectOf(metricTypePropType), sources: objectOf(sourceTypePropType), + subjects: objectOf(subjectTypePropType), }) export const destinationPropType = shape({ diff --git a/components/frontend/src/subject/SubjectTitle.js b/components/frontend/src/subject/SubjectTitle.js index 1298daf98a..c3b99686bf 100644 --- a/components/frontend/src/subject/SubjectTitle.js +++ b/components/frontend/src/subject/SubjectTitle.js @@ -10,7 +10,7 @@ import { EDIT_REPORT_PERMISSION, ReadOnlyOrEditable } from "../context/Permissio import { Header, Tab } from "../semantic_ui_react_wrappers" import { Share } from "../share/Share" import { reportPropType, settingsPropType } from "../sharedPropTypes" -import { slugify } from "../utils" +import { getSubjectType, slugify } from "../utils" import { DeleteButton, ReorderButtonGroup } from "../widgets/Button" import { FocusableTab } from "../widgets/FocusableTab" import { HeaderWithDetails } from "../widgets/HeaderWithDetails" @@ -76,7 +76,7 @@ export function SubjectTitle({ }) { const dataModel = useContext(DataModel) const tabIndex = activeTabIndex(settings.expandedItems, subject_uuid) - const subjectType = dataModel.subjects[subject.type] || { name: "Unknown subject type" } + const subjectType = getSubjectType(subject.type, dataModel.subjects) || { name: "Unknown subject type" } const subjectName = subject.name || subjectType.name const subjectTitle = (atReportsOverview ? report.title + " ❯ " : "") + subjectName const subjectUrl = `${window.location}#${subject_uuid}` diff --git a/components/frontend/src/subject/SubjectType.js b/components/frontend/src/subject/SubjectType.js index 19ebfb5df7..e353327734 100644 --- a/components/frontend/src/subject/SubjectType.js +++ b/components/frontend/src/subject/SubjectType.js @@ -1,26 +1,39 @@ -import { func, objectOf, string } from "prop-types" +import { func, number, objectOf, string } from "prop-types" import { useContext } from "react" +import { HeaderContent, HeaderSubheader } from "semantic-ui-react" import { DataModel } from "../context/DataModel" import { EDIT_REPORT_PERMISSION } from "../context/Permissions" import { SingleChoiceInput } from "../fields/SingleChoiceInput" -import { Header } from "../semantic_ui_react_wrappers" +import { Header, Icon } from "../semantic_ui_react_wrappers" import { subjectPropType } from "../sharedPropTypes" -export function subjectTypes(subjectTypesMapping) { +export function subjectTypes(subjectTypesMapping, level = 0) { const options = [] + const headingLevel = `h${Math.min(level, 2) + 4}` // Ensure the heading level is at least h4 and at most h6 + const bullet = level === 0 ? null : Object.entries(subjectTypesMapping).forEach(([key, subjectType]) => { options.push({ key: key, text: subjectType.name, value: key, - content:
, + content: ( +
+ {bullet} + + {subjectType.name} + {subjectType.description} + +
+ ), }) + options.push(...subjectTypes(subjectType?.subjects ?? [], level + 1)) }) return options } subjectTypes.propTypes = { subjectTypesMapping: objectOf(subjectPropType), + level: number, } export function SubjectType({ subjectType, setValue }) { @@ -30,6 +43,7 @@ export function SubjectType({ subjectType, setValue }) { label="Subject type" options={subjectTypes(useContext(DataModel).subjects)} set_value={(value) => setValue(value)} + sort={false} value={subjectType} /> ) diff --git a/components/frontend/src/utils.js b/components/frontend/src/utils.js index 54532ac2a4..b5a3b624fa 100644 --- a/components/frontend/src/utils.js +++ b/components/frontend/src/utils.js @@ -10,6 +10,7 @@ import { reportsPropType, scalePropType, stringsPropType, + subjectTypePropType, } from "./sharedPropTypes" import { HyperLink } from "./widgets/HyperLink" @@ -75,8 +76,56 @@ export function getSourceName(source, dataModel) { return source.name || dataModel.sources[source.type].name } +function childSubjects(subjects) { + return Object.values(subjects).filter((subject) => !!subject.subjects) +} +childSubjects.propTypes = { + subjects: objectOf(subjectTypePropType), +} + +function allMetrics(subject) { + // Return all metrics of the subject, recursively + const metrics = [...(subject.metrics ?? [])] + Object.values(subject.subjects ?? {}).forEach((childSubject) => metrics.push(...allMetrics(childSubject))) + return metrics +} + +export function getSubjectTypeMetrics(subjectTypeKey, subjects) { + // Return the metric types supported by the specified subject type + const metrics = [] + Object.entries(subjects ?? {}).forEach(([key, subject]) => { + if (key === subjectTypeKey) { + metrics.push(...allMetrics(subject)) + } else { + metrics.push(...getSubjectTypeMetrics(subjectTypeKey, subject.subjects)) + } + }) + return Array.from(new Set(metrics)) +} +getSubjectTypeMetrics.propTypes = { + subjectTypeKey: string, + subjects: objectOf(subjectTypePropType), +} + +export function getSubjectType(subjectTypeKey, subjects) { + // Return the subject type object + if (Object.keys(subjects).includes(subjectTypeKey)) { + return subjects[subjectTypeKey] + } + for (const childSubject of childSubjects(subjects)) { + const result = getSubjectType(subjectTypeKey, childSubject.subjects) + if (result) { + return result + } + } +} +getSubjectType.propTypes = { + subjectTypeKey: string, + subjects: objectOf(subjectTypePropType), +} + export function getSubjectName(subject, dataModel) { - return subject.name || dataModel.subjects[subject.type].name + return subject.name || getSubjectType(subject.type, dataModel.subjects).name } export function getMetricTarget(metric) { @@ -386,7 +435,8 @@ export function dropdownOptions(options) { } export function slugify(name) { - return `#${name?.toLowerCase().replaceAll(" ", "-").replaceAll("(", "").replaceAll(")", "")}` + // The hash isn't really part of the slug, but to prevent duplication it is included anyway + return `#${name?.toLowerCase().replaceAll(" ", "-").replaceAll("(", "").replaceAll(")", "").replaceAll("/", "")}` } export function sum(object) { diff --git a/components/frontend/src/utils.test.js b/components/frontend/src/utils.test.js index 5b1801d1ea..811402b3c4 100644 --- a/components/frontend/src/utils.test.js +++ b/components/frontend/src/utils.test.js @@ -8,6 +8,8 @@ import { getSourceName, getStatusName, getSubjectName, + getSubjectType, + getSubjectTypeMetrics, getUserPermissions, niceNumber, nrMetricsInReport, @@ -139,6 +141,61 @@ it("gets the source name from the data model if the source has no name", () => { ) }) +it("gets the subject type", () => { + const subject = { name: "Subject" } + expect(getSubjectType("subject", { subject: subject })).toStrictEqual({ name: "Subject" }) +}) + +it("gets the subject type recursively", () => { + const subject = { name: "Subject" } + expect(getSubjectType("subject", { parent: { subjects: { subject: subject } } })).toStrictEqual({ + name: "Subject", + }) +}) + +it("gets the subject type recursively from the second subject type", () => { + const subject = { name: "Subject" } + expect( + getSubjectType("subject", { + first: { subjects: { foo: { name: "Foo" } } }, + second: { subjects: { subject: subject } }, + }), + ).toStrictEqual(subject) +}) + +it("gets the subject type metrics", () => { + expect(getSubjectTypeMetrics("subject", { subject: { metrics: ["metric"] } })).toStrictEqual(["metric"]) +}) + +it("gets the subject type metrics recursively", () => { + const metrics = getSubjectTypeMetrics("child", { + parent: { metrics: ["parent metric"], subjects: { child: { metrics: ["child metric"] } } }, + }) + expect(metrics).toStrictEqual(["child metric"]) +}) + +it("gets the subject type metrics recursively, including child metrics", () => { + const metrics = getSubjectTypeMetrics("parent", { + parent: { metrics: ["parent metric"], subjects: { child: { metrics: ["child metric"] } } }, + }) + expect(metrics).toStrictEqual(["parent metric", "child metric"]) +}) + +it("gets the subject type metrics recursively from the second subject type", () => { + const metrics = getSubjectTypeMetrics("child", { + first: { subjects: { foo: { metrics: ["foo metric"] } } }, + second: { metrics: ["second metric"], subjects: { child: { metrics: ["child metric"] } } }, + }) + expect(metrics).toStrictEqual(["child metric"]) +}) + +it("gets the subject type metrics deduplicated", () => { + const metrics = getSubjectTypeMetrics("parent", { + parent: { subjects: { child1: { metrics: ["child metric"] }, child2: { metrics: ["child metric"] } } }, + }) + expect(metrics).toStrictEqual(["child metric"]) +}) + it("gets the subject name", () => { expect(getSubjectName({ name: "subject" }, {})).toStrictEqual("subject") }) diff --git a/components/frontend/src/widgets/menu_options.js b/components/frontend/src/widgets/menu_options.js index 1e5e558de6..5b62cb2ca3 100644 --- a/components/frontend/src/widgets/menu_options.js +++ b/components/frontend/src/widgets/menu_options.js @@ -1,8 +1,8 @@ -import { getMetricName, getSourceName, getSubjectName } from "../utils" +import { getMetricName, getSourceName, getSubjectName, getSubjectTypeMetrics } from "../utils" import { ItemBreadcrumb } from "./ItemBreadcrumb" export function metric_options(reports, dataModel, current_subject_type, current_subject_uuid) { - const subject_metrics = dataModel.subjects[current_subject_type].metrics + const subject_metrics = getSubjectTypeMetrics(current_subject_type, dataModel.subjects) let options = [] reports.forEach((report) => { Object.entries(report.subjects).forEach(([subject_uuid, subject]) => { diff --git a/components/notifier/src/models/metric_notification_data.py b/components/notifier/src/models/metric_notification_data.py index 32155a1149..f9aa761235 100644 --- a/components/notifier/src/models/metric_notification_data.py +++ b/components/notifier/src/models/metric_notification_data.py @@ -29,7 +29,7 @@ def __init__( self.measurements = measurements self.metric_name = metric["name"] or DATA_MODEL.metrics[metric["type"]].name self.metric_unit = metric["unit"] or DATA_MODEL.metrics[metric["type"]].unit.value - self.subject_name = subject.get("name") or DATA_MODEL.subjects[subject["type"]].name + self.subject_name = subject.get("name") or DATA_MODEL.all_subjects[subject["type"]].name self.scale = metric["scale"] self.status = self.__status(LAST) self.new_metric_value = self.__value(LAST) diff --git a/components/shared_code/src/shared_data_model/meta/data_model.py b/components/shared_code/src/shared_data_model/meta/data_model.py index 70fcdfd88d..682a3293c4 100644 --- a/components/shared_code/src/shared_data_model/meta/data_model.py +++ b/components/shared_code/src/shared_data_model/meta/data_model.py @@ -3,21 +3,20 @@ import pathlib from typing import Self -from pydantic import BaseModel, model_validator +from pydantic import model_validator from .metric import Metric from .scale import Scale from .source import Source -from .subject import Subject +from .subject import SubjectContainer -class DataModel(BaseModel): +class DataModel(SubjectContainer): """Data model model.""" scales: dict[str, Scale] metrics: dict[str, Metric] sources: dict[str, Source] - subjects: dict[str, Subject] @model_validator(mode="after") def check_scales(self) -> Self: @@ -137,7 +136,7 @@ def check_quality_time_source_types(self) -> None: def check_subjects(self) -> Self: """Check that each metric belongs to at least one subject.""" for metric_key in self.metrics: - for subject in self.subjects.values(): + for subject in self.all_subjects.values(): if metric_key in subject.metrics: break else: diff --git a/components/shared_code/src/shared_data_model/meta/subject.py b/components/shared_code/src/shared_data_model/meta/subject.py index 19a5e0c4eb..37e889dc7a 100644 --- a/components/shared_code/src/shared_data_model/meta/subject.py +++ b/components/shared_code/src/shared_data_model/meta/subject.py @@ -1,11 +1,38 @@ """Data model subject.""" -from pydantic import Field +from __future__ import annotations + +from pydantic import BaseModel, Field from .base import DescribedModel -class Subject(DescribedModel): +class SubjectContainer(BaseModel): + """Base model for subject containers.""" + + subjects: dict[str, Subject] = Field(default_factory=dict) + + @property + def all_subjects(self) -> dict[str, Subject]: + """Return all subjects, recursively.""" + all_subjects = {**self.subjects} + for subject in self.subjects.values(): + all_subjects.update(**subject.all_subjects) + return all_subjects + + +class Subject(SubjectContainer, DescribedModel): """Base model for subjects.""" - metrics: list[str] = Field(..., min_length=1) + metrics: list[str] = Field(default_factory=list) + + @property + def all_metrics(self) -> list[str]: # pragma: no-feature-test-cover + """Return all metrics, recursively.""" + all_metrics = {*self.metrics} + for subject in self.all_subjects.values(): + all_metrics |= {*subject.metrics} + return sorted(all_metrics) + + +SubjectContainer.model_rebuild() diff --git a/components/shared_code/src/shared_data_model/subjects.py b/components/shared_code/src/shared_data_model/subjects.py index cf435b9ab1..98f6d7e05d 100644 --- a/components/shared_code/src/shared_data_model/subjects.py +++ b/components/shared_code/src/shared_data_model/subjects.py @@ -7,35 +7,88 @@ name="Development environment", description="A software development and/or maintenance environment, consisting of infrastructure, pipelines, " "and tools needed to build, test, and deploy software.", - metrics=[ - "failed_jobs", - "job_runs_within_time_period", - "merge_requests", - "performancetest_duration", - "pipeline_duration", - "software_version", - "source_up_to_dateness", - "source_version", - "unmerged_branches", - "unused_jobs", - ], + subjects={ + "development_environment_pipeline": Subject( + name="Pipeline and/or job", + description="One or more pipelines and/or jobs to build, test, and deploy software.", + metrics=[ + "failed_jobs", + "job_runs_within_time_period", + "pipeline_duration", + "unused_jobs", + ], + ), + "development_environment_tool": Subject( + name="Development tool", + description="A software development and/or maintenance tool.", + metrics=[ + "source_up_to_dateness", + "source_version", + "software_version", + ], + ), + }, ), "process": Subject( name="Process", - description="A software development and/or maintenance process.", + description="A software development, maintenance, and/or operations process.", + subjects={ + "process_backlog": Subject( + name="Backlog management", + description="A process to manage the product backlog.", + metrics=["user_story_points", "velocity"], + ), + "process_ci": Subject( + name="Continuous integration and/or deployment", + description="Continuous integration and/or continuous deployment process.", + metrics=[ + "merge_requests", + "pipeline_duration", + "unmerged_branches", + ], + ), + "process_defect": Subject( + name="Defect management", + description="A process to manage known defects.", + metrics=[ + "issues", + ], + ), + "process_operations": Subject( + name="Operations", + description="A process to manage software in production.", + metrics=[ + "average_issue_lead_time", + "change_failure_rate", + "job_runs_within_time_period", + ], + ), + "process_technical_debt": Subject( + name="Technical debt management", + description="A process to manage outstanding technical debt.", + metrics=[ + "dependencies", + "issues", + "remediation_effort", + "suppressed_violations", + "todo_and_fixme_comments", + ], + ), + "process_test": Subject( + name="Test management", + description="A process to manage tests.", + metrics=[ + "manual_test_duration", + "manual_test_execution", + "tests", + "test_cases", + "uncovered_branches", + "uncovered_lines", + ], + ), + }, metrics=[ - "average_issue_lead_time", - "change_failure_rate", - "issues", - "manual_test_duration", - "manual_test_execution", - "merge_requests", - "source_up_to_dateness", "time_remaining", - "unmerged_branches", - "user_story_points", - "velocity", - "sentiment", ], ), "report": Subject( @@ -46,40 +99,59 @@ "software": Subject( name="Software", description="A custom software application or component.", - metrics=[ - "average_issue_lead_time", - "change_failure_rate", - "commented_out_code", - "complex_units", - "dependencies", - "duplicated_lines", - "issues", - "job_runs_within_time_period", - "loc", - "long_units", - "manual_test_duration", - "manual_test_execution", - "many_parameters", - "merge_requests", - "performancetest_duration", - "performancetest_stability", - "pipeline_duration", - "remediation_effort", - "scalability", - "security_warnings", - "slow_transactions", - "software_version", - "source_up_to_dateness", - "source_version", - "suppressed_violations", - "test_cases", - "tests", - "todo_and_fixme_comments", - "uncovered_branches", - "uncovered_lines", - "unmerged_branches", - "user_story_points", - "violations", - ], + subjects={ + "software_application": Subject( + name="Software application", + description="A custom software application.", + metrics=[ + "loc", + "security_warnings", + "software_version", + "scalability", + "slow_transactions", + ], + ), + "software_component": Subject( + name="Software component", + description="A custom software component.", + metrics=[ + "commented_out_code", + "complex_units", + "dependencies", + "duplicated_lines", + "loc", + "long_units", + "many_parameters", + "software_version", + "security_warnings", + "violations", + ], + ), + "software_tests": Subject( + name="Software tests", + description="A test suite for custom software.", + metrics=[ + "performancetest_duration", + "performancetest_stability", + "tests", + "test_cases", + "uncovered_branches", + "uncovered_lines", + ], + ), + "software_documentation": Subject( + name="Software documentation", + description="Documentation of custom software.", + metrics=[ + "source_up_to_dateness", + "time_remaining", + ], + ), + }, + ), + "team": Subject( + name="Team", + description="A team developing, maintaining and/or operation custom software.", + metrics=["sentiment"], ), } diff --git a/components/shared_code/tests/shared_data_model/meta/test_subject.py b/components/shared_code/tests/shared_data_model/meta/test_subject.py new file mode 100644 index 0000000000..580b3049f1 --- /dev/null +++ b/components/shared_code/tests/shared_data_model/meta/test_subject.py @@ -0,0 +1,28 @@ +"""Unit tests for the subject model.""" + +from shared_data_model.meta.subject import Subject + +from .base import MetaModelTestCase + + +class SubjectTest(MetaModelTestCase): + """Unit tests for the subject model.""" + + MODEL = Subject + + def test_all_metrics(self): + """Test that the all_metrics property of a subject returns all metrics recursively and deduplicated.""" + subject = Subject( + name="Subject", + description="Description.", + metrics=["parent metric"], + subjects={ + "child_subject1": Subject( + name="Child subject 1", description="Child 1 description.", metrics=["child metric"] + ), + "child_subject2": Subject( + name="Child subject 2", description="Child 2 description.", metrics=["child metric"] + ), + }, + ) + self.assertEqual(["child metric", "parent metric"], subject.all_metrics) diff --git a/docs/src/changelog.md b/docs/src/changelog.md index b5eec56824..9abe44879e 100644 --- a/docs/src/changelog.md +++ b/docs/src/changelog.md @@ -20,13 +20,15 @@ If your currently installed *Quality-time* version is v4.10.0 or older, please r ### Added +- Add more subject types. Closes [#3130](https://github.com/ICTU/quality-time/issues/3130). - Group digits in numbers. Closes [#8076](https://github.com/ICTU/quality-time/issues/8076). - In the measurement entity status menu, the description of the menu items would say "undefined days" if the desired response time for the status had not been changed from its default value. Fixes [#8284](https://github.com/ICTU/quality-time/issues/8284). - Allow for specifying supported source versions in the data model. Show the supported source version in the UI and the reference documentation. Closes [#8786](https://github.com/ICTU/quality-time/issues/8786). ### Changed -- Migrate to the new SonarQube issue structure introduced in SonarQube 10.2. See the [release 10.2 upgrade notes](https://docs.sonarsource.com/sonarqube/latest/setup-and-upgrade/release-upgrade-notes/#release-10.2-upgrade-notes). Closes [#8354](https://github.com/ICTU/quality-time/issues/8354). Where possible, SonarQube parameters and parameter values are migrated automatically: +- Rename the "CI-environment" subject type to "Development environment". Prepares for [#3130](https://github.com/ICTU/quality-time/issues/3130). +- Migrate to the new SonarQube issue structure introduced in SonarQube 10.2. See the [release 10.2 upgrade notes](https://docs.sonarsource.com/sonarqube/latest/setup-and-upgrade/release-upgrade-notes/#release-10.2-upgrade-notes). Closes [#8354](https://github.com/ICTU/quality-time/issues/8354). Where possible, SonarQube parameters and parameter values are migrated automatically: - The 'severities' parameter is changed into 'impact severities' and the severity values are changed ('blocker' and 'critical' become 'high', 'major' becomes 'medium', and 'minor' and 'info' become 'low'). - The 'types' parameter is changed into 'impacted software qualities' and the types are changed ('code smell' becomes 'maintainability', 'vulnerability' becomes 'security', and 'bug' becomes 'reliability'). - The 'security types' parameter values are changed ('security_hotspot' becomes 'security hotspot' and 'vulnerability' becomes 'issue with security impact'). @@ -51,10 +53,6 @@ If your currently installed *Quality-time* version is v4.10.0 or older, please r - When using Dependency-Track as source for dependencies, security warnings, or source-up-to-dateness, allow for filtering by project name and version. Closes [#8686](https://github.com/ICTU/quality-time/issues/8686). -### Changed - -- Rename the "CI-environment" subject type to "Development environment". Prepares for [#3130](https://github.com/ICTU/quality-time/issues/3130). - ## v5.12.0 - 2024-05-17 ### Deployment notes diff --git a/docs/src/conf.py b/docs/src/conf.py index 6dcda72eba..0c3d21612d 100644 --- a/docs/src/conf.py +++ b/docs/src/conf.py @@ -51,7 +51,7 @@ # -- Options for MyST parser ------------------------------------------------- # See https://myst-parser.readthedocs.io/en/latest/syntax/optional.html#auto-generated-header-anchors -myst_heading_anchors = 3 +myst_heading_anchors = 4 # -- Options for HTML output ------------------------------------------------- diff --git a/docs/src/create_reference_md.py b/docs/src/create_reference_md.py index 53505f1761..1bfe7b46d8 100644 --- a/docs/src/create_reference_md.py +++ b/docs/src/create_reference_md.py @@ -69,10 +69,13 @@ def subject_section(subject: Subject, level: int) -> str: markdown = markdown_header(subject.name, level=level, index=True) markdown += markdown_paragraph(subject.description) supporting_metrics_markdown = "" - for metric in subject.metrics: - metric_name = DATA_MODEL.metrics[metric].name - supporting_metrics_markdown += f"- [{metric_name}]({slugify(metric_name)})\n" + subject_metrics = [DATA_MODEL.metrics[metric] for metric in subject.all_metrics] + for metric in sorted(subject_metrics, key=get_model_name): + supporting_metrics_markdown += f"- [{metric.name}]({slugify(metric.name)})\n" markdown += admonition(supporting_metrics_markdown, "Supporting metrics") + child_subjects = [DATA_MODEL.all_subjects[child_subject] for child_subject in subject.subjects] + for child_subject in sorted(child_subjects, key=get_model_name): + markdown += subject_section(child_subject, level + 1) return markdown @@ -102,7 +105,7 @@ def metric_section(metric_key: str, metric: Metric, level: int) -> str: markdown += definition_list("Scales", *metric_scales(metric)) markdown += definition_list("Default tags", *[tag.value for tag in metric.tags]) supported_subjects_markdown = "" - subjects = [subject for subject in DATA_MODEL.subjects.values() if metric_key in subject.metrics] + subjects = [subject for subject in DATA_MODEL.all_subjects.values() if metric_key in subject.all_metrics] for subject in subjects: supported_subjects_markdown += f"- [{subject.name}]({slugify(subject.name)})\n" markdown += admonition(supported_subjects_markdown, "Supported subjects") @@ -180,7 +183,10 @@ def admonition(text: str, title: str = "", admonition: AdmonitionType = "admonit def slugify(name: str) -> str: """Return a slugified version of the name.""" - return f'#{name.lower().replace(" ", "-").replace("(", "").replace(")", "")}' + # Add type to prevent mypy complaining that 'Argument 1 to "maketrans" of "str" has incompatible type...' + char_mapping: dict[str, str | int | None] = {" ": "-", "(": "", ")": "", "/": ""} + slug = name.lower().translate(str.maketrans(char_mapping)) + return f"#{slug}" # The hash isn't really part of the slug, but to prevent duplication it is included anyway def decapitalize(name: str) -> str: diff --git a/docs/src/usage.md b/docs/src/usage.md index 59291fa7b2..372fa854b9 100644 --- a/docs/src/usage.md +++ b/docs/src/usage.md @@ -471,12 +471,12 @@ Multiple issues can be linked to one metric. At most one issue tracker can be co DORA metrics are a set of four key metrics for measuring the performance of software delivery, first described by the DevOps Research & Assessment (DORA) team in the 2016 State of DevOps report. See the [DORA research program](https://www.devops-research.com/research.html) for more information. -*Quality-time* can monitor these metrics in the following manner: +*Quality-time* can monitor these metrics in the following manner. Add a subject of type [Operations](reference.md#operations) and add the following metrics to the subject: -- Deployment Frequency: measure "Job runs within time period" filtered on deployment jobs. -- Lead Time for Changes: measure "Average issue lead time" filtered on issues marked as change. -- Time to Restore Services: measure "Average issue lead time" filtered on issues marked as failure. -- Change Failure Rate: measure "Issues" filtered on issues marked as failure in production. +- Deployment Frequency: measure [job runs within time period](reference.md#job-runs-within-time-period) filtered on deployment jobs. +- Lead Time for Changes: measure [average issue lead time](reference.md#average-issue-lead-time) filtered on issues marked as change. +- Time to Restore Services: measure [average issue lead time](reference.md#average-issue-lead-time) filtered on issues marked as failure. +- Change Failure Rate: measure [change failure rate](reference.md#change-failure-rate). ```{index} Notification ```