Skip to content

Commit d005a71

Browse files
feat: add git history analysis to go flaky tests (#1029)
* feat: add Git author tracking functionality Add Git client implementation to track test authors: - Find test files using grep search through repository - Extract commit history using git log -L for specific test functions - Filter commits to last 6 months to focus on recent contributors - Add FilePath and RecentCommits to FlakyTest struct - Update action to include repository-directory input - Enhance test reporting with author information Builds on PR1's Loki analysis with Git history tracking. GitHub username resolution will be added in PR3. # Conflicts: # actions/go-flaky-tests/cmd/go-flaky-tests/analyzer.go * Format Go code with gofmt * Revert "Remove PR2-related features from documentation" This reverts commit 9a29cc3. * Remove unnecessary function * Adapt tests * Pull author resolution out of git client * Revert "Remove repository-directory parameter from local script" This reverts commit 085c861. * Remove redundant author printing
1 parent 6d6e438 commit d005a71

File tree

10 files changed

+481
-56
lines changed

10 files changed

+481
-56
lines changed

actions/go-flaky-tests/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66

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

1112
### Features
1213

1314
- **Loki Log Analysis**: Fetches and parses test failure logs using LogQL
1415
- **Flaky Test Detection**: Identifies tests that fail inconsistently across branches
16+
- **Git Author Tracking**: Finds recent commits that modified flaky tests
1517
- **Configurable Limits**: Top-K filtering to focus on most problematic tests

actions/go-flaky-tests/README.md

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
# Go Flaky Tests
22

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

55
## Features
66

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

1011
## Usage
1112

@@ -35,14 +36,15 @@ jobs:
3536
3637
## Inputs
3738
38-
| Input | Description | Required | Default |
39-
| --------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------- | ------- |
40-
| `loki-url` | Loki endpoint URL | ✅ | - |
41-
| `loki-username` | Username for Loki authentication | ❌ | - |
42-
| `loki-password` | Password for Loki authentication. If using Grafana Cloud, then the access policy for this token needs the `logs:read` scope. | ❌ | - |
43-
| `repository` | Repository name in 'owner/repo' format | ✅ | - |
44-
| `time-range` | Time range for the query (e.g., '1h', '24h', '7d') | ❌ | `1h` |
45-
| `top-k` | Include only the top K flaky tests by distinct branches count | ❌ | `3` |
39+
| Input | Description | Required | Default |
40+
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------- | ------------------------- |
41+
| `loki-url` | Loki endpoint URL | ✅ | - |
42+
| `loki-username` | Username for Loki authentication | ❌ | - |
43+
| `loki-password` | Password for Loki authentication. If using Grafana Cloud, then the access policy for this token needs the `logs:read` scope. | ❌ | - |
44+
| `repository` | Repository name in 'owner/repo' format | ✅ | - |
45+
| `time-range` | Time range for the query (e.g., '1h', '24h', '7d') | ❌ | `1h` |
46+
| `repository-directory` | Relative path to the directory with a git repository | ❌ | `${{ github.workspace }}` |
47+
| `top-k` | Include only the top K flaky tests by distinct branches count | ❌ | `3` |
4648

4749
## Outputs
4850

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

6165
## Flaky Test Detection Logic
6266

@@ -76,13 +80,16 @@ Run the analysis locally using the provided script:
7680
export LOKI_URL="your-loki-url"
7781
export REPOSITORY="owner/repo"
7882
export TIME_RANGE="24h"
83+
export REPOSITORY_DIRECTORY="."
84+
7985
# Run the analysis
80-
go run ./cmd/go-flaky-tests
86+
./run-local.sh
8187
```
8288

8389
## Requirements
8490

8591
- Go 1.22 or later
92+
- Git repository with test files
8693
- Access to Loki instance with test failure logs
8794

8895
## Output Format

actions/go-flaky-tests/action.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ inputs:
1919
description: "Time range for the query (e.g., '1h', '24h', '7d')"
2020
required: false
2121
default: "1h"
22+
repository-directory:
23+
description: "Relative path to the directory with a git repository"
24+
required: false
25+
default: ${{ github.workspace }}
2226
top-k:
2327
description: "Include only the top K flaky tests by distinct branches count in analysis"
2428
required: false
@@ -44,4 +48,5 @@ runs:
4448
LOKI_PASSWORD: ${{ inputs.loki-password }}
4549
REPOSITORY: ${{ inputs.repository }}
4650
TIME_RANGE: ${{ inputs.time-range }}
51+
REPOSITORY_DIRECTORY: ${{ inputs.repository-directory }}
4752
TOP_K: ${{ inputs.top-k }}

actions/go-flaky-tests/cmd/go-flaky-tests/analyzer.go

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,38 @@ import (
77
"os"
88
"path/filepath"
99
"strings"
10+
"time"
1011
)
1112

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

17+
type GitClient interface {
18+
FindTestFile(testName string) (string, error)
19+
TestCommits(filePath, testName string) ([]CommitInfo, error)
20+
}
21+
1622
type TestFailureAnalyzer struct {
1723
lokiClient LokiClient
24+
gitClient GitClient
1825
fileSystem FileSystem
1926
}
2027

28+
type CommitInfo struct {
29+
Hash string `json:"hash"`
30+
Author string `json:"author"`
31+
Timestamp time.Time `json:"timestamp"`
32+
Title string `json:"title"`
33+
}
34+
2135
type FlakyTest struct {
2236
TestName string `json:"test_name"`
37+
FilePath string `json:"file_path"`
2338
TotalFailures int `json:"total_failures"`
2439
BranchCounts map[string]int `json:"branch_counts"`
2540
ExampleWorkflows []GithubActionsWorkflow `json:"example_workflows"`
41+
RecentCommits []CommitInfo `json:"recent_commits"`
2642
}
2743

2844
type GithubActionsWorkflow struct {
@@ -32,7 +48,17 @@ type GithubActionsWorkflow struct {
3248
}
3349

3450
func (f *FlakyTest) String() string {
35-
return fmt.Sprintf("%s (%d total failures)", f.TestName, f.TotalFailures)
51+
var authors []string
52+
for _, commit := range f.RecentCommits {
53+
if commit.Author != "" && commit.Author != "unknown" {
54+
authors = append(authors, commit.Author)
55+
}
56+
}
57+
authorsStr := "unknown"
58+
if len(authors) > 0 {
59+
authorsStr = strings.Join(authors, ", ")
60+
}
61+
return fmt.Sprintf("%s (%d total failures; recently changed by %s)", f.TestName, f.TotalFailures, authorsStr)
3662
}
3763

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

51-
func NewTestFailureAnalyzer(loki LokiClient, fs FileSystem) *TestFailureAnalyzer {
77+
func NewTestFailureAnalyzer(loki LokiClient, git GitClient, fs FileSystem) *TestFailureAnalyzer {
5278
return &TestFailureAnalyzer{
5379
lokiClient: loki,
80+
gitClient: git,
5481
fileSystem: fs,
5582
}
5683
}
5784

5885
func NewDefaultTestFailureAnalyzer(config Config) *TestFailureAnalyzer {
5986
lokiClient := NewDefaultLokiClient(config)
87+
gitClient := NewDefaultGitClient(config)
6088
fileSystem := &DefaultFileSystem{}
6189

62-
return NewTestFailureAnalyzer(lokiClient, fileSystem)
90+
return NewTestFailureAnalyzer(lokiClient, gitClient, fileSystem)
6391
}
6492

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

86114
log.Printf("🧪 Found %d flaky tests that meet criteria", len(flakyTests))
115+
log.Printf("📁 Finding test files in repository...")
116+
err = t.findFilePaths(flakyTests)
117+
if err != nil {
118+
return nil, fmt.Errorf("failed to find file paths for flaky tests: %w", err)
119+
}
120+
121+
log.Printf("👥 Finding authors of flaky tests...")
122+
err = t.findTestAuthors(flakyTests)
123+
if err != nil {
124+
return nil, fmt.Errorf("failed to find test authors: %w", err)
125+
}
126+
127+
for _, test := range flakyTests {
128+
if len(test.RecentCommits) > 0 {
129+
var authors []string
130+
for _, commit := range test.RecentCommits {
131+
if commit.Author != "" && commit.Author != "unknown" {
132+
authors = append(authors, commit.Author)
133+
}
134+
}
135+
if len(authors) > 0 {
136+
log.Printf("👤 %s: %s", test.TestName, strings.Join(authors, ", "))
137+
} else {
138+
log.Printf("👤 %s: no authors found", test.TestName)
139+
}
140+
} else {
141+
log.Printf("👤 %s: no commits found", test.TestName)
142+
}
143+
}
87144

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

206+
func (t *TestFailureAnalyzer) findFilePaths(flakyTests []FlakyTest) error {
207+
for i, test := range flakyTests {
208+
filePath, err := t.gitClient.FindTestFile(test.TestName)
209+
if err != nil {
210+
return fmt.Errorf("failed to find file path for test %s: %w", test.TestName, err)
211+
}
212+
flakyTests[i].FilePath = filePath
213+
}
214+
return nil
215+
}
216+
217+
func (t *TestFailureAnalyzer) findTestAuthors(flakyTests []FlakyTest) error {
218+
for i, test := range flakyTests {
219+
commits, err := t.gitClient.TestCommits(test.FilePath, test.TestName)
220+
if err != nil {
221+
return fmt.Errorf("failed to get authors for test %s in %s: %w", test.TestName, test.FilePath, err)
222+
}
223+
flakyTests[i].RecentCommits = commits
224+
225+
if len(commits) > 0 {
226+
var authors []string
227+
for _, commit := range commits {
228+
authors = append(authors, commit.Author)
229+
}
230+
}
231+
}
232+
return nil
233+
}
234+
149235
func generateSummary(flakyTests []FlakyTest) string {
150236
if len(flakyTests) == 0 {
151237
return "No flaky tests found in the specified time range."

actions/go-flaky-tests/cmd/go-flaky-tests/config.go

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,24 @@ import (
66
)
77

88
type Config struct {
9-
LokiURL string
10-
LokiUsername string
11-
LokiPassword string
12-
Repository string
13-
TimeRange string
14-
TopK int
9+
LokiURL string
10+
LokiUsername string
11+
LokiPassword string
12+
Repository string
13+
TimeRange string
14+
RepositoryDirectory string
15+
TopK int
1516
}
1617

1718
func getConfigFromEnv() Config {
1819
return Config{
19-
LokiURL: os.Getenv("LOKI_URL"),
20-
LokiUsername: os.Getenv("LOKI_USERNAME"),
21-
LokiPassword: os.Getenv("LOKI_PASSWORD"),
22-
Repository: os.Getenv("REPOSITORY"),
23-
TimeRange: getEnvWithDefault("TIME_RANGE", "24h"),
24-
TopK: getIntEnvWithDefault("TOP_K", 3),
20+
LokiURL: os.Getenv("LOKI_URL"),
21+
LokiUsername: os.Getenv("LOKI_USERNAME"),
22+
LokiPassword: os.Getenv("LOKI_PASSWORD"),
23+
Repository: os.Getenv("REPOSITORY"),
24+
TimeRange: getEnvWithDefault("TIME_RANGE", "24h"),
25+
RepositoryDirectory: getEnvWithDefault("REPOSITORY_DIRECTORY", "."),
26+
TopK: getIntEnvWithDefault("TOP_K", 3),
2527
}
2628
}
2729

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"log"
6+
"os/exec"
7+
"strconv"
8+
"strings"
9+
"time"
10+
)
11+
12+
type DefaultGitClient struct {
13+
config Config
14+
}
15+
16+
func NewDefaultGitClient(config Config) *DefaultGitClient {
17+
return &DefaultGitClient{config: config}
18+
}
19+
20+
func (g *DefaultGitClient) FindTestFile(testName string) (string, error) {
21+
return findTestFilePath(g.config.RepositoryDirectory, testName)
22+
}
23+
24+
func (g *DefaultGitClient) TestCommits(filePath, testName string) ([]CommitInfo, error) {
25+
return getFileAuthors(g.config, filePath, testName)
26+
}
27+
28+
func findTestFilePath(repoDir, testName string) (string, error) {
29+
if !strings.HasPrefix(testName, "Test") {
30+
return "", fmt.Errorf("invalid test name format: %s", testName)
31+
}
32+
33+
grepCmd := exec.Command("grep", "-rl", "--include=*_test.go", fmt.Sprintf("func %s(", testName), ".")
34+
grepCmd.Dir = repoDir
35+
36+
result, err := grepCmd.Output()
37+
if err != nil {
38+
return "", fmt.Errorf("failed to search for test function %s: %w", testName, err)
39+
}
40+
41+
lines := strings.Split(strings.TrimSpace(string(result)), "\n")
42+
if len(lines) > 0 && lines[0] != "" {
43+
if len(lines) > 1 {
44+
log.Printf("Warning: test function %s found in multiple files, using first match: %s", testName, lines[0])
45+
}
46+
47+
filePath := strings.TrimPrefix(lines[0], "./")
48+
return filePath, nil
49+
}
50+
51+
return "", fmt.Errorf("test function %s not found in repository", testName)
52+
}
53+
54+
func getFileAuthors(config Config, filePath, testName string) ([]CommitInfo, error) {
55+
return getFileAuthorsWithClient(config.RepositoryDirectory, filePath, testName)
56+
}
57+
58+
func getFileAuthorsWithClient(repoDir, filePath, testName string) ([]CommitInfo, error) {
59+
// Get 10 commits, because some of them might just be only bots.
60+
cmd := exec.Command("git", "log", "-10", "-L", fmt.Sprintf(":%s:%s", testName, filePath), "--pretty=format:%H|%ct|%s|%an", "-s")
61+
cmd.Dir = repoDir
62+
63+
result, err := cmd.Output()
64+
if err != nil {
65+
log.Printf("Warning: failed to get git log for test %s in %s: %v", testName, filePath, err)
66+
return []CommitInfo{}, nil
67+
}
68+
69+
lines := strings.Split(strings.TrimSpace(string(result)), "\n")
70+
if len(lines) == 0 || lines[0] == "" {
71+
log.Printf("Warning: no git log results for test %s in %s", testName, filePath)
72+
return []CommitInfo{}, nil
73+
}
74+
75+
var commits []CommitInfo
76+
sixMonthsAgo := time.Now().AddDate(0, -6, 0)
77+
78+
for _, line := range lines {
79+
parts := strings.SplitN(strings.TrimSpace(line), "|", 4)
80+
if len(parts) != 4 {
81+
return nil, fmt.Errorf("invalid git log format for test %s in %s: %s", testName, filePath, line)
82+
}
83+
84+
hash := parts[0]
85+
timestampStr := parts[1]
86+
title := parts[2]
87+
author := parts[3]
88+
89+
var timestamp time.Time
90+
if timestampUnix, err := strconv.ParseInt(timestampStr, 10, 64); err == nil {
91+
timestamp = time.Unix(timestampUnix, 0)
92+
}
93+
94+
if timestamp.Before(sixMonthsAgo) {
95+
continue
96+
}
97+
98+
if strings.HasSuffix(author, "[bot]") {
99+
continue
100+
}
101+
102+
commitInfo := CommitInfo{
103+
Hash: hash,
104+
Timestamp: timestamp,
105+
Title: title,
106+
}
107+
commits = append(commits, commitInfo)
108+
if len(commits) >= 3 {
109+
break
110+
}
111+
}
112+
113+
return commits, nil
114+
}

0 commit comments

Comments
 (0)