flaky-friend 0.1.2 → 0.3.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: d016af0bdc9c0fbadfe3671661682a1cdf85eb96b2f666dec28c1c1d69420757
4
- data.tar.gz: 6983cdd0f2763185858bf3774f8ab4df10e503691f1f37480be65f31ea1ef4ae
3
+ metadata.gz: c996492175a4e470f3ea4c08890a4626c90e4de13e35e33a1d919ef41a5b04b6
4
+ data.tar.gz: a7a08059d87983af6e0b163177cee5db2f659201f92267a338355e6b6df71024
5
5
  SHA512:
6
- metadata.gz: 2619613daf34a41ae20f86702e212fd4379fa738dfcc1fcf30e32e16558ad741152612559d8f50ab368c2538a80fd7ce357899962495bfa7d3e05d8bdda2e117
7
- data.tar.gz: 32f4ca285cf88f32ff8b80eb082628063dd8c745ce39175ef96f41af5c825df7b0ae3cc8d7501cde240affeaa5c79d46214d21f9fdf6aa154a9380ecf78612ea
6
+ metadata.gz: 7282d21702ca0d52b80c74c01dc6bf8fce827f4543bd082a45f57d2080b04d9cbc8b866b10aee1352a74ca1f0279cc3ba73d1baed98911b6843428bb5a184662
7
+ data.tar.gz: 5201e17baf1732b2cd21879c391021ba96d0a38a0a9b59970225c6b8f2cba52aaf4b72697e6ac052d9af17cc67bbb28336eb3685dcbb19e2daa0e1ef721e743b
@@ -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,101 @@ 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
+ commit_sha: wf[:commit_sha]
65
+ )
66
+
67
+ puts "#{jobs.length} test jobs (#{pipeline_result})"
68
+
69
+ failures = 0
70
+ jobs.each_with_index do |job, ji|
71
+ failures += process_job(provider, job, wf, ji, jobs.length)
72
+ end
73
+
74
+ [jobs.length, failures]
75
+ end
76
+
77
+ def process_job(provider, job, wf, index, total)
78
+ print " [#{index + 1}/#{total}] #{job[:name]}... "
79
+ $stdout.flush
80
+
81
+ log = provider.fetch_log(job_id: job[:id])
82
+ parsed = @parser.parse(log)
83
+
84
+ @repo.insert_job_result(
85
+ 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",
87
+ example_count: parsed.example_count, failure_count: parsed.failure_count,
88
+ seed: parsed.seed, duration_seconds: parsed.duration_seconds
89
+ )
90
+
91
+ if parsed.failures.any?
92
+ parsed.failures.each do |failure|
93
+ @repo.insert_test_failure(
94
+ workflow_id: wf[:id], job_id: job[:id], job_name: job[:name],
95
+ spec_file: failure.spec_file, line_number: failure.line_number,
96
+ description: failure.description, seed: parsed.seed,
97
+ branch: wf[:branch], failed_at: wf[:created_at]
98
+ )
99
+ end
100
+ puts "\e[31m#{parsed.failures.length} failure(s)\e[0m"
101
+ parsed.failures.length
102
+ else
103
+ puts "\e[32mok\e[0m (#{parsed.example_count} examples)"
104
+ 0
105
+ end
72
106
  end
73
107
  end
74
108
  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}'."
@@ -47,20 +22,21 @@ module Flaky
47
22
  first = rows.first
48
23
  puts "Failure history for #{first['spec_file']}:#{first['line_number']}"
49
24
  puts " #{first['description']}\n\n"
50
- puts format("%-20s %-8s %-30s %s", "Date", "Seed", "Job", "Workflow")
51
- puts "-" * 100
25
+ puts format("%-20s %-8s %-10s %-30s %s", "Date", "Seed", "Commit", "Job", "Workflow")
26
+ puts "-" * 110
52
27
 
53
28
  rows.each do |row|
54
- puts format("%-20s %-8d %-30s %s", row["failed_at"], row["seed"], row["job_name"], row["workflow_id"])
29
+ sha = row["commit_sha"] ? row["commit_sha"][0..6] : " "
30
+ puts format("%-20s %-8d %-10s %-30s %s", row["failed_at"], row["seed"], sha, row["job_name"], row["workflow_id"])
55
31
  end
56
32
 
57
33
  puts "\nTotal failures: #{rows.length}"
58
34
 
59
35
  seeds = rows.map { |r| r["seed"] }.uniq
60
36
  puts "Unique seeds: #{seeds.join(', ')}"
61
- puts "\nTo reproduce: rake flaky:stress[#{first['spec_file']}:#{first['line_number']},10,#{seeds.first},true]"
37
+ puts "\nTo reproduce: SPEC=#{first['spec_file']}:#{first['line_number']} SEED=#{seeds.first} CI=true bin/rails flaky:stress"
62
38
  ensure
63
- @db.close
39
+ @repo.close
64
40
  end
65
41
 
66
42
  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")
@@ -55,11 +36,12 @@ module Flaky
55
36
  puts "\n \e[33m▶ Next to investigate:\e[0m #{location}"
