diff --git a/pkg/api/job_runs.go b/pkg/api/job_runs.go index 46b9a53ece..0911a73d6d 100644 --- a/pkg/api/job_runs.go +++ b/pkg/api/job_runs.go @@ -5,10 +5,8 @@ import ( "encoding/json" "errors" "fmt" - "net/http" "regexp" gosort "sort" - "strconv" "strings" "time" @@ -18,8 +16,11 @@ import ( "google.golang.org/api/iterator" "github.com/hashicorp/go-version" + "github.com/lib/pq" log "github.com/sirupsen/logrus" + "golang.org/x/sync/errgroup" "gorm.io/gorm" + "k8s.io/apimachinery/pkg/util/sets" apitype "github.com/openshift/sippy/pkg/apis/api" "github.com/openshift/sippy/pkg/apis/cache" @@ -33,7 +34,6 @@ import ( "github.com/openshift/sippy/pkg/db/query" "github.com/openshift/sippy/pkg/filter" "github.com/openshift/sippy/pkg/testidentification" - "github.com/openshift/sippy/pkg/util/param" ) const ( @@ -45,87 +45,63 @@ const ( // nonDeterministicRiskLevels indicate incomplete analysis and allow for fallback to other analysis methodologies name -> variant var nonDeterministicRiskLevels = []int{apitype.FailureRiskLevelUnknown.Level, apitype.FailureRiskLevelIncompleteTests.Level, apitype.FailureRiskLevelMissingData.Level} -func (runs apiRunResults) sort(req *http.Request) apiRunResults { - sortField := param.SafeRead(req, "sortField") - sort := apitype.Sort(param.SafeRead(req, "sort")) - - if sortField == "" { - sortField = "test_failures" - } - - if sort == "" { - sort = apitype.SortDescending +// JobsRunsReportFromDB renders a filtered summary of matching jobs using a +// two-phase approach: Phase 1 paginates from base tables (prow_job_runs + +// prow_jobs), Phase 2 enriches the page with test counts, test name arrays, +// pull request data, and annotations. +func JobsRunsReportFromDB(dbc *db.DB, filterOpts *filter.FilterOptions, release string, pagination *apitype.Pagination, reportEnd time.Time) (*apitype.PaginationResult, error) { + if filterOpts.SortField != "" && isTestNameField(filterOpts.SortField) { + return nil, &ValidationError{Message: fmt.Sprintf("sorting by %s is not supported", filterOpts.SortField)} } - gosort.Slice(runs, func(i, j int) bool { - if sort == apitype.SortAscending { - return filter.Compare(runs[i], runs[j], sortField) - } - return filter.Compare(runs[j], runs[i], sortField) - }) + jobsResult := make([]apitype.JobRun, 0) + lookback := reportEnd.Add(-90 * 24 * time.Hour) - return runs -} + prColumns := sets.New[string]("pull_request_link", "pull_request_sha", "pull_request_org", "pull_request_repo", "pull_request_author") + needs := analyzeJobRunFilters(filterOpts, prColumns) -func (runs apiRunResults) limit(req *http.Request) apiRunResults { - limit, _ := strconv.Atoi(req.URL.Query().Get("limit")) - if limit > 0 && len(runs) >= limit { - return runs[:limit] + // Phase 1: build SELECT from base tables. + selectSQL := jobRunsBaseSelect + if needs.needsPRJoin() { + selectSQL += `, pp.link AS pull_request_link, pp.sha AS pull_request_sha, pp.org AS pull_request_org, pp.repo AS pull_request_repo, pp.author AS pull_request_author` } - return runs -} + dbQuery := dbc.DB.Table("prow_job_runs"). + Select(selectSQL). + Joins("JOIN prow_jobs ON prow_job_runs.prow_job_id = prow_jobs.id") -type apiRunResults []apitype.JobRun + addPRJoin := func(q *gorm.DB) *gorm.DB { + return q. + Joins(`LEFT JOIN (SELECT DISTINCT ON(prow_job_run_id) prow_job_run_id, prow_pull_request_id FROM prow_job_run_prow_pull_requests ORDER BY prow_job_run_id, prow_pull_request_id DESC) jrpp ON jrpp.prow_job_run_id = prow_job_runs.id`). + Joins("LEFT JOIN prow_pull_requests pp ON pp.id = jrpp.prow_pull_request_id") + } -// JobsRunsReportFromDB renders a filtered summary of matching jobs. -func JobsRunsReportFromDB(dbc *db.DB, filterOpts *filter.FilterOptions, release string, pagination *apitype.Pagination, reportEnd time.Time) (*apitype.PaginationResult, error) { - jobsResult := make([]apitype.JobRun, 0) - table := "prow_job_runs_report_matview" - - dbQuery := dbc.DB.Table(table) - - // Split out ran_test_names filters — these are handled via a subquery - // against prow_job_run_tests rather than a column on the matview. - if filterOpts.Filter != nil { - ranTestFilter, remainingFilter := filterOpts.Filter.Split([]string{"ran_test_names"}) - filterOpts.Filter = remainingFilter - for _, item := range ranTestFilter.Items { - baseSubquery := "EXISTS (SELECT 1 FROM prow_job_run_tests JOIN tests ON tests.id = prow_job_run_tests.test_id WHERE prow_job_run_tests.prow_job_run_id = prow_job_runs_report_matview.id AND prow_job_run_tests.prow_job_run_release = prow_job_runs_report_matview.release AND tests.name %s ?)" - var pattern string - switch item.Operator { - case filter.OperatorHasEntry, filter.OperatorEquals: - baseSubquery = fmt.Sprintf(baseSubquery, "=") - pattern = item.Value - default: - baseSubquery = fmt.Sprintf(baseSubquery, "ILIKE") - pattern = fmt.Sprintf("%%%s%%", item.Value) - } - if item.Not { - baseSubquery = "NOT " + baseSubquery - } - dbQuery = dbQuery.Where(baseSubquery, pattern) - } + if needs.prJoinForFilter { + dbQuery = addPRJoin(dbQuery) } - q, err := filter.FilterableDBResult(dbQuery, filterOpts, apitype.JobRun{}) + q, err := applyJobRunFilters(dbQuery, filterOpts, lookback) if err != nil { return nil, err } if len(release) > 0 { - q = q.Where("release = ?", release) + q = q.Where("prow_jobs.release = ?", release) } + q = q.Where(`prow_job_runs."timestamp" < ?`, reportEnd) + q = q.Where(`prow_job_runs."timestamp" >= ?`, lookback) - q = q.Where("timestamp < ?", reportEnd.UnixMilli()) - - // Get the row count before pagination var rowCount int64 if err := q.Count(&rowCount).Error; err != nil { return nil, err } - // Paginate the results: + if needs.prJoinForSort && !needs.prJoinForFilter { + q = addPRJoin(q) + } + + q = q.Order("prow_job_runs.id DESC") + if pagination == nil { pagination = &apitype.Pagination{ PerPage: int(rowCount), @@ -135,38 +111,38 @@ func JobsRunsReportFromDB(dbc *db.DB, filterOpts *filter.FilterOptions, release q = q.Limit(pagination.PerPage).Offset(pagination.Page * pagination.PerPage) } - res := q.Scan(&jobsResult) - if res.Error != nil { - return nil, res.Error + if err := q.Scan(&jobsResult).Error; err != nil { + return nil, err } - // Fetch annotations separately to avoid bloating the materialized view. + // Phase 2: enrich paginated results in parallel. Each enrichment + // function writes to disjoint fields of jobsResult, so no locking + // is needed. if len(jobsResult) > 0 { ids := make([]int, len(jobsResult)) for i, jr := range jobsResult { ids[i] = jr.ID } - var annotations []models.ProwJobRunAnnotation - annotationQuery := dbc.DB.Where("prow_job_run_id IN ?", ids) - if len(release) > 0 { - annotationQuery = annotationQuery.Where("prow_job_run_release = ?", release) + + var g errgroup.Group + + g.Go(func() error { + return enrichJobRunsWithTestNames(dbc, jobsResult, ids, release, lookback) + }) + + if !needs.needsPRJoin() { + g.Go(func() error { + return enrichJobRunsWithPRData(dbc, jobsResult, ids) + }) } - if err := annotationQuery.Find(&annotations).Error; err != nil { + + g.Go(func() error { + return enrichJobRunsWithAnnotations(dbc, jobsResult, ids, release) + }) + + if err := g.Wait(); err != nil { return nil, err } - annotationsByRun := make(map[string]apitype.AnnotationMap) - for _, a := range annotations { - annotationID := strconv.FormatUint(uint64(a.ProwJobRunID), 10) - if annotationsByRun[annotationID] == nil { - annotationsByRun[annotationID] = make(apitype.AnnotationMap) - } - annotationsByRun[annotationID][a.Key] = a.Value - } - for i := range jobsResult { - if am, ok := annotationsByRun[strconv.Itoa(jobsResult[i].ID)]; ok { - jobsResult[i].Annotations = am - } - } } return &apitype.PaginationResult{ @@ -177,6 +153,278 @@ func JobsRunsReportFromDB(dbc *db.DB, filterOpts *filter.FilterOptions, release }, nil } +const jobRunsBaseSelect = `prow_job_runs.id, + prow_jobs.release, + prow_jobs.name, + prow_jobs.name AS job, + prow_jobs.variants, + regexp_replace(prow_jobs.name, 'periodic-ci-openshift-(multiarch|release)-master-(ci|nightly)-[0-9]+.[0-9]+-', '') AS brief_name, + prow_job_runs.overall_result, + prow_job_runs.url AS test_grid_url, + prow_job_runs.url, + prow_job_runs.succeeded, + prow_job_runs.infrastructure_failure, + prow_job_runs.known_failure, + (EXTRACT(epoch FROM (prow_job_runs."timestamp" AT TIME ZONE 'utc')) * 1000)::bigint AS "timestamp", + prow_job_runs.id AS prow_id, + prow_job_runs.cluster, + prow_job_runs.labels, + prow_job_runs.test_failures, + prow_job_runs.test_flakes` + +type jobRunFilterNeeds struct { + prJoinForSort bool + prJoinForFilter bool +} + +func (n jobRunFilterNeeds) needsPRJoin() bool { return n.prJoinForSort || n.prJoinForFilter } + +func analyzeJobRunFilters(filterOpts *filter.FilterOptions, prColumns sets.Set[string]) jobRunFilterNeeds { + needs := jobRunFilterNeeds{ + prJoinForSort: prColumns.Has(filterOpts.SortField), + } + if filterOpts.Filter == nil { + return needs + } + for _, item := range filterOpts.Filter.Items { + if prColumns.Has(item.Field) { + needs.prJoinForFilter = true + } + } + return needs +} + +// columnAliases maps filter field names that are SELECT aliases (not base +// table columns) to their table-qualified column expressions. PostgreSQL +// WHERE clauses cannot reference SELECT aliases, so these fields must be +// rewritten before they reach the generic filter system. +var columnAliases = map[string]string{ + "id": "prow_job_runs.id", + "job": "prow_jobs.name", + "brief_name": "regexp_replace(prow_jobs.name, 'periodic-ci-openshift-(multiarch|release)-master-(ci|nightly)-[0-9]+.[0-9]+-', '')", + "prow_id": "prow_job_runs.id", + "test_grid_url": "prow_job_runs.url", + "timestamp": `(EXTRACT(epoch FROM (prow_job_runs."timestamp" AT TIME ZONE 'utc')) * 1000)::bigint`, + "pull_request_link": "pp.link", + "pull_request_sha": "pp.sha", + "pull_request_org": "pp.org", + "pull_request_repo": "pp.repo", + "pull_request_author": "pp.author", +} + +// testNameStatuses maps test name filter fields to the prow_job_run_tests +// status code used in EXISTS subqueries. ran_test_names uses 0 to mean +// "no status constraint" (matches any test outcome). This is safe because +// TestStatusAbsent (0) is never stored in prow_job_run_tests rows. +var testNameStatuses = map[string]int{ + "ran_test_names": 0, + "failed_test_names": int(sippyprocessingv1.TestStatusFailure), + "flaked_test_names": int(sippyprocessingv1.TestStatusFlake), +} + +func isTestNameField(field string) bool { + _, ok := testNameStatuses[field] + return ok +} + +// applyJobRunFilters processes all filter items in a single pass, dispatching +// each to the appropriate handler based on field name. Fields that need +// special SQL (test name EXISTS or table-qualified column aliases) are handled +// directly; the rest use the generic filter SQL generator. All clauses are +// collected and combined with AND or OR based on linkOperator. +func applyJobRunFilters(q *gorm.DB, filterOpts *filter.FilterOptions, lookback time.Time) (*gorm.DB, error) { + if filterOpts.Filter == nil || len(filterOpts.Filter.Items) == 0 { + return filter.FilterableDBResult(q, filterOpts, apitype.JobRun{}) + } + + var clauses []string + var allArgs []any + for _, item := range filterOpts.Filter.Items { + var sql string + var param any + switch { + case isTestNameField(item.Field): + sqlFrag, args, err := testNameFilterSQL(item, lookback) + if err != nil { + return nil, &ValidationError{Message: err.Error()} + } + clauses = append(clauses, sqlFrag) + allArgs = append(allArgs, args...) + continue + default: + if col, ok := columnAliases[item.Field]; ok { + var err error + sql, param, err = item.FilterItemToSQL(col) + if err != nil { + return nil, &ValidationError{Message: err.Error()} + } + } else { + sql, param = item.FilterFieldToSQL(apitype.JobRun{}) + } + } + if sql != "" { + clauses = append(clauses, sql) + if param != nil { + allArgs = append(allArgs, param) + } + } + } + if len(clauses) > 0 { + joiner := " AND " + if filterOpts.Filter.LinkOperator == filter.LinkOperatorOr { + joiner = " OR " + } + q = q.Where("("+strings.Join(clauses, joiner)+")", allArgs...) + } + + sortOpts := *filterOpts + sortOpts.Filter = &filter.Filter{} + return filter.FilterableDBResult(q, &sortOpts, apitype.JobRun{}) +} + +func testNameFilterSQL(item filter.FilterItem, lookback time.Time) (string, []any, error) { + statusClause := "" + if status := testNameStatuses[item.Field]; status != 0 { + statusClause = fmt.Sprintf(" AND prow_job_run_tests.status = %d", status) + } + + existsBase := fmt.Sprintf( + "EXISTS (SELECT 1 FROM prow_job_run_tests JOIN tests ON tests.id = prow_job_run_tests.test_id WHERE prow_job_run_tests.prow_job_run_id = prow_job_runs.id AND prow_job_run_tests.prow_job_run_release = prow_jobs.release AND prow_job_run_tests.prow_job_run_timestamp >= ?%s", + statusClause, + ) + + var sql string + var args []any + switch item.Operator { + case filter.OperatorIsEmpty: + sql = "NOT " + existsBase + ")" + args = []any{lookback} + case filter.OperatorIsNotEmpty: + sql = existsBase + ")" + args = []any{lookback} + case filter.OperatorHasEntry, filter.OperatorEquals: + sql = existsBase + " AND tests.name = ?)" + args = []any{lookback, item.Value} + case filter.OperatorStartsWith: + sql = existsBase + " AND tests.name ILIKE ?)" + args = []any{lookback, fmt.Sprintf("%s%%", filter.EscapeLikeMetachars(item.Value))} + case filter.OperatorEndsWith: + sql = existsBase + " AND tests.name ILIKE ?)" + args = []any{lookback, fmt.Sprintf("%%%s", filter.EscapeLikeMetachars(item.Value))} + case filter.OperatorContains, filter.OperatorHasEntryContaining: + sql = existsBase + " AND tests.name ILIKE ?)" + args = []any{lookback, fmt.Sprintf("%%%s%%", filter.EscapeLikeMetachars(item.Value))} + default: + return "", nil, fmt.Errorf("unsupported operator %q for field %q", item.Operator, item.Field) + } + return filter.WrapNot(sql, item.Not), args, nil +} + +func enrichJobRunsWithTestNames(dbc *db.DB, results []apitype.JobRun, ids []int, release string, lookback time.Time) error { + type testNameResult struct { + ProwJobRunID int `gorm:"column:prow_job_run_id"` + FailedTestNames pq.StringArray `gorm:"column:failed_test_names;type:text[]"` + FlakedTestNames pq.StringArray `gorm:"column:flaked_test_names;type:text[]"` + } + var nameResults []testNameResult + nameSQL := fmt.Sprintf(`SELECT pjrt.prow_job_run_id, + array_agg(t.name) FILTER (WHERE pjrt.status = %d) AS failed_test_names, + array_agg(t.name) FILTER (WHERE pjrt.status = %d) AS flaked_test_names + FROM prow_job_run_tests pjrt + JOIN tests t ON t.id = pjrt.test_id + WHERE pjrt.prow_job_run_id IN ? + AND pjrt.status IN (%d, %d) + AND pjrt.prow_job_run_timestamp >= ?`, + sippyprocessingv1.TestStatusFailure, sippyprocessingv1.TestStatusFlake, sippyprocessingv1.TestStatusFailure, sippyprocessingv1.TestStatusFlake) + nameArgs := []any{ids, lookback} + if len(release) > 0 { + nameSQL += ` AND pjrt.prow_job_run_release = ?` + nameArgs = append(nameArgs, release) + } + nameSQL += ` GROUP BY pjrt.prow_job_run_id` + if err := dbc.DB.Raw(nameSQL, nameArgs...).Scan(&nameResults).Error; err != nil { + return err + } + nameMap := make(map[int]*testNameResult, len(nameResults)) + for i := range nameResults { + nameMap[nameResults[i].ProwJobRunID] = &nameResults[i] + } + for i := range results { + if names, ok := nameMap[results[i].ID]; ok { + results[i].FailedTestNames = names.FailedTestNames + results[i].FlakedTestNames = names.FlakedTestNames + } + } + return nil +} + +func enrichJobRunsWithPRData(dbc *db.DB, results []apitype.JobRun, ids []int) error { + type prResult struct { + ID int `gorm:"column:id"` + PullRequestLink string `gorm:"column:pull_request_link"` + PullRequestSHA string `gorm:"column:pull_request_sha"` + PullRequestOrg string `gorm:"column:pull_request_org"` + PullRequestRepo string `gorm:"column:pull_request_repo"` + PullRequestAuthor string `gorm:"column:pull_request_author"` + } + var prResults []prResult + if err := dbc.DB.Raw(` + SELECT DISTINCT ON(jrpp.prow_job_run_id) + jrpp.prow_job_run_id AS id, + pp.link AS pull_request_link, + pp.sha AS pull_request_sha, + pp.org AS pull_request_org, + pp.author AS pull_request_author, + pp.repo AS pull_request_repo + FROM prow_job_run_prow_pull_requests jrpp + INNER JOIN prow_pull_requests pp ON pp.id = jrpp.prow_pull_request_id + WHERE jrpp.prow_job_run_id IN ? + ORDER BY jrpp.prow_job_run_id, jrpp.prow_pull_request_id DESC`, ids).Scan(&prResults).Error; err != nil { + return err + } + prMap := make(map[int]*prResult, len(prResults)) + for i := range prResults { + prMap[prResults[i].ID] = &prResults[i] + } + for i := range results { + pr, ok := prMap[results[i].ID] + if !ok { + continue + } + results[i].PullRequestLink = pr.PullRequestLink + results[i].PullRequestSHA = pr.PullRequestSHA + results[i].PullRequestOrg = pr.PullRequestOrg + results[i].PullRequestRepo = pr.PullRequestRepo + results[i].PullRequestAuthor = pr.PullRequestAuthor + } + return nil +} + +func enrichJobRunsWithAnnotations(dbc *db.DB, results []apitype.JobRun, ids []int, release string) error { + var annotations []models.ProwJobRunAnnotation + annotationQuery := dbc.DB.Where("prow_job_run_id IN ?", ids) + if len(release) > 0 { + annotationQuery = annotationQuery.Where("prow_job_run_release = ?", release) + } + if err := annotationQuery.Find(&annotations).Error; err != nil { + return err + } + annotationsByRun := make(map[int]apitype.AnnotationMap) + for _, a := range annotations { + runID := int(a.ProwJobRunID) //nolint:gosec // DB IDs are well within int range + if annotationsByRun[runID] == nil { + annotationsByRun[runID] = make(apitype.AnnotationMap) + } + annotationsByRun[runID][a.Key] = a.Value + } + for i := range results { + if am, ok := annotationsByRun[results[i].ID]; ok { + results[i].Annotations = am + } + } + return nil +} + // FetchJobRun returns a single job run loaded from postgres and populated with the ProwJob and test results. // If onlyNewTests is true, only new tests are loaded: those not registered in test_ownerships and not // previously seen in a merged pull request. Otherwise any failed tests are loaded. diff --git a/pkg/db/query/cumulative_query.go b/pkg/db/query/cumulative_query.go index 33da66edf1..73d91073a6 100644 --- a/pkg/db/query/cumulative_query.go +++ b/pkg/db/query/cumulative_query.go @@ -66,11 +66,11 @@ func nameMatchConditions(matches TestNameMatches) (conditions []string, args []a } for _, prefix := range matches.Prefixes { conditions = append(conditions, "tests.name LIKE ?") - args = append(args, escapeLikeMetachars(prefix)+"%") + args = append(args, filter.EscapeLikeMetachars(prefix)+"%") } for _, sub := range matches.Substrings { conditions = append(conditions, "tests.name ILIKE ?") - args = append(args, "%"+escapeLikeMetachars(sub)+"%") + args = append(args, "%"+filter.EscapeLikeMetachars(sub)+"%") } return conditions, args } @@ -95,13 +95,6 @@ func buildTestsJoinCondition(matches TestNameMatches) (string, []any) { return testsJoinClause + " AND (" + strings.Join(conditions, " OR ") + ")", args } -// escapeLikeMetachars escapes LIKE/ILIKE metacharacters (%, _, \) so they -// match literally. -func escapeLikeMetachars(s string) string { - r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) - return r.Replace(s) -} - // DateRange defines a half-open date interval [Start, End) used to compute // period counts from prefix sums in test_cumulative_summaries. // @@ -193,7 +186,7 @@ func variantFilterConditions(variantFilter *filter.Filter) (conditions []string, } args = append(args, item.Value) case filter.OperatorHasEntryContaining, filter.OperatorContains: - pattern := "%" + escapeLikeMetachars(strings.ToLower(item.Value)) + "%" + pattern := "%" + filter.EscapeLikeMetachars(strings.ToLower(item.Value)) + "%" if item.Not { conditions = append(conditions, "NOT EXISTS (SELECT 1 FROM variant_combinations vc, LATERAL unnest(vc.variants) AS v(item) WHERE vc.id = variant_combination_id AND LOWER(v.item) LIKE ?)") } else { diff --git a/pkg/db/query/cumulative_query_test.go b/pkg/db/query/cumulative_query_test.go index 06cd5691a4..f66f141e66 100644 --- a/pkg/db/query/cumulative_query_test.go +++ b/pkg/db/query/cumulative_query_test.go @@ -49,9 +49,9 @@ func TestEscapeLikeMetachars(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got := escapeLikeMetachars(tc.input) + got := filter.EscapeLikeMetachars(tc.input) if got != tc.expected { - t.Errorf("escapeLikeMetachars(%q) = %q, want %q", tc.input, got, tc.expected) + t.Errorf("filter.EscapeLikeMetachars(%q) = %q, want %q", tc.input, got, tc.expected) } }) } diff --git a/pkg/db/views.go b/pkg/db/views.go index 30d28b5be4..81abbe9d6f 100644 --- a/pkg/db/views.go +++ b/pkg/db/views.go @@ -13,14 +13,7 @@ const replaceTimeNow = "|||TIMENOW|||" const timestampFormat = "2006-01-02 15:04:05" // TODO: for historical sippy we need to specify the pinnedDate and not use NOW -var PostgresMatViews = []PostgresView{ - { - Name: "prow_job_runs_report_matview", - Definition: jobRunsReportMatView, - IndexColumns: []string{"id"}, - AdditionalIndexes: []string{"release, timestamp DESC"}, - }, -} +var PostgresMatViews = []PostgresView{} // PostgresViews are regular, non-materialized views: var PostgresViews = []PostgresView{} @@ -147,71 +140,3 @@ func syncPostgresViews(db *gorm.DB, reportEnd *time.Time) error { return nil } - -// jobRunsReportMatView limits all data to a 90-day window. This is intentional: -// prow_job_run_tests is heavily partitioned and scanning beyond 90 days is expensive -// with no consumer needing older per-test failure/flake details in this view. -const jobRunsReportMatView = ` -WITH test_results AS ( - SELECT prow_job_run_tests.prow_job_run_id, - prow_job_run_tests.prow_job_run_release, - count(tests.id) FILTER (WHERE prow_job_run_tests.status = 12) AS failed_test_count, - array_agg(tests.name) FILTER (WHERE prow_job_run_tests.status = 12) AS failed_test_names, - count(tests.id) FILTER (WHERE prow_job_run_tests.status = 13) AS flaked_test_count, - array_agg(tests.name) FILTER (WHERE prow_job_run_tests.status = 13) AS flaked_test_names - FROM prow_job_run_tests - JOIN tests ON tests.id = prow_job_run_tests.test_id - WHERE prow_job_run_tests.status IN (12, 13) - AND prow_job_run_tests.prow_job_run_timestamp >= |||TIMENOW||| - interval '90 days' - GROUP BY prow_job_run_tests.prow_job_run_id, prow_job_run_tests.prow_job_run_release -), -pull_requests AS ( - SELECT - DISTINCT ON(prow_job_runs.id) - prow_job_runs.id as id, - prow_pull_requests.link, - prow_pull_requests.sha, - prow_pull_requests.org, - prow_pull_requests.author, - prow_pull_requests.repo - FROM - prow_pull_requests - INNER JOIN - prow_job_run_prow_pull_requests ON prow_job_run_prow_pull_requests.prow_pull_request_id = prow_pull_requests.id - INNER JOIN - prow_job_runs ON prow_job_run_prow_pull_requests.prow_job_run_id = prow_job_runs.id - WHERE prow_job_runs."timestamp" >= |||TIMENOW||| - interval '90 days' - GROUP BY prow_job_runs.id, prow_pull_requests.link, prow_pull_requests.sha, prow_pull_requests.org, prow_pull_requests.repo, prow_pull_requests.author -) -SELECT prow_job_runs.id, - prow_jobs.release, - prow_jobs.name, - prow_jobs.name AS job, - prow_jobs.variants, - regexp_replace(prow_jobs.name, 'periodic-ci-openshift-(multiarch|release)-master-(ci|nightly)-[0-9]+.[0-9]+-'::text, ''::text) AS brief_name, - prow_job_runs.overall_result, - prow_job_runs.url AS test_grid_url, - prow_job_runs.url, - prow_job_runs.succeeded, - prow_job_runs.infrastructure_failure, - prow_job_runs.known_failure, - (EXTRACT(epoch FROM (prow_job_runs."timestamp" AT TIME ZONE 'utc'::text)) * 1000::numeric)::bigint AS "timestamp", - prow_job_runs.id AS prow_id, - prow_job_runs.cluster AS cluster, - prow_job_runs.labels as labels, - test_results.flaked_test_names AS flaked_test_names, - test_results.flaked_test_count AS test_flakes, - test_results.failed_test_names AS failed_test_names, - test_results.failed_test_count AS test_failures, - pull_requests.link as pull_request_link, - pull_requests.sha as pull_request_sha, - pull_requests.org as pull_request_org, - pull_requests.repo as pull_request_repo, - pull_requests.author as pull_request_author -FROM prow_job_runs - LEFT JOIN test_results ON test_results.prow_job_run_id = prow_job_runs.id - AND test_results.prow_job_run_release = prow_job_runs.prow_job_release - LEFT JOIN pull_requests ON pull_requests.id = prow_job_runs.id - JOIN prow_jobs ON prow_job_runs.prow_job_id = prow_jobs.id -WHERE prow_job_runs."timestamp" >= |||TIMENOW||| - interval '90 days' -` diff --git a/pkg/filter/filterable.go b/pkg/filter/filterable.go index 469cfe2a3f..ced7bc860b 100644 --- a/pkg/filter/filterable.go +++ b/pkg/filter/filterable.go @@ -73,19 +73,31 @@ func optNot(not bool) string { return "" } +// WrapNot wraps a SQL expression in NOT(...) when negated. +func WrapNot(sql string, not bool) string { + if not { + return fmt.Sprintf("NOT(%s)", sql) + } + return sql +} + +// EscapeLikeMetachars escapes LIKE/ILIKE metacharacters (%, _, \) so they +// match literally in PostgreSQL pattern expressions. +func EscapeLikeMetachars(s string) string { + r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) + return r.Replace(s) +} + // ilikeFilter returns the SQL filter and parameters for ILIKE pattern matching, // handling both string fields (using ILIKE directly) and array fields (using unnest with EXISTS). -func ilikeFilter(field, pattern string, not bool, filterable Filterable, fieldName string) (string, interface{}) { +func ilikeFilter(field, pattern string, not bool, filterable Filterable, fieldName string) (string, any) { + var sql string if filterable != nil && filterable.GetFieldType(fieldName) == apitype.ColumnTypeArray { - return fmt.Sprintf("%s EXISTS (SELECT 1 FROM unnest(%s) AS elem WHERE elem ILIKE ?)", optNot(not), field), pattern + sql = fmt.Sprintf("EXISTS (SELECT 1 FROM unnest(%s) AS elem WHERE elem ILIKE ?)", field) + } else { + sql = fmt.Sprintf("%s ILIKE ?", field) } - return fmt.Sprintf("%s %s ILIKE ?", field, optNot(not)), pattern -} - -// applyIlikeFilter applies an ILIKE filter to a GORM DB handle, handling both string and array fields. -func applyIlikeFilter(db *gorm.DB, field, pattern string, not bool, filterable Filterable, fieldName string) *gorm.DB { - filterSQL, params := ilikeFilter(field, pattern, not, filterable, fieldName) - return db.Where(filterSQL, params) + return WrapNot(sql, not), pattern } func (f FilterItem) isEmptyFilter(field string, filterable Filterable, forBQ bool) string { @@ -97,131 +109,75 @@ func (f FilterItem) isEmptyFilter(field string, filterable Filterable, forBQ boo sql = fmt.Sprintf("(%s IS NULL or ARRAY_LENGTH(%s) = 0)", field, field) } } - if f.Not { - return fmt.Sprintf("NOT(%s)", sql) - } - return sql + return WrapNot(sql, f.Not) } -func (f FilterItem) orFilterToSQL(db *gorm.DB, filterable Filterable) (orFilter string, orParams interface{}) { //nolint - field := fmt.Sprintf("%q", f.Field) - if filterable != nil && filterable.GetFieldType(f.Field) == apitype.ColumnTypeTimestamp { - field = fmt.Sprintf("extract(epoch from %s at time zone 'utc') * 1000", f.Field) - } - +// FilterItemToSQL returns a SQL fragment and parameter for a filter item +// applied to the given column expression. The column is used as-is with no +// type-aware transformations: ILIKE operators match the column value directly +// rather than unnesting array elements, and timestamps are not converted to +// epoch milliseconds. Use FilterFieldToSQL when the column may be an array +// or timestamp type. +func (f FilterItem) FilterItemToSQL(column string) (string, any, error) { + var sql string + var param any switch f.Operator { case OperatorHasEntry: - if f.Not { - return fmt.Sprintf("%s IS NULL OR ? != ALL(%s)", field, field), f.Value - } - return fmt.Sprintf("? = ANY(%s)", field), f.Value + sql, param = fmt.Sprintf("? = ANY(COALESCE(%s, '{}'))", column), f.Value case OperatorHasEntryContaining, OperatorContains: - return ilikeFilter(field, fmt.Sprintf("%%%s%%", f.Value), f.Not, filterable, f.Field) + sql, param = fmt.Sprintf("%s ILIKE ?", column), fmt.Sprintf("%%%s%%", EscapeLikeMetachars(f.Value)) case OperatorEquals, OperatorArithmeticEquals: - if f.Not { - return fmt.Sprintf("%s != ?", field), f.Value - } - return fmt.Sprintf("%s = ?", field), f.Value + sql, param = fmt.Sprintf("%s = ?", column), f.Value case OperatorArithmeticGreaterThan: - if f.Not { - return fmt.Sprintf("%s <= ?", field), f.Value - } - return fmt.Sprintf("%s > ?", field), f.Value + sql, param = fmt.Sprintf("%s > ?", column), f.Value case OperatorArithmeticGreaterThanOrEquals: - if f.Not { - return fmt.Sprintf("%s < ?", field), f.Value - } - return fmt.Sprintf("%s >= ?", field), f.Value + sql, param = fmt.Sprintf("%s >= ?", column), f.Value case OperatorArithmeticLessThan: - if f.Not { - return fmt.Sprintf("%s >= ?", field), f.Value - } - return fmt.Sprintf("%s < ?", field), f.Value + sql, param = fmt.Sprintf("%s < ?", column), f.Value case OperatorArithmeticLessThanOrEquals: - if f.Not { - return fmt.Sprintf("%s > ?", field), f.Value - } - return fmt.Sprintf("%s <= ?", field), f.Value + sql, param = fmt.Sprintf("%s <= ?", column), f.Value case OperatorArithmeticNotEquals: - if f.Not { - return fmt.Sprintf("%s = ?", field), f.Value - } - return fmt.Sprintf("%s <> ?", field), f.Value + sql, param = fmt.Sprintf("%s <> ?", column), f.Value case OperatorStartsWith: - return ilikeFilter(field, fmt.Sprintf("%s%%", f.Value), f.Not, filterable, f.Field) + sql, param = fmt.Sprintf("%s ILIKE ?", column), fmt.Sprintf("%s%%", EscapeLikeMetachars(f.Value)) case OperatorEndsWith: - return ilikeFilter(field, fmt.Sprintf("%%%s", f.Value), f.Not, filterable, f.Field) + sql, param = fmt.Sprintf("%s ILIKE ?", column), fmt.Sprintf("%%%s", EscapeLikeMetachars(f.Value)) case OperatorIsEmpty: - return f.isEmptyFilter(field, filterable, false), nil + sql = fmt.Sprintf("%s IS NULL", column) case OperatorIsNotEmpty: - return fmt.Sprintf("%s IS %s NULL", field, optNot(!f.Not)), nil + sql = fmt.Sprintf("%s IS NOT NULL", column) + default: + return "", nil, fmt.Errorf("unsupported operator %q for field %q", f.Operator, f.Field) } - - return "UnknownFilterOperator()", nil // cause SQL to fail in obvious way + return WrapNot(sql, f.Not), param, nil } -func (f FilterItem) andFilterToSQL(db *gorm.DB, filterable Filterable) *gorm.DB { //nolint +// FilterFieldToSQL returns a SQL fragment and parameter for a filter item, +// with array and timestamp type awareness from the filterable. +func (f FilterItem) FilterFieldToSQL(filterable Filterable) (string, any) { field := fmt.Sprintf("%q", f.Field) if filterable != nil && filterable.GetFieldType(f.Field) == apitype.ColumnTypeTimestamp { field = fmt.Sprintf("extract(epoch from %s at time zone 'utc') * 1000", f.Field) } + // Operators that need array-aware handling delegate to specialized helpers; + // all other operators use the common scalar implementation. switch f.Operator { - case OperatorHasEntry: - if f.Not { - db = db.Where(fmt.Sprintf("%s IS NULL OR ? != ALL(%s)", field, field), f.Value) - } else { - db = db.Where(fmt.Sprintf("? = ANY(%s)", field), f.Value) - } case OperatorHasEntryContaining, OperatorContains: - db = applyIlikeFilter(db, field, fmt.Sprintf("%%%s%%", f.Value), f.Not, filterable, f.Field) - case OperatorEquals, OperatorArithmeticEquals: - if f.Not { - db = db.Not(fmt.Sprintf("%s = ?", field), f.Value) - } else { - db = db.Where(fmt.Sprintf("%s = ?", field), f.Value) - } - case OperatorArithmeticGreaterThan: - if f.Not { - db = db.Not(fmt.Sprintf("%s > ?", field), f.Value) - } else { - db = db.Where(fmt.Sprintf("%s > ?", field), f.Value) - } - case OperatorArithmeticGreaterThanOrEquals: - if f.Not { - db = db.Not(fmt.Sprintf("%s >= ?", field), f.Value) - } else { - db = db.Where(fmt.Sprintf("%s >= ?", field), f.Value) - } - case OperatorArithmeticLessThan: - if f.Not { - db = db.Not(fmt.Sprintf("%s < ?", field), f.Value) - } else { - db = db.Where(fmt.Sprintf("%s < ?", field), f.Value) - } - case OperatorArithmeticLessThanOrEquals: - if f.Not { - db = db.Not(fmt.Sprintf("%s <= ?", field), f.Value) - } else { - db = db.Where(fmt.Sprintf("%s <= ?", field), f.Value) - } - case OperatorArithmeticNotEquals: - if f.Not { - db = db.Not(fmt.Sprintf("%s <> ?", field), f.Value) - } else { - db = db.Where(fmt.Sprintf("%s <> ?", field), f.Value) - } + return ilikeFilter(field, fmt.Sprintf("%%%s%%", EscapeLikeMetachars(f.Value)), f.Not, filterable, f.Field) case OperatorStartsWith: - db = applyIlikeFilter(db, field, fmt.Sprintf("%s%%", f.Value), f.Not, filterable, f.Field) + return ilikeFilter(field, fmt.Sprintf("%s%%", EscapeLikeMetachars(f.Value)), f.Not, filterable, f.Field) case OperatorEndsWith: - db = applyIlikeFilter(db, field, fmt.Sprintf("%%%s", f.Value), f.Not, filterable, f.Field) + return ilikeFilter(field, fmt.Sprintf("%%%s", EscapeLikeMetachars(f.Value)), f.Not, filterable, f.Field) case OperatorIsEmpty: - db = db.Where(f.isEmptyFilter(field, filterable, false)) - case OperatorIsNotEmpty: - db = db.Where(fmt.Sprintf("%s IS %s NULL", field, optNot(!f.Not))) + return f.isEmptyFilter(field, filterable, false), nil } - return db + sql, param, err := f.FilterItemToSQL(field) + if err != nil { + return "UnknownFilterOperator()", nil + } + return sql, param } func (f FilterItem) toBQStr(filterable Filterable, paramIndex int) (sql string, params []bigquery.QueryParameter) { //nolint @@ -441,16 +397,19 @@ filterOuterLoop: } func (filters Filter) ToSQL(db *gorm.DB, filterable Filterable) *gorm.DB { - - orFilters := []string{} - orFilterParams := []interface{}{} + var orFilters []string + var orFilterParams []interface{} for _, f := range filters.Items { + q, p := f.FilterFieldToSQL(filterable) switch filters.LinkOperator { case LinkOperatorAnd, "": - db = f.andFilterToSQL(db, filterable) + if p != nil { + db = db.Where(q, p) + } else { + db = db.Where(q) + } case LinkOperatorOr: - q, p := f.orFilterToSQL(db, filterable) orFilters = append(orFilters, q) if p != nil { orFilterParams = append(orFilterParams, p) diff --git a/pkg/flags/postgres_benchmarking_test.go b/pkg/flags/postgres_benchmarking_test.go index 0d9ed2c667..7299f9a3c4 100644 --- a/pkg/flags/postgres_benchmarking_test.go +++ b/pkg/flags/postgres_benchmarking_test.go @@ -980,128 +980,6 @@ func Test_BenchmarkCumulativeQueryTestsReport(t *testing.T) { printSummaryTable(t, results, connName) } -func Test_BenchmarkJobRunsReportMatview(t *testing.T) { - dbc, connName := getBenchmarkDBClient(t) - - var source db.PostgresView - for _, mv := range db.PostgresMatViews { - if mv.Name == "prow_job_runs_report_matview" { - source = mv - break - } - } - if source.Name == "" { - t.Fatal("prow_job_runs_report_matview not found in PostgresMatViews") - } - - matviewName := "bench_job_runs_report" - viewDef := source.Definition - for k, v := range source.ReplaceStrings { - viewDef = strings.ReplaceAll(viewDef, k, v) - } - viewDef = strings.ReplaceAll(viewDef, "|||TIMENOW|||", "NOW()") - - t.Cleanup(func() { - if err := dbc.DB.Exec(fmt.Sprintf("DROP MATERIALIZED VIEW IF EXISTS %s", matviewName)).Error; err != nil { - t.Logf("failed to drop materialized view %s during cleanup: %v", matviewName, err) - } - }) - if err := dbc.DB.Exec(fmt.Sprintf("DROP MATERIALIZED VIEW IF EXISTS %s", matviewName)).Error; err != nil { - t.Fatalf("failed to drop pre-existing materialized view %s: %v", matviewName, err) - } - - iterations := 1 - var results []benchmarkResult - - results = append(results, runBenchmarkCase(t, dbc, benchmarkCase{ - name: "CreateMatview", - fn: func(dbc *db.DB) error { - if err := dbc.DB.Exec(fmt.Sprintf("DROP MATERIALIZED VIEW IF EXISTS %s", matviewName)).Error; err != nil { - return err - } - res := dbc.DB.Exec(fmt.Sprintf("CREATE MATERIALIZED VIEW %s AS %s WITH DATA", matviewName, viewDef)) - if res.Error != nil { - return res.Error - } - var count int64 - if err := dbc.DB.Raw(fmt.Sprintf("SELECT COUNT(*) FROM %s", matviewName)).Scan(&count).Error; err != nil { - return err - } - log.Printf("CreateMatview: %s populated with %d rows", matviewName, count) - return nil - }, - }, iterations)) - - indexName := fmt.Sprintf("idx_%s", matviewName) - indexCols := strings.Join(source.IndexColumns, ", ") - results = append(results, runBenchmarkCase(t, dbc, benchmarkCase{ - name: "CreateIndex", - fn: func(dbc *db.DB) error { - dbc.DB.Exec(fmt.Sprintf("DROP INDEX IF EXISTS %s", indexName)) - res := dbc.DB.Exec(fmt.Sprintf("CREATE UNIQUE INDEX %s ON %s(%s)", indexName, matviewName, indexCols)) - return res.Error - }, - }, iterations)) - - results = append(results, runBenchmarkCase(t, dbc, benchmarkCase{ - name: "RefreshConcurrently", - fn: func(dbc *db.DB) error { - res := dbc.DB.Exec(fmt.Sprintf("REFRESH MATERIALIZED VIEW CONCURRENTLY %s", matviewName)) - return res.Error - }, - }, iterations)) - - results = append(results, runBenchmarkCase(t, dbc, benchmarkCase{ - name: "QueryByRelease", - fn: func(dbc *db.DB) error { - var jobRuns []apitype.JobRun - res := dbc.DB.Table(matviewName). - Where("release = ?", benchmarkRelease). - Order("timestamp desc"). - Limit(100). - Scan(&jobRuns) - if res.Error != nil { - return res.Error - } - log.Printf("QueryByRelease: %d rows from %s", len(jobRuns), matviewName) - return nil - }, - }, iterations)) - - results = append(results, runBenchmarkCase(t, dbc, benchmarkCase{ - name: "QueryByJob", - fn: func(dbc *db.DB) error { - var jobRuns []apitype.JobRun - res := dbc.DB.Table(matviewName). - Where("release = ? AND name = ?", benchmarkRelease, benchmarkJobName). - Order("timestamp desc"). - Scan(&jobRuns) - if res.Error != nil { - return res.Error - } - log.Printf("QueryByJob: %d rows from %s", len(jobRuns), matviewName) - return nil - }, - }, iterations)) - - results = append(results, runBenchmarkCase(t, dbc, benchmarkCase{ - name: "CountWithFailures", - fn: func(dbc *db.DB) error { - var count int64 - res := dbc.DB.Table(matviewName). - Where("release = ? AND test_failures > 0", benchmarkRelease). - Count(&count) - if res.Error != nil { - return res.Error - } - log.Printf("CountWithFailures: %d rows from %s", count, matviewName) - return nil - }, - }, iterations)) - - printSummaryTable(t, results, connName) -} - func Test_BenchmarkRefreshData(t *testing.T) { dbc, connName := getBenchmarkDBClient(t) diff --git a/sippy-ng/src/jobs/JobRunsTable.jsx b/sippy-ng/src/jobs/JobRunsTable.jsx index 9a5c90d368..f5a31de0be 100644 --- a/sippy-ng/src/jobs/JobRunsTable.jsx +++ b/sippy-ng/src/jobs/JobRunsTable.jsx @@ -362,6 +362,7 @@ export default function JobRunsTable(props) { autocomplete: 'tests', headerName: 'Failed tests', hide: true, + sortable: false, }, { field: 'flaked_test_names', @@ -369,6 +370,7 @@ export default function JobRunsTable(props) { autocomplete: 'tests', headerName: 'Flaked tests', hide: true, + sortable: false, }, { field: 'ran_test_names', @@ -376,6 +378,7 @@ export default function JobRunsTable(props) { autocomplete: 'tests', headerName: 'Tests ran', hide: true, + sortable: false, }, { field: 'pull_request_author', diff --git a/test/integration/job_runs_report_test.go b/test/integration/job_runs_report_test.go new file mode 100644 index 0000000000..0ae2705448 --- /dev/null +++ b/test/integration/job_runs_report_test.go @@ -0,0 +1,1387 @@ +package integration + +import ( + "fmt" + "sort" + "testing" + "time" + + "github.com/lib/pq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openshift/sippy/pkg/api" + apitype "github.com/openshift/sippy/pkg/apis/api" + v1 "github.com/openshift/sippy/pkg/apis/sippyprocessing/v1" + "github.com/openshift/sippy/pkg/db" + "github.com/openshift/sippy/pkg/db/models" + "github.com/openshift/sippy/pkg/filter" + intutil "github.com/openshift/sippy/test/integration/util" +) + +var ( + jrReportEnd = time.Date(2024, 7, 15, 12, 0, 0, 0, time.UTC) + jrLookback = jrReportEnd.Add(-90 * 24 * time.Hour) +) + +type jobRunsTestData struct { + jobAWS models.ProwJob + jobGCP models.ProwJob + jobOther models.ProwJob + + runA1 models.ProwJobRun + runA2 models.ProwJobRun + runA3 models.ProwJobRun + runG1 models.ProwJobRun + runOther models.ProwJobRun + runOutsideLookback models.ProwJobRun + runAtReportEnd models.ProwJobRun + + testEtcd models.Test + testNetwork models.Test + testUpgrade models.Test + + pr1 models.ProwPullRequest +} + +func setupJobRunsTestData(t *testing.T, dbc *db.DB) jobRunsTestData { + t.Helper() + var td jobRunsTestData + + td.jobAWS = models.ProwJob{ + Name: "periodic-ci-openshift-release-master-nightly-4.16-e2e-aws-ovn", + Release: "4.16", + Variants: pq.StringArray{"aws", "ovn"}, + } + td.jobGCP = models.ProwJob{ + Name: "periodic-ci-openshift-release-master-nightly-4.16-e2e-gcp-sdn", + Release: "4.16", + Variants: pq.StringArray{"gcp", "sdn"}, + } + td.jobOther = models.ProwJob{ + Name: "periodic-ci-openshift-release-master-nightly-4.15-e2e-aws-ovn", + Release: "4.15", + Variants: pq.StringArray{"aws", "ovn"}, + } + for _, job := range []*models.ProwJob{&td.jobAWS, &td.jobGCP, &td.jobOther} { + require.NoError(t, dbc.DB.Create(job).Error) + } + + td.runA1 = createSingleRun(t, dbc, td.jobAWS.ID, "4.16", runSpec{ + timestamp: time.Date(2024, 7, 10, 12, 0, 0, 0, time.UTC), + succeeded: true, + testFailures: 2, + testFlakes: 1, + cluster: "build01", + url: "https://prow.ci/runA1", + }) + td.runA2 = createSingleRun(t, dbc, td.jobAWS.ID, "4.16", runSpec{ + timestamp: time.Date(2024, 7, 12, 12, 0, 0, 0, time.UTC), + testFailures: 5, + testFlakes: 3, + cluster: "build02", + url: "https://prow.ci/runA2", + }) + td.runA3 = createSingleRun(t, dbc, td.jobAWS.ID, "4.16", runSpec{ + timestamp: time.Date(2024, 5, 1, 12, 0, 0, 0, time.UTC), + succeeded: true, + cluster: "build01", + url: "https://prow.ci/runA3", + }) + td.runG1 = createSingleRun(t, dbc, td.jobGCP.ID, "4.16", runSpec{ + timestamp: time.Date(2024, 7, 11, 12, 0, 0, 0, time.UTC), + succeeded: true, + testFailures: 1, + cluster: "build03", + url: "https://prow.ci/runG1", + }) + td.runOther = createSingleRun(t, dbc, td.jobOther.ID, "4.15", runSpec{ + timestamp: time.Date(2024, 7, 10, 12, 0, 0, 0, time.UTC), + succeeded: true, + url: "https://prow.ci/runOther", + }) + td.runOutsideLookback = createSingleRun(t, dbc, td.jobAWS.ID, "4.16", runSpec{ + timestamp: jrLookback.Add(-24 * time.Hour), + succeeded: true, + url: "https://prow.ci/runOld", + }) + td.runAtReportEnd = createSingleRun(t, dbc, td.jobAWS.ID, "4.16", runSpec{ + timestamp: jrReportEnd, + succeeded: true, + url: "https://prow.ci/runAtEnd", + }) + + td.testEtcd = intutil.CreateTest(t, dbc, "openshift-tests.etcd-leader-election") + td.testNetwork = intutil.CreateTest(t, dbc, "openshift-tests.network-connectivity") + td.testUpgrade = intutil.CreateTest(t, dbc, "openshift-tests.upgrade-cluster") + + // runA1: test_failures=2, test_flakes=1 + testExtra1 := intutil.CreateTest(t, dbc, "openshift-tests.extra-failure-1") + intutil.CreateProwJobRunTest(t, dbc, td.runA1.ID, td.runA1.ProwJobID, td.testEtcd.ID, "4.16", td.runA1.Timestamp, int(v1.TestStatusFailure)) + intutil.CreateProwJobRunTest(t, dbc, td.runA1.ID, td.runA1.ProwJobID, testExtra1.ID, "4.16", td.runA1.Timestamp, int(v1.TestStatusFailure)) + intutil.CreateProwJobRunTest(t, dbc, td.runA1.ID, td.runA1.ProwJobID, td.testNetwork.ID, "4.16", td.runA1.Timestamp, int(v1.TestStatusFlake)) + + // runA2: 5 failed, 3 flaked + testExtra2 := intutil.CreateTest(t, dbc, "openshift-tests.extra-failure-2") + testExtra3 := intutil.CreateTest(t, dbc, "openshift-tests.extra-failure-3") + testExtra4 := intutil.CreateTest(t, dbc, "openshift-tests.extra-failure-4") + testFlakeExtra1 := intutil.CreateTest(t, dbc, "openshift-tests.flake-extra-1") + testFlakeExtra2 := intutil.CreateTest(t, dbc, "openshift-tests.flake-extra-2") + intutil.CreateProwJobRunTest(t, dbc, td.runA2.ID, td.runA2.ProwJobID, td.testEtcd.ID, "4.16", td.runA2.Timestamp, int(v1.TestStatusFailure)) + intutil.CreateProwJobRunTest(t, dbc, td.runA2.ID, td.runA2.ProwJobID, td.testNetwork.ID, "4.16", td.runA2.Timestamp, int(v1.TestStatusFailure)) + intutil.CreateProwJobRunTest(t, dbc, td.runA2.ID, td.runA2.ProwJobID, testExtra2.ID, "4.16", td.runA2.Timestamp, int(v1.TestStatusFailure)) + intutil.CreateProwJobRunTest(t, dbc, td.runA2.ID, td.runA2.ProwJobID, testExtra3.ID, "4.16", td.runA2.Timestamp, int(v1.TestStatusFailure)) + intutil.CreateProwJobRunTest(t, dbc, td.runA2.ID, td.runA2.ProwJobID, testExtra4.ID, "4.16", td.runA2.Timestamp, int(v1.TestStatusFailure)) + intutil.CreateProwJobRunTest(t, dbc, td.runA2.ID, td.runA2.ProwJobID, td.testUpgrade.ID, "4.16", td.runA2.Timestamp, int(v1.TestStatusFlake)) + intutil.CreateProwJobRunTest(t, dbc, td.runA2.ID, td.runA2.ProwJobID, testFlakeExtra1.ID, "4.16", td.runA2.Timestamp, int(v1.TestStatusFlake)) + intutil.CreateProwJobRunTest(t, dbc, td.runA2.ID, td.runA2.ProwJobID, testFlakeExtra2.ID, "4.16", td.runA2.Timestamp, int(v1.TestStatusFlake)) + + // runG1: 1 failed + intutil.CreateProwJobRunTest(t, dbc, td.runG1.ID, td.runG1.ProwJobID, td.testUpgrade.ID, "4.16", td.runG1.Timestamp, int(v1.TestStatusFailure)) + + // runA3: upgrade passed + intutil.CreateProwJobRunTest(t, dbc, td.runA3.ID, td.runA3.ProwJobID, td.testUpgrade.ID, "4.16", td.runA3.Timestamp, int(v1.TestStatusSuccess)) + + // Pull request linked to runA1 + td.pr1 = jrCreatePullRequest(t, dbc, "openshift", "origin", 100, "dev1", "abc123", "https://github.com/openshift/origin/pull/100") + jrLinkRunToPR(t, dbc, td.runA1, td.pr1.ID) + + // Annotation on runA2 + jrCreateAnnotation(t, dbc, td.runA2.ID, "4.16", td.runA2.Timestamp, "jira/trt", "TRT-1234") + + return td +} + +// Helpers (prefixed with jr to avoid conflicts with component_readiness_test.go) + +func jrCreatePullRequest(t *testing.T, dbc *db.DB, org, repo string, number int, author, sha, link string) models.ProwPullRequest { + t.Helper() + pr := models.ProwPullRequest{ + Org: org, + Repo: repo, + Number: number, + Author: author, + SHA: sha, + Link: link, + } + require.NoError(t, dbc.DB.Create(&pr).Error) + return pr +} + +func jrLinkRunToPR(t *testing.T, dbc *db.DB, run models.ProwJobRun, prID uint) { + t.Helper() + link := models.ProwJobRunProwPullRequest{ + ProwJobRunID: run.ID, + ProwPullRequestID: prID, + ProwJobRunRelease: run.ProwJobRelease, + ProwJobRunTimestamp: run.Timestamp, + } + require.NoError(t, dbc.DB.Create(&link).Error) +} + +func jrCreateAnnotation(t *testing.T, dbc *db.DB, runID uint, release string, timestamp time.Time, key, value string) { + t.Helper() + ann := models.ProwJobRunAnnotation{ + ProwJobRunID: runID, + Key: key, + Value: value, + ProwJobRunRelease: release, + ProwJobRunTimestamp: timestamp, + } + require.NoError(t, dbc.DB.Create(&ann).Error) +} + +func callJobRunsReport(t *testing.T, dbc *db.DB, release string, filterOpts *filter.FilterOptions, pagination *apitype.Pagination, end time.Time) *apitype.PaginationResult { + t.Helper() + result, err := api.JobsRunsReportFromDB(dbc, filterOpts, release, pagination, end) + require.NoError(t, err) + return result +} + +func jobRunsFromResult(t *testing.T, result *apitype.PaginationResult) []apitype.JobRun { + t.Helper() + runs, ok := result.Rows.([]apitype.JobRun) + require.True(t, ok, "result.Rows should be []apitype.JobRun") + return runs +} + +func defaultFilterOpts() *filter.FilterOptions { + return &filter.FilterOptions{Filter: &filter.Filter{}} +} + +func defaultPagination() *apitype.Pagination { + return &apitype.Pagination{PerPage: 25, Page: 0} +} + +// idInt converts a GORM model uint ID to the int type used in the API response. +func idInt(id uint) int { + return int(id) //nolint:gosec // DB auto-increment IDs are well within int range +} + +func findRunByID(runs []apitype.JobRun, id uint) *apitype.JobRun { + for i := range runs { + if runs[i].ID == idInt(id) { + return &runs[i] + } + } + return nil +} + +func runIDs(runs []apitype.JobRun) []int { + ids := make([]int, len(runs)) + for i, r := range runs { + ids[i] = r.ID + } + return ids +} + +// Tests + +func TestJobRunsReport_BasicQuery(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + result := callJobRunsReport(t, dbc, "4.16", defaultFilterOpts(), defaultPagination(), jrReportEnd) + + assert.Equal(t, int64(4), result.TotalRows, "should have 4 runs for release 4.16 within time window") + + runs := jobRunsFromResult(t, result) + require.Len(t, runs, 4) + + r := findRunByID(runs, td.runA1.ID) + require.NotNil(t, r, "runA1 should be in results") + assert.Equal(t, idInt(td.runA1.ID), r.ID) //nolint:gosec // DB IDs are well within int range + assert.Equal(t, td.jobAWS.Name, r.Job) + assert.Equal(t, "e2e-aws-ovn", r.BriefName, "regexp_replace should strip the periodic prefix") + assert.ElementsMatch(t, pq.StringArray{"aws", "ovn"}, r.Variants) + assert.Equal(t, string(v1.JobSucceeded), string(r.OverallResult)) + assert.Equal(t, "https://prow.ci/runA1", r.URL) + assert.Equal(t, "https://prow.ci/runA1", r.TestGridURL) + assert.True(t, r.Succeeded) + assert.False(t, r.InfrastructureFailure) + assert.False(t, r.KnownFailure) + assert.Equal(t, "build01", r.Cluster) + assert.Equal(t, 2, r.TestFailures) + assert.Equal(t, 1, r.TestFlakes) + + expectedTimestampMs := td.runA1.Timestamp.UnixMilli() + assert.Equal(t, int(expectedTimestampMs), r.Timestamp, "timestamp should be epoch milliseconds") +} + +func TestJobRunsReport_Pagination(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + setupJobRunsTestData(t, dbc) + + opts := defaultFilterOpts() + opts.SortField = "timestamp" + opts.Sort = apitype.SortDescending + + page0 := callJobRunsReport(t, dbc, "4.16", opts, &apitype.Pagination{PerPage: 2, Page: 0}, jrReportEnd) + page1 := callJobRunsReport(t, dbc, "4.16", opts, &apitype.Pagination{PerPage: 2, Page: 1}, jrReportEnd) + + assert.Equal(t, page0.TotalRows, page1.TotalRows, "total rows should be consistent across pages") + assert.Equal(t, int64(4), page0.TotalRows) + + runs0 := jobRunsFromResult(t, page0) + runs1 := jobRunsFromResult(t, page1) + assert.Len(t, runs0, 2) + assert.Len(t, runs1, 2) + + ids0 := runIDs(runs0) + ids1 := runIDs(runs1) + for _, id := range ids0 { + assert.NotContains(t, ids1, id, "pages should not overlap") + } +} + +func TestJobRunsReport_PaginationStableWithTiedSortKey(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + opts := defaultFilterOpts() + opts.SortField = "test_flakes" + opts.Sort = apitype.SortAscending + + var allIDs []int + for page := 0; page < 4; page++ { + result := callJobRunsReport(t, dbc, "4.16", opts, &apitype.Pagination{PerPage: 1, Page: page}, jrReportEnd) + runs := jobRunsFromResult(t, result) + require.Len(t, runs, 1, "page %d should have exactly 1 run", page) + allIDs = append(allIDs, runs[0].ID) + } + + assert.ElementsMatch(t, + []int{idInt(td.runA1.ID), idInt(td.runA2.ID), idInt(td.runA3.ID), idInt(td.runG1.ID)}, + allIDs, + "paginating one-by-one through a tied sort key should return every run exactly once") +} + +func TestJobRunsReport_PaginationDeterministicWithoutSort(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + var allIDs []int + for page := 0; page < 4; page++ { + result := callJobRunsReport(t, dbc, "4.16", defaultFilterOpts(), &apitype.Pagination{PerPage: 1, Page: page}, jrReportEnd) + runs := jobRunsFromResult(t, result) + require.Len(t, runs, 1, "page %d should have exactly 1 run", page) + allIDs = append(allIDs, runs[0].ID) + } + + assert.ElementsMatch(t, + []int{idInt(td.runA1.ID), idInt(td.runA2.ID), idInt(td.runA3.ID), idInt(td.runG1.ID)}, + allIDs, + "paginating without an explicit sort field should still return every run exactly once") +} + +func TestJobRunsReport_NoPagination(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + setupJobRunsTestData(t, dbc) + + result := callJobRunsReport(t, dbc, "4.16", defaultFilterOpts(), nil, jrReportEnd) + runs := jobRunsFromResult(t, result) + + assert.Equal(t, int64(4), result.TotalRows) + assert.Len(t, runs, 4, "nil pagination should return all rows") + assert.Equal(t, 4, result.PageSize) +} + +func TestJobRunsReport_ReleaseFilter(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + t.Run("release 4.16", func(t *testing.T) { + result := callJobRunsReport(t, dbc, "4.16", defaultFilterOpts(), defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + ids := runIDs(runs) + assert.NotContains(t, ids, idInt(td.runOther.ID), "4.15 runs should be excluded") //nolint:gosec // DB IDs are well within int range + }) + + t.Run("release 4.15", func(t *testing.T) { + result := callJobRunsReport(t, dbc, "4.15", defaultFilterOpts(), defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + require.Len(t, runs, 1) + assert.Equal(t, idInt(td.runOther.ID), runs[0].ID) //nolint:gosec // DB IDs are well within int range + }) + + t.Run("empty release returns all", func(t *testing.T) { + result := callJobRunsReport(t, dbc, "", defaultFilterOpts(), defaultPagination(), jrReportEnd) + assert.True(t, result.TotalRows >= 5, "empty release should include runs from all releases within window") + }) +} + +func TestJobRunsReport_TimestampWindow(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + result := callJobRunsReport(t, dbc, "4.16", defaultFilterOpts(), defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + ids := runIDs(runs) + + assert.NotContains(t, ids, idInt(td.runOutsideLookback.ID), "run before jrLookback should be excluded") //nolint:gosec // DB IDs are well within int range + assert.NotContains(t, ids, idInt(td.runAtReportEnd.ID), "run at jrReportEnd should be excluded (uses < not <=)") //nolint:gosec // DB IDs are well within int range + assert.Contains(t, ids, idInt(td.runA1.ID), "run within window should be included") //nolint:gosec // DB IDs are well within int range + assert.Contains(t, ids, idInt(td.runA3.ID), "run near jrLookback boundary but within window should be included") //nolint:gosec // DB IDs are well within int range +} + +func TestJobRunsReport_SortByTimestamp(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + setupJobRunsTestData(t, dbc) + + t.Run("descending", func(t *testing.T) { + opts := &filter.FilterOptions{ + Filter: &filter.Filter{}, + SortField: "timestamp", + Sort: apitype.SortDescending, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + for i := 1; i < len(runs); i++ { + assert.GreaterOrEqual(t, runs[i-1].Timestamp, runs[i].Timestamp, + "runs should be ordered by timestamp descending") + } + }) + + t.Run("ascending", func(t *testing.T) { + opts := &filter.FilterOptions{ + Filter: &filter.Filter{}, + SortField: "timestamp", + Sort: apitype.SortAscending, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + for i := 1; i < len(runs); i++ { + assert.LessOrEqual(t, runs[i-1].Timestamp, runs[i].Timestamp, + "runs should be ordered by timestamp ascending") + } + }) +} + +func TestJobRunsReport_SortByTestFailures(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + opts := &filter.FilterOptions{ + Filter: &filter.Filter{}, + SortField: "test_failures", + Sort: apitype.SortDescending, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + require.NotEmpty(t, runs) + assert.Equal(t, idInt(td.runA2.ID), runs[0].ID, "run with most failures should be first") + for i := 1; i < len(runs); i++ { + assert.GreaterOrEqual(t, runs[i-1].TestFailures, runs[i].TestFailures) + } +} + +func TestJobRunsReport_SortByTestFlakes(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + opts := &filter.FilterOptions{ + Filter: &filter.Filter{}, + SortField: "test_flakes", + Sort: apitype.SortDescending, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + require.NotEmpty(t, runs) + assert.Equal(t, idInt(td.runA2.ID), runs[0].ID, "run with most flakes should be first") + for i := 1; i < len(runs); i++ { + assert.GreaterOrEqual(t, runs[i-1].TestFlakes, runs[i].TestFlakes) + } +} + +func TestJobRunsReport_SortByJob(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + setupJobRunsTestData(t, dbc) + + opts := &filter.FilterOptions{ + Filter: &filter.Filter{}, + SortField: "job", + Sort: apitype.SortAscending, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + for i := 1; i < len(runs); i++ { + assert.LessOrEqual(t, runs[i-1].Job, runs[i].Job, + "runs should be ordered by job name ascending") + } +} + +func TestJobRunsReport_FilterByJob(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + tests := []struct { + name string + operator filter.Operator + value string + wantIDs []int + }{ + { + name: "contains aws", + operator: filter.OperatorContains, + value: "aws", + wantIDs: []int{idInt(td.runA1.ID), idInt(td.runA2.ID), idInt(td.runA3.ID)}, + }, + { + name: "contains gcp", + operator: filter.OperatorContains, + value: "gcp", + wantIDs: []int{idInt(td.runG1.ID)}, + }, + { + name: "equals exact name", + operator: filter.OperatorEquals, + value: td.jobGCP.Name, + wantIDs: []int{idInt(td.runG1.ID)}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "job", Operator: tt.operator, Value: tt.value}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + assert.ElementsMatch(t, tt.wantIDs, runIDs(runs)) + }) + } +} + +func TestJobRunsReport_FilterByTestFailures(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + tests := []struct { + name string + operator filter.Operator + value string + wantIDs []int + }{ + { + name: "greater than 0", + operator: filter.OperatorArithmeticGreaterThan, + value: "0", + wantIDs: []int{idInt(td.runA1.ID), idInt(td.runA2.ID), idInt(td.runG1.ID)}, + }, + { + name: "greater than or equal to 5", + operator: filter.OperatorArithmeticGreaterThanOrEquals, + value: "5", + wantIDs: []int{idInt(td.runA2.ID)}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "test_failures", Operator: tt.operator, Value: tt.value}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + assert.ElementsMatch(t, tt.wantIDs, runIDs(runs)) + }) + } +} + +func TestJobRunsReport_FilterByTestFlakes(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + tests := []struct { + name string + operator filter.Operator + value string + wantIDs []int + }{ + { + name: "greater than 0", + operator: filter.OperatorArithmeticGreaterThan, + value: "0", + wantIDs: []int{idInt(td.runA1.ID), idInt(td.runA2.ID)}, + }, + { + name: "equals 0", + operator: filter.OperatorArithmeticEquals, + value: "0", + wantIDs: []int{idInt(td.runA3.ID), idInt(td.runG1.ID)}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "test_flakes", Operator: tt.operator, Value: tt.value}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + assert.ElementsMatch(t, tt.wantIDs, runIDs(runs)) + }) + } +} + +func TestJobRunsReport_FilterByFailedTestNames(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + tests := []struct { + name string + operator filter.Operator + value string + wantIDs []int + }{ + { + name: "contains etcd", + operator: filter.OperatorContains, + value: "etcd", + wantIDs: []int{idInt(td.runA1.ID), idInt(td.runA2.ID)}, + }, + { + name: "isEmpty", + operator: filter.OperatorIsEmpty, + wantIDs: []int{idInt(td.runA3.ID)}, + }, + { + name: "isNotEmpty", + operator: filter.OperatorIsNotEmpty, + wantIDs: []int{idInt(td.runA1.ID), idInt(td.runA2.ID), idInt(td.runG1.ID)}, + }, + { + name: "hasEntry exact name", + operator: filter.OperatorHasEntry, + value: "openshift-tests.etcd-leader-election", + wantIDs: []int{idInt(td.runA1.ID), idInt(td.runA2.ID)}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "failed_test_names", Operator: tt.operator, Value: tt.value}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + assert.ElementsMatch(t, tt.wantIDs, runIDs(runs)) + }) + } +} + +func TestJobRunsReport_FilterByFlakedTestNames(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "flaked_test_names", Operator: filter.OperatorContains, Value: "network"}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + assert.ElementsMatch(t, []int{idInt(td.runA1.ID)}, runIDs(runs), + "only runA1 has a flaked test matching 'network'") +} + +func TestJobRunsReport_FilterByRanTestNames(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "ran_test_names", Operator: filter.OperatorContains, Value: "upgrade"}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + ids := runIDs(runs) + + assert.Contains(t, ids, idInt(td.runA2.ID), "runA2 has upgrade test flaked (status 13)") + assert.Contains(t, ids, idInt(td.runG1.ID), "runG1 has upgrade test failed (status 12)") + assert.Contains(t, ids, idInt(td.runA3.ID), "runA3 has upgrade test passed (status 1)") + assert.NotContains(t, ids, idInt(td.runA1.ID), "runA1 did not run upgrade test") +} + +func TestJobRunsReport_FilterByPRFields(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "pull_request_author", Operator: filter.OperatorEquals, Value: "dev1"}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + require.Len(t, runs, 1) + assert.Equal(t, idInt(td.runA1.ID), runs[0].ID) + assert.Equal(t, "dev1", runs[0].PullRequestAuthor) +} + +func TestJobRunsReport_ORLinkOperator(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "job", Operator: filter.OperatorContains, Value: "gcp"}, + {Field: "test_failures", Operator: filter.OperatorArithmeticGreaterThanOrEquals, Value: "5"}, + }, + LinkOperator: filter.LinkOperatorOr, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + ids := runIDs(runs) + + assert.Contains(t, ids, idInt(td.runG1.ID), "GCP run should match the job filter") + assert.Contains(t, ids, idInt(td.runA2.ID), "runA2 with 5 failures should match the test_failures filter") + assert.Len(t, runs, 2) +} + +func TestJobRunsReport_SortByTestNameFieldRejected(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + setupJobRunsTestData(t, dbc) + + for _, field := range []string{"failed_test_names", "flaked_test_names", "ran_test_names"} { + t.Run(field, func(t *testing.T) { + opts := &filter.FilterOptions{ + Filter: &filter.Filter{}, + SortField: field, + Sort: apitype.SortDescending, + } + _, err := api.JobsRunsReportFromDB(dbc, opts, "4.16", defaultPagination(), jrReportEnd) + require.Error(t, err) + var validationErr *api.ValidationError + assert.ErrorAs(t, err, &validationErr, "should return ValidationError") + }) + } +} + +func TestJobRunsReport_UnsupportedTestNameOperator(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + setupJobRunsTestData(t, dbc) + + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "failed_test_names", Operator: filter.OperatorArithmeticGreaterThan, Value: "5"}, + }, + }, + } + _, err := api.JobsRunsReportFromDB(dbc, opts, "4.16", defaultPagination(), jrReportEnd) + require.Error(t, err) + var validationErr *api.ValidationError + assert.ErrorAs(t, err, &validationErr, "unsupported operator should return ValidationError") +} + +func TestJobRunsReport_Enrichment(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + result := callJobRunsReport(t, dbc, "4.16", defaultFilterOpts(), defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + + t.Run("failed and flaked test names", func(t *testing.T) { + r := findRunByID(runs, td.runA1.ID) + require.NotNil(t, r) + + sortedFailed := make([]string, len(r.FailedTestNames)) + copy(sortedFailed, r.FailedTestNames) + sort.Strings(sortedFailed) + + assert.Contains(t, sortedFailed, "openshift-tests.etcd-leader-election", + "failed_test_names should include the failed etcd test") + assert.Contains(t, sortedFailed, "openshift-tests.extra-failure-1", + "failed_test_names should include the extra failure") + + assert.Len(t, r.FlakedTestNames, 1) + assert.Contains(t, r.FlakedTestNames, "openshift-tests.network-connectivity", + "flaked_test_names should include the flaked network test") + }) + + t.Run("pull request data", func(t *testing.T) { + r := findRunByID(runs, td.runA1.ID) + require.NotNil(t, r) + assert.Equal(t, "https://github.com/openshift/origin/pull/100", r.PullRequestLink) + assert.Equal(t, "abc123", r.PullRequestSHA) + assert.Equal(t, "openshift", r.PullRequestOrg) + assert.Equal(t, "origin", r.PullRequestRepo) + assert.Equal(t, "dev1", r.PullRequestAuthor) + }) + + t.Run("no pull request data for unlinked run", func(t *testing.T) { + r := findRunByID(runs, td.runG1.ID) + require.NotNil(t, r) + assert.Empty(t, r.PullRequestLink) + assert.Empty(t, r.PullRequestAuthor) + }) + + t.Run("annotations", func(t *testing.T) { + r := findRunByID(runs, td.runA2.ID) + require.NotNil(t, r) + require.NotNil(t, r.Annotations) + assert.Equal(t, "TRT-1234", r.Annotations["jira/trt"]) + }) + + t.Run("no annotations for unannotated run", func(t *testing.T) { + r := findRunByID(runs, td.runA3.ID) + require.NotNil(t, r) + assert.Empty(t, r.Annotations) + }) +} + +func TestJobRunsReport_NOTNegationExcludesMatchingRuns(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + t.Run("NOT job contains aws returns only GCP run", func(t *testing.T) { + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "job", Operator: filter.OperatorContains, Value: "aws", Not: true}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + assert.ElementsMatch(t, []int{idInt(td.runG1.ID)}, runIDs(runs)) + }) + + t.Run("NOT failed_test_names contains etcd excludes runs with etcd failures", func(t *testing.T) { + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "failed_test_names", Operator: filter.OperatorContains, Value: "etcd", Not: true}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + ids := runIDs(runs) + assert.NotContains(t, ids, idInt(td.runA1.ID), "runA1 has etcd failure, should be excluded") + assert.NotContains(t, ids, idInt(td.runA2.ID), "runA2 has etcd failure, should be excluded") + assert.Contains(t, ids, idInt(td.runG1.ID)) + assert.Contains(t, ids, idInt(td.runA3.ID)) + }) +} + +func TestJobRunsReport_MultipleANDFiltersNarrowResults(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "job", Operator: filter.OperatorContains, Value: "aws"}, + {Field: "test_failures", Operator: filter.OperatorArithmeticGreaterThan, Value: "2"}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + assert.ElementsMatch(t, []int{idInt(td.runA2.ID)}, runIDs(runs), + "only runA2 is an aws job with >2 test failures") +} + +func TestJobRunsReport_SpecialCharactersInFilterValueAreLiteral(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + specialTest := intutil.CreateTest(t, dbc, "openshift-tests.100%_coverage") + intutil.CreateProwJobRunTest(t, dbc, td.runA3.ID, td.runA3.ProwJobID, specialTest.ID, "4.16", td.runA3.Timestamp, int(v1.TestStatusFailure)) + + t.Run("percent in filter is literal", func(t *testing.T) { + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "failed_test_names", Operator: filter.OperatorContains, Value: "100%"}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + assert.ElementsMatch(t, []int{idInt(td.runA3.ID)}, runIDs(runs), + "only runA3 has the test with literal percent in its name") + }) + + t.Run("underscore in filter is literal", func(t *testing.T) { + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "failed_test_names", Operator: filter.OperatorContains, Value: "100_"}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + assert.Empty(t, runs, + "underscore should be literal; no test name contains the exact substring '100_'") + }) + + t.Run("percent in job filter via columnAliases path", func(t *testing.T) { + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "job", Operator: filter.OperatorContains, Value: "4.16-e2e_"}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + assert.Empty(t, runs, "underscore should be literal; no job name contains '4.16-e2e_'") + }) +} + +func TestJobRunsReport_SortByPRFieldDoesNotInflateTotalRows(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + opts := &filter.FilterOptions{ + Filter: &filter.Filter{}, + SortField: "pull_request_author", + Sort: apitype.SortAscending, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + assert.Equal(t, int64(4), result.TotalRows, "LEFT JOIN for PR sort should not inflate TotalRows") + + runs := jobRunsFromResult(t, result) + r := findRunByID(runs, td.runA1.ID) + require.NotNil(t, r) + assert.Equal(t, "dev1", r.PullRequestAuthor, "PR data should be populated via JOIN path") +} + +func TestJobRunsReport_PRDataPopulatedWhenFilteredByPRField(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "pull_request_author", Operator: filter.OperatorEquals, Value: "dev1"}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + require.Len(t, runs, 1) + assert.Equal(t, idInt(td.runA1.ID), runs[0].ID) + assert.Equal(t, "dev1", runs[0].PullRequestAuthor) + assert.Equal(t, "https://github.com/openshift/origin/pull/100", runs[0].PullRequestLink) + assert.Equal(t, "abc123", runs[0].PullRequestSHA) + assert.Equal(t, "openshift", runs[0].PullRequestOrg) + assert.Equal(t, "origin", runs[0].PullRequestRepo) +} + +func TestJobRunsReport_ZeroMatchingRowsReturnsEmptyResult(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + setupJobRunsTestData(t, dbc) + + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "job", Operator: filter.OperatorEquals, Value: "nonexistent-job"}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + assert.Equal(t, int64(0), result.TotalRows) + runs := jobRunsFromResult(t, result) + assert.Empty(t, runs) +} + +func TestJobRunsReport_RunExactlyAtLookbackBoundaryIsIncluded(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + runAtBoundary := createSingleRun(t, dbc, td.jobAWS.ID, "4.16", runSpec{ + timestamp: jrLookback, + succeeded: true, + url: "https://prow.ci/runAtBoundary", + }) + + result := callJobRunsReport(t, dbc, "4.16", defaultFilterOpts(), defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + ids := runIDs(runs) + assert.Contains(t, ids, idInt(runAtBoundary.ID), + "run at exactly the jrLookback boundary should be included (>= semantics)") +} + +func TestJobRunsReport_RunWithNoTestFailuresHasEmptyTestNames(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + result := callJobRunsReport(t, dbc, "4.16", defaultFilterOpts(), defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + + r := findRunByID(runs, td.runA3.ID) + require.NotNil(t, r, "runA3 should be in results") + assert.Equal(t, 0, r.TestFailures) + assert.Equal(t, 0, r.TestFlakes) + assert.Empty(t, r.FailedTestNames, "run with no failures should have empty failed test names") + assert.Empty(t, r.FlakedTestNames, "run with no flakes should have empty flaked test names") +} + +func TestJobRunsReport_StartsWithAndEndsWithFilterTestNames(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + t.Run("startsWith matches prefix", func(t *testing.T) { + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "failed_test_names", Operator: filter.OperatorStartsWith, Value: "openshift-tests.etcd"}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + assert.ElementsMatch(t, []int{idInt(td.runA1.ID), idInt(td.runA2.ID)}, runIDs(runs), + "only runA1 and runA2 have failed tests starting with 'openshift-tests.etcd'") + }) + + t.Run("endsWith matches suffix", func(t *testing.T) { + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "failed_test_names", Operator: filter.OperatorEndsWith, Value: "leader-election"}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + assert.ElementsMatch(t, []int{idInt(td.runA1.ID), idInt(td.runA2.ID)}, runIDs(runs), + "only runA1 and runA2 have failed tests ending with 'leader-election'") + }) +} + +func TestJobRunsReport_MultipleAnnotationsOnOneRun(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + jrCreateAnnotation(t, dbc, td.runA2.ID, "4.16", td.runA2.Timestamp, "group/team", "team-alpha") + + result := callJobRunsReport(t, dbc, "4.16", defaultFilterOpts(), defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + + r := findRunByID(runs, td.runA2.ID) + require.NotNil(t, r) + require.NotNil(t, r.Annotations) + assert.Equal(t, "TRT-1234", r.Annotations["jira/trt"]) + assert.Equal(t, "team-alpha", r.Annotations["group/team"]) +} + +func TestJobRunsReport_MultiplePRsOnOneRunReturnsSingleRun(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + pr2 := jrCreatePullRequest(t, dbc, "openshift", "installer", 200, "dev2", "def456", "https://github.com/openshift/installer/pull/200") + jrLinkRunToPR(t, dbc, td.runA1, pr2.ID) + + result := callJobRunsReport(t, dbc, "4.16", defaultFilterOpts(), defaultPagination(), jrReportEnd) + assert.Equal(t, int64(4), result.TotalRows, "multiple PRs should not duplicate the run in results") + + runs := jobRunsFromResult(t, result) + count := 0 + for _, r := range runs { + if r.ID == idInt(td.runA1.ID) { + count++ + } + } + assert.Equal(t, 1, count, "runA1 should appear exactly once despite having two linked PRs") +} + +func TestJobRunsReport_CrossReleaseAnnotationsExcluded(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + jrCreateAnnotation(t, dbc, td.runA1.ID, "4.15", td.runA1.Timestamp, "jira/wrong-release", "TRT-9999") + + result := callJobRunsReport(t, dbc, "4.16", defaultFilterOpts(), defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + + r := findRunByID(runs, td.runA1.ID) + require.NotNil(t, r) + if r.Annotations != nil { + assert.Empty(t, r.Annotations["jira/wrong-release"], + "annotation for release 4.15 should not appear in 4.16 query results") + } +} + +// Priority 1: Missing arithmetic operators + +func TestJobRunsReport_FilterByTestFailuresLessThan(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + tests := []struct { + name string + operator filter.Operator + value string + expected []uint + }{ + { + name: "less than 2 returns runs with 0 and 1 failures", + operator: filter.OperatorArithmeticLessThan, + value: "2", + expected: []uint{td.runA3.ID, td.runG1.ID}, + }, + { + name: "less than or equal 1 returns runs with 0 and 1 failures", + operator: filter.OperatorArithmeticLessThanOrEquals, + value: "1", + expected: []uint{td.runA3.ID, td.runG1.ID}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "test_failures", Operator: tc.operator, Value: tc.value}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + var expectedIDs []int + for _, id := range tc.expected { + expectedIDs = append(expectedIDs, idInt(id)) + } + assert.ElementsMatch(t, expectedIDs, runIDs(runs)) + }) + } +} + +func TestJobRunsReport_FilterByTestFailuresNotEquals(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "test_failures", Operator: filter.OperatorArithmeticNotEquals, Value: "0"}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + assert.ElementsMatch(t, + []int{idInt(td.runA1.ID), idInt(td.runA2.ID), idInt(td.runG1.ID)}, + runIDs(runs), + "should exclude runA3 which has 0 test failures") +} + +// Priority 2: Untested filterable fields + +func TestJobRunsReport_FilterByCluster(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + tests := []struct { + name string + cluster string + expected []uint + }{ + { + name: "build01 returns two AWS runs", + cluster: "build01", + expected: []uint{td.runA1.ID, td.runA3.ID}, + }, + { + name: "build03 returns only GCP run", + cluster: "build03", + expected: []uint{td.runG1.ID}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "cluster", Operator: filter.OperatorEquals, Value: tc.cluster}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + var expectedIDs []int + for _, id := range tc.expected { + expectedIDs = append(expectedIDs, idInt(id)) + } + assert.ElementsMatch(t, expectedIDs, runIDs(runs)) + }) + } +} + +func TestJobRunsReport_FilterByBriefName(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "brief_name", Operator: filter.OperatorContains, Value: "e2e-aws"}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + assert.ElementsMatch(t, + []int{idInt(td.runA1.ID), idInt(td.runA2.ID), idInt(td.runA3.ID)}, + runIDs(runs), + "brief_name 'e2e-aws-ovn' matches all AWS job runs") +} + +func TestJobRunsReport_FilterByOverallResult(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + tests := []struct { + name string + result string + expected []uint + }{ + { + name: "succeeded runs", + result: string(v1.JobSucceeded), + expected: []uint{td.runA1.ID, td.runA3.ID, td.runG1.ID}, + }, + { + name: "test failure runs", + result: string(v1.JobTestFailure), + expected: []uint{td.runA2.ID}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "overall_result", Operator: filter.OperatorEquals, Value: tc.result}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + var expectedIDs []int + for _, id := range tc.expected { + expectedIDs = append(expectedIDs, idInt(id)) + } + assert.ElementsMatch(t, expectedIDs, runIDs(runs)) + }) + } +} + +func TestJobRunsReport_FilterByVariants(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + tests := []struct { + name string + variant string + expected []uint + }{ + { + name: "aws variant returns all AWS job runs", + variant: "aws", + expected: []uint{td.runA1.ID, td.runA2.ID, td.runA3.ID}, + }, + { + name: "sdn variant returns only GCP run", + variant: "sdn", + expected: []uint{td.runG1.ID}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "variants", Operator: filter.OperatorHasEntry, Value: tc.variant}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + var expectedIDs []int + for _, id := range tc.expected { + expectedIDs = append(expectedIDs, idInt(id)) + } + assert.ElementsMatch(t, expectedIDs, runIDs(runs)) + }) + } +} + +// Priority 3: Edge cases + +func TestJobRunsReport_FilterByTimestampEpoch(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + cutoff := time.Date(2024, 7, 1, 0, 0, 0, 0, time.UTC) + cutoffEpochMs := fmt.Sprintf("%d", cutoff.UnixMilli()) + + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "timestamp", Operator: filter.OperatorArithmeticGreaterThan, Value: cutoffEpochMs}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + assert.ElementsMatch(t, + []int{idInt(td.runA1.ID), idInt(td.runA2.ID), idInt(td.runG1.ID)}, + runIDs(runs), + "should return July runs, excluding May runA3") +} + +func TestJobRunsReport_ORCombiningTestNameAndColumnAlias(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "flaked_test_names", Operator: filter.OperatorContains, Value: "upgrade"}, + {Field: "job", Operator: filter.OperatorContains, Value: "gcp"}, + }, + LinkOperator: filter.LinkOperatorOr, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + assert.ElementsMatch(t, + []int{idInt(td.runA2.ID), idInt(td.runG1.ID)}, + runIDs(runs), + "runA2 has upgrade as flake, runG1 matches GCP job name") +} + +func TestJobRunsReport_PaginationBeyondResults(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + setupJobRunsTestData(t, dbc) + + result := callJobRunsReport(t, dbc, "4.16", defaultFilterOpts(), &apitype.Pagination{PerPage: 25, Page: 100}, jrReportEnd) + assert.Equal(t, int64(4), result.TotalRows, "total should still reflect all matching rows") + runs := jobRunsFromResult(t, result) + assert.Empty(t, runs, "page beyond results should return no rows") +} + +func TestJobRunsReport_PaginationPartialLastPage(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + setupJobRunsTestData(t, dbc) + + result := callJobRunsReport(t, dbc, "4.16", defaultFilterOpts(), &apitype.Pagination{PerPage: 3, Page: 1}, jrReportEnd) + assert.Equal(t, int64(4), result.TotalRows, "total rows should be 4") + runs := jobRunsFromResult(t, result) + assert.Len(t, runs, 1, "partial last page should have exactly 1 run") +} + +func TestJobRunsReport_HasEntryContainingOnTestNames(t *testing.T) { + dbc := intutil.NewTestDB(t, pgContainer) + td := setupJobRunsTestData(t, dbc) + + opts := &filter.FilterOptions{ + Filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "failed_test_names", Operator: filter.OperatorHasEntryContaining, Value: "etcd"}, + }, + }, + } + result := callJobRunsReport(t, dbc, "4.16", opts, defaultPagination(), jrReportEnd) + runs := jobRunsFromResult(t, result) + assert.ElementsMatch(t, + []int{idInt(td.runA1.ID), idInt(td.runA2.ID)}, + runIDs(runs), + "hasEntryContaining should match runs with failed tests containing 'etcd'") +} diff --git a/test/integration/jobs_test.go b/test/integration/jobs_test.go index 209bbfdfd7..43ee8c54a9 100644 --- a/test/integration/jobs_test.go +++ b/test/integration/jobs_test.go @@ -731,7 +731,10 @@ type runSpec struct { succeeded bool infraFailure bool duration time.Duration + testFailures int + testFlakes int cluster string + url string } // createRuns inserts ProwJobRun records for the given job using the provided specs. @@ -753,7 +756,10 @@ func createSingleRun(t *testing.T, dbc *db.DB, jobID uint, release string, spec Failed: !spec.succeeded, InfrastructureFailure: spec.infraFailure, Duration: spec.duration, + TestFailures: spec.testFailures, + TestFlakes: spec.testFlakes, Cluster: spec.cluster, + URL: spec.url, OverallResult: v1.JobSucceeded, } if spec.infraFailure { @@ -987,6 +993,46 @@ func TestListFilteredJobIDs(t *testing.T) { wantIDs: []int{int(gcpJob.ID)}, //nolint:gosec checkIDs: true, }, + { + name: "hasEntry on variants matches shared variant", + filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "variants", Operator: filter.OperatorHasEntry, Value: "ovn"}, + }, + }, + wantLen: 2, + checkIDs: false, + }, + { + name: "hasEntry on variants matches unique variant", + filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "variants", Operator: filter.OperatorHasEntry, Value: "aws"}, + }, + }, + wantIDs: []int{int(awsJob.ID)}, //nolint:gosec + checkIDs: true, + }, + { + name: "NOT hasEntry excludes jobs with that variant", + filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "variants", Operator: filter.OperatorHasEntry, Value: "aws", Not: true}, + }, + }, + wantIDs: []int{int(gcpJob.ID)}, //nolint:gosec + checkIDs: true, + }, + { + name: "NOT hasEntry on shared variant excludes all jobs", + filter: &filter.Filter{ + Items: []filter.FilterItem{ + {Field: "variants", Operator: filter.OperatorHasEntry, Value: "ovn", Not: true}, + }, + }, + wantLen: 0, + checkIDs: false, + }, } for _, tt := range tests {