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/__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/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
new file mode 100644
index 00000000..ccbe75d9
--- /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
+from bublik.interfaces.api_v2.result.schemas import result_viewset_schema
+
+
+__all__ = [
+ 'ResultViewSet',
+]
+
+
+@result_viewset_schema
+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/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
new file mode 100644
index 00000000..f4106da0
--- /dev/null
+++ b/bublik/interfaces/api_v2/run/serializers.py
@@ -0,0 +1,363 @@
+# 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 rest_framework import serializers
+
+
+if TYPE_CHECKING:
+ from bublik.core.run.dto import (
+ MarkRunCompromisedResult,
+ RunCommentResult,
+ RunCompromisedDetails,
+ RunCompromisedResult,
+ RunDetailsResult,
+ RunRevision,
+ RunSpecialCategory,
+ RunStatsComment,
+ RunStatsResult,
+ RunStatsValues,
+ RunSummaryResult,
+ RunSummaryStats,
+ )
+
+
+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,
+):
+ 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/results.py b/bublik/interfaces/api_v2/run/views.py
similarity index 55%
rename from bublik/interfaces/api_v2/results.py
rename to bublik/interfaces/api_v2/run/views.py
index 95ede3d7..511650cc 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,25 +8,37 @@
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
-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,
+ serialize_run_details,
+ serialize_run_stats_result,
+ serialize_run_summary_results,
)
-all = [
+__all__ = [
'RunViewSet',
- 'ResultViewSet',
]
+@run_viewset_schema
class RunViewSet(ModelViewSet):
serializer_class = TestIterationResultSerializer
@@ -54,7 +64,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)),
},
)
@@ -93,12 +103,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):
@@ -112,15 +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(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))
+ @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'})
@@ -130,64 +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(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)
-
- 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)
-
-
-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):
+ 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(
- data={'results': generate_results_details(self.get_queryset())},
+ serialize_run_comment_result(result),
+ status=status.HTTP_201_CREATED,
)
- @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))
+ @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)
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/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 fe56d9f9..e3800486 100644
--- a/bublik/mcp/tools.py
+++ b/bublik/mcp/tools.py
@@ -19,8 +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,
+)
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:
@@ -31,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)
@@ -44,82 +48,99 @@ 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_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 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:
- '''
- Get statistics for a test run.
-
- Args:
- run_id: The ID of the test run
- requirements: Optional requirements filter
-
- Returns:
- Dictionary with run statistics including pass/fail counts
- '''
- return await sync_to_async(RunService.get_run_stats)(run_id, requirements)
+ unexpected_only: bool = False,
+ ) -> str:
+ """
+ Get a complete Markdown overview of a test run.
- @mcp.tool()
- async def get_run_source(run_id: int) -> str:
- '''
- Get the source URL for 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 semicolon-separated requirements filter
+ unexpected_only: Return only test leaves containing unexpected or
+ abnormal results
Returns:
- Source URL string
- '''
- return await sync_to_async(RunService.get_run_source)(run_id)
+ Markdown document containing run details and aggregate statistics
+ """
+ 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_compromised(run_id: int) -> dict:
- '''
- Get the compromised status of a test run.
+ 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:
+ """
+ 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
+ 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
- '''
- return await sync_to_async(RunService.get_run_compromised)(run_id)
+ 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,
+ 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:
- '''
+ """
Get detailed information about a test result.
Args:
@@ -127,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:
@@ -140,66 +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)
- @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()
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:
@@ -207,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
@@ -226,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:
@@ -244,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,
@@ -257,10 +236,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()
@@ -269,7 +250,7 @@ async def list_runs_today(
page: int | None = None,
page_size: int | None = None,
) -> dict:
- '''
+ """
List test runs for today.
Args:
@@ -279,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,
)
@@ -292,15 +273,17 @@ 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()
async def get_latest_run_date(project_id: int | None = None) -> str | None:
- '''
+ """
Get the most recent run date.
Args:
@@ -308,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')
@@ -328,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:
@@ -349,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)(
@@ -369,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:
@@ -378,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,
)
@@ -395,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:
@@ -403,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,
)
@@ -412,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:
@@ -421,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,
@@ -430,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:
@@ -438,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
@@ -450,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,
@@ -469,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)
@@ -500,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.
@@ -523,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)
@@ -553,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
@@ -569,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)
@@ -600,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:
@@ -608,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
@@ -626,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:
@@ -643,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()
@@ -674,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:
@@ -691,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()
@@ -713,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:
@@ -721,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:
@@ -734,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)()