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
4 changes: 4 additions & 0 deletions backend/kernelCI_app/constants/localization.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ class DocStrings:
)

HARDWARE_LISTING_ORIGIN_DESCRIPTION = "Origin of the hardware"
HARDWARE_LISTING_COMMITS_LIST_DESCRIPTION = (
"Optional comma-separated git commit identifiers: full SHA(s) "
"and/or tag strings that appear in checkout.git_commit_tags."
)

ISSUE_DETAILS_VERSION_DESCRIPTION = "Issue version"

Expand Down
40 changes: 31 additions & 9 deletions backend/kernelCI_app/queries/hardware.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,24 +110,51 @@ def _get_hardware_listing_count_clauses() -> str:


def get_hardware_listing_data(
start_date: datetime, end_date: datetime, origin: str
*,
start_date: datetime,
end_date: datetime,
origin: str,
commits_list: Optional[list[str]] = None,
) -> list[dict]:
"""
Retrieves the listing of platform, compatibles, and
the status counts of builds, boots and tests
for the latest checkout of every tree.
The selected checkouts and tests are limited to the start_date/end_date interval.
When commits_list is set, tree heads are not used: checkouts qualify if
git_commit_hash is in the token list or git_commit_tags overlaps it (comma-
separated request values can be full SHAs or tag strings stored on checkouts).
Still scoped by the test start_time and origin filters below.
"""

count_clauses = _get_hardware_listing_count_clauses()
tree_head_clause = _get_hardware_tree_heads_clause(id_only=True)

params = {
"start_date": start_date,
"end_date": end_date,
"origin": origin,
}

if commits_list:
params["commits_list"] = commits_list
checkout_ids_select = """
SELECT C.id
FROM checkouts C
WHERE C.git_commit_hash = ANY(%(commits_list)s)
OR (
C.git_commit_tags IS NOT NULL
AND C.git_commit_tags && %(commits_list)s::text[]
)
"""
else:
checkout_ids_select = _get_hardware_tree_heads_clause(id_only=True)

selected_checkouts_cte = f"""
selected_checkouts AS (
{checkout_ids_select}
),
"""

