From a384ab534ff57c4d4062d761f028ebf0b943d3c9 Mon Sep 17 00:00:00 2001 From: Mikhail Zayats Date: Mon, 15 Jun 2026 18:37:12 +0000 Subject: [PATCH 1/7] api: fix results module export declaration The API module intended to declare public exports, but used a plain all variable instead of Python's __all__ convention. Fix the export declaration separately so later run and result API refactors do not carry an unrelated module-level cleanup. Signed-off-by: Mikhail Zayats --- bublik/interfaces/api_v2/results.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bublik/interfaces/api_v2/results.py b/bublik/interfaces/api_v2/results.py index 95ede3d7..19a90b5c 100644 --- a/bublik/interfaces/api_v2/results.py +++ b/bublik/interfaces/api_v2/results.py @@ -23,7 +23,7 @@ ) -all = [ +__all__ = [ 'RunViewSet', 'ResultViewSet', ] From 360f5bd5fdddacfa9888608729f8854e07b85a2d Mon Sep 17 00:00:00 2001 From: Mikhail Zayats Date: Mon, 15 Jun 2026 19:44:01 +0000 Subject: [PATCH 2/7] run: prepare API modules for independent evolution Split run and result API views into dedicated modules to keep entity-specific code isolated and easier to evolve independently. Signed-off-by: Mikhail Zayats --- bublik/interfaces/api_v2/__init__.py | 3 +- bublik/interfaces/api_v2/result/views.py | 59 +++++++++++++++++++ .../api_v2/{results.py => run/views.py} | 43 -------------- 3 files changed, 61 insertions(+), 44 deletions(-) create mode 100644 bublik/interfaces/api_v2/result/views.py rename bublik/interfaces/api_v2/{results.py => run/views.py} (78%) diff --git a/bublik/interfaces/api_v2/__init__.py b/bublik/interfaces/api_v2/__init__.py index d975f50c..6d2b48a0 100644 --- a/bublik/interfaces/api_v2/__init__.py +++ b/bublik/interfaces/api_v2/__init__.py @@ -30,7 +30,8 @@ from .performance import PerformanceCheckView from .project import ProjectViewSet from .report import ReportViewSet -from .results import ResultViewSet, RunViewSet +from .result.views import ResultViewSet +from .run.views import RunViewSet from .server import ServerViewSet from .tree import TreeViewSet from .url_shortener import URLShortenerView diff --git a/bublik/interfaces/api_v2/result/views.py b/bublik/interfaces/api_v2/result/views.py new file mode 100644 index 00000000..9daf201f --- /dev/null +++ b/bublik/interfaces/api_v2/result/views.py @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2016-2023 OKTET Labs Ltd. All rights reserved. + +from typing import ClassVar + +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + +from bublik.core.result import ResultService +from bublik.core.run.stats import ( + generate_results_details, +) +from bublik.data.serializers import ( + TestIterationResultSerializer, +) + + +__all__ = [ + 'ResultViewSet', +] + + +class ResultViewSet(ModelViewSet): + serializer_class = TestIterationResultSerializer + filter_backends: ClassVar[list] = [] + + def get_queryset(self): + parent_id = self.request.query_params.get('parent_id') + test_name = self.request.query_params.get('test_name') + start_exec_seqno = self.request.query_params.get('start_exec_seqno') + results = self.request.query_params.get('results') + result_properties = self.request.query_params.get('result_properties') + requirements = self.request.query_params.get('requirements') + + return ResultService.list_results( + parent_id=parent_id, + test_name=test_name, + start_exec_seqno=start_exec_seqno, + results=results, + result_properties=result_properties, + requirements=requirements, + ) + + def retrieve(self, request, pk=None): + return Response(data={'result': ResultService.get_result_details(pk)}) + + def list(self, request): + return Response( + data={'results': generate_results_details(self.get_queryset())}, + ) + + @action(detail=True, methods=['get']) + def artifacts_and_verdicts(self, request, pk=None): + return Response(ResultService.get_result_artifacts_and_verdicts(pk)) + + @action(detail=True, methods=['get']) + def measurements(self, request, pk=None): + return Response(ResultService.get_result_measurements(pk)) diff --git a/bublik/interfaces/api_v2/results.py b/bublik/interfaces/api_v2/run/views.py similarity index 78% rename from bublik/interfaces/api_v2/results.py rename to bublik/interfaces/api_v2/run/views.py index 19a90b5c..52a3a4bf 100644 --- a/bublik/interfaces/api_v2/results.py +++ b/bublik/interfaces/api_v2/run/views.py @@ -1,8 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (C) 2016-2023 OKTET Labs Ltd. All rights reserved. -from typing import ClassVar - from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import ValidationError @@ -10,10 +8,8 @@ from rest_framework.viewsets import ModelViewSet from bublik.core.cache import RunCache -from bublik.core.result import ResultService from bublik.core.run.services import RunsChartGroupBy, RunService from bublik.core.run.stats import ( - generate_results_details, generate_runs_details, ) from bublik.core.utils import get_difference @@ -25,7 +21,6 @@ __all__ = [ 'RunViewSet', - 'ResultViewSet', ] @@ -153,41 +148,3 @@ def comment(self, request, pk=None): return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) - - -class ResultViewSet(ModelViewSet): - serializer_class = TestIterationResultSerializer - filter_backends: ClassVar[list] = [] - - def get_queryset(self): - parent_id = self.request.query_params.get('parent_id') - test_name = self.request.query_params.get('test_name') - start_exec_seqno = self.request.query_params.get('start_exec_seqno') - results = self.request.query_params.get('results') - result_properties = self.request.query_params.get('result_properties') - requirements = self.request.query_params.get('requirements') - - return ResultService.list_results( - parent_id=parent_id, - test_name=test_name, - start_exec_seqno=start_exec_seqno, - results=results, - result_properties=result_properties, - requirements=requirements, - ) - - def retrieve(self, request, pk=None): - return Response(data={'result': ResultService.get_result_details(pk)}) - - def list(self, request): - return Response( - data={'results': generate_results_details(self.get_queryset())}, - ) - - @action(detail=True, methods=['get']) - def artifacts_and_verdicts(self, request, pk=None): - return Response(ResultService.get_result_artifacts_and_verdicts(pk)) - - @action(detail=True, methods=['get']) - def measurements(self, request, pk=None): - return Response(ResultService.get_result_measurements(pk)) From 3877043c938a9aac5c69ebc6619ea4c2f898685c Mon Sep 17 00:00:00 2001 From: Mikhail Zayats Date: Mon, 15 Jun 2026 19:48:16 +0000 Subject: [PATCH 3/7] run: introduce DTO-based service contracts Replace dict-based service responses with typed DTOs to introduce explicit and structured data contracts across service boundaries and move serialization to API/MCP boundary to improve separation of concerns between service layer and API/MCP layers. Signed-off-by: Mikhail Zayats --- bublik/core/run/dto.py | 149 ++++++++++++++++ bublik/core/run/services.py | 71 ++++---- bublik/core/run/stats.py | 149 ++++++++++------ bublik/interfaces/api_v2/run/serializers.py | 179 ++++++++++++++++++++ bublik/interfaces/api_v2/run/views.py | 32 +++- bublik/mcp/tools.py | 36 ++-- 6 files changed, 518 insertions(+), 98 deletions(-) create mode 100644 bublik/core/run/dto.py create mode 100644 bublik/interfaces/api_v2/run/serializers.py diff --git a/bublik/core/run/dto.py b/bublik/core/run/dto.py new file mode 100644 index 00000000..3072309c --- /dev/null +++ b/bublik/core/run/dto.py @@ -0,0 +1,149 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026 OKTET Labs Ltd. All rights reserved. + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from datetime import datetime, timedelta + + +@dataclass +class RunCompromisedDetails: + status: bool + comment: str | None = None + bug_id: str | None = None + bug_url: str | None = None + + +@dataclass +class RunRevision: + name: str + value: str + url: str + + +@dataclass +class RunSpecialCategory: + name: str + values: list[str] + + +@dataclass +class RunDetailsResult: + project_id: int + project_name: str + id: int + start: datetime | None + finish: datetime | None + duration: timedelta | None + main_package: str | None + status: str | None + status_by_nok: str + compromised: RunCompromisedDetails | None + conclusion: str + conclusion_reason: str | None + important_tags: list[str] + relevant_tags: list[str] + branches: list[str] + revisions: list[RunRevision] + labels: list[str] + special_categories: list[RunSpecialCategory] + configuration: str | None + + +@dataclass +class RunStatsValues: + passed: int + failed: int + passed_unexpected: int + failed_unexpected: int + skipped: int + skipped_unexpected: int + abnormal: int + + +@dataclass +class RunStatsComment: + comment_id: str + updated: str + serial: str + comment: str + + +@dataclass +class RunStatsResult: + result_id: int + exec_seqno: int + parent_id: int | None + type: str + test_id: int + test_name: str + period: str + path: list[str] + objective: str + children: list[RunStatsResult] + stats: RunStatsValues + comments: list[RunStatsComment] + + +@dataclass +class RunCompromisedResult: + compromised: bool + + +@dataclass +class MarkRunCompromisedResult: + comment: str + bug: str | None + + +@dataclass +class RunSummaryStats: + tests_total: int + tests_total_plan_percent: int | None + tests_total_ok: int + tests_total_ok_percent: int + tests_total_nok: int + tests_total_nok_percent: int + + +@dataclass +class RunSummaryResult: + id: int + project_id: int + project_name: str + start: datetime | None + finish: datetime | None + duration: timedelta | None + status: str | None + status_by_nok: str + compromised: bool | None + conclusion: str + conclusion_reason: str | None + metadata: list[str] + important_tags: list[str] + relevant_tags: list[str] + stats: RunSummaryStats | None + + +@dataclass +class RunListPagination: + count: int + next: str | None + previous: str | None + + +@dataclass +class RunListResult: + pagination: RunListPagination + results: list[RunSummaryResult] + + +@dataclass +class RunCommentResult: + id: int + comment: str diff --git a/bublik/core/run/services.py b/bublik/core/run/services.py index e0bb1e75..551b0e25 100644 --- a/bublik/core/run/services.py +++ b/bublik/core/run/services.py @@ -22,6 +22,15 @@ unmark_run_compromised, validate_compromised_request, ) +from bublik.core.run.dto import ( + MarkRunCompromisedResult, + RunCommentResult, + RunCompromisedResult, + RunDetailsResult, + RunListPagination, + RunListResult, + RunStatsResult, +) from bublik.core.run.external_links import get_sources from bublik.core.run.filter_expression import filter_by_expression from bublik.core.run.stats import ( @@ -71,7 +80,7 @@ def get_run(run_id: int) -> models.TestIterationResult: raise NotFoundError(msg) from e @staticmethod - def get_run_details(run_id: int) -> dict: + def get_run_details(run_id: int) -> RunDetailsResult: ''' Get full details for a single run. @@ -79,7 +88,7 @@ def get_run_details(run_id: int) -> dict: run_id: The ID of the test run Returns: - Dictionary with full run details + RunDetailsResult with full run details ''' run = RunService.get_run(run_id) return generate_all_run_details(run) @@ -99,7 +108,10 @@ def get_run_status(run_id: int) -> str: return get_run_status(run) @staticmethod - def get_run_stats(run_id: int, requirements: str | None = None) -> dict: + def get_run_stats( + run_id: int, + requirements: str | None = None, + ) -> RunStatsResult | None: ''' Get statistics for a run. @@ -108,7 +120,7 @@ def get_run_stats(run_id: int, requirements: str | None = None) -> dict: requirements: Optional requirements filter Returns: - Dictionary with run statistics + RunStatsResult with run statistics or None if stats are unavailable ''' return get_run_stats_detailed_with_comments(run_id, requirements) @@ -127,7 +139,7 @@ def get_run_source(run_id: int) -> str: return get_sources(run) @staticmethod - def get_run_compromised(run_id: int) -> dict: + def get_run_compromised(run_id: int) -> RunCompromisedResult: ''' Get compromised status for a run. @@ -135,13 +147,10 @@ def get_run_compromised(run_id: int) -> dict: run_id: The ID of the test run Returns: - Dictionary with compromised status data + RunCompromisedResult with compromised status data ''' run = RunService.get_run(run_id) - compromised_data = is_run_compromised(run) - if not compromised_data: - compromised_data = {'compromised': False} - return compromised_data + return RunCompromisedResult(compromised=bool(is_run_compromised(run))) @staticmethod def mark_run_compromised( @@ -149,7 +158,7 @@ def mark_run_compromised( comment: str, bug_id: str | None = None, reference_key: str | None = None, - ) -> dict: + ) -> MarkRunCompromisedResult: ''' Mark a run as compromised. @@ -160,7 +169,7 @@ def mark_run_compromised( reference_key: Optional reference key Returns: - Dictionary with comment and bug info + MarkRunCompromisedResult with comment and bug info Raises: ValidationError: if validation fails @@ -170,10 +179,10 @@ def mark_run_compromised( raise ValidationError(err_msg) mark_run_compromised(run_id, comment, bug_id, reference_key) - return { - 'comment': comment, - 'bug': f'Bug ID: {bug_id}' if bug_id else None, - } + return MarkRunCompromisedResult( + comment=comment, + bug=f'Bug ID: {bug_id}' if bug_id else None, + ) @staticmethod def unmark_run_compromised(run_id: int) -> None: @@ -260,7 +269,7 @@ def list_runs( project_id: int | None = None, page: int | None = None, page_size: int | None = None, - ) -> dict: + ) -> RunListResult: ''' List runs filtered by date range and optionally by project. @@ -272,7 +281,7 @@ def list_runs( page_size: Items per page (default: 25, max: 10000) Returns: - Dictionary with pagination metadata and run detail dictionaries + RunListResult with pagination metadata and run details ''' runs = RunService.list_runs_queryset( @@ -280,8 +289,12 @@ def list_runs( finish_date=finish_date, project_id=project_id, ) - runs_details = generate_runs_details(runs) - return PaginatedResult.paginate_queryset(runs_details, page, page_size) + paginated_runs = PaginatedResult.paginate_queryset(runs, page, page_size) + + return RunListResult( + pagination=RunListPagination(**paginated_runs['pagination']), + results=generate_runs_details(paginated_runs['results']), + ) @staticmethod def aggregate_runs_by_period( @@ -526,7 +539,7 @@ def get_run_requirements(run_id: int) -> list[str]: ) @staticmethod - def get_nok_distribution(run_id: int) -> dict: + def get_nok_distribution(run_id: int) -> list[bool]: ''' Get NOK (failure) distribution for a run. @@ -534,10 +547,10 @@ def get_nok_distribution(run_id: int) -> dict: run_id: The ID of the test run Returns: - Dictionary with NOK distribution data + List of NOK distribution flags ''' run = RunService.get_run(run_id) - return get_nok_results_distribution(run) + return list(get_nok_results_distribution(run)) @staticmethod def get_run_comment(run_id: int) -> str | None: @@ -566,7 +579,7 @@ def get_run_comment(run_id: int) -> str | None: return comments.first().meta.value @staticmethod - def create_run_comment(run_id: int, content: str) -> dict: + def create_run_comment(run_id: int, content: str) -> RunCommentResult: ''' Create or update run comment. @@ -575,7 +588,7 @@ def create_run_comment(run_id: int, content: str) -> dict: content: Comment content Returns: - Dictionary with comment details + RunCommentResult with comment details ''' run = RunService.get_run(run_id) @@ -594,10 +607,10 @@ def create_run_comment(run_id: int, content: str) -> dict: ) mr, _ = mr_serializer.get_or_create() - return { - 'id': mr.id, - 'comment': mr.meta.value, - } + return RunCommentResult( + id=mr.id, + comment=mr.meta.value, + ) @staticmethod def delete_run_comment(run_id: int) -> None: diff --git a/bublik/core/run/stats.py b/bublik/core/run/stats.py index 21a76276..cd95996e 100644 --- a/bublik/core/run/stats.py +++ b/bublik/core/run/stats.py @@ -27,6 +27,17 @@ get_tags_by_runs, is_result_unexpected, ) +from bublik.core.run.dto import ( + RunCompromisedDetails, + RunDetailsResult, + RunRevision, + RunSpecialCategory, + RunStatsComment, + RunStatsResult, + RunStatsValues, + RunSummaryResult, + RunSummaryStats, +) from bublik.core.run.filter_expression import filter_by_expression from bublik.core.utils import key_value_dict_transforming, key_value_list_transforming from bublik.data.models import ( @@ -203,9 +214,29 @@ def generate_result( def get_run_stats_detailed_with_comments(run_id, requirements): run_stats = get_run_stats_detailed(run_id, requirements) + if run_stats is None: + return None + tests_comments = get_tests_comments(run_id) add_comments(run_stats, tests_comments) - return run_stats + return _run_stats_data_to_result(run_stats) + + +def _run_stats_data_to_result(stats_data): + return RunStatsResult( + result_id=stats_data['result_id'], + exec_seqno=stats_data['exec_seqno'], + parent_id=stats_data['parent_id'], + type=stats_data['type'], + test_id=stats_data['test_id'], + test_name=stats_data['test_name'], + period=stats_data['period'], + path=stats_data['path'], + objective=stats_data['objective'], + children=[_run_stats_data_to_result(child) for child in stats_data.get('children', [])], + stats=RunStatsValues(**stats_data['stats']), + comments=[RunStatsComment(**comment) for comment in stats_data.get('comments', [])], + ) def add_comments(node, tests_comments): @@ -440,14 +471,14 @@ def get_run_stats_summary(run_id): tests_total_ok_percent += 1 tests_total_nok_percent -= 1 - return { - 'tests_total': tests_total, - 'tests_total_plan_percent': tests_total_plan_percent, - 'tests_total_ok': tests_total_ok, - 'tests_total_ok_percent': tests_total_ok_percent, - 'tests_total_nok': tests_total_nok, - 'tests_total_nok_percent': tests_total_nok_percent, - } + return RunSummaryStats( + tests_total=tests_total, + tests_total_plan_percent=tests_total_plan_percent, + tests_total_ok=tests_total_ok, + tests_total_ok_percent=tests_total_ok_percent, + tests_total_nok=tests_total_nok, + tests_total_nok_percent=tests_total_nok_percent, + ) def get_expected_results(result): @@ -655,7 +686,10 @@ def generate_all_run_details(run): q = MetaResultsQuery(run_meta_results) branches = q.metas_query('branch') - revisions = build_revision_references(q.revision_related_query(), project.id) + revisions = [ + RunRevision(**revision) + for revision in build_revision_references(q.revision_related_query(), project.id) + ] labels = q.labels_query(category_names, project.id) configurations = list( key_value_list_transforming( @@ -664,32 +698,45 @@ def generate_all_run_details(run): ], ), ) - categories = get_metas_by_category(run_meta_results, category_names, project.id) - for category, category_values in categories.items(): - categories[category] = list(key_value_list_transforming(category_values)) + categories = [ + RunSpecialCategory( + name=category, + values=list(key_value_list_transforming(category_values)), + ) + for category, category_values in get_metas_by_category( + run_meta_results, + category_names, + project.id, + ).items() + ] + compromised_details = get_compromised_details(run) logger.debug('[run_details]: preparing resulting dict') - return { - 'project_id': project.id, - 'project_name': project.name, - 'id': run_id, - 'start': run.start, - 'finish': run.finish, - 'duration': run.duration, - 'main_package': run.main_package.iteration.test.name if run.main_package else None, - 'status': get_run_status(run), - 'status_by_nok': get_run_status_by_nok(run)[0], - 'compromised': get_compromised_details(run), - 'conclusion': conclusion, - 'conclusion_reason': conclusion_reason, - 'important_tags': important_tags.get(run_id, []), - 'relevant_tags': relevant_tags.get(run_id, []), - 'branches': list(key_value_list_transforming(branches)), - 'revisions': revisions, - 'labels': list(key_value_list_transforming(labels)), - 'special_categories': categories, - 'configuration': configurations[0] if configurations else None, - } + return RunDetailsResult( + project_id=project.id, + project_name=project.name, + id=run_id, + start=run.start, + finish=run.finish, + duration=run.duration, + main_package=run.main_package.iteration.test.name if run.main_package else None, + status=get_run_status(run), + status_by_nok=get_run_status_by_nok(run)[0], + compromised=( + RunCompromisedDetails(**compromised_details) + if compromised_details is not None + else None + ), + conclusion=conclusion, + conclusion_reason=conclusion_reason, + important_tags=important_tags.get(run_id, []), + relevant_tags=relevant_tags.get(run_id, []), + branches=list(key_value_list_transforming(branches)), + revisions=revisions, + labels=list(key_value_list_transforming(labels)), + special_categories=categories, + configuration=configurations[0] if configurations else None, + ) def generate_runs_details(runs): @@ -701,23 +748,23 @@ def generate_runs_details(runs): run_id = run.id conclusion, conclusion_reason = get_run_conclusion(run) runs_data.append( - { - 'id': run_id, - 'project_id': run.project.id, - 'project_name': run.project.name, - 'start': run.start, - 'finish': run.finish, - 'duration': run.duration, - 'status': get_run_status(run), - 'status_by_nok': get_run_status_by_nok(run)[0], - 'compromised': is_run_compromised(run), - 'conclusion': conclusion, - 'conclusion_reason': conclusion_reason, - 'metadata': metadata_by_runs.get(run_id, []), - 'important_tags': important_tags.get(run_id, []), - 'relevant_tags': relevant_tags.get(run_id, []), - 'stats': get_run_stats_summary(run_id), - }, + RunSummaryResult( + id=run_id, + project_id=run.project.id, + project_name=run.project.name, + start=run.start, + finish=run.finish, + duration=run.duration, + status=get_run_status(run), + status_by_nok=get_run_status_by_nok(run)[0], + compromised=is_run_compromised(run), + conclusion=conclusion, + conclusion_reason=conclusion_reason, + metadata=metadata_by_runs.get(run_id, []), + important_tags=important_tags.get(run_id, []), + relevant_tags=relevant_tags.get(run_id, []), + stats=get_run_stats_summary(run_id), + ), ) return runs_data diff --git a/bublik/interfaces/api_v2/run/serializers.py b/bublik/interfaces/api_v2/run/serializers.py new file mode 100644 index 00000000..a973f82c --- /dev/null +++ b/bublik/interfaces/api_v2/run/serializers.py @@ -0,0 +1,179 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026 OKTET Labs Ltd. All rights reserved. + +from __future__ import annotations + +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from bublik.core.run.dto import ( + MarkRunCompromisedResult, + RunCommentResult, + RunCompromisedDetails, + RunCompromisedResult, + RunDetailsResult, + RunRevision, + RunSpecialCategory, + RunStatsComment, + RunStatsResult, + RunStatsValues, + RunSummaryResult, + RunSummaryStats, + ) + + +def serialize_run_compromised_details( + compromised: RunCompromisedDetails | None, +): + if compromised is None: + return None + + return { + 'status': compromised.status, + 'comment': compromised.comment, + 'bug_id': compromised.bug_id, + 'bug_url': compromised.bug_url, + } + + +def serialize_run_revision(revision: RunRevision): + return { + 'name': revision.name, + 'value': revision.value, + 'url': revision.url, + } + + +def serialize_run_special_categories(categories: list[RunSpecialCategory]): + return {category.name: category.values for category in categories} + + +def serialize_run_details(run_details: RunDetailsResult): + return { + 'project_id': run_details.project_id, + 'project_name': run_details.project_name, + 'id': run_details.id, + 'start': run_details.start, + 'finish': run_details.finish, + 'duration': run_details.duration, + 'main_package': run_details.main_package, + 'status': run_details.status, + 'status_by_nok': run_details.status_by_nok, + 'compromised': serialize_run_compromised_details(run_details.compromised), + 'conclusion': run_details.conclusion, + 'conclusion_reason': run_details.conclusion_reason, + 'important_tags': run_details.important_tags, + 'relevant_tags': run_details.relevant_tags, + 'branches': run_details.branches, + 'revisions': [serialize_run_revision(revision) for revision in run_details.revisions], + 'labels': run_details.labels, + 'special_categories': serialize_run_special_categories( + run_details.special_categories, + ), + 'configuration': run_details.configuration, + } + + +def serialize_run_stats_values(stats: RunStatsValues): + return { + 'passed': stats.passed, + 'failed': stats.failed, + 'passed_unexpected': stats.passed_unexpected, + 'failed_unexpected': stats.failed_unexpected, + 'skipped': stats.skipped, + 'skipped_unexpected': stats.skipped_unexpected, + 'abnormal': stats.abnormal, + } + + +def serialize_run_stats_comment(comment: RunStatsComment): + return { + 'comment_id': comment.comment_id, + 'updated': comment.updated, + 'serial': comment.serial, + 'comment': comment.comment, + } + + +def serialize_run_stats_result(stats: RunStatsResult | None): + if stats is None: + return None + + return { + 'result_id': stats.result_id, + 'exec_seqno': stats.exec_seqno, + 'parent_id': stats.parent_id, + 'type': stats.type, + 'test_id': stats.test_id, + 'test_name': stats.test_name, + 'period': stats.period, + 'path': stats.path, + 'objective': stats.objective, + 'children': [serialize_run_stats_result(child) for child in stats.children], + 'stats': serialize_run_stats_values(stats.stats), + 'comments': [serialize_run_stats_comment(comment) for comment in stats.comments], + } + + +def serialize_run_compromised_result(result: RunCompromisedResult): + return {'compromised': result.compromised} + + +def serialize_mark_run_compromised_result(result: MarkRunCompromisedResult): + return { + 'comment': result.comment, + 'bug': result.bug, + } + + +def serialize_run_summary_stats(stats: RunSummaryStats | None): + if stats is None: + return None + + return { + 'tests_total': stats.tests_total, + 'tests_total_plan_percent': stats.tests_total_plan_percent, + 'tests_total_ok': stats.tests_total_ok, + 'tests_total_ok_percent': stats.tests_total_ok_percent, + 'tests_total_nok': stats.tests_total_nok, + 'tests_total_nok_percent': stats.tests_total_nok_percent, + } + + +def serialize_run_summary_result(result: RunSummaryResult): + return { + 'id': result.id, + 'project_id': result.project_id, + 'project_name': result.project_name, + 'start': result.start, + 'finish': result.finish, + 'duration': result.duration, + 'status': result.status, + 'status_by_nok': result.status_by_nok, + 'compromised': result.compromised, + 'conclusion': result.conclusion, + 'conclusion_reason': result.conclusion_reason, + 'metadata': result.metadata, + 'important_tags': result.important_tags, + 'relevant_tags': result.relevant_tags, + 'stats': serialize_run_summary_stats(result.stats), + } + + +def serialize_run_summary_results(results: list[RunSummaryResult]): + return [serialize_run_summary_result(result) for result in results] + + +def serialize_paginated_run_summary_results(paginated_result): + paginated_result['results'] = serialize_run_summary_results( + paginated_result['results'], + ) + return paginated_result + + +def serialize_run_comment_result(result: RunCommentResult): + return { + 'id': result.id, + 'comment': result.comment, + } diff --git a/bublik/interfaces/api_v2/run/views.py b/bublik/interfaces/api_v2/run/views.py index 52a3a4bf..419a4ae1 100644 --- a/bublik/interfaces/api_v2/run/views.py +++ b/bublik/interfaces/api_v2/run/views.py @@ -17,6 +17,14 @@ RunCommentSerializer, TestIterationResultSerializer, ) +from bublik.interfaces.api_v2.run.serializers import ( + serialize_mark_run_compromised_result, + serialize_run_comment_result, + serialize_run_compromised_result, + serialize_run_details, + serialize_run_stats_result, + serialize_run_summary_results, +) __all__ = [ @@ -49,7 +57,7 @@ def list(self, request): return Response( { 'pagination': self.paginator.get_pagination(), - 'results': generate_runs_details(results), + 'results': serialize_run_summary_results(generate_runs_details(results)), }, ) @@ -88,12 +96,13 @@ def nok_distribution(self, _request, pk=None): @action(detail=True, methods=['get']) def details(self, _request, pk=None): - return Response(data=RunService.get_run_details(pk)) + return Response(data=serialize_run_details(RunService.get_run_details(pk))) @action(detail=True, methods=['get']) def stats(self, _request, pk=None): requirements = self.request.query_params.get('requirements') - return Response({'results': RunService.get_run_stats(pk, requirements)}) + run_stats = RunService.get_run_stats(pk, requirements) + return Response({'results': serialize_run_stats_result(run_stats)}) @action(detail=True, methods=['get']) def requirements(self, _request, pk=None): @@ -110,12 +119,18 @@ def status(self, _request, pk=None): @action(detail=True, methods=['get', 'post', 'delete']) def compromised(self, request, pk=None): if request.method == 'GET': - return Response(RunService.get_run_compromised(pk)) + return Response( + serialize_run_compromised_result(RunService.get_run_compromised(pk)), + ) if request.method == 'POST': comment = request.data.get('comment') bug_id = request.data.get('bug_id') reference_key = request.data.get('reference_key') - return Response(RunService.mark_run_compromised(pk, comment, bug_id, reference_key)) + return Response( + serialize_mark_run_compromised_result( + RunService.mark_run_compromised(pk, comment, bug_id, reference_key), + ), + ) try: RunService.unmark_run_compromised(pk) return Response({'message': f'Run {pk} is no longer compromised'}) @@ -136,12 +151,15 @@ def comment(self, request, pk=None): if request.method == 'POST': content = request.data.get('comment') result = RunService.create_run_comment(pk, content) - return Response(result, status=status.HTTP_201_CREATED) + return Response( + serialize_run_comment_result(result), + status=status.HTTP_201_CREATED, + ) if request.method == 'PUT': content = request.data.get('comment') result = RunService.create_run_comment(pk, content) - return Response(result, status=status.HTTP_200_OK) + return Response(serialize_run_comment_result(result), status=status.HTTP_200_OK) if request.method == 'DELETE': RunService.delete_run_comment(pk) diff --git a/bublik/mcp/tools.py b/bublik/mcp/tools.py index fe56d9f9..fb48a2f9 100644 --- a/bublik/mcp/tools.py +++ b/bublik/mcp/tools.py @@ -19,6 +19,12 @@ from bublik.core.run.stats import generate_runs_details, get_test_runs from bublik.core.server import ServerService from bublik.core.tree.services import TreeService +from bublik.interfaces.api_v2.run.serializers import ( + serialize_paginated_run_summary_results, + serialize_run_compromised_result, + serialize_run_details, + serialize_run_stats_result, +) from bublik.mcp.models import JsonLog from bublik.mcp.processor import LogProcessor @@ -59,7 +65,7 @@ async def get_run_details(run_id: int) -> dict: Returns: Dictionary with full run details including metadata, stats, etc. ''' - return await sync_to_async(RunService.get_run_details)(run_id) + return serialize_run_details(await sync_to_async(RunService.get_run_details)(run_id)) @mcp.tool() async def get_run_status(run_id: int) -> str: @@ -89,7 +95,9 @@ async def get_run_stats( Returns: Dictionary with run statistics including pass/fail counts ''' - return await sync_to_async(RunService.get_run_stats)(run_id, requirements) + return serialize_run_stats_result( + await sync_to_async(RunService.get_run_stats)(run_id, requirements), + ) @mcp.tool() async def get_run_source(run_id: int) -> str: @@ -115,7 +123,9 @@ async def get_run_compromised(run_id: int) -> dict: Returns: Dictionary with compromised status data including comment and bug ID ''' - return await sync_to_async(RunService.get_run_compromised)(run_id) + return serialize_run_compromised_result( + await sync_to_async(RunService.get_run_compromised)(run_id), + ) @mcp.tool() async def get_result_details(result_id: int) -> dict: @@ -257,10 +267,12 @@ async def list_runs( # noqa: PLR0913 branch_expr=branch_expr, ) runs_details = await sync_to_async(generate_runs_details)(queryset) - return await sync_to_async(PaginatedResult.paginate_queryset)( - runs_details, - page, - page_size, + return serialize_paginated_run_summary_results( + await sync_to_async(PaginatedResult.paginate_queryset)( + runs_details, + page, + page_size, + ), ) @mcp.tool() @@ -292,10 +304,12 @@ async def list_runs_today( else [] ) runs_details = await sync_to_async(generate_runs_details)(queryset) if date_str else [] - return await sync_to_async(PaginatedResult.paginate_queryset)( - runs_details, - page, - page_size, + return serialize_paginated_run_summary_results( + await sync_to_async(PaginatedResult.paginate_queryset)( + runs_details, + page, + page_size, + ), ) @mcp.tool() From 7bcbb2875b64a192ab4641ee65eacc33f569bbec Mon Sep 17 00:00:00 2001 From: Mikhail Zayats Date: Wed, 17 Jun 2026 20:13:24 +0000 Subject: [PATCH 4/7] run: align OpenAPI schemas with actual API responses Ensure consistency between OpenAPI schemas and API responses by introducing explicit request/response serializers and binding them via drf-spectacular. Signed-off-by: Mikhail Zayats --- bublik/interfaces/api_v2/run/schemas.py | 311 ++++++++++++++++++++ bublik/interfaces/api_v2/run/serializers.py | 184 ++++++++++++ bublik/interfaces/api_v2/run/views.py | 102 ++++--- 3 files changed, 554 insertions(+), 43 deletions(-) create mode 100644 bublik/interfaces/api_v2/run/schemas.py diff --git a/bublik/interfaces/api_v2/run/schemas.py b/bublik/interfaces/api_v2/run/schemas.py new file mode 100644 index 00000000..a70c8b2d --- /dev/null +++ b/bublik/interfaces/api_v2/run/schemas.py @@ -0,0 +1,311 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026 OKTET Labs Ltd. All rights reserved. + +from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view + +from bublik.interfaces.api_v2.errors.serializers import ErrorResponseSerializer +from bublik.interfaces.api_v2.run.serializers import ( + DropCacheRequestSerializer, + DropCacheResponseSerializer, + MarkRunCompromisedRequestSerializer, + MarkRunCompromisedResponseSerializer, + RunCommentRequestSerializer, + RunCommentResponseSerializer, + RunCommentValueResponseSerializer, + RunCompromisedResponseSerializer, + RunDetailsResponseSerializer, + RunListQuerySerializer, + RunListResponseSerializer, + RunRequirementsResponseSerializer, + RunSourceResponseSerializer, + RunStatsQuerySerializer, + RunStatsResponseSerializer, + RunStatusResponseSerializer, + UnmarkRunCompromisedResponseSerializer, +) + + +RUN_TAG = 'Runs' + + +run_viewset_schema = extend_schema_view( + list=extend_schema( + summary='List runs', + description=''' + Returns a paginated list of test runs with project, timing, status, + classification, metadata, tag, and summary statistics fields. + Supports date, project, status, metadata, and expression filters. + ''', + parameters=[RunListQuerySerializer], + responses={ + 200: OpenApiResponse( + response=RunListResponseSerializer, + description='Runs were successfully retrieved', + ), + }, + tags=[RUN_TAG], + ), + drop_cache=extend_schema( + summary='Drop run cache', + description=''' + Deletes selected cache entries for every run matching the current run + filters and returns identifiers of affected runs. + ''', + request=DropCacheRequestSerializer, + responses={ + 200: OpenApiResponse( + response=DropCacheResponseSerializer, + description='Run cache entries were successfully deleted', + ), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description='Unknown cache key was provided', + ), + }, + tags=[RUN_TAG], + ), + nok_distribution=extend_schema( + summary='Get NOK distribution', + description=''' + Returns a boolean NOK flag for each test result in the run. + ''', + responses={ + 200: OpenApiResponse( + response={'type': 'array', 'items': {'type': 'boolean'}}, + description='NOK distribution was successfully retrieved', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run was not found', + ), + }, + tags=[RUN_TAG], + ), + details=extend_schema( + summary='Get run details', + description=''' + Returns full details for a single run, including metadata, tags, + branches, revisions, labels, configuration, status, and conclusion. + ''', + responses={ + 200: OpenApiResponse( + response=RunDetailsResponseSerializer, + description='Run details were successfully retrieved', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run was not found', + ), + }, + tags=[RUN_TAG], + ), + stats=extend_schema( + summary='Get run statistics', + description=''' + Returns the run result tree with aggregated pass/fail statistics and + comments. The optional requirements filter limits statistics to tests + matching the provided requirement list. + ''', + parameters=[RunStatsQuerySerializer], + responses={ + 200: OpenApiResponse( + response=RunStatsResponseSerializer, + description='Run statistics were successfully retrieved', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run was not found', + ), + }, + tags=[RUN_TAG], + ), + requirements=extend_schema( + summary='List run requirements', + description=''' + Returns sorted requirement values associated with a run. + ''', + responses={ + 200: OpenApiResponse( + response=RunRequirementsResponseSerializer, + description='Run requirements were successfully retrieved', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run was not found', + ), + }, + tags=[RUN_TAG], + ), + source=extend_schema( + summary='Get run source URL', + description=''' + Returns the external source URL associated with a run. + ''', + responses={ + 200: OpenApiResponse( + response=RunSourceResponseSerializer, + description='Run source URL was successfully retrieved', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run was not found', + ), + }, + tags=[RUN_TAG], + ), + status=extend_schema( + summary='Get run status', + description=''' + Returns the configured status value for a run. + ''', + responses={ + 200: OpenApiResponse( + response=RunStatusResponseSerializer, + description='Run status was successfully retrieved', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run was not found', + ), + }, + tags=[RUN_TAG], + ), + compromised=extend_schema( + summary='Get run compromised status', + description=''' + Returns whether a run is marked as compromised. + ''', + responses={ + 200: OpenApiResponse( + response=RunCompromisedResponseSerializer, + description='Run compromised status was successfully retrieved', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run was not found', + ), + }, + tags=[RUN_TAG], + ), + comment=extend_schema( + summary='Get run comment', + description=''' + Returns the current run comment, or null if no comment exists. + ''', + responses={ + 200: OpenApiResponse( + response=RunCommentValueResponseSerializer, + description='Run comment was successfully retrieved', + ), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description='Multiple comments were found for the run', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run was not found', + ), + }, + tags=[RUN_TAG], + ), +) + + +mark_compromised_schema = extend_schema( + summary='Mark run as compromised', + description=''' + Marks a run as compromised using a required comment and optional bug + reference data. + ''', + request=MarkRunCompromisedRequestSerializer, + responses={ + 200: OpenApiResponse( + response=MarkRunCompromisedResponseSerializer, + description='Run was successfully marked as compromised', + ), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description='Compromised request validation failed', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run was not found', + ), + }, + tags=[RUN_TAG], +) + + +unmark_compromised_schema = extend_schema( + summary='Unmark run as compromised', + description=''' + Removes the compromised marker from a run. + ''', + responses={ + 200: OpenApiResponse( + response=UnmarkRunCompromisedResponseSerializer, + description='Run was successfully unmarked as compromised', + ), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run could not be unmarked', + ), + }, + tags=[RUN_TAG], +) + + +create_comment_schema = extend_schema( + summary='Create run comment', + description=''' + Creates or replaces a run comment. + ''', + request=RunCommentRequestSerializer, + responses={ + 201: OpenApiResponse( + response=RunCommentResponseSerializer, + description='Run comment was successfully created', + ), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run comment request validation failed', + ), + }, + tags=[RUN_TAG], +) + + +update_comment_schema = extend_schema( + summary='Update run comment', + description=''' + Creates or replaces a run comment. + ''', + request=RunCommentRequestSerializer, + responses={ + 200: OpenApiResponse( + response=RunCommentResponseSerializer, + description='Run comment was successfully updated', + ), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run comment request validation failed', + ), + }, + tags=[RUN_TAG], +) + + +delete_comment_schema = extend_schema( + summary='Delete run comment', + description=''' + Deletes the current run comment. + ''', + responses={ + 204: OpenApiResponse(description='Run comment was successfully deleted'), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description='No comment exists for the run', + ), + }, + tags=[RUN_TAG], +) diff --git a/bublik/interfaces/api_v2/run/serializers.py b/bublik/interfaces/api_v2/run/serializers.py index a973f82c..f4106da0 100644 --- a/bublik/interfaces/api_v2/run/serializers.py +++ b/bublik/interfaces/api_v2/run/serializers.py @@ -5,6 +5,8 @@ from typing import TYPE_CHECKING +from rest_framework import serializers + if TYPE_CHECKING: from bublik.core.run.dto import ( @@ -23,6 +25,188 @@ ) +class PaginationSerializer(serializers.Serializer): + count = serializers.IntegerField() + next = serializers.CharField(allow_null=True) + previous = serializers.CharField(allow_null=True) + + +class RunListQuerySerializer(serializers.Serializer): + start_date = serializers.DateField(required=False) + finish_date = serializers.DateField(required=False) + project = serializers.IntegerField(required=False) + run_status = serializers.CharField(required=False) + run_metas = serializers.CharField(required=False) + tag_expr = serializers.CharField(required=False) + label_expr = serializers.CharField(required=False) + revision_expr = serializers.CharField(required=False) + branch_expr = serializers.CharField(required=False) + + +class DropCacheRequestSerializer(serializers.Serializer): + keys = serializers.ListField(child=serializers.CharField()) + + +class DropCacheResponseSerializer(serializers.Serializer): + results = serializers.ListField(child=serializers.IntegerField()) + + +class CompromisedDetailsSerializer(serializers.Serializer): + status = serializers.BooleanField() + comment = serializers.CharField(allow_null=True, required=False) + bug_id = serializers.CharField(allow_null=True, required=False) + bug_url = serializers.CharField(allow_null=True, required=False) + + +class RevisionSerializer(serializers.Serializer): + name = serializers.CharField() + value = serializers.CharField() + url = serializers.CharField(allow_blank=True) + + +class RunSummaryStatsSerializer(serializers.Serializer): + tests_total = serializers.IntegerField() + tests_total_plan_percent = serializers.IntegerField(allow_null=True) + tests_total_ok = serializers.IntegerField() + tests_total_ok_percent = serializers.IntegerField() + tests_total_nok = serializers.IntegerField() + tests_total_nok_percent = serializers.IntegerField() + + +class RunListItemSerializer(serializers.Serializer): + id = serializers.IntegerField() + project_id = serializers.IntegerField() + project_name = serializers.CharField() + start = serializers.DateTimeField(allow_null=True) + finish = serializers.DateTimeField(allow_null=True) + duration = serializers.DurationField(allow_null=True) + status = serializers.CharField(allow_null=True) + status_by_nok = serializers.CharField() + compromised = serializers.BooleanField(allow_null=True) + conclusion = serializers.CharField() + conclusion_reason = serializers.CharField(allow_null=True) + metadata = serializers.ListField(child=serializers.CharField()) + important_tags = serializers.ListField(child=serializers.CharField()) + relevant_tags = serializers.ListField(child=serializers.CharField()) + stats = RunSummaryStatsSerializer(allow_null=True) + + +class RunListResponseSerializer(serializers.Serializer): + pagination = PaginationSerializer() + results = RunListItemSerializer(many=True) + + +class RunDetailsResponseSerializer(serializers.Serializer): + project_id = serializers.IntegerField() + project_name = serializers.CharField() + id = serializers.IntegerField() + start = serializers.DateTimeField(allow_null=True) + finish = serializers.DateTimeField(allow_null=True) + duration = serializers.DurationField(allow_null=True) + main_package = serializers.CharField(allow_null=True) + status = serializers.CharField(allow_null=True) + status_by_nok = serializers.CharField() + compromised = CompromisedDetailsSerializer(allow_null=True) + conclusion = serializers.CharField() + conclusion_reason = serializers.CharField(allow_null=True) + important_tags = serializers.ListField(child=serializers.CharField()) + relevant_tags = serializers.ListField(child=serializers.CharField()) + branches = serializers.ListField(child=serializers.CharField()) + revisions = RevisionSerializer(many=True) + labels = serializers.ListField(child=serializers.CharField()) + special_categories = serializers.DictField( + child=serializers.ListField(child=serializers.CharField()), + ) + configuration = serializers.CharField(allow_null=True) + + +class RunStatsQuerySerializer(serializers.Serializer): + requirements = serializers.CharField(required=False) + + +class RunStatsValuesSerializer(serializers.Serializer): + passed = serializers.IntegerField() + failed = serializers.IntegerField() + passed_unexpected = serializers.IntegerField() + failed_unexpected = serializers.IntegerField() + skipped = serializers.IntegerField() + skipped_unexpected = serializers.IntegerField() + abnormal = serializers.IntegerField() + + +class RunStatsCommentSerializer(serializers.Serializer): + comment_id = serializers.CharField() + updated = serializers.CharField() + serial = serializers.CharField() + comment = serializers.CharField() + + +class RunStatsNodeSerializer(serializers.Serializer): + result_id = serializers.IntegerField() + exec_seqno = serializers.IntegerField() + parent_id = serializers.IntegerField(allow_null=True) + type = serializers.CharField() + test_id = serializers.IntegerField() + test_name = serializers.CharField() + period = serializers.CharField() + path = serializers.ListField(child=serializers.CharField()) + objective = serializers.CharField(allow_blank=True) + children = serializers.ListField( + child=serializers.DictField(), + help_text='Child nodes with the same structure.', + ) + stats = RunStatsValuesSerializer() + comments = RunStatsCommentSerializer(many=True) + + +class RunStatsResponseSerializer(serializers.Serializer): + results = RunStatsNodeSerializer(allow_null=True) + + +class RunRequirementsResponseSerializer(serializers.Serializer): + requirements = serializers.ListField(child=serializers.CharField()) + + +class RunSourceResponseSerializer(serializers.Serializer): + url = serializers.CharField(allow_null=True) + + +class RunStatusResponseSerializer(serializers.Serializer): + status = serializers.CharField(allow_null=True) + + +class RunCompromisedResponseSerializer(serializers.Serializer): + compromised = serializers.BooleanField() + + +class MarkRunCompromisedRequestSerializer(serializers.Serializer): + comment = serializers.CharField() + bug_id = serializers.CharField(required=False, allow_null=True) + reference_key = serializers.CharField(required=False, allow_null=True) + + +class MarkRunCompromisedResponseSerializer(serializers.Serializer): + comment = serializers.CharField() + bug = serializers.CharField(allow_null=True) + + +class UnmarkRunCompromisedResponseSerializer(serializers.Serializer): + message = serializers.CharField() + + +class RunCommentRequestSerializer(serializers.Serializer): + comment = serializers.CharField() + + +class RunCommentValueResponseSerializer(serializers.Serializer): + comment = serializers.CharField(allow_null=True) + + +class RunCommentResponseSerializer(serializers.Serializer): + id = serializers.IntegerField() + comment = serializers.CharField() + + def serialize_run_compromised_details( compromised: RunCompromisedDetails | None, ): diff --git a/bublik/interfaces/api_v2/run/views.py b/bublik/interfaces/api_v2/run/views.py index 419a4ae1..511650cc 100644 --- a/bublik/interfaces/api_v2/run/views.py +++ b/bublik/interfaces/api_v2/run/views.py @@ -13,11 +13,17 @@ generate_runs_details, ) from bublik.core.utils import get_difference -from bublik.data.serializers import ( - RunCommentSerializer, - TestIterationResultSerializer, +from bublik.data.serializers import TestIterationResultSerializer +from bublik.interfaces.api_v2.run.schemas import ( + create_comment_schema, + delete_comment_schema, + mark_compromised_schema, + run_viewset_schema, + unmark_compromised_schema, + update_comment_schema, ) from bublik.interfaces.api_v2.run.serializers import ( + RunCommentRequestSerializer, serialize_mark_run_compromised_result, serialize_run_comment_result, serialize_run_compromised_result, @@ -32,6 +38,7 @@ ] +@run_viewset_schema class RunViewSet(ModelViewSet): serializer_class = TestIterationResultSerializer @@ -116,21 +123,27 @@ def source(self, _request, pk=None): def status(self, _request, pk=None): return Response({'status': RunService.get_run_status(pk)}) - @action(detail=True, methods=['get', 'post', 'delete']) - def compromised(self, request, pk=None): - if request.method == 'GET': - return Response( - serialize_run_compromised_result(RunService.get_run_compromised(pk)), - ) - if request.method == 'POST': - comment = request.data.get('comment') - bug_id = request.data.get('bug_id') - reference_key = request.data.get('reference_key') - return Response( - serialize_mark_run_compromised_result( - RunService.mark_run_compromised(pk, comment, bug_id, reference_key), - ), - ) + @action(detail=True, methods=['get']) + def compromised(self, _request, pk=None): + return Response( + serialize_run_compromised_result(RunService.get_run_compromised(pk)), + ) + + @mark_compromised_schema + @compromised.mapping.post + def mark_compromised(self, request, pk=None): + comment = request.data.get('comment') + bug_id = request.data.get('bug_id') + reference_key = request.data.get('reference_key') + return Response( + serialize_mark_run_compromised_result( + RunService.mark_run_compromised(pk, comment, bug_id, reference_key), + ), + ) + + @unmark_compromised_schema + @compromised.mapping.delete + def unmark_compromised(self, _request, pk=None): try: RunService.unmark_run_compromised(pk) return Response({'message': f'Run {pk} is no longer compromised'}) @@ -140,29 +153,32 @@ def compromised(self, request, pk=None): @action( detail=True, - methods=['get', 'post', 'put', 'delete'], - serializer_class=RunCommentSerializer, + methods=['get'], + serializer_class=RunCommentRequestSerializer, ) - def comment(self, request, pk=None): - if request.method == 'GET': - comment = RunService.get_run_comment(pk) - return Response({'comment': comment}) - - if request.method == 'POST': - content = request.data.get('comment') - result = RunService.create_run_comment(pk, content) - return Response( - serialize_run_comment_result(result), - status=status.HTTP_201_CREATED, - ) - - if request.method == 'PUT': - content = request.data.get('comment') - result = RunService.create_run_comment(pk, content) - return Response(serialize_run_comment_result(result), status=status.HTTP_200_OK) - - if request.method == 'DELETE': - RunService.delete_run_comment(pk) - return Response(status=status.HTTP_204_NO_CONTENT) - - return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) + def comment(self, _request, pk=None): + comment = RunService.get_run_comment(pk) + return Response({'comment': comment}) + + @create_comment_schema + @comment.mapping.post + def create_comment(self, request, pk=None): + content = request.data.get('comment') + result = RunService.create_run_comment(pk, content) + return Response( + serialize_run_comment_result(result), + status=status.HTTP_201_CREATED, + ) + + @update_comment_schema + @comment.mapping.put + def update_comment(self, request, pk=None): + content = request.data.get('comment') + result = RunService.create_run_comment(pk, content) + return Response(serialize_run_comment_result(result), status=status.HTTP_200_OK) + + @delete_comment_schema + @comment.mapping.delete + def delete_comment(self, _request, pk=None): + RunService.delete_run_comment(pk) + return Response(status=status.HTTP_204_NO_CONTENT) From dd7e981396abea3611fd5d04b3874c7299a7d245 Mon Sep 17 00:00:00 2001 From: Mikhail Zayats Date: Wed, 17 Jun 2026 20:14:18 +0000 Subject: [PATCH 5/7] result: align OpenAPI schemas with actual API responses Ensure consistency between OpenAPI schemas and API responses by introducing explicit request/response serializers and binding them via drf-spectacular. Signed-off-by: Mikhail Zayats --- bublik/interfaces/api_v2/result/schemas.py | 93 +++++++++++++++++++ .../interfaces/api_v2/result/serializers.py | 76 +++++++++++++++ bublik/interfaces/api_v2/result/views.py | 6 +- 3 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 bublik/interfaces/api_v2/result/schemas.py create mode 100644 bublik/interfaces/api_v2/result/serializers.py diff --git a/bublik/interfaces/api_v2/result/schemas.py b/bublik/interfaces/api_v2/result/schemas.py new file mode 100644 index 00000000..9f401d82 --- /dev/null +++ b/bublik/interfaces/api_v2/result/schemas.py @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026 OKTET Labs Ltd. All rights reserved. + +from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view + +from bublik.interfaces.api_v2.errors.serializers import ErrorResponseSerializer +from bublik.interfaces.api_v2.result.serializers import ( + ResultArtifactsAndVerdictsResponseSerializer, + ResultListQuerySerializer, + ResultListResponseSerializer, + ResultMeasurementsResponseSerializer, + ResultRetrieveResponseSerializer, +) + + +RESULT_TAG = 'Results' + + +result_viewset_schema = extend_schema_view( + retrieve=extend_schema( + summary='Get result details', + description=''' + Returns full details for a single test iteration result, including + expected and obtained results, artifacts, parameters, comments, + requirements, error state, and measurement availability. + ''', + responses={ + 200: OpenApiResponse( + response=ResultRetrieveResponseSerializer, + description='Result details were successfully retrieved', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Result was not found', + ), + }, + tags=[RESULT_TAG], + ), + list=extend_schema( + summary='List results', + description=''' + Returns test iteration results matching the provided parent, test name, + execution sequence, result status, classification, and requirement + filters. + ''', + parameters=[ResultListQuerySerializer], + responses={ + 200: OpenApiResponse( + response=ResultListResponseSerializer, + description='Results were successfully retrieved', + ), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description='Result filter validation failed', + ), + }, + tags=[RESULT_TAG], + ), + artifacts_and_verdicts=extend_schema( + summary='Get result artifacts and verdicts', + description=''' + Returns artifact and verdict meta values for a result. + ''', + responses={ + 200: OpenApiResponse( + response=ResultArtifactsAndVerdictsResponseSerializer, + description='Artifacts and verdicts were successfully retrieved', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Result was not found', + ), + }, + tags=[RESULT_TAG], + ), + measurements=extend_schema( + summary='Get result measurements', + description=''' + Returns measurement chart and table data for a result. + ''', + responses={ + 200: OpenApiResponse( + response=ResultMeasurementsResponseSerializer, + description='Result measurements were successfully retrieved', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Result was not found', + ), + }, + tags=[RESULT_TAG], + ), +) diff --git a/bublik/interfaces/api_v2/result/serializers.py b/bublik/interfaces/api_v2/result/serializers.py new file mode 100644 index 00000000..8bb7e058 --- /dev/null +++ b/bublik/interfaces/api_v2/result/serializers.py @@ -0,0 +1,76 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026 OKTET Labs Ltd. All rights reserved. + +from rest_framework import serializers + + +class ResultListQuerySerializer(serializers.Serializer): + parent_id = serializers.IntegerField(required=False) + test_name = serializers.CharField(required=False) + start_exec_seqno = serializers.IntegerField(required=False) + results = serializers.CharField(required=False) + result_properties = serializers.CharField(required=False) + requirements = serializers.CharField(required=False) + + +class ResultKeySerializer(serializers.Serializer): + name = serializers.CharField() + url = serializers.CharField(allow_null=True) + + +class ExpectedResultSerializer(serializers.Serializer): + result_type = serializers.CharField(allow_null=True) + verdicts = serializers.ListField(child=serializers.CharField()) + keys = ResultKeySerializer(many=True) + + +class ObtainedResultSerializer(serializers.Serializer): + result_type = serializers.CharField(allow_null=True) + verdicts = serializers.ListField(child=serializers.CharField()) + + +class ResultDetailsSerializer(serializers.Serializer): + name = serializers.CharField() + result_id = serializers.IntegerField() + run_id = serializers.IntegerField() + project_id = serializers.IntegerField() + project_name = serializers.CharField() + iteration_id = serializers.IntegerField() + start = serializers.DateTimeField(allow_null=True) + obtained_result = ObtainedResultSerializer() + expected_results = ExpectedResultSerializer(many=True) + artifacts = serializers.ListField(child=serializers.CharField()) + parameters = serializers.ListField(child=serializers.CharField()) + comments = serializers.ListField(child=serializers.CharField()) + requirements = serializers.ListField(child=serializers.CharField()) + has_error = serializers.BooleanField() + has_measurements = serializers.BooleanField() + + +class ResultListResponseSerializer(serializers.Serializer): + results = ResultDetailsSerializer(many=True) + + +class ResultRetrieveResponseSerializer(serializers.Serializer): + result = ResultDetailsSerializer() + + +class MetaValueSerializer(serializers.Serializer): + id = serializers.IntegerField() + name = serializers.CharField(allow_null=True) + type = serializers.CharField() + value = serializers.CharField(allow_null=True) + hash = serializers.CharField() + comment = serializers.CharField(allow_null=True) + + +class ResultArtifactsAndVerdictsResponseSerializer(serializers.Serializer): + artifacts = MetaValueSerializer(many=True) + verdicts = MetaValueSerializer(many=True) + + +class ResultMeasurementsResponseSerializer(serializers.Serializer): + run_id = serializers.IntegerField(allow_null=True) + iteration_id = serializers.IntegerField() + charts = serializers.ListField(child=serializers.DictField()) + tables = serializers.ListField(child=serializers.DictField()) diff --git a/bublik/interfaces/api_v2/result/views.py b/bublik/interfaces/api_v2/result/views.py index 9daf201f..ccbe75d9 100644 --- a/bublik/interfaces/api_v2/result/views.py +++ b/bublik/interfaces/api_v2/result/views.py @@ -11,9 +11,8 @@ from bublik.core.run.stats import ( generate_results_details, ) -from bublik.data.serializers import ( - TestIterationResultSerializer, -) +from bublik.data.serializers import TestIterationResultSerializer +from bublik.interfaces.api_v2.result.schemas import result_viewset_schema __all__ = [ @@ -21,6 +20,7 @@ ] +@result_viewset_schema class ResultViewSet(ModelViewSet): serializer_class = TestIterationResultSerializer filter_backends: ClassVar[list] = [] From cf5f503233f1b44e14d209c333ba9268c2468308 Mon Sep 17 00:00:00 2001 From: Danil Kostromin Date: Mon, 15 Jun 2026 22:24:06 +0300 Subject: [PATCH 6/7] mcp: enforce run/result navigation to reduce context usage Replace the fragmented run metadata tools and the ambiguous direct-child `list_results` interface with two validated Markdown workflows. `get_run_overview` combines run metadata with the recursive statistics tree and can reduce it to unexpected leaves. `get_run_leaf_results` accepts an aggregate test result ID from that overview and delegates concrete execution filtering and pagination to the existing result service. This gives MCP clients a compact way to inspect large run trees without reconstructing UI lazy-loading semantics, while preserving strict payload validation before rendering. Issue: https://github.com/ts-factory/bublik/issues/326 Issue: https://github.com/ts-factory/bublik/issues/327 Signed-off-by: Danil Kostromin --- bublik/mcp/run/__init__.py | 12 ++ bublik/mcp/run/helpers.py | 144 ++++++++++++++++++++++ bublik/mcp/run/markdown.py | 240 +++++++++++++++++++++++++++++++++++++ bublik/mcp/run/models.py | 78 ++++++++++++ bublik/mcp/tools.py | 161 ++++++++++--------------- 5 files changed, 539 insertions(+), 96 deletions(-) create mode 100644 bublik/mcp/run/__init__.py create mode 100644 bublik/mcp/run/helpers.py create mode 100644 bublik/mcp/run/markdown.py create mode 100644 bublik/mcp/run/models.py diff --git a/bublik/mcp/run/__init__.py b/bublik/mcp/run/__init__.py new file mode 100644 index 00000000..c285de18 --- /dev/null +++ b/bublik/mcp/run/__init__.py @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026 OKTET Labs Ltd. All rights reserved. + +from .helpers import _get_run_leaf_results +from .markdown import render_run_leaf_results, render_run_overview + + +__all__ = [ + '_get_run_leaf_results', + 'render_run_leaf_results', + 'render_run_overview', +] diff --git a/bublik/mcp/run/helpers.py b/bublik/mcp/run/helpers.py new file mode 100644 index 00000000..13d0c481 --- /dev/null +++ b/bublik/mcp/run/helpers.py @@ -0,0 +1,144 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026 OKTET Labs Ltd. All rights reserved. + + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.db.models import Q +from rest_framework.exceptions import ValidationError + +from bublik.core.pagination_helpers import PaginatedResult +from bublik.core.result import ResultService +from bublik.core.run.services import RunService +from bublik.core.run.stats import generate_results_details +from bublik.data import models + +from .models import RunLeafResultsPayload + + +if TYPE_CHECKING: + from bublik.core.run.dto import RunStatsResult + + +def _find_stats_node(node: RunStatsResult, result_id: int) -> RunStatsResult | None: + if node.result_id == result_id: + return node + + for child in node.children: + found = _find_stats_node(child, result_id) + if found is not None: + return found + + return None + + +def _list_unexpected_or_abnormal_results( + *, + parent_id: int, + test_name: str, + start_exec_seqno: int, + page: int | None, + page_size: int | None, +) -> dict: + queryset = ResultService.list_results( + parent_id=parent_id, + test_name=test_name, + start_exec_seqno=start_exec_seqno, + ) + abnormal_statuses = models.ResultStatus.discover_statuses({'abnormal'}) + queryset = queryset.filter( + Q(meta_results__meta__type='err') + | Q( + meta_results__meta__type='result', + meta_results__meta__value__in=abnormal_statuses, + ), + ) + result_details = generate_results_details(queryset) + return PaginatedResult.paginate_queryset(result_details, page, page_size) + + +def _get_run_leaf_results( + leaf_result_id: int, + requirements: str | None = None, + results: str | None = None, + result_properties: str | None = None, + page: int | None = None, + page_size: int | None = None, + unexpected_only: bool = False, +) -> RunLeafResultsPayload: + if unexpected_only and any((requirements, results, result_properties)): + msg = ( + 'unexpected_only cannot be combined with requirements, results, ' + 'or result_properties' + ) + raise ValidationError(msg) + + result = ResultService.get_result(leaf_result_id) + run_id = result.test_run_id + if run_id is None: + msg = 'A test leaf result ID from get_run_overview is required' + raise ValidationError(msg) + + stats = RunService.get_run_stats(run_id, None) + if stats is None: + msg = f'Run statistics are unavailable for run {run_id}' + raise ValidationError(msg) + + leaf = _find_stats_node(stats, leaf_result_id) + if leaf is None: + msg = ( + 'Result ID is not an aggregate leaf ID; use the leaf result ID ' + 'shown by get_run_overview' + ) + raise ValidationError(msg) + if ( + leaf.type != 'test' + or leaf.children + or leaf.parent_id is None + or leaf.exec_seqno is None + ): + msg = 'A test leaf result ID from get_run_overview is required' + raise ValidationError(msg) + + if unexpected_only: + paginated = _list_unexpected_or_abnormal_results( + parent_id=leaf.parent_id, + test_name=leaf.test_name, + start_exec_seqno=leaf.exec_seqno, + page=page, + page_size=page_size, + ) + else: + paginated = ResultService.list_results_paginated( + parent_id=leaf.parent_id, + test_name=leaf.test_name, + start_exec_seqno=leaf.exec_seqno, + results=results, + result_properties=result_properties, + requirements=requirements, + page=page, + page_size=page_size, + ) + result_rows = [ + { + **row, + 'classification': 'unexpected' if row['has_error'] else 'expected', + } + for row in paginated['results'] + ] + + return RunLeafResultsPayload.model_validate( + { + 'leaf': { + 'result_id': leaf.result_id, + 'run_id': run_id, + 'test_name': leaf.test_name, + 'path': leaf.path, + }, + 'requirements': requirements, + 'pagination': paginated['pagination'], + 'results': result_rows, + }, + ) diff --git a/bublik/mcp/run/markdown.py b/bublik/mcp/run/markdown.py new file mode 100644 index 00000000..6460ac13 --- /dev/null +++ b/bublik/mcp/run/markdown.py @@ -0,0 +1,240 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026 OKTET Labs Ltd. All rights reserved. + + +from __future__ import annotations + +from dataclasses import asdict, is_dataclass +import json +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + from bublik.core.run.dto import ( + RunDetailsResult, + RunStatsComment, + RunStatsResult, + RunStatsValues, + ) + + from .models import ( + RunLeafResultsPayload, + ) + + +def _cell(value: Any) -> str: + if value is None or value == '' or (isinstance(value, (dict, list, tuple)) and not value): + return '-' + if isinstance(value, bool): + return 'Yes' if value else 'No' + if is_dataclass(value) and not isinstance(value, type): + value = asdict(value) + if isinstance(value, (dict, list, tuple)): + value = json.dumps( + value, + default=lambda item: ( + asdict(item) if is_dataclass(item) and not isinstance(item, type) else str(item) + ), + ensure_ascii=True, + sort_keys=True, + ) + return str(value).replace('|', '\\|').replace('\n', '
') + + +def _flatten_stats(node: RunStatsResult | None) -> list[RunStatsResult]: + if not node: + return [] + descendants = (item for child in node.children for item in _flatten_stats(child)) + return [node, *descendants] + + +def _comments_text(comments: list[RunStatsComment]) -> str: + values = [comment.comment for comment in comments] + return '
'.join(str(value) for value in values) if values else '-' + + +def _stats_total(stats: RunStatsValues) -> int: + return sum( + ( + stats.passed, + stats.failed, + stats.passed_unexpected, + stats.failed_unexpected, + stats.skipped, + stats.skipped_unexpected, + stats.abnormal, + ), + ) + + +def _stats_unexpected(stats: RunStatsValues) -> int: + return sum( + ( + stats.passed_unexpected, + stats.failed_unexpected, + stats.skipped_unexpected, + stats.abnormal, + ), + ) + + +def _special_categories(details: RunDetailsResult) -> dict[str, list[str]]: + return {category.name: category.values for category in details.special_categories} + + +def render_run_overview( + details: RunDetailsResult, + source: str | None, + stats: RunStatsResult | None, + requirements: str | None, + unexpected_only: bool = False, +) -> str: + compromised = details.compromised + rows = [ + ('Run ID', details.id), + ('Project', details.project_name), + ('Status', details.status), + ('Status by NOK', details.status_by_nok), + ('Conclusion', details.conclusion), + ('Conclusion reason', details.conclusion_reason), + ('Start', details.start), + ('Finish', details.finish), + ('Duration', details.duration), + ('Main package', details.main_package), + ('Source', source), + ('Requirements', requirements or 'none'), + ('Result view', 'unexpected leaves' if unexpected_only else 'all results'), + ('Compromised', compromised.status if compromised is not None else None), + ('Compromised comment', compromised.comment if compromised is not None else None), + ( + 'Compromised bug', + (compromised.bug_url or compromised.bug_id) if compromised is not None else None, + ), + ('Important tags', details.important_tags), + ('Relevant tags', details.relevant_tags), + ('Branches', details.branches), + ('Revisions', details.revisions), + ('Labels', details.labels), + ('Configuration', details.configuration), + ('Special categories', _special_categories(details)), + ] + lines = [ + f'# Run {_cell(details.id)} Overview', + '', + '| Field | Value |', + '|---|---|', + *(f'| {_cell(name)} | {_cell(value)} |' for name, value in rows), + '', + '## Result Statistics', + '', + ( + '| Result ID | Type | Path | Objective | Comments | Passed | Failed | ' + 'Passed NOK | Failed NOK | Skipped | Skipped NOK | Abnormal | Total | NOK |' + ), + '|---:|---|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|', + ] + + stat_nodes = _flatten_stats(stats) + if unexpected_only: + stat_nodes = [ + node + for node in stat_nodes + if not node.children and _stats_unexpected(node.stats) > 0 + ] + + for node in stat_nodes: + node_stats = node.stats + result_type = {'pkg': 'package', 'session': 'session', 'test': 'test'}.get( + node.type, + node.type, + ) + lines.append( + '| {result_id} | {result_type} | {path} | {objective} | {comments} | ' + '{passed} | {failed} | ' + '{passed_nok} | {failed_nok} | {skipped} | {skipped_nok} | ' + '{abnormal} | {total} | {nok} |'.format( + result_id=_cell(node.result_id), + result_type=_cell(result_type), + path=_cell(' / '.join(node.path)), + objective=_cell(node.objective), + comments=_cell(_comments_text(node.comments)), + passed=node_stats.passed, + failed=node_stats.failed, + passed_nok=node_stats.passed_unexpected, + failed_nok=node_stats.failed_unexpected, + skipped=node_stats.skipped, + skipped_nok=node_stats.skipped_unexpected, + abnormal=node_stats.abnormal, + total=_stats_total(node_stats), + nok=_stats_unexpected(node_stats), + ), + ) + + if not stat_nodes: + lines.append( + '| - | - | - | - | - | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |', + ) + if unexpected_only: + lines.extend( + [ + '', + '*No unexpected or abnormal result leaves found.*', + ], + ) + + lines.extend( + [ + '', + '*Use a test row Result ID with `get_run_leaf_results` to inspect ' + 'its concrete executions.*', + ], + ) + return '\n'.join(lines) + + +def _expected_result_text(expected_results) -> str: + values = [item.result_type for item in expected_results if item.result_type] + return ', '.join(values) if values else '-' + + +def render_run_leaf_results(payload: RunLeafResultsPayload) -> str: + leaf = payload.leaf + pagination = payload.pagination + lines = [ + f'# Leaf Results: {_cell(leaf.test_name)}', + '', + f'Path: `{_cell(" / ".join(leaf.path))}`', + f'Aggregate leaf: `{_cell(leaf.result_id)}`', + f'Run: `{_cell(leaf.run_id)}`', + f'Requirements: `{_cell(payload.requirements or "none")}`', + '', + ('| Result ID | Start | Obtained | Expected | Classification | Verdicts | Artifacts |'), + '|---:|---|---|---|---|---|---:|', + ] + for result in payload.results: + obtained = result.obtained_result + lines.append( + f'| {_cell(result.result_id)} | {_cell(result.start)} | ' + f'{_cell(obtained.result_type)} | ' + f'{_cell(_expected_result_text(result.expected_results))} | ' + f'{_cell(result.classification)} | ' + f'{_cell(obtained.verdicts)} | ' + f'{len(result.artifacts)} |', + ) + if not payload.results: + lines.append('| - | - | - | - | - | - | 0 |') + + page = 1 + if pagination.previous: + page = int(pagination.previous.split('=')[1]) + 1 + lines.extend( + [ + '', + ( + f'*Page {page} | {pagination.count} results | ' + f'previous: {pagination.previous or "-"} | ' + f'next: {pagination.next or "-"}*' + ), + ], + ) + return '\n'.join(lines) diff --git a/bublik/mcp/run/models.py b/bublik/mcp/run/models.py new file mode 100644 index 00000000..358101a1 --- /dev/null +++ b/bublik/mcp/run/models.py @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026 OKTET Labs Ltd. All rights reserved. + +from __future__ import annotations + +from datetime import datetime # noqa: TC003 - Pydantic needs this at runtime. +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class RunLeafIdentity(BaseModel): + model_config = ConfigDict(extra='forbid') + + result_id: int + run_id: int + test_name: str + path: list[str] + + +class RunLeafPagination(BaseModel): + model_config = ConfigDict(extra='forbid') + + count: int = Field(ge=0) + next: str | None + previous: str | None + + +class RunExpectedKey(BaseModel): + model_config = ConfigDict(extra='forbid') + + name: str + url: str | None + + +class RunExpectedResult(BaseModel): + model_config = ConfigDict(extra='forbid') + + result_type: str + verdicts: list[str] + keys: list[RunExpectedKey] + + +class RunObtainedResult(BaseModel): + model_config = ConfigDict(extra='forbid') + + result_type: str | None + verdicts: list[str] + + +class RunLeafResult(BaseModel): + model_config = ConfigDict(extra='forbid') + + name: str + result_id: int + run_id: int + project_id: int + project_name: str + iteration_id: int + start: datetime + obtained_result: RunObtainedResult + expected_results: list[RunExpectedResult] + artifacts: list[str] + parameters: list[str] + comments: list[str] + requirements: list[str] + has_error: bool + has_measurements: bool + classification: Literal['expected', 'unexpected'] + + +class RunLeafResultsPayload(BaseModel): + model_config = ConfigDict(extra='forbid') + + leaf: RunLeafIdentity + requirements: str | None + pagination: RunLeafPagination + results: list[RunLeafResult] diff --git a/bublik/mcp/tools.py b/bublik/mcp/tools.py index fb48a2f9..7f8cb49e 100644 --- a/bublik/mcp/tools.py +++ b/bublik/mcp/tools.py @@ -21,12 +21,10 @@ from bublik.core.tree.services import TreeService from bublik.interfaces.api_v2.run.serializers import ( serialize_paginated_run_summary_results, - serialize_run_compromised_result, - serialize_run_details, - serialize_run_stats_result, ) from bublik.mcp.models import JsonLog from bublik.mcp.processor import LogProcessor +from bublik.mcp.run import _get_run_leaf_results, render_run_leaf_results, render_run_overview if TYPE_CHECKING: @@ -55,77 +53,90 @@ def register_tools(mcp: FastMCP): # noqa: C901 ''' @mcp.tool() - async def get_run_details(run_id: int) -> dict: - ''' - Get detailed information about a test run. - - Args: - run_id: The ID of the test run - - Returns: - Dictionary with full run details including metadata, stats, etc. - ''' - return serialize_run_details(await sync_to_async(RunService.get_run_details)(run_id)) - - @mcp.tool() - async def get_run_status(run_id: int) -> str: - ''' - Get the status of a test run. - - Args: - run_id: The ID of the test run - - Returns: - Status string for the run (e.g., 'passed', 'failed', 'skipped') - ''' - return await sync_to_async(RunService.get_run_status)(run_id) - - @mcp.tool() - async def get_run_stats( + async def get_run_overview( run_id: int, requirements: str | None = None, - ) -> dict: + unexpected_only: bool = False, + ) -> str: ''' - Get statistics for a test run. + Get a complete Markdown overview of a test run. + + The overview combines run metadata, status, conclusion, source, + compromised details, and the aggregate result statistics tree. Each + test row includes a Result ID that can be passed to + get_run_leaf_results for concrete executions. Args: run_id: The ID of the test run - requirements: Optional requirements filter + requirements: Optional semicolon-separated requirements filter + unexpected_only: Return only test leaves containing unexpected or + abnormal results Returns: - Dictionary with run statistics including pass/fail counts + Markdown document containing run details and aggregate statistics ''' - return serialize_run_stats_result( - await sync_to_async(RunService.get_run_stats)(run_id, requirements), + details, source, stats = await sync_to_async( + lambda: ( + RunService.get_run_details(run_id), + RunService.get_run_source(run_id), + RunService.get_run_stats(run_id, requirements), + ), + )() + + return render_run_overview( + details, + source, + stats, + requirements, + unexpected_only, ) @mcp.tool() - async def get_run_source(run_id: int) -> str: - ''' - Get the source URL for a test run. - - Args: - run_id: The ID of the test run - - Returns: - Source URL string + async def get_run_leaf_results( + leaf_result_id: int, + requirements: str | None = None, + results: str | None = None, + result_properties: str | None = None, + page: int | None = None, + page_size: int | None = None, + unexpected_only: bool = False, + ) -> str: ''' - return await sync_to_async(RunService.get_run_source)(run_id) + Get paginated executions represented by a test leaf in a run overview. - @mcp.tool() - async def get_run_compromised(run_id: int) -> dict: - ''' - Get the compromised status of a test run. + Pass a test Result ID shown by get_run_overview, not an individual + execution ID. For the common failure-investigation workflow, set + unexpected_only to true to return only unexpected or abnormal executions. + Otherwise, use the advanced requirements, results, and result_properties + filters. unexpected_only is mutually exclusive with all advanced filters. + With no toggle or filters, all executions represented by the leaf are + returned. Args: - run_id: The ID of the test run + leaf_result_id: Aggregate test Result ID shown by get_run_overview + requirements: Optional semicolon-separated requirement names + results: Optional semicolon-separated obtained result statuses + (e.g., 'PASSED;FAILED;SKIPPED;KILLED;CORED;FAKED;INCOMPLETE') + result_properties: Optional semicolon-separated result properties + (e.g., 'expected;unexpected;not_run') + page: Page number (default: 1) + page_size: Items per page (default: 25, max: 10000) + unexpected_only: Return only unexpected or abnormal executions. + Cannot be combined with requirements, results, or result_properties Returns: - Dictionary with compromised status data including comment and bug ID + Markdown table with the matching concrete executions and pagination ''' - return serialize_run_compromised_result( - await sync_to_async(RunService.get_run_compromised)(run_id), + payload = await sync_to_async(_get_run_leaf_results)( + leaf_result_id=leaf_result_id, + requirements=requirements, + results=results, + result_properties=result_properties, + page=page, + page_size=page_size, + unexpected_only=unexpected_only, ) + return render_run_leaf_results(payload) @mcp.tool() async def get_result_details(result_id: int) -> dict: @@ -153,48 +164,6 @@ async def get_result_artifacts_and_verdicts(result_id: int) -> dict: ''' return await sync_to_async(ResultService.get_result_artifacts_and_verdicts)(result_id) - @mcp.tool() - async def list_results( - parent_id: int, - test_name: str, - start_exec_seqno: str, - results: str | None = None, - result_properties: str | None = None, - requirements: str | None = None, - page: int | None = None, - page_size: int | None = None, - ) -> dict: - ''' - List test results with filtering. - - Args: - parent_id: Filter by parent package ID - test_name: Filter by test name - start_exec_seqno: Retain only the consecutive sequence of results - starting from the specified execution number, based on the global - run sequence - results: Semicolon-separated result statuses - (e.g., 'PASSED;FAILED;SKIPPED;KILLED;CORED;FAKED;INCOMPLETE') - result_properties: Semicolon-separated result properties - (e.g., 'expected;unexpected;not_run') - requirements: Semicolon-separated requirement names - page: Page number (default: 1) - page_size: Items per page (default: 25, max: 10000) - - Returns: - Dictionary with pagination metadata and result details - ''' - return await sync_to_async(ResultService.list_results_paginated)( - parent_id=parent_id, - test_name=test_name, - start_exec_seqno=start_exec_seqno, - results=results, - result_properties=result_properties, - requirements=requirements, - page=page, - page_size=page_size, - ) - # Project tools @mcp.tool() From 4551d964a5b31ef14a661435359506d7240e0a13 Mon Sep 17 00:00:00 2001 From: Danil Kostromin Date: Tue, 16 Jun 2026 20:58:27 +0300 Subject: [PATCH 7/7] mcp: fix docstring quotes to use `"` as adviced in PEP 257 Link: https://peps.python.org/pep-0257/ Link: https://github.com/ts-factory/bublik/pull/331 Signed-off-by: Danil Kostromin --- bublik/mcp/models.py | 44 +++++++++---------- bublik/mcp/tools.py | 100 +++++++++++++++++++++---------------------- 2 files changed, 72 insertions(+), 72 deletions(-) diff --git a/bublik/mcp/models.py b/bublik/mcp/models.py index 3393943f..f23850da 100644 --- a/bublik/mcp/models.py +++ b/bublik/mcp/models.py @@ -296,9 +296,9 @@ class JsonLog(BaseModel): class LogLine(BaseModel): - ''' + """ Single log line with markdown conversion support. - ''' + """ line_number: int level: str @@ -315,7 +315,7 @@ class LogLine(BaseModel): table_index: int = 0 # Index of source te-log-table block def truncate_content(self, max_length: int) -> LogLine: - ''' + """ Create a copy with truncated content. Args: @@ -323,7 +323,7 @@ def truncate_content(self, max_length: int) -> LogLine: Returns: New LogLine with truncated content and metadata - ''' + """ if len(self.content) <= max_length: return self @@ -336,7 +336,7 @@ def truncate_content(self, max_length: int) -> LogLine: ) def to_markdown(self, max_content_length: int | None = None) -> str: - ''' + """ Convert to markdown table row. Args: @@ -344,7 +344,7 @@ def to_markdown(self, max_content_length: int | None = None) -> str: Returns: Markdown table row string - ''' + """ content = self.content if ( @@ -366,12 +366,12 @@ def to_markdown(self, max_content_length: int | None = None) -> str: ) def to_markdown_full(self) -> str: - ''' + """ Convert to full markdown block with all details. Returns: Full markdown representation of the log line - ''' + """ lines = [ f'### Line {self.line_number}', f'- **Level:** {self.level}', @@ -395,16 +395,16 @@ def to_markdown_full(self) -> str: class LogLinesResult(BaseModel): - ''' + """ Result of line extraction/filtering with markdown support. - ''' + """ lines: list[LogLine] total_count: int filter_applied: str | None = None # Description of filter def to_markdown(self, max_content_length: int | None = None) -> str: - ''' + """ Convert to markdown table with separate tables per te-log-table block. Args: @@ -412,7 +412,7 @@ def to_markdown(self, max_content_length: int | None = None) -> str: Returns: Markdown table string with separate tables grouped by table_index - ''' + """ if not self.lines: result = ['| Line | Depth | Level | Entity:User | Time | Content |'] result.append('|------|-------|-------|-------------|------|---------|') @@ -446,12 +446,12 @@ def to_markdown(self, max_content_length: int | None = None) -> str: return '\n'.join(result) def to_markdown_summary(self) -> str: - ''' + """ Convert to summary with counts by level. Returns: Markdown summary string - ''' + """ level_counts = Counter(line.level for line in self.lines) lines = [ @@ -468,9 +468,9 @@ def to_markdown_summary(self) -> str: class LogHeaderInfo(BaseModel): - ''' + """ Metadata from a single te-log-meta block. - ''' + """ test_id: str test_name: str @@ -492,9 +492,9 @@ class LogHeaderInfo(BaseModel): class LogOverview(BaseModel): - ''' + """ Log overview with full metadata and markdown conversion support. - ''' + """ # Identity (from LogEntityModel) test_id: str @@ -534,12 +534,12 @@ class LogOverview(BaseModel): scenario_lines: list[LogLine] = Field(default_factory=list) def to_markdown(self) -> str: # noqa: C901 - ''' + """ Convert to full markdown document with tables. Returns: Complete markdown representation of the log overview - ''' + """ sections = [] # Header @@ -687,12 +687,12 @@ def to_markdown(self) -> str: # noqa: C901 return '\n'.join(sections) def to_markdown_summary(self) -> str: - ''' + """ Convert to brief markdown summary. Returns: Brief markdown summary - ''' + """ sections = [ f'# {self.test_name}', '', diff --git a/bublik/mcp/tools.py b/bublik/mcp/tools.py index 7f8cb49e..e3800486 100644 --- a/bublik/mcp/tools.py +++ b/bublik/mcp/tools.py @@ -35,12 +35,12 @@ def get_default_date_range(): - ''' + """ Calculate default date range: 6 months ago to today. Returns: Tuple of (from_date, to_date) as ISO format strings (yyyy-mm-dd) - ''' + """ to_date = date.today() from_date = to_date - timedelta(days=180) @@ -48,9 +48,9 @@ def get_default_date_range(): def register_tools(mcp: FastMCP): # noqa: C901 - ''' + """ Register all MCP tools with the FastMCP server. - ''' + """ @mcp.tool() async def get_run_overview( @@ -58,7 +58,7 @@ async def get_run_overview( requirements: str | None = None, unexpected_only: bool = False, ) -> str: - ''' + """ Get a complete Markdown overview of a test run. The overview combines run metadata, status, conclusion, source, @@ -74,7 +74,7 @@ async def get_run_overview( Returns: Markdown document containing run details and aggregate statistics - ''' + """ details, source, stats = await sync_to_async( lambda: ( RunService.get_run_details(run_id), @@ -101,7 +101,7 @@ async def get_run_leaf_results( page_size: int | None = None, unexpected_only: bool = False, ) -> str: - ''' + """ Get paginated executions represented by a test leaf in a run overview. Pass a test Result ID shown by get_run_overview, not an individual @@ -126,7 +126,7 @@ async def get_run_leaf_results( Returns: Markdown table with the matching concrete executions and pagination - ''' + """ payload = await sync_to_async(_get_run_leaf_results)( leaf_result_id=leaf_result_id, requirements=requirements, @@ -140,7 +140,7 @@ async def get_run_leaf_results( @mcp.tool() async def get_result_details(result_id: int) -> dict: - ''' + """ Get detailed information about a test result. Args: @@ -148,12 +148,12 @@ async def get_result_details(result_id: int) -> dict: Returns: Dictionary with full result details - ''' + """ return await sync_to_async(ResultService.get_result_details)(result_id) @mcp.tool() async def get_result_artifacts_and_verdicts(result_id: int) -> dict: - ''' + """ Get artifacts and verdicts for a test result. Args: @@ -161,24 +161,24 @@ async def get_result_artifacts_and_verdicts(result_id: int) -> dict: Returns: Dictionary with artifacts and verdicts lists - ''' + """ return await sync_to_async(ResultService.get_result_artifacts_and_verdicts)(result_id) # Project tools @mcp.tool() async def list_projects() -> list[dict]: - ''' + """ List all available projects. Returns: List of projects with id and name - ''' + """ return await sync_to_async(ProjectService.list_projects)() @mcp.tool() async def get_project(project_id: int) -> dict: - ''' + """ Get details of a specific project. Args: @@ -186,7 +186,7 @@ async def get_project(project_id: int) -> dict: Returns: Dictionary with project id and name - ''' + """ return await sync_to_async(ProjectService.get_project)(project_id) # Runs tools @@ -205,7 +205,7 @@ async def list_runs( # noqa: PLR0913 page: int | None = None, page_size: int | None = None, ) -> dict: - ''' + """ List test runs with comprehensive filtering. Args: @@ -223,7 +223,7 @@ async def list_runs( # noqa: PLR0913 Returns: Dictionary with pagination metadata and run details - ''' + """ queryset = await sync_to_async(RunService.list_runs_queryset)( start_date=start_date, finish_date=finish_date, @@ -250,7 +250,7 @@ async def list_runs_today( page: int | None = None, page_size: int | None = None, ) -> dict: - ''' + """ List test runs for today. Args: @@ -260,7 +260,7 @@ async def list_runs_today( Returns: Dictionary with pagination metadata and today's run details - ''' + """ date_str = await sync_to_async(DashboardService.get_latest_dashboard_date)( project_id=project_id, ) @@ -283,7 +283,7 @@ async def list_runs_today( @mcp.tool() async def get_latest_run_date(project_id: int | None = None) -> str | None: - ''' + """ Get the most recent run date. Args: @@ -291,7 +291,7 @@ async def get_latest_run_date(project_id: int | None = None) -> str | None: Returns: Date string in yyyy-mm-dd format, or None if no runs exist - ''' + """ def _get_latest_date(): runs = get_test_runs(order_by='-start') @@ -311,7 +311,7 @@ async def get_dashboard( sort_by: str | None = None, validate: bool = False, ) -> dict: - ''' + """ Get dashboard data for a specific date (structured format). Returns the same format as /api/v2/dashboard/ endpoint with: @@ -332,7 +332,7 @@ async def get_dashboard( Raises: ValidationError: if validate=True and settings are invalid - ''' + """ # Optional validation (useful for debugging config issues) if validate: await sync_to_async(DashboardService.validate_dashboard_settings)( @@ -352,7 +352,7 @@ async def get_dashboard_today( project_id: int | None = None, sort_by: str | None = None, ) -> dict: - ''' + """ Get dashboard data for today (structured format). Args: @@ -361,7 +361,7 @@ async def get_dashboard_today( Returns: Dictionary with the latest dashboard structure - ''' + """ date_str = await sync_to_async(DashboardService.get_latest_dashboard_date)( project_id=project_id, ) @@ -378,7 +378,7 @@ async def get_dashboard_today( @mcp.tool() async def get_latest_dashboard_date(project_id: int | None = None) -> str | None: - ''' + """ Get the most recent date with dashboard data. Args: @@ -386,7 +386,7 @@ async def get_latest_dashboard_date(project_id: int | None = None) -> str | None Returns: Date string in yyyy-mm-dd format, or None if no data - ''' + """ return await sync_to_async(DashboardService.get_latest_dashboard_date)( project_id=project_id, ) @@ -395,7 +395,7 @@ async def get_latest_dashboard_date(project_id: int | None = None) -> str | None @mcp.tool() async def get_log_urls(result_id: int, page: int | None = None) -> dict: - ''' + """ Get log URLs for a test result (without fetching content). Args: @@ -404,7 +404,7 @@ async def get_log_urls(result_id: int, page: int | None = None) -> dict: Returns: Dictionary with 'url' and 'attachments_url' keys - ''' + """ return await sync_to_async(LogService.get_json_log_urls)( result_id, page, @@ -413,7 +413,7 @@ async def get_log_urls(result_id: int, page: int | None = None) -> dict: @mcp.tool() async def get_log_html_url(result_id: int) -> str | None: - ''' + """ Get HTML log URL for a test result. Args: @@ -421,7 +421,7 @@ async def get_log_html_url(result_id: int) -> str | None: Returns: URL string or None if not available - ''' + """ return await sync_to_async(LogService.get_html_log_url)(result_id) LOG_RETURN_FORMAT: str = 'markdown' # noqa: N806 @@ -433,7 +433,7 @@ async def get_log_overview( include_scenario: bool = True, max_content_length: int | None = 200, ) -> dict | str: - ''' + """ Get structured log overview with metadata and optional scenario logs. Extracts comprehensive log header information including test metadata, @@ -452,7 +452,7 @@ async def get_log_overview( Raises: ValueError: If no header block is found in the log - ''' + """ def _get_overview(): log_data = LogService.get_log_json(result_id, page) @@ -483,7 +483,7 @@ async def get_log_lines( # noqa: PLR0913 table_index: int = 0, max_content_length: int | None = 200, ) -> dict | str: - ''' + """ Extract and filter log lines. Retrieves log lines with optional range-based and content-based filtering. @@ -506,7 +506,7 @@ async def get_log_lines( # noqa: PLR0913 Returns: LogLinesResult as dictionary or markdown string. When truncated, lines will have content_truncated=True and original_content_length set. - ''' + """ def _get_lines(): log_data = LogService.get_log_json(result_id, page) @@ -536,7 +536,7 @@ async def get_log_line( line_number: int, page: int | None = None, ) -> dict | str: - ''' + """ Get a single log line with full, untruncated content. Use this tool to retrieve the complete content of a specific line @@ -552,7 +552,7 @@ async def get_log_line( Raises: ValueError: If line_number is not found - ''' + """ def _get_line(): log_data = LogService.get_log_json(result_id, page) @@ -583,7 +583,7 @@ def _get_line(): @mcp.tool() async def get_tree_path(result_id: int) -> list[int]: - ''' + """ Get path to a specific test result in the tree. Args: @@ -591,7 +591,7 @@ async def get_tree_path(result_id: int) -> list[int]: Returns: List of node IDs from root to the specified result - ''' + """ return await sync_to_async(TreeService.get_tree_path)(result_id) # History tools @@ -609,7 +609,7 @@ async def get_history( # noqa: PLR0913 page: int | None = None, page_size: int | None = None, ) -> dict: - ''' + """ Get test history (linear format). Args: @@ -626,7 +626,7 @@ async def get_history( # noqa: PLR0913 Returns: Dictionary with history results, counts, date range, and pagination - ''' + """ # Set default date range if not provided if from_date is None and to_date is None: from_date, to_date = get_default_date_range() @@ -657,7 +657,7 @@ async def get_history_grouped( # noqa: PLR0913 page: int | None = None, page_size: int | None = None, ) -> dict: - ''' + """ Get test history grouped by iteration. Args: @@ -674,7 +674,7 @@ async def get_history_grouped( # noqa: PLR0913 Returns: Dictionary with grouped history results, counts, date range, and pagination - ''' + """ # Set default date range if not provided if from_date is None and to_date is None: from_date, to_date = get_default_date_range() @@ -696,7 +696,7 @@ async def get_history_grouped( # noqa: PLR0913 @mcp.tool() async def get_run_requirements(run_id: int) -> list[str]: - ''' + """ Get requirements for a run. Args: @@ -704,12 +704,12 @@ async def get_run_requirements(run_id: int) -> list[str]: Returns: Sorted list of requirement strings - ''' + """ return await sync_to_async(RunService.get_run_requirements)(run_id) @mcp.tool() async def get_run_comment(run_id: int) -> str | None: - ''' + """ Get comment for a run. Args: @@ -717,17 +717,17 @@ async def get_run_comment(run_id: int) -> str | None: Returns: Comment string or None if no comment exists - ''' + """ return await sync_to_async(RunService.get_run_comment)(run_id) # Server tools @mcp.tool() async def get_server_version() -> dict: - ''' + """ Get server version information. Returns: Dictionary with repository revision information - ''' + """ return await sync_to_async(ServerService.get_version)()