diff --git a/pkg/api/componentreadiness/triage.go b/pkg/api/componentreadiness/triage.go index 26c5b875e7..c64d0013a3 100644 --- a/pkg/api/componentreadiness/triage.go +++ b/pkg/api/componentreadiness/triage.go @@ -323,6 +323,20 @@ func ListRegressions(dbc *db.DB, release string, views []crview.View, releases [ return regressions, err } +// GetRegressionsForTest returns regressions matching a specific test name, optionally filtered by release. +func GetRegressionsForTest(dbc *db.DB, release, testName string, views []crview.View, releases []v1.Release, crTimeRoundingFactor, crTimeRoundingOffset time.Duration, req *http.Request) ([]models.TestRegression, error) { + regressions, err := query.GetRegressionsForTest(dbc, release, testName) + if err != nil { + return nil, err + } + + for i := range regressions { + InjectRegressionHATEOASLinks(®ressions[i], views, releases, crTimeRoundingFactor, crTimeRoundingOffset, sippyapi.GetBaseURL(req), sippyapi.GetBaseFrontendURL(req)) + } + + return regressions, err +} + // GetRegression returns the regression with the matching ID func GetRegression(dbc *db.DB, id int, views []crview.View, releases []v1.Release, crTimeRoundingFactor, crTimeRoundingOffset time.Duration, req *http.Request) (*models.TestRegression, error) { regression := &models.TestRegression{} diff --git a/pkg/db/query/triage_queries.go b/pkg/db/query/triage_queries.go index c99c4f93b9..542deaf249 100644 --- a/pkg/db/query/triage_queries.go +++ b/pkg/db/query/triage_queries.go @@ -62,3 +62,20 @@ func ListRegressions(dbc *db.DB, release string) ([]models.TestRegression, error } return regressions, res.Error } + +func GetRegressionsForTest(dbc *db.DB, release, testName string) ([]models.TestRegression, error) { + var regressions []models.TestRegression + query := dbc.DB.Model(&models.TestRegression{}).Preload("Triages").Preload("JobRuns").Preload("Views") + + if release != "" { + query = query.Where("test_regressions.release = ?", release) + } + + query = query.Where("test_regressions.test_name = ?", testName) + + res := query.Find(®ressions) + if res.Error != nil { + log.WithError(res.Error).Error("error getting regressions for test") + } + return regressions, res.Error +} diff --git a/pkg/sippyserver/server.go b/pkg/sippyserver/server.go index 31a659383f..82489b6e32 100644 --- a/pkg/sippyserver/server.go +++ b/pkg/sippyserver/server.go @@ -1980,6 +1980,7 @@ func (s *Server) jsonGetRegressions(w http.ResponseWriter, req *http.Request) { // Read query parameters for listing view := param.SafeRead(req, "view") release := param.SafeRead(req, "release") + testName := param.SafeRead(req, "test") // Error if both view and release are specified if view != "" && release != "" { @@ -2003,7 +2004,12 @@ func (s *Server) jsonGetRegressions(w http.ResponseWriter, req *http.Request) { } } - regressions, err := componentreadiness.ListRegressions(s.db, release, views, allReleases, s.crTimeRoundingFactor, s.crTimeRoundingOffset, req) + var regressions []models.TestRegression + if testName != "" { + regressions, err = componentreadiness.GetRegressionsForTest(s.db, release, testName, views, allReleases, s.crTimeRoundingFactor, s.crTimeRoundingOffset, req) + } else { + regressions, err = componentreadiness.ListRegressions(s.db, release, views, allReleases, s.crTimeRoundingFactor, s.crTimeRoundingOffset, req) + } if err != nil { failureResponse(w, http.StatusInternalServerError, err.Error()) return @@ -2788,7 +2794,7 @@ func (s *Server) Serve() { }, { EndpointPath: "/api/component_readiness/regressions", - Description: "List component readiness test regressions. Supports view OR release query parameters (not both).", + Description: "List component readiness test regressions. Supports view OR release query parameters (not both). Optional test parameter filters by exact test name.", Capabilities: []string{LocalDBCapability, ComponentReadinessCapability}, HandlerFunc: s.jsonGetRegressions, }, diff --git a/sippy-ng/src/tests/TestAnalysis.js b/sippy-ng/src/tests/TestAnalysis.js index fd0fc6e070..f8e207843b 100644 --- a/sippy-ng/src/tests/TestAnalysis.js +++ b/sippy-ng/src/tests/TestAnalysis.js @@ -50,6 +50,7 @@ import React, { Fragment, useEffect } from 'react' import SimpleBreadcrumbs from '../components/SimpleBreadcrumbs' import SummaryCard from '../components/SummaryCard' import TestPassRateCharts from './TestPassRateCharts' +import TestRegressionsTable from './TestRegressionsTable' import TestTable from './TestTable' export function TestAnalysis(props) { @@ -406,6 +407,17 @@ export function TestAnalysis(props) { testName={testName} /> + + Active Regressions + + + + + diff --git a/sippy-ng/src/tests/TestRegressionsTable.js b/sippy-ng/src/tests/TestRegressionsTable.js new file mode 100644 index 0000000000..a91bfd15d1 --- /dev/null +++ b/sippy-ng/src/tests/TestRegressionsTable.js @@ -0,0 +1,162 @@ +import { Link } from 'react-router-dom' +import { + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + Typography, +} from '@mui/material' +import { + parseVariantName, + relativeTime, + safeEncodeURIComponent, +} from '../helpers' +import Alert from '@mui/material/Alert' +import PropTypes from 'prop-types' +import React, { useEffect, useMemo } from 'react' + +export default function TestRegressionsTable({ + release, + testName, + filterModel, +}) { + const [isLoaded, setLoaded] = React.useState(false) + const [regressions, setRegressions] = React.useState([]) + const [fetchError, setFetchError] = React.useState('') + + useEffect(() => { + if (!testName || !release) return + + setLoaded(false) + setFetchError('') + + const url = `${ + process.env.REACT_APP_API_URL + }/api/component_readiness/regressions?release=${safeEncodeURIComponent( + release + )}&test=${safeEncodeURIComponent(testName)}` + + fetch(url) + .then((res) => { + if (res.status !== 200) { + throw new Error('server returned ' + res.status) + } + return res.json() + }) + .then((data) => { + setRegressions(data || []) + setLoaded(true) + }) + .catch((error) => { + setFetchError('Could not retrieve regression data: ' + error) + setLoaded(true) + }) + }, [release, testName]) + + const variantFilters = useMemo(() => { + if (!filterModel || !filterModel.items) return [] + return filterModel.items.filter((f) => f.columnField === 'variants') + }, [filterModel]) + + const filteredRegressions = useMemo(() => { + let filtered = regressions.filter((r) => !r.closed || !r.closed.Valid) + + if (variantFilters.length === 0) return filtered + + return filtered.filter((regression) => { + const variantValues = (regression.variants || []).map( + (v) => parseVariantName(v).name + ) + + return variantFilters.every((filter) => { + const hasMatch = variantValues.some( + (v) => v.toLowerCase() === filter.value.toLowerCase() + ) + if (filter.not) { + return !hasMatch + } + return hasMatch + }) + }) + }, [regressions, variantFilters]) + + if (!isLoaded) { + return

Loading...

+ } + if (fetchError !== '') { + return {fetchError} + } + if (filteredRegressions.length === 0) { + return No active regressions found + } + + return ( + + + + + ID + Variants + Regressed Since + Last Failure + + + + {filteredRegressions.map((regression) => { + const variantValues = (regression.variants || []) + .map((v) => parseVariantName(v).name) + .filter((val) => !['default', 'none', 'unknown'].includes(val)) + + const tooltipLines = (regression.variants || []).sort().join('\n') + + return ( + + + + {regression.id} + + + + + {tooltipLines} + + } + > + {variantValues.join(', ')} + + + + {regression.opened + ? relativeTime(new Date(regression.opened), new Date()) + : ''} + + + {regression.last_failure && regression.last_failure.Valid + ? relativeTime( + new Date(regression.last_failure.Time), + new Date() + ) + : ''} + + + ) + })} + +
+
+ ) +} + +TestRegressionsTable.propTypes = { + release: PropTypes.string.isRequired, + testName: PropTypes.string.isRequired, + filterModel: PropTypes.object, +} diff --git a/test/e2e/componentreadiness/triage/triageapi_test.go b/test/e2e/componentreadiness/triage/triageapi_test.go index 0e7d28897b..fe7bf95b69 100644 --- a/test/e2e/componentreadiness/triage/triageapi_test.go +++ b/test/e2e/componentreadiness/triage/triageapi_test.go @@ -760,6 +760,63 @@ func Test_RegressionAPI(t *testing.T) { err := util.SippyGet(fmt.Sprintf("/api/component_readiness/regressions?view=%s-main&release=%s", util.Release, util.Release), ®ressions) require.Error(t, err, "Expected error when both view and release are provided") }) + t.Run("filter regressions by test name", func(t *testing.T) { + defer cleanupAllTriages(dbc) + + // Create a regression with a unique test name + uniqueTestName := "TestUniqueFilterByName" + uniqueRegression := createTestRegressionWithDetails(t, tracker, view, "filter-test-id", "comp-filter", "cap-filter", uniqueTestName, crtest.ExtremeRegression) + defer dbc.DB.Delete(uniqueRegression.Regression) + + // Create additional regressions with different test names to verify they are excluded + otherRegression1 := createTestRegressionWithDetails(t, tracker, view, "filter-other-id-1", "comp-other-1", "cap-other-1", "TestOtherName1", crtest.SignificantRegression) + defer dbc.DB.Delete(otherRegression1.Regression) + otherRegression2 := createTestRegressionWithDetails(t, tracker, view, "filter-other-id-2", "comp-other-2", "cap-other-2", "TestOtherName2", crtest.ExtremeRegression) + defer dbc.DB.Delete(otherRegression2.Regression) + + // Verify filtering by the unique test name returns only that regression + var filtered []models.TestRegression + err := util.SippyGet(fmt.Sprintf("/api/component_readiness/regressions?release=%s&test=%s", release, uniqueTestName), &filtered) + require.NoError(t, err) + require.Len(t, filtered, 1, "expected exactly one regression matching the test name") + assert.Equal(t, uniqueRegression.Regression.ID, filtered[0].ID) + assert.Equal(t, uniqueTestName, filtered[0].TestName) + + // Verify non-matching regressions are not included + for _, r := range filtered { + assert.NotEqual(t, otherRegression1.Regression.ID, r.ID, "otherRegression1 should not be in filtered results") + assert.NotEqual(t, otherRegression2.Regression.ID, r.ID, "otherRegression2 should not be in filtered results") + } + + // Verify filtering by a non-existent test name returns empty + var empty []models.TestRegression + err = util.SippyGet(fmt.Sprintf("/api/component_readiness/regressions?release=%s&test=%s", release, "NonExistentTestName"), &empty) + require.NoError(t, err) + assert.Empty(t, empty, "expected no regressions for a non-existent test name") + }) + t.Run("filter regressions by test name without release", func(t *testing.T) { + defer cleanupAllTriages(dbc) + + uniqueTestName := "TestFilterNoRelease" + uniqueRegression := createTestRegressionWithDetails(t, tracker, view, "filter-norel-id", "comp-norel", "cap-norel", uniqueTestName, crtest.SignificantRegression) + defer dbc.DB.Delete(uniqueRegression.Regression) + + // Create another regression with a different test name to verify it is excluded + otherRegression := createTestRegressionWithDetails(t, tracker, view, "filter-norel-other-id", "comp-norel-other", "cap-norel-other", "TestDifferentNoRelease", crtest.ExtremeRegression) + defer dbc.DB.Delete(otherRegression.Regression) + + // Filtering by test name alone (no release) should also work + var filtered []models.TestRegression + err := util.SippyGet(fmt.Sprintf("/api/component_readiness/regressions?test=%s", uniqueTestName), &filtered) + require.NoError(t, err) + require.Len(t, filtered, 1, "expected exactly one regression matching the test name") + assert.Equal(t, uniqueRegression.Regression.ID, filtered[0].ID) + + // Verify the other regression is not included + for _, r := range filtered { + assert.NotEqual(t, otherRegression.Regression.ID, r.ID, "otherRegression should not be in filtered results") + } + }) } // Test_RegressionPotentialMatchingTriages tests the /api/component_readiness/regressions/{id}/matches endpoint