From 290f94ee4bb6d520f64984e738a14bca9e33188d Mon Sep 17 00:00:00 2001 From: Brady Pratt Date: Mon, 2 Mar 2026 16:37:42 -0600 Subject: [PATCH 1/7] fix(ui): show warning icons for missing builds/tests in progress stepper Use PatternFly's warning variant on the Builds ready and Tests passed steps when no snapshot or test data exists. Increase spacing between the signal row and progress stepper. Co-Authored-By: Claude Opus 4.6 (1M context) --- web/src/pages/ReleaseDetail.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/web/src/pages/ReleaseDetail.tsx b/web/src/pages/ReleaseDetail.tsx index d177a1c..9595db8 100644 --- a/web/src/pages/ReleaseDetail.tsx +++ b/web/src/pages/ReleaseDetail.tsx @@ -393,8 +393,8 @@ function ReleaseSignal({ const qeSignOff = allTestsPassed && (bugsVerified || issueSummary === null); const progressItems = [ - { label: "Builds ready", done: buildsReady }, - { label: "Tests passed", done: allTestsPassed }, + { label: "Builds ready", done: buildsReady, warning: snapshot === null }, + { label: "Tests passed", done: allTestsPassed, warning: !hasTests }, ...(issueSummary ? [{ label: "Bugs verified", done: bugsVerified }] : []), { label: "QE sign off", done: qeSignOff }, ]; @@ -452,11 +452,17 @@ function ReleaseSignal({ )} - + {progressItems.map((item, idx) => ( Date: Mon, 2 Mar 2026 17:26:20 -0600 Subject: [PATCH 2/7] refactor: replace JUnit test processing with CTRF format Resolve merge conflicts from stashed CTRF refactor against the has_tests bug fix. Replace snapshot_test_results with test_suites and test_cases tables, simplify SnapshotRecord by removing trigger/released fields, and preserve the has_tests computed field for missing-test detection. - Add internal/ctrf package for CTRF report types - Update S3 sync to discover and parse CTRF reports - Replace JUnit XML parsing pipeline entirely - Update frontend to display test suites with tool info - Regenerate sqlc from updated schema and queries Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Brady Pratt --- CLAUDE.md | 2 +- internal/ctrf/ctrf.go | 45 +++++ internal/db/queries/snapshots.sql | 42 ++-- internal/db/schema.sql | 48 +++-- internal/db/snapshots.go | 154 +++++++++------ internal/db/sqlc/models.go | 44 +++-- internal/db/sqlc/snapshots.sql.go | 265 ++++++++++++++++---------- internal/junit/parser.go | 136 ------------- internal/junit/parser_test.go | 105 ---------- internal/konflux/snapshot.go | 136 ++----------- internal/konflux/snapshot_test.go | 145 ++------------ internal/model/model.go | 67 ++++--- internal/model/snapshot.go | 64 +------ internal/s3/client.go | 75 ++++---- internal/s3/sync.go | 97 ++++++---- internal/server/handlers_api.go | 2 +- internal/server/handlers_api_test.go | 8 +- web/src/api/types.ts | 37 +++- web/src/components/TestCasesTable.tsx | 243 +++++++++++++++++++++++ web/src/pages/ReleaseDetail.tsx | 80 ++------ web/src/pages/SnapshotsList.tsx | 78 ++------ 21 files changed, 875 insertions(+), 998 deletions(-) create mode 100644 internal/ctrf/ctrf.go delete mode 100644 internal/junit/parser.go delete mode 100644 internal/junit/parser_test.go create mode 100644 web/src/components/TestCasesTable.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 1865070..7d5562e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ The Vite dev server proxies `/api` requests to `localhost:8088` (the Go backend) - **`internal/s3/`** — AWS SDK v2 client for fetching snapshot data from S3/Garage object storage. - **`internal/jira/`** — JIRA REST API client. Discovers active releases, syncs issues by fixVersion. - **`internal/model/`** — Shared data types used across packages. -- **`internal/junit/`** — JUnit XML test result parser. +- **`internal/ctrf/`** — CTRF (Common Test Report Format) JSON types. ### Frontend (`web/`) - React 19 + TypeScript, built with Vite 6 diff --git a/internal/ctrf/ctrf.go b/internal/ctrf/ctrf.go new file mode 100644 index 0000000..a4ccded --- /dev/null +++ b/internal/ctrf/ctrf.go @@ -0,0 +1,45 @@ +package ctrf + +// Report is the top-level CTRF JSON structure. +type Report struct { + Results Results `json:"results"` +} + +// Results contains the tool info, summary, and individual test outcomes. +type Results struct { + Tool Tool `json:"tool"` + Summary Summary `json:"summary"` + Tests []Test `json:"tests"` +} + +// Tool identifies the test runner that produced the report. +type Tool struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// Summary holds aggregate counts from a CTRF report. +type Summary struct { + Tests int `json:"tests"` + Passed int `json:"passed"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` + Pending int `json:"pending"` + Other int `json:"other"` + Flaky int `json:"flaky"` + Start int64 `json:"start"` + Stop int64 `json:"stop"` +} + +// Test represents a single test case outcome in a CTRF report. +type Test struct { + Name string `json:"name"` + Status string `json:"status"` + Duration float64 `json:"duration"` + Message string `json:"message,omitempty"` + Trace string `json:"trace,omitempty"` + FilePath string `json:"filePath,omitempty"` + Suite string `json:"suite,omitempty"` + Retries int `json:"retries,omitempty"` + Flaky bool `json:"flaky,omitempty"` +} diff --git a/internal/db/queries/snapshots.sql b/internal/db/queries/snapshots.sql index 8d0fc4e..c6f4ade 100644 --- a/internal/db/queries/snapshots.sql +++ b/internal/db/queries/snapshots.sql @@ -1,13 +1,12 @@ -- name: CreateSnapshot :execlastid -INSERT INTO snapshots (application, name, trigger_component, trigger_git_sha, trigger_pipeline_run, tests_passed, released, release_blocked_reason, created_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); +INSERT INTO snapshots (application, name, tests_passed, created_at) +VALUES (?, ?, ?, ?); -- name: SnapshotExistsByName :one SELECT COUNT(*) FROM snapshots WHERE name = ?; -- name: GetSnapshotRow :one -SELECT id, application, name, trigger_component, trigger_git_sha, trigger_pipeline_run, - tests_passed, released, release_blocked_reason, created_at +SELECT id, application, name, tests_passed, created_at FROM snapshots WHERE name = ?; -- name: CreateSnapshotComponent :exec @@ -21,22 +20,19 @@ WHERE snapshot_id = ? ORDER BY component; -- name: ListAllSnapshots :many -SELECT id, application, name, trigger_component, trigger_git_sha, trigger_pipeline_run, - tests_passed, released, release_blocked_reason, created_at +SELECT id, application, name, tests_passed, created_at FROM snapshots ORDER BY id DESC LIMIT ? OFFSET ?; -- name: ListSnapshotsByApplication :many -SELECT id, application, name, trigger_component, trigger_git_sha, trigger_pipeline_run, - tests_passed, released, release_blocked_reason, created_at +SELECT id, application, name, tests_passed, created_at FROM snapshots WHERE application = ? ORDER BY id DESC LIMIT ? OFFSET ?; -- name: LatestSnapshotPerApplication :many -SELECT s.id, s.application, s.name, s.trigger_component, s.trigger_git_sha, s.trigger_pipeline_run, - s.tests_passed, s.released, s.release_blocked_reason, s.created_at, CAST(counts.cnt AS INTEGER) AS cnt, - (SELECT COUNT(*) FROM snapshot_test_results WHERE snapshot_id = s.id) AS test_count +SELECT s.id, s.application, s.name, s.tests_passed, s.created_at, CAST(counts.cnt AS INTEGER) AS cnt, + (SELECT COUNT(*) FROM test_suites WHERE snapshot_id = s.id) AS test_count FROM snapshots s JOIN ( SELECT application, MAX(id) AS max_id, COUNT(*) AS cnt @@ -45,12 +41,22 @@ JOIN ( ) counts ON s.id = counts.max_id ORDER BY s.application; --- name: CreateSnapshotTestResult :exec -INSERT INTO snapshot_test_results (snapshot_id, scenario, status, pipeline_run, total, passed, failed, skipped, duration_sec) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); +-- name: CreateTestSuite :execlastid +INSERT INTO test_suites (snapshot_id, name, status, pipeline_run, tool_name, tool_version, tests, passed, failed, skipped, pending, other, flaky, start_time, stop_time, duration_ms) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); --- name: ListSnapshotTestResults :many -SELECT id, snapshot_id, scenario, status, pipeline_run, total, passed, failed, skipped, duration_sec, created_at -FROM snapshot_test_results +-- name: CreateTestCase :exec +INSERT INTO test_cases (test_suite_id, name, status, duration_ms, message, trace, file_path, suite, retries, flaky) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + +-- name: ListTestSuitesBySnapshot :many +SELECT id, snapshot_id, name, status, pipeline_run, tool_name, tool_version, tests, passed, failed, skipped, pending, other, flaky, start_time, stop_time, duration_ms, created_at +FROM test_suites WHERE snapshot_id = ? -ORDER BY scenario; +ORDER BY name; + +-- name: ListTestCasesBySuite :many +SELECT id, test_suite_id, name, status, duration_ms, message, trace, file_path, suite, retries, flaky +FROM test_cases +WHERE test_suite_id = ? +ORDER BY name; diff --git a/internal/db/schema.sql b/internal/db/schema.sql index 61f2d24..8cf13b3 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -6,36 +6,54 @@ CREATE TABLE IF NOT EXISTS components ( ); CREATE TABLE IF NOT EXISTS snapshots ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - application TEXT NOT NULL, - name TEXT NOT NULL UNIQUE, - trigger_component TEXT NOT NULL DEFAULT '', - trigger_git_sha TEXT NOT NULL DEFAULT '', - trigger_pipeline_run TEXT NOT NULL DEFAULT '', - tests_passed INTEGER NOT NULL DEFAULT 0, - released INTEGER NOT NULL DEFAULT 0, - release_blocked_reason TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + application TEXT NOT NULL, + name TEXT NOT NULL UNIQUE, + tests_passed INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) ); CREATE INDEX IF NOT EXISTS idx_snapshots_application ON snapshots(application); CREATE INDEX IF NOT EXISTS idx_snapshots_created ON snapshots(created_at DESC); -CREATE TABLE IF NOT EXISTS snapshot_test_results ( +CREATE TABLE IF NOT EXISTS test_suites ( id INTEGER PRIMARY KEY AUTOINCREMENT, snapshot_id INTEGER NOT NULL REFERENCES snapshots(id) ON DELETE CASCADE, - scenario TEXT NOT NULL, + name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'unknown', pipeline_run TEXT NOT NULL DEFAULT '', - total INTEGER NOT NULL DEFAULT 0, + tool_name TEXT NOT NULL DEFAULT '', + tool_version TEXT NOT NULL DEFAULT '', + tests INTEGER NOT NULL DEFAULT 0, passed INTEGER NOT NULL DEFAULT 0, failed INTEGER NOT NULL DEFAULT 0, skipped INTEGER NOT NULL DEFAULT 0, - duration_sec REAL NOT NULL DEFAULT 0.0, + pending INTEGER NOT NULL DEFAULT 0, + other INTEGER NOT NULL DEFAULT 0, + flaky INTEGER NOT NULL DEFAULT 0, + start_time INTEGER NOT NULL DEFAULT 0, + stop_time INTEGER NOT NULL DEFAULT 0, + duration_ms INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) ); -CREATE INDEX IF NOT EXISTS idx_snapshot_test_results_snapshot ON snapshot_test_results(snapshot_id); +CREATE INDEX IF NOT EXISTS idx_test_suites_snapshot ON test_suites(snapshot_id); + +CREATE TABLE IF NOT EXISTS test_cases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + test_suite_id INTEGER NOT NULL REFERENCES test_suites(id) ON DELETE CASCADE, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'unknown', + duration_ms REAL NOT NULL DEFAULT 0.0, + message TEXT NOT NULL DEFAULT '', + trace TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL DEFAULT '', + suite TEXT NOT NULL DEFAULT '', + retries INTEGER NOT NULL DEFAULT 0, + flaky INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_test_cases_suite ON test_cases(test_suite_id); CREATE TABLE IF NOT EXISTS snapshot_components ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/internal/db/snapshots.go b/internal/db/snapshots.go index 6b1cef5..d8b2a88 100644 --- a/internal/db/snapshots.go +++ b/internal/db/snapshots.go @@ -8,32 +8,22 @@ import ( "github.com/quay/release-readiness/internal/model" ) -func (d *DB) CreateSnapshot(ctx context.Context, application, name, triggerComponent, triggerGitSHA, triggerPipelineRun string, testsPassed, released bool, releaseBlockedReason string, createdAt time.Time) (*model.SnapshotRecord, error) { +func (d *DB) CreateSnapshot(ctx context.Context, application, name string, testsPassed bool, createdAt time.Time) (*model.SnapshotRecord, error) { id, err := d.queries().CreateSnapshot(ctx, dbsqlc.CreateSnapshotParams{ - Application: application, - Name: name, - TriggerComponent: triggerComponent, - TriggerGitSha: triggerGitSHA, - TriggerPipelineRun: triggerPipelineRun, - TestsPassed: boolToInt64(testsPassed), - Released: boolToInt64(released), - ReleaseBlockedReason: releaseBlockedReason, - CreatedAt: createdAt.UTC().Format(time.RFC3339), + Application: application, + Name: name, + TestsPassed: boolToInt64(testsPassed), + CreatedAt: createdAt.UTC().Format(time.RFC3339), }) if err != nil { return nil, err } return &model.SnapshotRecord{ - ID: id, - Application: application, - Name: name, - TriggerComponent: triggerComponent, - TriggerGitSHA: triggerGitSHA, - TriggerPipelineRun: triggerPipelineRun, - TestsPassed: testsPassed, - Released: released, - ReleaseBlockedReason: releaseBlockedReason, - CreatedAt: createdAt.UTC(), + ID: id, + Application: application, + Name: name, + TestsPassed: testsPassed, + CreatedAt: createdAt.UTC(), }, nil } @@ -58,12 +48,19 @@ func (d *DB) GetSnapshotByName(ctx context.Context, name string) (*model.Snapsho } s.Components = components - results, err := d.ListSnapshotTestResults(ctx, s.ID) + suites, err := d.ListTestSuites(ctx, s.ID) if err != nil { return nil, err } - s.TestResults = results - s.HasTests = len(results) > 0 + for i, suite := range suites { + cases, err := d.ListTestCases(ctx, suite.ID) + if err != nil { + return nil, err + } + suites[i].TestCases = cases + } + s.TestSuites = suites + s.HasTests = len(suites) > 0 return &s, nil } @@ -130,17 +127,12 @@ func (d *DB) LatestSnapshotPerApplication(ctx context.Context) ([]model.Applicat summaries := make([]model.ApplicationSummary, len(rows)) for i, r := range rows { s := model.SnapshotRecord{ - ID: r.ID, - Application: r.Application, - Name: r.Name, - TriggerComponent: r.TriggerComponent, - TriggerGitSHA: r.TriggerGitSha, - TriggerPipelineRun: r.TriggerPipelineRun, - TestsPassed: r.TestsPassed == 1, - HasTests: r.TestCount > 0, - Released: r.Released == 1, - ReleaseBlockedReason: r.ReleaseBlockedReason, - CreatedAt: parseTime(r.CreatedAt), + ID: r.ID, + Application: r.Application, + Name: r.Name, + TestsPassed: r.TestsPassed == 1, + HasTests: r.TestCount > 0, + CreatedAt: parseTime(r.CreatedAt), } summaries[i] = model.ApplicationSummary{ Application: r.Application, @@ -151,55 +143,103 @@ func (d *DB) LatestSnapshotPerApplication(ctx context.Context) ([]model.Applicat return summaries, nil } -func (d *DB) CreateSnapshotTestResult(ctx context.Context, snapshotID int64, scenario, status, pipelineRun string, total, passed, failed, skipped int, durationSec float64) error { - return d.queries().CreateSnapshotTestResult(ctx, dbsqlc.CreateSnapshotTestResultParams{ +func (d *DB) CreateTestSuite(ctx context.Context, snapshotID int64, name, status, pipelineRun, toolName, toolVersion string, tests, passed, failed, skipped, pending, other, flaky int, startTime, stopTime, durationMs int64) (int64, error) { + return d.queries().CreateTestSuite(ctx, dbsqlc.CreateTestSuiteParams{ SnapshotID: snapshotID, - Scenario: scenario, + Name: name, Status: status, PipelineRun: pipelineRun, - Total: int64(total), + ToolName: toolName, + ToolVersion: toolVersion, + Tests: int64(tests), Passed: int64(passed), Failed: int64(failed), Skipped: int64(skipped), - DurationSec: durationSec, + Pending: int64(pending), + Other: int64(other), + Flaky: int64(flaky), + StartTime: startTime, + StopTime: stopTime, + DurationMs: durationMs, }) } -func (d *DB) ListSnapshotTestResults(ctx context.Context, snapshotID int64) ([]model.SnapshotTestResult, error) { - rows, err := d.queries().ListSnapshotTestResults(ctx, snapshotID) +func (d *DB) CreateTestCase(ctx context.Context, testSuiteID int64, name, status string, durationMs float64, message, trace, filePath, suite string, retries int, flaky bool) error { + return d.queries().CreateTestCase(ctx, dbsqlc.CreateTestCaseParams{ + TestSuiteID: testSuiteID, + Name: name, + Status: status, + DurationMs: durationMs, + Message: message, + Trace: trace, + FilePath: filePath, + Suite: suite, + Retries: int64(retries), + Flaky: boolToInt64(flaky), + }) +} + +func (d *DB) ListTestSuites(ctx context.Context, snapshotID int64) ([]model.TestSuite, error) { + rows, err := d.queries().ListTestSuitesBySnapshot(ctx, snapshotID) if err != nil { return nil, err } - results := make([]model.SnapshotTestResult, len(rows)) + suites := make([]model.TestSuite, len(rows)) for i, r := range rows { - results[i] = model.SnapshotTestResult{ + suites[i] = model.TestSuite{ ID: r.ID, SnapshotID: r.SnapshotID, - Scenario: r.Scenario, + Name: r.Name, Status: r.Status, PipelineRun: r.PipelineRun, - Total: int(r.Total), + ToolName: r.ToolName, + ToolVersion: r.ToolVersion, + Tests: int(r.Tests), Passed: int(r.Passed), Failed: int(r.Failed), Skipped: int(r.Skipped), - DurationSec: r.DurationSec, + Pending: int(r.Pending), + Other: int(r.Other), + Flaky: int(r.Flaky), + StartTime: r.StartTime, + StopTime: r.StopTime, + DurationMs: r.DurationMs, CreatedAt: parseTime(r.CreatedAt), } } - return results, nil + return suites, nil +} + +func (d *DB) ListTestCases(ctx context.Context, testSuiteID int64) ([]model.TestCase, error) { + rows, err := d.queries().ListTestCasesBySuite(ctx, testSuiteID) + if err != nil { + return nil, err + } + cases := make([]model.TestCase, len(rows)) + for i, r := range rows { + cases[i] = model.TestCase{ + ID: r.ID, + TestSuiteID: r.TestSuiteID, + Name: r.Name, + Status: r.Status, + DurationMs: r.DurationMs, + Message: r.Message, + Trace: r.Trace, + FilePath: r.FilePath, + Suite: r.Suite, + Retries: int(r.Retries), + Flaky: r.Flaky == 1, + } + } + return cases, nil } func toSnapshotRecord(r dbsqlc.Snapshot) model.SnapshotRecord { return model.SnapshotRecord{ - ID: r.ID, - Application: r.Application, - Name: r.Name, - TriggerComponent: r.TriggerComponent, - TriggerGitSHA: r.TriggerGitSha, - TriggerPipelineRun: r.TriggerPipelineRun, - TestsPassed: r.TestsPassed == 1, - Released: r.Released == 1, - ReleaseBlockedReason: r.ReleaseBlockedReason, - CreatedAt: parseTime(r.CreatedAt), + ID: r.ID, + Application: r.Application, + Name: r.Name, + TestsPassed: r.TestsPassed == 1, + CreatedAt: parseTime(r.CreatedAt), } } diff --git a/internal/db/sqlc/models.go b/internal/db/sqlc/models.go index 7240879..b001c71 100644 --- a/internal/db/sqlc/models.go +++ b/internal/db/sqlc/models.go @@ -40,16 +40,11 @@ type ReleaseVersion struct { } type Snapshot struct { - ID int64 - Application string - Name string - TriggerComponent string - TriggerGitSha string - TriggerPipelineRun string - TestsPassed int64 - Released int64 - ReleaseBlockedReason string - CreatedAt string + ID int64 + Application string + Name string + TestsPassed int64 + CreatedAt string } type SnapshotComponent struct { @@ -61,16 +56,37 @@ type SnapshotComponent struct { GitUrl string } -type SnapshotTestResult struct { +type TestCase struct { + ID int64 + TestSuiteID int64 + Name string + Status string + DurationMs float64 + Message string + Trace string + FilePath string + Suite string + Retries int64 + Flaky int64 +} + +type TestSuite struct { ID int64 SnapshotID int64 - Scenario string + Name string Status string PipelineRun string - Total int64 + ToolName string + ToolVersion string + Tests int64 Passed int64 Failed int64 Skipped int64 - DurationSec float64 + Pending int64 + Other int64 + Flaky int64 + StartTime int64 + StopTime int64 + DurationMs int64 CreatedAt string } diff --git a/internal/db/sqlc/snapshots.sql.go b/internal/db/sqlc/snapshots.sql.go index 9a08d87..bd4884f 100644 --- a/internal/db/sqlc/snapshots.sql.go +++ b/internal/db/sqlc/snapshots.sql.go @@ -10,32 +10,22 @@ import ( ) const createSnapshot = `-- name: CreateSnapshot :execlastid -INSERT INTO snapshots (application, name, trigger_component, trigger_git_sha, trigger_pipeline_run, tests_passed, released, release_blocked_reason, created_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO snapshots (application, name, tests_passed, created_at) +VALUES (?, ?, ?, ?) ` type CreateSnapshotParams struct { - Application string - Name string - TriggerComponent string - TriggerGitSha string - TriggerPipelineRun string - TestsPassed int64 - Released int64 - ReleaseBlockedReason string - CreatedAt string + Application string + Name string + TestsPassed int64 + CreatedAt string } func (q *Queries) CreateSnapshot(ctx context.Context, arg CreateSnapshotParams) (int64, error) { result, err := q.db.ExecContext(ctx, createSnapshot, arg.Application, arg.Name, - arg.TriggerComponent, - arg.TriggerGitSha, - arg.TriggerPipelineRun, arg.TestsPassed, - arg.Released, - arg.ReleaseBlockedReason, arg.CreatedAt, ) if err != nil { @@ -68,41 +58,91 @@ func (q *Queries) CreateSnapshotComponent(ctx context.Context, arg CreateSnapsho return err } -const createSnapshotTestResult = `-- name: CreateSnapshotTestResult :exec -INSERT INTO snapshot_test_results (snapshot_id, scenario, status, pipeline_run, total, passed, failed, skipped, duration_sec) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +const createTestCase = `-- name: CreateTestCase :exec +INSERT INTO test_cases (test_suite_id, name, status, duration_ms, message, trace, file_path, suite, retries, flaky) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +` + +type CreateTestCaseParams struct { + TestSuiteID int64 + Name string + Status string + DurationMs float64 + Message string + Trace string + FilePath string + Suite string + Retries int64 + Flaky int64 +} + +func (q *Queries) CreateTestCase(ctx context.Context, arg CreateTestCaseParams) error { + _, err := q.db.ExecContext(ctx, createTestCase, + arg.TestSuiteID, + arg.Name, + arg.Status, + arg.DurationMs, + arg.Message, + arg.Trace, + arg.FilePath, + arg.Suite, + arg.Retries, + arg.Flaky, + ) + return err +} + +const createTestSuite = `-- name: CreateTestSuite :execlastid +INSERT INTO test_suites (snapshot_id, name, status, pipeline_run, tool_name, tool_version, tests, passed, failed, skipped, pending, other, flaky, start_time, stop_time, duration_ms) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` -type CreateSnapshotTestResultParams struct { +type CreateTestSuiteParams struct { SnapshotID int64 - Scenario string + Name string Status string PipelineRun string - Total int64 + ToolName string + ToolVersion string + Tests int64 Passed int64 Failed int64 Skipped int64 - DurationSec float64 + Pending int64 + Other int64 + Flaky int64 + StartTime int64 + StopTime int64 + DurationMs int64 } -func (q *Queries) CreateSnapshotTestResult(ctx context.Context, arg CreateSnapshotTestResultParams) error { - _, err := q.db.ExecContext(ctx, createSnapshotTestResult, +func (q *Queries) CreateTestSuite(ctx context.Context, arg CreateTestSuiteParams) (int64, error) { + result, err := q.db.ExecContext(ctx, createTestSuite, arg.SnapshotID, - arg.Scenario, + arg.Name, arg.Status, arg.PipelineRun, - arg.Total, + arg.ToolName, + arg.ToolVersion, + arg.Tests, arg.Passed, arg.Failed, arg.Skipped, - arg.DurationSec, + arg.Pending, + arg.Other, + arg.Flaky, + arg.StartTime, + arg.StopTime, + arg.DurationMs, ) - return err + if err != nil { + return 0, err + } + return result.LastInsertId() } const getSnapshotRow = `-- name: GetSnapshotRow :one -SELECT id, application, name, trigger_component, trigger_git_sha, trigger_pipeline_run, - tests_passed, released, release_blocked_reason, created_at +SELECT id, application, name, tests_passed, created_at FROM snapshots WHERE name = ? ` @@ -113,21 +153,15 @@ func (q *Queries) GetSnapshotRow(ctx context.Context, name string) (Snapshot, er &i.ID, &i.Application, &i.Name, - &i.TriggerComponent, - &i.TriggerGitSha, - &i.TriggerPipelineRun, &i.TestsPassed, - &i.Released, - &i.ReleaseBlockedReason, &i.CreatedAt, ) return i, err } const latestSnapshotPerApplication = `-- name: LatestSnapshotPerApplication :many -SELECT s.id, s.application, s.name, s.trigger_component, s.trigger_git_sha, s.trigger_pipeline_run, - s.tests_passed, s.released, s.release_blocked_reason, s.created_at, CAST(counts.cnt AS INTEGER) AS cnt, - (SELECT COUNT(*) FROM snapshot_test_results WHERE snapshot_id = s.id) AS test_count +SELECT s.id, s.application, s.name, s.tests_passed, s.created_at, CAST(counts.cnt AS INTEGER) AS cnt, + (SELECT COUNT(*) FROM test_suites WHERE snapshot_id = s.id) AS test_count FROM snapshots s JOIN ( SELECT application, MAX(id) AS max_id, COUNT(*) AS cnt @@ -138,18 +172,13 @@ ORDER BY s.application ` type LatestSnapshotPerApplicationRow struct { - ID int64 - Application string - Name string - TriggerComponent string - TriggerGitSha string - TriggerPipelineRun string - TestsPassed int64 - Released int64 - ReleaseBlockedReason string - CreatedAt string - Cnt int64 - TestCount int64 + ID int64 + Application string + Name string + TestsPassed int64 + CreatedAt string + Cnt int64 + TestCount int64 } func (q *Queries) LatestSnapshotPerApplication(ctx context.Context) ([]LatestSnapshotPerApplicationRow, error) { @@ -165,12 +194,7 @@ func (q *Queries) LatestSnapshotPerApplication(ctx context.Context) ([]LatestSna &i.ID, &i.Application, &i.Name, - &i.TriggerComponent, - &i.TriggerGitSha, - &i.TriggerPipelineRun, &i.TestsPassed, - &i.Released, - &i.ReleaseBlockedReason, &i.CreatedAt, &i.Cnt, &i.TestCount, @@ -189,8 +213,7 @@ func (q *Queries) LatestSnapshotPerApplication(ctx context.Context) ([]LatestSna } const listAllSnapshots = `-- name: ListAllSnapshots :many -SELECT id, application, name, trigger_component, trigger_git_sha, trigger_pipeline_run, - tests_passed, released, release_blocked_reason, created_at +SELECT id, application, name, tests_passed, created_at FROM snapshots ORDER BY id DESC LIMIT ? OFFSET ? ` @@ -213,12 +236,7 @@ func (q *Queries) ListAllSnapshots(ctx context.Context, arg ListAllSnapshotsPara &i.ID, &i.Application, &i.Name, - &i.TriggerComponent, - &i.TriggerGitSha, - &i.TriggerPipelineRun, &i.TestsPassed, - &i.Released, - &i.ReleaseBlockedReason, &i.CreatedAt, ); err != nil { return nil, err @@ -271,33 +289,33 @@ func (q *Queries) ListSnapshotComponents(ctx context.Context, snapshotID int64) return items, nil } -const listSnapshotTestResults = `-- name: ListSnapshotTestResults :many -SELECT id, snapshot_id, scenario, status, pipeline_run, total, passed, failed, skipped, duration_sec, created_at -FROM snapshot_test_results -WHERE snapshot_id = ? -ORDER BY scenario +const listSnapshotsByApplication = `-- name: ListSnapshotsByApplication :many +SELECT id, application, name, tests_passed, created_at +FROM snapshots +WHERE application = ? +ORDER BY id DESC LIMIT ? OFFSET ? ` -func (q *Queries) ListSnapshotTestResults(ctx context.Context, snapshotID int64) ([]SnapshotTestResult, error) { - rows, err := q.db.QueryContext(ctx, listSnapshotTestResults, snapshotID) +type ListSnapshotsByApplicationParams struct { + Application string + Limit int64 + Offset int64 +} + +func (q *Queries) ListSnapshotsByApplication(ctx context.Context, arg ListSnapshotsByApplicationParams) ([]Snapshot, error) { + rows, err := q.db.QueryContext(ctx, listSnapshotsByApplication, arg.Application, arg.Limit, arg.Offset) if err != nil { return nil, err } defer rows.Close() - var items []SnapshotTestResult + var items []Snapshot for rows.Next() { - var i SnapshotTestResult + var i Snapshot if err := rows.Scan( &i.ID, - &i.SnapshotID, - &i.Scenario, - &i.Status, - &i.PipelineRun, - &i.Total, - &i.Passed, - &i.Failed, - &i.Skipped, - &i.DurationSec, + &i.Application, + &i.Name, + &i.TestsPassed, &i.CreatedAt, ); err != nil { return nil, err @@ -313,39 +331,82 @@ func (q *Queries) ListSnapshotTestResults(ctx context.Context, snapshotID int64) return items, nil } -const listSnapshotsByApplication = `-- name: ListSnapshotsByApplication :many -SELECT id, application, name, trigger_component, trigger_git_sha, trigger_pipeline_run, - tests_passed, released, release_blocked_reason, created_at -FROM snapshots -WHERE application = ? -ORDER BY id DESC LIMIT ? OFFSET ? +const listTestCasesBySuite = `-- name: ListTestCasesBySuite :many +SELECT id, test_suite_id, name, status, duration_ms, message, trace, file_path, suite, retries, flaky +FROM test_cases +WHERE test_suite_id = ? +ORDER BY name ` -type ListSnapshotsByApplicationParams struct { - Application string - Limit int64 - Offset int64 +func (q *Queries) ListTestCasesBySuite(ctx context.Context, testSuiteID int64) ([]TestCase, error) { + rows, err := q.db.QueryContext(ctx, listTestCasesBySuite, testSuiteID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []TestCase + for rows.Next() { + var i TestCase + if err := rows.Scan( + &i.ID, + &i.TestSuiteID, + &i.Name, + &i.Status, + &i.DurationMs, + &i.Message, + &i.Trace, + &i.FilePath, + &i.Suite, + &i.Retries, + &i.Flaky, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil } -func (q *Queries) ListSnapshotsByApplication(ctx context.Context, arg ListSnapshotsByApplicationParams) ([]Snapshot, error) { - rows, err := q.db.QueryContext(ctx, listSnapshotsByApplication, arg.Application, arg.Limit, arg.Offset) +const listTestSuitesBySnapshot = `-- name: ListTestSuitesBySnapshot :many +SELECT id, snapshot_id, name, status, pipeline_run, tool_name, tool_version, tests, passed, failed, skipped, pending, other, flaky, start_time, stop_time, duration_ms, created_at +FROM test_suites +WHERE snapshot_id = ? +ORDER BY name +` + +func (q *Queries) ListTestSuitesBySnapshot(ctx context.Context, snapshotID int64) ([]TestSuite, error) { + rows, err := q.db.QueryContext(ctx, listTestSuitesBySnapshot, snapshotID) if err != nil { return nil, err } defer rows.Close() - var items []Snapshot + var items []TestSuite for rows.Next() { - var i Snapshot + var i TestSuite if err := rows.Scan( &i.ID, - &i.Application, + &i.SnapshotID, &i.Name, - &i.TriggerComponent, - &i.TriggerGitSha, - &i.TriggerPipelineRun, - &i.TestsPassed, - &i.Released, - &i.ReleaseBlockedReason, + &i.Status, + &i.PipelineRun, + &i.ToolName, + &i.ToolVersion, + &i.Tests, + &i.Passed, + &i.Failed, + &i.Skipped, + &i.Pending, + &i.Other, + &i.Flaky, + &i.StartTime, + &i.StopTime, + &i.DurationMs, &i.CreatedAt, ); err != nil { return nil, err diff --git a/internal/junit/parser.go b/internal/junit/parser.go deleted file mode 100644 index d6d3520..0000000 --- a/internal/junit/parser.go +++ /dev/null @@ -1,136 +0,0 @@ -package junit - -import ( - "encoding/xml" - "fmt" - "os" -) - -type xmlTestSuites struct { - XMLName xml.Name `xml:"testsuites"` - TestSuites []xmlTestSuite `xml:"testsuite"` -} - -type xmlTestSuite struct { - XMLName xml.Name `xml:"testsuite"` - Name string `xml:"name,attr"` - Tests int `xml:"tests,attr"` - Failures int `xml:"failures,attr"` - Errors int `xml:"errors,attr"` - Skipped int `xml:"skipped,attr"` - Time float64 `xml:"time,attr"` - TestCases []xmlTestCase `xml:"testcase"` -} - -type xmlTestCase struct { - Name string `xml:"name,attr"` - ClassName string `xml:"classname,attr"` - Time float64 `xml:"time,attr"` - Failure *xmlFailure `xml:"failure"` - Error *xmlFailure `xml:"error"` - Skipped *xmlSkipped `xml:"skipped"` -} - -type xmlFailure struct { - Message string `xml:"message,attr"` - Text string `xml:",chardata"` -} - -type xmlSkipped struct { - Message string `xml:"message,attr"` -} - -type TestCase struct { - Name string `json:"name"` - ClassName string `json:"classname"` - DurationSec float64 `json:"duration_sec"` - Status string `json:"status"` - FailureMsg string `json:"failure_msg,omitempty"` - FailureText string `json:"failure_text,omitempty"` -} - -type Result struct { - Total int - Passed int - Failed int - Skipped int - DurationSec float64 - TestCases []TestCase -} - -// ParseFile parses a JUnit XML file and returns aggregated results. -func ParseFile(path string) (*Result, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read %s: %w", path, err) - } - return Parse(data) -} - -// Parse parses JUnit XML data. Handles both and bare roots. -func Parse(data []byte) (*Result, error) { - // Try first - var suites xmlTestSuites - if err := xml.Unmarshal(data, &suites); err == nil && len(suites.TestSuites) > 0 { - return aggregate(suites.TestSuites), nil - } - - // Try bare - var suite xmlTestSuite - if err := xml.Unmarshal(data, &suite); err == nil { - return aggregate([]xmlTestSuite{suite}), nil - } - - return nil, fmt.Errorf("unrecognized JUnit XML format") -} - -func aggregate(suites []xmlTestSuite) *Result { - r := &Result{} - for _, s := range suites { - r.DurationSec += s.Time - for _, tc := range s.TestCases { - c := TestCase{ - Name: tc.Name, - ClassName: tc.ClassName, - DurationSec: tc.Time, - } - - switch { - case tc.Failure != nil: - c.Status = "failed" - c.FailureMsg = tc.Failure.Message - c.FailureText = tc.Failure.Text - r.Failed++ - case tc.Error != nil: - c.Status = "error" - c.FailureMsg = tc.Error.Message - c.FailureText = tc.Error.Text - r.Failed++ - case tc.Skipped != nil: - c.Status = "skipped" - r.Skipped++ - default: - c.Status = "passed" - r.Passed++ - } - - r.Total++ - r.TestCases = append(r.TestCases, c) - } - } - return r -} - -// MergeResults merges multiple Result objects into one. -func MergeResults(results ...*Result) *Result { - merged := &Result{} - for _, r := range results { - merged.Total += r.Total - merged.Passed += r.Passed - merged.Failed += r.Failed - merged.Skipped += r.Skipped - merged.DurationSec += r.DurationSec - merged.TestCases = append(merged.TestCases, r.TestCases...) - } - return merged -} diff --git a/internal/junit/parser_test.go b/internal/junit/parser_test.go deleted file mode 100644 index 7f8d02d..0000000 --- a/internal/junit/parser_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package junit - -import "testing" - -func TestParse_TestSuitesWrapper(t *testing.T) { - xml := ` - - - - assert failed - - - - - - - - ` - - r, err := Parse([]byte(xml)) - if err != nil { - t.Fatal(err) - } - - if r.Total != 3 { - t.Errorf("total: got %d, want 3", r.Total) - } - if r.Passed != 1 { - t.Errorf("passed: got %d, want 1", r.Passed) - } - if r.Failed != 1 { - t.Errorf("failed: got %d, want 1", r.Failed) - } - if r.Skipped != 1 { - t.Errorf("skipped: got %d, want 1", r.Skipped) - } - - // Check failure details - for _, tc := range r.TestCases { - if tc.Name == "test fail" { - if tc.Status != "failed" { - t.Errorf("status: got %q, want failed", tc.Status) - } - if tc.FailureMsg != "expected true" { - t.Errorf("failure msg: got %q", tc.FailureMsg) - } - } - } -} - -func TestParse_BareTestSuite(t *testing.T) { - xml := ` - - - - ` - - r, err := Parse([]byte(xml)) - if err != nil { - t.Fatal(err) - } - - if r.Total != 2 { - t.Errorf("total: got %d, want 2", r.Total) - } - if r.Passed != 2 { - t.Errorf("passed: got %d, want 2", r.Passed) - } -} - -func TestParse_ErrorElement(t *testing.T) { - xml := ` - - stack trace - - ` - - r, err := Parse([]byte(xml)) - if err != nil { - t.Fatal(err) - } - - if r.Failed != 1 { - t.Errorf("failed: got %d, want 1", r.Failed) - } - if r.TestCases[0].Status != "error" { - t.Errorf("status: got %q, want error", r.TestCases[0].Status) - } -} - -func TestMergeResults(t *testing.T) { - a := &Result{Total: 5, Passed: 3, Failed: 1, Skipped: 1, DurationSec: 10} - b := &Result{Total: 3, Passed: 2, Failed: 1, Skipped: 0, DurationSec: 5} - m := MergeResults(a, b) - - if m.Total != 8 { - t.Errorf("total: got %d, want 8", m.Total) - } - if m.Failed != 2 { - t.Errorf("failed: got %d, want 2", m.Failed) - } - if m.DurationSec != 15 { - t.Errorf("duration: got %f, want 15", m.DurationSec) - } -} diff --git a/internal/konflux/snapshot.go b/internal/konflux/snapshot.go index 2a622f1..d4dbff8 100644 --- a/internal/konflux/snapshot.go +++ b/internal/konflux/snapshot.go @@ -1,55 +1,35 @@ package konflux import ( - "encoding/json" - "log" - "strings" - "time" - "github.com/quay/release-readiness/internal/model" ) -// SnapshotCR represents a raw Konflux Snapshot custom resource as stored in S3. -type SnapshotCR struct { - Metadata struct { - Name string `json:"name"` - CreationTimestamp time.Time `json:"creationTimestamp"` - Annotations map[string]string `json:"annotations"` - DeletionTimestamp *time.Time `json:"deletionTimestamp"` - } `json:"metadata"` - Spec struct { - Application string `json:"application"` - Components []struct { - Name string `json:"name"` - ContainerImage string `json:"containerImage"` - Source struct { - Git struct { - URL string `json:"url"` - Revision string `json:"revision"` - } `json:"git"` - } `json:"source"` - } `json:"components"` - } `json:"spec"` -} - -// TestStatus represents a single test scenario status from the Snapshot CR annotation. -type TestStatus struct { - Scenario string `json:"scenario"` - Status string `json:"status"` - LastUpdateTime string `json:"lastUpdateTime"` +// SnapshotSpec is the Konflux Snapshot spec as stored in S3. +// This is the spec section of the Snapshot CR, not the full Kubernetes resource. +type SnapshotSpec struct { + Application string `json:"application"` + Components []struct { + Name string `json:"name"` + ContainerImage string `json:"containerImage"` + Source struct { + Git struct { + URL string `json:"url"` + Revision string `json:"revision"` + } `json:"git"` + } `json:"source"` + } `json:"components"` } -// Convert transforms a raw Konflux SnapshotCR into a model.Snapshot. -// TestResult.Summary fields are left nil; JUnit data is populated separately -// during ingestion. -func Convert(cr SnapshotCR) model.Snapshot { +// Convert transforms a SnapshotSpec into a model.Snapshot. +// The name parameter is the snapshot directory name from S3 (since +// the spec does not include the snapshot name). +func Convert(spec SnapshotSpec, name string) model.Snapshot { snap := model.Snapshot{ - Application: cr.Spec.Application, - Snapshot: cr.Metadata.Name, - CreatedAt: cr.Metadata.CreationTimestamp, + Application: spec.Application, + Snapshot: name, } - for _, c := range cr.Spec.Components { + for _, c := range spec.Components { snap.Components = append(snap.Components, model.SnapshotComponent{ Name: c.Name, ContainerImage: c.ContainerImage, @@ -58,79 +38,5 @@ func Convert(cr SnapshotCR) model.Snapshot { }) } - snap.Trigger = deriveTrigger(cr) - snap.TestResults = parseTestResults(cr) - - allPassed := len(snap.TestResults) > 0 - for _, tr := range snap.TestResults { - if tr.Status != "passed" { - allPassed = false - break - } - } - snap.Readiness = model.Readiness{ - TestsPassed: allPassed, - } - return snap } - -func deriveTrigger(cr SnapshotCR) model.Trigger { - annotations := cr.Metadata.Annotations - t := model.Trigger{ - Component: annotations["build.appstudio.openshift.io/component"], - PipelineRun: annotations["pac.test.appstudio.openshift.io/log-url"], - } - - for _, c := range cr.Spec.Components { - if c.Name == t.Component { - t.GitSHA = c.Source.Git.Revision - break - } - } - - if t.Component == "" && len(cr.Spec.Components) > 0 { - first := cr.Spec.Components[0] - t.Component = first.Name - t.GitSHA = first.Source.Git.Revision - } - - return t -} - -func parseTestResults(cr SnapshotCR) []model.TestResult { - raw, ok := cr.Metadata.Annotations["test.appstudio.openshift.io/status"] - if !ok || raw == "" { - return nil - } - - var statuses []TestStatus - if err := json.Unmarshal([]byte(raw), &statuses); err != nil { - log.Printf("warning: failed to parse test status annotation for %s: %v", cr.Metadata.Name, err) - return nil - } - - results := make([]model.TestResult, 0, len(statuses)) - for _, ts := range statuses { - results = append(results, model.TestResult{ - Scenario: ts.Scenario, - Status: normalizeStatus(ts.Status), - }) - } - return results -} - -// normalizeStatus maps Konflux test status strings to dashboard statuses. -func normalizeStatus(status string) string { - s := strings.ToLower(status) - switch { - case strings.Contains(s, "succeeded") || strings.Contains(s, "passed"): - return "passed" - case strings.Contains(s, "fail") || strings.Contains(s, "error"): - return "failed" - case strings.Contains(s, "inprogress") || strings.Contains(s, "pending"): - return "pending" - default: - return status - } -} diff --git a/internal/konflux/snapshot_test.go b/internal/konflux/snapshot_test.go index 077141f..11fdfb6 100644 --- a/internal/konflux/snapshot_test.go +++ b/internal/konflux/snapshot_test.go @@ -2,15 +2,13 @@ package konflux import ( "testing" - "time" ) -func TestConvert_FieldMapping(t *testing.T) { - cr := SnapshotCR{} - cr.Metadata.Name = "snapshot-abc123" - cr.Metadata.CreationTimestamp = time.Date(2026, 2, 14, 15, 30, 0, 0, time.UTC) - cr.Spec.Application = "quay-v3-17" - cr.Spec.Components = append(cr.Spec.Components, struct { +func TestConvert(t *testing.T) { + spec := SnapshotSpec{ + Application: "quay-v3-17", + } + spec.Components = append(spec.Components, struct { Name string `json:"name"` ContainerImage string `json:"containerImage"` Source struct { @@ -21,27 +19,18 @@ func TestConvert_FieldMapping(t *testing.T) { } `json:"source"` }{ Name: "quay-server", - ContainerImage: "quay.io/quay/quay:sha-abc123", + ContainerImage: "quay.io/quay/quay@sha256:abc123", }) - cr.Spec.Components[0].Source.Git.URL = "https://github.com/quay/quay" - cr.Spec.Components[0].Source.Git.Revision = "abc123def456" - - cr.Metadata.Annotations = map[string]string{ - "build.appstudio.openshift.io/component": "quay-server", - "pac.test.appstudio.openshift.io/log-url": "https://console.example.com/run/123", - "test.appstudio.openshift.io/status": `[{"scenario":"e2e-tests","status":"Succeeded"}]`, - } + spec.Components[0].Source.Git.URL = "https://github.com/quay/quay" + spec.Components[0].Source.Git.Revision = "abc123def456" - snap := Convert(cr) + snap := Convert(spec, "my-snapshot-name") if snap.Application != "quay-v3-17" { t.Errorf("Application = %q, want %q", snap.Application, "quay-v3-17") } - if snap.Snapshot != "snapshot-abc123" { - t.Errorf("Snapshot = %q, want %q", snap.Snapshot, "snapshot-abc123") - } - if !snap.CreatedAt.Equal(cr.Metadata.CreationTimestamp) { - t.Errorf("CreatedAt = %v, want %v", snap.CreatedAt, cr.Metadata.CreationTimestamp) + if snap.Snapshot != "my-snapshot-name" { + t.Errorf("Snapshot = %q, want %q", snap.Snapshot, "my-snapshot-name") } if len(snap.Components) != 1 { t.Fatalf("len(Components) = %d, want 1", len(snap.Components)) @@ -50,7 +39,7 @@ func TestConvert_FieldMapping(t *testing.T) { if c.Name != "quay-server" { t.Errorf("Component.Name = %q, want %q", c.Name, "quay-server") } - if c.ContainerImage != "quay.io/quay/quay:sha-abc123" { + if c.ContainerImage != "quay.io/quay/quay@sha256:abc123" { t.Errorf("Component.ContainerImage = %q", c.ContainerImage) } if c.GitRevision != "abc123def456" { @@ -60,113 +49,3 @@ func TestConvert_FieldMapping(t *testing.T) { t.Errorf("Component.GitURL = %q", c.GitURL) } } - -func TestConvert_TriggerFromAnnotation(t *testing.T) { - cr := SnapshotCR{} - cr.Metadata.Annotations = map[string]string{ - "build.appstudio.openshift.io/component": "quay-server", - "pac.test.appstudio.openshift.io/log-url": "https://console.example.com/run/123", - } - cr.Spec.Components = append(cr.Spec.Components, struct { - Name string `json:"name"` - ContainerImage string `json:"containerImage"` - Source struct { - Git struct { - URL string `json:"url"` - Revision string `json:"revision"` - } `json:"git"` - } `json:"source"` - }{Name: "quay-server"}) - cr.Spec.Components[0].Source.Git.Revision = "sha-from-annotation" - - snap := Convert(cr) - - if snap.Trigger.Component != "quay-server" { - t.Errorf("Trigger.Component = %q, want %q", snap.Trigger.Component, "quay-server") - } - if snap.Trigger.GitSHA != "sha-from-annotation" { - t.Errorf("Trigger.GitSHA = %q, want %q", snap.Trigger.GitSHA, "sha-from-annotation") - } - if snap.Trigger.PipelineRun != "https://console.example.com/run/123" { - t.Errorf("Trigger.PipelineRun = %q", snap.Trigger.PipelineRun) - } -} - -func TestConvert_TriggerFallbackToFirstComponent(t *testing.T) { - cr := SnapshotCR{} - cr.Spec.Components = append(cr.Spec.Components, struct { - Name string `json:"name"` - ContainerImage string `json:"containerImage"` - Source struct { - Git struct { - URL string `json:"url"` - Revision string `json:"revision"` - } `json:"git"` - } `json:"source"` - }{Name: "quay-builder"}) - cr.Spec.Components[0].Source.Git.Revision = "fallback-sha" - - snap := Convert(cr) - - if snap.Trigger.Component != "quay-builder" { - t.Errorf("Trigger.Component = %q, want %q", snap.Trigger.Component, "quay-builder") - } - if snap.Trigger.GitSHA != "fallback-sha" { - t.Errorf("Trigger.GitSHA = %q, want %q", snap.Trigger.GitSHA, "fallback-sha") - } -} - -func TestNormalizeStatus(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"Succeeded", "passed"}, - {"SUCCEEDED", "passed"}, - {"passed", "passed"}, - {"Failed", "failed"}, - {"EnvironmentProvisionError", "failed"}, - {"InProgress", "pending"}, - {"Pending", "pending"}, - {"unknown", "unknown"}, - } - - for _, tt := range tests { - got := normalizeStatus(tt.input) - if got != tt.want { - t.Errorf("normalizeStatus(%q) = %q, want %q", tt.input, got, tt.want) - } - } -} - -func TestConvert_ReadinessAllPassed(t *testing.T) { - cr := SnapshotCR{} - cr.Metadata.Annotations = map[string]string{ - "test.appstudio.openshift.io/status": `[{"scenario":"a","status":"Succeeded"},{"scenario":"b","status":"Succeeded"}]`, - } - - snap := Convert(cr) - if !snap.Readiness.TestsPassed { - t.Error("Readiness.TestsPassed = false, want true when all tests pass") - } -} - -func TestConvert_ReadinessNotAllPassed(t *testing.T) { - cr := SnapshotCR{} - cr.Metadata.Annotations = map[string]string{ - "test.appstudio.openshift.io/status": `[{"scenario":"a","status":"Succeeded"},{"scenario":"b","status":"Failed"}]`, - } - - snap := Convert(cr) - if snap.Readiness.TestsPassed { - t.Error("Readiness.TestsPassed = true, want false when a test fails") - } -} - -func TestConvert_ReadinessNoTests(t *testing.T) { - cr := SnapshotCR{} - snap := Convert(cr) - if snap.Readiness.TestsPassed { - t.Error("Readiness.TestsPassed = true, want false when no tests present") - } -} diff --git a/internal/model/model.go b/internal/model/model.go index 266f964..f5ce276 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -19,33 +19,50 @@ type ComponentRecord struct { } type SnapshotRecord struct { - ID int64 `json:"id"` - Application string `json:"application"` - Name string `json:"name"` - TriggerComponent string `json:"trigger_component"` - TriggerGitSHA string `json:"trigger_git_sha"` - TriggerPipelineRun string `json:"trigger_pipeline_run"` - TestsPassed bool `json:"tests_passed"` - HasTests bool `json:"has_tests"` - Released bool `json:"released"` - ReleaseBlockedReason string `json:"release_blocked_reason,omitempty"` - CreatedAt time.Time `json:"created_at"` - Components []ComponentRecord `json:"components,omitempty"` - TestResults []SnapshotTestResult `json:"test_results,omitempty"` + ID int64 `json:"id"` + Application string `json:"application"` + Name string `json:"name"` + TestsPassed bool `json:"tests_passed"` + HasTests bool `json:"has_tests"` + CreatedAt time.Time `json:"created_at"` + Components []ComponentRecord `json:"components,omitempty"` + TestSuites []TestSuite `json:"test_suites,omitempty"` } -type SnapshotTestResult struct { - ID int64 `json:"id"` - SnapshotID int64 `json:"snapshot_id"` - Scenario string `json:"scenario"` - Status string `json:"status"` - PipelineRun string `json:"pipeline_run"` - Total int `json:"total"` - Passed int `json:"passed"` - Failed int `json:"failed"` - Skipped int `json:"skipped"` - DurationSec float64 `json:"duration_sec"` - CreatedAt time.Time `json:"created_at"` +type TestSuite struct { + ID int64 `json:"id"` + SnapshotID int64 `json:"snapshot_id"` + Name string `json:"name"` + Status string `json:"status"` + PipelineRun string `json:"pipeline_run"` + ToolName string `json:"tool_name"` + ToolVersion string `json:"tool_version"` + Tests int `json:"tests"` + Passed int `json:"passed"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` + Pending int `json:"pending"` + Other int `json:"other"` + Flaky int `json:"flaky"` + StartTime int64 `json:"start_time"` + StopTime int64 `json:"stop_time"` + DurationMs int64 `json:"duration_ms"` + CreatedAt time.Time `json:"created_at"` + TestCases []TestCase `json:"test_cases,omitempty"` +} + +type TestCase struct { + ID int64 `json:"id"` + TestSuiteID int64 `json:"test_suite_id"` + Name string `json:"name"` + Status string `json:"status"` + DurationMs float64 `json:"duration_ms"` + Message string `json:"message,omitempty"` + Trace string `json:"trace,omitempty"` + FilePath string `json:"file_path,omitempty"` + Suite string `json:"suite,omitempty"` + Retries int `json:"retries"` + Flaky bool `json:"flaky"` } type ApplicationSummary struct { diff --git a/internal/model/snapshot.go b/internal/model/snapshot.go index b51c5ae..eac9450 100644 --- a/internal/model/snapshot.go +++ b/internal/model/snapshot.go @@ -1,26 +1,10 @@ package model -import "time" - -// Snapshot represents the full state of a Konflux Snapshot stored in S3. -// Each file contains everything needed to render the dashboard for that -// point in time: components, test results, and release status. +// Snapshot represents the parsed state of a Konflux Snapshot from S3. type Snapshot struct { Application string `json:"application"` Snapshot string `json:"snapshot"` - CreatedAt time.Time `json:"created_at"` - Trigger Trigger `json:"trigger"` Components []SnapshotComponent `json:"components"` - TestResults []TestResult `json:"test_results"` - Releases []Release `json:"releases"` - Readiness Readiness `json:"readiness"` -} - -// Trigger identifies the component build that created this snapshot. -type Trigger struct { - Component string `json:"component"` - GitSHA string `json:"git_sha"` - PipelineRun string `json:"pipeline_run"` } // SnapshotComponent is a single component image captured in the snapshot. @@ -30,49 +14,3 @@ type SnapshotComponent struct { GitRevision string `json:"git_revision"` GitURL string `json:"git_url"` } - -// TestResult holds the high-level outcome of one IntegrationTestScenario run. -type TestResult struct { - Scenario string `json:"scenario"` - Status string `json:"status"` // passed, failed, invalid - PipelineRun string `json:"pipeline_run,omitempty"` - StartTime *time.Time `json:"start_time,omitempty"` - CompletionTime *time.Time `json:"completion_time,omitempty"` - Details string `json:"details,omitempty"` - JUnitPath *string `json:"junit_path"` - Summary *TestSummary `json:"summary"` -} - -// TestSummary contains pre-computed aggregate counts from JUnit XML. -type TestSummary struct { - Total int `json:"total"` - Passed int `json:"passed"` - Failed int `json:"failed"` - Skipped int `json:"skipped"` - DurationSec float64 `json:"duration_sec"` -} - -// Release represents a Release CR associated with a snapshot. -type Release struct { - Name string `json:"name"` - ReleasePlan string `json:"release_plan"` - Target string `json:"target"` - Status string `json:"status"` // succeeded, failed, progressing - Reason string `json:"reason,omitempty"` - StartTime *time.Time `json:"start_time,omitempty"` - CompletionTime *time.Time `json:"completion_time,omitempty"` - JiraIssues []JiraIssue `json:"jira_issues,omitempty"` -} - -// JiraIssue is a Jira issue referenced in a release's release notes. -type JiraIssue struct { - ID string `json:"id"` - Summary string `json:"summary"` -} - -// Readiness summarizes whether this snapshot is ready for release. -type Readiness struct { - TestsPassed bool `json:"tests_passed"` - Released bool `json:"released"` - ReleaseBlockedReason string `json:"release_blocked_reason,omitempty"` -} diff --git a/internal/s3/client.go b/internal/s3/client.go index 8a6f4e1..b52a6c0 100644 --- a/internal/s3/client.go +++ b/internal/s3/client.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "log/slog" + "path" "strings" "github.com/aws/aws-sdk-go-v2/aws" @@ -13,7 +14,7 @@ import ( "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" - "github.com/quay/release-readiness/internal/junit" + "github.com/quay/release-readiness/internal/ctrf" "github.com/quay/release-readiness/internal/konflux" "github.com/quay/release-readiness/internal/model" ) @@ -105,62 +106,68 @@ func (c *Client) ListSnapshots(ctx context.Context, application string) ([]strin return keys, nil } -// GetSnapshot fetches a raw Snapshot CR JSON by its full S3 key, -// parses it as a Konflux SnapshotCR, and converts to model.Snapshot. +// GetSnapshot fetches a Snapshot spec JSON by its full S3 key, +// parses it, and converts to model.Snapshot. The snapshot name is +// derived from the S3 directory name. func (c *Client) GetSnapshot(ctx context.Context, key string) (*model.Snapshot, error) { data, err := c.getObject(ctx, key) if err != nil { return nil, err } - var cr konflux.SnapshotCR - if err := json.Unmarshal(data, &cr); err != nil { - return nil, fmt.Errorf("decode snapshot CR %s: %w", key, err) + var spec konflux.SnapshotSpec + if err := json.Unmarshal(data, &spec); err != nil { + return nil, fmt.Errorf("decode snapshot %s: %w", key, err) } - snap := konflux.Convert(cr) + // Extract snapshot name from S3 key. + // key is "{app}/snapshots/{snapshot-name}/snapshot.json" + name := path.Base(path.Dir(key)) + snap := konflux.Convert(spec, name) return &snap, nil } -// GetTestResults fetches all JUnit XML files under the given prefix, -// parses each, and returns a merged result. -func (c *Client) GetTestResults(ctx context.Context, junitPath string) (*junit.Result, error) { - prefix := junitPath - if !strings.HasSuffix(prefix, "/") { - prefix += "/" - } - +// ListTestSuites discovers test suite subdirectories under snapshotDir +// by looking for keys matching {snapshotDir}{suite}/results/ctrf-report.json. +// Returns the suite directory names (e.g. "api-tests", "ui-tests"). +func (c *Client) ListTestSuites(ctx context.Context, snapshotDir string) ([]string, error) { paginator := s3.NewListObjectsV2Paginator(c.s3, &s3.ListObjectsV2Input{ Bucket: &c.bucket, - Prefix: &prefix, + Prefix: aws.String(snapshotDir), }) - var results []*junit.Result + suffix := "/results/ctrf-report.json" + var suites []string for paginator.HasMorePages() { page, err := paginator.NextPage(ctx) if err != nil { - return nil, fmt.Errorf("list junit files: %w", err) + return nil, fmt.Errorf("list test suites: %w", err) } for _, obj := range page.Contents { - if !strings.HasSuffix(*obj.Key, ".xml") { - continue - } - data, err := c.getObject(ctx, *obj.Key) - if err != nil { - c.logger.Warn("skipping junit file", "key", *obj.Key, "error", err) - continue + key := *obj.Key + // Match keys like {snapshotDir}{suite}/results/ctrf-report.json + rel := strings.TrimPrefix(key, snapshotDir) + if strings.HasSuffix(rel, suffix) { + suite := strings.TrimSuffix(rel, suffix) + if suite != "" && !strings.Contains(suite, "/") { + suites = append(suites, suite) + } } - r, err := junit.Parse(data) - if err != nil { - c.logger.Warn("skipping junit file", "key", *obj.Key, "error", err) - continue - } - results = append(results, r) } } + return suites, nil +} + +// GetCTRFReport fetches and parses a single CTRF JSON report from S3. +func (c *Client) GetCTRFReport(ctx context.Context, key string) (*ctrf.Report, error) { + data, err := c.getObject(ctx, key) + if err != nil { + return nil, err + } - if len(results) == 0 { - return nil, fmt.Errorf("no junit xml files found under %s", junitPath) + var report ctrf.Report + if err := json.Unmarshal(data, &report); err != nil { + return nil, fmt.Errorf("decode ctrf report %s: %w", key, err) } - return junit.MergeResults(results...), nil + return &report, nil } func (c *Client) getObject(ctx context.Context, key string) ([]byte, error) { diff --git a/internal/s3/sync.go b/internal/s3/sync.go index 284c4da..f819672 100644 --- a/internal/s3/sync.go +++ b/internal/s3/sync.go @@ -7,16 +7,18 @@ import ( "path" "time" + "github.com/quay/release-readiness/internal/ctrf" "github.com/quay/release-readiness/internal/model" ) // Store is the subset of the database layer needed by the S3 syncer. type Store interface { SnapshotExistsByName(ctx context.Context, name string) (bool, error) - CreateSnapshot(ctx context.Context, application, name, triggerComponent, triggerGitSHA, triggerPipelineRun string, testsPassed, released bool, releaseBlockedReason string, createdAt time.Time) (*model.SnapshotRecord, error) + CreateSnapshot(ctx context.Context, application, name string, testsPassed bool, createdAt time.Time) (*model.SnapshotRecord, error) EnsureComponent(ctx context.Context, name string) (*model.Component, error) CreateSnapshotComponent(ctx context.Context, snapshotID int64, component, gitSHA, imageURL, gitURL string) error - CreateSnapshotTestResult(ctx context.Context, snapshotID int64, scenario, status, pipelineRun string, total, passed, failed, skipped int, durationSec float64) error + CreateTestSuite(ctx context.Context, snapshotID int64, name, status, pipelineRun, toolName, toolVersion string, tests, passed, failed, skipped, pending, other, flaky int, startTime, stopTime, durationMs int64) (int64, error) + CreateTestCase(ctx context.Context, testSuiteID int64, name, status string, durationMs float64, message, trace, filePath, suite string, retries int, flaky bool) error } // Syncer orchestrates periodic S3 snapshot synchronisation into a Store. @@ -65,7 +67,7 @@ func (s *Syncer) SyncOnce(ctx context.Context) { for _, key := range keys { snap, err := s.client.GetSnapshot(ctx, key) if err != nil { - s.logger.Error("get snapshot", "key", key, "error", err) + s.logger.Debug("skipping snapshot", "key", key, "error", err) continue } @@ -87,41 +89,47 @@ func (s *Syncer) SyncOnce(ctx context.Context) { } } +type suiteData struct { + name string + report *ctrf.Report +} + // ingest persists a single snapshot and its components/test results into the store. func (s *Syncer) ingest(ctx context.Context, key string, snap *model.Snapshot) error { // Derive the snapshot directory prefix from the key. // key is like "{app}/snapshots/{snapshot-name}/snapshot.json" - // We need "{app}/snapshots/{snapshot-name}/" as the base for JUnit paths. snapshotDir := path.Dir(key) + "/" - // Fetch JUnit data for each test result before writing to DB. - for i, tr := range snap.TestResults { - junitPrefix := snapshotDir + "junit/" + tr.Scenario + "/" - result, err := s.client.GetTestResults(ctx, junitPrefix) + // Discover test suites from S3 and fetch CTRF reports to determine testsPassed. + suiteNames, err := s.client.ListTestSuites(ctx, snapshotDir) + if err != nil { + s.logger.Debug("no test suites found", "snapshot", snap.Snapshot, "error", err) + } + + var suites []suiteData + testsPassed := len(suiteNames) > 0 + for _, name := range suiteNames { + ctrfPath := snapshotDir + name + "/results/ctrf-report.json" + report, err := s.client.GetCTRFReport(ctx, ctrfPath) if err != nil { - s.logger.Debug("no junit data", "scenario", tr.Scenario, "path", junitPrefix) + s.logger.Debug("failed to fetch ctrf report", "suite", name, "error", err) continue } - snap.TestResults[i].Summary = &model.TestSummary{ - Total: result.Total, - Passed: result.Passed, - Failed: result.Failed, - Skipped: result.Skipped, - DurationSec: result.DurationSec, + suites = append(suites, suiteData{name: name, report: report}) + if report.Results.Summary.Failed > 0 { + testsPassed = false } } + if len(suites) == 0 { + testsPassed = false + } snapshotRecord, err := s.store.CreateSnapshot( ctx, snap.Application, snap.Snapshot, - snap.Trigger.Component, - snap.Trigger.GitSHA, - snap.Trigger.PipelineRun, - snap.Readiness.TestsPassed, - snap.Readiness.Released, - snap.Readiness.ReleaseBlockedReason, - snap.CreatedAt, + testsPassed, + time.Now().UTC(), ) if err != nil { return fmt.Errorf("create snapshot: %w", err) @@ -137,27 +145,34 @@ func (s *Syncer) ingest(ctx context.Context, key string, snap *model.Snapshot) e } } - for _, tr := range snap.TestResults { - total, passed, failed, skipped := 0, 0, 0, 0 - var durationSec float64 - if tr.Summary != nil { - total = tr.Summary.Total - passed = tr.Summary.Passed - failed = tr.Summary.Failed - skipped = tr.Summary.Skipped - durationSec = tr.Summary.DurationSec + for _, sd := range suites { + status := "passed" + if sd.report.Results.Summary.Failed > 0 { + status = "failed" } - if err := s.store.CreateSnapshotTestResult( - ctx, - snapshotRecord.ID, - tr.Scenario, - tr.Status, - tr.PipelineRun, - total, passed, failed, skipped, - durationSec, - ); err != nil { - return fmt.Errorf("create snapshot test result %s: %w", tr.Scenario, err) + sum := sd.report.Results.Summary + suiteID, err := s.store.CreateTestSuite( + ctx, snapshotRecord.ID, + sd.name, status, "", + sd.report.Results.Tool.Name, sd.report.Results.Tool.Version, + sum.Tests, sum.Passed, sum.Failed, sum.Skipped, + sum.Pending, sum.Other, sum.Flaky, + sum.Start, sum.Stop, sum.Stop-sum.Start, + ) + if err != nil { + return fmt.Errorf("create test suite %s: %w", sd.name, err) + } + + for _, tc := range sd.report.Results.Tests { + if err := s.store.CreateTestCase( + ctx, suiteID, + tc.Name, tc.Status, tc.Duration, + tc.Message, tc.Trace, tc.FilePath, tc.Suite, + tc.Retries, tc.Flaky, + ); err != nil { + return fmt.Errorf("create test case %s: %w", tc.Name, err) + } } } diff --git a/internal/server/handlers_api.go b/internal/server/handlers_api.go index 27f409c..1c6e8e5 100644 --- a/internal/server/handlers_api.go +++ b/internal/server/handlers_api.go @@ -193,7 +193,7 @@ func (s *Server) handleReleasesOverview(w http.ResponseWriter, r *http.Request) // Return snapshot metadata only (no components/test_results) snapCopy := *s snapCopy.Components = nil - snapCopy.TestResults = nil + snapCopy.TestSuites = nil snap = &snapCopy testsPassed = s.TestsPassed hasTests = s.HasTests diff --git a/internal/server/handlers_api_test.go b/internal/server/handlers_api_test.go index 926f64c..64979b3 100644 --- a/internal/server/handlers_api_test.go +++ b/internal/server/handlers_api_test.go @@ -51,7 +51,7 @@ func TestListSnapshots(t *testing.T) { srv := setupTestServer(t) ctx := t.Context() - _, err := srv.db.CreateSnapshot(ctx, "quay-v3-17", "quay-v3-17-20260213-000", "quay", "abc123", "pr-1", true, false, "", time.Now()) + _, err := srv.db.CreateSnapshot(ctx, "quay-v3-17", "quay-v3-17-20260213-000", true, time.Now()) if err != nil { t.Fatalf("create snapshot: %v", err) } @@ -81,7 +81,7 @@ func TestGetReleaseSnapshot(t *testing.T) { ctx := t.Context() // Create a snapshot for the S3 application - _, err := srv.db.CreateSnapshot(ctx, "quay-v3-16", "quay-v3-16-snap-1", "quay", "abc123", "pr-1", true, false, "", time.Now()) + _, err := srv.db.CreateSnapshot(ctx, "quay-v3-16", "quay-v3-16-snap-1", true, time.Now()) if err != nil { t.Fatalf("create snapshot: %v", err) } @@ -126,7 +126,7 @@ func TestReleasesOverview(t *testing.T) { t.Fatalf("upsert release: %v", err) } - _, err = srv.db.CreateSnapshot(ctx, "quay-v3-16", "quay-v3-16-snap-1", "quay", "abc123", "pr-1", true, false, "", time.Now()) + _, err = srv.db.CreateSnapshot(ctx, "quay-v3-16", "quay-v3-16-snap-1", true, time.Now()) if err != nil { t.Fatalf("create snapshot: %v", err) } @@ -248,7 +248,7 @@ func TestGetReleaseReadiness(t *testing.T) { } // Create a passing snapshot - _, err = srv.db.CreateSnapshot(ctx, "quay-v3-16", "quay-v3-16-snap-1", "quay", "abc123", "pr-1", true, false, "", time.Now()) + _, err = srv.db.CreateSnapshot(ctx, "quay-v3-16", "quay-v3-16-snap-1", true, time.Now()) if err != nil { t.Fatalf("create snapshot: %v", err) } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 73e6c8f..c4d9fd5 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -7,34 +7,51 @@ export interface ComponentRecord { git_url: string; } -export interface SnapshotTestResult { +export interface TestCase { + id: number; + test_suite_id: number; + name: string; + status: string; + duration_ms: number; + message?: string; + trace?: string; + file_path?: string; + suite?: string; + retries: number; + flaky: boolean; +} + +export interface TestSuite { id: number; snapshot_id: number; - scenario: string; + name: string; status: string; pipeline_run: string; - total: number; + tool_name: string; + tool_version: string; + tests: number; passed: number; failed: number; skipped: number; - duration_sec: number; + pending: number; + other: number; + flaky: number; + start_time: number; + stop_time: number; + duration_ms: number; created_at: string; + test_cases?: TestCase[]; } export interface SnapshotRecord { id: number; application: string; name: string; - trigger_component: string; - trigger_git_sha: string; - trigger_pipeline_run: string; tests_passed: boolean; has_tests: boolean; - released: boolean; - release_blocked_reason?: string; created_at: string; components?: ComponentRecord[]; - test_results?: SnapshotTestResult[]; + test_suites?: TestSuite[]; } export interface JiraIssue { diff --git a/web/src/components/TestCasesTable.tsx b/web/src/components/TestCasesTable.tsx new file mode 100644 index 0000000..580fd38 --- /dev/null +++ b/web/src/components/TestCasesTable.tsx @@ -0,0 +1,243 @@ +import { + Flex, + FlexItem, + SearchInput, + ToggleGroup, + ToggleGroupItem, + Toolbar, + ToolbarContent, + ToolbarItem, +} from "@patternfly/react-core"; +import { + ExpandableRowContent, + Table, + Tbody, + Td, + Th, + Thead, + type ThProps, + Tr, +} from "@patternfly/react-table"; +import { useMemo, useState } from "react"; +import type { TestCase } from "../api/types"; +import StatusLabel from "./StatusLabel"; + +const statusWeight: Record = { + failed: 0, + error: 1, + pending: 2, + skipped: 3, + passed: 4, +}; + +export default function TestCasesTable({ + testCases, +}: { testCases: TestCase[] }) { + const [activeSortIndex, setActiveSortIndex] = useState( + undefined, + ); + const [activeSortDirection, setActiveSortDirection] = useState< + "asc" | "desc" | undefined + >(undefined); + const [nameFilter, setNameFilter] = useState(""); + const [statusFilters, setStatusFilters] = useState>(new Set()); + const [expandedCases, setExpandedCases] = useState>(new Set()); + + const availableStatuses = useMemo(() => { + const statuses = new Set(); + for (const tc of testCases) { + statuses.add(tc.status.toLowerCase()); + } + return [...statuses].sort( + (a, b) => (statusWeight[a] ?? 99) - (statusWeight[b] ?? 99), + ); + }, [testCases]); + + const processedCases = useMemo(() => { + let cases = [...testCases]; + + if (nameFilter) { + const lower = nameFilter.toLowerCase(); + cases = cases.filter((tc) => tc.name.toLowerCase().includes(lower)); + } + if (statusFilters.size > 0) { + cases = cases.filter((tc) => + statusFilters.has(tc.status.toLowerCase()), + ); + } + + if (activeSortIndex !== undefined && activeSortDirection !== undefined) { + cases.sort((a, b) => { + let cmp = 0; + switch (activeSortIndex) { + case 1: + cmp = a.name.localeCompare(b.name); + break; + case 2: + cmp = + (statusWeight[a.status.toLowerCase()] ?? 99) - + (statusWeight[b.status.toLowerCase()] ?? 99); + break; + } + return activeSortDirection === "asc" ? cmp : -cmp; + }); + } + + return cases; + }, [testCases, nameFilter, statusFilters, activeSortIndex, activeSortDirection]); + + const getSortParams = (columnIndex: number): ThProps["sort"] => ({ + sortBy: { + index: activeSortIndex, + direction: activeSortDirection, + }, + onSort: (_event, index, direction) => { + setActiveSortIndex(index); + setActiveSortDirection(direction); + }, + columnIndex, + }); + + return ( + <> + + + + setNameFilter(val)} + onClear={() => setNameFilter("")} + /> + + + + {availableStatuses.map((s) => ( + { + setStatusFilters((prev) => { + const next = new Set(prev); + if (next.has(s)) { + next.delete(s); + } else { + next.add(s); + } + return next; + }); + }} + /> + ))} + + + + + {(nameFilter || statusFilters.size > 0) && ( +
+ {processedCases.length} of {testCases.length} test cases +
+ )} + + + + + + + + + + {processedCases.map((tc) => { + const isCaseExpanded = expandedCases.has(tc.id); + const hasDetails = !!(tc.message || tc.trace); + return ( + + + + + + + + {isCaseExpanded && ( + + + + )} + + ); + })} +
+ Test Name + Status + DurationFile
+ setExpandedCases((prev) => { + const next = new Set(prev); + if (next.has(tc.id)) { + next.delete(tc.id); + } else { + next.add(tc.id); + } + return next; + }), + } + : undefined + } + /> + {tc.name} + + + {tc.duration_ms > 0 + ? `${(tc.duration_ms / 1000).toFixed(1)}s` + : "\u2014"} + + + {tc.file_path || "\u2014"} + +
+ + + +
Message
+
+														{tc.message || "\u2014"}
+													
+
+ +
Trace
+
+														{tc.trace || "\u2014"}
+													
+
+
+
+
+ + ); +} diff --git a/web/src/pages/ReleaseDetail.tsx b/web/src/pages/ReleaseDetail.tsx index 9595db8..5f183e4 100644 --- a/web/src/pages/ReleaseDetail.tsx +++ b/web/src/pages/ReleaseDetail.tsx @@ -51,7 +51,6 @@ import PriorityLabel from "../components/PriorityLabel"; import StatusLabel from "../components/StatusLabel"; import { useCachedFetch } from "../hooks/useCachedFetch"; import { useConfig } from "../hooks/useConfig"; -import { formatDuration } from "../utils/format"; import { formatReleaseName, jiraIssueUrl, quayImageUrl } from "../utils/links"; export default function ReleaseDetail() { @@ -158,38 +157,6 @@ export default function ReleaseDetail() {
Snapshot
{snapshot.name}
- -
Trigger Component
-
{snapshot.trigger_component}
-
- -
Git SHA
-
- c.component === snapshot.trigger_component, - )?.git_url - } - /> -
-
- {snapshot.trigger_pipeline_run && ( - -
Pipeline Run
- -
- )}
Tests
@@ -206,22 +173,6 @@ export default function ReleaseDetail() { )}
- -
Released
-
- {snapshot.released ? ( - - ) : ( - - )} -
-
- {snapshot.release_blocked_reason && ( - -
Blocked
-
{snapshot.release_blocked_reason}
-
- )}
Created
{new Date(snapshot.created_at).toLocaleString()}
@@ -286,10 +237,10 @@ export default function ReleaseDetail() { )} - {/* Test Results Table */} - {snapshot.test_results && snapshot.test_results.length > 0 && ( + {/* Test Suites Table */} + {snapshot.test_suites && snapshot.test_suites.length > 0 && ( setTestResultsExpanded(val)} style={{ marginTop: "1rem" }} @@ -297,31 +248,30 @@ export default function ReleaseDetail() { - + + - - {snapshot.test_results.map((tr) => ( - - + {snapshot.test_suites.map((ts) => ( + + - - - - + + + + ))} diff --git a/web/src/pages/SnapshotsList.tsx b/web/src/pages/SnapshotsList.tsx index 764e5ed..477bd8b 100644 --- a/web/src/pages/SnapshotsList.tsx +++ b/web/src/pages/SnapshotsList.tsx @@ -15,7 +15,7 @@ import { getRelease, listSnapshots } from "../api/client"; import type { SnapshotRecord } from "../api/types"; import StatusLabel from "../components/StatusLabel"; import { useCachedFetch } from "../hooks/useCachedFetch"; -import { formatReleaseName, githubCommitUrl } from "../utils/links"; +import { formatReleaseName } from "../utils/links"; const PAGE_SIZE = 50; @@ -121,69 +121,29 @@ export default function SnapshotsList() { - - - {snapshots.map((s) => { - const commitUrl = githubCommitUrl( - s.trigger_component, - s.trigger_git_sha, - ); - return ( - - - - - - - - - ); - })} + {snapshots.map((s) => ( + + + + + + + ))}
ScenarioSuite StatusTool Passed Failed Skipped TotalDuration
{tr.scenario}
{ts.name} - + {tr.total === 0 ? "\u2014" : tr.passed}{tr.total === 0 ? "\u2014" : tr.failed}{tr.total === 0 ? "\u2014" : tr.skipped}{tr.total === 0 ? "\u2014" : tr.total} - {tr.total === 0 - ? "\u2014" - : formatDuration(tr.duration_sec)} + {ts.tool_name} + {ts.tool_version ? ` ${ts.tool_version}` : ""} {ts.tests === 0 ? "\u2014" : ts.passed}{ts.tests === 0 ? "\u2014" : ts.failed}{ts.tests === 0 ? "\u2014" : ts.skipped}{ts.tests === 0 ? "\u2014" : ts.tests}
Snapshot ApplicationTrigger TestsReleased Created
- {s.trigger_pipeline_run ? ( - - {s.name} - - ) : ( - s.name - )} - {s.application} - {s.trigger_component} - {" @ "} - {commitUrl ? ( - - {s.trigger_git_sha?.substring(0, 12)} - - ) : ( - {s.trigger_git_sha?.substring(0, 12)} - )} - - - - - {new Date(s.created_at).toLocaleString()}
{s.name}{s.application} + + {new Date(s.created_at).toLocaleString()}
Date: Mon, 2 Mar 2026 18:37:08 -0600 Subject: [PATCH 3/7] feat: add Clair vulnerability scan ingestion and UI tab Ingest Clair vulnerability scan reports from S3 during the snapshot sync loop, store per-component/arch vulnerability data in SQLite, and render a new "Security Scans" tab on the release detail page. Backend: - Add vulnerability_reports and vulnerabilities tables with sqlc queries - Create internal/clair package for Clair JSON parse types - Parse scans/summary.json and per-arch clair-report-*.json from S3 - Extend sync.go Store interface and ingest() to persist scan data - Load vulnerability data in GetSnapshotByName alongside test results Frontend: - Add VulnerabilitiesTable component with CVE search, severity filters, and expandable description rows - Add VulnerabilityReport/Vulnerability types to SnapshotRecord - Security Scans tab with severity-colored labels and fix availability - Switch tabs to isFilled layout for better visual balance Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/clair/clair.go | 33 ++ internal/db/queries/snapshots.sql | 28 ++ internal/db/schema.sql | 31 ++ internal/db/snapshots.go | 88 +++++ internal/db/sqlc/models.go | 27 ++ internal/db/sqlc/snapshots.sql.go | 158 ++++++++ internal/model/model.go | 45 ++- internal/s3/client.go | 50 +++ internal/s3/sync.go | 108 ++++++ web/src/api/types.ts | 29 ++ web/src/components/VulnerabilitiesTable.tsx | 263 ++++++++++++++ web/src/pages/ReleaseDetail.tsx | 376 ++++++++++++++------ 12 files changed, 1126 insertions(+), 110 deletions(-) create mode 100644 internal/clair/clair.go create mode 100644 web/src/components/VulnerabilitiesTable.tsx diff --git a/internal/clair/clair.go b/internal/clair/clair.go new file mode 100644 index 0000000..c1c8673 --- /dev/null +++ b/internal/clair/clair.go @@ -0,0 +1,33 @@ +package clair + +// Report is a Clair vulnerability report for a single container image. +type Report struct { + Vulnerabilities map[string]Vulnerability `json:"vulnerabilities"` + PackageVulnerabilities map[string][]string `json:"package_vulnerabilities"` + Packages map[string]Package `json:"packages"` +} + +// Vulnerability describes a single CVE found by Clair. +type Vulnerability struct { + Name string `json:"name"` + NormalizedSeverity string `json:"normalized_severity"` + Description string `json:"description"` + Links string `json:"links"` + FixedInVersion string `json:"fixed_in_version"` + Package struct { + Name string `json:"name"` + } `json:"package"` +} + +// Package describes a software package detected in the image. +type Package struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// ScanSummaryEntry is one element of the scans/summary.json array. +type ScanSummaryEntry struct { + Component string `json:"component"` + Status string `json:"status"` + Reports int `json:"reports"` +} diff --git a/internal/db/queries/snapshots.sql b/internal/db/queries/snapshots.sql index c6f4ade..3709fd7 100644 --- a/internal/db/queries/snapshots.sql +++ b/internal/db/queries/snapshots.sql @@ -60,3 +60,31 @@ SELECT id, test_suite_id, name, status, duration_ms, message, trace, file_path, FROM test_cases WHERE test_suite_id = ? ORDER BY name; + +-- name: CreateVulnerabilityReport :execlastid +INSERT INTO vulnerability_reports (snapshot_id, component, arch, total, critical, high, medium, low, unknown, fixable) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + +-- name: CreateVulnerability :exec +INSERT INTO vulnerabilities (report_id, name, severity, package_name, package_version, fixed_in_version, description, link) +VALUES (?, ?, ?, ?, ?, ?, ?, ?); + +-- name: ListVulnerabilityReportsBySnapshot :many +SELECT id, snapshot_id, component, arch, total, critical, high, medium, low, unknown, fixable, created_at +FROM vulnerability_reports +WHERE snapshot_id = ? +ORDER BY component, arch; + +-- name: ListVulnerabilitiesByReport :many +SELECT id, report_id, name, severity, package_name, package_version, fixed_in_version, description, link +FROM vulnerabilities +WHERE report_id = ? +ORDER BY + CASE severity + WHEN 'Critical' THEN 0 + WHEN 'High' THEN 1 + WHEN 'Medium' THEN 2 + WHEN 'Low' THEN 3 + ELSE 4 + END, + name; diff --git a/internal/db/schema.sql b/internal/db/schema.sql index 8cf13b3..a4e28e3 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -82,6 +82,37 @@ CREATE TABLE IF NOT EXISTS jira_issues ( ); CREATE UNIQUE INDEX IF NOT EXISTS idx_jira_issues_key_version ON jira_issues(key, fix_version); + +CREATE TABLE IF NOT EXISTS vulnerability_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_id INTEGER NOT NULL REFERENCES snapshots(id) ON DELETE CASCADE, + component TEXT NOT NULL, + arch TEXT NOT NULL, + total INTEGER NOT NULL DEFAULT 0, + critical INTEGER NOT NULL DEFAULT 0, + high INTEGER NOT NULL DEFAULT 0, + medium INTEGER NOT NULL DEFAULT 0, + low INTEGER NOT NULL DEFAULT 0, + unknown INTEGER NOT NULL DEFAULT 0, + fixable INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); + +CREATE INDEX IF NOT EXISTS idx_vuln_reports_snapshot ON vulnerability_reports(snapshot_id); + +CREATE TABLE IF NOT EXISTS vulnerabilities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + report_id INTEGER NOT NULL REFERENCES vulnerability_reports(id) ON DELETE CASCADE, + name TEXT NOT NULL, + severity TEXT NOT NULL DEFAULT '', + package_name TEXT NOT NULL DEFAULT '', + package_version TEXT NOT NULL DEFAULT '', + fixed_in_version TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + link TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS idx_vulns_report ON vulnerabilities(report_id); CREATE INDEX IF NOT EXISTS idx_jira_issues_fix_version ON jira_issues(fix_version); CREATE TABLE IF NOT EXISTS release_versions ( diff --git a/internal/db/snapshots.go b/internal/db/snapshots.go index d8b2a88..9a64428 100644 --- a/internal/db/snapshots.go +++ b/internal/db/snapshots.go @@ -62,6 +62,19 @@ func (d *DB) GetSnapshotByName(ctx context.Context, name string) (*model.Snapsho s.TestSuites = suites s.HasTests = len(suites) > 0 + vulnReports, err := d.ListVulnerabilityReports(ctx, s.ID) + if err != nil { + return nil, err + } + for i, rpt := range vulnReports { + vulns, err := d.ListVulnerabilities(ctx, rpt.ID) + if err != nil { + return nil, err + } + vulnReports[i].Vulnerabilities = vulns + } + s.VulnerabilityReports = vulnReports + return &s, nil } @@ -234,6 +247,81 @@ func (d *DB) ListTestCases(ctx context.Context, testSuiteID int64) ([]model.Test return cases, nil } +func (d *DB) CreateVulnerabilityReport(ctx context.Context, snapshotID int64, component, arch string, total, critical, high, medium, low, unknown, fixable int) (int64, error) { + return d.queries().CreateVulnerabilityReport(ctx, dbsqlc.CreateVulnerabilityReportParams{ + SnapshotID: snapshotID, + Component: component, + Arch: arch, + Total: int64(total), + Critical: int64(critical), + High: int64(high), + Medium: int64(medium), + Low: int64(low), + Unknown: int64(unknown), + Fixable: int64(fixable), + }) +} + +func (d *DB) CreateVulnerability(ctx context.Context, reportID int64, name, severity, packageName, packageVersion, fixedInVersion, description, link string) error { + return d.queries().CreateVulnerability(ctx, dbsqlc.CreateVulnerabilityParams{ + ReportID: reportID, + Name: name, + Severity: severity, + PackageName: packageName, + PackageVersion: packageVersion, + FixedInVersion: fixedInVersion, + Description: description, + Link: link, + }) +} + +func (d *DB) ListVulnerabilityReports(ctx context.Context, snapshotID int64) ([]model.VulnerabilityReport, error) { + rows, err := d.queries().ListVulnerabilityReportsBySnapshot(ctx, snapshotID) + if err != nil { + return nil, err + } + reports := make([]model.VulnerabilityReport, len(rows)) + for i, r := range rows { + reports[i] = model.VulnerabilityReport{ + ID: r.ID, + SnapshotID: r.SnapshotID, + Component: r.Component, + Arch: r.Arch, + Total: int(r.Total), + Critical: int(r.Critical), + High: int(r.High), + Medium: int(r.Medium), + Low: int(r.Low), + Unknown: int(r.Unknown), + Fixable: int(r.Fixable), + CreatedAt: parseTime(r.CreatedAt), + } + } + return reports, nil +} + +func (d *DB) ListVulnerabilities(ctx context.Context, reportID int64) ([]model.Vulnerability, error) { + rows, err := d.queries().ListVulnerabilitiesByReport(ctx, reportID) + if err != nil { + return nil, err + } + vulns := make([]model.Vulnerability, len(rows)) + for i, r := range rows { + vulns[i] = model.Vulnerability{ + ID: r.ID, + ReportID: r.ReportID, + Name: r.Name, + Severity: r.Severity, + PackageName: r.PackageName, + PackageVersion: r.PackageVersion, + FixedInVersion: r.FixedInVersion, + Description: r.Description, + Link: r.Link, + } + } + return vulns, nil +} + func toSnapshotRecord(r dbsqlc.Snapshot) model.SnapshotRecord { return model.SnapshotRecord{ ID: r.ID, diff --git a/internal/db/sqlc/models.go b/internal/db/sqlc/models.go index b001c71..27ffc07 100644 --- a/internal/db/sqlc/models.go +++ b/internal/db/sqlc/models.go @@ -90,3 +90,30 @@ type TestSuite struct { DurationMs int64 CreatedAt string } + +type Vulnerability struct { + ID int64 + ReportID int64 + Name string + Severity string + PackageName string + PackageVersion string + FixedInVersion string + Description string + Link string +} + +type VulnerabilityReport struct { + ID int64 + SnapshotID int64 + Component string + Arch string + Total int64 + Critical int64 + High int64 + Medium int64 + Low int64 + Unknown int64 + Fixable int64 + CreatedAt string +} diff --git a/internal/db/sqlc/snapshots.sql.go b/internal/db/sqlc/snapshots.sql.go index bd4884f..03a20d4 100644 --- a/internal/db/sqlc/snapshots.sql.go +++ b/internal/db/sqlc/snapshots.sql.go @@ -141,6 +141,73 @@ func (q *Queries) CreateTestSuite(ctx context.Context, arg CreateTestSuiteParams return result.LastInsertId() } +const createVulnerability = `-- name: CreateVulnerability :exec +INSERT INTO vulnerabilities (report_id, name, severity, package_name, package_version, fixed_in_version, description, link) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +` + +type CreateVulnerabilityParams struct { + ReportID int64 + Name string + Severity string + PackageName string + PackageVersion string + FixedInVersion string + Description string + Link string +} + +func (q *Queries) CreateVulnerability(ctx context.Context, arg CreateVulnerabilityParams) error { + _, err := q.db.ExecContext(ctx, createVulnerability, + arg.ReportID, + arg.Name, + arg.Severity, + arg.PackageName, + arg.PackageVersion, + arg.FixedInVersion, + arg.Description, + arg.Link, + ) + return err +} + +const createVulnerabilityReport = `-- name: CreateVulnerabilityReport :execlastid +INSERT INTO vulnerability_reports (snapshot_id, component, arch, total, critical, high, medium, low, unknown, fixable) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +` + +type CreateVulnerabilityReportParams struct { + SnapshotID int64 + Component string + Arch string + Total int64 + Critical int64 + High int64 + Medium int64 + Low int64 + Unknown int64 + Fixable int64 +} + +func (q *Queries) CreateVulnerabilityReport(ctx context.Context, arg CreateVulnerabilityReportParams) (int64, error) { + result, err := q.db.ExecContext(ctx, createVulnerabilityReport, + arg.SnapshotID, + arg.Component, + arg.Arch, + arg.Total, + arg.Critical, + arg.High, + arg.Medium, + arg.Low, + arg.Unknown, + arg.Fixable, + ) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + const getSnapshotRow = `-- name: GetSnapshotRow :one SELECT id, application, name, tests_passed, created_at FROM snapshots WHERE name = ? @@ -422,6 +489,97 @@ func (q *Queries) ListTestSuitesBySnapshot(ctx context.Context, snapshotID int64 return items, nil } +const listVulnerabilitiesByReport = `-- name: ListVulnerabilitiesByReport :many +SELECT id, report_id, name, severity, package_name, package_version, fixed_in_version, description, link +FROM vulnerabilities +WHERE report_id = ? +ORDER BY + CASE severity + WHEN 'Critical' THEN 0 + WHEN 'High' THEN 1 + WHEN 'Medium' THEN 2 + WHEN 'Low' THEN 3 + ELSE 4 + END, + name +` + +func (q *Queries) ListVulnerabilitiesByReport(ctx context.Context, reportID int64) ([]Vulnerability, error) { + rows, err := q.db.QueryContext(ctx, listVulnerabilitiesByReport, reportID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Vulnerability + for rows.Next() { + var i Vulnerability + if err := rows.Scan( + &i.ID, + &i.ReportID, + &i.Name, + &i.Severity, + &i.PackageName, + &i.PackageVersion, + &i.FixedInVersion, + &i.Description, + &i.Link, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listVulnerabilityReportsBySnapshot = `-- name: ListVulnerabilityReportsBySnapshot :many +SELECT id, snapshot_id, component, arch, total, critical, high, medium, low, unknown, fixable, created_at +FROM vulnerability_reports +WHERE snapshot_id = ? +ORDER BY component, arch +` + +func (q *Queries) ListVulnerabilityReportsBySnapshot(ctx context.Context, snapshotID int64) ([]VulnerabilityReport, error) { + rows, err := q.db.QueryContext(ctx, listVulnerabilityReportsBySnapshot, snapshotID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []VulnerabilityReport + for rows.Next() { + var i VulnerabilityReport + if err := rows.Scan( + &i.ID, + &i.SnapshotID, + &i.Component, + &i.Arch, + &i.Total, + &i.Critical, + &i.High, + &i.Medium, + &i.Low, + &i.Unknown, + &i.Fixable, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const snapshotExistsByName = `-- name: SnapshotExistsByName :one SELECT COUNT(*) FROM snapshots WHERE name = ? ` diff --git a/internal/model/model.go b/internal/model/model.go index f5ce276..24d9e1f 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -19,14 +19,15 @@ type ComponentRecord struct { } type SnapshotRecord struct { - ID int64 `json:"id"` - Application string `json:"application"` - Name string `json:"name"` - TestsPassed bool `json:"tests_passed"` - HasTests bool `json:"has_tests"` - CreatedAt time.Time `json:"created_at"` - Components []ComponentRecord `json:"components,omitempty"` - TestSuites []TestSuite `json:"test_suites,omitempty"` + ID int64 `json:"id"` + Application string `json:"application"` + Name string `json:"name"` + TestsPassed bool `json:"tests_passed"` + HasTests bool `json:"has_tests"` + CreatedAt time.Time `json:"created_at"` + Components []ComponentRecord `json:"components,omitempty"` + TestSuites []TestSuite `json:"test_suites,omitempty"` + VulnerabilityReports []VulnerabilityReport `json:"vulnerability_reports,omitempty"` } type TestSuite struct { @@ -65,6 +66,34 @@ type TestCase struct { Flaky bool `json:"flaky"` } +type VulnerabilityReport struct { + ID int64 `json:"id"` + SnapshotID int64 `json:"snapshot_id"` + Component string `json:"component"` + Arch string `json:"arch"` + Total int `json:"total"` + Critical int `json:"critical"` + High int `json:"high"` + Medium int `json:"medium"` + Low int `json:"low"` + Unknown int `json:"unknown"` + Fixable int `json:"fixable"` + CreatedAt time.Time `json:"created_at"` + Vulnerabilities []Vulnerability `json:"vulnerabilities,omitempty"` +} + +type Vulnerability struct { + ID int64 `json:"id"` + ReportID int64 `json:"report_id"` + Name string `json:"name"` + Severity string `json:"severity"` + PackageName string `json:"package_name"` + PackageVersion string `json:"package_version"` + FixedInVersion string `json:"fixed_in_version"` + Description string `json:"description"` + Link string `json:"link"` +} + type ApplicationSummary struct { Application string `json:"application"` LatestSnapshot *SnapshotRecord `json:"latest_snapshot,omitempty"` diff --git a/internal/s3/client.go b/internal/s3/client.go index b52a6c0..35a6ad3 100644 --- a/internal/s3/client.go +++ b/internal/s3/client.go @@ -14,6 +14,7 @@ import ( "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/quay/release-readiness/internal/clair" "github.com/quay/release-readiness/internal/ctrf" "github.com/quay/release-readiness/internal/konflux" "github.com/quay/release-readiness/internal/model" @@ -170,6 +171,55 @@ func (c *Client) GetCTRFReport(ctx context.Context, key string) (*ctrf.Report, e return &report, nil } +// GetScanSummary fetches and parses the scans/summary.json file from a snapshot directory. +func (c *Client) GetScanSummary(ctx context.Context, snapshotDir string) ([]clair.ScanSummaryEntry, error) { + key := snapshotDir + "scans/summary.json" + data, err := c.getObject(ctx, key) + if err != nil { + return nil, err + } + var entries []clair.ScanSummaryEntry + if err := json.Unmarshal(data, &entries); err != nil { + return nil, fmt.Errorf("decode scan summary %s: %w", key, err) + } + return entries, nil +} + +// ListClairReports returns the architecture suffixes for clair reports under +// {snapshotDir}scans/{component}/ by matching clair-report-*.json keys. +func (c *Client) ListClairReports(ctx context.Context, snapshotDir, component string) ([]string, error) { + prefix := snapshotDir + "scans/" + component + "/clair-report-" + paginator := s3.NewListObjectsV2Paginator(c.s3, &s3.ListObjectsV2Input{ + Bucket: &c.bucket, + Prefix: aws.String(prefix), + }) + + var keys []string + for paginator.HasMorePages() { + page, err := paginator.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("list clair reports: %w", err) + } + for _, obj := range page.Contents { + keys = append(keys, *obj.Key) + } + } + return keys, nil +} + +// GetClairReport fetches and parses a single Clair vulnerability report from S3. +func (c *Client) GetClairReport(ctx context.Context, key string) (*clair.Report, error) { + data, err := c.getObject(ctx, key) + if err != nil { + return nil, err + } + var report clair.Report + if err := json.Unmarshal(data, &report); err != nil { + return nil, fmt.Errorf("decode clair report %s: %w", key, err) + } + return &report, nil +} + func (c *Client) getObject(ctx context.Context, key string) ([]byte, error) { out, err := c.s3.GetObject(ctx, &s3.GetObjectInput{ Bucket: &c.bucket, diff --git a/internal/s3/sync.go b/internal/s3/sync.go index f819672..2677f2c 100644 --- a/internal/s3/sync.go +++ b/internal/s3/sync.go @@ -5,8 +5,10 @@ import ( "fmt" "log/slog" "path" + "strings" "time" + "github.com/quay/release-readiness/internal/clair" "github.com/quay/release-readiness/internal/ctrf" "github.com/quay/release-readiness/internal/model" ) @@ -19,6 +21,8 @@ type Store interface { CreateSnapshotComponent(ctx context.Context, snapshotID int64, component, gitSHA, imageURL, gitURL string) error CreateTestSuite(ctx context.Context, snapshotID int64, name, status, pipelineRun, toolName, toolVersion string, tests, passed, failed, skipped, pending, other, flaky int, startTime, stopTime, durationMs int64) (int64, error) CreateTestCase(ctx context.Context, testSuiteID int64, name, status string, durationMs float64, message, trace, filePath, suite string, retries int, flaky bool) error + CreateVulnerabilityReport(ctx context.Context, snapshotID int64, component, arch string, total, critical, high, medium, low, unknown, fixable int) (int64, error) + CreateVulnerability(ctx context.Context, reportID int64, name, severity, packageName, packageVersion, fixedInVersion, description, link string) error } // Syncer orchestrates periodic S3 snapshot synchronisation into a Store. @@ -176,5 +180,109 @@ func (s *Syncer) ingest(ctx context.Context, key string, snap *model.Snapshot) e } } + // Ingest Clair vulnerability scans. + if err := s.ingestScans(ctx, snapshotDir, snapshotRecord.ID); err != nil { + s.logger.Error("ingest scans", "snapshot", snap.Snapshot, "error", err) + } + + return nil +} + +// ingestScans fetches scan summary and clair reports from S3, persisting vulnerability data. +func (s *Syncer) ingestScans(ctx context.Context, snapshotDir string, snapshotID int64) error { + summary, err := s.client.GetScanSummary(ctx, snapshotDir) + if err != nil { + return nil // scans directory may not exist + } + + for _, entry := range summary { + if entry.Status != "ok" { + continue + } + + reportKeys, err := s.client.ListClairReports(ctx, snapshotDir, entry.Component) + if err != nil { + s.logger.Debug("list clair reports", "component", entry.Component, "error", err) + continue + } + + for _, key := range reportKeys { + arch := archFromKey(key) + report, err := s.client.GetClairReport(ctx, key) + if err != nil { + s.logger.Debug("fetch clair report", "key", key, "error", err) + continue + } + + counts := countSeverities(report) + reportID, err := s.store.CreateVulnerabilityReport( + ctx, snapshotID, entry.Component, arch, + counts.total, counts.critical, counts.high, + counts.medium, counts.low, counts.unknown, counts.fixable, + ) + if err != nil { + return fmt.Errorf("create vulnerability report %s/%s: %w", entry.Component, arch, err) + } + + for _, v := range report.Vulnerabilities { + link := firstLink(v.Links) + if err := s.store.CreateVulnerability( + ctx, reportID, + v.Name, v.NormalizedSeverity, + v.Package.Name, "", + v.FixedInVersion, v.Description, link, + ); err != nil { + return fmt.Errorf("create vulnerability %s: %w", v.Name, err) + } + } + + s.logger.Info("ingested clair report", + "component", entry.Component, "arch", arch, + "vulnerabilities", counts.total) + } + } return nil } + +type severityCounts struct { + total, critical, high, medium, low, unknown, fixable int +} + +func countSeverities(report *clair.Report) severityCounts { + var c severityCounts + for _, v := range report.Vulnerabilities { + c.total++ + switch v.NormalizedSeverity { + case "Critical": + c.critical++ + case "High": + c.high++ + case "Medium": + c.medium++ + case "Low": + c.low++ + default: + c.unknown++ + } + if v.FixedInVersion != "" { + c.fixable++ + } + } + return c +} + +// archFromKey extracts the architecture from a key like ".../clair-report-amd64.json". +func archFromKey(key string) string { + base := path.Base(key) // clair-report-amd64.json + base = strings.TrimPrefix(base, "clair-report-") // amd64.json + base = strings.TrimSuffix(base, ".json") // amd64 + return base +} + +// firstLink returns the first whitespace-separated link from a Clair links string. +func firstLink(links string) string { + if i := strings.IndexByte(links, ' '); i > 0 { + return links[:i] + } + return links +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts index c4d9fd5..1ddea13 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -43,6 +43,34 @@ export interface TestSuite { test_cases?: TestCase[]; } +export interface Vulnerability { + id: number; + report_id: number; + name: string; + severity: string; + package_name: string; + package_version: string; + fixed_in_version: string; + description: string; + link: string; +} + +export interface VulnerabilityReport { + id: number; + snapshot_id: number; + component: string; + arch: string; + total: number; + critical: number; + high: number; + medium: number; + low: number; + unknown: number; + fixable: number; + created_at: string; + vulnerabilities?: Vulnerability[]; +} + export interface SnapshotRecord { id: number; application: string; @@ -52,6 +80,7 @@ export interface SnapshotRecord { created_at: string; components?: ComponentRecord[]; test_suites?: TestSuite[]; + vulnerability_reports?: VulnerabilityReport[]; } export interface JiraIssue { diff --git a/web/src/components/VulnerabilitiesTable.tsx b/web/src/components/VulnerabilitiesTable.tsx new file mode 100644 index 0000000..3f006d7 --- /dev/null +++ b/web/src/components/VulnerabilitiesTable.tsx @@ -0,0 +1,263 @@ +import { + Label, + SearchInput, + ToggleGroup, + ToggleGroupItem, + Toolbar, + ToolbarContent, + ToolbarItem, +} from "@patternfly/react-core"; +import { + ExpandableRowContent, + Table, + Tbody, + Td, + Th, + Thead, + type ThProps, + Tr, +} from "@patternfly/react-table"; +import { useMemo, useState } from "react"; +import type { Vulnerability } from "../api/types"; + +const severityWeight: Record = { + Critical: 0, + High: 1, + Medium: 2, + Low: 3, + Unknown: 4, +}; + +const severityColor: Record = { + Critical: "red", + High: "red", + Medium: "orange", + Low: "yellow", +}; + +export default function VulnerabilitiesTable({ + vulnerabilities, +}: { vulnerabilities: Vulnerability[] }) { + const [activeSortIndex, setActiveSortIndex] = useState( + undefined, + ); + const [activeSortDirection, setActiveSortDirection] = useState< + "asc" | "desc" | undefined + >(undefined); + const [nameFilter, setNameFilter] = useState(""); + const [severityFilters, setSeverityFilters] = useState>( + new Set(), + ); + const [expandedVulns, setExpandedVulns] = useState>(new Set()); + + const availableSeverities = useMemo(() => { + const sevs = new Set(); + for (const v of vulnerabilities) { + sevs.add(v.severity || "Unknown"); + } + return [...sevs].sort( + (a, b) => (severityWeight[a] ?? 99) - (severityWeight[b] ?? 99), + ); + }, [vulnerabilities]); + + const processedVulns = useMemo(() => { + let vulns = [...vulnerabilities]; + + if (nameFilter) { + const lower = nameFilter.toLowerCase(); + vulns = vulns.filter( + (v) => + v.name.toLowerCase().includes(lower) || + v.package_name.toLowerCase().includes(lower), + ); + } + if (severityFilters.size > 0) { + vulns = vulns.filter((v) => + severityFilters.has(v.severity || "Unknown"), + ); + } + + if (activeSortIndex !== undefined && activeSortDirection !== undefined) { + vulns.sort((a, b) => { + let cmp = 0; + switch (activeSortIndex) { + case 1: + cmp = a.name.localeCompare(b.name); + break; + case 2: + cmp = + (severityWeight[a.severity] ?? 99) - + (severityWeight[b.severity] ?? 99); + break; + case 3: + cmp = a.package_name.localeCompare(b.package_name); + break; + } + return activeSortDirection === "asc" ? cmp : -cmp; + }); + } + + return vulns; + }, [ + vulnerabilities, + nameFilter, + severityFilters, + activeSortIndex, + activeSortDirection, + ]); + + const getSortParams = (columnIndex: number): ThProps["sort"] => ({ + sortBy: { + index: activeSortIndex, + direction: activeSortDirection, + }, + onSort: (_event, index, direction) => { + setActiveSortIndex(index); + setActiveSortDirection(direction); + }, + columnIndex, + }); + + return ( + <> + + + + setNameFilter(val)} + onClear={() => setNameFilter("")} + /> + + + + {availableSeverities.map((s) => ( + { + setSeverityFilters((prev) => { + const next = new Set(prev); + if (next.has(s)) { + next.delete(s); + } else { + next.add(s); + } + return next; + }); + }} + /> + ))} + + + + + {(nameFilter || severityFilters.size > 0) && ( +
+ {processedVulns.length} of {vulnerabilities.length}{" "} + vulnerabilities +
+ )} + + + + + + + + + + {processedVulns.map((v) => { + const isExpanded = expandedVulns.has(v.id); + return ( + + + + + + + + {isExpanded && ( + + + + )} + + ); + })} +
+ CVE + Severity + PackageFix Available
+ setExpandedVulns((prev) => { + const next = new Set(prev); + if (next.has(v.id)) { + next.delete(v.id); + } else { + next.add(v.id); + } + return next; + }), + }} + /> + + {v.link ? ( + + {v.name} + + ) : ( + v.name + )} + + + + + {v.package_name || "\u2014"} + + + {v.fixed_in_version ? ( + + ) : ( + "\u2014" + )} +
+ +
+ {v.description || "No description available."} +
+
+
+ + ); +} diff --git a/web/src/pages/ReleaseDetail.tsx b/web/src/pages/ReleaseDetail.tsx index 5f183e4..e41a62d 100644 --- a/web/src/pages/ReleaseDetail.tsx +++ b/web/src/pages/ReleaseDetail.tsx @@ -6,7 +6,6 @@ import { CardTitle, EmptyState, EmptyStateBody, - ExpandableSection, Flex, FlexItem, Label, @@ -14,6 +13,9 @@ import { ProgressStep, ProgressStepper, Spinner, + Tab, + Tabs, + TabTitleText, Title, } from "@patternfly/react-core"; import { @@ -21,6 +23,7 @@ import { ExclamationCircleIcon, } from "@patternfly/react-icons"; import { + ExpandableRowContent, Table, Tbody, Td, @@ -49,6 +52,8 @@ import ExpandableCard from "../components/ExpandableCard"; import GitShaLink from "../components/GitShaLink"; import PriorityLabel from "../components/PriorityLabel"; import StatusLabel from "../components/StatusLabel"; +import TestCasesTable from "../components/TestCasesTable"; +import VulnerabilitiesTable from "../components/VulnerabilitiesTable"; import { useCachedFetch } from "../hooks/useCachedFetch"; import { useConfig } from "../hooks/useConfig"; import { formatReleaseName, jiraIssueUrl, quayImageUrl } from "../utils/links"; @@ -78,8 +83,15 @@ export default function ReleaseDetail() { () => getReleaseReadiness(version!), ); - const [componentsExpanded, setComponentsExpanded] = useState(false); - const [testResultsExpanded, setTestResultsExpanded] = useState(false); + const [activeSnapshotTab, setActiveSnapshotTab] = useState< + string | number + >("components"); + const [expandedSuites, setExpandedSuites] = useState>( + new Set(), + ); + const [expandedReports, setExpandedReports] = useState>( + new Set(), + ); if (loadingRelease && !release) { return ( @@ -179,105 +191,247 @@ export default function ReleaseDetail() {
- {/* Components Table */} - {snapshot.components && snapshot.components.length > 0 && ( - setComponentsExpanded(val)} - style={{ marginTop: "1rem" }} - > - - - - - - - - - - {snapshot.components.map((c) => { - const imgUrl = quayImageUrl(c.image_url); - const imgDisplay = c.image_url.includes("/") - ? (c.image_url.split("/").pop()?.split("@")[0] ?? - c.image_url) - : c.image_url; - return ( - - - - + + + + + + + + + + + {isReportExpanded && ( + + + + )} + + ); + })} +
ComponentGit SHAImage
{c.component} - - - {imgUrl ? ( - + setActiveSnapshotTab(key)} + isFilled + style={{ marginTop: "1rem" }} + > + {snapshot.components && snapshot.components.length > 0 && ( + + Components ({snapshot.components.length}) + + } + > + + + + + + + + + + {snapshot.components.map((c) => { + const imgUrl = quayImageUrl(c.image_url); + const imgDisplay = c.image_url.includes("/") + ? (c.image_url + .split("/") + .pop() + ?.split("@")[0] ?? c.image_url) + : c.image_url; + return ( + + + + + + ); + })} + +
ComponentGit SHAImage
{c.component} + + + {imgUrl ? ( + + + {imgDisplay} + + + ) : ( - {imgDisplay} + {c.image_url} - - ) : ( - - {c.image_url} - + )} +
+
+ )} + + {snapshot.test_suites && + snapshot.test_suites.length > 0 && ( + + Test Suites ({snapshot.test_suites.length}) + + } + > + + + + + + + + + + + + + {snapshot.test_suites.map((ts) => { + const isSuiteExpanded = expandedSuites.has(ts.id); + return ( + + + + + + + + + + + {isSuiteExpanded && ( + + + )} - + + ); + })} +
+ SuiteStatusToolPassedFailedSkippedTotal
+ setExpandedSuites((prev) => { + const next = new Set(prev); + if (next.has(ts.id)) { + next.delete(ts.id); + } else { + next.add(ts.id); + } + return next; + }), + }} + /> + {ts.name} + + + {ts.tool_name} + {ts.tool_version + ? ` ${ts.tool_version}` + : ""} + + {ts.tests === 0 ? "\u2014" : ts.passed} + + {ts.tests === 0 ? "\u2014" : ts.failed} + + {ts.tests === 0 ? "\u2014" : ts.skipped} + + {ts.tests === 0 ? "\u2014" : ts.tests} +
+ + {ts.test_cases && + ts.test_cases.length > 0 ? ( + + ) : ( + No test cases recorded. + )} + +
+
+ )} + {snapshot.vulnerability_reports && + snapshot.vulnerability_reports.length > 0 && ( + + Security Scans ({snapshot.vulnerability_reports.length}) + + } + > + + + + + + + + + + + - ); - })} - -
+ ComponentArchCriticalHighMediumLowTotalFixable
- - )} - - {/* Test Suites Table */} - {snapshot.test_suites && snapshot.test_suites.length > 0 && ( - setTestResultsExpanded(val)} - style={{ marginTop: "1rem" }} - > - - - - - - - - - - - - - - {snapshot.test_suites.map((ts) => ( - - - - - - - - - - ))} - -
SuiteStatusToolPassedFailedSkippedTotal
{ts.name} - - - {ts.tool_name} - {ts.tool_version ? ` ${ts.tool_version}` : ""} - {ts.tests === 0 ? "\u2014" : ts.passed}{ts.tests === 0 ? "\u2014" : ts.failed}{ts.tests === 0 ? "\u2014" : ts.skipped}{ts.tests === 0 ? "\u2014" : ts.tests}
-
- )} + + {snapshot.vulnerability_reports.map((rpt) => { + const isReportExpanded = expandedReports.has(rpt.id); + return ( +
+ setExpandedReports((prev) => { + const next = new Set(prev); + if (next.has(rpt.id)) { + next.delete(rpt.id); + } else { + next.add(rpt.id); + } + return next; + }), + }} + /> + {rpt.component}{rpt.arch} + + + + + + + + {rpt.total}{rpt.fixable}
+ + {rpt.vulnerabilities && + rpt.vulnerabilities.length > 0 ? ( + + ) : ( + No vulnerabilities recorded. + )} + +
+ + )} + )} @@ -344,7 +498,7 @@ function ReleaseSignal({ const progressItems = [ { label: "Builds ready", done: buildsReady, warning: snapshot === null }, - { label: "Tests passed", done: allTestsPassed, warning: !hasTests }, + { label: "Tests passed", done: allTestsPassed, warning: !hasTests, danger: hasTests && !allTestsPassed }, ...(issueSummary ? [{ label: "Bugs verified", done: bugsVerified }] : []), { label: "QE sign off", done: qeSignOff }, ]; @@ -409,9 +563,11 @@ function ReleaseSignal({ variant={ item.done ? "success" - : item.warning - ? "warning" - : "pending" + : item.danger + ? "danger" + : item.warning + ? "warning" + : "pending" } isCurrent={idx === firstIncomplete} id={`step-${idx}`} @@ -427,6 +583,22 @@ function ReleaseSignal({ ); } +const severityLabelColor: Record = { + Critical: "red", + High: "red", + Medium: "orange", + Low: "yellow", +}; + +function SeverityCount({ count, severity }: { count: number; severity: string }) { + if (count === 0) return <>{"\u2014"}; + return ( + + ); +} + const priorityWeight: Record = { blocker: 0, critical: 1, From f296249c607cd38dfb65c7397b08e139a40d6db6 Mon Sep 17 00:00:00 2001 From: Brady Pratt Date: Mon, 2 Mar 2026 18:42:31 -0600 Subject: [PATCH 4/7] fix(web): don't seed incomplete snapshot data Signed-off-by: Brady Pratt --- web/src/pages/ReleasesOverview.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/web/src/pages/ReleasesOverview.tsx b/web/src/pages/ReleasesOverview.tsx index 96d662e..c2deb3f 100644 --- a/web/src/pages/ReleasesOverview.tsx +++ b/web/src/pages/ReleasesOverview.tsx @@ -69,9 +69,6 @@ export default function ReleasesOverview() { for (const ov of overviews) { seedCache(`issueSummary:${ov.release.name}`, ov.issue_summary); seedCache(`readiness:${ov.release.name}`, ov.readiness); - if (ov.snapshot) { - seedCache(`snapshot:${ov.release.name}`, ov.snapshot); - } } }, [overviews]); From 0e01a6303e649f7714a1b9296aff732905cee7b1 Mon Sep 17 00:00:00 2001 From: Brady Pratt Date: Mon, 2 Mar 2026 18:52:48 -0600 Subject: [PATCH 5/7] refactor(ui): group security scans by component with arch tabs Collapse the flat (component, arch) row layout into one row per component with aggregated severity counts. Expanding a component reveals filled PatternFly tabs for each architecture, showing per-arch severity breakdown and the existing CVE table. Co-Authored-By: Claude Opus 4.6 (1M context) --- web/src/pages/ReleaseDetail.tsx | 131 ++++++++++++++++++++++++-------- 1 file changed, 100 insertions(+), 31 deletions(-) diff --git a/web/src/pages/ReleaseDetail.tsx b/web/src/pages/ReleaseDetail.tsx index e41a62d..bb123ac 100644 --- a/web/src/pages/ReleaseDetail.tsx +++ b/web/src/pages/ReleaseDetail.tsx @@ -47,6 +47,7 @@ import type { ReadinessResponse, ReleaseVersion, SnapshotRecord, + VulnerabilityReport, } from "../api/types"; import ExpandableCard from "../components/ExpandableCard"; import GitShaLink from "../components/GitShaLink"; @@ -89,9 +90,40 @@ export default function ReleaseDetail() { const [expandedSuites, setExpandedSuites] = useState>( new Set(), ); - const [expandedReports, setExpandedReports] = useState>( + const [expandedComponents, setExpandedComponents] = useState>( new Set(), ); + const [activeArchTab, setActiveArchTab] = useState>( + {}, + ); + + const groupedVulnReports = useMemo(() => { + const reports = snapshot?.vulnerability_reports; + if (!reports || reports.length === 0) return []; + + const map = new Map(); + for (const rpt of reports) { + const existing = map.get(rpt.component); + if (existing) { + existing.push(rpt); + } else { + map.set(rpt.component, [rpt]); + } + } + + return [...map.entries()] + .map(([component, compReports]) => ({ + component, + reports: compReports.sort((a, b) => a.arch.localeCompare(b.arch)), + total: compReports.reduce((s, r) => s + r.total, 0), + critical: compReports.reduce((s, r) => s + r.critical, 0), + high: compReports.reduce((s, r) => s + r.high, 0), + medium: compReports.reduce((s, r) => s + r.medium, 0), + low: compReports.reduce((s, r) => s + r.low, 0), + fixable: compReports.reduce((s, r) => s + r.fixable, 0), + })) + .sort((a, b) => a.component.localeCompare(b.component)); + }, [snapshot?.vulnerability_reports]); if (loadingRelease && !release) { return ( @@ -347,13 +379,12 @@ export default function ReleaseDetail() { )} - {snapshot.vulnerability_reports && - snapshot.vulnerability_reports.length > 0 && ( + {groupedVulnReports.length > 0 && ( - Security Scans ({snapshot.vulnerability_reports.length}) + Security Scans ({groupedVulnReports.length}) } > @@ -362,7 +393,7 @@ export default function ReleaseDetail() { Component - Arch + Architectures Critical High Medium @@ -371,56 +402,94 @@ export default function ReleaseDetail() { Fixable - {snapshot.vulnerability_reports.map((rpt) => { - const isReportExpanded = expandedReports.has(rpt.id); + {groupedVulnReports.map((group, groupIdx) => { + const isExpanded = expandedComponents.has(group.component); + const selectedArch = + activeArchTab[group.component] ?? group.reports[0]?.arch; + const selectedReport = group.reports.find( + (r) => r.arch === selectedArch, + ); return ( - + - setExpandedReports((prev) => { + setExpandedComponents((prev) => { const next = new Set(prev); - if (next.has(rpt.id)) { - next.delete(rpt.id); + if (next.has(group.component)) { + next.delete(group.component); } else { - next.add(rpt.id); + next.add(group.component); } return next; }), }} /> - {rpt.component} - {rpt.arch} + {group.component} + {group.reports.length} - + - + - + - + - {rpt.total} - {rpt.fixable} + {group.total} + {group.fixable} - {isReportExpanded && ( + {isExpanded && selectedReport && ( - {rpt.vulnerabilities && - rpt.vulnerabilities.length > 0 ? ( - - ) : ( - No vulnerabilities recorded. - )} + + setActiveArchTab((prev) => ({ + ...prev, + [group.component]: String(key), + })) + } + > + {group.reports.map((rpt) => ( + + {rpt.arch} ({rpt.total}) + + } + > +
+ + Critical: + High: + Medium: + Low: + Total: {rpt.total} + Fixable: {rpt.fixable} + + {rpt.vulnerabilities && + rpt.vulnerabilities.length > 0 ? ( + + ) : ( + No vulnerabilities recorded. + )} +
+
+ ))} +
From 5b25dbf2ba7a340820fb033324d2a97bd0aedb5a Mon Sep 17 00:00:00 2001 From: Brady Pratt Date: Mon, 2 Mar 2026 18:54:43 -0600 Subject: [PATCH 6/7] fix(web): remove expandable bug verification Signed-off-by: Brady Pratt --- web/src/components/ExpandableCard.tsx | 25 ------------------------- web/src/pages/ReleaseDetail.tsx | 10 ++++++---- 2 files changed, 6 insertions(+), 29 deletions(-) delete mode 100644 web/src/components/ExpandableCard.tsx diff --git a/web/src/components/ExpandableCard.tsx b/web/src/components/ExpandableCard.tsx deleted file mode 100644 index 6305c94..0000000 --- a/web/src/components/ExpandableCard.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Card, CardBody, ExpandableSection } from "@patternfly/react-core"; -import { useState } from "react"; - -export default function ExpandableCard({ - title, - children, -}: { - title: string; - children: React.ReactNode; -}) { - const [expanded, setExpanded] = useState(true); - return ( - - - setExpanded(val)} - > - {children} - - - - ); -} diff --git a/web/src/pages/ReleaseDetail.tsx b/web/src/pages/ReleaseDetail.tsx index bb123ac..7efdd8b 100644 --- a/web/src/pages/ReleaseDetail.tsx +++ b/web/src/pages/ReleaseDetail.tsx @@ -49,7 +49,6 @@ import type { SnapshotRecord, VulnerabilityReport, } from "../api/types"; -import ExpandableCard from "../components/ExpandableCard"; import GitShaLink from "../components/GitShaLink"; import PriorityLabel from "../components/PriorityLabel"; import StatusLabel from "../components/StatusLabel"; @@ -507,9 +506,12 @@ export default function ReleaseDetail() { {/* Bug Verification Table */} {(issues ?? []).length > 0 && ( - - - + + {`Bug Verification (${(issues ?? []).length})`} + + + + )} From 7928a6188af8aab875e5e957ccf25c463dbc9d25 Mon Sep 17 00:00:00 2001 From: Brady Pratt Date: Tue, 3 Mar 2026 08:46:25 -0600 Subject: [PATCH 7/7] fix: format files Signed-off-by: Brady Pratt --- internal/ctrf/ctrf.go | 2 +- web/src/components/TestCasesTable.tsx | 16 +- web/src/components/VulnerabilitiesTable.tsx | 24 +- web/src/pages/ReleaseDetail.tsx | 491 +++++++++++--------- 4 files changed, 289 insertions(+), 244 deletions(-) diff --git a/internal/ctrf/ctrf.go b/internal/ctrf/ctrf.go index a4ccded..b69e0b0 100644 --- a/internal/ctrf/ctrf.go +++ b/internal/ctrf/ctrf.go @@ -7,7 +7,7 @@ type Report struct { // Results contains the tool info, summary, and individual test outcomes. type Results struct { - Tool Tool `json:"tool"` + Tool Tool `json:"tool"` Summary Summary `json:"summary"` Tests []Test `json:"tests"` } diff --git a/web/src/components/TestCasesTable.tsx b/web/src/components/TestCasesTable.tsx index 580fd38..60ef18c 100644 --- a/web/src/components/TestCasesTable.tsx +++ b/web/src/components/TestCasesTable.tsx @@ -32,7 +32,9 @@ const statusWeight: Record = { export default function TestCasesTable({ testCases, -}: { testCases: TestCase[] }) { +}: { + testCases: TestCase[]; +}) { const [activeSortIndex, setActiveSortIndex] = useState( undefined, ); @@ -61,9 +63,7 @@ export default function TestCasesTable({ cases = cases.filter((tc) => tc.name.toLowerCase().includes(lower)); } if (statusFilters.size > 0) { - cases = cases.filter((tc) => - statusFilters.has(tc.status.toLowerCase()), - ); + cases = cases.filter((tc) => statusFilters.has(tc.status.toLowerCase())); } if (activeSortIndex !== undefined && activeSortDirection !== undefined) { @@ -84,7 +84,13 @@ export default function TestCasesTable({ } return cases; - }, [testCases, nameFilter, statusFilters, activeSortIndex, activeSortDirection]); + }, [ + testCases, + nameFilter, + statusFilters, + activeSortIndex, + activeSortDirection, + ]); const getSortParams = (columnIndex: number): ThProps["sort"] => ({ sortBy: { diff --git a/web/src/components/VulnerabilitiesTable.tsx b/web/src/components/VulnerabilitiesTable.tsx index 3f006d7..8bd9dd1 100644 --- a/web/src/components/VulnerabilitiesTable.tsx +++ b/web/src/components/VulnerabilitiesTable.tsx @@ -37,7 +37,9 @@ const severityColor: Record = { export default function VulnerabilitiesTable({ vulnerabilities, -}: { vulnerabilities: Vulnerability[] }) { +}: { + vulnerabilities: Vulnerability[]; +}) { const [activeSortIndex, setActiveSortIndex] = useState( undefined, ); @@ -72,9 +74,7 @@ export default function VulnerabilitiesTable({ ); } if (severityFilters.size > 0) { - vulns = vulns.filter((v) => - severityFilters.has(v.severity || "Unknown"), - ); + vulns = vulns.filter((v) => severityFilters.has(v.severity || "Unknown")); } if (activeSortIndex !== undefined && activeSortDirection !== undefined) { @@ -162,8 +162,7 @@ export default function VulnerabilitiesTable({ color: "var(--pf-t--global--text--color--subtle)", }} > - {processedVulns.length} of {vulnerabilities.length}{" "} - vulnerabilities + {processedVulns.length} of {vulnerabilities.length} vulnerabilities )} @@ -201,11 +200,7 @@ export default function VulnerabilitiesTable({ /> diff --git a/web/src/pages/ReleaseDetail.tsx b/web/src/pages/ReleaseDetail.tsx index 7efdd8b..b10caa0 100644 --- a/web/src/pages/ReleaseDetail.tsx +++ b/web/src/pages/ReleaseDetail.tsx @@ -83,12 +83,10 @@ export default function ReleaseDetail() { () => getReleaseReadiness(version!), ); - const [activeSnapshotTab, setActiveSnapshotTab] = useState< - string | number - >("components"); - const [expandedSuites, setExpandedSuites] = useState>( - new Set(), + const [activeSnapshotTab, setActiveSnapshotTab] = useState( + "components", ); + const [expandedSuites, setExpandedSuites] = useState>(new Set()); const [expandedComponents, setExpandedComponents] = useState>( new Set(), ); @@ -249,10 +247,8 @@ export default function ReleaseDetail() { {snapshot.components.map((c) => { const imgUrl = quayImageUrl(c.image_url); const imgDisplay = c.image_url.includes("/") - ? (c.image_url - .split("/") - .pop() - ?.split("@")[0] ?? c.image_url) + ? (c.image_url.split("/").pop()?.split("@")[0] ?? + c.image_url) : c.image_url; return ( @@ -289,216 +285,257 @@ export default function ReleaseDetail() { )} - {snapshot.test_suites && - snapshot.test_suites.length > 0 && ( - - Test Suites ({snapshot.test_suites.length}) - - } - > -
{v.link ? ( - + {v.name} ) : ( @@ -213,12 +208,7 @@ export default function VulnerabilitiesTable({ )} -
- - - - - - - - - - - - {snapshot.test_suites.map((ts) => { - const isSuiteExpanded = expandedSuites.has(ts.id); - return ( - - - - - - - - - + ); + })} +
- SuiteStatusToolPassedFailedSkippedTotal
- setExpandedSuites((prev) => { - const next = new Set(prev); - if (next.has(ts.id)) { - next.delete(ts.id); - } else { - next.add(ts.id); - } - return next; - }), - }} - /> - {ts.name} - - - {ts.tool_name} - {ts.tool_version - ? ` ${ts.tool_version}` - : ""} - - {ts.tests === 0 ? "\u2014" : ts.passed} - - {ts.tests === 0 ? "\u2014" : ts.failed} - - {ts.tests === 0 ? "\u2014" : ts.skipped} - - {ts.tests === 0 ? "\u2014" : ts.tests} + {snapshot.test_suites && snapshot.test_suites.length > 0 && ( + + Test Suites ({snapshot.test_suites.length}) + + } + > + + + + + + + + + + + + + {snapshot.test_suites.map((ts) => { + const isSuiteExpanded = expandedSuites.has(ts.id); + return ( + + + + + + + + + + + {isSuiteExpanded && ( + + - {isSuiteExpanded && ( - - - - )} - - ); - })} -
+ SuiteStatusToolPassedFailedSkippedTotal
+ setExpandedSuites((prev) => { + const next = new Set(prev); + if (next.has(ts.id)) { + next.delete(ts.id); + } else { + next.add(ts.id); + } + return next; + }), + }} + /> + {ts.name} + + + {ts.tool_name} + {ts.tool_version ? ` ${ts.tool_version}` : ""} + {ts.tests === 0 ? "\u2014" : ts.passed}{ts.tests === 0 ? "\u2014" : ts.failed}{ts.tests === 0 ? "\u2014" : ts.skipped}{ts.tests === 0 ? "\u2014" : ts.tests}
+ + {ts.test_cases && + ts.test_cases.length > 0 ? ( + + ) : ( + No test cases recorded. + )} +
- - {ts.test_cases && - ts.test_cases.length > 0 ? ( - - ) : ( - No test cases recorded. - )} - -
-
- )} + )} +
+
+ )} {groupedVulnReports.length > 0 && ( - - Security Scans ({groupedVulnReports.length}) - - } - > - - - - - - - - - - - - - - {groupedVulnReports.map((group, groupIdx) => { - const isExpanded = expandedComponents.has(group.component); - const selectedArch = - activeArchTab[group.component] ?? group.reports[0]?.arch; - const selectedReport = group.reports.find( - (r) => r.arch === selectedArch, - ); - return ( - - - + ); + })} +
- ComponentArchitecturesCriticalHighMediumLowTotalFixable
- setExpandedComponents((prev) => { - const next = new Set(prev); - if (next.has(group.component)) { - next.delete(group.component); - } else { - next.add(group.component); - } - return next; - }), - }} + + Security Scans ({groupedVulnReports.length}) + + } + > + + + + + + + + + + + + + + {groupedVulnReports.map((group, groupIdx) => { + const isExpanded = expandedComponents.has( + group.component, + ); + const selectedArch = + activeArchTab[group.component] ?? + group.reports[0]?.arch; + const selectedReport = group.reports.find( + (r) => r.arch === selectedArch, + ); + return ( + + + + + - - - - - + + + + + + + {isExpanded && selectedReport && ( + + - - - {isExpanded && selectedReport && ( - - - - )} - - ); - })} -
+ ComponentArchitecturesCriticalHighMediumLowTotalFixable
+ setExpandedComponents((prev) => { + const next = new Set(prev); + if (next.has(group.component)) { + next.delete(group.component); + } else { + next.add(group.component); + } + return next; + }), + }} + /> + {group.component}{group.reports.length} + - {group.component}{group.reports.length} - - - - - - - + + + + + + + {group.total}{group.fixable}
+ + + setActiveArchTab((prev) => ({ + ...prev, + [group.component]: String(key), + })) + } + > + {group.reports.map((rpt) => ( + + {rpt.arch} ({rpt.total}) + + } + > +
+ + + Critical:{" "} + + + + High:{" "} + + + + Medium:{" "} + + + + Low:{" "} + + + + Total: {rpt.total} + + + Fixable: {rpt.fixable} + + + {rpt.vulnerabilities && + rpt.vulnerabilities.length > 0 ? ( + + ) : ( + + No vulnerabilities recorded. + + )} +
+
+ ))} +
+
{group.total}{group.fixable}
- - - setActiveArchTab((prev) => ({ - ...prev, - [group.component]: String(key), - })) - } - > - {group.reports.map((rpt) => ( - - {rpt.arch} ({rpt.total}) - - } - > -
- - Critical: - High: - Medium: - Low: - Total: {rpt.total} - Fixable: {rpt.fixable} - - {rpt.vulnerabilities && - rpt.vulnerabilities.length > 0 ? ( - - ) : ( - No vulnerabilities recorded. - )} -
-
- ))} -
-
-
-
- )} + )} +
+
+ )} @@ -569,7 +606,12 @@ function ReleaseSignal({ const progressItems = [ { label: "Builds ready", done: buildsReady, warning: snapshot === null }, - { label: "Tests passed", done: allTestsPassed, warning: !hasTests, danger: hasTests && !allTestsPassed }, + { + label: "Tests passed", + done: allTestsPassed, + warning: !hasTests, + danger: hasTests && !allTestsPassed, + }, ...(issueSummary ? [{ label: "Bugs verified", done: bugsVerified }] : []), { label: "QE sign off", done: qeSignOff }, ]; @@ -654,14 +696,21 @@ function ReleaseSignal({ ); } -const severityLabelColor: Record = { - Critical: "red", - High: "red", - Medium: "orange", - Low: "yellow", -}; +const severityLabelColor: Record = + { + Critical: "red", + High: "red", + Medium: "orange", + Low: "yellow", + }; -function SeverityCount({ count, severity }: { count: number; severity: string }) { +function SeverityCount({ + count, + severity, +}: { + count: number; + severity: string; +}) { if (count === 0) return <>{"\u2014"}; return (