Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,28 @@ compact aggregates unless `detail=true` is passed, and the list tools
paginate (default `limit` of 20) and accept a `fields` list to return
only the named keys per entry. Prefer `status`/`arch` filters, small
limits and field projection when exploring large trees.

## Querying a single lab

To look at one lab (test runtime) rather than a whole tree, start from
`list_labs`, which returns the labs reporting to KernelCI with their
build, boot and test counts for the last N days. Those names are then
usable as:

- the `lab` filter of `list_builds`, `list_boots` and `list_tests`,
which narrows a commit's results to that lab;
- the `data.runtime` filter of `list_nodes`, for example
`list_nodes(filters=["kind=job", "data.runtime=lava-collabora",
"data.platform__re=^qcom"])`. Maestro applies this filter server-side,
so it is the cheapest way to ask what one lab is doing with a family
of boards.

For the status of a lab rather than its individual results, `get_summary`
and `get_hardware_summary` both carry a per-lab breakdown of pass/fail
counts under `summary.<section>.labs`, which answers "how is this tree or
platform doing in lab X" in a single call.

The dashboard has no server-side lab filter, so the `lab` option of the
list tools is applied to the fetched page after the request. It shrinks
the response, not the query: `total` counts entries before filtering and
`matched` after.
10 changes: 10 additions & 0 deletions kcidev/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
dashboard_fetch_issue_list,
dashboard_fetch_issue_tests,
dashboard_fetch_issues_extra,
dashboard_fetch_metrics,
dashboard_fetch_summary,
dashboard_fetch_test,
dashboard_fetch_tests,
Expand Down Expand Up @@ -595,6 +596,15 @@ def get_tree_list(self, origin, days=7):
days,
)

def get_metrics(self, start_days_ago=7, end_days_ago=0):
return self._dashboard_request(
"Dashboard metrics request failed",
dashboard_fetch_metrics,
True,
start_days_ago,
end_days_ago,
)