# The grouping by platform and compatibles is possible because a platform
# can dictate the array of compatibles, meaning that if the array of compatibles
# is different, then the platform should/must be different as well.
Expand All @@ -137,12 +164,7 @@ def get_hardware_listing_data(
# to the tests, not checkouts. There are no platforms being tested by multiple origins yet.
query = f"""
WITH
-- Selects the id of the latest checkout of all trees in the given period.
-- No checkout data is returned in the end.
tree_heads AS (
{tree_head_clause}
),
-- Selects all tests/builds related to those checkouts.
{selected_checkouts_cte}
relevant_tests AS (
SELECT
"tests"."environment_compatible" AS hardware,
Expand All @@ -155,7 +177,7 @@ def get_hardware_listing_data(
FROM
tests
INNER JOIN builds b ON tests.build_id = b.id
JOIN tree_heads TH ON b.checkout_id = TH.id
JOIN selected_checkouts AC ON b.checkout_id = AC.id
WHERE
"tests"."environment_misc" ->> 'platform' IS NOT NULL
AND "tests"."origin" = %(origin)s
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,30 @@ def test_get_hardware_listing_data_success(self, mock_connection):

assert result == expected_result
mock_cursor.execute.assert_called_once()
sql, exec_params = mock_cursor.execute.call_args[0]
assert "selected_checkouts AS" in sql
assert "SELECT DISTINCT" in sql
assert "commits_list" not in exec_params

@patch("kernelCI_app.queries.hardware.connection")
def test_get_hardware_listing_data_commit_filter_tokens(self, mock_connection):
mock_cursor = setup_mock_cursor(mock_connection)
mock_cursor.fetchall.return_value = []

get_hardware_listing_data(
start_date=datetime(2025, 11, 10),
end_date=datetime(2025, 11, 12),
origin="maestro",
commits_list=["a" * 40],
)

sql, exec_params = mock_cursor.execute.call_args[0]
assert "selected_checkouts AS" in sql
assert "JOIN selected_checkouts AC ON b.checkout_id = AC.id" in sql
assert "ANY(%(commits_list)s)" in sql
assert "git_commit_tags" in sql and "&&" in sql
assert "SELECT DISTINCT" not in sql
assert exec_params["commits_list"] == ["a" * 40]


class TestGetHardwareDetailsData:
Expand Down
37 changes: 36 additions & 1 deletion backend/kernelCI_app/tests/unitTests/views/hardwareView_test.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from http import HTTPStatus
from unittest.mock import patch
from unittest.mock import ANY, patch

from django.test.testcases import SimpleTestCase
from rest_framework.test import APIRequestFactory
Expand Down Expand Up @@ -30,6 +30,41 @@ def test_get_hardware_listing_success(self, mock_get_hardware_listing_data):
response = self.view.get(request)

self.assertEqual(response.status_code, HTTPStatus.OK)
mock_get_hardware_listing_data.assert_called_once_with(
origin="origin1",
start_date=ANY,
end_date=ANY,
commits_list=None,
)

@patch("kernelCI_app.views.hardwareView.get_hardware_listing_data")
def test_get_hardware_listing_passes_commits_list(
self, mock_get_hardware_listing_data
):
mock_get_hardware_listing_data.return_value = [
("platform1", "hardware1", *range(22)),
]
h1 = "a" * 40
h2 = "b" * 40

request = self.factory.get(
self.url,
{
"startTimestampInSeconds": "1741192200",
"endTimestampInSeconds": "1741624200",
"origin": "origin1",
"commitsList": f"{h1},{h2}",
},
)
response = self.view.get(request)

self.assertEqual(response.status_code, HTTPStatus.OK)
mock_get_hardware_listing_data.assert_called_once_with(
origin="origin1",
start_date=ANY,
end_date=ANY,
commits_list=[h1, h2],
)

def test_get_hardware_listing_invalid_query_params_returns_bad_request(self):
query_params = {"origin": "origin1"}
Expand Down
17 changes: 17 additions & 0 deletions backend/kernelCI_app/typeModels/hardwareListing.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@
from kernelCI_app.typeModels.common import StatusCount


def _normalize_commits_list(value: object) -> Optional[list[str]]:
if value is None:
return None
if isinstance(value, str):
cleaned = [part.strip() for part in value.split(",") if part.strip()]
return cleaned if cleaned else None
return None


class HardwareItem(BaseModel):
hardware: Optional[Union[str, set[str]]]
platform: str
Expand Down Expand Up @@ -37,6 +46,10 @@ class HardwareQueryParamsDocumentationOnly(BaseModel):
endTimestampInSeconds: str = Field( # noqa: N815
description=DocStrings.DEFAULT_END_TS_DESCRIPTION
)
commitsList: Optional[str] = Field( # noqa: N815
default=None,
description=DocStrings.HARDWARE_LISTING_COMMITS_LIST_DESCRIPTION,
)


class HardwareQueryParams(BaseModel):
Expand All @@ -47,3 +60,7 @@ class HardwareQueryParams(BaseModel):
]
start_date: datetime
end_date: datetime
commits_list: Annotated[
Optional[list[str]],
BeforeValidator(_normalize_commits_list),
] = Field(default=None)
13 changes: 5 additions & 8 deletions backend/kernelCI_app/views/hardwareView.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from datetime import datetime
from http import HTTPStatus

from drf_spectacular.utils import extend_schema
Expand Down Expand Up @@ -70,18 +69,16 @@ def get(self, request: Request):
start_date=request.GET.get("startTimestampInSeconds"),
end_date=request.GET.get("endTimestampInSeconds"),
origin=request.GET.get("origin"),
commits_list=request.GET.get("commitsList"),
)

start_date: datetime = query_params.start_date
end_date: datetime = query_params.end_date
origin = query_params.origin
except ValidationError as e:
return Response(data=e.json(), status=HTTPStatus.BAD_REQUEST)

hardwares_raw = get_hardware_listing_data(
origin=origin,
start_date=start_date,
end_date=end_date,
origin=query_params.origin,
start_date=query_params.start_date,
end_date=query_params.end_date,
commits_list=query_params.commits_list,
)

try:
Expand Down
3 changes: 3 additions & 0 deletions backend/requests/hardware-listing.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
http 'http://localhost:8000/api/hardware/?startTimestampInSeconds=1736510400&endTimestampInSeconds=1736942400&origin=maestro'

# Optional: comma-separated commit identifiers — full SHA and/or git tag strings (matches hash or overlaps git_commit_tags)
# http 'http://localhost:8000/api/hardware/?startTimestampInSeconds=1736510400&endTimestampInSeconds=1736942400&origin=maestro&commitsList=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'

# HTTP/1.1 200 OK
# Allow: GET, HEAD, OPTIONS
# Cache-Control: max-age=0
Expand Down
5 changes: 5 additions & 0 deletions dashboard/src/api/hardware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const fetchHardwareListing = async (
origin: string,
startTimestampInSeconds: number,
endTimestampInSeconds: number,
commitsList?: string[],
): Promise<HardwareListingResponse> => {
const data = await RequestData.get<HardwareListingResponse>(
'/api/hardware/',
Expand All @@ -24,6 +25,7 @@ const fetchHardwareListing = async (
startTimestampInSeconds,
endTimestampInSeconds,
origin,
...(commitsList?.length ? { commitsList: commitsList.join(',') } : {}),
},
},
);
Expand All @@ -35,6 +37,7 @@ export const useHardwareListing = (
startTimestampInSeconds: number,
endTimestampInSeconds: number,
searchFrom: HardwareListingRoutesMap['v1']['search'],
commitsList?: string[],
): UseQueryResult<HardwareListingResponse> => {
const { origin } = useSearch({ from: searchFrom });

Expand All @@ -43,6 +46,7 @@ export const useHardwareListing = (
startTimestampInSeconds,
endTimestampInSeconds,
origin,
commitsList ?? null,
];

return useQuery({
Expand All @@ -52,6 +56,7 @@ export const useHardwareListing = (
origin,
startTimestampInSeconds,
endTimestampInSeconds,
commitsList,
),
refetchOnWindowFocus: false,
});
Expand Down
12 changes: 12 additions & 0 deletions dashboard/src/lib/commits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const COMMIT_REGEX = [
/\b[0-9a-f]{40}\b/g,
/\S*v?\d+\.\d+(?:\.\d+)?(?:-rc\d+)?\S*/gi,
/\b([a-z][a-z0-9]*(?:-[a-z0-9]+)*)-(\d{8})\S*/gi,
];

export const listCommits = (text: string): string[] => {
if (typeof text !== 'string') {
return [];
}
return COMMIT_REGEX.flatMap(r => text.match(r) || []);
};
22 changes: 22 additions & 0 deletions dashboard/src/lib/intent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { listCommits } from '@/lib/commits';

export type SearchIntent =
| { intent: 'commits'; commits: string[]; search: string }
| { intent: 'text'; search: string };

export const parseSearchIntent = (text: string): SearchIntent => {
const commitList = listCommits(text);
if (commitList.length > 0) {
return {
intent: 'commits',
commits: commitList,
search: commitList
.reduce((acc, word) => acc.replace(word, ''), text)
.trim(),
};
}
return {
intent: 'text',
search: text,
};
};
6 changes: 5 additions & 1 deletion dashboard/src/pages/Hardware/Hardware.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { MemoizedListingOGTags } from '@/components/OpenGraphTags/ListingOGTags'
import { OldPageBanner } from '@/components/Banner/PageBanner';
import { useFeatureFlag } from '@/hooks/useFeatureFlag';
import type { HardwareListingRoutesMap } from '@/utils/constants/hardwareListing';
import { parseSearchIntent } from '@/lib/intent';

const Hardware = ({
urlFromMap,
Expand All @@ -19,6 +20,8 @@ const Hardware = ({
from: urlFromMap.search,
});

const intent = parseSearchIntent(hardwareSearch ?? '');

return (
<>
<MemoizedListingOGTags monitor="/hardware" search={hardwareSearch} />
Expand All @@ -30,7 +33,8 @@ const Hardware = ({
)}
<div className="bg-light-gray w-full py-10">
<HardwareListingPage
inputFilter={hardwareSearch ?? ''}
inputFilter={intent.search}
commitsList={intent.intent === 'commits' ? intent.commits : undefined}
urlFromMap={urlFromMap}
/>
</div>
Expand Down
3 changes: 3 additions & 0 deletions dashboard/src/pages/Hardware/HardwareListingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { HardwareTable } from './HardwareTable';

interface HardwareListingPageProps {
inputFilter: string;
commitsList?: string[];
urlFromMap: HardwareListingRoutesMap['v1'];
}

Expand Down Expand Up @@ -68,6 +69,7 @@ const useHardwareListingTime = (

const HardwareListingPage = ({
inputFilter,
commitsList,
urlFromMap,
}: HardwareListingPageProps): JSX.Element => {
const { startTimestampInSeconds, endTimestampInSeconds } =
Expand All @@ -78,6 +80,7 @@ const HardwareListingPage = ({
startTimestampInSeconds,
endTimestampInSeconds,
urlFromMap.search,
commitsList,
);

const listItems: HardwareItem[] = useMemo(() => {
Expand Down
Loading
Loading