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 +4 -4
- data/lib/flaky/age_parser.rb +16 -0
- data/lib/flaky/commands/fetch.rb +78 -45
- data/lib/flaky/commands/history.rb +5 -30
- data/lib/flaky/commands/rank.rb +5 -24
- data/lib/flaky/commands/report.rb +16 -43
- data/lib/flaky/commands/stress.rb +6 -8
- data/lib/flaky/configuration.rb +12 -1
- data/lib/flaky/providers/base.rb +1 -1
- data/lib/flaky/providers/github_actions.rb +2 -10
- data/lib/flaky/providers/semaphore.rb +26 -16
- data/lib/flaky/repository.rb +163 -0
- data/lib/flaky/tasks/flaky.rake +71 -18
- data/lib/flaky/version.rb +1 -1
- data/lib/flaky-friend.rb +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8a9f46407de5b2f8ef6fa5178ec220bc84fef9376e5bc68e7d3703066dd663b7
|
|
4
|
+
data.tar.gz: ee59c6d55778658f109cdefe410d770af048579f1381fab466aae6c19d6b4392
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/flaky/commands/fetch.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "../
|
|
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
|
-
@
|
|
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
|
-
|
|
22
|
+
puts "#{workflows.length} found."
|
|
22
23
|
|
|
23
|
-
if
|
|
24
|
-
puts "
|
|
24
|
+
if workflows.empty?
|
|
25
|
+
puts "Nothing to do."
|
|
25
26
|
return
|
|
26
27
|
end
|
|
27
28
|
|
|
28
|
-
new_workflows =
|
|
29
|
-
new_failures = 0
|
|
30
|
-
total_jobs = 0
|
|
29
|
+
new_workflows = workflows.reject { |wf| @repo.workflow_fetched?(wf[:id]) }
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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 "
|
|
47
|
+
puts "\nDone: #{new_workflows.length} workflow(s), #{total_jobs} job(s), #{total_failures} failure(s) recorded."
|
|
70
48
|
ensure
|
|
71
|
-
@
|
|
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 "../
|
|
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
|
-
@
|
|
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:
|
|
36
|
+
puts "\nTo reproduce: SPEC=#{first['spec_file']}:#{first['line_number']} SEED=#{seeds.first} CI=true bin/rails flaky:stress"
|
|
62
37
|
ensure
|
|
63
|
-
@
|
|
38
|
+
@repo.close
|
|
64
39
|
end
|
|
65
40
|
|
|
66
41
|
private
|
data/lib/flaky/commands/rank.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "../
|
|
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
|
-
@
|
|
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 =
|
|
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 =
|
|
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
|
-
@
|
|
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 "../
|
|
3
|
+
require_relative "../repository"
|
|
4
4
|
|
|
5
5
|
module Flaky
|
|
6
6
|
module Commands
|
|
7
7
|
class Report
|
|
8
8
|
def initialize
|
|
9
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
puts "
|
|
27
|
-
puts "
|
|
28
|
-
puts "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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 "../
|
|
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
|
-
@
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
@
|
|
77
|
+
@repo.close
|
|
80
78
|
end
|
|
81
79
|
end
|
|
82
80
|
end
|
data/lib/flaky/configuration.rb
CHANGED
|
@@ -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
|
data/lib/flaky/providers/base.rb
CHANGED
|
@@ -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
|
-
|
|
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 -
|
|
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
|
|
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
|
data/lib/flaky/tasks/flaky.rake
CHANGED
|
@@ -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 "
|
|
5
|
-
task :
|
|
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
|
-
|
|
8
|
-
Flaky::Commands::Fetch.new(age:
|
|
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 (
|
|
12
|
-
task
|
|
63
|
+
desc "Rank flaky tests by failure frequency (SINCE=30)"
|
|
64
|
+
task rank: :environment do
|
|
13
65
|
require "flaky/commands/rank"
|
|
14
|
-
since = (
|
|
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 (
|
|
19
|
-
task
|
|
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
|
-
|
|
22
|
-
Flaky::Commands::History.new(spec_location:
|
|
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 (
|
|
26
|
-
task
|
|
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
|
-
|
|
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:
|
|
31
|
-
iterations: (
|
|
32
|
-
seed:
|
|
33
|
-
ci_simulate:
|
|
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
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
|
|
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
|