|
1 | 1 | from datetime import UTC, datetime, timedelta |
2 | 2 | from uuid import UUID |
3 | 3 |
|
| 4 | +from sqlalchemy import select |
| 5 | + |
4 | 6 | from testgen.common.enums import JobStatus |
5 | 7 | from testgen.common.models import get_current_session, with_database_session |
6 | 8 | from testgen.common.models.job_execution import JobExecution |
7 | 9 | from testgen.common.models.test_definition import TestType |
8 | 10 | from testgen.common.models.test_result import BucketInterval, TestResult, TestResultStatus |
9 | 11 | from testgen.common.models.test_run import TestRun, TestRunSummary |
10 | 12 | 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 | +) |
11 | 17 | from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError |
12 | 18 | from testgen.mcp.permissions import get_project_permissions, mcp_permission |
13 | 19 | from testgen.mcp.tools.common import ( |
|
18 | 24 | parse_failure_group_by, |
19 | 25 | parse_result_status, |
20 | 26 | parse_since_arg, |
| 27 | + parse_test_result_disposition, |
21 | 28 | parse_uuid, |
22 | 29 | resolve_aggregate_scope, |
| 30 | + resolve_test_result, |
| 31 | + resolve_test_suite, |
23 | 32 | resolve_test_type, |
24 | 33 | validate_limit, |
25 | 34 | validate_page, |
@@ -134,6 +143,7 @@ def list_test_results( |
134 | 143 | doc.heading(2, f"[{status_str}] {test_name} on `{r.column_names}` in `{r.table_name}`") |
135 | 144 | else: |
136 | 145 | doc.heading(2, f"[{status_str}] {test_name} on `{r.table_name}`") |
| 146 | + doc.field("Test result", r.id, code=True) |
137 | 147 | doc.field("Test definition", r.test_definition_id, code=True) |
138 | 148 | if r.column_names: |
139 | 149 | doc.field("Column", r.column_names, code=True) |
@@ -636,3 +646,116 @@ def _section(title: str, rows: list) -> None: |
636 | 646 | _section("Removed Tests", diff.removed_tests) |
637 | 647 |
|
638 | 648 | 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