mirror of
https://github.com/hanzoai/tasks.git
synced 2026-11-03 03:56:04 +00:00
## What changed? Fixed Crash and CI Breakers reporting in flakereport. ## Why? Misleading aggregation. ## How did you test it? ``` go run ./cmd/tools/flakereport generate --days 7 --output-dir out ``` <details><summary>GitHub (abridged)</summary> <p> ### Crashes | Test | Crash Rate | Last Failure | Links | |------|------------|-------------|-------| | `unit-test` | 4.0% (1/25) | 3h ago | [1](https://github.com/temporalio/temporal/actions/runs/22966733319) [2](https://github.com/temporalio/temporal/actions/runs/22923165266) | | `functional-test` | 3.5% (35/1003) | 1h ago | [1](https://github.com/temporalio/temporal/actions/runs/22970016748/job/66684243853) [2](https://github.com/temporalio/temporal/actions/runs/22970016748/job/66684243820) [3](https://github.com/temporalio/temporal/actions/runs/22969997600/job/66684218138) | ### CI Breakers (Failed All Retries) | Test | CI Break Rate | Last Failure | Links | |------|------------|-------------|-------| | `TestNexusStateReplicationTestSuite/EnableTransitionHistory/TestNexusCallbackReplicated` | **50.0% (13/26)** | 2h ago | [1](https://github.com/temporalio/temporal/actions/runs/22967233794/job/66674252859) [2](https://github.com/temporalio/temporal/actions/runs/22931602170/job/66576965326) [3](https://github.com/temporalio/temporal/actions/runs/22929090805/job/66546593587) | | `TestFuncClustersTestSuite/DisableTransitionHistory/TestForceMigration_ClosedWorkflow` | **30.8% (8/26)** | 1h ago | [1](https://github.com/temporalio/temporal/actions/runs/22923524988/job/66528148344) [2](https://github.com/temporalio/temporal/actions/runs/22931281792/job/66553396416) [3](https://github.com/temporalio/temporal/actions/runs/22929738731/job/66548562937) | | `TestFuncClustersTestSuite/DisableTransitionHistory/TestForceMigration_ResetWorkflow` | **19.2% (5/26)** | 19h ago | [1](https://github.com/temporalio/temporal/actions/runs/22928356811/job/66544280229) [2](https://github.com/temporalio/temporal/actions/runs/22923524988/job/66528148344) [3](https://github.com/temporalio/temporal/actions/runs/22929738731/job/66548562947) | | `TestFuncClustersTestSuite/EnableTransitionHistory/TestForceMigration_ClosedWorkflow` | **19.2% (5/26)** | 2h ago | [1](https://github.com/temporalio/temporal/actions/runs/22967233794/job/66674252859) [2](https://github.com/temporalio/temporal/actions/runs/22931602170/job/66576965326) [3](https://github.com/temporalio/temporal/actions/runs/22929738731/job/66548563003) | | `TestFuncClustersTestSuite/EnableTransitionHistory/TestForceMigration_ResetWorkflow` | **15.4% (4/26)** | 2h ago | [1](https://github.com/temporalio/temporal/actions/runs/22928356811/job/66544280299) [2](https://github.com/temporalio/temporal/actions/runs/22941944027/job/66585445737) [3](https://github.com/temporalio/temporal/actions/runs/22926291741/job/66537603391) | | `TestDeploymentVersionSuiteV2/TestReactivationSignalCache_Deduplication_Reset` | **11.5% (3/26)** | 2h ago | [1](https://github.com/temporalio/temporal/actions/runs/22929090805/job/66546593717) [2](https://github.com/temporalio/temporal/actions/runs/22931281792/job/66553396454) [3](https://github.com/temporalio/temporal/actions/runs/22967233794/job/66674253006) | | `TestScheduleFunctionalSuite/TestListSchedulesReturnsWorkflowStatus` | **7.7% (2/26)** | 2h ago | [1](https://github.com/temporalio/temporal/actions/runs/22929090805/job/66546593717) [2](https://github.com/temporalio/temporal/actions/runs/22967233794/job/66674253006) | | `TestWorkerDeploymentSuiteV2/TestSetCurrentVersion_Batching` | **7.7% (2/26)** | 2h ago | [1](https://github.com/temporalio/temporal/actions/runs/22929090805/job/66546593717) [2](https://github.com/temporalio/temporal/actions/runs/22967233794/job/66674253006) | | `TestClientDataConverterTestSuite` | **7.7% (2/26)** | 2h ago | [1](https://github.com/temporalio/temporal/actions/runs/22923165266/job/66526932600) [2](https://github.com/temporalio/temporal/actions/runs/22967233794/job/66674253006) | | `TestVersioning3FunctionalSuiteV0/TestTransitionDuringTransientTask_WithSignal` | **7.7% (2/26)** | 2h ago | [1](https://github.com/temporalio/temporal/actions/runs/22967653083/job/66676176237) [2](https://github.com/temporalio/temporal/actions/runs/22967653083/job/66676176283) | | `TestWorkerDeploymentSuiteV2/TestResourceExhaustedErrors_Converted_To_ReadableMessage` | **7.7% (2/26)** | 19h ago | [1](https://github.com/temporalio/temporal/actions/runs/22931281792/job/66553396454) [2](https://github.com/temporalio/temporal/actions/runs/22923165266/job/66526932600) | | `TestDeploymentVersionSuiteV0/TestVersionMissingTaskQueues_ValidSetCurrentVersion` | 3.8% (1/26) | 28h ago | [1](https://github.com/temporalio/temporal/actions/runs/22912627598/job/66489344605) | | `TestVersioning3FunctionalSuiteV2/TestPinnedCaN_UpgradeOnCaN_NormalWFT_PinnedOverride_WithSuggest/ForceTaskForwardNoPollForwardAllowSync` | 3.8% (1/26) | 28h ago | [1](https://github.com/temporalio/temporal/actions/runs/22912627598/job/66489344605) | | `TestVersioning3FunctionalSuiteV0/TestPinnedCaN_UpgradeOnCaN_NormalWFT_WithSuggest/ForceTaskForwardNoPollForwardForceAsync` | 3.8% (1/26) | 21h ago | [1](https://github.com/temporalio/temporal/actions/runs/22929090805/job/66546593717) | | `TestDeploymentVersionSuiteV2/TestVersionScavenger_DeleteOnAdd` | 3.8% (1/26) | 28h ago | [1](https://github.com/temporalio/temporal/actions/runs/22912627598/job/66489344605) | | `TestVersioning3FunctionalSuiteV2/TestTransitionDuringTransientTask_WithSignal` | 3.8% (1/26) | 28h ago | [1](https://github.com/temporalio/temporal/actions/runs/22912627598/job/66489344605) | | `TestVersioning3FunctionalSuiteV2/TestVersionedQueueUnload` | 3.8% (1/26) | 2h ago | [1](https://github.com/temporalio/temporal/actions/runs/22967653083/job/66676176237) | | `TestScheduleFunctionalSuite/TestNextTimeCache` | 3.8% (1/26) | 19h ago | [1](https://github.com/temporalio/temporal/actions/runs/22931281792/job/66553396454) | | `TestVersioningFunctionalSuite/TestDispatchActivityFail/NoTaskForwardForcePollForwardForceAsync` | 3.8% (1/26) | 23h ago | [1](https://github.com/temporalio/temporal/actions/runs/22923165266/job/66526932600) | | `TestPrioritySuite/TestSubqueue_Migration` | 3.8% (1/26) | 21h ago | [1](https://github.com/temporalio/temporal/actions/runs/22929090805/job/66546593717) | | `TestAdvancedVisibilitySuite/TestWorkerTaskReachability_Unversioned_InTaskQueue` | 3.8% (1/26) | 21h ago | [1](https://github.com/temporalio/temporal/actions/runs/22929090805/job/66546593717) | | `TestDeploymentVersionSuiteV2/TestDeleteVersion_DrainingVersion` | 3.8% (1/26) | 28h ago | [1](https://github.com/temporalio/temporal/actions/runs/22912627598/job/66489344605) | | `TestDeploymentVersionSuiteV0/TestStartWorkflowExecution_ReactivateVersionOnPinned_WithConflictPolicy` | 3.8% (1/26) | 28h ago | [1](https://github.com/temporalio/temporal/actions/runs/22912627598/job/66489344605) | | `TestTaskQueueStats_Classic_Suite/TestCurrentAbsorbsUnversionedBacklog_WhenRampingToUnversioned/ForceTaskForwardForcePollForwardAllowSync` | 3.8% (1/26) | 2h ago | [1](https://github.com/temporalio/temporal/actions/runs/22967653083/job/66676176237) | | `TestWorkflowUpdateSuite/SpeculativeWorkflowTask_QueryFailureClearsWFContext` | 3.8% (1/26) | 28h ago | [1](https://github.com/temporalio/temporal/actions/runs/22912627598/job/66489344605) | | `TestTaskQueueStats_Classic_Suite/TestMultipleTasks_WithMatchingBehavior_ValidateStats/ForceTaskForwardForcePollForwardForceAsync` | 3.8% (1/26) | 19h ago | [1](https://github.com/temporalio/temporal/actions/runs/22931281792/job/66553396454) | | `TestTaskQueueStats_Pri_Suite/TestRampingAndCurrentAbsorbUnversionedBacklog/ForceTaskForwardNoPollForwardForceAsync` | 3.8% (1/26) | 19h ago | [1](https://github.com/temporalio/temporal/actions/runs/22931281792/job/66553396454) | | `TestVersioningFunctionalSuite/TestDispatchActivityTimeoutCrossTq/NoTaskForwardForcePollForwardAllowSync` | 3.8% (1/26) | 2h ago | [1](https://github.com/temporalio/temporal/actions/runs/22967233794/job/66674253006) | | `TestWorkerDeploymentSuiteV0/TestDrainRollbackedVersion` | 3.8% (1/26) | 2h ago | [1](https://github.com/temporalio/temporal/actions/runs/22967233794/job/66674253006) | | `TestWorkerDeploymentSuiteV0/TestSetWorkerDeploymentRampingVersion_SetRampingAsCurrent` | 3.8% (1/26) | 28h ago | [1](https://github.com/temporalio/temporal/actions/runs/22912627598/job/66489344605) | | `TestStandaloneActivityTestSuite/TestHeartbeat/ActivityRetriesOnHeartbeatTimeout` | 3.8% (1/26) | 23h ago | [1](https://github.com/temporalio/temporal/actions/runs/22923165266/job/66526932600) | | `TestTaskQueueStats_Classic_Suite/TestMultipleTasks_WithMatchingBehavior_ValidateStats/NoTaskForwardForcePollForwardForceAsync` | 3.8% (1/26) | 23h ago | [1](https://github.com/temporalio/temporal/actions/runs/22923165266/job/66526932600) | | `TestTaskQueueStats_Pri_Suite/TestMultipleTasks_WithMatchingBehavior_ValidateStats/ForceTaskForwardForcePollForwardAllowSync` | 3.8% (1/26) | 2h ago | [1](https://github.com/temporalio/temporal/actions/runs/22967653083/job/66676176237) | | `TestWorkerDeploymentSuiteV0/TestSetRampingVersion_Concurrent_DifferentVersions_NoUnexpectedErrors` | 3.8% (1/26) | 19h ago | [1](https://github.com/temporalio/temporal/actions/runs/22931281792/job/66553396454) | | `TestDeploymentVersionSuiteV0/TestVersionMissingTaskQueues_ValidSetRampingVersion` | 3.8% (1/26) | 28h ago | [1](https://github.com/temporalio/temporal/actions/runs/22912627598/job/66489344605) | | `TestDeploymentVersionSuiteV2/TestVersionIgnoresDrainageSignalWhenCurrentOrRamping` | 3.8% (1/26) | 28h ago | [1](https://github.com/temporalio/temporal/actions/runs/22912627598/job/66489344605) | | `TestVersioning3FunctionalSuiteV0/TestPinnedCaN_UpgradeOnCaN_TransientWFT_WithSuggest/ForceTaskForwardForcePollForwardAllowSync` | 3.8% (1/26) | 19h ago | [1](https://github.com/temporalio/temporal/actions/runs/22931281792/job/66553396454) | | `TestStandaloneActivityTestSuite/TestDescribeActivityExecution_DeadlineExceeded/NoCallerDeadline` | 3.8% (1/26) | 1h ago | [1](https://github.com/temporalio/temporal/actions/runs/22970016748/job/66684243820) | | `TestAdvancedVisibilitySuiteLegacy/TestUpsertWorkflowExecutionSearchAttributes` | 3.8% (1/26) | 21h ago | [1](https://github.com/temporalio/temporal/actions/runs/22929090805/job/66546593717) | | `TestVersioning3FunctionalSuiteV2/TestPinnedCaN_UpgradeOnCaN_NormalWFT_NoSuggest/ForceTaskForwardForcePollForwardForceAsync` | 3.8% (1/26) | 28h ago | [1](https://github.com/temporalio/temporal/actions/runs/22912627598/job/66489344605) | | `TestDeploymentVersionSuiteV0/TestVersionIgnoresDrainageSignalWhenCurrentOrRamping` | 3.8% (1/26) | 28h ago | [1](https://github.com/temporalio/temporal/actions/runs/22912627598/job/66489344605) | | `TestVersioning3FunctionalSuiteV2/TestAutoUpgradeCaN_UpgradeOnCaN/ForceTaskForwardForcePollForwardAllowSync` | 3.8% (1/26) | 21h ago | [1](https://github.com/temporalio/temporal/actions/runs/22929090805/job/66546593717) | | `TestDeploymentVersionSuiteV0/TestUpdateWorkflowExecutionOptions_SetImpliedPinnedSuccess` | 3.8% (1/26) | 28h ago | [1](https://github.com/temporalio/temporal/actions/runs/22912627598/job/66489344605) | | `TestDeploymentVersionSuiteV0/TestReactivationSignalCache_Deduplication_StartWorkflow` | 3.8% (1/26) | 28h ago | [1](https://github.com/temporalio/temporal/actions/runs/22912627598/job/66489344605) | | `TestDeploymentVersionSuiteV2/TestVersionMissingTaskQueues_ValidSetRampingVersion` | 3.8% (1/26) | 28h ago | [1](https://github.com/temporalio/temporal/actions/runs/22912627598/job/66489344605) | | `TestDeploymentVersionSuiteV0/TestReactivationSignalCache_Deduplication_UpdateOptions` | 3.8% (1/26) | 122h ago | [1](https://github.com/temporalio/temporal/actions/runs/22775553239/job/66068056068) | | `TestNexusApiTestSuiteWithTemporalFailures/TestNexusCancelOperation_Outcomes/handler_timeout/ByNamespaceAndTaskQueue` | 3.8% (1/26) | 23h ago | [1](https://github.com/temporalio/temporal/actions/runs/22923165266/job/66526932600) | | `TestDeploymentVersionSuiteV0/TestStartWorkflowExecution_ReactivateVersionOnPinned` | 3.8% (1/26) | 2h ago | [1](https://github.com/temporalio/temporal/actions/runs/22967233794/job/66674253006) | | `TestCronTestSuite/TestCronWorkflow` | 3.8% (1/26) | 2h ago | [1](https://github.com/temporalio/temporal/actions/runs/22967653083/job/66676176283) | | `TestWorkerDeploymentSuiteV0/TestDeploymentVersionLimits` | 3.8% (1/26) | 23h ago | [1](https://github.com/temporalio/temporal/actions/runs/22923165266/job/66526932600) | </p> </details> <details><summary>Slack</summary> <p> Flaky Tests Report - Last 7 Days *CI Success Rate:* 9/26 (34.62%) *Total Test Runs:* 1134261 *Total Failures:* 3774 *Failure Rate:* 3.33 per 1000 tests *CI Breakers:* 50 *Crashes:* 2 *Flaky Tests:* 416 *Timeouts:* 0 *CI Breakers (top 10):* • 50.0% (13/26 runs): `TestNexusStateReplicationTestSuite/EnableTransitionHistory/TestNexusCallbackReplicated` • 30.8% (8/26 runs): `TestFuncClustersTestSuite/DisableTransitionHistory/TestForceMigration_ClosedWorkflow` • 19.2% (5/26 runs): `TestFuncClustersTestSuite/DisableTransitionHistory/TestForceMigration_ResetWorkflow` • 19.2% (5/26 runs): `TestFuncClustersTestSuite/EnableTransitionHistory/TestForceMigration_ClosedWorkflow` • 15.4% (4/26 runs): `TestFuncClustersTestSuite/EnableTransitionHistory/TestForceMigration_ResetWorkflow` • 11.5% (3/26 runs): `TestDeploymentVersionSuiteV2/TestReactivationSignalCache_Deduplication_Reset` • 7.7% (2/26 runs): `TestWorkerDeploymentSuiteV2/TestResourceExhaustedErrors_Converted_To_ReadableMessage` • 7.7% (2/26 runs): `TestClientDataConverterTestSuite` • 7.7% (2/26 runs): `TestWorkerDeploymentSuiteV2/TestSetCurrentVersion_Batching` • 7.7% (2/26 runs): `TestVersioning3FunctionalSuiteV0/TestTransitionDuringTransientTask_WithSignal` *Flaky Tests (top 10):* • 100.0% (4 failures): `DATA RACE: Data race detected` • 100.0% (24 failures): `TestClientDataConverterTestSuite` • 100.0% (4 failures): `PANIC: Fail in goroutine after TestWorkerDeploymentSuiteV2/TestConcurrentPollers_DifferentTaskQueues_SameVersion_SetCurrentVersion has completed — in TestDeploymentVersionSuiteV2/TestBatchUpdateWorkflowExecutionOptions_SetPinnedThenUnset` • 100.0% (36 failures): `PANIC: Fail in goroutine after TestWorkerDeploymentSuiteV2/TestConcurrentPollers_DifferentTaskQueues_SameVersion_SetCurrentVersion has completed — in TestWorkerDeploymentSuiteV2/TestConcurrentPollers_DifferentTaskQueues_SameVersion_SetCurrentVersion` • 100.0% (4 failures): `DATA RACE: Data race detected — in go.uber.org/zap/zaptest.TestingWriter.Write` • 49.3% (187 failures): `TestVersioning3FunctionalSuiteV2/TestTransitionDuringTransientTask_WithoutSignal` • 48.9% (183 failures): `TestVersioning3FunctionalSuiteV2/TestTransitionDuringTransientTask_WithSignal` • 47.5% (161 failures): `TestVersioning3FunctionalSuiteV0/TestTransitionDuringTransientTask_WithSignal` • 47.4% (172 failures): `TestVersioning3FunctionalSuiteV0/TestTransitionDuringTransientTask_WithoutSignal` • 27.2% (71 failures): `TestFuncClustersTestSuite/DisableTransitionHistory/TestForceMigration_ClosedWorkflow` </p> </details>
445 lines
15 KiB
Go
445 lines
15 KiB
Go
package flakereport
|
||
|
||
import (
|
||
"encoding/xml"
|
||
"fmt"
|
||
"os"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/jstemmer/go-junit-report/v2/junit"
|
||
)
|
||
|
||
var finalRegex = regexp.MustCompile(`\s*\(final\)$`)
|
||
var trailingSuffixRegex = regexp.MustCompile(`\s*\([^)]+\)$`)
|
||
|
||
// parseJUnitFile reads and parses a single JUnit XML file
|
||
func parseJUnitFile(filePath string) (*junit.Testsuites, error) {
|
||
file, err := os.Open(filePath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to open file %s: %w", filePath, err)
|
||
}
|
||
defer func() {
|
||
if err := file.Close(); err != nil {
|
||
fmt.Printf("Warning: Failed to close file %s: %v\n", filePath, err)
|
||
}
|
||
}()
|
||
|
||
var testsuites junit.Testsuites
|
||
decoder := xml.NewDecoder(file)
|
||
if err := decoder.Decode(&testsuites); err != nil {
|
||
// Try parsing as a single testsuite
|
||
if _, seekErr := file.Seek(0, 0); seekErr != nil {
|
||
return nil, fmt.Errorf("failed to seek file %s: %w", filePath, seekErr)
|
||
}
|
||
var testsuite junit.Testsuite
|
||
decoder = xml.NewDecoder(file)
|
||
if err := decoder.Decode(&testsuite); err != nil {
|
||
return nil, fmt.Errorf("failed to parse JUnit XML %s: %w", filePath, err)
|
||
}
|
||
testsuites.Suites = []junit.Testsuite{testsuite}
|
||
}
|
||
|
||
return &testsuites, nil
|
||
}
|
||
|
||
// topLevelTestName extracts the suite/top-level test name from a test name.
|
||
// For "TestSuiteV0/TestMethod" returns "TestSuiteV0".
|
||
// For "TestFoo" (no slash) returns "TestFoo".
|
||
func topLevelTestName(name string) string {
|
||
if idx := strings.IndexByte(name, '/'); idx >= 0 {
|
||
return name[:idx]
|
||
}
|
||
return name
|
||
}
|
||
|
||
// isGoTestSuite returns true if the name looks like a Go testify suite name
|
||
// (starts with "Test" and contains "Suite").
|
||
// e.g. TestDeploymentVersionSuiteV0, TestFunctionalSuite
|
||
func isGoTestSuite(name string) bool {
|
||
return strings.HasPrefix(name, "Test") && strings.Contains(name, "Suite")
|
||
}
|
||
|
||
// extractFailures extracts all test failures from parsed JUnit data
|
||
// Filters for: passed = false AND skipped = false (matching tringa SQL query)
|
||
func extractFailures(suites *junit.Testsuites, artifactName string, runID int64, timestamp time.Time) []TestFailure {
|
||
var failures []TestFailure
|
||
|
||
// Parse artifact name for run_id, job_id, and matrix_name
|
||
_, jobID, matrixName := parseArtifactName(artifactName)
|
||
|
||
for _, suite := range suites.Suites {
|
||
for _, testcase := range suite.Testcases {
|
||
// Filter: failure present AND not skipped
|
||
if testcase.Failure != nil && testcase.Skipped == nil {
|
||
failure := TestFailure{
|
||
ClassName: testcase.Classname,
|
||
Name: testcase.Name,
|
||
SuiteName: topLevelTestName(testcase.Name),
|
||
ArtifactID: artifactName,
|
||
RunID: runID,
|
||
JobID: jobID,
|
||
MatrixName: matrixName,
|
||
Timestamp: timestamp,
|
||
}
|
||
failures = append(failures, failure)
|
||
}
|
||
}
|
||
}
|
||
|
||
return failures
|
||
}
|
||
|
||
// extractAllTestRuns extracts all test runs (including successes) from parsed JUnit data
|
||
// Used for calculating failure rates
|
||
func extractAllTestRuns(suites *junit.Testsuites, runID int64, jobID, matrixName string) []TestRun {
|
||
var runs []TestRun
|
||
|
||
for _, suite := range suites.Suites {
|
||
for _, testcase := range suite.Testcases {
|
||
run := TestRun{
|
||
SuiteName: topLevelTestName(testcase.Name),
|
||
Name: testcase.Name,
|
||
Failed: testcase.Failure != nil,
|
||
Skipped: testcase.Skipped != nil,
|
||
RunID: runID,
|
||
JobID: jobID,
|
||
MatrixName: matrixName,
|
||
}
|
||
runs = append(runs, run)
|
||
}
|
||
}
|
||
|
||
return runs
|
||
}
|
||
|
||
// normalizeTestName strips all trailing parenthesized suffixes from test names,
|
||
// e.g. "(retry 1)", "(final)", "(timeout)".
|
||
func normalizeTestName(name string) string {
|
||
for {
|
||
stripped := trailingSuffixRegex.ReplaceAllString(name, "")
|
||
if stripped == name {
|
||
return name
|
||
}
|
||
name = stripped
|
||
}
|
||
}
|
||
|
||
// groupFailuresByTest groups failures by normalized test name
|
||
func groupFailuresByTest(failures []TestFailure) map[string][]TestFailure {
|
||
grouped := make(map[string][]TestFailure)
|
||
|
||
for _, failure := range failures {
|
||
normalizedName := normalizeTestName(failure.Name)
|
||
grouped[normalizedName] = append(grouped[normalizedName], failure)
|
||
}
|
||
|
||
return grouped
|
||
}
|
||
|
||
// countTestRuns counts total runs (including successes) by normalized test name
|
||
func countTestRuns(allRuns []TestRun) map[string]int {
|
||
counts := make(map[string]int)
|
||
|
||
for _, run := range allRuns {
|
||
// Only count non-skipped tests
|
||
if !run.Skipped {
|
||
normalizedName := normalizeTestName(run.Name)
|
||
counts[normalizedName]++
|
||
}
|
||
}
|
||
|
||
return counts
|
||
}
|
||
|
||
// classifyFailure returns "timeout", "crash", or "flaky" based on the test name.
|
||
// Uses Contains (not HasSuffix) so it works on both raw and normalized names.
|
||
func classifyFailure(name string) string {
|
||
lower := strings.ToLower(name)
|
||
if strings.Contains(lower, "(timeout)") {
|
||
return "timeout"
|
||
}
|
||
if strings.Contains(lower, "(crash)") {
|
||
return "crash"
|
||
}
|
||
return "flaky"
|
||
}
|
||
|
||
// classifyFailures separates failures into categories.
|
||
// Classifies using the raw failure name (not the normalized key) since
|
||
// normalizeTestName strips suffixes like "(timeout)".
|
||
func classifyFailures(grouped map[string][]TestFailure) (flaky, timeout, crash map[string][]TestFailure) {
|
||
flaky = make(map[string][]TestFailure)
|
||
timeout = make(map[string][]TestFailure)
|
||
crash = make(map[string][]TestFailure)
|
||
|
||
for testName, failures := range grouped {
|
||
switch classifyFailure(failures[0].Name) {
|
||
case "timeout":
|
||
timeout[testName] = failures
|
||
case "crash":
|
||
crash[testName] = failures
|
||
case "flaky":
|
||
flaky[testName] = failures
|
||
default:
|
||
panic("unknown failure classification: " + classifyFailure(failures[0].Name)) //nolint:forbidigo
|
||
}
|
||
}
|
||
|
||
return flaky, timeout, crash
|
||
}
|
||
|
||
// buildReports builds TestReport slice from grouped failures, sorts by FailureCount/TotalRuns descending.
|
||
// numerator and denominator return the rate components for each test.
|
||
func buildReports(grouped map[string][]TestFailure, numerator func(string, []TestFailure) int, denominator func(string, []TestFailure) int, repo string, maxLinks int) []TestReport {
|
||
var reports []TestReport
|
||
|
||
for testName, failures := range grouped {
|
||
// Find most recent failure
|
||
var lastFailure time.Time
|
||
for _, f := range failures {
|
||
if f.Timestamp.After(lastFailure) {
|
||
lastFailure = f.Timestamp
|
||
}
|
||
}
|
||
|
||
report := TestReport{
|
||
TestName: testName,
|
||
FailureCount: numerator(testName, failures),
|
||
TotalRuns: denominator(testName, failures),
|
||
LastFailure: lastFailure,
|
||
GitHubURLs: make([]string, 0, maxLinks),
|
||
}
|
||
|
||
// Add up to maxLinks URLs
|
||
for i := 0; i < len(failures) && i < maxLinks; i++ {
|
||
failure := failures[i]
|
||
runIDStr := strconv.FormatInt(failure.RunID, 10)
|
||
url := buildGitHubURL(repo, runIDStr, failure.JobID)
|
||
report.GitHubURLs = append(report.GitHubURLs, url)
|
||
}
|
||
|
||
reports = append(reports, report)
|
||
}
|
||
|
||
// Sort by rate descending (most problematic tests first)
|
||
sort.Slice(reports, func(i, j int) bool {
|
||
ri := float64(reports[i].FailureCount) / float64(max(reports[i].TotalRuns, 1))
|
||
rj := float64(reports[j].FailureCount) / float64(max(reports[j].TotalRuns, 1))
|
||
if ri != rj {
|
||
return ri > rj
|
||
}
|
||
return reports[i].FailureCount > reports[j].FailureCount
|
||
})
|
||
|
||
return reports
|
||
}
|
||
|
||
// convertToReports converts grouped failures to TestReport slice
|
||
// testRunCounts maps test name to total number of runs (including successes)
|
||
func convertToReports(grouped map[string][]TestFailure, testRunCounts map[string]int, repo string, maxLinks int) []TestReport {
|
||
return buildReports(grouped,
|
||
func(_ string, failures []TestFailure) int { return len(failures) },
|
||
func(name string, failures []TestFailure) int {
|
||
if n := testRunCounts[name]; n > 0 {
|
||
return n
|
||
}
|
||
return len(failures) // Fallback if we don't have run counts
|
||
},
|
||
repo, maxLinks)
|
||
}
|
||
|
||
// filterParentTests removes top-level test names from grouped when subtests of
|
||
// that parent were observed in testRunCounts. A top-level failure whose subtests
|
||
// ran in other CI jobs is already captured (with a correct denominator) in the
|
||
// Flaky Suites section, so including it in the per-test table produces a
|
||
// misleading 1/1 entry.
|
||
func filterParentTests(grouped map[string][]TestFailure, testRunCounts map[string]int) {
|
||
suitePrefix := make(map[string]bool, len(testRunCounts))
|
||
for name := range testRunCounts {
|
||
if idx := strings.IndexByte(name, '/'); idx >= 0 {
|
||
suitePrefix[name[:idx]] = true
|
||
}
|
||
}
|
||
for testName := range grouped {
|
||
if !strings.Contains(testName, "/") && suitePrefix[testName] {
|
||
delete(grouped, testName)
|
||
}
|
||
}
|
||
}
|
||
|
||
// isFinalRetry returns true if the test name has the "(final)" suffix,
|
||
// indicating the test runner exhausted all retries.
|
||
func isFinalRetry(testName string) bool {
|
||
return finalRegex.MatchString(testName)
|
||
}
|
||
|
||
// analyzeArtifactForCIBreakers analyzes a single artifact for CI breakers.
|
||
// A test is a CI breaker if it has a failure with the "(final)" suffix,
|
||
// meaning the test runner exhausted all retries.
|
||
func analyzeArtifactForCIBreakers(artifactID string, artifactFailures []TestFailure) map[string][]TestFailure {
|
||
ciBreakers := make(map[string][]TestFailure)
|
||
|
||
for _, failure := range artifactFailures {
|
||
if !isFinalRetry(failure.Name) {
|
||
continue
|
||
}
|
||
normalized := normalizeTestName(failure.Name)
|
||
ciBreakers[normalized] = append(ciBreakers[normalized], failure)
|
||
}
|
||
|
||
for testName := range ciBreakers {
|
||
fmt.Printf(" CI BREAKER: %s (artifact %s)\n", testName, artifactID)
|
||
}
|
||
|
||
return ciBreakers
|
||
}
|
||
|
||
// convertCrashesToReports converts crash failures to TestReport slice.
|
||
// Crashes are job-level events. The rate is unique-jobs-with-crash / total jobs of that type.
|
||
// The crash name (e.g. "functional-test") matches the artifact name suffix, so we count
|
||
// artifacts ending with "--<crash-name>" as the denominator.
|
||
func convertCrashesToReports(grouped map[string][]TestFailure, jobs []ArtifactJob, repo string, maxLinks int) []TestReport {
|
||
// Count artifacts per type suffix (e.g. "functional-test", "unit-test")
|
||
artifactsByType := make(map[string]int)
|
||
for _, job := range jobs {
|
||
name := job.Artifact.Name
|
||
if idx := strings.LastIndex(name, "--"); idx >= 0 {
|
||
artifactsByType[name[idx+2:]]++
|
||
}
|
||
}
|
||
|
||
return buildReports(grouped,
|
||
func(_ string, failures []TestFailure) int {
|
||
jobIDs := make(map[string]bool, len(failures))
|
||
for _, f := range failures {
|
||
jobIDs[f.JobID] = true
|
||
}
|
||
return len(jobIDs)
|
||
},
|
||
func(name string, _ []TestFailure) int {
|
||
if n := artifactsByType[name]; n > 0 {
|
||
return n
|
||
}
|
||
return 1 // Fallback to avoid division by zero
|
||
},
|
||
repo, maxLinks)
|
||
}
|
||
|
||
// convertCIBreakersToReports converts CI breaker failures to TestReport slice.
|
||
// totalWorkflowRuns is the total number of CI runs analyzed (denominator for break rate).
|
||
func convertCIBreakersToReports(grouped map[string][]TestFailure, ciBreakCounts map[string]int, totalWorkflowRuns int, repo string, maxLinks int) []TestReport {
|
||
return buildReports(grouped,
|
||
func(name string, _ []TestFailure) int { return ciBreakCounts[name] },
|
||
func(_ string, _ []TestFailure) int { return totalWorkflowRuns },
|
||
repo, maxLinks)
|
||
}
|
||
|
||
// identifyCIBreakers finds tests that failed their final retry in a CI job.
|
||
// A test breaks CI if it has a failure with the "(final)" suffix in an artifact.
|
||
// Returns: ciBreakers map and count of how many artifacts each test broke.
|
||
func identifyCIBreakers(failures []TestFailure) (map[string][]TestFailure, map[string]int) {
|
||
// Group failures by artifact ID first
|
||
byArtifact := make(map[string][]TestFailure)
|
||
for _, failure := range failures {
|
||
byArtifact[failure.ArtifactID] = append(byArtifact[failure.ArtifactID], failure)
|
||
}
|
||
|
||
fmt.Println("\n=== CI Breaker Analysis ===")
|
||
fmt.Printf("Total failures to analyze: %d\n", len(failures))
|
||
fmt.Printf("Grouped into %d artifacts\n", len(byArtifact))
|
||
|
||
// Track tests that broke CI
|
||
ciBreakers := make(map[string][]TestFailure)
|
||
ciBreakCount := make(map[string]int)
|
||
|
||
// Analyze each artifact for CI breakers
|
||
for artifactID, artifactFailures := range byArtifact {
|
||
breakersInArtifact := analyzeArtifactForCIBreakers(artifactID, artifactFailures)
|
||
|
||
// Aggregate results
|
||
for testName, failures := range breakersInArtifact {
|
||
ciBreakers[testName] = append(ciBreakers[testName], failures...)
|
||
ciBreakCount[testName]++
|
||
}
|
||
}
|
||
|
||
fmt.Printf("Unique tests that broke CI: %d\n", len(ciBreakers))
|
||
|
||
return ciBreakers, ciBreakCount
|
||
}
|
||
|
||
// suiteRunKey returns a string that uniquely identifies a single (CI run × DB config) pair.
|
||
// Each workflow run may spawn multiple matrix jobs sharing the same RunID but with distinct
|
||
// MatrixNames (DB configs). Keying by (RunID, MatrixName) ensures shards belonging to the
|
||
// same run+config are counted once, regardless of how many shard JobIDs they produce.
|
||
func suiteRunKey(runID int64, matrixName string) string {
|
||
return fmt.Sprintf("%d:%s", runID, matrixName)
|
||
}
|
||
|
||
// generateSuiteReports creates per-suite flake breakdown from all failures and test runs.
|
||
// Suite flake rate = % of (CI run × DB config) pairs where the suite had at least one
|
||
// non-retry failure.
|
||
func generateSuiteReports(allFailures []TestFailure, allTestRuns []TestRun) []SuiteReport {
|
||
// Track unique (CI run × DB config) pairs per suite (denominator).
|
||
// Using MatrixName avoids the inflation caused by per-shard JobIDs: suites whose
|
||
// test methods are spread across N shards would otherwise be counted N times per
|
||
// (run × DB config).
|
||
suiteRuns := make(map[string]map[string]bool)
|
||
for _, run := range allTestRuns {
|
||
if run.Skipped || !isGoTestSuite(run.SuiteName) {
|
||
continue
|
||
}
|
||
if suiteRuns[run.SuiteName] == nil {
|
||
suiteRuns[run.SuiteName] = make(map[string]bool)
|
||
}
|
||
suiteRuns[run.SuiteName][suiteRunKey(run.RunID, run.MatrixName)] = true
|
||
}
|
||
|
||
// Track (CI run × DB config) pairs with non-retry failures per suite (numerator)
|
||
suiteFailedRuns := make(map[string]map[string]bool)
|
||
suiteLastFailure := make(map[string]time.Time)
|
||
for _, failure := range allFailures {
|
||
if !isGoTestSuite(failure.SuiteName) {
|
||
continue
|
||
}
|
||
// Only report the original, complete run
|
||
if normalizeTestName(failure.Name) != failure.Name {
|
||
continue
|
||
}
|
||
if suiteFailedRuns[failure.SuiteName] == nil {
|
||
suiteFailedRuns[failure.SuiteName] = make(map[string]bool)
|
||
}
|
||
suiteFailedRuns[failure.SuiteName][suiteRunKey(failure.RunID, failure.MatrixName)] = true
|
||
if failure.Timestamp.After(suiteLastFailure[failure.SuiteName]) {
|
||
suiteLastFailure[failure.SuiteName] = failure.Timestamp
|
||
}
|
||
}
|
||
|
||
var reports []SuiteReport
|
||
for suiteName, runIDs := range suiteRuns {
|
||
failedRuns := len(suiteFailedRuns[suiteName])
|
||
if failedRuns == 0 {
|
||
continue
|
||
}
|
||
totalRuns := len(runIDs)
|
||
flakeRate := float64(failedRuns) / float64(totalRuns) * 100.0
|
||
reports = append(reports, SuiteReport{
|
||
SuiteName: suiteName,
|
||
FlakeRate: flakeRate,
|
||
FailedRuns: failedRuns,
|
||
TotalRuns: totalRuns,
|
||
LastFailure: suiteLastFailure[suiteName],
|
||
})
|
||
}
|
||
|
||
sort.Slice(reports, func(i, j int) bool {
|
||
return reports[i].SuiteName < reports[j].SuiteName
|
||
})
|
||
|
||
return reports
|
||
}
|