Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions actions/go-flaky-tests/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@

- Initial implementation of flaky test analysis action
- Loki integration for fetching test failure logs
- Git history analysis to find test authors
- Comprehensive test suite with golden file testing

### Features

- **Loki Log Analysis**: Fetches and parses test failure logs using LogQL
- **Flaky Test Detection**: Identifies tests that fail inconsistently across branches
- **Git Author Tracking**: Finds recent commits that modified flaky tests
- **Configurable Limits**: Top-K filtering to focus on most problematic tests
27 changes: 17 additions & 10 deletions actions/go-flaky-tests/README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# Go Flaky Tests

A GitHub Action that detects and analyzes flaky Go tests by fetching logs from Loki.
A GitHub Action that detects and analyzes flaky Go tests by fetching logs from Loki and finding their authors.

## Features

- **Loki Integration**: Fetches test failure logs from Loki using LogQL queries
- **Flaky Test Detection**: Identifies tests that fail inconsistently across different branches
- **Git History Analysis**: Finds test files and extracts recent commit authors

## Usage

Expand Down Expand Up @@ -35,14 +36,15 @@ jobs:

## Inputs

| Input | Description | Required | Default |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------- | ------- |
| `loki-url` | Loki endpoint URL | ✅ | - |
| `loki-username` | Username for Loki authentication | ❌ | - |
| `loki-password` | Password for Loki authentication. If using Grafana Cloud, then the access policy for this token needs the `logs:read` scope. | ❌ | - |
| `repository` | Repository name in 'owner/repo' format | ✅ | - |
| `time-range` | Time range for the query (e.g., '1h', '24h', '7d') | ❌ | `1h` |
| `top-k` | Include only the top K flaky tests by distinct branches count | ❌ | `3` |
| Input | Description | Required | Default |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------- | ------------------------- |
| `loki-url` | Loki endpoint URL | ✅ | - |
| `loki-username` | Username for Loki authentication | ❌ | - |
| `loki-password` | Password for Loki authentication. If using Grafana Cloud, then the access policy for this token needs the `logs:read` scope. | ❌ | - |
| `repository` | Repository name in 'owner/repo' format | ✅ | - |
| `time-range` | Time range for the query (e.g., '1h', '24h', '7d') | ❌ | `1h` |
| `repository-directory` | Relative path to the directory with a git repository | ❌ | `${{ github.workspace }}` |
| `top-k` | Include only the top K flaky tests by distinct branches count | ❌ | `3` |

## Outputs

Expand All @@ -57,6 +59,8 @@ jobs:
1. **Fetch Logs**: Queries Loki for test failure logs within the specified time range
2. **Parse Failures**: Extracts test names, branches, and workflow URLs from logs
3. **Detect Flaky Tests**: Identifies tests that fail on multiple branches or multiple times on main/master
4. **Find Test Files**: Locates test files in the repository using grep
5. **Extract Authors**: Uses `git log -L` to find recent commits that modified each test

## Flaky Test Detection Logic

Expand All @@ -76,13 +80,16 @@ Run the analysis locally using the provided script:
export LOKI_URL="your-loki-url"
export REPOSITORY="owner/repo"
export TIME_RANGE="24h"
export REPOSITORY_DIRECTORY="."

