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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6216aed376755cc4ce188f8c5dc42b0146d1c7b3397c409c3eef9238eb8b987e
4
- data.tar.gz: a41b69d5707e4fcb93ebb7bc4bfbefda33ce827c49475a1e8a5d0b85506cba18
3
+ metadata.gz: 002c918bce57180caeb36b4f4b3d22899a639c975417b6306ac1d771a74c593c
4
+ data.tar.gz: da819c2128568e5c84f016f841f914e679e169816da642423a4ddd3e48e59ab6
5
5
  SHA512:
6
- metadata.gz: 7e1fd50da98dfafcf952f79c39b920e7b25e4d381c5f8e3a3afa5dd71cf2eb239a59933163abb7102345aa740aa2ec434b0e94d9e764ab233401cc389bea3ccf
7
- data.tar.gz: 1b1f57e521913ff056e5c1732eccf76b5fe41b5ae66c25a830330fc01a2e7ad10b2a47d49d79ac211557ae634d86ca7305b67ff068474e34587d99f95d5c0a5e
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 GitHub
13
- gem 'flaky', github: 'Flytedesk/flaky', group: [:development, :test]
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 and authenticate the [`sem` CLI](https://docs.semaphoreci.com/reference/sem-command-line-tool/).
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
- ### `rake flaky:fetch[age]`
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
- rake flaky:fetch # last 24 hours (default)
49
- rake flaky:fetch[168h] # last 7 days
50
- rake flaky:fetch[2160h] # last 90 days
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 all test job logs, parses RSpec output for failures and random seeds, and inserts new records into `tmp/flaky.db`.
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
- ### `rake flaky:rank[since]`
59
+ ### `bin/rails flaky:rank SINCE=30`
56
60
 
57
- Rank flaky tests by failure frequency and suggest the next one to investigate.
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
- rake flaky:rank # last 30 days (default)
61
- rake flaky:rank[7] # last 7 days
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 Last Failure
70
- ------------------------------------------------------------------------------------------
71
- 5 ...spec/system/inventory_search_modal_spec.rb:83 2026-04-12 09:15:22
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
- ### `rake flaky:history[spec_location]`
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
- rake flaky:history[inventory_search_modal_spec.rb:83]
84
- rake flaky:history[inventory_search_modal_spec.rb] # all failures in this file
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
- ### `rake flaky:stress[spec,iterations,seed,ci]`
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 random seeds
93
- rake flaky:stress[path/to/spec.rb:83]
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
- rake flaky:stress[path/to/spec.rb:83,50,6432,true]
100
+ bin/rails flaky:stress SPEC=path/to/spec.rb:83 N=50 SEED=6432 CI=true
97
101
  ```
98
102
 
99
- Arguments:
100
- - `spec` (required) -- spec file path, optionally with line number
101
- - `iterations` -- number of runs (default: 20)
102
- - `seed` -- RSpec random seed; omit for random each run
103
- - `ci` -- `true` to enable CI environment simulation (default: false)
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 `rake flaky:report`.
110
+ Results are recorded to the database and shown in `bin/rails flaky:report`.
106
111
 
107
- ### `rake flaky:report`
112
+ ### `bin/rails flaky:report`
108
113
 
109
114
  Summary dashboard showing overall flaky test health.
110
115
 
111
116
  ```sh
112
- rake flaky:report
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 `ci=true` is passed to `rake flaky:stress`, the gem simulates CI environment constraints:
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
- rake flaky:fetch[168h]
200
+ bin/rails flaky:fetch DURATION=7d
196
201
 
197
202
  # 2. See what's flaky
198
- rake flaky:rank
203
+ bin/rails flaky:rank
199
204
 
200
205
  # 3. Investigate the top offender
201
- rake flaky:history[the_flaky_spec.rb:42]
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
- rake flaky:stress[the_flaky_spec.rb:42,30,6432,true]
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
- rake flaky:stress[the_flaky_spec.rb:42,50,,true]
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
- rake flaky:report
215
+ bin/rails flaky:report
211
216
  ```
212
217
 
213
218
  ## License
@@ -56,19 +56,26 @@ module Flaky
56
56
  $stdout.flush
57
57
 
58
58
  jobs = provider.fetch_jobs(pipeline_id: wf[:pipeline_id])
59
- pipeline_result = jobs.any? { |j| j[:result] == "failed" } ? "failed" : "passed"
59
+ results = jobs.map { |j| j[:result] }
60
60
 
61
- @repo.insert_ci_run(
62
- workflow_id: wf[:id], pipeline_id: wf[:pipeline_id],
63
- branch: wf[:branch], result: pipeline_result, created_at: wf[:created_at],
64
- commit_sha: wf[:commit_sha]
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
- failures = 0
70
- jobs.each_with_index do |job, ji|
71
- failures += process_job(provider, job, wf, ji, jobs.length)
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
- log = provider.fetch_log(job_id: job[:id])
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: parsed.failure_count.to_i > 0 ? "failed" : "passed",
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 (#{parsed.example_count} examples)"
117
+ puts "\e[32mok\e[0m"
104
118
  0
105
119
  end
106
120
  end
@@ -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 %-50s %s", "Fails", "Location", "Last Failure")
28
- puts "-" * 90
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
- truncated = location.length > 48 ? "...#{location[-45..]}" : location
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
- seeds = resolve_seeds
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}, Seeds: #{seed_description(seeds)}, CI simulation: #{@ci_simulate}"
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 = seeds ? seeds[i % seeds.length] : rand(100_000)
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 << run_seed
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 resolve_seeds
84
+ def resolve_seed
85
85
  case @seed
86
86
  when :random
87
87
  nil
88
88
  when Integer
89
- [@seed]
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
- if db_seeds.any?
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(seeds)
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 seeds ? "#{seeds.length} from database" : "random"
101
+ else seed.present? ? seed.to_s : "random (no known failing seeds)"
108
102
  end
109
103
  end
110
104
 
@@ -14,6 +14,11 @@ module Flaky
14
14
  @test_blocks = ["Unit Tests", "System Tests"]
15
15
  end
16
16
 
17
+ # `branch = :all` tracks every branch instead of one.
18
+ def all_branches?
19
+ branch == :all
20
+ end
21
+
17
22
  def provider=(name)
18
23
  @provider_name = name.to_sym
19
24
  end
@@ -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
- output = run_cmd("gh run list --branch #{config.branch} --limit 100 --json databaseId,conclusion,createdAt,headBranch,headSha,workflowName")
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 || "unknown"
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
- branch = config.branch
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 == "passed" ? "passed" : "failed"
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 = params.map { |k, v| "#{k}=#{v}" }.join("&")
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
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
92
- http.request(req)
93
- end
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
- raise Error, "Semaphore API error (#{response.code}): #{response.body[0..200]}" unless response.is_a?(Net::HTTPSuccess)
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
- JSON.parse(response.body)
98
- rescue Net::OpenTimeout, Net::ReadTimeout => e
99
- raise Error, "Semaphore API timeout: #{e.message}"
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
@@ -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
- ORDER BY failure_count DESC, last_failure DESC
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 DISTINCT seed FROM test_failures WHERE #{conditions.join(' AND ')} ORDER BY failed_at DESC",
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Flaky
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.0"
5
5
  end
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.3.1
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: 3.6.9
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: []