fun_ci 1.0.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 +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE.txt +21 -0
- data/README.md +107 -0
- data/exe/fun-ci +7 -0
- data/exe/fun-ci-trigger +19 -0
- data/exe/fun-ci-tui +18 -0
- data/lib/fun_ci/admin_tui.rb +226 -0
- data/lib/fun_ci/ansi.rb +21 -0
- data/lib/fun_ci/background_wrapper.rb +27 -0
- data/lib/fun_ci/board_data.rb +51 -0
- data/lib/fun_ci/cli.rb +144 -0
- data/lib/fun_ci/database.rb +50 -0
- data/lib/fun_ci/duration_formatter.rb +23 -0
- data/lib/fun_ci/hook_writer.rb +73 -0
- data/lib/fun_ci/installer.rb +53 -0
- data/lib/fun_ci/maven_linter_detector.rb +24 -0
- data/lib/fun_ci/pipeline_forker.rb +36 -0
- data/lib/fun_ci/pipeline_recorder.rb +72 -0
- data/lib/fun_ci/pipeline_run.rb +57 -0
- data/lib/fun_ci/progress_reporter.rb +29 -0
- data/lib/fun_ci/project_config.rb +40 -0
- data/lib/fun_ci/project_detector.rb +18 -0
- data/lib/fun_ci/relative_time.rb +20 -0
- data/lib/fun_ci/row_formatter.rb +106 -0
- data/lib/fun_ci/screen.rb +81 -0
- data/lib/fun_ci/setup_checker.rb +28 -0
- data/lib/fun_ci/spinner.rb +22 -0
- data/lib/fun_ci/stage_job.rb +44 -0
- data/lib/fun_ci/stage_runner.rb +108 -0
- data/lib/fun_ci/stale_pipeline_canceller.rb +51 -0
- data/lib/fun_ci/state_machine.rb +59 -0
- data/lib/fun_ci/streak_counter.rb +30 -0
- data/lib/fun_ci/template_writer.rb +51 -0
- data/lib/fun_ci/trigger.rb +150 -0
- data/lib/fun_ci.rb +15 -0
- metadata +95 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "project_config"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
class SetupChecker
|
|
7
|
+
def self.run(project_root:, stdout: $stdout, stderr: $stderr)
|
|
8
|
+
new(project_root: project_root, stdout: stdout).run
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(project_root:, stdout:)
|
|
12
|
+
@config = ProjectConfig.new(project_root)
|
|
13
|
+
@stdout = stdout
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
errors = @config.validate
|
|
18
|
+
|
|
19
|
+
if errors.empty?
|
|
20
|
+
@stdout.puts "All OK — project is configured."
|
|
21
|
+
0
|
|
22
|
+
else
|
|
23
|
+
errors.each { |e| @stdout.puts e }
|
|
24
|
+
1
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
class Spinner
|
|
5
|
+
BRAILLE_FRAMES = %W[\u2800 \u2801 \u2803 \u2807 \u280F \u281F \u283F \u287F].freeze
|
|
6
|
+
|
|
7
|
+
attr_reader :frames
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@frames = BRAILLE_FRAMES
|
|
11
|
+
@index = 0
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def current_frame
|
|
15
|
+
@frames[@index]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def advance!
|
|
19
|
+
@index = (@index + 1) % @frames.length
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module StageJob
|
|
7
|
+
TERMINAL_STATUSES = %w[completed failed timed_out cancelled].freeze
|
|
8
|
+
|
|
9
|
+
def self.create(db, pipeline_run_id:, stage:)
|
|
10
|
+
db.execute(
|
|
11
|
+
"INSERT INTO stage_jobs (pipeline_run_id, stage, status) VALUES (?, ?, 'scheduled')",
|
|
12
|
+
[pipeline_run_id, stage]
|
|
13
|
+
)
|
|
14
|
+
db.last_insert_row_id
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.find(db, id)
|
|
18
|
+
row = db.execute("SELECT id, pipeline_run_id, stage, status, started_at, completed_at FROM stage_jobs WHERE id = ?", [id]).first
|
|
19
|
+
return nil unless row
|
|
20
|
+
row_to_hash(row)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.update_status(db, id, new_status)
|
|
24
|
+
now = Time.now.utc.iso8601
|
|
25
|
+
if new_status == "running"
|
|
26
|
+
db.execute("UPDATE stage_jobs SET status = ?, started_at = ? WHERE id = ?", [new_status, now, id])
|
|
27
|
+
elsif TERMINAL_STATUSES.include?(new_status)
|
|
28
|
+
db.execute("UPDATE stage_jobs SET status = ?, completed_at = ? WHERE id = ?", [new_status, now, id])
|
|
29
|
+
else
|
|
30
|
+
db.execute("UPDATE stage_jobs SET status = ? WHERE id = ?", [new_status, id])
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.elapsed_duration(job)
|
|
35
|
+
return nil unless job[:started_at] && job[:completed_at]
|
|
36
|
+
Time.parse(job[:completed_at]) - Time.parse(job[:started_at])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.row_to_hash(row)
|
|
40
|
+
{ id: row[0], pipeline_run_id: row[1], stage: row[2], status: row[3], started_at: row[4], completed_at: row[5] }
|
|
41
|
+
end
|
|
42
|
+
private_class_method :row_to_hash
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
class StageRunner
|
|
8
|
+
def initialize(commit_hash:, stdout:, command_runner: nil, time_budgets: {}, recorder: NullRecorder.new)
|
|
9
|
+
@commit_hash = commit_hash
|
|
10
|
+
@stdout = stdout
|
|
11
|
+
@command_runner = command_runner
|
|
12
|
+
@time_budgets = time_budgets
|
|
13
|
+
@recorder = recorder
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run_stage(config, stage)
|
|
17
|
+
script = config.script_path(stage)
|
|
18
|
+
budget = @time_budgets[stage]
|
|
19
|
+
|
|
20
|
+
job_id = @recorder.start_stage(stage)
|
|
21
|
+
output, status, timed_out = run_with_timeout(script, budget)
|
|
22
|
+
|
|
23
|
+
if timed_out
|
|
24
|
+
@recorder.end_stage(job_id, "timed_out")
|
|
25
|
+
@stdout.puts "#{stage_label(stage)} killed -- exceeded #{budget}s time budget."
|
|
26
|
+
@stdout.puts budget_advice(stage)
|
|
27
|
+
return false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
unless status.success?
|
|
31
|
+
@recorder.end_stage(job_id, "failed")
|
|
32
|
+
@stdout.puts output unless output.empty?
|
|
33
|
+
@stdout.puts "#{stage_label(stage)} failed."
|
|
34
|
+
return false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@recorder.end_stage(job_id, "completed")
|
|
38
|
+
true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def run_with_timeout(script, budget)
|
|
44
|
+
cmd = "#{script} #{@commit_hash}"
|
|
45
|
+
|
|
46
|
+
if @command_runner
|
|
47
|
+
begin
|
|
48
|
+
Timeout.timeout(budget) do
|
|
49
|
+
output, status = @command_runner.call(cmd)
|
|
50
|
+
[output, status, false]
|
|
51
|
+
end
|
|
52
|
+
rescue Timeout::Error
|
|
53
|
+
["", nil, true]
|
|
54
|
+
end
|
|
55
|
+
else
|
|
56
|
+
run_process_with_timeout(cmd, budget)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def run_process_with_timeout(cmd, budget)
|
|
61
|
+
pid = nil
|
|
62
|
+
output = ""
|
|
63
|
+
r, w = IO.pipe
|
|
64
|
+
pid = Process.spawn(cmd, out: w, err: w)
|
|
65
|
+
w.close
|
|
66
|
+
|
|
67
|
+
begin
|
|
68
|
+
Timeout.timeout(budget) do
|
|
69
|
+
output = r.read
|
|
70
|
+
_, status = Process.waitpid2(pid)
|
|
71
|
+
pid = nil
|
|
72
|
+
[output, status, false]
|
|
73
|
+
end
|
|
74
|
+
rescue Timeout::Error
|
|
75
|
+
Process.kill("TERM", pid) rescue nil
|
|
76
|
+
Process.kill("KILL", pid) rescue nil
|
|
77
|
+
Process.waitpid(pid) rescue nil
|
|
78
|
+
r.close rescue nil
|
|
79
|
+
["", nil, true]
|
|
80
|
+
ensure
|
|
81
|
+
r.close rescue nil
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def stage_label(stage)
|
|
86
|
+
case stage
|
|
87
|
+
when "lint" then "Lint"
|
|
88
|
+
when "build" then "Build"
|
|
89
|
+
when "fast" then "Fast suite"
|
|
90
|
+
when "slow" then "Slow suite"
|
|
91
|
+
else stage
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def budget_advice(stage)
|
|
96
|
+
case stage
|
|
97
|
+
when "lint"
|
|
98
|
+
"Trim your linter config or split into stages."
|
|
99
|
+
when "build"
|
|
100
|
+
"Keep your build efficient and not let it become a bottleneck."
|
|
101
|
+
when "fast"
|
|
102
|
+
"Your fast tests have gotten too slow. Split or speed them up."
|
|
103
|
+
when "slow"
|
|
104
|
+
"Pare down integration tests, parallelise, or raise the budget."
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "pipeline_run"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
class StalePipelineCanceller
|
|
7
|
+
def initialize(db:, branch:, stdout:, process_killer: nil)
|
|
8
|
+
@db = db
|
|
9
|
+
@branch = branch
|
|
10
|
+
@stdout = stdout
|
|
11
|
+
@process_killer = process_killer || method(:default_process_killer)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def cancel(new_commit_hash:)
|
|
15
|
+
run = PipelineRun.find_running_with_pid(@db, @branch)
|
|
16
|
+
return unless run
|
|
17
|
+
|
|
18
|
+
kill_process(run[:pid])
|
|
19
|
+
PipelineRun.update_status(@db, run[:id], "cancelled")
|
|
20
|
+
@stdout.puts "Cancelled stale pipeline for #{run[:commit_hash]}. Starting fresh for #{new_commit_hash}."
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def store_pid(run_id, pid)
|
|
24
|
+
PipelineRun.store_pid(@db, run_id, pid)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def kill_process(pid)
|
|
30
|
+
@process_killer.call(0, pid)
|
|
31
|
+
safe_signal("TERM", pid)
|
|
32
|
+
safe_signal("KILL", pid)
|
|
33
|
+
begin
|
|
34
|
+
Process.waitpid(pid)
|
|
35
|
+
rescue Errno::ECHILD, Errno::ESRCH # rubocop:disable Lint/SuppressedException
|
|
36
|
+
end
|
|
37
|
+
rescue Errno::ESRCH
|
|
38
|
+
# Process already dead
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def safe_signal(signal, pid)
|
|
42
|
+
@process_killer.call(signal, pid)
|
|
43
|
+
rescue Errno::ESRCH
|
|
44
|
+
# Already dead
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def default_process_killer(signal, pid)
|
|
48
|
+
Process.kill(signal, pid)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
# Enforced state machine for pipeline runs and stage jobs.
|
|
5
|
+
#
|
|
6
|
+
# States: scheduled, running, completed, failed, timed_out, cancelled
|
|
7
|
+
#
|
|
8
|
+
# Valid transitions:
|
|
9
|
+
# scheduled -> running
|
|
10
|
+
# scheduled -> cancelled
|
|
11
|
+
# running -> completed
|
|
12
|
+
# running -> failed
|
|
13
|
+
# running -> timed_out
|
|
14
|
+
# running -> cancelled
|
|
15
|
+
class StateMachine
|
|
16
|
+
class InvalidTransition < StandardError; end
|
|
17
|
+
|
|
18
|
+
STATES = %i[scheduled running completed failed timed_out cancelled].freeze
|
|
19
|
+
|
|
20
|
+
TRANSITIONS = {
|
|
21
|
+
scheduled: %i[running cancelled],
|
|
22
|
+
running: %i[completed failed timed_out cancelled],
|
|
23
|
+
completed: [],
|
|
24
|
+
failed: [],
|
|
25
|
+
timed_out: [],
|
|
26
|
+
cancelled: []
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
TERMINAL_STATES = %i[completed failed timed_out cancelled].freeze
|
|
30
|
+
|
|
31
|
+
attr_reader :current_state
|
|
32
|
+
|
|
33
|
+
def initialize(initial_state)
|
|
34
|
+
validate_state!(initial_state)
|
|
35
|
+
@current_state = initial_state
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def transition_to!(new_state)
|
|
39
|
+
validate_state!(new_state)
|
|
40
|
+
unless TRANSITIONS.fetch(@current_state).include?(new_state)
|
|
41
|
+
raise InvalidTransition,
|
|
42
|
+
"Cannot transition from #{@current_state} to #{new_state}"
|
|
43
|
+
end
|
|
44
|
+
@current_state = new_state
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def terminal?
|
|
48
|
+
TERMINAL_STATES.include?(@current_state)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def validate_state!(state)
|
|
54
|
+
return if STATES.include?(state)
|
|
55
|
+
|
|
56
|
+
raise ArgumentError, "Unknown state: #{state}. Valid states: #{STATES.join(", ")}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module StreakCounter
|
|
5
|
+
PASS_STATUS = "completed"
|
|
6
|
+
ACTIVE_STATUSES = %w[running scheduled].freeze
|
|
7
|
+
|
|
8
|
+
# Counts consecutive passed runs from the most recent terminal run.
|
|
9
|
+
# Skips running/scheduled pipelines.
|
|
10
|
+
# Returns nil if no terminal runs exist, 0 if streak is broken.
|
|
11
|
+
def self.count(runs)
|
|
12
|
+
terminal_runs = runs.reject { |r| ACTIVE_STATUSES.include?(r[:status]) }
|
|
13
|
+
return nil if terminal_runs.empty?
|
|
14
|
+
|
|
15
|
+
streak = 0
|
|
16
|
+
terminal_runs.each do |run|
|
|
17
|
+
break unless run[:status] == PASS_STATUS
|
|
18
|
+
streak += 1
|
|
19
|
+
end
|
|
20
|
+
streak
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.format_text(streak)
|
|
24
|
+
return nil if streak.nil?
|
|
25
|
+
return "Streak broken" if streak == 0
|
|
26
|
+
|
|
27
|
+
"#{streak} in a row!"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
class TemplateWriter
|
|
5
|
+
TEMPLATES = {
|
|
6
|
+
ruby_bundler: {
|
|
7
|
+
"lint.sh" => "#!/bin/sh\nbundle exec rubocop\n",
|
|
8
|
+
"build.sh" => "#!/bin/sh\nbundle install --quiet\n",
|
|
9
|
+
"fast.sh" => "#!/bin/sh\nbundle exec rake test\n",
|
|
10
|
+
"slow.sh" => "#!/bin/sh\nbundle exec rake test:slow\n"
|
|
11
|
+
},
|
|
12
|
+
jvm_gradle_kotlin: {
|
|
13
|
+
"lint.sh" => "#!/bin/sh\n./gradlew check -x test\n",
|
|
14
|
+
"build.sh" => "#!/bin/sh\n./gradlew assemble\n",
|
|
15
|
+
"fast.sh" => "#!/bin/sh\n./gradlew test\n",
|
|
16
|
+
"slow.sh" => "#!/bin/sh\n./gradlew integrationTest\n"
|
|
17
|
+
},
|
|
18
|
+
jvm_gradle_groovy: {
|
|
19
|
+
"lint.sh" => "#!/bin/sh\n./gradlew check -x test\n",
|
|
20
|
+
"build.sh" => "#!/bin/sh\n./gradlew assemble\n",
|
|
21
|
+
"fast.sh" => "#!/bin/sh\n./gradlew test\n",
|
|
22
|
+
"slow.sh" => "#!/bin/sh\n./gradlew integrationTest\n"
|
|
23
|
+
},
|
|
24
|
+
jvm_maven: {
|
|
25
|
+
"lint.sh" => "#!/bin/sh\nmvn verify -DskipTests\n",
|
|
26
|
+
"build.sh" => "#!/bin/sh\nmvn compile\n",
|
|
27
|
+
"fast.sh" => "#!/bin/sh\nmvn test\n",
|
|
28
|
+
"slow.sh" => "#!/bin/sh\nmvn verify\n"
|
|
29
|
+
}
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
def initialize(template_id, target_dir, lint_override: nil)
|
|
33
|
+
@template_id = template_id
|
|
34
|
+
@target_dir = target_dir
|
|
35
|
+
@lint_override = lint_override
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def write
|
|
39
|
+
scripts = TEMPLATES.fetch(@template_id)
|
|
40
|
+
fun_ci_dir = File.join(@target_dir, ".fun-ci")
|
|
41
|
+
Dir.mkdir(fun_ci_dir)
|
|
42
|
+
|
|
43
|
+
scripts.each do |name, content|
|
|
44
|
+
content = "#!/bin/sh\n#{@lint_override}\n" if name == "lint.sh" && @lint_override
|
|
45
|
+
path = File.join(fun_ci_dir, name)
|
|
46
|
+
File.write(path, content)
|
|
47
|
+
File.chmod(0o755, path)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "timeout"
|
|
5
|
+
require_relative "project_config"
|
|
6
|
+
require_relative "pipeline_recorder"
|
|
7
|
+
require_relative "background_wrapper"
|
|
8
|
+
require_relative "stage_runner"
|
|
9
|
+
require_relative "stale_pipeline_canceller"
|
|
10
|
+
require_relative "progress_reporter"
|
|
11
|
+
require_relative "pipeline_forker"
|
|
12
|
+
|
|
13
|
+
module FunCi
|
|
14
|
+
class Trigger
|
|
15
|
+
DEFAULT_BUDGETS = {
|
|
16
|
+
"lint" => 30,
|
|
17
|
+
"build" => 30,
|
|
18
|
+
"fast" => 10,
|
|
19
|
+
"slow" => 300
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
def self.run_from_args(args, stdout: $stdout, stderr: $stderr, recorder: NullRecorder.new, pipeline_forker: nil)
|
|
23
|
+
positional = args.reject { |a| a.start_with?("--") }
|
|
24
|
+
if positional.length < 2
|
|
25
|
+
stderr.puts "fun-ci: commit hash and branch name are required."
|
|
26
|
+
stderr.puts "Usage: fun-ci trigger <commit-hash> <branch>"
|
|
27
|
+
return 1
|
|
28
|
+
end
|
|
29
|
+
commit_hash, branch = positional
|
|
30
|
+
if args.include?("--no-validate")
|
|
31
|
+
db_path = recorder.db_path
|
|
32
|
+
recorder.close
|
|
33
|
+
(pipeline_forker || PipelineForker.method(:fork_pipeline)).call(
|
|
34
|
+
commit_hash: commit_hash, branch: branch, db_path: db_path
|
|
35
|
+
)
|
|
36
|
+
return 0
|
|
37
|
+
end
|
|
38
|
+
new(project_root: Dir.pwd, commit_hash: commit_hash, branch: branch,
|
|
39
|
+
stdout: stdout, stderr: stderr, recorder: recorder).run
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
attr_writer :command_runner
|
|
43
|
+
|
|
44
|
+
def initialize(project_root:, commit_hash:, branch:, stdout: $stdout, stderr: $stderr, command_runner: nil, time_budgets: {}, commit_validator: nil, recorder: NullRecorder.new, background_launcher: nil)
|
|
45
|
+
@project_root = project_root
|
|
46
|
+
@commit_hash = commit_hash
|
|
47
|
+
@branch = branch
|
|
48
|
+
@stdout = stdout
|
|
49
|
+
@stderr = stderr
|
|
50
|
+
@command_runner = command_runner
|
|
51
|
+
@time_budgets = DEFAULT_BUDGETS.merge(time_budgets)
|
|
52
|
+
@commit_validator = commit_validator || method(:default_commit_validator)
|
|
53
|
+
@recorder = recorder
|
|
54
|
+
@background_launcher = background_launcher || method(:default_background_launcher)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def run
|
|
58
|
+
config = ProjectConfig.new(@project_root)
|
|
59
|
+
return handle_config_errors(config) if config.validate.any?
|
|
60
|
+
unless @commit_validator.call(@commit_hash)
|
|
61
|
+
@stderr.puts "fun-ci: commit #{@commit_hash} not found in this repository."
|
|
62
|
+
return 1
|
|
63
|
+
end
|
|
64
|
+
cancel_stale_pipelines
|
|
65
|
+
@recorder.create_run(commit_hash: @commit_hash, branch: @branch, project_path: @project_root)
|
|
66
|
+
stage_runner = make_stage_runner
|
|
67
|
+
progress = ProgressReporter.new(stdout: @stdout)
|
|
68
|
+
results = run_phase_one(stage_runner, config)
|
|
69
|
+
progress.phase_one_result(results)
|
|
70
|
+
unless results.values.all?
|
|
71
|
+
@recorder.fail_run
|
|
72
|
+
return 1
|
|
73
|
+
end
|
|
74
|
+
spawn_slow_suite(config)
|
|
75
|
+
progress.slow_launched
|
|
76
|
+
fast_runner = make_stage_runner
|
|
77
|
+
fast_passed = fast_runner.run_stage(config, "fast")
|
|
78
|
+
progress.fast_result(fast_passed)
|
|
79
|
+
unless fast_passed
|
|
80
|
+
@recorder.fail_run
|
|
81
|
+
return 1
|
|
82
|
+
end
|
|
83
|
+
0
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def handle_config_errors(config)
|
|
89
|
+
config.validate.each { |e| @stdout.puts "fun-ci: #{e}" }
|
|
90
|
+
unless config.folder_exists?
|
|
91
|
+
@stdout.puts "Create .fun-ci/lint.sh, build.sh, fast.sh, and slow.sh to set up this project."
|
|
92
|
+
@stdout.puts "Commit will proceed without CI."
|
|
93
|
+
end
|
|
94
|
+
0
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def run_phase_one(stage_runner, config)
|
|
98
|
+
results = {}
|
|
99
|
+
threads = %w[lint build].map do |stage|
|
|
100
|
+
Thread.new { results[stage] = stage_runner.run_stage(config, stage) }
|
|
101
|
+
end
|
|
102
|
+
threads.each(&:join)
|
|
103
|
+
results
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def spawn_slow_suite(config)
|
|
107
|
+
cmd = "#{config.script_path("slow")} #{@commit_hash}"
|
|
108
|
+
job_id = @recorder.start_stage("slow")
|
|
109
|
+
runner = @command_runner || ->(c) { Open3.capture2e(c) }
|
|
110
|
+
budget = @time_budgets["slow"]
|
|
111
|
+
executor = -> { Timeout.timeout(budget) { runner.call(cmd) } }
|
|
112
|
+
@background_launcher.call(
|
|
113
|
+
db_path: @recorder.db_path, pipeline_run_id: @recorder.pipeline_run_id,
|
|
114
|
+
job_id: job_id, executor: executor
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def make_stage_runner
|
|
119
|
+
StageRunner.new(
|
|
120
|
+
commit_hash: @commit_hash, stdout: @stdout,
|
|
121
|
+
command_runner: @command_runner,
|
|
122
|
+
time_budgets: @time_budgets, recorder: @recorder
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def cancel_stale_pipelines
|
|
127
|
+
return unless @recorder.db
|
|
128
|
+
StalePipelineCanceller.new(
|
|
129
|
+
db: @recorder.db, branch: @branch, stdout: @stdout
|
|
130
|
+
).cancel(new_commit_hash: @commit_hash)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def default_background_launcher(db_path:, pipeline_run_id:, job_id:, executor:)
|
|
134
|
+
@recorder.close
|
|
135
|
+
pid = fork do
|
|
136
|
+
recorder = DbRecorder.for_background(db_path, pipeline_run_id)
|
|
137
|
+
BackgroundWrapper.new(recorder: recorder, job_id: job_id, executor: executor).run
|
|
138
|
+
recorder.close
|
|
139
|
+
end
|
|
140
|
+
@recorder = DbRecorder.for_background(db_path, pipeline_run_id)
|
|
141
|
+
Process.detach(pid)
|
|
142
|
+
PipelineRun.store_pid(@recorder.db, pipeline_run_id, pid)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def default_commit_validator(commit_hash)
|
|
146
|
+
_, status = Open3.capture2e("git", "cat-file", "-t", commit_hash)
|
|
147
|
+
status.success?
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
data/lib/fun_ci.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "fun_ci/database"
|
|
4
|
+
require_relative "fun_ci/state_machine"
|
|
5
|
+
require_relative "fun_ci/pipeline_run"
|
|
6
|
+
require_relative "fun_ci/stage_job"
|
|
7
|
+
require_relative "fun_ci/project_config"
|
|
8
|
+
|
|
9
|
+
module FunCi
|
|
10
|
+
VERSION = "1.0.0"
|
|
11
|
+
|
|
12
|
+
BUILD_TIMEOUT = 30
|
|
13
|
+
FAST_SUITE_TIMEOUT = 10
|
|
14
|
+
SLOW_SUITE_TIMEOUT = 300
|
|
15
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: fun_ci
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Erik Thyge Madsen
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: sqlite3
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '2.0'
|
|
26
|
+
description: Opinionated local CI that checks your code before it leaves your machine.
|
|
27
|
+
Runs a four-stage pipeline (lint, build, fast tests, slow tests) on every commit
|
|
28
|
+
with strict time budgets. Hooks into git pre-commit and pre-push, stores results
|
|
29
|
+
in SQLite, and includes a TUI dashboard for monitoring.
|
|
30
|
+
executables:
|
|
31
|
+
- fun-ci
|
|
32
|
+
- fun-ci-trigger
|
|
33
|
+
- fun-ci-tui
|
|
34
|
+
extensions: []
|
|
35
|
+
extra_rdoc_files: []
|
|
36
|
+
files:
|
|
37
|
+
- CHANGELOG.md
|
|
38
|
+
- LICENSE.txt
|
|
39
|
+
- README.md
|
|
40
|
+
- exe/fun-ci
|
|
41
|
+
- exe/fun-ci-trigger
|
|
42
|
+
- exe/fun-ci-tui
|
|
43
|
+
- lib/fun_ci.rb
|
|
44
|
+
- lib/fun_ci/admin_tui.rb
|
|
45
|
+
- lib/fun_ci/ansi.rb
|
|
46
|
+
- lib/fun_ci/background_wrapper.rb
|
|
47
|
+
- lib/fun_ci/board_data.rb
|
|
48
|
+
- lib/fun_ci/cli.rb
|
|
49
|
+
- lib/fun_ci/database.rb
|
|
50
|
+
- lib/fun_ci/duration_formatter.rb
|
|
51
|
+
- lib/fun_ci/hook_writer.rb
|
|
52
|
+
- lib/fun_ci/installer.rb
|
|
53
|
+
- lib/fun_ci/maven_linter_detector.rb
|
|
54
|
+
- lib/fun_ci/pipeline_forker.rb
|
|
55
|
+
- lib/fun_ci/pipeline_recorder.rb
|
|
56
|
+
- lib/fun_ci/pipeline_run.rb
|
|
57
|
+
- lib/fun_ci/progress_reporter.rb
|
|
58
|
+
- lib/fun_ci/project_config.rb
|
|
59
|
+
- lib/fun_ci/project_detector.rb
|
|
60
|
+
- lib/fun_ci/relative_time.rb
|
|
61
|
+
- lib/fun_ci/row_formatter.rb
|
|
62
|
+
- lib/fun_ci/screen.rb
|
|
63
|
+
- lib/fun_ci/setup_checker.rb
|
|
64
|
+
- lib/fun_ci/spinner.rb
|
|
65
|
+
- lib/fun_ci/stage_job.rb
|
|
66
|
+
- lib/fun_ci/stage_runner.rb
|
|
67
|
+
- lib/fun_ci/stale_pipeline_canceller.rb
|
|
68
|
+
- lib/fun_ci/state_machine.rb
|
|
69
|
+
- lib/fun_ci/streak_counter.rb
|
|
70
|
+
- lib/fun_ci/template_writer.rb
|
|
71
|
+
- lib/fun_ci/trigger.rb
|
|
72
|
+
homepage: https://github.com/beatmadsen/fun-ci
|
|
73
|
+
licenses:
|
|
74
|
+
- MIT
|
|
75
|
+
metadata:
|
|
76
|
+
source_code_uri: https://github.com/beatmadsen/fun-ci
|
|
77
|
+
changelog_uri: https://github.com/beatmadsen/fun-ci/blob/main/CHANGELOG.md
|
|
78
|
+
rdoc_options: []
|
|
79
|
+
require_paths:
|
|
80
|
+
- lib
|
|
81
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
82
|
+
requirements:
|
|
83
|
+
- - ">="
|
|
84
|
+
- !ruby/object:Gem::Version
|
|
85
|
+
version: '3.0'
|
|
86
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
87
|
+
requirements:
|
|
88
|
+
- - ">="
|
|
89
|
+
- !ruby/object:Gem::Version
|
|
90
|
+
version: '0'
|
|
91
|
+
requirements: []
|
|
92
|
+
rubygems_version: 4.0.3
|
|
93
|
+
specification_version: 4
|
|
94
|
+
summary: Opinionated local CI that checks your code before it leaves your machine
|
|
95
|
+
test_files: []
|