flaky-friend 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +46 -41
- data/lib/flaky/commands/fetch.rb +26 -12
- data/lib/flaky/commands/rank.rb +3 -4
- data/lib/flaky/commands/stress.rb +10 -16
- data/lib/flaky/configuration.rb +5 -0
- data/lib/flaky/providers/github_actions.rb +3 -2
- data/lib/flaky/providers/semaphore.rb +25 -13
- data/lib/flaky/repository.rb +30 -17
- data/lib/flaky/version.rb +1 -1
- data/lib/flaky-friend.rb +4 -2
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 002c918bce57180caeb36b4f4b3d22899a639c975417b6306ac1d771a74c593c
|
|
4
|
+
data.tar.gz: da819c2128568e5c84f016f841f914e679e169816da642423a4ddd3e48e59ab6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ab98c7c884c4a4984db1af236fa9f626d977a9c19aa563da5fd32c7df30c6a26cda2dd1bcd0127aaa06715e4792a5058091fb363bbbb38560f74a181e3710341
|
|
7
|
+
data.tar.gz: 0ff57b02a4dce8e319607ef90e3819247da560c8f74bc5ef68994bb156143d3d909020c9a79b96d82cd82b9704090daa0e10155241310122e8fdb2ad54325314
|
data/README.md
CHANGED
|
@@ -9,11 +9,11 @@ Flaky fetches test results from your CI provider, stores failures in a local SQL
|
|
|
9
9
|
Add to your Gemfile:
|
|
10
10
|
|
|
11
11
|
```ruby
|
|
12
|
-
# From
|
|
13
|
-
gem 'flaky',
|
|
12
|
+
# From RubyGems
|
|
13
|
+
gem 'flaky-friend', group: [:development, :test]
|
|
14
14
|
|
|
15
15
|
# Or from a local path during development
|
|
16
|
-
gem 'flaky', path: '../flaky', group: [:development, :test]
|
|
16
|
+
gem 'flaky-friend', path: '../flaky', group: [:development, :test]
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
Then `bundle install`.
|
|
@@ -27,38 +27,42 @@ if defined?(Flaky)
|
|
|
27
27
|
Flaky.configure do |c|
|
|
28
28
|
c.provider = :semaphore # or :github_actions
|
|
29
29
|
c.project = "my-project" # CI project name
|
|
30
|
-
c.branch = "main" # branch to track
|
|
30
|
+
c.branch = "main" # branch to track, or :all for every branch
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
`:all` gives a much larger sample: flakes on feature branches count too. `flaky:rank` then orders tests by how many distinct branches they failed on, so a test broken by one branch's work in progress (many failures, one branch) ranks below a test that fails on unrelated branches.
|
|
36
|
+
|
|
35
37
|
### Prerequisites by provider
|
|
36
38
|
|
|
37
|
-
**Semaphore**: Install
|
|
39
|
+
**Semaphore**: Install the [`sem` CLI](https://docs.semaphoreci.com/reference/sem-command-line-tool/) and run `sem connect`. Flaky reads the host and API token of the active context from `~/.sem.yaml` and calls the Semaphore API directly. Server errors and timeouts are retried up to 3 times.
|
|
38
40
|
|
|
39
41
|
**GitHub Actions**: Install and authenticate the [`gh` CLI](https://cli.github.com/).
|
|
40
42
|
|
|
41
43
|
## Rake Tasks
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
Tasks take environment variables rather than rake arguments (no zsh bracket escaping). `bin/rails flaky` prints help.
|
|
46
|
+
|
|
47
|
+
### `bin/rails flaky:fetch DURATION=24h`
|
|
44
48
|
|
|
45
49
|
Fetch recent CI results and store failures in the local database.
|
|
46
50
|
|
|
47
51
|
```sh
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
bin/rails flaky:fetch # last 24 hours (default)
|
|
53
|
+
bin/rails flaky:fetch DURATION=7d # last 7 days
|
|
54
|
+
bin/rails flaky:fetch DURATION=90d # last 90 days
|
|
51
55
|
```
|
|
52
56
|
|
|
53
|
-
For each workflow on the configured branch, fetches
|
|
57
|
+
`DURATION` accepts `m`, `h`, or `d` suffixes. For each workflow on the configured branch, fetches the logs of failed test jobs, parses RSpec output for failures and random seeds, and inserts new records into `tmp/flaky.db`. Workflows already in the database are skipped. A workflow is stored together with all its jobs or not at all, so an interrupted fetch is picked up again by the next run.
|
|
54
58
|
|
|
55
|
-
### `
|
|
59
|
+
### `bin/rails flaky:rank SINCE=30`
|
|
56
60
|
|
|
57
|
-
Rank flaky tests by failure
|
|
61
|
+
Rank flaky tests by the number of branches they failed on, then by failure count, and suggest the next one to investigate.
|
|
58
62
|
|
|
59
63
|
```sh
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
bin/rails flaky:rank # last 30 days (default)
|
|
65
|
+
bin/rails flaky:rank SINCE=7 # last 7 days
|
|
62
66
|
```
|
|
63
67
|
|
|
64
68
|
Output:
|
|
@@ -66,50 +70,51 @@ Output:
|
|
|
66
70
|
```
|
|
67
71
|
Flaky tests on main (last 30 days, 42 CI runs):
|
|
68
72
|
|
|
69
|
-
Fails Location
|
|
70
|
-
|
|
71
|
-
5 ...spec/system/inventory_search_modal_spec.rb:83
|
|
73
|
+
Fails Branches Location
|
|
74
|
+
----------------------------------------------------------------------------------------------------
|
|
75
|
+
5 3 ...spec/system/inventory_search_modal_spec.rb:83 (2026-04-12 09:15:22)
|
|
72
76
|
|
|
73
77
|
> Next to investigate: packs/.../inventory_search_modal_spec.rb:83
|
|
74
78
|
Inventory search modal filters by enrollment
|
|
75
79
|
Seeds: 6432, 51203, 8891
|
|
76
80
|
```
|
|
77
81
|
|
|
78
|
-
### `
|
|
82
|
+
### `bin/rails flaky:history SPEC=path/to/spec.rb:42`
|
|
79
83
|
|
|
80
84
|
Show the full failure timeline for a specific test, including every seed and CI job it failed in.
|
|
81
85
|
|
|
82
86
|
```sh
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
bin/rails flaky:history SPEC=inventory_search_modal_spec.rb:83
|
|
88
|
+
bin/rails flaky:history SPEC=inventory_search_modal_spec.rb # all failures in this file
|
|
85
89
|
```
|
|
86
90
|
|
|
87
|
-
### `
|
|
91
|
+
### `bin/rails flaky:stress SPEC=... N=20 SEED=... CI=true TIMEOUT=600`
|
|
88
92
|
|
|
89
93
|
Run a test repeatedly to reproduce a flaky failure or prove a fix is stable.
|
|
90
94
|
|
|
91
95
|
```sh
|
|
92
|
-
# 20 iterations with
|
|
93
|
-
|
|
96
|
+
# 20 iterations with the seed it most often failed with on CI
|
|
97
|
+
bin/rails flaky:stress SPEC=path/to/spec.rb:83
|
|
94
98
|
|
|
95
99
|
# 50 iterations with a specific seed and CI simulation
|
|
96
|
-
|
|
100
|
+
bin/rails flaky:stress SPEC=path/to/spec.rb:83 N=50 SEED=6432 CI=true
|
|
97
101
|
```
|
|
98
102
|
|
|
99
|
-
|
|
100
|
-
- `
|
|
101
|
-
- `
|
|
102
|
-
- `
|
|
103
|
-
- `
|
|
103
|
+
Variables:
|
|
104
|
+
- `SPEC` (required) -- spec file path, optionally with line number
|
|
105
|
+
- `N` -- number of runs (default: 20)
|
|
106
|
+
- `SEED` -- RSpec random seed; omit to use the most frequent failing seed from the database (random if none), `random` for a new seed each run
|
|
107
|
+
- `CI` -- `true` to enable CI environment simulation (default: false)
|
|
108
|
+
- `TIMEOUT` -- total seconds; no new iteration starts after this (default: 600)
|
|
104
109
|
|
|
105
|
-
Results are recorded to the database and shown in `
|
|
110
|
+
Results are recorded to the database and shown in `bin/rails flaky:report`.
|
|
106
111
|
|
|
107
|
-
### `
|
|
112
|
+
### `bin/rails flaky:report`
|
|
108
113
|
|
|
109
114
|
Summary dashboard showing overall flaky test health.
|
|
110
115
|
|
|
111
116
|
```sh
|
|
112
|
-
|
|
117
|
+
bin/rails flaky:report
|
|
113
118
|
```
|
|
114
119
|
|
|
115
120
|
Output:
|
|
@@ -138,7 +143,7 @@ Recent stress runs:
|
|
|
138
143
|
|
|
139
144
|
## CI Simulation
|
|
140
145
|
|
|
141
|
-
When `
|
|
146
|
+
When `CI=true` is passed to `bin/rails flaky:stress`, the gem simulates CI environment constraints:
|
|
142
147
|
|
|
143
148
|
1. **Rack middleware latency** -- adds 30ms delay per HTTP request (approximates the difference between a Mac and an f1-standard-2 CI machine). Configurable via `FLAKY_LATENCY_MS` env var.
|
|
144
149
|
|
|
@@ -171,11 +176,11 @@ To add a CI provider, implement the three-method interface and register it:
|
|
|
171
176
|
```ruby
|
|
172
177
|
class Flaky::Providers::CircleCI < Flaky::Providers::Base
|
|
173
178
|
def fetch_workflows(age: "24h")
|
|
174
|
-
# Return [{ id:, pipeline_id:, branch:, created_at: }, ...]
|
|
179
|
+
# Return [{ id:, pipeline_id:, branch:, commit_sha:, created_at: }, ...]
|
|
175
180
|
end
|
|
176
181
|
|
|
177
182
|
def fetch_jobs(pipeline_id:)
|
|
178
|
-
# Return [{ id:, name:, block_name:, result: }, ...]
|
|
183
|
+
# Return [{ id:, name:, block_name:, result: }, ...] (result: "passed" / "failed")
|
|
179
184
|
end
|
|
180
185
|
|
|
181
186
|
def fetch_log(job_id:)
|
|
@@ -192,22 +197,22 @@ The log parser is CI-agnostic -- it extracts failures, seeds, and counts from st
|
|
|
192
197
|
|
|
193
198
|
```sh
|
|
194
199
|
# 1. Fetch recent CI data
|
|
195
|
-
|
|
200
|
+
bin/rails flaky:fetch DURATION=7d
|
|
196
201
|
|
|
197
202
|
# 2. See what's flaky
|
|
198
|
-
|
|
203
|
+
bin/rails flaky:rank
|
|
199
204
|
|
|
200
205
|
# 3. Investigate the top offender
|
|
201
|
-
|
|
206
|
+
bin/rails flaky:history SPEC=the_flaky_spec.rb:42
|
|
202
207
|
|
|
203
208
|
# 4. Try to reproduce it locally with CI simulation
|
|
204
|
-
|
|
209
|
+
bin/rails flaky:stress SPEC=the_flaky_spec.rb:42 N=30 SEED=6432 CI=true
|
|
205
210
|
|
|
206
211
|
# 5. Fix the test, then prove the fix holds
|
|
207
|
-
|
|
212
|
+
bin/rails flaky:stress SPEC=the_flaky_spec.rb:42 N=50 SEED=random CI=true
|
|
208
213
|
|
|
209
214
|
# 6. Check overall health
|
|
210
|
-
|
|
215
|
+
bin/rails flaky:report
|
|
211
216
|
```
|
|
212
217
|
|
|
213
218
|
## License
|
data/lib/flaky/commands/fetch.rb
CHANGED
|
@@ -56,19 +56,26 @@ module Flaky
|
|
|
56
56
|
$stdout.flush
|
|
57
57
|
|
|
58
58
|
jobs = provider.fetch_jobs(pipeline_id: wf[:pipeline_id])
|
|
59
|
-
|
|
59
|
+
results = jobs.map { |j| j[:result] }
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
if results.include?(nil)
|
|
62
|
+
puts "still running, skipped"
|
|
63
|
+
return [0, 0]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
pipeline_result = %w[failed stopped].find { |r| results.include?(r) } || "passed"
|
|
66
67
|
|
|
67
68
|
puts "#{jobs.length} test jobs (#{pipeline_result})"
|
|
68
69
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
# All or nothing: a workflow in ci_runs counts as fetched and is never revisited.
|
|
71
|
+
failures = @repo.transaction do
|
|
72
|
+
@repo.insert_ci_run(
|
|
73
|
+
workflow_id: wf[:id], pipeline_id: wf[:pipeline_id],
|
|
74
|
+
branch: wf[:branch], result: pipeline_result, created_at: wf[:created_at],
|
|
75
|
+
commit_sha: wf[:commit_sha]
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
jobs.each_with_index.sum { |job, ji| process_job(provider, job, wf, ji, jobs.length) }
|
|
72
79
|
end
|
|
73
80
|
|
|
74
81
|
[jobs.length, failures]
|
|
@@ -78,12 +85,13 @@ module Flaky
|
|
|
78
85
|
print " [#{index + 1}/#{total}] #{job[:name]}... "
|
|
79
86
|
$stdout.flush
|
|
80
87
|
|
|
81
|
-
|
|
88
|
+
# Only failed jobs have failures to record; passed-job logs are large and slow to fetch.
|
|
89
|
+
log = job[:result] == "failed" ? provider.fetch_log(job_id: job[:id]) : ""
|
|
82
90
|
parsed = @parser.parse(log)
|
|
83
91
|
|
|
84
92
|
@repo.insert_job_result(
|
|
85
93
|
job_id: job[:id], workflow_id: wf[:id], job_name: job[:name],
|
|
86
|
-
block_name: job[:block_name], result:
|
|
94
|
+
block_name: job[:block_name], result: job[:result],
|
|
87
95
|
example_count: parsed.example_count, failure_count: parsed.failure_count,
|
|
88
96
|
seed: parsed.seed, duration_seconds: parsed.duration_seconds
|
|
89
97
|
)
|
|
@@ -99,8 +107,14 @@ module Flaky
|
|
|
99
107
|
end
|
|
100
108
|
puts "\e[31m#{parsed.failures.length} failure(s)\e[0m"
|
|
101
109
|
parsed.failures.length
|
|
110
|
+
elsif job[:result] == "failed"
|
|
111
|
+
puts "\e[31mfailed\e[0m (no RSpec failures in log)"
|
|
112
|
+
0
|
|
113
|
+
elsif job[:result] == "stopped"
|
|
114
|
+
puts "\e[33mstopped\e[0m"
|
|
115
|
+
0
|
|
102
116
|
else
|
|
103
|
-
puts "\e[32mok\e[0m
|
|
117
|
+
puts "\e[32mok\e[0m"
|
|
104
118
|
0
|
|
105
119
|
end
|
|
106
120
|
end
|
data/lib/flaky/commands/rank.rb
CHANGED
|
@@ -24,13 +24,12 @@ module Flaky
|
|
|
24
24
|
total_runs = @repo.total_runs_count(branch: branch, since_days: @since_days)
|
|
25
25
|
|
|
26
26
|
puts "Flaky tests on #{branch} (last #{@since_days} days, #{total_runs} CI runs):\n\n"
|
|
27
|
-
puts format("%-6s %-
|
|
28
|
-
puts "-" *
|
|
27
|
+
puts format("%-6s %-9s %s", "Fails", "Branches", "Location")
|
|
28
|
+
puts "-" * 100
|
|
29
29
|
|
|
30
30
|
rows.each_with_index do |row, i|
|
|
31
31
|
location = "#{row['spec_file']}:#{row['line_number']}"
|
|
32
|
-
|
|
33
|
-
puts format("%-6d %-50s %s", row["failure_count"], truncated, row["last_failure"])
|
|
32
|
+
puts format("%-6d %-9d %s (%s)", row["failure_count"], row["branch_count"], location, row["last_failure"])
|
|
34
33
|
|
|
35
34
|
if i == 0
|
|
36
35
|
puts "\n \e[33m▶ Next to investigate:\e[0m #{location}"
|
|
@@ -18,14 +18,14 @@ module Flaky
|
|
|
18
18
|
env = {}
|
|
19
19
|
env["FLAKY_CI_SIMULATE"] = "1" if @ci_simulate
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
seed = resolve_seed
|
|
22
22
|
passes = 0
|
|
23
23
|
failures = 0
|
|
24
|
-
failed_seeds =
|
|
24
|
+
failed_seeds = Set.new
|
|
25
25
|
start_time = Time.now
|
|
26
26
|
|
|
27
27
|
puts "Stress testing: #{@spec_location}"
|
|
28
|
-
puts " Iterations: #{@iterations},
|
|
28
|
+
puts " Iterations: #{@iterations}, Seed: #{seed_description(seed)}, CI simulation: #{@ci_simulate}"
|
|
29
29
|
puts ""
|
|
30
30
|
|
|
31
31
|
@iterations.times do |i|
|
|
@@ -35,7 +35,7 @@ module Flaky
|
|
|
35
35
|
break
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
run_seed =
|
|
38
|
+
run_seed = seed.presence || rand(100_000)
|
|
39
39
|
cmd = "bundle exec rspec #{@spec_location} --seed #{run_seed} --format progress 2>&1"
|
|
40
40
|
|
|
41
41
|
output = nil
|
|
@@ -51,7 +51,7 @@ module Flaky
|
|
|
51
51
|
print "\e[32m.\e[0m"
|
|
52
52
|
else
|
|
53
53
|
failures += 1
|
|
54
|
-
failed_seeds
|
|
54
|
+
failed_seeds.add(run_seed)
|
|
55
55
|
print "\e[31mF\e[0m"
|
|
56
56
|
end
|
|
57
57
|
end
|
|
@@ -81,30 +81,24 @@ module Flaky
|
|
|
81
81
|
|
|
82
82
|
private
|
|
83
83
|
|
|
84
|
-
def
|
|
84
|
+
def resolve_seed
|
|
85
85
|
case @seed
|
|
86
86
|
when :random
|
|
87
87
|
nil
|
|
88
88
|
when Integer
|
|
89
|
-
|
|
89
|
+
@seed
|
|
90
90
|
when nil
|
|
91
91
|
file, line = parse_location(@spec_location)
|
|
92
92
|
db_seeds = @repo.failing_seeds(file: file, line: line)
|
|
93
|
-
|
|
94
|
-
puts "Found #{db_seeds.length} known failing seed(s): #{db_seeds.join(', ')}"
|
|
95
|
-
db_seeds
|
|
96
|
-
else
|
|
97
|
-
puts "No known failing seeds — using random."
|
|
98
|
-
nil
|
|
99
|
-
end
|
|
93
|
+
db_seeds.any? ? db_seeds.first : nil
|
|
100
94
|
end
|
|
101
95
|
end
|
|
102
96
|
|
|
103
|
-
def seed_description(
|
|
97
|
+
def seed_description(seed)
|
|
104
98
|
case @seed
|
|
105
99
|
when :random then "random"
|
|
106
100
|
when Integer then @seed.to_s
|
|
107
|
-
else
|
|
101
|
+
else seed.present? ? seed.to_s : "random (no known failing seeds)"
|
|
108
102
|
end
|
|
109
103
|
end
|
|
110
104
|
|
data/lib/flaky/configuration.rb
CHANGED
|
@@ -10,7 +10,8 @@ module Flaky
|
|
|
10
10
|
def fetch_workflows(age: "24h")
|
|
11
11
|
cutoff = Time.now - AgeParser.to_seconds(age)
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
branch_flag = config.all_branches? ? "" : "--branch #{config.branch} "
|
|
14
|
+
output = run_cmd("gh run list #{branch_flag}--limit 100 --json databaseId,conclusion,createdAt,headBranch,headSha,workflowName")
|
|
14
15
|
runs = JSON.parse(output)
|
|
15
16
|
|
|
16
17
|
runs.filter_map do |run|
|
|
@@ -61,7 +62,7 @@ module Flaky
|
|
|
61
62
|
case conclusion
|
|
62
63
|
when "success" then "passed"
|
|
63
64
|
when "failure" then "failed"
|
|
64
|
-
else conclusion
|
|
65
|
+
else conclusion # nil while in progress
|
|
65
66
|
end
|
|
66
67
|
end
|
|
67
68
|
end
|
|
@@ -10,21 +10,24 @@ require_relative "../age_parser"
|
|
|
10
10
|
module Flaky
|
|
11
11
|
module Providers
|
|
12
12
|
class Semaphore < Base
|
|
13
|
+
MAX_ATTEMPTS = 3
|
|
14
|
+
|
|
15
|
+
# Semaphore intermittently answers 500/504; worth another try.
|
|
16
|
+
class TransientError < Error; end
|
|
17
|
+
|
|
13
18
|
def fetch_workflows(age: "24h")
|
|
14
19
|
cutoff = Time.now - AgeParser.to_seconds(age)
|
|
15
20
|
project_id = resolve_project_id
|
|
16
|
-
|
|
21
|
+
branch_filter = config.all_branches? ? {} : { branch_name: config.branch }
|
|
17
22
|
workflows = []
|
|
18
23
|
|
|
19
24
|
page = 1
|
|
20
25
|
loop do
|
|
21
|
-
data = api_get("plumber-workflows", project_id: project_id, page: page)
|
|
26
|
+
data = api_get("plumber-workflows", project_id: project_id, **branch_filter, page: page)
|
|
22
27
|
break if data.empty?
|
|
23
28
|
|
|
24
29
|
data.each do |wf|
|
|
25
30
|
created_at = Time.at(wf.dig("created_at", "seconds").to_i)
|
|
26
|
-
next unless wf["branch_name"] == branch
|
|
27
|
-
|
|
28
31
|
if created_at < cutoff
|
|
29
32
|
return workflows # older than cutoff, done
|
|
30
33
|
end
|
|
@@ -62,7 +65,7 @@ module Flaky
|
|
|
62
65
|
id: job["job_id"],
|
|
63
66
|
name: job["name"],
|
|
64
67
|
block_name: block_name,
|
|
65
|
-
result: job["result"]&.downcase
|
|
68
|
+
result: job["result"]&.downcase # passed, failed, stopped; nil while running
|
|
66
69
|
}
|
|
67
70
|
end
|
|
68
71
|
end
|
|
@@ -80,7 +83,7 @@ module Flaky
|
|
|
80
83
|
private
|
|
81
84
|
|
|
82
85
|
def api_get(path, **params)
|
|
83
|
-
query =
|
|
86
|
+
query = URI.encode_www_form(params)
|
|
84
87
|
url = "#{api_host}/api/v1alpha/#{path}"
|
|
85
88
|
url += "?#{query}" unless query.empty?
|
|
86
89
|
|
|
@@ -88,15 +91,24 @@ module Flaky
|
|
|
88
91
|
req = Net::HTTP::Get.new(uri)
|
|
89
92
|
req["Authorization"] = "Token #{api_token}"
|
|
90
93
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
attempt = 1
|
|
95
|
+
begin
|
|
96
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
|
|
97
|
+
http.request(req)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
message = "Semaphore API error (#{response.code}): #{response.body[0..200]}"
|
|
101
|
+
raise TransientError, message if response.is_a?(Net::HTTPServerError)
|
|
102
|
+
raise Error, message unless response.is_a?(Net::HTTPSuccess)
|
|
94
103
|
|
|
95
|
-
|
|
104
|
+
JSON.parse(response.body)
|
|
105
|
+
rescue TransientError, Net::OpenTimeout, Net::ReadTimeout => e
|
|
106
|
+
raise Error, "#{e.message} (gave up after #{attempt} attempts)" if attempt >= MAX_ATTEMPTS
|
|
96
107
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
108
|
+
sleep(2**attempt)
|
|
109
|
+
attempt += 1
|
|
110
|
+
retry
|
|
111
|
+
end
|
|
100
112
|
rescue SocketError => e
|
|
101
113
|
raise Error, "Cannot reach Semaphore API: #{e.message}"
|
|
102
114
|
rescue Errno::ECONNREFUSED => e
|
data/lib/flaky/repository.rb
CHANGED
|
@@ -12,6 +12,10 @@ module Flaky
|
|
|
12
12
|
@db.close
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
def transaction(&)
|
|
16
|
+
connection.transaction(&)
|
|
17
|
+
end
|
|
18
|
+
|
|
15
19
|
# --- CI Runs ---
|
|
16
20
|
|
|
17
21
|
def workflow_fetched?(workflow_id)
|
|
@@ -46,29 +50,31 @@ module Flaky
|
|
|
46
50
|
# --- Ranking ---
|
|
47
51
|
|
|
48
52
|
def rank_failures(branch:, since_days:, min_failures: 1)
|
|
49
|
-
connection.execute(<<~SQL, [branch, "-#{since_days} days", min_failures])
|
|
53
|
+
connection.execute(<<~SQL, [branch_param(branch), "-#{since_days} days", min_failures])
|
|
50
54
|
SELECT
|
|
51
55
|
tf.spec_file,
|
|
52
56
|
tf.line_number,
|
|
53
57
|
tf.description,
|
|
54
58
|
COUNT(*) as failure_count,
|
|
59
|
+
COUNT(DISTINCT tf.branch) as branch_count,
|
|
55
60
|
MAX(tf.failed_at) as last_failure,
|
|
56
61
|
GROUP_CONCAT(DISTINCT tf.seed) as seeds,
|
|
57
62
|
GROUP_CONCAT(DISTINCT cr.commit_sha) as commit_shas
|
|
58
63
|
FROM test_failures tf
|
|
59
64
|
JOIN ci_runs cr ON cr.workflow_id = tf.workflow_id
|
|
60
|
-
WHERE cr.branch = ?
|
|
61
|
-
AND cr.created_at >= datetime('now', ?)
|
|
65
|
+
WHERE (?1 IS NULL OR cr.branch = ?1)
|
|
66
|
+
AND cr.created_at >= datetime('now', ?2)
|
|
62
67
|
GROUP BY tf.spec_file, tf.line_number
|
|
63
|
-
HAVING COUNT(*) >= ?
|
|
64
|
-
|
|
68
|
+
HAVING COUNT(*) >= ?3
|
|
69
|
+
-- A flake fails on unrelated branches; a branch's own regression fails only there.
|
|
70
|
+
ORDER BY branch_count DESC, failure_count DESC, last_failure DESC
|
|
65
71
|
SQL
|
|
66
72
|
end
|
|
67
73
|
|
|
68
74
|
def total_runs_count(branch:, since_days:)
|
|
69
75
|
connection.get_first_value(
|
|
70
|
-
"SELECT COUNT(DISTINCT workflow_id) FROM ci_runs WHERE branch = ? AND created_at >= datetime('now', ?)",
|
|
71
|
-
[branch, "-#{since_days} days"]
|
|
76
|
+
"SELECT COUNT(DISTINCT workflow_id) FROM ci_runs WHERE (?1 IS NULL OR branch = ?1) AND created_at >= datetime('now', ?2)",
|
|
77
|
+
[branch_param(branch), "-#{since_days} days"]
|
|
72
78
|
).to_i
|
|
73
79
|
end
|
|
74
80
|
|
|
@@ -104,23 +110,25 @@ module Flaky
|
|
|
104
110
|
# --- Report ---
|
|
105
111
|
|
|
106
112
|
def run_stats(branch:)
|
|
113
|
+
branch = branch_param(branch)
|
|
107
114
|
{
|
|
108
|
-
total_runs: connection.get_first_value("SELECT COUNT(*) FROM ci_runs WHERE branch = ?", branch).to_i,
|
|
109
|
-
failed_runs: connection.get_first_value("SELECT COUNT(*) FROM ci_runs WHERE branch = ? AND result = 'failed'", branch).to_i,
|
|
110
|
-
total_failures: connection.get_first_value("SELECT COUNT(*) FROM test_failures WHERE branch = ?", branch).to_i,
|
|
111
|
-
unique_specs: connection.get_first_value("SELECT COUNT(DISTINCT spec_file || ':' || line_number) FROM test_failures WHERE branch = ?", branch).to_i,
|
|
115
|
+
total_runs: connection.get_first_value("SELECT COUNT(*) FROM ci_runs WHERE (?1 IS NULL OR branch = ?1)", branch).to_i,
|
|
116
|
+
failed_runs: connection.get_first_value("SELECT COUNT(*) FROM ci_runs WHERE (?1 IS NULL OR branch = ?1) AND result = 'failed'", branch).to_i,
|
|
117
|
+
total_failures: connection.get_first_value("SELECT COUNT(*) FROM test_failures WHERE (?1 IS NULL OR branch = ?1)", branch).to_i,
|
|
118
|
+
unique_specs: connection.get_first_value("SELECT COUNT(DISTINCT spec_file || ':' || line_number) FROM test_failures WHERE (?1 IS NULL OR branch = ?1)", branch).to_i,
|
|
112
119
|
last_fetch: connection.get_first_value("SELECT MAX(fetched_at) FROM ci_runs")
|
|
113
120
|
}
|
|
114
121
|
end
|
|
115
122
|
|
|
116
123
|
def failure_trend(branch:, period_days: 7)
|
|
124
|
+
branch = branch_param(branch)
|
|
117
125
|
recent = connection.get_first_value(
|
|
118
|
-
"SELECT COUNT(*) FROM test_failures WHERE branch = ? AND failed_at >= datetime('now', ?)",
|
|
126
|
+
"SELECT COUNT(*) FROM test_failures WHERE (?1 IS NULL OR branch = ?1) AND failed_at >= datetime('now', ?2)",
|
|
119
127
|
[branch, "-#{period_days} days"]
|
|
120
128
|
).to_i
|
|
121
129
|
|
|
122
130
|
prior = connection.get_first_value(
|
|
123
|
-
"SELECT COUNT(*) FROM test_failures WHERE branch = ? AND failed_at >= datetime('now', ?) AND failed_at < datetime('now', ?)",
|
|
131
|
+
"SELECT COUNT(*) FROM test_failures WHERE (?1 IS NULL OR branch = ?1) AND failed_at >= datetime('now', ?2) AND failed_at < datetime('now', ?3)",
|
|
124
132
|
[branch, "-#{period_days * 2} days", "-#{period_days} days"]
|
|
125
133
|
).to_i
|
|
126
134
|
|
|
@@ -128,7 +136,7 @@ module Flaky
|
|
|
128
136
|
end
|
|
129
137
|
|
|
130
138
|
def top_flaky(branch:, limit: 5)
|
|
131
|
-
connection.execute(<<~SQL, [branch, limit])
|
|
139
|
+
connection.execute(<<~SQL, [branch_param(branch), limit])
|
|
132
140
|
SELECT
|
|
133
141
|
spec_file,
|
|
134
142
|
line_number,
|
|
@@ -136,10 +144,10 @@ module Flaky
|
|
|
136
144
|
COUNT(*) as failure_count,
|
|
137
145
|
MAX(failed_at) as last_failure
|
|
138
146
|
FROM test_failures
|
|
139
|
-
WHERE branch = ?
|
|
147
|
+
WHERE (?1 IS NULL OR branch = ?1)
|
|
140
148
|
GROUP BY spec_file, line_number
|
|
141
149
|
ORDER BY failure_count DESC
|
|
142
|
-
LIMIT ?
|
|
150
|
+
LIMIT ?2
|
|
143
151
|
SQL
|
|
144
152
|
end
|
|
145
153
|
|
|
@@ -159,7 +167,7 @@ module Flaky
|
|
|
159
167
|
end
|
|
160
168
|
|
|
161
169
|
connection.execute(
|
|
162
|
-
"SELECT
|
|
170
|
+
"SELECT seed, COUNT(*) AS n FROM test_failures WHERE #{conditions.join(' AND ')} GROUP BY seed ORDER BY n DESC, MAX(failed_at) DESC",
|
|
163
171
|
params
|
|
164
172
|
).map { |row| row["seed"] }
|
|
165
173
|
end
|
|
@@ -175,6 +183,11 @@ module Flaky
|
|
|
175
183
|
|
|
176
184
|
private
|
|
177
185
|
|
|
186
|
+
# SQL NULL matches every branch; see Configuration#all_branches?.
|
|
187
|
+
def branch_param(branch)
|
|
188
|
+
branch == :all ? nil : branch
|
|
189
|
+
end
|
|
190
|
+
|
|
178
191
|
def connection
|
|
179
192
|
@db.connection
|
|
180
193
|
end
|
data/lib/flaky/version.rb
CHANGED
data/lib/flaky-friend.rb
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
module Flaky
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
end
|
|
6
|
+
|
|
3
7
|
require_relative "flaky/version"
|
|
4
8
|
require_relative "flaky/configuration"
|
|
5
9
|
require_relative "flaky/age_parser"
|
|
@@ -8,8 +12,6 @@ require_relative "flaky/providers/github_actions"
|
|
|
8
12
|
require_relative "flaky/railtie" if defined?(Rails::Railtie)
|
|
9
13
|
|
|
10
14
|
module Flaky
|
|
11
|
-
class Error < StandardError; end
|
|
12
|
-
|
|
13
15
|
class << self
|
|
14
16
|
def configuration
|
|
15
17
|
@configuration ||= Configuration.new
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: flaky-friend
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Flytedesk
|
|
@@ -109,7 +109,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
109
109
|
- !ruby/object:Gem::Version
|
|
110
110
|
version: '0'
|
|
111
111
|
requirements: []
|
|
112
|
-
rubygems_version:
|
|
112
|
+
rubygems_version: 4.0.16
|
|
113
113
|
specification_version: 4
|
|
114
114
|
summary: Track, rank, and reproduce flaky CI test failures
|
|
115
115
|
test_files: []
|