flaky-friend 0.1.2 → 0.2.1

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: d016af0bdc9c0fbadfe3671661682a1cdf85eb96b2f666dec28c1c1d69420757
4
- data.tar.gz: 6983cdd0f2763185858bf3774f8ab4df10e503691f1f37480be65f31ea1ef4ae
3
+ metadata.gz: 8a9f46407de5b2f8ef6fa5178ec220bc84fef9376e5bc68e7d3703066dd663b7
4
+ data.tar.gz: ee59c6d55778658f109cdefe410d770af048579f1381fab466aae6c19d6b4392
5
5
  SHA512:
6
- metadata.gz: 2619613daf34a41ae20f86702e212fd4379fa738dfcc1fcf30e32e16558ad741152612559d8f50ab368c2538a80fd7ce357899962495bfa7d3e05d8bdda2e117
7
- data.tar.gz: 32f4ca285cf88f32ff8b80eb082628063dd8c745ce39175ef96f41af5c825df7b0ae3cc8d7501cde240affeaa5c79d46214d21f9fdf6aa154a9380ecf78612ea
6
+ metadata.gz: 90cdea90b46b2aad131864b0caa56e35243fe97c346242bdb8462873d8fab3df64d91270a234219ff9a74a0cf40860daa09773e3da14a5b81dbb7ef437f3a442
7
+ data.tar.gz: 421d03e4fac9a19c847d428a8186f91730622f376fece021414617a4121b30482ca86da05277ddfff443732de05c55432c0d1ea41e077f01797308f2e98750c0
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flaky
4
+ module AgeParser
5
+ # Parses a human-friendly age string ("24h", "7d", "30m") into seconds.
6
+ # Returns 86400 (24h) for unrecognized formats.
7
+ def self.to_seconds(age)
8
+ case age.to_s
9
+ when /\A(\d+)h\z/ then $1.to_i * 3600
10
+ when /\A(\d+)d\z/ then $1.to_i * 86400
11
+ when /\A(\d+)m\z/ then $1.to_i * 60
12
+ else 86400
13
+ end
14
+ end
15
+ end
16
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../database"
3
+ require_relative "../repository"
4
4
  require_relative "../log_parser"
5
5
 
6
6
  module Flaky
@@ -8,67 +8,100 @@ module Flaky
8
8
  class Fetch
9
9
  def initialize(age: "24h")
10
10
  @age = age
11
- @db = Database.new
11
+ @repo = Repository.new
12
12
  @parser = LogParser.new
13
13
  end
14
14
 
15
15
  def execute
16
16
  provider = Flaky.provider
17
17
  branch = Flaky.configuration.branch
18
- conn = @db.connection
19
18
 
19
+ print "Fetching #{branch} workflows (last #{@age})... "
20
+ $stdout.flush
20
21
  workflows = provider.fetch_workflows(age: @age)
21
- main_workflows = workflows.select { |w| w[:branch] == branch }
22
+ puts "#{workflows.length} found."
22
23
 
23
- if main_workflows.empty?
24
- puts "No #{branch} workflows found in the last #{@age}."
24
+ if workflows.empty?
25
+ puts "Nothing to do."
25
26
  return
26
27
  end
27
28
 
28
- new_workflows = 0
29
- new_failures = 0
30
- total_jobs = 0
29
+ new_workflows = workflows.reject { |wf| @repo.workflow_fetched?(wf[:id]) }
31
30
 
32
- main_workflows.each do |wf|
33
- # Skip if already fetched
34
- existing = conn.get_first_value("SELECT 1 FROM ci_runs WHERE workflow_id = ?", wf[:id])
35
- next if existing
36
-
37
- # Determine pipeline result by fetching jobs
38
- jobs = provider.fetch_jobs(pipeline_id: wf[:pipeline_id])
39
- pipeline_result = jobs.any? { |j| j[:result] == "failed" } ? "failed" : "passed"
40
-
41
- conn.execute(
42
- "INSERT INTO ci_runs (workflow_id, pipeline_id, branch, result, created_at) VALUES (?, ?, ?, ?, ?)",
43
- [wf[:id], wf[:pipeline_id], wf[:branch], pipeline_result, wf[:created_at]]
44
- )
45
- new_workflows += 1
46
-
47
- jobs.each do |job|
48
- total_jobs += 1
49
- log = provider.fetch_log(job_id: job[:id])
50
- parsed = @parser.parse(log)
51
-
52
- conn.execute(
53
- "INSERT OR IGNORE INTO job_results (job_id, workflow_id, job_name, block_name, result, example_count, failure_count, seed, duration_seconds) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
54
- [job[:id], wf[:id], job[:name], job[:block_name], parsed.failure_count.to_i > 0 ? "failed" : "passed",
55
- parsed.example_count, parsed.failure_count, parsed.seed, parsed.duration_seconds]
56
- )
31
+ if new_workflows.empty?
32
+ puts "All #{workflows.length} workflows already in database."
33
+ return
34
+ end
57
35
 
