Skip to content

Commit 43ec6f0

Browse files
author
ci bot
committed
Merge branch 'feat/TG-1117-mcp-filter-runs-by-schedule' into 'enterprise'
feat(mcp): filter test-run and profiling-run lists by schedule (TG-1117) See merge request dkinternal/testgen/dataops-testgen!555
2 parents 625bc07 + 7621301 commit 43ec6f0

7 files changed

Lines changed: 148 additions & 2 deletions

File tree

testgen/common/models/job_execution.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from sqlalchemy import Column, String, Text, case, delete, func, select, text, update
77
from sqlalchemy.dialects import postgresql
8+
from sqlalchemy.sql.elements import ColumnElement
89

910
from testgen.common.enums import JobSource, JobStatus
1011
from testgen.common.models import Base, database_session, get_current_session
@@ -97,6 +98,7 @@ def claim_actionable(cls, limit: int = 5) -> list[Self]:
9798
@classmethod
9899
def select_active_by_kwargs(
99100
cls,
101+
*clauses: ColumnElement[bool],
100102
project_code: str,
101103
job_key: str,
102104
kwargs_match: dict[str, str | list[str]],
@@ -105,13 +107,15 @@ def select_active_by_kwargs(
105107
"""Find JE rows whose ``kwargs`` JSONB matches the given (key, value) pairs.
106108
107109
Values may be a single string or a list of strings (which becomes an ``IN`` filter).
110+
Additional caller-supplied WHERE expressions may be passed as ``*clauses``.
108111
Defaults to active (non-terminal) statuses.
109112
"""
110113
statuses = statuses or cls._ACTIVE_STATUSES
111114
query = select(cls).where(
112115
cls.project_code == project_code,
113116
cls.job_key == job_key,
114117
cls.status.in_(statuses),
118+
*clauses,
115119
)
116120
for k, v in kwargs_match.items():
117121
if isinstance(v, list):

testgen/common/models/profiling_run.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,13 +208,15 @@ def select_summary(
208208
project_code: str | None = None,
209209
table_group_id: str | UUID | None = None,
210210
job_execution_id: str | UUID | None = None,
211+
schedule_id: str | None = None,
211212
statuses: list[JobStatus] | None = None,
212213
page: int = 1,
213214
page_size: int = 20,
214215
) -> tuple[list[ProfilingRunSummary], int]:
215216
if (
216217
(table_group_id and not is_uuid4(table_group_id))
217218
or (job_execution_id and not is_uuid4(job_execution_id))
219+
or (schedule_id and not is_uuid4(schedule_id))
218220
):
219221
return [], 0
220222

@@ -271,6 +273,7 @@ def select_summary(
271273
{" AND je.project_code = :project_code" if project_code else ""}
272274
{" AND tg.id = :table_group_id" if table_group_id else ""}
273275
{" AND je.id = :job_execution_id" if job_execution_id else ""}
276+
{" AND je.job_schedule_id = :schedule_id" if schedule_id else ""}
274277
{" AND je.status IN :statuses" if statuses else ""}
275278
ORDER BY je.created_at DESC
276279
LIMIT :limit OFFSET :offset;
@@ -279,6 +282,7 @@ def select_summary(
279282
"project_code": project_code,
280283
"table_group_id": str(table_group_id) if table_group_id else None,
281284
"job_execution_id": str(job_execution_id) if job_execution_id else None,
285+
"schedule_id": schedule_id,
282286
"statuses": tuple(statuses) if statuses else (),
283287
"limit": page_size,
284288
"offset": (page - 1) * page_size,

testgen/common/models/test_run.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ def select_summary(
219219
test_suite_id: str | None = None,
220220
test_run_ids: list[str | UUID] | None = None,
221221
job_execution_id: str | UUID | None = None,
222+
schedule_id: str | None = None,
222223
statuses: list[JobStatus] | None = None,
223224
page: int = 1,
224225
page_size: int = 20,
@@ -228,6 +229,7 @@ def select_summary(
228229
or (test_suite_id and not is_uuid4(test_suite_id))
229230
or (test_run_ids and not all(is_uuid4(run_id) for run_id in test_run_ids))
230231
or (job_execution_id and not is_uuid4(job_execution_id))
232+
or (schedule_id and not is_uuid4(schedule_id))
231233
):
232234
return [], 0
233235

@@ -290,6 +292,7 @@ def select_summary(
290292
{" AND ts.id = :test_suite_id" if test_suite_id else ""}
291293
{" AND tr.id IN :test_run_ids" if test_run_ids else ""}
292294
{" AND je.id = :job_execution_id" if job_execution_id else ""}
295+
{" AND je.job_schedule_id = :schedule_id" if schedule_id else ""}
293296
{" AND je.status IN :statuses" if statuses else ""}
294297
ORDER BY je.created_at DESC
295298
LIMIT :limit OFFSET :offset;
@@ -300,6 +303,7 @@ def select_summary(
300303
"test_suite_id": test_suite_id,
301304
"test_run_ids": tuple(test_run_ids or []),
302305
"job_execution_id": str(job_execution_id) if job_execution_id else None,
306+
"schedule_id": schedule_id,
303307
"statuses": tuple(statuses) if statuses else (),
304308
"limit": page_size,
305309
"offset": (page - 1) * page_size,

testgen/mcp/tools/profiling.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,7 @@ def _render_column_profile_row(c: ColumnProfileSummary) -> list:
414414
@mcp_permission("catalog")
415415
def list_profiling_runs(
416416
table_group_id: str,
417+
schedule_id: str | None = None,
417418
status: str | None = None,
418419
limit: int = 10,
419420
page: int = 1,
@@ -423,6 +424,8 @@ def list_profiling_runs(
423424
424425
Args:
425426
table_group_id: UUID of the table group, e.g. from `get_data_inventory`.
427+
schedule_id: Optional UUID of a schedule, e.g. from `list_schedules`. Returns only runs
428+
triggered by that schedule.
426429
status: Optional run status filter. One of: Pending, Running, Completed, Canceled, Error.
427430
limit: Page size (default 10, max 100).
428431
page: Page number starting at 1 (default 1).
@@ -431,11 +434,14 @@ def list_profiling_runs(
431434
validate_page(page)
432435

433436
statuses = parse_run_status_filter(status) if status else None
437+
if schedule_id:
438+
parse_uuid(schedule_id, "schedule_id")
434439
tg = resolve_table_group(table_group_id)
435440

436441
summaries, total = ProfilingRun.select_summary(
437442
project_code=tg.project_code,
438443
table_group_id=tg.id,
444+
schedule_id=schedule_id,
439445
statuses=statuses,
440446
page=page,
441447
page_size=limit,
@@ -445,15 +451,22 @@ def list_profiling_runs(
445451
# joined-run queries. Surface them as a separate "Pending" section on page 1.
446452
pending_jes: list[JobExecution] = []
447453
if page == 1:
454+
clauses = [JobExecution.job_schedule_id == schedule_id] if schedule_id else []
448455
pending_jes = JobExecution.select_active_by_kwargs(
456+
*clauses,
449457
project_code=tg.project_code,
450458
job_key=RUN_PROFILE_JOB_KEY,
451459
kwargs_match={"table_group_id": str(tg.id)},
452460
statuses=statuses,
453461
)
454462

455463
doc = MdDoc()
456-
scope = f" — status `{status}`" if status else ""
464+
scope_parts = []
465+
if schedule_id:
466+
scope_parts.append(f"schedule `{schedule_id}`")
467+
if status:
468+
scope_parts.append(f"status `{status}`")
469+
scope = f" — {', '.join(scope_parts)}" if scope_parts else ""
457470
doc.heading(1, f"Profiling runs for `{tg.table_groups_name}`{scope}")
458471

459472
next_run = next_scheduled_run(

testgen/mcp/tools/test_runs.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def list_test_runs(
3030
project_code: str | None = None,
3131
test_suite: str | None = None,
3232
table_group_id: str | None = None,
33+
schedule_id: str | None = None,
3334
status: str | None = None,
3435
limit: int = 10,
3536
page: int = 1,
@@ -43,6 +44,8 @@ def list_test_runs(
4344
test_suite: Optional test suite name to filter by (case-sensitive).
4445
table_group_id: Optional UUID of a table group, e.g. from `get_data_inventory`. Returns
4546
runs for any suite in the group.
47+
schedule_id: Optional UUID of a schedule, e.g. from `list_schedules`. Returns only runs
48+
triggered by that schedule.
4649
status: Optional run status filter. One of: Pending, Running, Completed, Canceled, Error.
4750
limit: Page size (default 10, max 100).
4851
page: Page number starting at 1 (default 1).
@@ -51,6 +54,8 @@ def list_test_runs(
5154
validate_page(page)
5255

5356
statuses = parse_run_status_filter(status) if status else None
57+
if schedule_id:
58+
parse_uuid(schedule_id, "schedule_id")
5459

5560
if not project_code and not table_group_id:
5661
raise MCPUserError("Provide either `project_code` or `table_group_id`.")
@@ -86,6 +91,7 @@ def list_test_runs(
8691
project_code=project_code,
8792
table_group_id=str(table_group.id) if table_group else None,
8893
test_suite_id=test_suite_id,
94+
schedule_id=schedule_id,
8995
statuses=statuses,
9096
page=page,
9197
page_size=limit,
@@ -99,10 +105,11 @@ def list_test_runs(
99105
project_code=project_code,
100106
test_suite_id=test_suite_id,
101107
table_group_id=str(table_group.id) if table_group else None,
108+
schedule_id=schedule_id,
102109
statuses=statuses,
103110
)
104111

105-
scope_descriptor = _scope_descriptor(project_code, test_suite, table_group_id, status)
112+
scope_descriptor = _scope_descriptor(project_code, test_suite, table_group_id, schedule_id, status)
106113
doc = MdDoc()
107114
doc.heading(1, f"Test runs{scope_descriptor}")
108115

@@ -199,6 +206,7 @@ def _scope_descriptor(
199206
project_code: str | None,
200207
test_suite: str | None,
201208
table_group_id: str | None,
209+
schedule_id: str | None,
202210
status: str | None,
203211
) -> str:
204212
parts: list[str] = []
@@ -208,6 +216,8 @@ def _scope_descriptor(
208216
parts.append(f"suite `{test_suite}`")
209217
if table_group_id:
210218
parts.append(f"table group `{table_group_id}`")
219+
if schedule_id:
220+
parts.append(f"schedule `{schedule_id}`")
211221
if status:
212222
parts.append(f"status `{status}`")
213223
return f" — {', '.join(parts)}" if parts else ""
@@ -246,6 +256,7 @@ def _select_pending_test_jes(
246256
project_code: str,
247257
test_suite_id: str | None,
248258
table_group_id: str | None,
259+
schedule_id: str | None,
249260
statuses,
250261
) -> list[JobExecution]:
251262
"""Find queued/in-flight test-run JEs for a given suite or table group scope. For a
@@ -267,7 +278,9 @@ def _select_pending_test_jes(
267278
return []
268279
else:
269280
return []
281+
clauses = [JobExecution.job_schedule_id == schedule_id] if schedule_id else []
270282
return JobExecution.select_active_by_kwargs(
283+
*clauses,
271284
project_code=project_code,
272285
job_key=RUN_TESTS_JOB_KEY,
273286
kwargs_match={"test_suite_id": suite_ids},

tests/unit/mcp/test_tools_profiling.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,53 @@ def test_list_profiling_runs_invalid_status(mock_tg_cls, mock_run_cls, mock_next
583583
list_profiling_runs(table_group_id=str(uuid4()), status="Bogus")
584584

585585

586+
@patch("testgen.mcp.tools.profiling.JobExecution")
587+
@patch("testgen.mcp.tools.profiling.next_scheduled_run", return_value=None)
588+
@patch("testgen.mcp.tools.profiling.ProfilingRun")
589+
@patch("testgen.mcp.tools.common.TableGroup")
590+
def test_list_profiling_runs_schedule_filter(mock_tg_cls, mock_run_cls, mock_next, mock_je, db_session_mock):
591+
mock_je.select_active_by_kwargs.return_value = []
592+
mock_tg_cls.get.return_value = _mock_table_group()
593+
mock_run_cls.select_summary.return_value = ([], 0)
594+
schedule_id = str(uuid4())
595+
596+
from testgen.mcp.tools.profiling import list_profiling_runs
597+
list_profiling_runs(table_group_id=str(uuid4()), schedule_id=schedule_id, status="Completed")
598+
599+
call_kwargs = mock_run_cls.select_summary.call_args.kwargs
600+
assert call_kwargs["schedule_id"] == schedule_id
601+
assert call_kwargs["statuses"] == [JobStatus.COMPLETED]
602+
# The schedule clause is forwarded to the pending-JE query (positional *clauses arg).
603+
assert mock_je.select_active_by_kwargs.call_args.args
604+
605+
606+
@patch("testgen.mcp.tools.profiling.JobExecution")
607+
@patch("testgen.mcp.tools.profiling.next_scheduled_run", return_value=None)
608+
@patch("testgen.mcp.tools.profiling.ProfilingRun")
609+
@patch("testgen.mcp.tools.common.TableGroup")
610+
def test_list_profiling_runs_unknown_schedule_returns_empty_envelope(mock_tg_cls, mock_run_cls, mock_next, mock_je, db_session_mock):
611+
mock_je.select_active_by_kwargs.return_value = []
612+
mock_tg_cls.get.return_value = _mock_table_group()
613+
mock_run_cls.select_summary.return_value = ([], 0)
614+
615+
from testgen.mcp.tools.profiling import list_profiling_runs
616+
result = list_profiling_runs(table_group_id=str(uuid4()), schedule_id=str(uuid4()))
617+
618+
assert "No profiling runs" in result
619+
620+
621+
@patch("testgen.mcp.tools.profiling.next_scheduled_run", return_value=None)
622+
@patch("testgen.mcp.tools.profiling.ProfilingRun")
623+
@patch("testgen.mcp.tools.common.TableGroup")
624+
def test_list_profiling_runs_malformed_schedule_raises(mock_tg_cls, mock_run_cls, mock_next, db_session_mock):
625+
mock_tg_cls.get.return_value = _mock_table_group()
626+
627+
from testgen.mcp.tools.profiling import list_profiling_runs
628+
with pytest.raises(MCPUserError):
629+
list_profiling_runs(table_group_id=str(uuid4()), schedule_id="not-a-uuid")
630+
mock_run_cls.select_summary.assert_not_called()
631+
632+
586633
# ----------------------------------------------------------------------
587634
# get_profiling_run
588635
# ----------------------------------------------------------------------

tests/unit/mcp/test_tools_test_runs.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ def test_list_test_runs_default(mock_suite, mock_run, mock_next, db_session_mock
4343
project_code="demo",
4444
table_group_id=None,
4545
test_suite_id=None,
46+
schedule_id=None,
4647
statuses=None,
4748
page=1,
4849
page_size=10,
@@ -67,6 +68,66 @@ def test_list_test_runs_with_status_filter(mock_suite, mock_run, mock_next, db_s
6768
assert call_kwargs["statuses"] == [JobStatus.PENDING, JobStatus.CLAIMED]
6869

6970

71+
@patch("testgen.mcp.tools.test_runs.next_scheduled_run", return_value=None)
72+
@patch("testgen.mcp.tools.test_runs.TestRun")
73+
@patch("testgen.mcp.tools.test_runs.TestSuite")
74+
def test_list_test_runs_with_schedule_filter(mock_suite, mock_run, mock_next, db_session_mock):
75+
mock_run.select_summary.return_value = ([], 0)
76+
schedule_id = str(uuid4())
77+
78+
from testgen.mcp.tools.test_runs import list_test_runs
79+
80+
list_test_runs(project_code="demo", schedule_id=schedule_id, status="Completed")
81+
82+
call_kwargs = mock_run.select_summary.call_args.kwargs
83+
assert call_kwargs["schedule_id"] == schedule_id
84+
assert call_kwargs["statuses"] == [JobStatus.COMPLETED]
85+
86+
87+
@patch("testgen.mcp.tools.test_runs.next_scheduled_run", return_value=None)
88+
@patch("testgen.mcp.tools.test_runs.TestRun")
89+
@patch("testgen.mcp.tools.test_runs.TestSuite")
90+
def test_list_test_runs_unknown_schedule_returns_empty_envelope(mock_suite, mock_run, mock_next, db_session_mock):
91+
# Unknown/inaccessible/wrong-kind schedule yields no rows — standard empty envelope, not an error.
92+
mock_run.select_summary.return_value = ([], 0)
93+
94+
from testgen.mcp.tools.test_runs import list_test_runs
95+
96+
result = list_test_runs(project_code="demo", schedule_id=str(uuid4()))
97+
98+
assert "No test runs" in result
99+
100+
101+
@patch("testgen.mcp.tools.test_runs.next_scheduled_run", return_value=None)
102+
@patch("testgen.mcp.tools.test_runs.TestRun")
103+
@patch("testgen.mcp.tools.test_runs.TestSuite")
104+
def test_list_test_runs_malformed_schedule_raises(mock_suite, mock_run, mock_next, db_session_mock):
105+
from testgen.mcp.tools.test_runs import list_test_runs
106+
107+
with pytest.raises(MCPUserError):
108+
list_test_runs(project_code="demo", schedule_id="not-a-uuid")
109+
mock_run.select_summary.assert_not_called()
110+
111+
112+
@patch("testgen.mcp.tools.test_runs.JobExecution")
113+
@patch("testgen.mcp.tools.test_runs.next_scheduled_run", return_value=None)
114+
@patch("testgen.mcp.tools.test_runs.TestRun")
115+
@patch("testgen.mcp.tools.test_runs.TestSuite")
116+
def test_list_test_runs_schedule_filters_pending(mock_suite, mock_run, mock_next, mock_je, db_session_mock):
117+
mock_je.select_active_by_kwargs.return_value = []
118+
suite_id = uuid4()
119+
mock_suite.select_minimal_where.return_value = [MagicMock(id=suite_id)]
120+
mock_run.select_summary.return_value = ([], 0)
121+
schedule_id = str(uuid4())
122+
123+
from testgen.mcp.tools.test_runs import list_test_runs
124+
125+
list_test_runs(project_code="demo", test_suite="Quality", schedule_id=schedule_id)
126+
127+
# A schedule clause is forwarded to the pending-JE query (positional *clauses arg).
128+
assert mock_je.select_active_by_kwargs.call_args.args
129+
130+
70131
@patch("testgen.mcp.tools.test_runs.JobExecution")
71132
@patch("testgen.mcp.tools.test_runs.next_scheduled_run", return_value=None)
72133
@patch("testgen.mcp.tools.test_runs.TestRun")

0 commit comments

Comments
 (0)