fun_ci 1.0.0 → 1.2.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 +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +3 -1
- data/exe/fun-ci-trigger +6 -6
- data/exe/fun-ci-tui +9 -4
- data/lib/fun_ci/animations/celebrate.rb +210 -0
- data/lib/fun_ci/animations/explosion.rb +138 -0
- data/lib/fun_ci/animations/flash.rb +205 -0
- data/lib/fun_ci/animations/idle.rb +127 -0
- data/lib/fun_ci/animations/leprechauns.rb +240 -0
- data/lib/fun_ci/animations/running.rb +129 -0
- data/lib/fun_ci/animations/success.rb +281 -0
- data/lib/fun_ci/animations/yay.rb +169 -0
- data/lib/fun_ci/cli.rb +20 -14
- data/lib/fun_ci/persistence/database.rb +52 -0
- data/lib/fun_ci/persistence/pipeline_recorder.rb +74 -0
- data/lib/fun_ci/persistence/pipeline_run.rb +59 -0
- data/lib/fun_ci/persistence/stage_job.rb +46 -0
- data/lib/fun_ci/{state_machine.rb → persistence/state_machine.rb} +3 -12
- data/lib/fun_ci/pipeline/background_wrapper.rb +27 -0
- data/lib/fun_ci/pipeline/pipeline_forker.rb +38 -0
- data/lib/fun_ci/pipeline/process_runner.rb +28 -0
- data/lib/fun_ci/pipeline/progress_reporter.rb +31 -0
- data/lib/fun_ci/pipeline/stage_runner.rb +85 -0
- data/lib/fun_ci/pipeline/stale_pipeline_canceller.rb +53 -0
- data/lib/fun_ci/pipeline/trigger.rb +153 -0
- data/lib/fun_ci/setup/hook_writer.rb +75 -0
- data/lib/fun_ci/setup/installer.rb +55 -0
- data/lib/fun_ci/setup/maven_linter_detector.rb +26 -0
- data/lib/fun_ci/setup/project_config.rb +42 -0
- data/lib/fun_ci/setup/project_detector.rb +22 -0
- data/lib/fun_ci/setup/setup_checker.rb +30 -0
- data/lib/fun_ci/setup/template_writer.rb +53 -0
- data/lib/fun_ci/tui/admin_tui.rb +90 -0
- data/lib/fun_ci/tui/animation.rb +49 -0
- data/lib/fun_ci/tui/animation_compositor.rb +107 -0
- data/lib/fun_ci/tui/animation_frames.rb +112 -0
- data/lib/fun_ci/tui/animation_library.rb +46 -0
- data/lib/fun_ci/tui/animation_renderer.rb +144 -0
- data/lib/fun_ci/tui/ansi.rb +34 -0
- data/lib/fun_ci/tui/board_data.rb +53 -0
- data/lib/fun_ci/tui/board_renderer.rb +105 -0
- data/lib/fun_ci/tui/duration_formatter.rb +24 -0
- data/lib/fun_ci/tui/header_animation_manager.rb +71 -0
- data/lib/fun_ci/tui/header_animation_player.rb +45 -0
- data/lib/fun_ci/tui/key_handler.rb +86 -0
- data/lib/fun_ci/tui/looping_animation_player.rb +45 -0
- data/lib/fun_ci/tui/relative_time.rb +22 -0
- data/lib/fun_ci/tui/row_formatter.rb +108 -0
- data/lib/fun_ci/tui/screen.rb +103 -0
- data/lib/fun_ci/tui/spinner.rb +24 -0
- data/lib/fun_ci/tui/stage_change_detector.rb +58 -0
- data/lib/fun_ci/tui/streak_counter.rb +29 -0
- data/lib/fun_ci/tui/terminal_input.rb +69 -0
- data/lib/fun_ci.rb +6 -6
- metadata +49 -28
- data/lib/fun_ci/admin_tui.rb +0 -226
- data/lib/fun_ci/ansi.rb +0 -21
- data/lib/fun_ci/background_wrapper.rb +0 -27
- data/lib/fun_ci/board_data.rb +0 -51
- data/lib/fun_ci/database.rb +0 -50
- data/lib/fun_ci/duration_formatter.rb +0 -23
- data/lib/fun_ci/hook_writer.rb +0 -73
- data/lib/fun_ci/installer.rb +0 -53
- data/lib/fun_ci/maven_linter_detector.rb +0 -24
- data/lib/fun_ci/pipeline_forker.rb +0 -36
- data/lib/fun_ci/pipeline_recorder.rb +0 -72
- data/lib/fun_ci/pipeline_run.rb +0 -57
- data/lib/fun_ci/progress_reporter.rb +0 -29
- data/lib/fun_ci/project_config.rb +0 -40
- data/lib/fun_ci/project_detector.rb +0 -18
- data/lib/fun_ci/relative_time.rb +0 -20
- data/lib/fun_ci/row_formatter.rb +0 -106
- data/lib/fun_ci/screen.rb +0 -81
- data/lib/fun_ci/setup_checker.rb +0 -28
- data/lib/fun_ci/spinner.rb +0 -22
- data/lib/fun_ci/stage_job.rb +0 -44
- data/lib/fun_ci/stage_runner.rb +0 -108
- data/lib/fun_ci/stale_pipeline_canceller.rb +0 -51
- data/lib/fun_ci/streak_counter.rb +0 -30
- data/lib/fun_ci/template_writer.rb +0 -51
- data/lib/fun_ci/trigger.rb +0 -150
data/lib/fun_ci/pipeline_run.rb
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "time"
|
|
4
|
-
|
|
5
|
-
module FunCi
|
|
6
|
-
module PipelineRun
|
|
7
|
-
def self.create(db, commit_hash:, branch:, project_path: nil)
|
|
8
|
-
now = Time.now.utc.iso8601
|
|
9
|
-
db.execute(
|
|
10
|
-
"INSERT INTO pipeline_runs (commit_hash, branch, project_path, status, created_at, updated_at) VALUES (?, ?, ?, 'scheduled', ?, ?)",
|
|
11
|
-
[commit_hash, branch, project_path, now, now]
|
|
12
|
-
)
|
|
13
|
-
db.last_insert_row_id
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def self.find(db, id)
|
|
17
|
-
row = db.execute("SELECT id, commit_hash, branch, status, pid, project_path, created_at, updated_at FROM pipeline_runs WHERE id = ?", [id]).first
|
|
18
|
-
return nil unless row
|
|
19
|
-
row_to_hash(row)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def self.find_by_branch(db, branch)
|
|
23
|
-
rows = db.execute("SELECT id, commit_hash, branch, status, pid, project_path, created_at, updated_at FROM pipeline_runs WHERE branch = ? ORDER BY id DESC", [branch])
|
|
24
|
-
rows.map { |row| row_to_hash(row) }
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def self.find_by_commit(db, commit_hash)
|
|
28
|
-
rows = db.execute("SELECT id, commit_hash, branch, status, pid, project_path, created_at, updated_at FROM pipeline_runs WHERE commit_hash = ? ORDER BY id DESC", [commit_hash])
|
|
29
|
-
rows.map { |row| row_to_hash(row) }
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def self.store_pid(db, id, pid)
|
|
33
|
-
db.execute("UPDATE pipeline_runs SET pid = ? WHERE id = ?", [pid, id])
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def self.find_running_with_pid(db, branch)
|
|
37
|
-
row = db.execute("SELECT id, commit_hash, branch, status, pid, project_path, created_at, updated_at FROM pipeline_runs WHERE branch = ? AND status = 'running' AND pid IS NOT NULL ORDER BY id DESC LIMIT 1", [branch]).first
|
|
38
|
-
return nil unless row
|
|
39
|
-
row_to_hash(row)
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def self.update_status(db, id, new_status)
|
|
43
|
-
now = Time.now.utc.iso8601
|
|
44
|
-
db.execute("UPDATE pipeline_runs SET status = ?, updated_at = ? WHERE id = ?", [new_status, now, id])
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def self.recent(db, limit: 20)
|
|
48
|
-
rows = db.execute("SELECT id, commit_hash, branch, status, pid, project_path, created_at, updated_at FROM pipeline_runs ORDER BY id DESC LIMIT ?", [limit])
|
|
49
|
-
rows.map { |row| row_to_hash(row) }
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def self.row_to_hash(row)
|
|
53
|
-
{ id: row[0], commit_hash: row[1], branch: row[2], status: row[3], pid: row[4], project_path: row[5], created_at: row[6], updated_at: row[7] }
|
|
54
|
-
end
|
|
55
|
-
private_class_method :row_to_hash
|
|
56
|
-
end
|
|
57
|
-
end
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module FunCi
|
|
4
|
-
class ProgressReporter
|
|
5
|
-
def initialize(stdout:)
|
|
6
|
-
@stdout = stdout
|
|
7
|
-
end
|
|
8
|
-
|
|
9
|
-
def phase_one_result(results)
|
|
10
|
-
markers = results.map { |stage, passed| "#{stage} #{result_marker(passed)}" }
|
|
11
|
-
summary = results.values.all? ? "phase 1 passed" : "phase 1 failed"
|
|
12
|
-
@stdout.puts "fun-ci: #{markers.join(" ")} (#{summary})"
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def fast_result(passed)
|
|
16
|
-
@stdout.puts "fun-ci: fast #{result_marker(passed)}"
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def slow_launched
|
|
20
|
-
@stdout.puts "fun-ci: slow (running in background)"
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
private
|
|
24
|
-
|
|
25
|
-
def result_marker(passed)
|
|
26
|
-
passed ? "ok" : "FAIL"
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module FunCi
|
|
4
|
-
class ProjectConfig
|
|
5
|
-
REQUIRED_SCRIPTS = %w[lint.sh build.sh fast.sh slow.sh].freeze
|
|
6
|
-
|
|
7
|
-
def initialize(project_root)
|
|
8
|
-
@project_root = project_root
|
|
9
|
-
@fun_ci_dir = File.join(project_root, ".fun-ci")
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def folder_exists?
|
|
13
|
-
Dir.exist?(@fun_ci_dir)
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def validate
|
|
17
|
-
errors = []
|
|
18
|
-
|
|
19
|
-
unless folder_exists?
|
|
20
|
-
errors << "No .fun-ci/ folder found in #{@project_root}"
|
|
21
|
-
return errors
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
REQUIRED_SCRIPTS.each do |script|
|
|
25
|
-
path = File.join(@fun_ci_dir, script)
|
|
26
|
-
if !File.exist?(path)
|
|
27
|
-
errors << ".fun-ci/#{script} is not found"
|
|
28
|
-
elsif !File.executable?(path)
|
|
29
|
-
errors << ".fun-ci/#{script} is not executable"
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
errors
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def script_path(stage)
|
|
37
|
-
File.join(@fun_ci_dir, "#{stage}.sh")
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
end
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module FunCi
|
|
4
|
-
class ProjectDetector
|
|
5
|
-
def initialize(filenames)
|
|
6
|
-
@filenames = filenames
|
|
7
|
-
end
|
|
8
|
-
|
|
9
|
-
def detect
|
|
10
|
-
return :ruby_bundler if @filenames.include?("Gemfile")
|
|
11
|
-
return :jvm_gradle_kotlin if @filenames.include?("build.gradle.kts")
|
|
12
|
-
return :jvm_gradle_groovy if @filenames.include?("build.gradle")
|
|
13
|
-
return :jvm_maven if @filenames.include?("pom.xml")
|
|
14
|
-
|
|
15
|
-
:unknown
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
end
|
data/lib/fun_ci/relative_time.rb
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "time"
|
|
4
|
-
|
|
5
|
-
module FunCi
|
|
6
|
-
module RelativeTime
|
|
7
|
-
def self.format(iso_timestamp, now: Time.now)
|
|
8
|
-
elapsed = now - Time.parse(iso_timestamp)
|
|
9
|
-
seconds = elapsed.to_i
|
|
10
|
-
|
|
11
|
-
if seconds < 60
|
|
12
|
-
"just now"
|
|
13
|
-
elsif seconds < 3600
|
|
14
|
-
"#{seconds / 60}m ago"
|
|
15
|
-
else
|
|
16
|
-
"#{seconds / 3600}h ago"
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
end
|
data/lib/fun_ci/row_formatter.rb
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "ansi"
|
|
4
|
-
require_relative "duration_formatter"
|
|
5
|
-
require_relative "relative_time"
|
|
6
|
-
|
|
7
|
-
module FunCi
|
|
8
|
-
module RowFormatter
|
|
9
|
-
STAGE_NAMES = { "lint" => "Lint", "build" => "Build", "fast" => "Fast", "slow" => "Slow" }.freeze
|
|
10
|
-
STATUS_LABELS = {
|
|
11
|
-
"completed" => "PASSED", "failed" => "FAILED", "timed_out" => "TIMED OUT",
|
|
12
|
-
"running" => "RUNNING", "scheduled" => "Scheduled...", "cancelled" => "CANCELLED"
|
|
13
|
-
}.freeze
|
|
14
|
-
PROJECT_COLORS = %w[31 32 33 34 35 36 91 92 93 94].freeze
|
|
15
|
-
|
|
16
|
-
def self.format(run, now: Time.now, spinner_frame: nil, elapsed_seconds: nil)
|
|
17
|
-
commit = run[:commit_hash][0, 7]
|
|
18
|
-
branch = run[:branch]
|
|
19
|
-
status = run[:status]
|
|
20
|
-
project = format_project(run[:project_path])
|
|
21
|
-
|
|
22
|
-
case status
|
|
23
|
-
when "scheduled"
|
|
24
|
-
format_scheduled(commit, branch, project, run[:updated_at], now)
|
|
25
|
-
when "cancelled"
|
|
26
|
-
format_cancelled(commit, branch, project, run[:stages], run[:updated_at], now)
|
|
27
|
-
else
|
|
28
|
-
stages_text = format_stages(run[:stages], status, spinner_frame, elapsed_seconds)
|
|
29
|
-
status_text = format_status(status)
|
|
30
|
-
time_text = Ansi.dim(RelativeTime.format(run[:updated_at], now: now))
|
|
31
|
-
" #{commit} #{branch}#{project} #{stages_text} #{status_text} #{time_text}"
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def self.format_project(project_path)
|
|
36
|
-
return "" unless project_path
|
|
37
|
-
name = File.basename(project_path)
|
|
38
|
-
code = PROJECT_COLORS[name.hash.abs % PROJECT_COLORS.size]
|
|
39
|
-
" \e[#{code}m#{name}#{Ansi::RESET}"
|
|
40
|
-
end
|
|
41
|
-
private_class_method :format_project
|
|
42
|
-
|
|
43
|
-
def self.format_scheduled(commit, branch, project, updated_at, now)
|
|
44
|
-
time_text = RelativeTime.format(updated_at, now: now)
|
|
45
|
-
Ansi.dim(" #{commit} #{branch}#{project} Scheduled... #{time_text}")
|
|
46
|
-
end
|
|
47
|
-
private_class_method :format_scheduled
|
|
48
|
-
|
|
49
|
-
def self.format_cancelled(commit, branch, project, stages, updated_at, now)
|
|
50
|
-
stages_text = stages.map { |s| format_cancelled_stage(s) }.join(" ")
|
|
51
|
-
time_text = RelativeTime.format(updated_at, now: now)
|
|
52
|
-
Ansi.dim(" #{commit} #{branch}#{project} #{stages_text} CANCELLED #{time_text}")
|
|
53
|
-
end
|
|
54
|
-
private_class_method :format_cancelled
|
|
55
|
-
|
|
56
|
-
def self.format_cancelled_stage(stage)
|
|
57
|
-
name = STAGE_NAMES[stage[:stage]]
|
|
58
|
-
if stage[:duration]
|
|
59
|
-
"#{name} #{DurationFormatter.format(stage[:duration])}"
|
|
60
|
-
else
|
|
61
|
-
"#{name} --"
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
private_class_method :format_cancelled_stage
|
|
65
|
-
|
|
66
|
-
def self.format_stages(stages, run_status, spinner_frame, elapsed_seconds)
|
|
67
|
-
stages.map { |s| format_stage(s, spinner_frame, elapsed_seconds) }.join(" ")
|
|
68
|
-
end
|
|
69
|
-
private_class_method :format_stages
|
|
70
|
-
|
|
71
|
-
def self.format_stage(stage, spinner_frame, elapsed_seconds)
|
|
72
|
-
name = STAGE_NAMES[stage[:stage]]
|
|
73
|
-
|
|
74
|
-
case stage[:status]
|
|
75
|
-
when "completed"
|
|
76
|
-
time = DurationFormatter.format(stage[:duration])
|
|
77
|
-
Ansi.green("#{name} #{time}")
|
|
78
|
-
when "failed"
|
|
79
|
-
time = DurationFormatter.format(stage[:duration])
|
|
80
|
-
Ansi.bold_red("#{name} FAIL #{time}")
|
|
81
|
-
when "timed_out"
|
|
82
|
-
time = DurationFormatter.format(stage[:duration])
|
|
83
|
-
Ansi.bold_yellow("#{name} TIMEOUT #{time}")
|
|
84
|
-
when "running"
|
|
85
|
-
frame = spinner_frame || "\u2800"
|
|
86
|
-
time = elapsed_seconds ? "#{elapsed_seconds.to_i}s" : "--"
|
|
87
|
-
Ansi.cyan("#{name} #{frame} #{time}")
|
|
88
|
-
else
|
|
89
|
-
Ansi.dim("#{name} --")
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
private_class_method :format_stage
|
|
93
|
-
|
|
94
|
-
def self.format_status(status)
|
|
95
|
-
label = STATUS_LABELS[status]
|
|
96
|
-
case status
|
|
97
|
-
when "completed" then Ansi.bold_green(label)
|
|
98
|
-
when "failed" then Ansi.bold_red(label)
|
|
99
|
-
when "timed_out" then Ansi.bold_yellow(label)
|
|
100
|
-
when "running" then Ansi.bold_cyan(label)
|
|
101
|
-
else Ansi.dim(label)
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
private_class_method :format_status
|
|
105
|
-
end
|
|
106
|
-
end
|
data/lib/fun_ci/screen.rb
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "ansi"
|
|
4
|
-
|
|
5
|
-
module FunCi
|
|
6
|
-
class Screen
|
|
7
|
-
def initialize(output: $stdout, width: 80)
|
|
8
|
-
@output = output
|
|
9
|
-
@width = width
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def width=(new_width)
|
|
13
|
-
return if new_width == @width
|
|
14
|
-
|
|
15
|
-
clear
|
|
16
|
-
@width = new_width
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def render_header(streak_text:)
|
|
20
|
-
title = " fun-ci"
|
|
21
|
-
if streak_text
|
|
22
|
-
streak_display = Ansi.green(streak_text)
|
|
23
|
-
plain_streak = streak_text
|
|
24
|
-
padding = @width - title.length - plain_streak.length
|
|
25
|
-
padding = 1 if padding < 1
|
|
26
|
-
line = "#{title}#{" " * padding}#{streak_display}"
|
|
27
|
-
else
|
|
28
|
-
line = title.ljust(@width)
|
|
29
|
-
end
|
|
30
|
-
println Ansi.bg_charcoal(Ansi.white(line))
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def render_footer(empty: false, confirming: false)
|
|
34
|
-
if confirming
|
|
35
|
-
println Ansi.dim(" Cancel running pipeline? y/n")
|
|
36
|
-
elsif empty
|
|
37
|
-
println Ansi.dim(" q quit")
|
|
38
|
-
else
|
|
39
|
-
println Ansi.dim(" j/k move c cancel q quit")
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def render_empty_state
|
|
44
|
-
println ""
|
|
45
|
-
println ""
|
|
46
|
-
println " No runs yet."
|
|
47
|
-
println ""
|
|
48
|
-
println " Trigger one: fun-ci trigger HEAD"
|
|
49
|
-
println " Or hook it: fun-ci install-hook pre-push"
|
|
50
|
-
println ""
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def render_board(rows, cursor_index:)
|
|
54
|
-
rows.each_with_index do |row, i|
|
|
55
|
-
if cursor_index == i
|
|
56
|
-
# Replace leading spaces with cursor marker
|
|
57
|
-
println "> #{row.lstrip}"
|
|
58
|
-
else
|
|
59
|
-
println row
|
|
60
|
-
end
|
|
61
|
-
println unless i == rows.length - 1
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def println(text = "")
|
|
66
|
-
@output.print "#{text}\e[K\r\n"
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def clear
|
|
70
|
-
@output.print "\e[2J\e[H"
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def move_cursor_home
|
|
74
|
-
@output.print "\e[H"
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def clear_below
|
|
78
|
-
@output.print "\e[J"
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
end
|
data/lib/fun_ci/setup_checker.rb
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
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
|
data/lib/fun_ci/spinner.rb
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
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
|
data/lib/fun_ci/stage_job.rb
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
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
|
data/lib/fun_ci/stage_runner.rb
DELETED
|
@@ -1,108 +0,0 @@
|
|
|
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
|
|
@@ -1,51 +0,0 @@
|
|
|
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
|
|
@@ -1,30 +0,0 @@
|
|
|
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
|
|
@@ -1,51 +0,0 @@
|
|
|
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
|