def get_hardware_list(self, origin):
return self._dashboard_request(
"Dashboard hardware list request failed",
Expand Down
20 changes: 20 additions & 0 deletions kcidev/libs/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,18 @@ def dashboard_fetch_tree_list(origin, use_json, days=7):
return dashboard_api_fetch("tree", params, use_json)


def dashboard_fetch_metrics(use_json, start_days_ago=7, end_days_ago=0):
"""Fetch global KernelCI metrics, including per-lab result counts."""
params = {
"start_days_ago": start_days_ago,
"end_days_ago": end_days_ago,
}
logging.info(
f"Fetching metrics for days {start_days_ago} to {end_days_ago} days ago"
)
return dashboard_api_fetch("metrics/", params, use_json)


def dashboard_fetch_hardware_list(origin, use_json):
# TODO: add date filter
now = datetime.today()
Expand Down Expand Up @@ -400,12 +412,19 @@ def dashboard_fetch_issue_list(origin, days, use_json):
return dashboard_api_fetch("issue/", params, use_json)


def _require_issue_id(issue_id):
if not issue_id or not issue_id.strip():
raise click.ClickException("Issue id is required")


def dashboard_fetch_issue(issue_id, use_json):
_require_issue_id(issue_id)
logging.info(f"Fetching issue details for issue ID: {issue_id}")
return dashboard_api_fetch(f"issue/{issue_id}", {}, use_json)


def dashboard_fetch_issue_builds(origin, issue_id, use_json, error_verbose=True):
_require_issue_id(issue_id)
logging.info(f"Fetching builds for issue ID: {issue_id}")
params = {"filter_origin": origin} if origin else {}
return dashboard_api_fetch(
Expand All @@ -414,6 +433,7 @@ def dashboard_fetch_issue_builds(origin, issue_id, use_json, error_verbose=True)


def dashboard_fetch_issue_tests(origin, issue_id, use_json, error_verbose=True):
_require_issue_id(issue_id)
logging.info(f"Fetching tests for issue ID: {issue_id}")
params = {"filter_origin": origin} if origin else {}
return dashboard_api_fetch(
Expand Down
127 changes: 102 additions & 25 deletions kcidev/mcp/tools_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from kcidev.api import KciDevError, KernelCIClient
from kcidev.libs.filters import StatusFilter
from kcidev.mcp.errors import tool_errors
from kcidev.mcp.validation import check_page_bounds, checked_days, checked_status

_active_client = ContextVar("dashboard_tool_client", default=None)

Expand All @@ -15,22 +16,43 @@ def _current_client():
return _active_client.get() or KernelCIClient()


def _page(data, key, status, limit, offset, fields=None):
def _entry_labs(item):
"""Return the lab/runtime names an entry reports, lowercased.

Boots and tests carry a top-level 'lab'; builds report the same
information as 'misc.lab' and 'misc.runtime'.
"""
misc = item.get("misc") or {}
names = (item.get("lab"), misc.get("lab"), misc.get("runtime"))
return {name.lower() for name in names if isinstance(name, str)}


def _page(data, key, status, limit, offset, fields=None, lab=None):
check_page_bounds(limit, offset)
items = data[key] if isinstance(data, dict) else data
total = len(items)
candidates = items
if status:
status_filter = StatusFilter(status)
status_filter = StatusFilter(checked_status(status))
items = [item for item in items if status_filter.matches(item)]
if lab:
wanted = lab.lower()
items = [item for item in items if wanted in _entry_labs(item)]
page = items[offset : offset + limit]
if fields:
page = [{k: item[k] for k in fields if k in item} for item in page]
return {
result = {
key: page,
"total": total,
"matched": len(items),
"limit": limit,
"offset": offset,
}
if lab and not items:
result["labs_present"] = sorted(
{name for item in candidates for name in _entry_labs(item)}
)
return result


@tool_errors
Expand All @@ -44,7 +66,13 @@ def list_trees(origin: str = "maestro", days: int = 7):
return _current_client().get_tree_list(origin, days)


_COMPACT_SUMMARY_KEYS = ("status", "architectures", "issues", "failed_platforms")
_COMPACT_SUMMARY_KEYS = (
"status",
"architectures",
"labs",
"issues",
"failed_platforms",
)


@tool_errors
Expand Down Expand Up @@ -135,23 +163,32 @@ def list_builds(
start_date: str | None = None,
end_date: str | None = None,
status: str | None = None,
lab: str | None = None,
limit: int = 20,
offset: int = 0,
fields: list[str] | None = None,
):
"""List kernel builds for one commit of a tree.

Optional filters: arch (e.g. 'arm64'), tree name, ISO date range, and
status ('pass', 'fail' or 'inconclusive'). Results are paginated with
limit/offset; the response carries 'total' (before status filtering)
and 'matched' counts so you know whether to fetch further pages;
fields projects each entry to only those keys.
Optional filters: arch (e.g. 'arm64'), tree name, ISO date range,
status ('pass', 'fail', 'inconclusive' or 'all'), and lab, the lab
or runtime that produced the build (builds report this as
'misc.lab' and 'misc.runtime'; 'misc.lab' is effectively the
origin, so the runtime cluster such as 'k8s-all' is the value that
discriminates); use list_labs to find valid names. Results are
paginated with limit/offset; the response carries 'total' (before
filtering) and 'matched' counts so you know whether to fetch
further pages, and a lab matching nothing returns 'labs_present',
every lab the entries report before any status filter, so a mistyped
name shows up without a second call and a real lab with no matching
status is still listed; fields projects each entry to only those
keys.
Returns build entries with ids usable with get_build.
"""
data = _current_client().get_builds(
origin, giturl, branch, commit, arch, tree, start_date, end_date
)
return _page(data, "builds", status, limit, offset, fields)
return _page(data, "builds", status, limit, offset, fields, lab)


@tool_errors
Expand All @@ -166,23 +203,31 @@ def list_boots(
end_date: str | None = None,
boot_origin: str | None = None,
status: str | None = None,
lab: str | None = None,
limit: int = 20,
offset: int = 0,
fields: list[str] | None = None,
):
"""List boot test results for one commit of a tree.

Optional filters: arch, tree name, ISO date range, boot origin, and
status ('pass', 'fail' or 'inconclusive'). Results are paginated with
limit/offset; the response carries 'total' (before status filtering)
and 'matched' counts so you know whether to fetch further pages;
fields projects each entry to only those keys.
Optional filters: arch, tree name, ISO date range, boot origin,
status ('pass', 'fail', 'inconclusive' or 'all'), and lab, the lab
or runtime that ran the boot (for example 'lava-collabora'); use
list_labs to find valid names, or get_summary, whose per-section
'labs' counts show which labs ran this commit at all. Results are
paginated with limit/offset; the response carries 'total' (before
filtering) and 'matched' counts so you know whether to fetch
further pages, and a lab matching nothing returns 'labs_present',
every lab the entries report before any status filter, so a mistyped
name shows up without a second call and a real lab with no matching
status is still listed; fields projects each entry to only those
keys.
Returns boot entries with ids usable with get_test.
"""
data = _current_client().get_boots(
origin, giturl, branch, commit, arch, tree, start_date, end_date, boot_origin
)
return _page(data, "boots", status, limit, offset, fields)
return _page(data, "boots", status, limit, offset, fields, lab)


@tool_errors
Expand All @@ -196,24 +241,31 @@ def list_tests(
start_date: str | None = None,
end_date: str | None = None,
status: str | None = None,
lab: str | None = None,
limit: int = 20,
offset: int = 0,
fields: list[str] | None = None,
):
"""List test results for one commit of a tree.

Optional filters: arch, tree name, ISO date range, and status ('pass',
'fail' or 'inconclusive'). A full commit can carry tens of thousands
of tests, so filter by status and paginate with limit/offset; the
response carries 'total' (before status filtering) and 'matched'
counts so you know whether to fetch further pages; fields projects
each entry to only those keys.
Optional filters: arch, tree name, ISO date range, status ('pass',
'fail', 'inconclusive' or 'all'), and lab, the lab or runtime that
ran the test (for example 'lava-collabora'); use list_labs to find
valid names, or get_summary, whose per-section 'labs' counts show
which labs ran this commit at all. A full commit can carry tens of
thousands of tests, so filter by lab and status and paginate with
limit/offset; the response carries 'total' (before filtering) and
'matched' counts so you know whether to fetch further pages, and a
lab matching nothing returns 'labs_present', every lab the entries
report before any status filter, so a mistyped name shows up without
a second call and a real lab with no matching status is still listed;
fields projects each entry to only those keys.
Returns test entries with ids usable with get_test.
"""
data = _current_client().get_tests(
origin, giturl, branch, commit, arch, tree, start_date, end_date
)
return _page(data, "tests", status, limit, offset, fields)
return _page(data, "tests", status, limit, offset, fields, lab)


@tool_errors
Expand Down Expand Up @@ -276,6 +328,27 @@ def get_build_issues(build_id: str):
return _current_client().get_build_issues(build_id)


@tool_errors
def list_labs(days: int = 7):
"""List the labs (test runtimes) reporting to KernelCI.

Returns each lab name with how many builds, boots and tests it
reported over the last N days, so you can pick a valid lab name
without scanning result listings. The names are usable as the 'lab'
filter of list_builds, list_boots and list_tests, and as the
'data.runtime' filter of list_nodes. Counts cover all origins and
trees; for the labs that ran one specific tree or platform, use the
per-section 'labs' counts of get_summary or get_hardware_summary.
The window is capped at 7 days; wider windows time out in the
dashboard's metrics aggregation.
"""
data = _current_client().get_metrics(start_days_ago=checked_days(days))
labs = data.get("lab_maps") if isinstance(data, dict) else None
if not isinstance(labs, dict):
raise KciDevError("dashboard metrics response carried no lab data")
return {"labs": labs, "days": days}


@tool_errors
def list_hardware(origin: str = "maestro"):
"""List hardware platforms with results over the last 7 days.
Expand All @@ -290,6 +363,9 @@ def get_hardware_summary(name: str, origin: str = "maestro"):
"""Get the build/boot/test summary for one hardware platform.

Covers the last 7 days. Use list_hardware to find platform names.
Each build/boot/test section carries a 'labs' breakdown of status
counts per lab, so this answers "how is this platform doing in lab
X" in one call, without listing and filtering individual results.
"""
return _current_client().get_hardware_summary(name, origin)

Expand Down Expand Up @@ -327,7 +403,7 @@ def get_issue_builds(
An empty list means the issue has no builds recorded against it, and
also what an unknown issue id returns, since the dashboard reports
both the same way; confirm the id with get_issue if it matters.
Optional status filter ('pass', 'fail' or 'inconclusive') and
Optional status filter ('pass', 'fail', 'inconclusive' or 'all') and
limit/offset pagination; the response carries 'total' and 'matched'
counts; fields projects each entry to only those keys.
"""
Expand All @@ -349,7 +425,7 @@ def get_issue_tests(
An empty list means the issue has no tests recorded against it, and
also what an unknown issue id returns, since the dashboard reports
both the same way; confirm the id with get_issue if it matters.
Optional status filter ('pass', 'fail' or 'inconclusive') and
Optional status filter ('pass', 'fail', 'inconclusive' or 'all') and
limit/offset pagination; the response carries 'total' and 'matched'
counts; fields projects each entry to only those keys.
"""
Expand All @@ -370,6 +446,7 @@ def get_issue_tests(
get_log,
get_test_issues,
get_build_issues,
list_labs,
list_hardware,
get_hardware_summary,
list_issues,
Expand Down
32 changes: 23 additions & 9 deletions kcidev/mcp/tools_maestro.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# -*- coding: utf-8 -*-

from kcidev.mcp.errors import tool_errors
from kcidev.mcp.validation import check_page_bounds, checked_filters


def register_tools(server, client, api_url, pipeline_url, token):
Expand Down Expand Up @@ -35,16 +36,29 @@ def list_nodes(
"""List Maestro nodes, oldest first, optionally filtered.

Filters are 'field=value' strings, for example 'name=checkout',
'state=done', 'result=fail' or 'treeid=<tree id>'. Matching is
exact; append '__re' to a field for a regex match, for example
'name__re=baseline' matches all baseline job variants. Results
are returned oldest first, so to reach recent nodes window the
query with a filter such as 'created__gt=2026-07-01' rather
than paginating from the start. Use limit and offset to
paginate within the window; full nodes are large, so use
fields to project each node to only those keys.
'state=done', 'result=fail' or 'treeid=<tree id>'. Nested node
fields are addressed with a dot, most usefully
'data.runtime=<lab>' to restrict results to one lab or runtime
(for example 'data.runtime=lava-collabora') and
'data.platform=<platform>' for one board; both are applied by
the server, so prefer them over listing everything and
filtering afterwards. Matching is exact; append '__re' to a
field for a regex match, for example 'name__re=baseline'
matches all baseline job variants and
'data.platform__re=sc7180' all sc7180 boards. Filters combine,
so 'data.runtime=lava-collabora' with
'data.platform__re=^qcom' answers "what is this lab doing with
qcom boards" in one query. Results are returned oldest first,
so to reach recent nodes window the query with a filter such as
'created__gt=2026-07-01' rather than paginating from the start.
Use limit and offset to paginate within the window; full nodes
are large, so use fields to project each node to only those
keys.
"""
nodes = client.get_nodes(limit=limit, offset=offset, filters=filters or [])
check_page_bounds(limit, offset)
nodes = client.get_nodes(
limit=limit, offset=offset, filters=checked_filters(filters)
)
if fields:
return [{k: n[k] for k in fields if k in n} for n in nodes]
return nodes
Expand Down
Loading
Loading