58
- parsed.failures.each do |failure|
59
- conn.execute(
60
- "INSERT OR IGNORE INTO test_failures (workflow_id, job_id, job_name, spec_file, line_number, description, seed, branch, failed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
61
- [wf[:id], job[:id], job[:name], failure.spec_file, failure.line_number, failure.description,
62
- parsed.seed, wf[:branch], wf[:created_at]]
63
- )
64
- new_failures += 1
65
- end
66
- end
36
+ puts "Processing #{new_workflows.length} new workflow(s)..."
37
+
38
+ total_jobs = 0
39
+ total_failures = 0
40
+
41
+ new_workflows.each_with_index do |wf, wi|
42
+ jobs, failures = process_workflow(provider, wf, wi, new_workflows.length)
43
+ total_jobs += jobs
44
+ total_failures += failures
67
45
  end
68
46
 
69
- puts "Fetched #{new_workflows} new workflow(s), #{total_jobs} job(s) parsed, #{new_failures} failure(s) recorded."
47
+ puts "\nDone: #{new_workflows.length} workflow(s), #{total_jobs} job(s), #{total_failures} failure(s) recorded."
70
48
  ensure
71
- @db.close
49
+ @repo.close
50
+ end
51
+
52
+ private
53
+
54
+ def process_workflow(provider, wf, index, total)
55
+ print " [#{index + 1}/#{total}] #{wf[:created_at]} — fetching jobs... "
56
+ $stdout.flush
57
+
58
+ jobs = provider.fetch_jobs(pipeline_id: wf[:pipeline_id])
59
+ pipeline_result = jobs.any? { |j| j[:result] == "failed" } ? "failed" : "passed"
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
+ )
65
+
66
+ puts "#{jobs.length} test jobs (#{pipeline_result})"
67
+
68
+ failures = 0
69
+ jobs.each_with_index do |job, ji|
70
+ failures += process_job(provider, job, wf, ji, jobs.length)
71
+ end
72
+
73
+ [jobs.length, failures]
74
+ end
75
+
76
+ def process_job(provider, job, wf, index, total)
77
+ print " [#{index + 1}/#{total}] #{job[:name]}... "
78
+ $stdout.flush
79
+
80
+ log = provider.fetch_log(job_id: job[:id])
81
+ parsed = @parser.parse(log)
82
+
83
+ @repo.insert_job_result(
84
+ job_id: job[:id], workflow_id: wf[:id], job_name: job[:name],
85
+ block_name: job[:block_name], result: parsed.failure_count.to_i > 0 ? "failed" : "passed",
86
+ example_count: parsed.example_count, failure_count: parsed.failure_count,
87
+ seed: parsed.seed, duration_seconds: parsed.duration_seconds
88
+ )
89
+
90
+ if parsed.failures.any?
91
+ parsed.failures.each do |failure|
92
+ @repo.insert_test_failure(
93
+ workflow_id: wf[:id], job_id: job[:id], job_name: job[:name],
94
+ spec_file: failure.spec_file, line_number: failure.line_number,
95
+ description: failure.description, seed: parsed.seed,
96
+ branch: wf[:branch], failed_at: wf[:created_at]
97
+ )
98
+ end
99
+ puts "\e[31m#{parsed.failures.length} failure(s)\e[0m"
100
+ parsed.failures.length
101
+ else
102
+ puts "\e[32mok\e[0m (#{parsed.example_count} examples)"
103
+ 0
104
+ end
72
105
  end
73
106
  end
74
107
  end
@@ -1,43 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../database"
3
+ require_relative "../repository"
4
4
 
5
5
  module Flaky
6
6
  module Commands
7
7
  class History
8
8
  def initialize(spec_location:)
9
9
  @spec_location = spec_location
10
- @db = Database.new
10
+ @repo = Repository.new
11
11
  end
12
12
 
13
13
  def execute
14
- conn = @db.connection
15
-
16
14
  file, line = parse_location(@spec_location)
17
-
18
- conditions = ["tf.spec_file LIKE ?"]
19
- params = ["%#{file}%"]
20
-
21
- if line
22
- conditions << "tf.line_number = ?"
23
- params << line
24
- end
25
-
26
- rows = conn.execute(<<~SQL, params)
27
- SELECT
28
- tf.spec_file,
29
- tf.line_number,
30
- tf.description,
31
- tf.seed,
32
- tf.job_name,
33
- tf.branch,
34
- tf.failed_at,
35
- cr.workflow_id
36
- FROM test_failures tf
37
- JOIN ci_runs cr ON cr.workflow_id = tf.workflow_id
38
- WHERE #{conditions.join(" AND ")}
39
- ORDER BY tf.failed_at DESC
40
- SQL
15
+ rows = @repo.failure_history(file: file, line: line)
41
16
 
42
17
  if rows.empty?
43
18
  puts "No failures found matching '#{@spec_location}'."
@@ -58,9 +33,9 @@ module Flaky
58
33
 
59
34
  seeds = rows.map { |r| r["seed"] }.uniq
60
35
  puts "Unique seeds: #{seeds.join(', ')}"
