Skip to content

Commit 683b51c

Browse files
author
ci bot
committed
Merge branch 'mcp-testresult-disposition' into 'enterprise'
feat(mcp): add test-result disposition tools (TG-1088) See merge request dkinternal/testgen/dataops-testgen!535
2 parents 504c6ab + 01124a6 commit 683b51c

8 files changed

Lines changed: 671 additions & 43 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Shared test-result disposition service.
2+
3+
Sets the disposition on test results and keeps the parent test definition's
4+
active/lock state coupled: a "Muted" (``Disposition.INACTIVE``) disposition
5+
deactivates the test definition and locks it against auto-regeneration; any
6+
other value — or clearing the disposition — reactivates and unlocks it. Passed
7+
test results are never dispositioned. Used by both the Streamlit UI
8+
(test_results page) and the MCP tools. Must not import Streamlit.
9+
"""
10+
from collections.abc import Sequence
11+
from dataclasses import dataclass
12+
from datetime import UTC, datetime
13+
from uuid import UUID
14+
15+
from sqlalchemy import func, select, update
16+
17+
from testgen.common.enums import Disposition
18+
from testgen.common.models import get_current_session
19+
from testgen.common.models.test_definition import TestDefinition
20+
from testgen.common.models.test_result import TestResult, TestResultStatus
21+
22+
23+
@dataclass(frozen=True)
24+
class DispositionUpdate:
25+
"""Outcome of a disposition write.
26+
27+
``matched`` is the number of non-Passed results updated; ``passed_skipped`` is
28+
the number of Passed results that matched but were left unchanged.
29+
"""
30+
31+
matched: int
32+
passed_skipped: int
33+
34+
35+
def coupled_test_definition_state(disposition: Disposition | None) -> tuple[bool, bool]:
36+
"""Return the parent test definition's ``(test_active, lock_refresh)`` for a disposition.
37+
38+
Muted (``INACTIVE``) → ``(False, True)``: deactivate and lock against
39+
auto-regeneration. Any other value, or cleared (``None``) → ``(True, False)``.
40+
"""
41+
deactivate = disposition == Disposition.INACTIVE
42+
return (not deactivate, deactivate)
43+
44+
45+
def coerce_ui_disposition(value: str | None) -> Disposition | None:
46+
"""Map a UI disposition string to the stored value.
47+
48+
The UI passes ``Confirmed`` / ``Dismissed`` / ``Inactive``, or ``No Decision`` /
49+
empty / ``None`` to clear. Unknown values raise ``ValueError`` (caller's bug).
50+
"""
51+
if value in (None, "", "No Decision"):
52+
return None
53+
return Disposition(value)
54+
55+
56+
def set_test_results_disposition(
57+
test_result_ids: Sequence[str | UUID],
58+
disposition: Disposition | None,
59+
) -> DispositionUpdate:
60+
"""Set ``disposition`` on the given results and couple their parent test definitions.
61+
62+
Passed results are excluded from the write. ``disposition=None`` clears it (NULL).
63+
Returns the matched (non-Passed, updated) and passed-skipped counts.
64+
"""
65+
ids = [UUID(str(rid)) for rid in test_result_ids]
66+
if not ids:
67+
return DispositionUpdate(matched=0, passed_skipped=0)
68+
69+
session = get_current_session()
70+
71+
passed_skipped = session.scalar(
72+
select(func.count())
73+
.select_from(TestResult)
74+
.where(TestResult.id.in_(ids), TestResult.status == TestResultStatus.Passed)
75+
) or 0
76+
77+
# NULL result_status rows (e.g. training-mode results) are excluded by `!= Passed`,
78+
# matching the prior UI behavior — disposition only applies to evaluated results.
79+
non_passed = (TestResult.id.in_(ids), TestResult.status != TestResultStatus.Passed)
80+
81+
tr_stmt = (
82+
update(TestResult)
83+
.where(*non_passed)
84+
.values(disposition=disposition.value if disposition is not None else None)
85+
)
86+
matched = session.execute(tr_stmt).rowcount
87+
88+
test_active, lock_refresh = coupled_test_definition_state(disposition)
89+
affected_td_ids = select(TestResult.test_definition_id).where(*non_passed)
90+
td_stmt = (
91+
update(TestDefinition)
92+
.where(TestDefinition.id.in_(affected_td_ids))
93+
.values(
94+
test_active=test_active,
95+
lock_refresh=lock_refresh,
96+
last_manual_update=datetime.now(UTC).replace(tzinfo=None),
97+
)
98+
)
99+
session.execute(td_stmt)
100+
101+
return DispositionUpdate(matched=matched, passed_skipped=passed_skipped)

testgen/mcp/server.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,12 +220,14 @@ def build_mcp_server(
220220
validate_custom_test,
221221
)
222222
from testgen.mcp.tools.test_results import (
223+
bulk_update_test_results,
223224
compare_test_runs,
224225
get_failure_summary,
225226
get_failure_trend,
226227
list_test_result_history,
227228
list_test_results,
228229
search_test_results,
230+
update_test_result,
229231
)
230232
from testgen.mcp.tools.test_runs import get_test_run, list_test_runs
231233

@@ -266,6 +268,8 @@ def safe_prompt(fn):
266268
safe_tool(search_test_results)
267269
safe_tool(get_failure_trend)
268270
safe_tool(compare_test_runs)
271+
safe_tool(update_test_result)
272+
safe_tool(bulk_update_test_results)
269273
safe_tool(get_test_type)
270274
safe_tool(get_source_data)
271275
safe_tool(get_source_data_query)

testgen/mcp/tools/common.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from testgen.common.models.scores import ScoreCategory, ScoreDefinition
3131
from testgen.common.models.table_group import TableGroup
3232
from testgen.common.models.test_definition import TestDefinition, TestDefinitionNote, TestType
33-
from testgen.common.models.test_result import TestResultStatus
33+
from testgen.common.models.test_result import TestResult, TestResultStatus
3434
from testgen.common.models.test_suite import TestSuite
3535
from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError
3636
from testgen.mcp.permissions import get_project_permissions
@@ -353,6 +353,25 @@ def parse_disposition(value: str) -> Disposition:
353353
return db_value
354354

355355

356+
_NO_DECISION = "No Decision"
357+
358+
359+
def parse_test_result_disposition(value: str) -> Disposition | None:
360+
"""Validate a user-facing test-result disposition and return the stored value.
361+
362+
Accepts ``Confirmed``, ``Dismissed``, ``Muted``, and ``No Decision``. ``Muted``
363+
maps to ``Disposition.INACTIVE``; ``No Decision`` clears the disposition (returns
364+
``None`` → NULL).
365+
"""
366+
if value == _NO_DECISION:
367+
return None
368+
db_value = _DISPOSITION_USER_TO_DB.get(value)
369+
if db_value is None:
370+
valid = ", ".join([*_DISPOSITION_USER_TO_DB, _NO_DECISION])
371+
raise MCPUserError(f"Invalid disposition `{value}`. Valid values: {valid}")
372+
return db_value
373+
374+
356375
def format_disposition(value: Disposition | str) -> str:
357376
"""Map a stored disposition to its user-facing label (``INACTIVE`` → "Muted")."""
358377
try:
@@ -623,6 +642,28 @@ def resolve_test_definition(test_definition_id: str) -> TestDefinition:
623642
return td
624643

625644

645+
def resolve_test_result(test_result_id: str) -> TestResult:
646+
"""Resolve a test result ID to the live ORM model, collapsing missing-or-inaccessible.
647+
648+
Filters monitor suites and project access via the result's parent test suite.
649+
"""
650+
result_uuid = parse_uuid(test_result_id, "test_result_id")
651+
perms = get_project_permissions()
652+
query = (
653+
select(TestResult)
654+
.join(TestSuite, TestResult.test_suite_id == TestSuite.id)
655+
.where(
656+
TestResult.id == result_uuid,
657+
TestSuite.is_monitor.isnot(True),
658+
TestSuite.project_code.in_(perms.allowed_codes),
659+
)
660+
)
661+
result = get_current_session().scalars(query).first()
662+
if result is None:
663+
raise MCPResourceNotAccessible("Test result", test_result_id)
664+
return result
665+
666+
626667
def resolve_test_note(test_note_id: str) -> TestDefinitionNote:
627668
"""Resolve a test note ID to the live ORM model, collapsing missing-or-inaccessible.
628669

testgen/mcp/tools/test_results.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
from datetime import UTC, datetime, timedelta
22
from uuid import UUID
33

4+
from sqlalchemy import select
5+
46
from testgen.common.enums import JobStatus
57
from testgen.common.models import get_current_session, with_database_session
68
from testgen.common.models.job_execution import JobExecution
79
from testgen.common.models.test_definition import TestType
810
from testgen.common.models.test_result import BucketInterval, TestResult, TestResultStatus
911
from testgen.common.models.test_run import TestRun, TestRunSummary
1012
from testgen.common.models.test_suite import TestSuite
13+
from testgen.common.test_result_disposition_service import (
14+
DispositionUpdate,
15+
set_test_results_disposition,
16+
)
1117
from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError
1218
from testgen.mcp.permissions import get_project_permissions, mcp_permission
1319
from testgen.mcp.tools.common import (
@@ -18,8 +24,11 @@
1824
parse_failure_group_by,
1925
parse_result_status,
2026
parse_since_arg,
27+
parse_test_result_disposition,
2128
parse_uuid,
2229
resolve_aggregate_scope,
30+
resolve_test_result,
31+
resolve_test_suite,
2332
resolve_test_type,
2433
validate_limit,
2534
validate_page,
@@ -134,6 +143,7 @@ def list_test_results(
134143
doc.heading(2, f"[{status_str}] {test_name} on `{r.column_names}` in `{r.table_name}`")
135144
else:
136145
doc.heading(2, f"[{status_str}] {test_name} on `{r.table_name}`")
146+
doc.field("Test result", r.id, code=True)
137147
doc.field("Test definition", r.test_definition_id, code=True)
138148
if r.column_names:
139149
doc.field("Column", r.column_names, code=True)
@@ -636,3 +646,116 @@ def _section(title: str, rows: list) -> None:
636646
_section("Removed Tests", diff.removed_tests)
637647

638648
return doc.render()
649+
650+
651+
@with_database_session
652+
@mcp_permission("disposition")
653+
def update_test_result(test_result_id: str, disposition: str) -> str:
654+
"""Set the disposition on a single test result (confirm, dismiss, mute, or clear).
655+
656+
Args:
657+
test_result_id: UUID of the test result, e.g. from ``list_test_results``.
658+
disposition: New disposition. One of 'Confirmed', 'Dismissed', 'Muted',
659+
'No Decision' (clears it). 'Muted' deactivates the parent test and locks
660+
it against auto-regeneration; any other value reactivates and unlocks it.
661+
"""
662+
result = resolve_test_result(test_result_id)
663+
db_disposition = parse_test_result_disposition(disposition)
664+
665+
update: DispositionUpdate = set_test_results_disposition([result.id], db_disposition)
666+
667+
doc = MdDoc()
668+
if update.matched == 0:
669+
doc.text(
670+
f"Test result {MdDoc.code(test_result_id)} was not dispositioned — disposition does "
671+
f"not apply to passed results. No change made."
672+
)
673+
return doc.render()
674+
675+
doc.text(f"Updated test result {MdDoc.code(test_result_id)} disposition to **{disposition}**.")
676+
return doc.render()
677+
678+
679+
@with_database_session
680+
@mcp_permission("disposition")
681+
def bulk_update_test_results(
682+
test_suite_id: str,
683+
disposition: str,
684+
job_execution_id: str | None = None,
685+
table_name: str | None = None,
686+
test_type: str | None = None,
687+
status: str | None = None,
688+
test_definition_id: str | None = None,
689+
) -> str:
690+
"""Set the disposition on every matching test result in a suite (confirm, dismiss, mute, clear).
691+
692+
Args:
693+
test_suite_id: UUID of the test suite, e.g. from ``list_test_suites``.
694+
disposition: New disposition. One of 'Confirmed', 'Dismissed', 'Muted',
695+
'No Decision' (clears it). 'Muted' deactivates the parent tests and locks
696+
them against auto-regeneration; any other value reactivates and unlocks.
697+
job_execution_id: UUID of a test run within the suite. Defaults to the suite's
698+
latest completed run when omitted.
699+
table_name: Optional table-name filter. Case-sensitive.
700+
test_type: Optional test type name (e.g. 'Alpha Truncation').
701+
status: Optional result-status filter (Passed, Failed, Warning, Error, Log).
702+
test_definition_id: Optional single test-definition filter.
703+
"""
704+
suite = resolve_test_suite(test_suite_id)
705+
db_disposition = parse_test_result_disposition(disposition)
706+
707+
if job_execution_id:
708+
run = TestRun.get_by_id_or_job(parse_uuid(job_execution_id, "job_execution_id"))
709+
if run is None or run.test_suite_id != suite.id:
710+
raise MCPResourceNotAccessible("Test run", job_execution_id)
711+
else:
712+
run = (
713+
TestRun.get_by_id_or_job(suite.last_complete_test_run_id)
714+
if suite.last_complete_test_run_id
715+
else None
716+
)
717+
if run is None:
718+
raise MCPUserError(f"No completed test runs found for test suite `{test_suite_id}`.")
719+
720+
clauses = [TestResult.test_suite_id == suite.id, TestResult.test_run_id == run.id]
721+
if status:
722+
clauses.append(TestResult.status == parse_result_status(status))
723+
if table_name:
724+
clauses.append(TestResult.table_name == table_name)
725+
if test_type:
726+
clauses.append(TestResult.test_type == resolve_test_type(test_type))
727+
if test_definition_id:
728+
clauses.append(TestResult.test_definition_id == parse_uuid(test_definition_id, "test_definition_id"))
729+
730+
result_ids = list(get_current_session().scalars(select(TestResult.id).where(*clauses)).all())
731+
update = set_test_results_disposition(result_ids, db_disposition)
732+
733+
filters = []
734+
if table_name:
735+
filters.append(f"table_name=`{table_name}`")
736+
if test_type:
737+
filters.append(f"test_type=`{test_type}`")
738+
if status:
739+
filters.append(f"status=`{status}`")
740+
if test_definition_id:
741+
filters.append(f"test_definition_id=`{test_definition_id}`")
742+
filter_str = ", ".join(filters) if filters else "no filter"
743+
744+
doc = MdDoc()
745+
if update.matched == 0 and update.passed_skipped == 0:
746+
doc.heading(1, "No test results matched")
747+
doc.text(
748+
f"No test results in suite `{suite.test_suite}` matched the filter ({filter_str}). Nothing changed."
749+
)
750+
return doc.render()
751+
752+
doc.heading(1, f"Updated {update.matched} test results in suite `{suite.test_suite}`")
753+
doc.field("Disposition", disposition)
754+
doc.field("Run", run.job_execution_id, code=True)
755+
doc.field("Filter", filter_str)
756+
if update.passed_skipped:
757+
doc.text(
758+
f"Left {update.passed_skipped} passed results unchanged — disposition is not "
759+
f"applied to passed results."
760+
)
761+
return doc.render()

0 commit comments

Comments
 (0)