mirror of
https://github.com/hanzoai/tasks.git
synced 2026-10-04 02:04:03 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
3.13
|
||||
@@ -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 }}"
|
||||
```
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
Generated
-87
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user