Skip to content
Merged
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
14 changes: 14 additions & 0 deletions pkg/api/componentreadiness/triage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(&regressions[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{}
Expand Down
17 changes: 17 additions & 0 deletions pkg/db/query/triage_queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(&regressions)
if res.Error != nil {
log.WithError(res.Error).Error("error getting regressions for test")
}
return regressions, res.Error
}
10 changes: 8 additions & 2 deletions pkg/sippyserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand All @@ -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
Expand Down Expand Up @@ -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,
},
Expand Down
12 changes: 12 additions & 0 deletions sippy-ng/src/tests/TestAnalysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -406,6 +407,17 @@ export function TestAnalysis(props) {
testName={testName}
/>
</Box>
<Typography variant="h6" sx={{ marginTop: 3 }}>
Active Regressions
<Tooltip title="Active regressions detected by Component Readiness for this test. Honors variant filters applied above.">
<InfoIcon />
</Tooltip>
</Typography>
<TestRegressionsTable
release={props.release}
testName={testName}
filterModel={filterModel}
/>
</Card>
</Grid>

Expand Down
162 changes: 162 additions & 0 deletions sippy-ng/src/tests/TestRegressionsTable.js
Original file line number Diff line number Diff line change
@@ -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

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 <p>Loading...</p>
}
if (fetchError !== '') {
return <Alert severity="error">{fetchError}</Alert>
}
if (filteredRegressions.length === 0) {
return <Typography>No active regressions found</Typography>
}

return (
<TableContainer component={Paper} style={{ marginTop: 20 }}>
<Table size="small" aria-label="regressions-table">
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Variants</TableCell>
<TableCell>Regressed Since</TableCell>
<TableCell>Last Failure</TableCell>
</TableRow>
</TableHead>
<TableBody>
{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 (
<TableRow key={regression.id}>
<TableCell>
<Link
to={`/sippy-ng/component_readiness/regressions/${regression.id}`}
>
{regression.id}
</Link>
</TableCell>
<TableCell>
<Tooltip
title={
<span style={{ whiteSpace: 'pre-line' }}>
{tooltipLines}
</span>
}
>
<span>{variantValues.join(', ')}</span>
</Tooltip>
</TableCell>
<TableCell>
{regression.opened
? relativeTime(new Date(regression.opened), new Date())
: ''}
</TableCell>
<TableCell>
{regression.last_failure && regression.last_failure.Valid
? relativeTime(
new Date(regression.last_failure.Time),
new Date()
)
: ''}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</TableContainer>
)
}

TestRegressionsTable.propTypes = {
release: PropTypes.string.isRequired,
testName: PropTypes.string.isRequired,
filterModel: PropTypes.object,
}
57 changes: 57 additions & 0 deletions test/e2e/componentreadiness/triage/triageapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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), &regressions)
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)
Comment on lines +767 to +769

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other regressions weren't created though...we better make sure to have some more that we verify are not included in the response.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added decoy regressions with different test names to both test cases ("filter regressions by test name" and "filter regressions by test name without release"). The tests now create additional regressions and explicitly verify they are NOT included in the filtered response.


// 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
Expand Down