Files
tasks/tools/flakereport/flakereport.go
zeekay 5a601d081b rebrand: go.temporal.io/server -> github.com/hanzoai/tasks
Full module path rebrand from Temporal to Hanzo Tasks:
- go.mod module: github.com/hanzoai/tasks
- All internal Go imports updated (1906 files)
- cmd/server renamed to cmd/tasksd
- Binary name: tasksd (was temporal-server)
- Docker images: hanzoai/tasks (was temporalio/server)
- README rewritten with Hanzo Tasks branding
- LLM.md created with integration notes
- Makefile, goreleaser, CI workflows updated
- go.temporal.io/sdk and go.temporal.io/api imports preserved (separate repos)

Fork of Temporal.io (MIT license).
2026-03-19 10:36:40 -07:00

341 lines
10 KiB
Go

package flakereport
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/urfave/cli/v2"
"github.com/hanzoai/tasks/common/headers"
)
const (
minFlakyFailures = 3
defaultMaxLinks = 3
defaultLookbackDays = 7
defaultWorkflowID = 80591745
defaultRepository = "temporalio/temporal"
defaultBranch = "main"
defaultConcurrency = 20
slackMaxListItems = 10
)
// NewCliApp instantiates a new instance of the CLI application
func NewCliApp() *cli.App {
app := cli.NewApp()
app.Name = "flakesreport"
app.Usage = "Generate flaky test reports"
app.Version = headers.ServerVersion
app.Commands = []*cli.Command{
{
Name: "generate",
Usage: "Generate flaky test reports from GitHub Actions",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "repository",
Value: defaultRepository,
Usage: "GitHub repository (owner/repo)",
},
&cli.StringFlag{
Name: "branch",
Value: defaultBranch,
Usage: "Git branch to analyze",
},
&cli.IntFlag{
Name: "days",
Value: defaultLookbackDays,
Usage: "Number of days to look back",
},
&cli.Int64Flag{
Name: "workflow-id",
Value: defaultWorkflowID,
Usage: "GitHub Actions workflow ID",
},
&cli.IntFlag{
Name: "max-links",
Value: defaultMaxLinks,
Usage: "Maximum failure links per test",
},
&cli.IntFlag{
Name: "concurrency",
Value: defaultConcurrency,
Usage: "Number of parallel workers for artifact processing",
},
&cli.StringFlag{
Name: "output-dir",
Value: "out",
Usage: "Output directory for report files",
},
&cli.StringFlag{
Name: "slack-webhook",
Usage: "Slack webhook URL for notifications",
EnvVars: []string{"SLACK_WEBHOOK"},
},
&cli.StringFlag{
Name: "run-id",
Usage: "GitHub Actions run ID (for links)",
},
&cli.StringFlag{
Name: "ref-name",
Usage: "Git ref name (for failure messages)",
},
&cli.StringFlag{
Name: "sha",
Usage: "Git commit SHA (for failure messages)",
},
},
Action: runGenerateCommand,
},
}
return app
}
// fetchAndAnalyzeWorkflowRuns fetches workflow runs and counts successes
func fetchAndAnalyzeWorkflowRuns(ctx context.Context, repo string, workflowID int64, branch string, days int) ([]WorkflowRun, int, error) {
fmt.Println("\n=== Fetching workflow runs ===")
runs, err := fetchWorkflowRuns(ctx, repo, workflowID, branch, days)
if err != nil {
return nil, 0, fmt.Errorf("failed to fetch workflow runs: %w", err)
}
if len(runs) == 0 {
fmt.Println("No workflow runs found in the specified time range")
return nil, 0, nil
}
// Count successful runs
successfulRuns := 0
for _, run := range runs {
if run.Conclusion == "success" {
successfulRuns++
}
}
fmt.Printf("Workflow runs: %d total, %d successful (%.1f%% success rate)\n",
len(runs), successfulRuns, float64(successfulRuns)/float64(len(runs))*100.0)
return runs, successfulRuns, nil
}
// collectArtifactJobs collects all artifact jobs from workflow runs
func collectArtifactJobs(ctx context.Context, repo string, runs []WorkflowRun, tempDir string) ([]ArtifactJob, error) {
fmt.Println("\n=== Collecting artifacts ===")
var jobs []ArtifactJob
totalArtifacts := 0
for i, run := range runs {
artifacts, err := fetchRunArtifacts(ctx, repo, run.ID)
if err != nil {
fmt.Printf("Warning: Failed to fetch artifacts for run %d: %v\n", run.ID, err)
continue
}
if len(artifacts) == 0 {
fmt.Printf("Run %d/%d (ID: %d, CreatedAt: %s): No test artifacts found\n",
i+1, len(runs), run.ID, run.CreatedAt.Format(time.RFC3339Nano))
continue
}
fmt.Printf("Run %d/%d (ID: %d, CreatedAt: %s): Found %d artifacts\n",
i+1, len(runs), run.ID, run.CreatedAt.Format(time.RFC3339Nano), len(artifacts))
for _, artifact := range artifacts {
totalArtifacts++
jobs = append(jobs, ArtifactJob{
Repo: repo,
RunID: run.ID,
RunCreatedAt: run.CreatedAt,
Artifact: artifact,
TempDir: tempDir,
RunNumber: i + 1,
TotalRuns: len(runs),
ArtifactNum: totalArtifacts,
})
}
}
fmt.Printf("\nTotal artifacts to process: %d\n", totalArtifacts)
return jobs, nil
}
// buildReportSummary builds the complete report summary from processed data
func buildReportSummary(flakyReports, timeoutReports, crashReports, ciBreakerReports []TestReport,
suiteReports []SuiteReport,
allFailures []TestFailure, allTestRuns []TestRun, runs []WorkflowRun, successfulRuns int) *ReportSummary {
// Calculate overall failure rate
overallFailureRate := 0.0
totalTestRuns := len(allTestRuns)
if totalTestRuns > 0 {
overallFailureRate = (float64(len(allFailures)) / float64(totalTestRuns)) * 1000.0
}
fmt.Printf("Overall failure rate: %.2f per 1000 test runs\n", overallFailureRate)
return &ReportSummary{
FlakyTests: flakyReports,
Timeouts: timeoutReports,
Crashes: crashReports,
CIBreakers: ciBreakerReports,
Suites: suiteReports,
TotalFailures: len(allFailures),
TotalTestRuns: totalTestRuns,
OverallFailureRate: overallFailureRate,
TotalFlakyCount: len(flakyReports),
TotalWorkflowRuns: len(runs),
SuccessfulRuns: successfulRuns,
}
}
func runGenerateCommand(c *cli.Context) (err error) {
// Extract parameters
repo := c.String("repository")
branch := c.String("branch")
days := c.Int("days")
workflowID := c.Int64("workflow-id")
maxLinks := c.Int("max-links")
concurrency := c.Int("concurrency")
outputDir := c.String("output-dir")
slackWebhook := c.String("slack-webhook")
runID := c.String("run-id")
refName := c.String("ref-name")
sha := c.String("sha")
// Send failure notification on error
defer func() {
if err != nil {
sendFailureNotification(slackWebhook, runID, refName, sha, repo, err)
}
}()
fmt.Println("Starting flaky test report generation...")
fmt.Printf("Repository: %s\n", repo)
fmt.Printf("Branch: %s\n", branch)
fmt.Printf("Lookback days: %d\n", days)
fmt.Printf("Workflow ID: %d\n", workflowID)
fmt.Printf("Parallel workers: %d\n", concurrency)
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
// Fetch and analyze workflow runs
runs, successfulRuns, err := fetchAndAnalyzeWorkflowRuns(ctx, repo, workflowID, branch, days)
if err != nil {
return err
}
if len(runs) == 0 {
return nil
}
// Create temp directory for downloads
tempDir, err := os.MkdirTemp("", "flakereport-*")
if err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer func() {
if err := os.RemoveAll(tempDir); err != nil {
fmt.Printf("Warning: Failed to remove temp directory: %v\n", err)
}
}()
// Collect all artifacts
jobs, err := collectArtifactJobs(ctx, repo, runs, tempDir)
if err != nil {
return err
}
// Process artifacts in parallel
fmt.Println("\n=== Processing artifacts in parallel ===")
allFailures, allTestRuns, processedArtifacts := processArtifactsParallel(ctx, jobs, concurrency)
fmt.Println("\n=== Processing Results ===")
fmt.Printf("Total test runs: %d\n", len(allTestRuns))
fmt.Printf("Total test failures found: %d\n", len(allFailures))
fmt.Printf("Processed artifacts: %d\n", processedArtifacts)
// Count test runs by name for failure rate calculation
testRunCounts := countTestRuns(allTestRuns)
// Group failures by test name, then remove parent entries whose subtests were observed.
grouped := groupFailuresByTest(allFailures)
filterParentTests(grouped, testRunCounts)
fmt.Printf("Unique tests with failures: %d\n", len(grouped))
// Classify failures
flakyMap, timeoutMap, crashMap := classifyFailures(grouped)
fmt.Printf("Flaky tests: %d\n", len(flakyMap))
fmt.Printf("Timeout tests: %d\n", len(timeoutMap))
fmt.Printf("Crash tests: %d\n", len(crashMap))
// Identify CI breakers (tests that failed all retries in a single job)
ciBreakerMap, ciBreakCounts := identifyCIBreakers(allFailures)
filterParentTests(ciBreakerMap, testRunCounts)
fmt.Printf("CI breaker tests (failed all retries): %d\n", len(ciBreakerMap))
// Convert to reports with failure rates and sort
flakyReports := convertToReports(flakyMap, testRunCounts, repo, maxLinks)
timeoutReports := convertToReports(timeoutMap, testRunCounts, repo, maxLinks)
crashReports := convertCrashesToReports(crashMap, jobs, repo, maxLinks)
ciBreakerReports := convertCIBreakersToReports(ciBreakerMap, ciBreakCounts, len(runs), repo, maxLinks)
// Compute suite-level breakdown
suiteReports := generateSuiteReports(allFailures, allTestRuns)
fmt.Printf("Suites: %d\n", len(suiteReports))
// Build summary
summary := buildReportSummary(
flakyReports, timeoutReports, crashReports, ciBreakerReports,
suiteReports,
allFailures, allTestRuns, runs, successfulRuns,
)
fmt.Println("\n=== Writing report files ===")
if err = os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
// Write failure JSON data
if err = writeFailuresJSON(outputDir, allFailures, repo); err != nil {
return fmt.Errorf("failed to write failures.json: %w", err)
}
// Write GitHub summary (to GITHUB_STEP_SUMMARY if set, otherwise to output dir)
fmt.Println("\n=== Writing GitHub summary ===")
summaryContent := generateGitHubSummary(summary, runID, maxLinks)
if err := writeGitHubSummary(summaryContent, outputDir); err != nil {
fmt.Printf("Warning: Failed to write GitHub summary: %v\n", err)
}
// Build and send Slack message
message := buildSuccessMessage(summary, runID, repo, days)
if slackWebhook != "" {
fmt.Println("\n=== Sending Slack notification ===")
if err := message.send(slackWebhook); err != nil {
fmt.Printf("Warning: Failed to send Slack notification: %v\n", err)
}
} else {
md := message.renderMarkdown()
if writeErr := os.WriteFile(filepath.Join(outputDir, "slack-report.md"), []byte(md), 0644); writeErr != nil {
fmt.Printf("Warning: Failed to write slack-report.md: %v\n", writeErr)
}
}
fmt.Println("\n=== Report generation complete! ===")
return nil
}
func sendFailureNotification(webhookURL, runID, refName, sha, repo string, err error) {
if webhookURL == "" {
return
}
fmt.Println("Sending failure notification to Slack...")
message := buildFailureMessage(runID, refName, sha, repo)
if sendErr := message.send(webhookURL); sendErr != nil {
fmt.Printf("Warning: Failed to send failure notification: %v\n", sendErr)
}
}