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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Setup
|
|
7
|
+
class HookWriter
|
|
8
|
+
ALLOWED_HOOKS = %w[pre-commit pre-push].freeze
|
|
9
|
+
MARKER = "# fun-ci-managed-hook"
|
|
10
|
+
|
|
11
|
+
HOOK_COMMANDS = {
|
|
12
|
+
"pre-commit" => "fun-ci trigger --no-validate",
|
|
13
|
+
"pre-push" => "fun-ci trigger"
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
HOOK_TEMPLATE = <<~SH
|
|
17
|
+
#!/bin/sh
|
|
18
|
+
#{MARKER}
|
|
19
|
+
COMMIT=$(git rev-parse HEAD 2>/dev/null || echo "0000000000000000000000000000000000000000")
|
|
20
|
+
BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
|
|
21
|
+
%<command>s "$COMMIT" "$BRANCH"
|
|
22
|
+
SH
|
|
23
|
+
|
|
24
|
+
def self.run(project_root:, hook_type:, stdout: $stdout)
|
|
25
|
+
new(project_root: project_root, hook_type: hook_type, stdout: stdout).run
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(project_root:, hook_type:, stdout:)
|
|
29
|
+
@project_root = project_root
|
|
30
|
+
@hook_type = hook_type
|
|
31
|
+
@stdout = stdout
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def run
|
|
35
|
+
return reject("Not a git repository — no .git/ found.") unless git_repo?
|
|
36
|
+
return reject("Unknown hook type: #{@hook_type}") unless ALLOWED_HOOKS.include?(@hook_type)
|
|
37
|
+
return skip("Hook #{@hook_type} already exists from another tool — skipping.") if foreign_hook?
|
|
38
|
+
|
|
39
|
+
write_hook
|
|
40
|
+
@stdout.puts "Installed #{@hook_type} hook."
|
|
41
|
+
0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def git_repo?
|
|
47
|
+
Dir.exist?(File.join(@project_root, ".git"))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def hook_path
|
|
51
|
+
File.join(@project_root, ".git", "hooks", @hook_type)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def foreign_hook?
|
|
55
|
+
File.exist?(hook_path) && !File.read(hook_path).include?(MARKER)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def write_hook
|
|
59
|
+
FileUtils.mkdir_p(File.join(@project_root, ".git", "hooks"))
|
|
60
|
+
File.write(hook_path, format(HOOK_TEMPLATE, command: HOOK_COMMANDS[@hook_type]))
|
|
61
|
+
File.chmod(0o755, hook_path)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def reject(message)
|
|
65
|
+
@stdout.puts message
|
|
66
|
+
1
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def skip(message)
|
|
70
|
+
@stdout.puts message
|
|
71
|
+
0
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "project_detector"
|
|
4
|
+
require_relative "template_writer"
|
|
5
|
+
require_relative "maven_linter_detector"
|
|
6
|
+
|
|
7
|
+
module FunCi
|
|
8
|
+
module Setup
|
|
9
|
+
class Installer
|
|
10
|
+
def self.run(project_root:, stdout: $stdout, pom_reader: nil)
|
|
11
|
+
new(project_root: project_root, stdout: stdout, pom_reader: pom_reader).run
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(project_root:, stdout:, pom_reader: nil)
|
|
15
|
+
@project_root = project_root
|
|
16
|
+
@stdout = stdout
|
|
17
|
+
@pom_reader = pom_reader || ->(path) { File.read(path) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run
|
|
21
|
+
if Dir.exist?(File.join(@project_root, ".fun-ci"))
|
|
22
|
+
@stdout.puts ".fun-ci/ already exists — skipping init."
|
|
23
|
+
return 0
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
filenames = Dir.children(@project_root)
|
|
27
|
+
detected = ProjectDetector.new(filenames).detect
|
|
28
|
+
|
|
29
|
+
if detected == :unknown
|
|
30
|
+
@stdout.puts "Could not detect project type. Create .fun-ci/ manually."
|
|
31
|
+
return 1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@stdout.puts "Detected: #{detected.to_s.tr("_", " ")}"
|
|
35
|
+
|
|
36
|
+
lint_override = detect_maven_linter(detected)
|
|
37
|
+
TemplateWriter.new(detected, @project_root, lint_override: lint_override).write
|
|
38
|
+
|
|
39
|
+
@stdout.puts "Created .fun-ci/ with template scripts."
|
|
40
|
+
0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def detect_maven_linter(detected)
|
|
46
|
+
return nil unless detected == :jvm_maven
|
|
47
|
+
|
|
48
|
+
pom_path = File.join(@project_root, "pom.xml")
|
|
49
|
+
pom_content = @pom_reader.call(pom_path)
|
|
50
|
+
command = MavenLinterDetector.new(pom_content).lint_command
|
|
51
|
+
command == MavenLinterDetector::DEFAULT_COMMAND ? nil : command
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Setup
|
|
5
|
+
class MavenLinterDetector
|
|
6
|
+
LINTERS = [
|
|
7
|
+
{ artifact_id: "detekt-maven-plugin", command: "mvn detekt:check" },
|
|
8
|
+
{ artifact_id: "ktlint-maven-plugin", command: "mvn ktlint:check" },
|
|
9
|
+
{ artifact_id: "maven-checkstyle-plugin", command: "mvn checkstyle:check" },
|
|
10
|
+
{ artifact_id: "spotbugs-maven-plugin", command: "mvn spotbugs:check" },
|
|
11
|
+
{ artifact_id: "maven-pmd-plugin", command: "mvn pmd:check" }
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
DEFAULT_COMMAND = "mvn verify -DskipTests"
|
|
15
|
+
|
|
16
|
+
def initialize(pom_content)
|
|
17
|
+
@pom_content = pom_content
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def lint_command
|
|
21
|
+
match = LINTERS.find { |linter| @pom_content.include?(linter[:artifact_id]) }
|
|
22
|
+
match ? match[:command] : DEFAULT_COMMAND
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Setup
|
|
5
|
+
class ProjectConfig
|
|
6
|
+
REQUIRED_SCRIPTS = %w[lint.sh build.sh fast.sh slow.sh].freeze
|
|
7
|
+
|
|
8
|
+
def initialize(project_root)
|
|
9
|
+
@project_root = project_root
|
|
10
|
+
@fun_ci_dir = File.join(project_root, ".fun-ci")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def folder_exists?
|
|
14
|
+
Dir.exist?(@fun_ci_dir)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def validate
|
|
18
|
+
errors = []
|
|
19
|
+
|
|
20
|
+
unless folder_exists?
|
|
21
|
+
errors << "No .fun-ci/ folder found in #{@project_root}"
|
|
22
|
+
return errors
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
REQUIRED_SCRIPTS.each do |script|
|
|
26
|
+
path = File.join(@fun_ci_dir, script)
|
|
27
|
+
if !File.exist?(path)
|
|
28
|
+
errors << ".fun-ci/#{script} is not found"
|
|
29
|
+
elsif !File.executable?(path)
|
|
30
|
+
errors << ".fun-ci/#{script} is not executable"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
errors
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def script_path(stage)
|
|
38
|
+
File.join(@fun_ci_dir, "#{stage}.sh")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Setup
|
|
5
|
+
class ProjectDetector
|
|
6
|
+
def initialize(filenames)
|
|
7
|
+
@filenames = filenames
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def detect
|
|
11
|
+
return :ruby_bundler if @filenames.include?("Gemfile")
|
|
12
|
+
return :jvm_gradle_kotlin if @filenames.include?("build.gradle.kts")
|
|
13
|
+
return :jvm_gradle_kotlin if @filenames.include?("settings.gradle.kts")
|
|
14
|
+
return :jvm_gradle_groovy if @filenames.include?("build.gradle")
|
|
15
|
+
return :jvm_gradle_groovy if @filenames.include?("settings.gradle")
|
|
16
|
+
return :jvm_maven if @filenames.include?("pom.xml")
|
|
17
|
+
|
|
18
|
+
:unknown
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "project_config"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Setup
|
|
7
|
+
class SetupChecker
|
|
8
|
+
def self.run(project_root:, stdout: $stdout, stderr: $stderr)
|
|
9
|
+
new(project_root: project_root, stdout: stdout).run
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(project_root:, stdout:)
|
|
13
|
+
@config = ProjectConfig.new(project_root)
|
|
14
|
+
@stdout = stdout
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run
|
|
18
|
+
errors = @config.validate
|
|
19
|
+
|
|
20
|
+
if errors.empty?
|
|
21
|
+
@stdout.puts "All OK — project is configured."
|
|
22
|
+
0
|
|
23
|
+
else
|
|
24
|
+
errors.each { |e| @stdout.puts e }
|
|
25
|
+
1
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Setup
|
|
5
|
+
class TemplateWriter
|
|
6
|
+
TEMPLATES = {
|
|
7
|
+
ruby_bundler: {
|
|
8
|
+
"lint.sh" => "#!/bin/sh\nbundle exec rubocop\n",
|
|
9
|
+
"build.sh" => "#!/bin/sh\nbundle install --quiet\n",
|
|
10
|
+
"fast.sh" => "#!/bin/sh\nbundle exec rake test\n",
|
|
11
|
+
"slow.sh" => "#!/bin/sh\nbundle exec rake test:slow\n"
|
|
12
|
+
},
|
|
13
|
+
jvm_gradle_kotlin: {
|
|
14
|
+
"lint.sh" => "#!/bin/sh\n./gradlew check -x test\n",
|
|
15
|
+
"build.sh" => "#!/bin/sh\n./gradlew assemble\n",
|
|
16
|
+
"fast.sh" => "#!/bin/sh\n./gradlew test\n",
|
|
17
|
+
"slow.sh" => "#!/bin/sh\n./gradlew integrationTest\n"
|
|
18
|
+
},
|
|
19
|
+
jvm_gradle_groovy: {
|
|
20
|
+
"lint.sh" => "#!/bin/sh\n./gradlew check -x test\n",
|
|
21
|
+
"build.sh" => "#!/bin/sh\n./gradlew assemble\n",
|
|
22
|
+
"fast.sh" => "#!/bin/sh\n./gradlew test\n",
|
|
23
|
+
"slow.sh" => "#!/bin/sh\n./gradlew integrationTest\n"
|
|
24
|
+
},
|
|
25
|
+
jvm_maven: {
|
|
26
|
+
"lint.sh" => "#!/bin/sh\nmvn verify -DskipTests\n",
|
|
27
|
+
"build.sh" => "#!/bin/sh\nmvn compile\n",
|
|
28
|
+
"fast.sh" => "#!/bin/sh\nmvn test\n",
|
|
29
|
+
"slow.sh" => "#!/bin/sh\nmvn verify\n"
|
|
30
|
+
}
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
def initialize(template_id, target_dir, lint_override: nil)
|
|
34
|
+
@template_id = template_id
|
|
35
|
+
@target_dir = target_dir
|
|
36
|
+
@lint_override = lint_override
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def write
|
|
40
|
+
scripts = TEMPLATES.fetch(@template_id)
|
|
41
|
+
fun_ci_dir = File.join(@target_dir, ".fun-ci")
|
|
42
|
+
Dir.mkdir(fun_ci_dir)
|
|
43
|
+
|
|
44
|
+
scripts.each do |name, content|
|
|
45
|
+
content = "#!/bin/sh\n#{@lint_override}\n" if name == "lint.sh" && @lint_override
|
|
46
|
+
path = File.join(fun_ci_dir, name)
|
|
47
|
+
File.write(path, content)
|
|
48
|
+
File.chmod(0o755, path)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "board_data"
|
|
4
|
+
require_relative "board_renderer"
|
|
5
|
+
require_relative "key_handler"
|
|
6
|
+
require_relative "screen"
|
|
7
|
+
require_relative "spinner"
|
|
8
|
+
require_relative "terminal_input"
|
|
9
|
+
require "io/console"
|
|
10
|
+
|
|
11
|
+
module FunCi
|
|
12
|
+
module Tui
|
|
13
|
+
class AdminTui
|
|
14
|
+
FAST_REFRESH = 0.1
|
|
15
|
+
SLOW_REFRESH = 5.0
|
|
16
|
+
|
|
17
|
+
def initialize(db:, output: $stdout, input: $stdin, width: 80,
|
|
18
|
+
width_provider: nil, height_provider: nil,
|
|
19
|
+
page_size: nil, animation_renderer: nil)
|
|
20
|
+
@board_data = BoardData.new(db, page_size: page_size)
|
|
21
|
+
@terminal_input = TerminalInput.new(input: input, width_provider: width_provider)
|
|
22
|
+
@key_handler = KeyHandler.new(board_data: @board_data)
|
|
23
|
+
@renderer = BoardRenderer.new(
|
|
24
|
+
screen: Screen.new(output: output, width: width),
|
|
25
|
+
spinner: Spinner.new,
|
|
26
|
+
animation_renderer: animation_renderer,
|
|
27
|
+
height_provider: height_provider
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def render_once
|
|
32
|
+
update_width_from_provider
|
|
33
|
+
@renderer.render(
|
|
34
|
+
runs: @board_data.runs,
|
|
35
|
+
streak: @board_data.streak,
|
|
36
|
+
cursor_index: @key_handler.cursor_index,
|
|
37
|
+
confirming: @key_handler.confirming?
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def run
|
|
42
|
+
@renderer.clear
|
|
43
|
+
|
|
44
|
+
begin
|
|
45
|
+
@terminal_input.setup_raw_mode
|
|
46
|
+
@terminal_input.setup_signal_trap
|
|
47
|
+
loop do
|
|
48
|
+
@renderer.begin_frame
|
|
49
|
+
render_once
|
|
50
|
+
key = @terminal_input.read_key_with_timeout(refresh_interval)
|
|
51
|
+
break if key && @key_handler.handle_key(key) == :quit
|
|
52
|
+
end
|
|
53
|
+
ensure
|
|
54
|
+
@terminal_input.restore_terminal
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def resize(new_width)
|
|
59
|
+
@renderer.resize(new_width)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def confirming?
|
|
63
|
+
@key_handler.confirming?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def confirmation_run
|
|
67
|
+
@key_handler.confirmation_run
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def handle_key(key)
|
|
71
|
+
@key_handler.handle_key(key)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def refresh_interval
|
|
77
|
+
return FAST_REFRESH if @renderer.animation_renderer?
|
|
78
|
+
|
|
79
|
+
runs = @board_data.runs
|
|
80
|
+
any_running = runs.any? { |r| r[:status] == "running" }
|
|
81
|
+
any_running ? FAST_REFRESH : SLOW_REFRESH
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def update_width_from_provider
|
|
85
|
+
new_width = @terminal_input.check_width
|
|
86
|
+
@renderer.resize(new_width) if new_width
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Tui
|
|
5
|
+
class Animation
|
|
6
|
+
TYPES = {
|
|
7
|
+
failure: { total_frames: 40, priority: 3 },
|
|
8
|
+
timeout: { total_frames: 4, priority: 2 },
|
|
9
|
+
success: { total_frames: 40, priority: 1 },
|
|
10
|
+
stage_pass: { total_frames: 3, priority: 0 }
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :type, :run_id, :stage, :frame
|
|
14
|
+
|
|
15
|
+
def initialize(type:, run_id:, stage: nil)
|
|
16
|
+
raise ArgumentError, "Unknown animation type: #{type}" unless TYPES.key?(type)
|
|
17
|
+
|
|
18
|
+
@type = type
|
|
19
|
+
@run_id = run_id
|
|
20
|
+
@stage = stage
|
|
21
|
+
@frame = 0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def advance!
|
|
25
|
+
@frame += 1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def finished?
|
|
29
|
+
frame >= total_frames
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def total_frames
|
|
33
|
+
TYPES[@type][:total_frames]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def priority
|
|
37
|
+
TYPES[@type][:priority]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def has_header?
|
|
41
|
+
%i[failure timeout success].include?(@type)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def has_footer?
|
|
45
|
+
%i[failure success].include?(@type)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ansi"
|
|
4
|
+
require_relative "animation_frames"
|
|
5
|
+
require_relative "row_formatter"
|
|
6
|
+
require_relative "duration_formatter"
|
|
7
|
+
|
|
8
|
+
module FunCi
|
|
9
|
+
module Tui
|
|
10
|
+
module AnimationCompositor
|
|
11
|
+
RESET = Ansi::RESET
|
|
12
|
+
STAGE_STAGGER = { "lint" => 0, "build" => 2, "fast" => 4, "slow" => 6 }.freeze
|
|
13
|
+
|
|
14
|
+
def self.header_overlay(animation, width)
|
|
15
|
+
frames = case animation.type
|
|
16
|
+
when :failure then AnimationFrames.failure_header(width)
|
|
17
|
+
when :success then AnimationFrames.success_header(width)
|
|
18
|
+
when :timeout then AnimationFrames.timeout_header(width)
|
|
19
|
+
else return nil
|
|
20
|
+
end
|
|
21
|
+
frame_at(frames, animation.frame)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.footer_overlay(animation, width)
|
|
25
|
+
frames = case animation.type
|
|
26
|
+
when :failure
|
|
27
|
+
AnimationFrames.failure_footer(animation.stage || "stage", width)
|
|
28
|
+
when :success then AnimationFrames.success_footer(width)
|
|
29
|
+
else return nil
|
|
30
|
+
end
|
|
31
|
+
frame_at(frames, animation.frame)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.stage_column_overlay(animation, stage_text, col)
|
|
35
|
+
case animation.type
|
|
36
|
+
when :stage_pass then color_flash(animation, stage_text, AnimationFrames.stage_pass_colors)
|
|
37
|
+
when :timeout then color_flash(animation, stage_text, AnimationFrames.timeout_colors)
|
|
38
|
+
when :failure then failure_flanks(animation, stage_text)
|
|
39
|
+
when :success then sparkle_sweep(animation, stage_text)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.stage_text_for(run, stage_name)
|
|
44
|
+
stage = run[:stages]&.find { |s| s[:stage] == stage_name }
|
|
45
|
+
return nil unless stage
|
|
46
|
+
|
|
47
|
+
name = RowFormatter::STAGE_NAMES[stage[:stage]] || stage[:stage]
|
|
48
|
+
dur = stage[:duration] ? DurationFormatter.format(stage[:duration]) : "--"
|
|
49
|
+
case stage[:status]
|
|
50
|
+
when "completed" then "#{name} #{dur}"
|
|
51
|
+
when "failed" then "#{name} FAIL #{dur}"
|
|
52
|
+
when "timed_out" then "#{name} TIMEOUT #{dur}"
|
|
53
|
+
else "#{name} --"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.stage_col_for(run, stage_name)
|
|
58
|
+
project = run[:project_path] ? " #{File.basename(run[:project_path])}" : ""
|
|
59
|
+
prefix_len = 2 + 7 + 2 + run[:branch].to_s.length + project.length + 2
|
|
60
|
+
(run[:stages] || []).each do |s|
|
|
61
|
+
return prefix_len + 1 if s[:stage] == stage_name
|
|
62
|
+
prefix_len += stage_text_for(run, s[:stage]).to_s.length + 2
|
|
63
|
+
end
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.color_flash(animation, stage_text, colors)
|
|
68
|
+
idx = [animation.frame, colors.length - 1].min
|
|
69
|
+
"#{colors[idx]}#{Ansi.strip(stage_text)}#{RESET}"
|
|
70
|
+
end
|
|
71
|
+
private_class_method :color_flash
|
|
72
|
+
|
|
73
|
+
def self.failure_flanks(animation, stage_text)
|
|
74
|
+
particles = AnimationFrames.failure_particles
|
|
75
|
+
return nil if animation.frame >= particles.length
|
|
76
|
+
|
|
77
|
+
p = particles[animation.frame]
|
|
78
|
+
plain = Ansi.strip(stage_text)
|
|
79
|
+
return plain if p[:chars].empty?
|
|
80
|
+
|
|
81
|
+
left = p[:chars].reverse
|
|
82
|
+
right = p[:chars]
|
|
83
|
+
"#{p[:color]}#{left}#{RESET} #{Ansi.bold_red(plain)} #{p[:color]}#{right}#{RESET}"
|
|
84
|
+
end
|
|
85
|
+
private_class_method :failure_flanks
|
|
86
|
+
|
|
87
|
+
def self.sparkle_sweep(animation, stage_text)
|
|
88
|
+
plain = Ansi.strip(stage_text)
|
|
89
|
+
stagger = STAGE_STAGGER[animation.stage] || 0
|
|
90
|
+
pos = animation.frame - stagger
|
|
91
|
+
return nil if pos < 0 || pos > plain.length
|
|
92
|
+
|
|
93
|
+
result = plain.chars.map { |c| "\e[32m#{c}" }
|
|
94
|
+
result[pos] = "\e[1;33m#{plain[pos]}" if pos < plain.length
|
|
95
|
+
"#{result.join}#{RESET}"
|
|
96
|
+
end
|
|
97
|
+
private_class_method :sparkle_sweep
|
|
98
|
+
|
|
99
|
+
def self.frame_at(frames, index)
|
|
100
|
+
return nil if index >= frames.length
|
|
101
|
+
|
|
102
|
+
frames[index]
|
|
103
|
+
end
|
|
104
|
+
private_class_method :frame_at
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ansi"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Tui
|
|
7
|
+
module AnimationFrames
|
|
8
|
+
RESET = Ansi::RESET
|
|
9
|
+
FOOTER_HOLD = 8
|
|
10
|
+
|
|
11
|
+
def self.failure_particles
|
|
12
|
+
[
|
|
13
|
+
{ chars: "*", color: "\e[1;31m" },
|
|
14
|
+
{ chars: ".*", color: "\e[1;31m" },
|
|
15
|
+
{ chars: ".+*.", color: "\e[38;5;208m" },
|
|
16
|
+
{ chars: "*.+'", color: "\e[38;5;208m" },
|
|
17
|
+
{ chars: "' .", color: "\e[38;5;52m" },
|
|
18
|
+
{ chars: ".", color: "\e[38;5;52m" },
|
|
19
|
+
{ chars: "", color: "" }
|
|
20
|
+
]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.failure_header(width)
|
|
24
|
+
boom = "BOOM"
|
|
25
|
+
[
|
|
26
|
+
nil,
|
|
27
|
+
banner(boom, width, "\e[48;5;124m", particles: "* "),
|
|
28
|
+
banner(boom, width, "\e[48;5;166m", particles: "* * "),
|
|
29
|
+
banner(boom, width, "\e[48;5;52m", particles: ". * * "),
|
|
30
|
+
banner(boom, width, "\e[48;5;236m", particles: "' . * "),
|
|
31
|
+
banner(boom, width, "\e[48;5;236m", particles: ". "),
|
|
32
|
+
nil
|
|
33
|
+
]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.failure_footer(stage_name, width)
|
|
37
|
+
up = stage_name.upcase
|
|
38
|
+
down = stage_name.downcase
|
|
39
|
+
hold [
|
|
40
|
+
nil,
|
|
41
|
+
center("\e[1;31m>>> #{up} FAILED <<<#{RESET}", ">>> #{up} FAILED <<<", width),
|
|
42
|
+
center("\e[31m>> #{down} failed <<#{RESET}", ">> #{down} failed <<", width),
|
|
43
|
+
center("\e[2;31m> #{down} failed <#{RESET}", "> #{down} failed <", width),
|
|
44
|
+
nil
|
|
45
|
+
]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.success_header(width)
|
|
49
|
+
text = "ALL PASSED"
|
|
50
|
+
[
|
|
51
|
+
nil,
|
|
52
|
+
banner(text, width, "\e[48;5;22m", particles: ""),
|
|
53
|
+
banner(text, width, "\e[48;5;22m", particles: "* * "),
|
|
54
|
+
banner(text, width, "\e[48;5;22m", particles: "* . . "),
|
|
55
|
+
banner(text, width, "\e[48;5;58m", particles: ". ' ' "),
|
|
56
|
+
banner(text, width, "\e[48;5;22m", particles: "' "),
|
|
57
|
+
banner(text, width, "\e[48;5;22m", particles: ""),
|
|
58
|
+
nil
|
|
59
|
+
]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.success_footer(width)
|
|
63
|
+
hold [
|
|
64
|
+
nil,
|
|
65
|
+
center("\e[1;32m* * * NICE! * * *#{RESET}", "* * * NICE! * * *", width),
|
|
66
|
+
center("\e[32m. + . * NICE! * . + .#{RESET}", ". + . * NICE! * . + .", width),
|
|
67
|
+
center("\e[2;32m' . + . nice . + . '#{RESET}", "' . + . nice . + . '", width),
|
|
68
|
+
nil
|
|
69
|
+
]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.timeout_header(width)
|
|
73
|
+
[
|
|
74
|
+
nil,
|
|
75
|
+
banner("TIMED OUT", width, "\e[48;5;58m", particles: ""),
|
|
76
|
+
banner("timed out", width, "\e[48;5;58m", particles: ""),
|
|
77
|
+
nil
|
|
78
|
+
]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.stage_pass_colors
|
|
82
|
+
["\e[1;33m", "\e[1;32m", "\e[32m"]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.timeout_colors
|
|
86
|
+
["\e[1;33m", "\e[33m", "\e[1;33m", "\e[33m"]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.banner(text, width, bg_code, particles: "")
|
|
90
|
+
padded = if particles.empty?
|
|
91
|
+
text.center(width)
|
|
92
|
+
else
|
|
93
|
+
inner = "#{particles} #{text} #{particles.reverse}"
|
|
94
|
+
inner.center(width)
|
|
95
|
+
end
|
|
96
|
+
"#{bg_code}\e[1;37m#{padded}#{RESET}"
|
|
97
|
+
end
|
|
98
|
+
private_class_method :banner
|
|
99
|
+
|
|
100
|
+
def self.hold(frames)
|
|
101
|
+
frames.flat_map { |f| Array.new(FOOTER_HOLD, f) }
|
|
102
|
+
end
|
|
103
|
+
private_class_method :hold
|
|
104
|
+
|
|
105
|
+
def self.center(colored_text, plain_text, width)
|
|
106
|
+
pad = [(width - plain_text.length) / 2, 0].max
|
|
107
|
+
"#{" " * pad}#{colored_text}"
|
|
108
|
+
end
|
|
109
|
+
private_class_method :center
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|