56
37
  puts " #{row['description']}"
57
38
  puts " Seeds: #{row['seeds']}"
39
+ puts " Commits: #{row['commit_shas']}" if row["commit_shas"]
58
40
  puts ""
59
41
  end
60
42
  end
61
43
  ensure
62
- @db.close
44
+ @repo.close
63
45
  end
64
46
  end
65
47
  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
@@ -4,7 +4,7 @@ require "sqlite3"
4
4
 
5
5
  module Flaky
6
6
  class Database
7
- SCHEMA_VERSION = 1
7
+ SCHEMA_VERSION = 2
8
8
 
9
9
  def initialize(path = nil)
10
10
  @path = path || Flaky.configuration.resolved_db_path
@@ -87,9 +87,14 @@ module Flaky
87
87
  CREATE INDEX IF NOT EXISTS idx_ci_runs_branch ON ci_runs(branch);
88
88
  CREATE INDEX IF NOT EXISTS idx_job_results_workflow ON job_results(workflow_id);
89
89
 
90
- PRAGMA user_version = 1;
90
+ PRAGMA user_version = 2;
91
91
  SQL
92
92
  end
93
+
94
+ if version < 2
95
+ db.execute("ALTER TABLE ci_runs ADD COLUMN commit_sha TEXT")
96
+ db.execute("PRAGMA user_version = 2")
97
+ end
93
98
  end
94
99
  end
95
100
  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:, commit_sha:, created_at: }
14
14
  def fetch_workflows(age: "24h")
15
15
  raise NotImplementedError
16
16
  end
@@ -2,15 +2,15 @@
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
- output = run_cmd("gh run list --branch #{config.branch} --limit 100 --json databaseId,conclusion,createdAt,headBranch,workflowName")
13
+ output = run_cmd("gh run list --branch #{config.branch} --limit 100 --json databaseId,conclusion,createdAt,headBranch,headSha,workflowName")
14
14
  runs = JSON.parse(output)
15
15
 
16
16
  runs.filter_map do |run|
@@ -21,6 +21,7 @@ module Flaky
21
21
  id: run["databaseId"].to_s,
22
22
  pipeline_id: run["databaseId"].to_s,
23
23
  branch: run["headBranch"],
24
+ commit_sha: run["headSha"],
24
25
  result: map_conclusion(run["conclusion"]),
25
26
  created_at: run["createdAt"]
26
27
  }
@@ -56,14 +57,6 @@ module Flaky
56
57
  output
57
58
  end
58
59
 
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
60
  def map_conclusion(conclusion)
68
61
  case conclusion
69
62
  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 = []
@@ -34,6 +33,7 @@ module Flaky
34
33
  id: wf["wf_id"],
35
34
  pipeline_id: wf["initial_ppl_id"],
36
35
  branch: wf["branch_name"],
36
+ commit_sha: wf["commit_sha"],
37
37
  created_at: created_at.strftime("%Y-%m-%d %H:%M:%S")
38
38
  }
39
39
  end
@@ -51,10 +51,11 @@ module Flaky
51
51
  def fetch_jobs(pipeline_id:)
52
52
  data = api_get("pipelines/#{pipeline_id}", detailed: true)
53
53
  blocks = data["blocks"] || []
54
+ test_blocks = config.test_blocks
54
55
 
55
56
  blocks.flat_map do |block|
56
57
  block_name = block["name"]
57
- next [] unless TEST_BLOCKS.include?(block_name)
58
+ next [] unless test_blocks.include?(block_name)
58
59
 
59
60
  (block["jobs"] || []).map do |job|