# Run the analysis
go run ./cmd/go-flaky-tests
./run-local.sh
```

## Requirements

- Go 1.22 or later
- Git repository with test files
- Access to Loki instance with test failure logs

## Output Format
Expand Down
5 changes: 5 additions & 0 deletions actions/go-flaky-tests/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ inputs:
description: "Time range for the query (e.g., '1h', '24h', '7d')"
required: false
default: "1h"
repository-directory:
description: "Relative path to the directory with a git repository"
required: false
default: ${{ github.workspace }}
top-k:
description: "Include only the top K flaky tests by distinct branches count in analysis"
required: false
Expand All @@ -44,4 +48,5 @@ runs:
LOKI_PASSWORD: ${{ inputs.loki-password }}
REPOSITORY: ${{ inputs.repository }}
TIME_RANGE: ${{ inputs.time-range }}
REPOSITORY_DIRECTORY: ${{ inputs.repository-directory }}
TOP_K: ${{ inputs.top-k }}
92 changes: 89 additions & 3 deletions actions/go-flaky-tests/cmd/go-flaky-tests/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,38 @@ import (
"os"
"path/filepath"
"strings"
"time"
)

type FileSystem interface {
WriteFile(filename string, data []byte, perm os.FileMode) error
}

type GitClient interface {
FindTestFile(testName string) (string, error)
TestCommits(filePath, testName string) ([]CommitInfo, error)
}

type TestFailureAnalyzer struct {
lokiClient LokiClient
gitClient GitClient
fileSystem FileSystem
}

type CommitInfo struct {
Hash string `json:"hash"`
Author string `json:"author"`
Timestamp time.Time `json:"timestamp"`
Title string `json:"title"`
}

type FlakyTest struct {
TestName string `json:"test_name"`
FilePath string `json:"file_path"`
TotalFailures int `json:"total_failures"`
BranchCounts map[string]int `json:"branch_counts"`
ExampleWorkflows []GithubActionsWorkflow `json:"example_workflows"`
RecentCommits []CommitInfo `json:"recent_commits"`
}

type GithubActionsWorkflow struct {
Expand All @@ -32,7 +48,17 @@ type GithubActionsWorkflow struct {
}

func (f *FlakyTest) String() string {
return fmt.Sprintf("%s (%d total failures)", f.TestName, f.TotalFailures)
var authors []string
for _, commit := range f.RecentCommits {
if commit.Author != "" && commit.Author != "unknown" {
authors = append(authors, commit.Author)
}
}
authorsStr := "unknown"
if len(authors) > 0 {
authorsStr = strings.Join(authors, ", ")
}
return fmt.Sprintf("%s (%d total failures; recently changed by %s)", f.TestName, f.TotalFailures, authorsStr)
}

type FailuresReport struct {
Expand All @@ -48,18 +74,20 @@ func (fs *DefaultFileSystem) WriteFile(filename string, data []byte, perm os.Fil
return os.WriteFile(filename, data, perm)
}

func NewTestFailureAnalyzer(loki LokiClient, fs FileSystem) *TestFailureAnalyzer {
func NewTestFailureAnalyzer(loki LokiClient, git GitClient, fs FileSystem) *TestFailureAnalyzer {
return &TestFailureAnalyzer{
lokiClient: loki,
gitClient: git,
fileSystem: fs,
}
}

func NewDefaultTestFailureAnalyzer(config Config) *TestFailureAnalyzer {
lokiClient := NewDefaultLokiClient(config)
gitClient := NewDefaultGitClient(config)
fileSystem := &DefaultFileSystem{}

return NewTestFailureAnalyzer(lokiClient, fileSystem)
return NewTestFailureAnalyzer(lokiClient, gitClient, fileSystem)
}

func (t *TestFailureAnalyzer) AnalyzeFailures(config Config) (*FailuresReport, error) {
Expand All @@ -84,6 +112,35 @@ func (t *TestFailureAnalyzer) AnalyzeFailures(config Config) (*FailuresReport, e
}

log.Printf("🧪 Found %d flaky tests that meet criteria", len(flakyTests))
log.Printf("📁 Finding test files in repository...")
err = t.findFilePaths(flakyTests)
if err != nil {
return nil, fmt.Errorf("failed to find file paths for flaky tests: %w", err)
}

log.Printf("👥 Finding authors of flaky tests...")
err = t.findTestAuthors(flakyTests)
if err != nil {
return nil, fmt.Errorf("failed to find test authors: %w", err)
}

for _, test := range flakyTests {
if len(test.RecentCommits) > 0 {
var authors []string
for _, commit := range test.RecentCommits {
if commit.Author != "" && commit.Author != "unknown" {
authors = append(authors, commit.Author)
}
}
if len(authors) > 0 {
log.Printf("👤 %s: %s", test.TestName, strings.Join(authors, ", "))
} else {
log.Printf("👤 %s: no authors found", test.TestName)
}
} else {
log.Printf("👤 %s: no commits found", test.TestName)
}
}

if flakyTests == nil {
flakyTests = []FlakyTest{}
Expand Down Expand Up @@ -146,6 +203,35 @@ func (t *TestFailureAnalyzer) generateReport(result FailuresReport) (string, err
return filepath.Abs(reportPath)
}

func (t *TestFailureAnalyzer) findFilePaths(flakyTests []FlakyTest) error {
for i, test := range flakyTests {
filePath, err := t.gitClient.FindTestFile(test.TestName)
if err != nil {
return fmt.Errorf("failed to find file path for test %s: %w", test.TestName, err)
}
flakyTests[i].FilePath = filePath
}
return nil
}

func (t *TestFailureAnalyzer) findTestAuthors(flakyTests []FlakyTest) error {
for i, test := range flakyTests {
commits, err := t.gitClient.TestCommits(test.FilePath, test.TestName)
if err != nil {
return fmt.Errorf("failed to get authors for test %s in %s: %w", test.TestName, test.FilePath, err)
}
flakyTests[i].RecentCommits = commits

if len(commits) > 0 {
var authors []string
for _, commit := range commits {
authors = append(authors, commit.Author)
}
}
}
return nil
}

func generateSummary(flakyTests []FlakyTest) string {
if len(flakyTests) == 0 {
return "No flaky tests found in the specified time range."
Expand Down
26 changes: 14 additions & 12 deletions actions/go-flaky-tests/cmd/go-flaky-tests/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,24 @@ import (
)

type Config struct {
LokiURL string
LokiUsername string
LokiPassword string
Repository string
TimeRange string
TopK int
LokiURL string
LokiUsername string
LokiPassword string
Repository string
TimeRange string
RepositoryDirectory string
TopK int
}

func getConfigFromEnv() Config {
return Config{
LokiURL: os.Getenv("LOKI_URL"),
LokiUsername: os.Getenv("LOKI_USERNAME"),
LokiPassword: os.Getenv("LOKI_PASSWORD"),
Repository: os.Getenv("REPOSITORY"),
TimeRange: getEnvWithDefault("TIME_RANGE", "24h"),
TopK: getIntEnvWithDefault("TOP_K", 3),
LokiURL: os.Getenv("LOKI_URL"),
LokiUsername: os.Getenv("LOKI_USERNAME"),
LokiPassword: os.Getenv("LOKI_PASSWORD"),
Repository: os.Getenv("REPOSITORY"),
TimeRange: getEnvWithDefault("TIME_RANGE", "24h"),
RepositoryDirectory: getEnvWithDefault("REPOSITORY_DIRECTORY", "."),
TopK: getIntEnvWithDefault("TOP_K", 3),
}
}

Expand Down
114 changes: 114 additions & 0 deletions actions/go-flaky-tests/cmd/go-flaky-tests/git.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package main

import (
"fmt"
"log"
"os/exec"
"strconv"
"strings"
"time"
)

type DefaultGitClient struct {
config Config
}

func NewDefaultGitClient(config Config) *DefaultGitClient {
return &DefaultGitClient{config: config}
}

func (g *DefaultGitClient) FindTestFile(testName string) (string, error) {
return findTestFilePath(g.config.RepositoryDirectory, testName)
}

func (g *DefaultGitClient) TestCommits(filePath, testName string) ([]CommitInfo, error) {
return getFileAuthors(g.config, filePath, testName)
}

func findTestFilePath(repoDir, testName string) (string, error) {
if !strings.HasPrefix(testName, "Test") {
return "", fmt.Errorf("invalid test name format: %s", testName)
}

grepCmd := exec.Command("grep", "-rl", "--include=*_test.go", fmt.Sprintf("func %s(", testName), ".")
grepCmd.Dir = repoDir

result, err := grepCmd.Output()
if err != nil {
return "", fmt.Errorf("failed to search for test function %s: %w", testName, err)
}

lines := strings.Split(strings.TrimSpace(string(result)), "\n")
if len(lines) > 0 && lines[0] != "" {
if len(lines) > 1 {
log.Printf("Warning: test function %s found in multiple files, using first match: %s", testName, lines[0])
}

filePath := strings.TrimPrefix(lines[0], "./")
return filePath, nil
}

return "", fmt.Errorf("test function %s not found in repository", testName)
}

func getFileAuthors(config Config, filePath, testName string) ([]CommitInfo, error) {
return getFileAuthorsWithClient(config.RepositoryDirectory, filePath, testName)
}

func getFileAuthorsWithClient(repoDir, filePath, testName string) ([]CommitInfo, error) {
// Get 10 commits, because some of them might just be only bots.
cmd := exec.Command("git", "log", "-10", "-L", fmt.Sprintf(":%s:%s", testName, filePath), "--pretty=format:%H|%ct|%s|%an", "-s")
cmd.Dir = repoDir

result, err := cmd.Output()
if err != nil {
log.Printf("Warning: failed to get git log for test %s in %s: %v", testName, filePath, err)
return []CommitInfo{}, nil
}

lines := strings.Split(strings.TrimSpace(string(result)), "\n")
if len(lines) == 0 || lines[0] == "" {
log.Printf("Warning: no git log results for test %s in %s", testName, filePath)
return []CommitInfo{}, nil
}

var commits []CommitInfo
sixMonthsAgo := time.Now().AddDate(0, -6, 0)

for _, line := range lines {
parts := strings.SplitN(strings.TrimSpace(line), "|", 4)
if len(parts) != 4 {
return nil, fmt.Errorf("invalid git log format for test %s in %s: %s", testName, filePath, line)
}

hash := parts[0]
timestampStr := parts[1]
title := parts[2]
author := parts[3]

var timestamp time.Time
if timestampUnix, err := strconv.ParseInt(timestampStr, 10, 64); err == nil {
timestamp = time.Unix(timestampUnix, 0)
}

if timestamp.Before(sixMonthsAgo) {
continue
}

if strings.HasSuffix(author, "[bot]") {
continue
}

commitInfo := CommitInfo{
Hash: hash,
Timestamp: timestamp,
Title: title,
}
commits = append(commits, commitInfo)
if len(commits) >= 3 {
break
}
}

return commits, nil
}
Loading
Loading