Convert flake report from Python to Go (#9334)

## What changed?
* Convert the flake report we use to monitor functional test reliability
from invoking the `tringa` cli tool and a Python script to only a Go
script that fetches the artifacts, parses the JUnit, and creates the
report.
* This also fixes an issue where `tringa` only found the last 20 tests,
the Go script will iterate through all failures in the last `N` days

## Why?
Everything is in a single executable making it easier to test and
extend.

## How did you test it?
- [X] built
- [X] run locally and tested manually
- [ ] covered by existing tests
- [X] added new unit test(s)
- [ ] added new functional test(s)

## Potential risks
Minimal, this is only a tooling change.
This commit is contained in:
Sean Kane
2026-02-18 16:53:51 -07:00
committed by GitHub
parent c08c773715
commit 42ba856e72
16 changed files with 1792 additions and 833 deletions
+14 -46
View File
@@ -40,65 +40,33 @@ jobs:
token: ${{ steps.generate_token.outputs.token }}
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
- name: Set up Go
uses: actions/setup-go@v5
with:
python-version: '3.13'
go-version-file: 'go.mod'
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: ">=0.8.0"
- name: Install Python dependencies
run: |
cd tools/flakes
uv sync
- name: Install tringa
run: |
uv tool install git+https://github.com/dandavison/tringa@main --force
- name: Run tringa
id: generate-output-file
env:
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
DAYS_PARAM: ${{ github.event.inputs.days || '7' }}
run: |
set -euo pipefail
# Create output directory
mkdir -p tools/flakes/out
tringa --json --since-days "$DAYS_PARAM" repo sql \
'select classname, name, artifact from test where passed = false and skipped = false order by classname, name, artifact desc' \
--branch main \
--workflow-id 80591745 \
https://github.com/temporalio/temporal > tools/flakes/out/out.json
echo "✅ Tringa command completed"
echo "📊 Output file size: $(wc -c < tools/flakes/out/out.json) bytes"
echo "📄 Full output file:"
cat tools/flakes/out/out.json
- name: Run Python script to process flaky tests
- name: Generate flaky test report
id: process-flaky-tests
env:
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
DAYS_PARAM: ${{ github.event.inputs.days || '7' }}
MAX_LINKS_PARAM: ${{ github.event.inputs.max_links || '3' }}
RUN_ID: ${{ github.run_id }}
REF_NAME: ${{ github.ref_name }}
SHA: ${{ github.sha }}
MAX_LINKS_PARAM: ${{ github.event.inputs.max_links || '3' }}
run: |
set -x
cd tools/flakes && uv run main.py \
--file out/out.json \
set -euo pipefail
go run ./cmd/tools/flakereport generate \
--days "$DAYS_PARAM" \
--max-links "$MAX_LINKS_PARAM" \
--output-dir tools/flakes/out \
--github-summary \
--slack-webhook "$SLACK_WEBHOOK" \
--run-id "$RUN_ID" \
--ref-name "$REF_NAME" \
--sha "$SHA" \
--max-links "$MAX_LINKS_PARAM"
--sha "$SHA"
- name: Upload generated reports
uses: actions/upload-artifact@v4
+15
View File
@@ -0,0 +1,15 @@
package main
import (
"fmt"
"os"
"go.temporal.io/server/tools/flakereport"
)
func main() {
if err := flakereport.NewCliApp().Run(os.Args); err != nil {
fmt.Fprintf(os.Stderr, "Error running flakereport: %v\n", err)
os.Exit(1)
}
}
+328
View File
@@ -0,0 +1,328 @@
package flakereport
import (
"context"
"fmt"
"os"
"time"
"github.com/urfave/cli/v2"
"go.temporal.io/server/common/headers"
)
const (
minFlakyFailures = 3
defaultMaxLinks = 3
defaultLookbackDays = 7
defaultWorkflowID = 80591745
defaultRepository = "temporalio/temporal"
defaultBranch = "main"
defaultConcurrency = 20
)
// 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.BoolFlag{
Name: "github-summary",
Usage: "Generate GitHub Actions step summary",
},
&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,
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,
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,
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")
enableGitHubSummary := c.Bool("github-summary")
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
grouped := groupFailuresByTest(allFailures)
fmt.Printf("Unique tests with failures: %d\n", len(grouped))
// Classify failures
flakyMap, timeoutMap, crashMap := classifyFailures(grouped)
fmt.Printf("Flaky tests (>%d failures): %d\n", minFlakyFailures, 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)
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 := convertToReports(crashMap, testRunCounts, repo, maxLinks)
ciBreakerReports := convertCIBreakersToReports(ciBreakerMap, ciBreakCounts, repo, maxLinks)
// Build summary
summary := buildReportSummary(
flakyReports, timeoutReports, crashReports, ciBreakerReports,
allFailures, allTestRuns, runs, successfulRuns,
)
// Write output files
fmt.Println("\n=== Writing report files ===")
if err = writeReportFiles(outputDir, summary, maxLinks); err != nil {
return fmt.Errorf("failed to write report files: %w", err)
}
// Write GitHub Actions summary
if enableGitHubSummary {
fmt.Println("\n=== Writing GitHub Actions summary ===")
summaryContent := generateGitHubSummary(summary, runID, maxLinks)
if err := writeGitHubSummary(summaryContent); err != nil {
fmt.Printf("Warning: Failed to write GitHub summary: %v\n", err)
}
}
// Send Slack notification
if slackWebhook != "" {
fmt.Println("\n=== Sending Slack notification ===")
_, flakySlack, _ := generateFlakyReport(summary.FlakyTests, maxLinks)
_, ciBreakerSlack := generateCIBreakerReport(summary.CIBreakers, maxLinks)
message := buildSuccessMessage(summary, flakySlack, ciBreakerSlack, runID, repo, days)
if err := sendSlackMessage(slackWebhook, message); err != nil {
fmt.Printf("Warning: Failed to send Slack notification: %v\n", err)
}
}
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 := sendSlackMessage(webhookURL, message); sendErr != nil {
fmt.Printf("Warning: Failed to send failure notification: %v\n", sendErr)
}
}
+219
View File
@@ -0,0 +1,219 @@
package flakereport
import (
"archive/zip"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// fetchWorkflowRuns retrieves all completed workflow runs within date range
// Implements proper pagination to fix the 100-run limit bug
func fetchWorkflowRuns(ctx context.Context, repo string, workflowID int64, branch string, sinceDays int) ([]WorkflowRun, error) {
var allRuns []WorkflowRun
sinceDate := time.Now().AddDate(0, 0, -sinceDays).Format("2006-01-02")
fmt.Printf("Fetching workflow runs since %s...\n", sinceDate)
page := 1
for {
ctxTimeout, cancel := context.WithTimeout(ctx, 30*time.Second)
cmd := exec.CommandContext(ctxTimeout, "gh", "api",
fmt.Sprintf("/repos/%s/actions/workflows/%d/runs?branch=%s&created=>=%s&per_page=100&page=%d",
repo, workflowID, branch, sinceDate, page),
)
output, err := cmd.Output()
cancel() // Cancel context immediately after command completes
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("gh api failed (page %d): %w\nstderr: %s", page, err, string(exitErr.Stderr))
}
return nil, fmt.Errorf("failed to execute gh command (page %d): %w", page, err)
}
var response struct {
WorkflowRuns []WorkflowRun `json:"workflow_runs"`
}
if err := json.Unmarshal(output, &response); err != nil {
return nil, fmt.Errorf("failed to parse workflow runs response (page %d): %w", page, err)
}
if len(response.WorkflowRuns) == 0 {
break
}
allRuns = append(allRuns, response.WorkflowRuns...)
fmt.Printf("Fetched page %d: %d runs (total: %d)\n", page, len(response.WorkflowRuns), len(allRuns))
// If we got fewer than 100 results, this is the last page
if len(response.WorkflowRuns) < 100 {
break
}
page++
}
fmt.Printf("Total workflow runs fetched: %d\n", len(allRuns))
return allRuns, nil
}
// fetchRunArtifacts retrieves all artifacts for a specific workflow run
func fetchRunArtifacts(ctx context.Context, repo string, runID int64) ([]WorkflowArtifact, error) {
ctxTimeout, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctxTimeout, "gh", "api",
fmt.Sprintf("/repos/%s/actions/runs/%d/artifacts?per_page=100", repo, runID),
)
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("gh api failed for run %d: %w\nstderr: %s", runID, err, string(exitErr.Stderr))
}
return nil, fmt.Errorf("failed to execute gh command for run %d: %w", runID, err)
}
var response ArtifactsResponse
if err := json.Unmarshal(output, &response); err != nil {
return nil, fmt.Errorf("failed to parse artifacts response for run %d: %w", runID, err)
}
// Filter for JUnit/test artifacts
var testArtifacts []WorkflowArtifact
for _, artifact := range response.Artifacts {
if artifact.Expired {
continue
}
name := strings.ToLower(artifact.Name)
if strings.Contains(name, "junit") {
testArtifacts = append(testArtifacts, artifact)
}
}
return testArtifacts, nil
}
// downloadArtifact downloads a single artifact zip file
func downloadArtifact(ctx context.Context, repo string, artifactID int64, outputDir string) (string, error) {
ctxTimeout, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
zipPath := filepath.Join(outputDir, fmt.Sprintf("artifact-%d.zip", artifactID))
cmd := exec.CommandContext(ctxTimeout, "gh", "api",
fmt.Sprintf("/repos/%s/actions/artifacts/%d/zip", repo, artifactID),
)
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return "", fmt.Errorf("failed to download artifact %d: %w\nstderr: %s", artifactID, err, string(exitErr.Stderr))
}
return "", fmt.Errorf("failed to download artifact %d: %w", artifactID, err)
}
if err := os.WriteFile(zipPath, output, 0644); err != nil {
return "", fmt.Errorf("failed to write artifact zip %d: %w", artifactID, err)
}
return zipPath, nil
}
// extractArtifactZip extracts zip file and returns paths to JUnit XML files
func extractArtifactZip(zipPath, outputDir string) ([]string, error) {
r, err := zip.OpenReader(zipPath)
if err != nil {
return nil, fmt.Errorf("failed to open zip file %s: %w", zipPath, err)
}
defer func() {
if err := r.Close(); err != nil {
fmt.Printf("Warning: Failed to close zip reader: %v\n", err)
}
}()
var xmlFiles []string
for _, f := range r.File {
// Skip directories
if f.FileInfo().IsDir() {
continue
}
// Only extract XML files
if !strings.HasSuffix(strings.ToLower(f.Name), ".xml") {
continue
}
// Create extraction path
extractPath := filepath.Join(outputDir, filepath.Base(f.Name))
// Open file from zip
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("failed to open file %s in zip: %w", f.Name, err)
}
// Create output file
outFile, err := os.Create(extractPath)
if err != nil {
_ = rc.Close()
return nil, fmt.Errorf("failed to create output file %s: %w", extractPath, err)
}
// Copy content
_, err = io.Copy(outFile, rc)
if closeErr := rc.Close(); closeErr != nil {
_ = outFile.Close()
return nil, fmt.Errorf("failed to close zip file reader: %w", closeErr)
}
if closeErr := outFile.Close(); closeErr != nil {
return nil, fmt.Errorf("failed to close output file: %w", closeErr)
}
if err != nil {
return nil, fmt.Errorf("failed to extract file %s: %w", f.Name, err)
}
xmlFiles = append(xmlFiles, extractPath)
}
return xmlFiles, nil
}
// parseArtifactName extracts run_id and job_id from artifact name
// Format: {prefix}--{run_id}--{job_id}--{suffix}
// Returns: runID, jobID (or "unknown" if not parseable)
func parseArtifactName(artifactName string) (runID string, jobID string) {
parts := strings.Split(artifactName, "--")
if len(parts) >= 3 {
runID = parts[1]
jobID = parts[2]
if jobID == "" {
jobID = "unknown"
}
return runID, jobID
}
return "unknown", "unknown"
}
// buildGitHubURL constructs GitHub Actions URL from run/job IDs
// If jobID == "unknown": https://github.com/{repo}/actions/runs/{runID}
// Otherwise: https://github.com/{repo}/actions/runs/{runID}/job/{jobID}
func buildGitHubURL(repo, runID, jobID string) string {
baseURL := fmt.Sprintf("https://github.com/%s/actions/runs/%s", repo, runID)
if jobID != "unknown" && jobID != "" {
return fmt.Sprintf("%s/job/%s", baseURL, jobID)
}
return baseURL
}
+151
View File
@@ -0,0 +1,151 @@
package flakereport
import (
"context"
"fmt"
"path/filepath"
"sync"
)
// ArtifactJob represents a job to download and process an artifact
type ArtifactJob struct {
Repo string
RunID int64
Artifact WorkflowArtifact
TempDir string
RunNumber int
TotalRuns int
ArtifactNum int
}
// ArtifactResult represents the result of processing an artifact
type ArtifactResult struct {
Failures []TestFailure
AllRuns []TestRun
Error error
Artifact WorkflowArtifact
}
// processArtifactsParallel downloads and processes artifacts in parallel with a worker pool
// Returns: all failures, all test runs, and count of successfully processed artifacts
func processArtifactsParallel(ctx context.Context, jobs []ArtifactJob, concurrency int) ([]TestFailure, []TestRun, int) {
if len(jobs) == 0 {
return nil, nil, 0
}
totalArtifacts := len(jobs)
// Create channels
jobChan := make(chan ArtifactJob, totalArtifacts)
resultChan := make(chan ArtifactResult, totalArtifacts)
// Start worker pool
var wg sync.WaitGroup
for i := 0; i < concurrency; i++ {
wg.Add(1)
go worker(ctx, jobChan, resultChan, totalArtifacts, &wg)
}
// Send jobs to workers
go func() {
for _, job := range jobs {
jobChan <- job
}
close(jobChan)
}()
// Wait for all workers to finish
go func() {
wg.Wait()
close(resultChan)
}()
// Collect results
var allFailures []TestFailure
var allTestRuns []TestRun
processedArtifacts := 0
errorCount := 0
for result := range resultChan {
if result.Error != nil {
errorCount++
// Error already logged by worker
continue
}
allFailures = append(allFailures, result.Failures...)
allTestRuns = append(allTestRuns, result.AllRuns...)
processedArtifacts++
}
if errorCount > 0 {
fmt.Printf("Warning: %d artifacts failed to process\n", errorCount)
}
return allFailures, allTestRuns, processedArtifacts
}
// worker processes jobs from the job channel
func worker(ctx context.Context, jobs <-chan ArtifactJob, results chan<- ArtifactResult, totalArtifacts int, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
result := processArtifactJob(ctx, job, totalArtifacts)
results <- result
}
}
// processArtifactJob downloads and processes a single artifact
func processArtifactJob(ctx context.Context, job ArtifactJob, totalArtifacts int) ArtifactResult {
result := ArtifactResult{
Artifact: job.Artifact,
}
fmt.Printf(" [%d/%d] Run %d/%d: Downloading artifact %s (ID: %d)...\n",
job.ArtifactNum, totalArtifacts, job.RunNumber, job.TotalRuns,
job.Artifact.Name, job.Artifact.ID)
// Download artifact
zipPath, err := downloadArtifact(ctx, job.Repo, job.Artifact.ID, job.TempDir)
if err != nil {
result.Error = fmt.Errorf("failed to download artifact %d: %w", job.Artifact.ID, err)
fmt.Printf(" Warning: %v\n", result.Error)
return result
}
// Extract XML files
xmlFiles, err := extractArtifactZip(zipPath, job.TempDir)
if err != nil {
result.Error = fmt.Errorf("failed to extract artifact %d: %w", job.Artifact.ID, err)
fmt.Printf(" Warning: %v\n", result.Error)
return result
}
fmt.Printf(" [%d/%d] Extracted %d XML files from %s\n",
job.ArtifactNum, totalArtifacts, len(xmlFiles), job.Artifact.Name)
// Parse JUnit XML files
for _, xmlFile := range xmlFiles {
suites, err := parseJUnitFile(xmlFile)
if err != nil {
fmt.Printf(" Warning: Failed to parse %s: %v\n", filepath.Base(xmlFile), err)
continue
}
// Extract failures
failures := extractFailures(suites, job.Artifact.Name, job.RunID)
result.Failures = append(result.Failures, failures...)
// Extract all test runs for failure rate calculation
testRuns := extractAllTestRuns(suites)
result.AllRuns = append(result.AllRuns, testRuns...)
}
fmt.Printf(" [%d/%d] Found %d failures from %d test runs in %s\n",
job.ArtifactNum, totalArtifacts, len(result.Failures), len(result.AllRuns), job.Artifact.Name)
for i := 0; i < len(result.Failures); i++ {
fmt.Printf(" Sample failure %d: %s\n", i+1, result.Failures[i].Name)
}
return result
}
+352
View File
@@ -0,0 +1,352 @@
package flakereport
import (
"encoding/xml"
"fmt"
"os"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/jstemmer/go-junit-report/v2/junit"
)
var retryRegex = regexp.MustCompile(`\s*\(retry \d+\)$`)
// 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
}
// 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) []TestFailure {
var failures []TestFailure
// Parse artifact name for run_id and job_id
_, jobID := 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,
ArtifactID: artifactName,
RunID: runID,
JobID: jobID,
Timestamp: time.Now(),
}
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) []TestRun {
var runs []TestRun
for _, suite := range suites.Suites {
for _, testcase := range suite.Testcases {
run := TestRun{
Name: testcase.Name,
Failed: testcase.Failure != nil,
Skipped: testcase.Skipped != nil,
}
runs = append(runs, run)
}
}
return runs
}
// normalizeTestName strips "(retry N)" suffix from test names
// Regex: \s*\(retry \d+\)$
func normalizeTestName(name string) string {
return retryRegex.ReplaceAllString(name, "")
}
// 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
}
// classifyFailures separates failures into categories
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 {
// Classify based on test name patterns
if strings.HasSuffix(testName, "(timeout)") {
timeout[testName] = failures
} else if strings.Contains(strings.ToLower(testName), "crash") {
crash[testName] = failures
}
// Flaky: more than MinFlakyFailures failures
if len(failures) >= minFlakyFailures {
flaky[testName] = failures
}
}
return flaky, timeout, crash
}
// 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 {
var reports []TestReport
for testName, failures := range grouped {
totalRuns := testRunCounts[testName]
if totalRuns == 0 {
totalRuns = len(failures) // Fallback if we don't have run counts
}
// Calculate failure rate per 1000 test runs
failureRate := 0.0
if totalRuns > 0 {
failureRate = (float64(len(failures)) / float64(totalRuns)) * 1000.0
}
report := TestReport{
TestName: testName,
FailureCount: len(failures),
TotalRuns: totalRuns,
FailureRate: failureRate,
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 failure rate descending (most problematic tests first)
sort.Slice(reports, func(i, j int) bool {
return reports[i].FailureRate > reports[j].FailureRate
})
return reports
}
// getRetryLevel determines the retry level from a test name
func getRetryLevel(testName string) string {
if strings.Contains(testName, "(retry 2)") {
return "retry2"
}
if strings.Contains(testName, "(retry 1)") {
return "retry1"
}
return "original"
}
// analyzeArtifactForCIBreakers analyzes a single artifact for CI breakers
// Returns map of test names that broke CI in this artifact
func analyzeArtifactForCIBreakers(artifactID string, artifactFailures []TestFailure) map[string][]TestFailure {
// Group by normalized name and track retry levels
testRetries := make(map[string]map[string][]TestFailure)
fmt.Printf("\nArtifact: %s (%d total failures)\n", artifactID, len(artifactFailures))
// Show first few raw failure names
for i, failure := range artifactFailures {
if i < 5 {
fmt.Printf(" Raw failure name: %s\n", failure.Name)
}
normalizedName := normalizeTestName(failure.Name)
retryLevel := getRetryLevel(failure.Name)
if testRetries[normalizedName] == nil {
testRetries[normalizedName] = make(map[string][]TestFailure)
}
testRetries[normalizedName][retryLevel] = append(testRetries[normalizedName][retryLevel], failure)
}
// Find tests with both retry1 and retry2 failures
fmt.Printf(" Unique tests (normalized): %d\n", len(testRetries))
ciBreakersInArtifact := make(map[string][]TestFailure)
for testName, retryLevels := range testRetries {
hasRetry1 := len(retryLevels["retry1"]) > 0
hasRetry2 := len(retryLevels["retry2"]) > 0
// Debug output for tests with retries
if hasRetry1 || hasRetry2 {
retryInfo := buildRetryInfo(retryLevels, hasRetry1, hasRetry2)
fmt.Printf(" %s: %s\n", testName, retryInfo)
}
// A test breaks CI if it failed BOTH retry 1 and retry 2
if hasRetry1 && hasRetry2 {
allFailures := append(retryLevels["retry1"], retryLevels["retry2"]...)
ciBreakersInArtifact[testName] = allFailures
fmt.Printf(" ✓ CI BREAKER FOUND: %s (failed retry 1 and retry 2)\n", testName)
}
}
return ciBreakersInArtifact
}
// buildRetryInfo builds a debug string showing retry level information
func buildRetryInfo(retryLevels map[string][]TestFailure, hasRetry1, hasRetry2 bool) string {
var retryInfo string
if hasRetry1 {
retryInfo = fmt.Sprintf("retry1: %d", len(retryLevels["retry1"]))
}
if hasRetry2 {
if retryInfo != "" {
retryInfo += ", "
}
retryInfo += fmt.Sprintf("retry2: %d", len(retryLevels["retry2"]))
}
return retryInfo
}
// convertCIBreakersToReports converts CI breaker failures to TestReport slice
// Includes the count of how many CI runs each test broke
func convertCIBreakersToReports(grouped map[string][]TestFailure, ciBreakCounts map[string]int, repo string, maxLinks int) []TestReport {
var reports []TestReport
for testName, failures := range grouped {
report := TestReport{
TestName: testName,
FailureCount: len(failures),
CIRunsBroken: ciBreakCounts[testName],
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 number of CI runs broken descending (most problematic tests first)
sort.Slice(reports, func(i, j int) bool {
if reports[i].CIRunsBroken != reports[j].CIRunsBroken {
return reports[i].CIRunsBroken > reports[j].CIRunsBroken
}
return reports[i].FailureCount > reports[j].FailureCount
})
return reports
}
// identifyCIBreakers finds tests that failed all retries in a single CI job
// A test breaks CI if it has both "(retry 1)" AND "(retry 2)" failures in the same 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)
totalArtifactsWithBreakers := 0
// Analyze each artifact for CI breakers
for artifactID, artifactFailures := range byArtifact {
breakersInArtifact := analyzeArtifactForCIBreakers(artifactID, artifactFailures)
if len(breakersInArtifact) > 0 {
totalArtifactsWithBreakers++
}
// Aggregate results
for testName, failures := range breakersInArtifact {
ciBreakers[testName] = append(ciBreakers[testName], failures...)
ciBreakCount[testName]++
}
}
// Print summary
printCIBreakerSummary(totalArtifactsWithBreakers, ciBreakers, ciBreakCount)
return ciBreakers, ciBreakCount
}
// printCIBreakerSummary prints the summary of CI breaker analysis
func printCIBreakerSummary(totalArtifacts int, ciBreakers map[string][]TestFailure, ciBreakCount map[string]int) {
fmt.Println("\n=== CI Breaker Summary ===")
fmt.Printf("Total artifacts with CI breakers: %d\n", totalArtifacts)
fmt.Printf("Unique tests that broke CI: %d\n", len(ciBreakers))
for testName, count := range ciBreakCount {
fmt.Printf(" %s: broke %d CI run(s)\n", testName, count)
}
}
+160
View File
@@ -0,0 +1,160 @@
package flakereport
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNormalizeTestName(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "test with retry suffix",
input: "TestSomething (retry 1)",
expected: "TestSomething",
},
{
name: "test with retry suffix and extra spaces",
input: "TestSomething (retry 5)",
expected: "TestSomething",
},
{
name: "test without retry suffix",
input: "TestSomething",
expected: "TestSomething",
},
{
name: "test with retry in name but not suffix",
input: "TestRetry",
expected: "TestRetry",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := normalizeTestName(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestParseArtifactName(t *testing.T) {
tests := []struct {
name string
artifactName string
expectedRunID string
expectedJobID string
}{
{
name: "valid artifact name",
artifactName: "test-results--12345678--87654321--junit",
expectedRunID: "12345678",
expectedJobID: "87654321",
},
{
name: "artifact name with empty job id",
artifactName: "test-results--12345678----junit",
expectedRunID: "12345678",
expectedJobID: "unknown",
},
{
name: "invalid artifact name",
artifactName: "test-results",
expectedRunID: "unknown",
expectedJobID: "unknown",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
runID, jobID := parseArtifactName(tt.artifactName)
assert.Equal(t, tt.expectedRunID, runID)
assert.Equal(t, tt.expectedJobID, jobID)
})
}
}
func TestBuildGitHubURL(t *testing.T) {
tests := []struct {
name string
repo string
runID string
jobID string
expectedURL string
}{
{
name: "with job ID",
repo: "temporalio/temporal",
runID: "12345678",
jobID: "87654321",
expectedURL: "https://github.com/temporalio/temporal/actions/runs/12345678/job/87654321",
},
{
name: "without job ID",
repo: "temporalio/temporal",
runID: "12345678",
jobID: "unknown",
expectedURL: "https://github.com/temporalio/temporal/actions/runs/12345678",
},
{
name: "with empty job ID",
repo: "temporalio/temporal",
runID: "12345678",
jobID: "",
expectedURL: "https://github.com/temporalio/temporal/actions/runs/12345678",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
url := buildGitHubURL(tt.repo, tt.runID, tt.jobID)
assert.Equal(t, tt.expectedURL, url)
})
}
}
func TestClassifyFailures(t *testing.T) {
grouped := map[string][]TestFailure{
"TestNormal": {
{Name: "TestNormal"}, {Name: "TestNormal"},
},
"TestFlaky": {
{Name: "TestFlaky"}, {Name: "TestFlaky"},
{Name: "TestFlaky"}, {Name: "TestFlaky"},
{Name: "TestFlaky"},
},
"TestTimeout (timeout)": {
{Name: "TestTimeout (timeout)"}, {Name: "TestTimeout (timeout)"},
},
"TestCrash": {
{Name: "TestCrash"}, {Name: "TestCrash"},
},
}
// Modify the test name to include "crash"
grouped["TestCrashHandler"] = []TestFailure{
{Name: "TestCrashHandler"}, {Name: "TestCrashHandler"},
}
delete(grouped, "TestCrash")
flaky, timeout, crash := classifyFailures(grouped)
// TestFlaky should be in flaky (>3 failures)
assert.Contains(t, flaky, "TestFlaky")
assert.Len(t, flaky["TestFlaky"], 5)
// TestTimeout should be in timeout
assert.Contains(t, timeout, "TestTimeout (timeout)")
assert.Len(t, timeout["TestTimeout (timeout)"], 2)
// TestCrashHandler should be in crash
assert.Contains(t, crash, "TestCrashHandler")
assert.Len(t, crash["TestCrashHandler"], 2)
// TestNormal should not be in flaky (only 2 failures)
assert.NotContains(t, flaky, "TestNormal")
}
+128
View File
@@ -0,0 +1,128 @@
package flakereport
import (
"fmt"
"strings"
)
// generateFlakyReport creates flaky test report (>3 failures)
// Markdown shows ALL tests, Slack text limited to top 10
// Returns: markdown content, slack plain text, total count
func generateFlakyReport(reports []TestReport, maxLinks int) (markdown, slackText string, totalCount int) {
if len(reports) == 0 {
return "", "", 0
}
totalCount = len(reports)
var mdLines []string
var slackLines []string
// Markdown: show ALL tests
for i := 0; i < totalCount; i++ {
report := reports[i]
mdLine := formatTestReportMarkdown(report.TestName, report.FailureCount, report.TotalRuns, report.FailureRate, report.GitHubURLs, maxLinks)
mdLines = append(mdLines, mdLine)
}
// Slack: limit to top 10 to keep message concise
slackDisplayCount := totalCount
if slackDisplayCount > 10 {
slackDisplayCount = 10
}
for i := 0; i < slackDisplayCount; i++ {
report := reports[i]
slackLine := formatTestReportPlainText(report.TestName, report.FailureCount, report.TotalRuns, report.FailureRate)
slackLines = append(slackLines, slackLine)
}
markdown = strings.Join(mdLines, "\n")
slackText = strings.Join(slackLines, "\n")
return markdown, slackText, totalCount
}
// generateStandardReport creates a report for a list of tests with failures
// Used for timeouts, crashes, and other categorized test failures
func generateStandardReport(reports []TestReport, maxLinks int) string {
if len(reports) == 0 {
return ""
}
var lines []string
for _, report := range reports {
line := formatTestReportMarkdown(report.TestName, report.FailureCount, report.TotalRuns, report.FailureRate, report.GitHubURLs, maxLinks)
lines = append(lines, line)
}
return strings.Join(lines, "\n")
}
// formatTestReportMarkdown formats a single test report line with markdown links
// Format: * {count} failures / {total} runs ({rate}/1000): `{test_name}` [1](url1) [2](url2) [3](url3)
func formatTestReportMarkdown(testName string, failureCount, totalRuns int, failureRate float64, urls []string, maxLinks int) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("* %d failures / %d runs (%.1f/1000): `%s`",
failureCount, totalRuns, failureRate, testName))
// Add numbered links
linkCount := len(urls)
if linkCount > maxLinks {
linkCount = maxLinks
}
for i := 0; i < linkCount; i++ {
sb.WriteString(fmt.Sprintf(" [%d](%s)", i+1, urls[i]))
}
return sb.String()
}
// formatTestReportPlainText formats a single test report line without links (for Slack)
// Format: • {count} failures / {total} runs ({rate}/1000): `{test_name}`
func formatTestReportPlainText(testName string, failureCount, totalRuns int, failureRate float64) string {
return fmt.Sprintf("• %d failures / %d runs (%.1f/1000): `%s`",
failureCount, totalRuns, failureRate, testName)
}
// generateCIBreakerReport creates CI breaker report (tests that failed all retries)
// Returns markdown and plain text versions
func generateCIBreakerReport(reports []TestReport, maxLinks int) (markdown, slackText string) {
if len(reports) == 0 {
return "", ""
}
var mdLines []string
var slackLines []string
for _, report := range reports {
// Use the pre-calculated CI runs broken count
brokenRuns := report.CIRunsBroken
mdLine := fmt.Sprintf("* %d CI run(s) broken: `%s` (%d total failures)",
brokenRuns, report.TestName, report.FailureCount)
// Add numbered links
linkCount := len(report.GitHubURLs)
if linkCount > maxLinks {
linkCount = maxLinks
}
for i := 0; i < linkCount; i++ {
mdLine += fmt.Sprintf(" [%d](%s)", i+1, report.GitHubURLs[i])
}
mdLines = append(mdLines, mdLine)
// Plain text version for Slack
slackLine := fmt.Sprintf("• %d CI run(s) broken: `%s` (%d total failures)",
brokenRuns, report.TestName, report.FailureCount)
slackLines = append(slackLines, slackLine)
}
markdown = strings.Join(mdLines, "\n")
slackText = strings.Join(slackLines, "\n")
return markdown, slackText
}
+208
View File
@@ -0,0 +1,208 @@
package flakereport
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
)
// SlackMessage represents Slack Block Kit message
type SlackMessage struct {
Text string `json:"text"`
Blocks []SlackBlock `json:"blocks"`
}
type SlackBlock struct {
Type string `json:"type"`
Text *SlackText `json:"text,omitempty"`
Fields []SlackText `json:"fields,omitempty"`
}
type SlackText struct {
Type string `json:"type"`
Text string `json:"text"`
}
// truncateToSlackLimit truncates text to stay within Slack's block text limit
// Slack blocks have a 3000 character limit per text field
func truncateToSlackLimit(text string, limit int) string {
if len(text) <= limit {
return text
}
return text[:limit-50] + "\n\n...(truncated due to length)"
}
// buildSuccessMessage creates success notification with report summary
func buildSuccessMessage(summary *ReportSummary, flakyContent, ciBreakerContent string, runID, repo string, days int) *SlackMessage {
// Calculate CI success rate
ciSuccessRate := 0.0
if summary.TotalWorkflowRuns > 0 {
ciSuccessRate = (float64(summary.SuccessfulRuns) / float64(summary.TotalWorkflowRuns)) * 100.0
}
// Summary stats
summaryText := fmt.Sprintf("*CI Success Rate:* %d/%d (%.2f%%)\n*Total Test Runs:* %d\n*Total Failures:* %d\n*Failure Rate:* %.2f per 1000 tests\n\n*CI Breakers:* %d\n*Crashes:* %d\n*Flaky Tests:* %d\n*Timeouts:* %d",
summary.SuccessfulRuns,
summary.TotalWorkflowRuns,
ciSuccessRate,
summary.TotalTestRuns,
summary.TotalFailures,
summary.OverallFailureRate,
len(summary.CIBreakers),
len(summary.Crashes),
summary.TotalFlakyCount,
len(summary.Timeouts))
// Build message
msg := &SlackMessage{
Text: "Flaky Tests Report Generated",
Blocks: []SlackBlock{
{
Type: "header",
Text: &SlackText{
Type: "plain_text",
Text: fmt.Sprintf("Flaky Tests Report - Last %d Days", days),
},
},
{
Type: "section",
Text: &SlackText{
Type: "mrkdwn",
Text: truncateToSlackLimit(summaryText, 2900), // Keep under 3000 char limit
},
},
},
}
// Add CI breakers details if present
if ciBreakerContent != "" {
ciBreakerText := fmt.Sprintf("*CI Breakers (Failed All Retries):*\n%s", ciBreakerContent)
msg.Blocks = append(msg.Blocks, SlackBlock{
Type: "section",
Text: &SlackText{
Type: "mrkdwn",
Text: truncateToSlackLimit(ciBreakerText, 2900),
},
})
}
// Add flaky tests details if present
if flakyContent != "" {
flakyText := fmt.Sprintf("*Flaky Tests Details:*\n%s", flakyContent)
msg.Blocks = append(msg.Blocks, SlackBlock{
Type: "section",
Text: &SlackText{
Type: "mrkdwn",
Text: truncateToSlackLimit(flakyText, 2900),
},
})
}
// Add link to report
if runID != "" {
linkURL := fmt.Sprintf("https://github.com/%s/actions/runs/%s", repo, runID)
msg.Blocks = append(msg.Blocks, SlackBlock{
Type: "section",
Text: &SlackText{
Type: "mrkdwn",
Text: fmt.Sprintf("<%s|Report & Artifacts>", linkURL),
},
})
}
return msg
}
// buildFailureMessage creates failure notification
func buildFailureMessage(runID, refName, sha, repo string) *SlackMessage {
msg := &SlackMessage{
Text: "Flaky Tests Report Generation Failed",
Blocks: []SlackBlock{
{
Type: "header",
Text: &SlackText{
Type: "plain_text",
Text: "Flaky Tests Report Generation Failed",
},
},
{
Type: "section",
Fields: []SlackText{
{
Type: "mrkdwn",
Text: fmt.Sprintf("*Run ID:*\n%s", runID),
},
{
Type: "mrkdwn",
Text: fmt.Sprintf("*Branch:*\n%s", refName),
},
{
Type: "mrkdwn",
Text: fmt.Sprintf("*Commit:*\n%s", sha[:7]),
},
{
Type: "mrkdwn",
Text: "*Status:*\nFailed",
},
},
},
},
}
// Add link to workflow run
if runID != "" {
linkURL := fmt.Sprintf("https://github.com/%s/actions/runs/%s", repo, runID)
msg.Blocks = append(msg.Blocks, SlackBlock{
Type: "section",
Text: &SlackText{
Type: "mrkdwn",
Text: fmt.Sprintf("<%s|View Workflow Run>", linkURL),
},
})
}
return msg
}
// sendSlackMessage sends message to webhook URL
func sendSlackMessage(webhookURL string, message *SlackMessage) error {
if webhookURL == "" {
return errors.New("webhook URL is empty")
}
jsonData, err := json.Marshal(message)
if err != nil {
return fmt.Errorf("failed to marshal message: %w", err)
}
client := &http.Client{
Timeout: 30 * time.Second,
}
req, err := http.NewRequest("POST", webhookURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send request: %w", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
fmt.Printf("Warning: Failed to close response body: %v\n", err)
}
}()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
fmt.Println("Slack notification sent successfully")
return nil
}
+68
View File
@@ -0,0 +1,68 @@
package flakereport
import "time"
// TestFailure represents a single test failure extracted from JUnit XML
type TestFailure struct {
ClassName string // Test class/module name
Name string // Test function name
ArtifactID string // Artifact identifier from GitHub
RunID int64 // GitHub Actions run ID
JobID string // GitHub Actions job ID (or "unknown")
Timestamp time.Time // When the test failure occurred
}
// TestRun represents a test execution (success or failure)
type TestRun struct {
Name string // Test name
Failed bool // Whether the test failed
Skipped bool // Whether the test was skipped
}
// TestReport represents aggregated failures for a single test
type TestReport struct {
TestName string // Normalized test name (retry suffix stripped)
FailureCount int // Total number of failures
TotalRuns int // Total number of times this test ran (including successes)
FailureRate float64 // Failures per 1000 test runs
CIRunsBroken int // Number of CI runs this test broke (for CI breakers only)
GitHubURLs []string // Up to max_links failure URLs
}
// ReportSummary contains all processed report data
type ReportSummary struct {
FlakyTests []TestReport // Tests with >3 failures
Timeouts []TestReport // Tests ending with "(timeout)"
Crashes []TestReport // Tests containing "crash"
CIBreakers []TestReport // Tests that failed all retries (3x) in a single job
TotalFailures int // Total raw failure count
TotalTestRuns int // Total test executions (all tests, all runs)
OverallFailureRate float64 // Overall failures per 1000 test runs
TotalFlakyCount int // Total flaky tests (not just top 10)
TotalWorkflowRuns int // Total workflow runs analyzed
SuccessfulRuns int // Workflow runs that succeeded
}
// WorkflowRun represents a GitHub Actions workflow run
type WorkflowRun struct {
ID int64 `json:"id"`
Number int `json:"run_number"`
CreatedAt time.Time `json:"created_at"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
HeadBranch string `json:"head_branch"`
}
// WorkflowArtifact represents a downloadable artifact
type WorkflowArtifact struct {
ID int64 `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
Expired bool `json:"expired"`
}
// ArtifactsResponse represents the GitHub API response for artifacts
type ArtifactsResponse struct {
TotalCount int `json:"total_count"`
Artifacts []WorkflowArtifact `json:"artifacts"`
}
+149
View File
@@ -0,0 +1,149 @@
package flakereport
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"time"
)
// writeReportFiles writes all report files to output directory
// Files: flaky.txt, flaky_slack.txt, flaky_count.txt, timeout.txt, crash.txt
func writeReportFiles(outputDir string, summary *ReportSummary, maxLinks int) error {
// Create output directory if it doesn't exist
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
// Generate report content (all flaky tests shown)
flakyMarkdown, flakySlack, flakyCount := generateFlakyReport(summary.FlakyTests, maxLinks)
timeoutMarkdown := generateStandardReport(summary.Timeouts, maxLinks)
crashMarkdown := generateStandardReport(summary.Crashes, maxLinks)
ciBreakerMarkdown, _ := generateCIBreakerReport(summary.CIBreakers, maxLinks)
// Write flaky.txt (markdown with links, top 10)
if err := os.WriteFile(filepath.Join(outputDir, "flaky.txt"), []byte(flakyMarkdown), 0644); err != nil {
return fmt.Errorf("failed to write flaky.txt: %w", err)
}
// Write flaky_slack.txt (plain text without links, top 10)
if err := os.WriteFile(filepath.Join(outputDir, "flaky_slack.txt"), []byte(flakySlack), 0644); err != nil {
return fmt.Errorf("failed to write flaky_slack.txt: %w", err)
}
// Write flaky_count.txt (total count of flaky tests)
countStr := strconv.Itoa(flakyCount)
if err := os.WriteFile(filepath.Join(outputDir, "flaky_count.txt"), []byte(countStr), 0644); err != nil {
return fmt.Errorf("failed to write flaky_count.txt: %w", err)
}
// Write timeout.txt (all timeouts)
if err := os.WriteFile(filepath.Join(outputDir, "timeout.txt"), []byte(timeoutMarkdown), 0644); err != nil {
return fmt.Errorf("failed to write timeout.txt: %w", err)
}
// Write crash.txt (all crashes)
if err := os.WriteFile(filepath.Join(outputDir, "crash.txt"), []byte(crashMarkdown), 0644); err != nil {
return fmt.Errorf("failed to write crash.txt: %w", err)
}
// Write ci_breakers.txt (tests that broke CI by failing all retries)
if err := os.WriteFile(filepath.Join(outputDir, "ci_breakers.txt"), []byte(ciBreakerMarkdown), 0644); err != nil {
return fmt.Errorf("failed to write ci_breakers.txt: %w", err)
}
fmt.Printf("Report files written to %s\n", outputDir)
return nil
}
// generateGitHubSummary creates markdown summary for GitHub Actions
func generateGitHubSummary(summary *ReportSummary, runID string, maxLinks int) string {
timestamp := time.Now().Format("2006-01-02 15:04:05")
var content string
content += fmt.Sprintf("## Flaky Tests Report - %s\n\n", timestamp)
// Overall statistics
content += "### Overall Statistics\n\n"
// CI success rate
ciSuccessRate := 0.0
if summary.TotalWorkflowRuns > 0 {
ciSuccessRate = (float64(summary.SuccessfulRuns) / float64(summary.TotalWorkflowRuns)) * 100.0
}
content += fmt.Sprintf("* **CI Success Rate**: %d/%d (%.2f%%)\n", summary.SuccessfulRuns, summary.TotalWorkflowRuns, ciSuccessRate)
content += fmt.Sprintf("* **Total Test Runs**: %d\n", summary.TotalTestRuns)
content += fmt.Sprintf("* **Total Failures**: %d\n", summary.TotalFailures)
content += fmt.Sprintf("* **Overall Failure Rate**: %.1f per 1000 tests\n\n", summary.OverallFailureRate)
// Summary table
content += "### Failure Categories Summary\n\n"
content += "| Category | Unique Tests |\n"
content += "|----------|--------------|\n"
content += fmt.Sprintf("| CI Breakers | %d |\n", len(summary.CIBreakers))
content += fmt.Sprintf("| Crashes | %d |\n", len(summary.Crashes))
content += fmt.Sprintf("| Timeouts | %d |\n", len(summary.Timeouts))
content += fmt.Sprintf("| Flaky Tests | %d |\n\n", summary.TotalFlakyCount)
// CI Breakers section (tests that failed all retries)
if len(summary.CIBreakers) > 0 {
content += "### CI Breakers (Failed All Retries)\n\n"
ciBreakerMarkdown, _ := generateCIBreakerReport(summary.CIBreakers, maxLinks)
content += ciBreakerMarkdown + "\n\n"
}
// Crashes section
if len(summary.Crashes) > 0 {
content += "### Crashes\n\n"
crashReport := generateStandardReport(summary.Crashes, maxLinks)
content += crashReport + "\n\n"
}
// Timeouts section
if len(summary.Timeouts) > 0 {
content += "### Timeouts\n\n"
timeoutReport := generateStandardReport(summary.Timeouts, maxLinks)
content += timeoutReport + "\n\n"
}
// Flaky tests section (show ALL tests)
if len(summary.FlakyTests) > 0 {
content += "### Flaky Tests\n\n"
flakyMarkdown, _, _ := generateFlakyReport(summary.FlakyTests, maxLinks)
content += flakyMarkdown + "\n\n"
}
// Link to run
if runID != "" {
content += fmt.Sprintf("\n[View Full Report & Artifacts](https://github.com/%s/actions/runs/%s)\n", defaultRepository, runID)
}
return content
}
// writeGitHubSummary writes summary to GITHUB_STEP_SUMMARY env var
func writeGitHubSummary(content string) error {
summaryFile := os.Getenv("GITHUB_STEP_SUMMARY")
if summaryFile == "" {
return errors.New("GITHUB_STEP_SUMMARY environment variable not set")
}
file, err := os.OpenFile(summaryFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("failed to open summary file: %w", err)
}
defer func() {
if err := file.Close(); err != nil {
fmt.Printf("Warning: Failed to close summary file: %v\n", err)
}
}()
if _, err := file.WriteString(content); err != nil {
return fmt.Errorf("failed to write summary: %w", err)
}
fmt.Println("GitHub Actions summary written successfully")
return nil
}
-1
View File
@@ -1 +0,0 @@
3.13
-86
View File
@@ -1,86 +0,0 @@
# Flaky Tests Analysis Tool
This tool processes test failure data and generates reports, with optional GitHub Actions summaries and Slack notifications.
## Usage
### Basic Processing
```bash
# Process test data and generate reports
uv run main.py --file out.json
```
### With GitHub Actions Summary
```bash
# Process data and generate GitHub Actions summary
uv run main.py \
--file out.json \
--github-summary \
--run-id "123456789"
```
### With Slack Notifications
```bash
# Process data, generate summary, and send Slack notification
uv run main.py \
--file out.json \
--github-summary \
--slack-webhook "https://hooks.slack.com/services/..." \
--run-id "123456789" \
--ref-name "main" \
--sha "abc123def456"
```
## Command Line Options
- `--file`, `-f`: Input JSON file to process (default: out.json)
- `--github-summary`: Generate GitHub Actions summary (writes to GITHUB_STEP_SUMMARY)
- `--slack-webhook`: Slack webhook URL for notifications
- `--run-id`: GitHub Actions run ID
- `--ref-name`: Git branch name (required for failure messages)
- `--sha`: Git commit SHA (required for failure messages)
## Output Files
The tool generates several report files:
- `flaky.txt`: Markdown table of flaky tests (includes all retry attempts grouped by test name)
- `flaky_count.txt`: Total count of flaky tests (for summary table)
- `flaky_slack.txt`: Plain text version for Slack
- `crash.txt`: Tests that crashed
- `timeout.txt`: Tests that timed out
## Features
### Retry Grouping
Tests with retry suffixes (e.g., "TestName (retry 1)", "TestName (retry 2)") are automatically grouped together under the base test name. This ensures that:
- All retry attempts are combined into a single entry in the flaky tests report
- Failure counts include all attempts (base + all retries)
- All failure links are preserved and displayed
- No separate "Retry Failures" section is needed since retries are grouped with their base tests
## GitHub Actions Integration
### Automatic Error Handling
- **Success Case**: Processes data, generates GitHub Actions summary, and sends success Slack notification
- **Failure Case**: Detects failures and sends Slack notification with workflow failure details
### GitHub Actions Summary
When `--github-summary` is specified, the tool:
- Creates a comprehensive summary with failure counts by category
- Includes detailed tables for each failure type
- Writes directly to the GitHub Actions step summary
- Provides links back to the workflow run
### Workflow Integration
The tool is typically called once in the workflow with all necessary parameters:
```yaml
- name: Run Python script to process flaky tests
run: |
cd tools/flakes && uv run main.py \
--file ../../out.json \
--github-summary \
--slack-webhook "${{ secrets.SLACK_WEBHOOK }}" \
--run-id "${{ github.run_id }}" \
--ref-name "${{ github.ref_name }}" \
--sha "${{ github.sha }}"
```
-604
View File
@@ -1,604 +0,0 @@
import argparse
import json
import os
import re
import sys
from datetime import datetime
from typing import Any, Dict
import requests
# Minimum number of failures for a test to be considered flaky
MIN_FLAKY_FAILURES = 3
def normalize_test_name(name: str) -> str:
"""
Strip retry suffix from test name.
Examples:
- "TestSomething (retry 1)" -> "TestSomething"
- "TestSomething (retry 2)" -> "TestSomething"
- "TestSomething" -> "TestSomething"
"""
return re.sub(r'\s*\(retry \d+\)$', '', name)
def process_tests(data, pattern, output_file: str, max_links: int = 3):
# Group data by test name and collect artifacts for tests matching pattern
test_groups = {}
for item in data:
name_parts = item["name"].split("/")
if len(name_parts) < 2:
continue
if not item["name"].endswith(pattern):
continue
test_name = normalize_test_name(item["name"])
if test_name not in test_groups:
test_groups[test_name] = []
parts = item["artifact"].split("--")
if len(parts) > 0 and len(parts[1]) > 0 and len(parts[2]) > 0:
p2 = parts[2]
if p2 == "unknown":
job_url = (
f"https://github.com/temporalio/temporal/actions/runs/{parts[1]}"
)
else:
job_url = f"https://github.com/temporalio/temporal/actions/runs/{parts[1]}/job/{p2}"
else:
job_url = item["artifact"]
test_groups[test_name].append(job_url)
# Transform into list with counts and multiple links
transformed = []
for test_name, artifacts in test_groups.items():
failure_count = len(artifacts)
# Get up to max_links most recent artifacts (already sorted desc from SQL)
recent_artifacts = artifacts[:max_links]
transformed.append({
"name": test_name,
"count": failure_count,
"artifacts": recent_artifacts,
})
# Sort by failure count descending
transformed.sort(key=lambda x: x["count"], reverse=True)
# Write bullet point format
lines = []
for item in transformed:
# Format: * XXX failures: `TestName` [1](url1) [2](url2) [3](url3)
links = " ".join([f"[{i+1}]({url})" for i, url in enumerate(item["artifacts"])])
lines.append(f"* {item['count']} failures: `{item['name']}` {links}\n")
with open(output_file, "w") as outfile:
outfile.writelines(lines)
def process_crash(data, pattern, output_file: str, max_links: int = 3):
# Group data by test name and collect artifacts for crash tests
test_groups = {}
for item in data:
if "crash" not in item["name"]:
continue
test_name = normalize_test_name(item["name"])
if test_name not in test_groups:
test_groups[test_name] = []
parts = item["artifact"].split("--")
if len(parts) > 0 and len(parts[1]) > 0:
job_url = (
f"https://github.com/temporalio/temporal/actions/runs/{parts[1]}"
)
else:
job_url = item["artifact"]
test_groups[test_name].append(job_url)
# Transform into list with counts and multiple links
transformed = []
for test_name, artifacts in test_groups.items():
failure_count = len(artifacts)
recent_artifacts = artifacts[:max_links]
transformed.append({
"name": test_name,
"count": failure_count,
"artifacts": recent_artifacts,
})
# Sort by failure count descending
transformed.sort(key=lambda x: x["count"], reverse=True)
# Write bullet point format
lines = []
for item in transformed:
links = " ".join([f"[{i+1}]({url})" for i, url in enumerate(item["artifacts"])])
lines.append(f"* {item['count']} failures: `{item['name']}` {links}\n")
with open(output_file, "w") as outfile:
outfile.writelines(lines)
def process_flaky(data, output_file: str, max_links: int = 3):
"""
Process flaky tests and generate reports.
Args:
data: Test failure data
output_file: Path to output file
max_links: Maximum number of failure links to include per test
Returns:
Total count of tests with > MIN_FLAKY_FAILURES
"""
# Group data by test name and collect artifacts
test_groups = {}
for item in data:
name_parts = item["name"].split("/")
if len(name_parts) < 2:
continue
test_name = normalize_test_name(item["name"])
if test_name not in test_groups:
test_groups[test_name] = []
parts = item["artifact"].split("--")
if len(parts) > 0 and len(parts[1]) > 0 and len(parts[2]) > 0:
p2 = parts[2]
if p2 == "unknown":
job_url = (
f"https://github.com/temporalio/temporal/actions/runs/{parts[1]}"
)
else:
job_url = f"https://github.com/temporalio/temporal/actions/runs/{parts[1]}/job/{p2}"
else:
job_url = item["artifact"]
test_groups[test_name].append(job_url)
# Transform into list with counts and multiple links
transformed = []
for test_name, artifacts in test_groups.items():
failure_count = len(artifacts)
# Only include tests that meet the minimum threshold
if failure_count <= MIN_FLAKY_FAILURES:
continue
# Get up to max_links most recent artifacts (already sorted desc from SQL)
recent_artifacts = artifacts[:max_links]
transformed.append({
"name": test_name,
"count": failure_count,
"artifacts": recent_artifacts,
})
# Sort by failure count descending
transformed.sort(key=lambda x: x["count"], reverse=True)
# Remember total count before limiting for display
total_count = len(transformed)
# Limit to top 10 flaky tests for display
display_tests = transformed[:10]
# Write bullet point format (for GitHub)
lines = []
for item in display_tests:
# Format: * XXX failures: `TestName` [1](url1) [2](url2) [3](url3)
links = " ".join([f"[{i+1}]({url})" for i, url in enumerate(item["artifacts"])])
lines.append(f"* {item['count']} failures: `{item['name']}` {links}\n")
with open(output_file, "w") as outfile:
outfile.writelines(lines)
# Also create a plain text version for Slack (without links for cleaner viewing)
slack_file = output_file.replace(".txt", "_slack.txt")
slack_lines = []
for item in display_tests:
# Format for Slack: • XXX failures: `TestName`
slack_lines.append(f"• {item['count']} failures: `{item['name']}`\n")
with open(slack_file, "w") as outfile:
outfile.writelines(slack_lines)
# Write count metadata for summary
count_file = output_file.replace(".txt", "_count.txt")
with open(count_file, "w") as f:
f.write(str(total_count))
return total_count
def create_success_message(
crash_count: int,
flaky_count: int,
timeout_count: int,
flaky_content: str,
run_id: str,
total_failures: int,
) -> Dict[str, Any]:
"""Create a success Slack message with flaky tests report."""
blocks = [
{
"type": "section",
"text": {"type": "mrkdwn", "text": "Flaky Tests Report - Last 7 Days"},
},
{
"type": "section",
"text": {"type": "mrkdwn", "text": f"Total Failures: {total_failures}"},
},
{
"type": "section",
"fields": [
{"type": "mrkdwn", "text": f"Crashes: {crash_count}"},
{"type": "mrkdwn", "text": f"Flaky Tests: {flaky_count}"},
{"type": "mrkdwn", "text": f"Timeouts: {timeout_count}"},
],
},
]
# Add flaky tests details if there are any
if flaky_count > 0 and flaky_content:
blocks.append(
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"Flaky Tests Details:\n{flaky_content}",
},
}
)
# Add link to full report
blocks.append(
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"<https://github.com/temporalio/temporal/actions/runs/{run_id}| Report & Artifacts>",
},
}
)
return {"text": "Flaky Tests Report - Last 7 Days", "blocks": blocks}
def create_failure_message(run_id: str, ref_name: str, sha: str) -> Dict[str, Any]:
"""Create a failure Slack message."""
blocks = [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Flaky Tests Report Generation Failed*",
},
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "The flaky tests report workflow failed to generate the report.",
},
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": f"*Run ID:* <https://github.com/temporalio/temporal/actions/runs/{run_id}|{run_id}>",
},
{"type": "mrkdwn", "text": f"*Branch:* `{ref_name}`"},
{
"type": "mrkdwn",
"text": f"*Commit:* <https://github.com/temporalio/temporal/commit/{sha}|{sha}>",
},
{"type": "mrkdwn", "text": "*Status:* Failed"},
],
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "Please check the workflow logs for more details.",
},
},
]
return {"text": "Flaky Tests Report Generation Failed", "blocks": blocks}
def send_slack_message(webhook_url: str, message: Dict[str, Any]) -> bool:
"""Send message to Slack webhook."""
try:
response = requests.post(
webhook_url,
json=message,
headers={"Content-Type": "application/json"},
timeout=30,
)
response.raise_for_status()
print(f"Slack message sent successfully (status: {response.status_code})")
return True
except requests.exceptions.RequestException as e:
print(f"Failed to send Slack message: {e}", file=sys.stderr)
return False
def read_flaky_content(
file_path: str, max_items: int = 10, max_length: int = 1000
) -> str:
"""Read and format flaky tests content for Slack."""
try:
if not os.path.exists(file_path):
return ""
with open(file_path, "r") as f:
lines = f.readlines()
# Take first max_items lines and format them
content_lines = []
for line in lines[:max_items]:
line = line.strip()
if line:
content_lines.append(line)
# Join with newlines for proper formatting in Slack
content = "\n".join(content_lines)
if len(content) > max_length:
content = content[:max_length] + "..."
return content
except Exception as e:
print(
f"Warning: Could not read flaky content from {file_path}: {e}",
file=sys.stderr,
)
return ""
def count_failures_in_file(file_path: str) -> int:
"""Count the number of failure entries in a report file."""
try:
if not os.path.exists(file_path):
return 0
with open(file_path, "r") as f:
content = f.read()
return content.count("* ")
except Exception:
return 0
def create_github_actions_summary(
crash_count: int,
flaky_count: int,
timeout_count: int,
run_id: str,
) -> str:
"""Create GitHub Actions summary content."""
summary_lines = []
# Header
summary_lines.append(f"## Flaky Tests Report - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
summary_lines.append("")
# Summary table
summary_lines.append("### Failure Categories Summary")
summary_lines.append("")
summary_lines.append("| Category | Unique Tests |")
summary_lines.append("|----------|--------------|")
summary_lines.append(f"| Crashes | {crash_count} |")
summary_lines.append(f"| Timeouts | {timeout_count} |")
summary_lines.append(f"| Flaky Tests | {flaky_count} |")
summary_lines.append("")
# Add detailed tables for each category
if crash_count > 0 and os.path.exists("out/crash.txt"):
summary_lines.append("### Crashes")
summary_lines.append("")
with open("out/crash.txt", "r") as f:
summary_lines.append(f.read())
summary_lines.append("")
if timeout_count > 0 and os.path.exists("out/timeout.txt"):
summary_lines.append("### Timeouts")
summary_lines.append("")
with open("out/timeout.txt", "r") as f:
summary_lines.append(f.read())
summary_lines.append("")
if flaky_count > 0 and os.path.exists("out/flaky.txt"):
summary_lines.append("### Flaky Tests")
summary_lines.append("")
with open("out/flaky.txt", "r") as f:
summary_lines.append(f.read())
summary_lines.append("")
if crash_count == 0 and flaky_count == 0 and timeout_count == 0:
summary_lines.append("**No test failures found in the last 7 days!**")
return "\n".join(summary_lines)
def write_github_actions_summary(summary_content: str) -> None:
"""Write GitHub Actions summary to the step summary file."""
try:
summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
if summary_file:
with open(summary_file, "w") as f:
f.write(summary_content)
print(f"GitHub Actions summary written to {summary_file}")
else:
print("GITHUB_STEP_SUMMARY environment variable not set, skipping summary creation")
except Exception as e:
print(f"Warning: Could not write GitHub Actions summary: {e}", file=sys.stderr)
def process_json_file(input_filename: str, max_links: int = 3):
with open(input_filename, "r") as file:
# Load the file content as JSON
data = json.load(file)
# Create output directory if it doesn't exist
os.makedirs("out", exist_ok=True)
process_flaky(data, "out/flaky.txt", max_links)
process_tests(data, "(timeout)", "out/timeout.txt", max_links)
process_crash(data, "(crash)", "out/crash.txt", max_links)
# Return total number of failures in the original data
return len(data)
def create_argument_parser() -> argparse.ArgumentParser:
"""Create and configure the argument parser."""
parser = argparse.ArgumentParser(
description="Process flaky test data, generate GitHub Actions summary, and optionally send Slack notifications"
)
parser.add_argument(
"--file",
"-f",
default="out.json",
help="Input JSON file to process (default: out.json)",
)
# GitHub Actions summary options
parser.add_argument(
"--github-summary",
action="store_true",
help="Generate GitHub Actions summary",
)
# Slack notification options
parser.add_argument("--slack-webhook", help="Slack webhook URL for notifications")
parser.add_argument("--run-id", help="GitHub Actions run ID")
parser.add_argument("--ref-name", help="Git branch name")
parser.add_argument("--sha", help="Git commit SHA")
# Display options
parser.add_argument(
"--max-links",
type=int,
default=3,
help="Maximum number of failure links to show per test (default: 3)",
)
return parser
def get_failure_counts() -> tuple[int, int, int]:
"""Count failures from generated report files."""
crash_count = count_failures_in_file("out/crash.txt")
# For flaky tests, read from metadata file which contains total count (not just top 10)
try:
with open("out/flaky_count.txt", "r") as f:
flaky_count = int(f.read().strip())
except (FileNotFoundError, ValueError):
# Fallback to counting lines if metadata doesn't exist
flaky_count = count_failures_in_file("out/flaky.txt")
timeout_count = count_failures_in_file("out/timeout.txt")
print(f"Failure counts - Crashes: {crash_count}, Flaky: {flaky_count}, Timeout: {timeout_count}")
return crash_count, flaky_count, timeout_count
def handle_success_case(args, total_failures: int) -> None:
"""Handle the successful processing case."""
# Count failures from generated files
crash_count, flaky_count, timeout_count = get_failure_counts()
# Generate GitHub Actions summary if requested
if args.github_summary:
print("Generating GitHub Actions summary...")
summary_content = create_github_actions_summary(
crash_count, flaky_count, timeout_count, args.run_id or "unknown"
)
write_github_actions_summary(summary_content)
if args.slack_webhook:
send_success_slack_notification(args, crash_count, flaky_count, timeout_count, total_failures)
def send_success_slack_notification(args, crash_count: int, flaky_count: int, timeout_count: int, total_failures: int) -> None:
"""Send success Slack notification."""
print("Sending success Slack notification...")
if not args.run_id:
print(
"Error: --run-id is required for success messages",
file=sys.stderr,
)
sys.exit(1)
# Read flaky content
flaky_content = read_flaky_content("out/flaky_slack.txt")
message = create_success_message(
crash_count,
flaky_count,
timeout_count,
flaky_content,
args.run_id,
total_failures,
)
# Send the message
if not send_slack_message(args.slack_webhook, message):
sys.exit(1)
def send_failure_slack_notification(args) -> None:
"""Send failure Slack notification."""
print("Sending failure Slack notification...")
if not all([args.run_id, args.ref_name, args.sha]):
print(
"Error: --run-id, --ref-name, and --sha are required for failure messages",
file=sys.stderr,
)
sys.exit(1)
message = create_failure_message(args.run_id, args.ref_name, args.sha)
if not send_slack_message(args.slack_webhook, message):
sys.exit(1)
def handle_failure_case(args, error_msg: str) -> None:
"""Handle the failure case with appropriate error reporting and notifications."""
print(error_msg, file=sys.stderr)
if args.slack_webhook:
send_failure_slack_notification(args)
sys.exit(1)
def main():
"""Main entry point for the flaky tests processing script."""
parser = create_argument_parser()
args = parser.parse_args()
# Try to process the JSON file and handle both success and failure cases
try:
total_failures = process_json_file(args.file, args.max_links)
print(f"Successfully processed {args.file}")
handle_success_case(args, total_failures)
except FileNotFoundError:
handle_failure_case(args, f"Error: File {args.file} not found")
except json.JSONDecodeError as e:
handle_failure_case(args, f"Error: Invalid JSON in {args.file}: {e}")
except Exception as e:
handle_failure_case(args, f"Error processing {args.file}: {e}")
if __name__ == "__main__":
main()
-9
View File
@@ -1,9 +0,0 @@
[project]
name = "flakes"
version = "0.1.0"
description = "A tool for analyzing flaky tests in the Temporal server repo"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"requests>=2.31.0",
]
-87
View File
@@ -1,87 +0,0 @@
version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "certifi"
version = "2025.8.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" },
{ url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" },
{ url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" },
{ url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" },
{ url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" },
{ url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" },
{ url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" },
{ url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" },
{ url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" },
{ url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" },
{ url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" },
{ url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" },
{ url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" },
{ url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" },
{ url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" },
{ url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" },
{ url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" },
{ url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" },
{ url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" },
{ url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" },
{ url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" },
{ url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" },
]
[[package]]
name = "flakes"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "requests" },
]
[package.metadata]
requires-dist = [{ name = "requests", specifier = ">=2.31.0" }]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "urllib3"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]