60
61
  {
@@ -94,22 +95,41 @@ module Flaky
94
95
  raise Error, "Semaphore API error (#{response.code}): #{response.body[0..200]}" unless response.is_a?(Net::HTTPSuccess)
95
96
 
96
97
  JSON.parse(response.body)
98
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
99
+ raise Error, "Semaphore API timeout: #{e.message}"
100
+ rescue SocketError => e
101
+ raise Error, "Cannot reach Semaphore API: #{e.message}"
102
+ rescue Errno::ECONNREFUSED => e
103
+ raise Error, "Connection refused to Semaphore API: #{e.message}"
104
+ rescue JSON::ParserError => e
105
+ raise Error, "Invalid JSON from Semaphore API: #{e.message}"
106
+ end
107
+
108
+ def sem_config
109
+ @sem_config ||= begin
110
+ path = File.expand_path("~/.sem.yaml")
111
+ unless File.exist?(path)
112
+ raise Error, "Semaphore config not found at #{path}. Run `sem connect` to authenticate."
113
+ end
114
+ YAML.safe_load_file(path)
115
+ end
97
116
  end
98
117
 
99
118
  def api_host
100
119
  @api_host ||= begin
101
- sem_config = YAML.load_file(File.expand_path("~/.sem.yaml"))
102
120
  context_name = sem_config["active-context"]
103
121
  host = sem_config.dig("contexts", context_name, "host")
122
+ raise Error, "No host found in ~/.sem.yaml for context '#{context_name}'" unless host
104
123
  "https://#{host}"
105
124
  end
106
125
  end
107
126
 
108
127
  def api_token
109
128
  @api_token ||= begin
110
- sem_config = YAML.load_file(File.expand_path("~/.sem.yaml"))
111
129
  context_name = sem_config["active-context"]
112
- sem_config.dig("contexts", context_name, "auth", "token")
130
+ token = sem_config.dig("contexts", context_name, "auth", "token")
131
+ raise Error, "No auth token found in ~/.sem.yaml for context '#{context_name}'" unless token
132
+ token
113
133
  end
114
134
  end
115
135
 
@@ -121,15 +141,6 @@ module Flaky
121
141
  project.dig("metadata", "id")
122
142
  end
123
143
  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
144
  end
134
145
 
135
146
  Configuration.register_provider(:semaphore, Semaphore)
@@ -0,0 +1,165 @@
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:, commit_sha: nil)
22
+ connection.execute(
23
+ "INSERT INTO ci_runs (workflow_id, pipeline_id, branch, result, created_at, commit_sha) VALUES (?, ?, ?, ?, ?, ?)",
24
+ [workflow_id, pipeline_id, branch, result, created_at, commit_sha]
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
+ GROUP_CONCAT(DISTINCT cr.commit_sha) as commit_shas
58
+ FROM test_failures tf
59
+ JOIN ci_runs cr ON cr.workflow_id = tf.workflow_id
60
+ WHERE cr.branch = ?
61
+ AND cr.created_at >= datetime('now', ?)
62
+ GROUP BY tf.spec_file, tf.line_number
63
+ HAVING COUNT(*) >= ?
64
+ ORDER BY failure_count DESC, last_failure DESC
65
+ SQL
66
+ end
67
+
68
+ def total_runs_count(branch:, since_days:)
69
+ 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"]
72
+ ).to_i
73
+ end
74
+
75
+ # --- History ---
76
+
77
+ def failure_history(file:, line: nil)
78
+ conditions = ["tf.spec_file LIKE ?"]
79
+ params = ["%#{file}%"]
80
+
81
+ if line
82
+ conditions << "tf.line_number = ?"
83
+ params << line
84
+ end
85
+
86
+ connection.execute(<<~SQL, params)
87
+ SELECT
88
+ tf.spec_file,
89
+ tf.line_number,
90
+ tf.description,
91
+ tf.seed,
92
+ tf.job_name,
93
+ tf.branch,
94
+ tf.failed_at,
95
+ cr.workflow_id,
96
+ cr.commit_sha
97
+ FROM test_failures tf
98
+ JOIN ci_runs cr ON cr.workflow_id = tf.workflow_id
99
+ WHERE #{conditions.join(" AND ")}
100
+ ORDER BY tf.failed_at DESC
101
+ SQL
102
+ end
103
+
104
+ # --- Report ---
105
+
106
+ def run_stats(branch:)
107
+ {
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,
112
+ last_fetch: connection.get_first_value("SELECT MAX(fetched_at) FROM ci_runs")
113
+ }
114
+ end
115
+
116
+ def failure_trend(branch:, period_days: 7)
117
+ recent = connection.get_first_value(
118
+ "SELECT COUNT(*) FROM test_failures WHERE branch = ? AND failed_at >= datetime('now', ?)",
119
+ [branch, "-#{period_days} days"]
120
+ ).to_i
121
+
122
+ prior = connection.get_first_value(
123
+ "SELECT COUNT(*) FROM test_failures WHERE branch = ? AND failed_at >= datetime('now', ?) AND failed_at < datetime('now', ?)",
124
+ [branch, "-#{period_days * 2} days", "-#{period_days} days"]
125
+ ).to_i
126
+
127
+ { recent: recent, prior: prior }
128
+ end
129
+
130
+ def top_flaky(branch:, limit: 5)
131
+ connection.execute(<<~SQL, [branch, limit])
132
+ SELECT
133
+ spec_file,
134
+ line_number,
135
+ description,
136
+ COUNT(*) as failure_count,
137
+ MAX(failed_at) as last_failure
138
+ FROM test_failures
139
+ WHERE branch = ?
140
+ GROUP BY spec_file, line_number
141
+ ORDER BY failure_count DESC
142
+ LIMIT ?
143
+ SQL
144
+ end
145
+
146
+ def recent_stress_runs(limit: 3)
147
+ connection.execute("SELECT * FROM stress_runs ORDER BY created_at DESC LIMIT ?", limit)
148
+ end
149
+
150
+ # --- Stress ---
151
+
152
+ def insert_stress_run(spec_location:, seed:, iterations:, passes:, failures:, ci_simulation:)
153
+ connection.execute(
154
+ "INSERT INTO stress_runs (spec_location, seed, iterations, passes, failures, ci_simulation) VALUES (?, ?, ?, ?, ?, ?)",
155
+ [spec_location, seed, iterations, passes, failures, ci_simulation]
156
+ )
157
+ end
158
+
159
+ private
160
+
161
+ def connection
162
+ @db.connection
163
+ end
164
+ end
165
+ 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.3.0"
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.3.0
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