61
- puts "\nTo reproduce: rake flaky:stress[#{first['spec_file']}:#{first['line_number']},10,#{seeds.first},true]"
36
+ puts "\nTo reproduce: SPEC=#{first['spec_file']}:#{first['line_number']} SEED=#{seeds.first} CI=true bin/rails flaky:stress"
62
37
  ensure
63
- @db.close
38
+ @repo.close
64
39
  end
65
40
 
66
41
  private
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../database"
3
+ require_relative "../repository"
4
4
 
5
5
  module Flaky
6
6
  module Commands
@@ -8,39 +8,20 @@ module Flaky
8
8
  def initialize(since_days: 30, min_failures: 1)
9
9
  @since_days = since_days
10
10
  @min_failures = min_failures
11
- @db = Database.new
11
+ @repo = Repository.new
12
12
  end
13
13
 
14
14
  def execute
15
- conn = @db.connection
16
15
  branch = Flaky.configuration.branch
17
16
 
18
- rows = conn.execute(<<~SQL, [branch, "-#{@since_days} days", @min_failures])
19
- SELECT
20
- tf.spec_file,
21
- tf.line_number,
22
- tf.description,
23
- COUNT(*) as failure_count,
24
- MAX(tf.failed_at) as last_failure,
25
- GROUP_CONCAT(DISTINCT tf.seed) as seeds
26
- FROM test_failures tf
27
- JOIN ci_runs cr ON cr.workflow_id = tf.workflow_id
28
- WHERE cr.branch = ?
29
- AND cr.created_at >= datetime('now', ?)
30
- GROUP BY tf.spec_file, tf.line_number
31
- HAVING COUNT(*) >= ?
32
- ORDER BY failure_count DESC, last_failure DESC
33
- SQL
17
+ rows = @repo.rank_failures(branch: branch, since_days: @since_days, min_failures: @min_failures)
34
18
 
35
19
  if rows.empty?
36
20
  puts "No flaky tests found in the last #{@since_days} days."
37
21
  return
38
22
  end
39
23
 
40
- total_runs = conn.get_first_value(
41
- "SELECT COUNT(DISTINCT workflow_id) FROM ci_runs WHERE branch = ? AND created_at >= datetime('now', ?)",
42
- [branch, "-#{@since_days} days"]
43
- ).to_i
24
+ total_runs = @repo.total_runs_count(branch: branch, since_days: @since_days)
44
25
 
45
26
  puts "Flaky tests on #{branch} (last #{@since_days} days, #{total_runs} CI runs):\n\n"
46
27
  puts format("%-6s %-50s %s", "Fails", "Location", "Last Failure")
@@ -59,7 +40,7 @@ module Flaky
59
40
  end
60
41
  end
61
42
  ensure
62
- @db.close
43
+ @repo.close
63
44
  end
64
45
  end
65
46
  end
@@ -1,66 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../database"
3
+ require_relative "../repository"
4
4
 
5
5
  module Flaky
6
6
  module Commands
7
7
  class Report
8
8
  def initialize
9
- @db = Database.new
9
+ @repo = Repository.new
10
10
  end
11
11
 
12
12
  def execute
13
- conn = @db.connection
14
13
  branch = Flaky.configuration.branch
15
-
16
- total_runs = conn.get_first_value("SELECT COUNT(*) FROM ci_runs WHERE branch = ?", branch).to_i
17
- failed_runs = conn.get_first_value("SELECT COUNT(*) FROM ci_runs WHERE branch = ? AND result = 'failed'", branch).to_i
18
- total_failures = conn.get_first_value("SELECT COUNT(*) FROM test_failures WHERE branch = ?", branch).to_i
19
- unique_specs = conn.get_first_value("SELECT COUNT(DISTINCT spec_file || ':' || line_number) FROM test_failures WHERE branch = ?", branch).to_i
20
- last_fetch = conn.get_first_value("SELECT MAX(fetched_at) FROM ci_runs")
14
+ stats = @repo.run_stats(branch: branch)
15
+ trend = @repo.failure_trend(branch: branch)
21
16
 
22
17
  puts "=== Flaky Test Report (#{branch}) ==="
23
18
  puts ""
24
- puts "CI Runs tracked: #{total_runs}"
25
- puts "Failed runs: #{failed_runs} (#{total_runs > 0 ? (failed_runs.to_f / total_runs * 100).round(1) : 0}%)"
26
- puts "Total test failures: #{total_failures}"
27
- puts "Unique flaky specs: #{unique_specs}"
28
- puts "Last fetch: #{last_fetch || 'never'}"
29
-
30
- # Recent trend
31
- recent = conn.get_first_value(
32
- "SELECT COUNT(*) FROM test_failures WHERE branch = ? AND failed_at >= datetime('now', '-7 days')", branch
33
- ).to_i
34
- prior = conn.get_first_value(
35
- "SELECT COUNT(*) FROM test_failures WHERE branch = ? AND failed_at >= datetime('now', '-14 days') AND failed_at < datetime('now', '-7 days')", branch
36
- ).to_i
19
+ puts "CI Runs tracked: #{stats[:total_runs]}"
20
+ failure_pct = stats[:total_runs] > 0 ? (stats[:failed_runs].to_f / stats[:total_runs] * 100).round(1) : 0
21
+ puts "Failed runs: #{stats[:failed_runs]} (#{failure_pct}%)"
22
+ puts "Total test failures: #{stats[:total_failures]}"
23
+ puts "Unique flaky specs: #{stats[:unique_specs]}"
24
+ puts "Last fetch: #{stats[:last_fetch] || 'never'}"
37
25
 
38
26
  puts ""
39
- puts "7-day trend: #{recent} failures (prior 7 days: #{prior})"
27
+ puts "7-day trend: #{trend[:recent]} failures (prior 7 days: #{trend[:prior]})"
40
28
 
41
- if recent > prior
29
+ if trend[:recent] > trend[:prior]
42
30
  puts " \e[31m▲ Trending worse\e[0m"
43
- elsif recent < prior
31
+ elsif trend[:recent] < trend[:prior]
44
32
  puts " \e[32m▼ Trending better\e[0m"
45
33
  else
46
34
  puts " → Stable"
47
35
  end
48
36
 
49
- # Top 5 flaky tests
50
- top = conn.execute(<<~SQL, [branch])
51
- SELECT
52
- spec_file,
53
- line_number,
54
- description,
55
- COUNT(*) as failure_count,
56
- MAX(failed_at) as last_failure
57
- FROM test_failures
58
- WHERE branch = ?
59
- GROUP BY spec_file, line_number
60
- ORDER BY failure_count DESC
61
- LIMIT 5
62
- SQL
63
-
37
+ top = @repo.top_flaky(branch: branch)
64
38
  if top.any?
65
39
  puts "\nTop 5 flaky tests:"
66
40
  puts "-" * 80
@@ -70,8 +44,7 @@ module Flaky
70
44
  end
71
45
  end
72
46
 
73
- # Recent stress runs
74
- stress = conn.execute("SELECT * FROM stress_runs ORDER BY created_at DESC LIMIT 3")
47
+ stress = @repo.recent_stress_runs
75
48
  if stress.any?
76
49
  puts "\nRecent stress runs:"
77
50
  puts "-" * 80
@@ -83,7 +56,7 @@ module Flaky
83
56
  end
84
57
  end
85
58
  ensure
86
- @db.close
59
+ @repo.close
87
60
  end
88
61
  end
89
62
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../database"
3
+ require_relative "../repository"
4
4
 
5
5
  module Flaky
6
6
  module Commands
@@ -11,7 +11,7 @@ module Flaky
11
11
  @seed = seed
12
12
  @ci_simulate = ci_simulate
13
13
  @timeout = timeout
14
- @db = Database.new
14
+ @repo = Repository.new
15
15
  end
16
16
 
17
17
  def execute
@@ -67,16 +67,14 @@ module Flaky
67
67
  puts " FLAKY_CI_SIMULATE=#{@ci_simulate ? '1' : '0'} bundle exec rspec #{@spec_location} --seed #{failed_seeds.first}"
68
68
  end
69
69
 
70
- # Record to database
71
- conn = @db.connection
72
- conn.execute(
73
- "INSERT INTO stress_runs (spec_location, seed, iterations, passes, failures, ci_simulation) VALUES (?, ?, ?, ?, ?, ?)",
74
- [@spec_location, @seed, @iterations, passes, failures, @ci_simulate ? 1 : 0]
70
+ @repo.insert_stress_run(
71
+ spec_location: @spec_location, seed: @seed, iterations: @iterations,
72
+ passes: passes, failures: failures, ci_simulation: @ci_simulate ? 1 : 0
75
73
  )
76
74
 
77
75
  exit(failures > 0 ? 1 : 0)
78
76
  ensure
79
- @db.close
77
+ @repo.close
80
78
  end
81
79
  end
82
80
  end
@@ -4,13 +4,14 @@ module Flaky
4
4
  class Configuration
5
5
  PROVIDERS = {}
6
6
 
7
- attr_accessor :project, :branch, :db_path
7
+ attr_accessor :project, :branch, :db_path, :test_blocks
8
8
 
9
9
  def initialize
10
10
  @provider_name = nil
11
11
  @project = nil
12
12
  @branch = "main"
13
13
  @db_path = nil # resolved lazily
14
+ @test_blocks = ["Unit Tests", "System Tests"]
14
15
  end
15
16
 
16
17
  def provider=(name)
@@ -18,6 +19,7 @@ module Flaky
18
19
  end
19
20
 
20
21
  def provider_instance
22
+ validate!
21
23
  klass = PROVIDERS[@provider_name] || raise(Error, "Unknown provider: #{@provider_name}. Registered: #{PROVIDERS.keys.join(', ')}")
22
24
  klass.new(self)
23
25
  end
@@ -26,6 +28,15 @@ module Flaky
26
28
  @db_path || (defined?(Rails) ? Rails.root.join("tmp", "flaky.db").to_s : "tmp/flaky.db")
27
29
  end
28
30
 
31
+ def validate!
32
+ unless @provider_name
33
+ raise Error, "Flaky: provider not configured. Add `Flaky.configure { |c| c.provider = :semaphore }` to your initializer."
34
+ end
35
+ unless @project
36
+ raise Error, "Flaky: project not configured. Add `Flaky.configure { |c| c.project = 'your-project' }` to your initializer."
37
+ end
38
+ end
39
+
29
40
  def self.register_provider(name, klass)
30
41
  PROVIDERS[name.to_sym] = klass
31
42
  end
@@ -10,7 +10,7 @@ module Flaky
10
10
  end
11
11
 
12
12
  # Returns Array of Hashes:
13
- # { id:, pipeline_id:, branch:, result:, created_at: }
13
+ # { id:, pipeline_id:, branch:, created_at: }
14
14
  def fetch_workflows(age: "24h")
15
15
  raise NotImplementedError
16
16
  end
@@ -2,13 +2,13 @@
2
2
 
3
3
  require "json"
4
4
  require_relative "base"
5
+ require_relative "../age_parser"
5
6
 
6
7
  module Flaky
7
8
  module Providers
8
9
  class GithubActions < Base
9
10
  def fetch_workflows(age: "24h")
10
- hours = parse_age_to_hours(age)
11
- cutoff = Time.now - (hours * 3600)
11
+ cutoff = Time.now - AgeParser.to_seconds(age)
12
12
 
13
13
  output = run_cmd("gh run list --branch #{config.branch} --limit 100 --json databaseId,conclusion,createdAt,headBranch,workflowName")
14
14
  runs = JSON.parse(output)
@@ -56,14 +56,6 @@ module Flaky
56
56
  output
57
57
  end
58
58
 
59
- def parse_age_to_hours(age)
60
- case age
61
- when /(\d+)h/ then $1.to_i
62
- when /(\d+)d/ then $1.to_i * 24
63
- else 24
64
- end
65
- end
66
-
67
59
  def map_conclusion(conclusion)
68
60
  case conclusion
69
61
  when "success" then "passed"
@@ -5,14 +5,13 @@ require "net/http"
5
5
  require "uri"
6
6
  require "yaml"
7
7
  require_relative "base"
8
+ require_relative "../age_parser"
8
9
 
9
10
  module Flaky
10
11
  module Providers
11
12
  class Semaphore < Base
12
- TEST_BLOCKS = ["Unit Tests", "System Tests"].freeze
13
-
14
13
  def fetch_workflows(age: "24h")
15
- cutoff = Time.now - parse_age_seconds(age)
14
+ cutoff = Time.now - AgeParser.to_seconds(age)
16
15
  project_id = resolve_project_id
17
16
  branch = config.branch
18
17
  workflows = []
@@ -51,10 +50,11 @@ module Flaky
51
50
  def fetch_jobs(pipeline_id:)
52
51
  data = api_get("pipelines/#{pipeline_id}", detailed: true)
53
52
  blocks = data["blocks"] || []
53
+ test_blocks = config.test_blocks
54
54
 
55
55
  blocks.flat_map do |block|
56
56
  block_name = block["name"]
57
- next [] unless TEST_BLOCKS.include?(block_name)
57
+ next [] unless test_blocks.include?(block_name)
58
58
 
59
59
  (block["jobs"] || []).map do |job|
60
60
  {
@@ -94,22 +94,41 @@ module Flaky
94
94
  raise Error, "Semaphore API error (#{response.code}): #{response.body[0..200]}" unless response.is_a?(Net::HTTPSuccess)
95
95
 
96
96
  JSON.parse(response.body)
97
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
98
+ raise Error, "Semaphore API timeout: #{e.message}"
99
+ rescue SocketError => e
100
+ raise Error, "Cannot reach Semaphore API: #{e.message}"
101
+ rescue Errno::ECONNREFUSED => e
102
+ raise Error, "Connection refused to Semaphore API: #{e.message}"
103
+ rescue JSON::ParserError => e
104
+ raise Error, "Invalid JSON from Semaphore API: #{e.message}"
105
+ end
106
+
107
+ def sem_config
108
+ @sem_config ||= begin
109
+ path = File.expand_path("~/.sem.yaml")
110
+ unless File.exist?(path)
111
+ raise Error, "Semaphore config not found at #{path}. Run `sem connect` to authenticate."
112
+ end
113
+ YAML.safe_load_file(path)
114
+ end
97
115
  end
98
116
 
99
117
  def api_host
100
118
  @api_host ||= begin
101
- sem_config = YAML.load_file(File.expand_path("~/.sem.yaml"))
102
119
  context_name = sem_config["active-context"]
103
120
  host = sem_config.dig("contexts", context_name, "host")
121
+ raise Error, "No host found in ~/.sem.yaml for context '#{context_name}'" unless host
104
122
  "https://#{host}"
105
123
  end
106
124
  end
107
125
 
108
126
  def api_token
109
127
  @api_token ||= begin
110
- sem_config = YAML.load_file(File.expand_path("~/.sem.yaml"))
111
128
  context_name = sem_config["active-context"]
112
- sem_config.dig("contexts", context_name, "auth", "token")
129
+ token = sem_config.dig("contexts", context_name, "auth", "token")
130
+ raise Error, "No auth token found in ~/.sem.yaml for context '#{context_name}'" unless token
131
+ token
113
132
  end
114
133
  end
115
134
 
@@ -121,15 +140,6 @@ module Flaky
121
140
  project.dig("metadata", "id")
122
141
  end
123
142
  end
124
-
125
- def parse_age_seconds(age)
126
- case age.to_s
127
- when /\A(\d+)h\z/ then $1.to_i * 3600
128
- when /\A(\d+)d\z/ then $1.to_i * 86400
129
- when /\A(\d+)m\z/ then $1.to_i * 60
130
- else 86400 # default 24h
131
- end
132
- end
133
143
  end
134
144
 
135
145
  Configuration.register_provider(:semaphore, Semaphore)
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "database"
4
+
5
+ module Flaky
6
+ class Repository
7
+ def initialize(path = nil)
8
+ @db = Database.new(path)
9
+ end
10
+
11
+ def close
12
+ @db.close
13
+ end
14
+
15
+ # --- CI Runs ---
16
+
17
+ def workflow_fetched?(workflow_id)
18
+ !!connection.get_first_value("SELECT 1 FROM ci_runs WHERE workflow_id = ?", workflow_id)
19
+ end
20
+
21
+ def insert_ci_run(workflow_id:, pipeline_id:, branch:, result:, created_at:)
22
+ connection.execute(
23
+ "INSERT INTO ci_runs (workflow_id, pipeline_id, branch, result, created_at) VALUES (?, ?, ?, ?, ?)",
24
+ [workflow_id, pipeline_id, branch, result, created_at]
25
+ )
26
+ end
27
+
28
+ # --- Job Results ---
29
+
30
+ def insert_job_result(job_id:, workflow_id:, job_name:, block_name:, result:, example_count:, failure_count:, seed:, duration_seconds:)
31
+ connection.execute(
32
+ "INSERT OR IGNORE INTO job_results (job_id, workflow_id, job_name, block_name, result, example_count, failure_count, seed, duration_seconds) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
33
+ [job_id, workflow_id, job_name, block_name, result, example_count, failure_count, seed, duration_seconds]
34
+ )
35
+ end
36
+
37
+ # --- Test Failures ---
38
+
39
+ def insert_test_failure(workflow_id:, job_id:, job_name:, spec_file:, line_number:, description:, seed:, branch:, failed_at:)
40
+ connection.execute(
41
+ "INSERT OR IGNORE INTO test_failures (workflow_id, job_id, job_name, spec_file, line_number, description, seed, branch, failed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
42
+ [workflow_id, job_id, job_name, spec_file, line_number, description, seed, branch, failed_at]
43
+ )
44
+ end
45
+
46
+ # --- Ranking ---
47
+
48
+ def rank_failures(branch:, since_days:, min_failures: 1)
49
+ connection.execute(<<~SQL, [branch, "-#{since_days} days", min_failures])
50
+ SELECT
51
+ tf.spec_file,
52
+ tf.line_number,
53
+ tf.description,
54
+ COUNT(*) as failure_count,
55
+ MAX(tf.failed_at) as last_failure,
56
+ GROUP_CONCAT(DISTINCT tf.seed) as seeds
57
+ FROM test_failures tf
58
+ JOIN ci_runs cr ON cr.workflow_id = tf.workflow_id
59
+ WHERE cr.branch = ?
60
+ AND cr.created_at >= datetime('now', ?)
61
+ GROUP BY tf.spec_file, tf.line_number
62
+ HAVING COUNT(*) >= ?
63
+ ORDER BY failure_count DESC, last_failure DESC
64
+ SQL
65
+ end
66
+
67
+ def total_runs_count(branch:, since_days:)
68
+ connection.get_first_value(
69
+ "SELECT COUNT(DISTINCT workflow_id) FROM ci_runs WHERE branch = ? AND created_at >= datetime('now', ?)",
70
+ [branch, "-#{since_days} days"]
71
+ ).to_i
72
+ end
73
+
74
+ # --- History ---
75
+
76
+ def failure_history(file:, line: nil)
77
+ conditions = ["tf.spec_file LIKE ?"]
78
+ params = ["%#{file}%"]
79
+
80
+ if line
81
+ conditions << "tf.line_number = ?"
82
+ params << line
83
+ end
84
+
85
+ connection.execute(<<~SQL, params)
86
+ SELECT
87
+ tf.spec_file,
88
+ tf.line_number,
89
+ tf.description,
90
+ tf.seed,
91
+ tf.job_name,
92
+ tf.branch,
93
+ tf.failed_at,
94
+ cr.workflow_id
95
+ FROM test_failures tf
96
+ JOIN ci_runs cr ON cr.workflow_id = tf.workflow_id
97
+ WHERE #{conditions.join(" AND ")}
98
+ ORDER BY tf.failed_at DESC
99
+ SQL
100
+ end
101
+
102
+ # --- Report ---
103
+
104
+ def run_stats(branch:)
105
+ {
106
+ total_runs: connection.get_first_value("SELECT COUNT(*) FROM ci_runs WHERE branch = ?", branch).to_i,
107
+ failed_runs: connection.get_first_value("SELECT COUNT(*) FROM ci_runs WHERE branch = ? AND result = 'failed'", branch).to_i,
108
+ total_failures: connection.get_first_value("SELECT COUNT(*) FROM test_failures WHERE branch = ?", branch).to_i,
109
+ unique_specs: connection.get_first_value("SELECT COUNT(DISTINCT spec_file || ':' || line_number) FROM test_failures WHERE branch = ?", branch).to_i,
110
+ last_fetch: connection.get_first_value("SELECT MAX(fetched_at) FROM ci_runs")
111
+ }
112
+ end
113
+
114
+ def failure_trend(branch:, period_days: 7)
115
+ recent = connection.get_first_value(
116
+ "SELECT COUNT(*) FROM test_failures WHERE branch = ? AND failed_at >= datetime('now', ?)",
117
+ [branch, "-#{period_days} days"]
118
+ ).to_i
119
+
120
+ prior = connection.get_first_value(
121
+ "SELECT COUNT(*) FROM test_failures WHERE branch = ? AND failed_at >= datetime('now', ?) AND failed_at < datetime('now', ?)",
122
+ [branch, "-#{period_days * 2} days", "-#{period_days} days"]
123
+ ).to_i
124
+
125
+ { recent: recent, prior: prior }
126
+ end
127
+
128
+ def top_flaky(branch:, limit: 5)
129
+ connection.execute(<<~SQL, [branch, limit])
130
+ SELECT
131
+ spec_file,
132
+ line_number,
133
+ description,
134
+ COUNT(*) as failure_count,
135
+ MAX(failed_at) as last_failure
136
+ FROM test_failures
137
+ WHERE branch = ?
138
+ GROUP BY spec_file, line_number
139
+ ORDER BY failure_count DESC
140
+ LIMIT ?
141
+ SQL
142
+ end
143
+
144
+ def recent_stress_runs(limit: 3)
145
+ connection.execute("SELECT * FROM stress_runs ORDER BY created_at DESC LIMIT ?", limit)
146
+ end
147
+
148
+ # --- Stress ---
149
+
150
+ def insert_stress_run(spec_location:, seed:, iterations:, passes:, failures:, ci_simulation:)
151
+ connection.execute(
152
+ "INSERT INTO stress_runs (spec_location, seed, iterations, passes, failures, ci_simulation) VALUES (?, ?, ?, ?, ?, ?)",
153
+ [spec_location, seed, iterations, passes, failures, ci_simulation]
154
+ )
155
+ end
156
+
157
+ private
158
+
159
+ def connection
160
+ @db.connection
161
+ end
162
+ end
163
+ end
@@ -1,36 +1,89 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # All tasks use environment variables instead of rake arguments
4
+ # to avoid zsh bracket escaping issues.
5
+ #
6
+ # bin/rails flaky:fetch DURATION=28d
7
+ # bin/rails flaky:rank SINCE=7
8
+ # bin/rails flaky:history SPEC=path/to/spec.rb:42
9
+ # bin/rails flaky:stress SPEC=path/to/spec.rb:42 N=20 SEED=12345 CI=true TIMEOUT=600
10
+ # bin/rails flaky:report
11
+
12
+ FLAKY_HELP = <<~HELP
13
+ \e[1mflaky-friend v#{Flaky::VERSION}\e[0m — Track, rank, and reproduce flaky CI test failures
14
+
15
+ \e[1mUsage:\e[0m
16
+ bin/rails flaky:<command> [ENV_VARS]
17
+
18
+ \e[1mCommands:\e[0m
19
+ flaky:fetch Fetch recent CI results into the local database
20
+ flaky:rank Rank flaky tests by failure frequency
21
+ flaky:history Show failure history for a specific spec
22
+ flaky:stress Stress-test a spec to reproduce/prove a fix
23
+ flaky:report Summary dashboard of flaky test status
24
+
25
+ \e[1mExamples:\e[0m
26
+ bin/rails flaky:fetch # last 24 hours (default)
27
+ bin/rails flaky:fetch DURATION=28d # last 28 days
28
+ bin/rails flaky:rank # last 30 days (default)
29
+ bin/rails flaky:rank SINCE=7 # last 7 days
30
+ bin/rails flaky:history SPEC=spec/foo_spec.rb:42
31
+ bin/rails flaky:stress SPEC=spec/foo_spec.rb:42 N=20 SEED=12345 CI=true
32
+ bin/rails flaky:report
33
+
34
+ \e[1mEnvironment variables:\e[0m
35
+ DURATION Age window for fetch (e.g. 24h, 7d, 30m) [default: 24h]
36
+ SINCE Number of days to look back for rank [default: 30]
37
+ SPEC Spec file location (path/to/spec.rb or path:line)
38
+ N Number of stress test iterations [default: 20]
39
+ SEED RSpec seed for deterministic ordering
40
+ CI Set to "true" to enable CI simulation (latency + reduced threads)
41
+ TIMEOUT Stress test timeout in seconds [default: 600]
42
+
43
+ HELP
44
+
45
+ desc "Show flaky-friend help"
46
+ task :flaky do
47
+ puts FLAKY_HELP
48
+ end
49
+
3
50
  namespace :flaky do
4
- desc "Fetch recent CI results (age: duration, default 24h)"
5
- task :fetch, [:age] => :environment do |_t, args|
51
+ desc "Show flaky-friend help"
52
+ task help: :environment do
53
+ puts FLAKY_HELP
54
+ end
55
+
56
+ desc "Fetch recent CI results (DURATION=24h)"
57
+ task fetch: :environment do
6
58
  require "flaky/commands/fetch"
7
- age = args[:age] || "24h"
8
- Flaky::Commands::Fetch.new(age: age).execute
59
+ duration = ENV.fetch("DURATION", "24h")
60
+ Flaky::Commands::Fetch.new(age: duration).execute
9
61
  end
10
62
 
11
- desc "Rank flaky tests by failure frequency (since: days, default 30)"
12
- task :rank, [:since] => :environment do |_t, args|
63
+ desc "Rank flaky tests by failure frequency (SINCE=30)"
64
+ task rank: :environment do
13
65
  require "flaky/commands/rank"
14
- since = (args[:since] || 30).to_i
66
+ since = ENV.fetch("SINCE", "30").to_i
15
67
  Flaky::Commands::Rank.new(since_days: since).execute
16
68
  end
17
69
 
18
- desc "Show failure history for a spec (spec_location: file:line)"
19
- task :history, [:spec_location] => :environment do |_t, args|
70
+ desc "Show failure history for a spec (SPEC=path/to/spec.rb:42)"
71
+ task history: :environment do
20
72
  require "flaky/commands/history"
21
- raise "Usage: rake flaky:history[path/to/spec.rb:42]" unless args[:spec_location]
22
- Flaky::Commands::History.new(spec_location: args[:spec_location]).execute
73
+ spec = ENV["SPEC"] || abort("Usage: bin/rails flaky:history SPEC=path/to/spec.rb:42")
74
+ Flaky::Commands::History.new(spec_location: spec).execute
23
75
  end
24
76
 
25
- desc "Stress test a spec (spec, iterations, seed, ci)"
26
- task :stress, [:spec, :iterations, :seed, :ci] => :environment do |_t, args|
77
+ desc "Stress test a spec (SPEC=... N=20 SEED=12345 CI=true TIMEOUT=600)"
78
+ task stress: :environment do
27
79
  require "flaky/commands/stress"
28
- raise "Usage: rake flaky:stress[path/to/spec.rb:42,20,12345,true]" unless args[:spec]
80
+ spec = ENV["SPEC"] || abort("Usage: bin/rails flaky:stress SPEC=path/to/spec.rb:42")
29
81
  Flaky::Commands::Stress.new(
30
- spec_location: args[:spec],
31
- iterations: (args[:iterations] || 20).to_i,
32
- seed: args[:seed]&.to_i,
33
- ci_simulate: args[:ci] == "true"
82
+ spec_location: spec,
83
+ iterations: ENV.fetch("N", "20").to_i,
84
+ seed: ENV["SEED"]&.to_i,
85
+ ci_simulate: ENV["CI"] == "true",
86
+ timeout: ENV.fetch("TIMEOUT", "600").to_i
34
87
  ).execute
35
88
  end
36
89
 
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.1.2"
4
+ VERSION = "0.2.1"
5
5
  end
data/lib/flaky-friend.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "flaky/version"
4
4
  require_relative "flaky/configuration"
5
+ require_relative "flaky/age_parser"
5
6
  require_relative "flaky/providers/semaphore"
6
7
  require_relative "flaky/providers/github_actions"
7
8
  require_relative "flaky/railtie" if defined?(Rails::Railtie)
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.1.2
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Flytedesk
@@ -74,6 +74,7 @@ files:
74
74
  - LICENSE
75
75
  - README.md
76
76
  - lib/flaky-friend.rb
77
+ - lib/flaky/age_parser.rb
77
78
  - lib/flaky/commands/fetch.rb
78
79
  - lib/flaky/commands/history.rb
79
80
  - lib/flaky/commands/rank.rb
@@ -87,6 +88,7 @@ files:
87
88
  - lib/flaky/providers/github_actions.rb
88
89
  - lib/flaky/providers/semaphore.rb
89
90
  - lib/flaky/railtie.rb
91
+ - lib/flaky/repository.rb
90
92
  - lib/flaky/tasks/flaky.rake
91
93
  - lib/flaky/version.rb
92
94
  homepage: https://github.com/Flytedesk/flaky