fun_ci 1.1.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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/exe/fun-ci-trigger +6 -6
  4. data/exe/fun-ci-tui +6 -6
  5. data/lib/fun_ci/cli.rb +17 -16
  6. data/lib/fun_ci/persistence/database.rb +52 -0
  7. data/lib/fun_ci/persistence/pipeline_recorder.rb +74 -0
  8. data/lib/fun_ci/persistence/pipeline_run.rb +59 -0
  9. data/lib/fun_ci/persistence/stage_job.rb +46 -0
  10. data/lib/fun_ci/{state_machine.rb → persistence/state_machine.rb} +3 -12
  11. data/lib/fun_ci/pipeline/background_wrapper.rb +27 -0
  12. data/lib/fun_ci/pipeline/pipeline_forker.rb +38 -0
  13. data/lib/fun_ci/pipeline/process_runner.rb +28 -0
  14. data/lib/fun_ci/pipeline/progress_reporter.rb +31 -0
  15. data/lib/fun_ci/pipeline/stage_runner.rb +85 -0
  16. data/lib/fun_ci/pipeline/stale_pipeline_canceller.rb +53 -0
  17. data/lib/fun_ci/pipeline/trigger.rb +153 -0
  18. data/lib/fun_ci/setup/hook_writer.rb +75 -0
  19. data/lib/fun_ci/setup/installer.rb +55 -0
  20. data/lib/fun_ci/setup/maven_linter_detector.rb +26 -0
  21. data/lib/fun_ci/setup/project_config.rb +42 -0
  22. data/lib/fun_ci/setup/project_detector.rb +22 -0
  23. data/lib/fun_ci/setup/setup_checker.rb +30 -0
  24. data/lib/fun_ci/setup/template_writer.rb +53 -0
  25. data/lib/fun_ci/tui/admin_tui.rb +90 -0
  26. data/lib/fun_ci/tui/animation.rb +49 -0
  27. data/lib/fun_ci/tui/animation_compositor.rb +107 -0
  28. data/lib/fun_ci/tui/animation_frames.rb +112 -0
  29. data/lib/fun_ci/tui/animation_library.rb +46 -0
  30. data/lib/fun_ci/tui/animation_renderer.rb +144 -0
  31. data/lib/fun_ci/tui/ansi.rb +34 -0
  32. data/lib/fun_ci/tui/board_data.rb +53 -0
  33. data/lib/fun_ci/tui/board_renderer.rb +105 -0
  34. data/lib/fun_ci/tui/duration_formatter.rb +24 -0
  35. data/lib/fun_ci/tui/header_animation_manager.rb +71 -0
  36. data/lib/fun_ci/tui/header_animation_player.rb +45 -0
  37. data/lib/fun_ci/tui/key_handler.rb +86 -0
  38. data/lib/fun_ci/tui/looping_animation_player.rb +45 -0
  39. data/lib/fun_ci/tui/relative_time.rb +22 -0
  40. data/lib/fun_ci/tui/row_formatter.rb +108 -0
  41. data/lib/fun_ci/tui/screen.rb +103 -0
  42. data/lib/fun_ci/tui/spinner.rb +24 -0
  43. data/lib/fun_ci/tui/stage_change_detector.rb +58 -0
  44. data/lib/fun_ci/tui/streak_counter.rb +29 -0
  45. data/lib/fun_ci/tui/terminal_input.rb +69 -0
  46. data/lib/fun_ci.rb +6 -6
  47. metadata +41 -37
  48. data/lib/fun_ci/admin_tui.rb +0 -238
  49. data/lib/fun_ci/animation.rb +0 -47
  50. data/lib/fun_ci/animation_compositor.rb +0 -105
  51. data/lib/fun_ci/animation_frames.rb +0 -111
  52. data/lib/fun_ci/animation_library.rb +0 -44
  53. data/lib/fun_ci/animation_renderer.rb +0 -142
  54. data/lib/fun_ci/ansi.rb +0 -32
  55. data/lib/fun_ci/background_wrapper.rb +0 -27
  56. data/lib/fun_ci/board_data.rb +0 -51
  57. data/lib/fun_ci/database.rb +0 -50
  58. data/lib/fun_ci/duration_formatter.rb +0 -23
  59. data/lib/fun_ci/header_animation_manager.rb +0 -69
  60. data/lib/fun_ci/header_animation_player.rb +0 -43
  61. data/lib/fun_ci/hook_writer.rb +0 -73
  62. data/lib/fun_ci/installer.rb +0 -53
  63. data/lib/fun_ci/looping_animation_player.rb +0 -43
  64. data/lib/fun_ci/maven_linter_detector.rb +0 -24
  65. data/lib/fun_ci/pipeline_forker.rb +0 -36
  66. data/lib/fun_ci/pipeline_recorder.rb +0 -72
  67. data/lib/fun_ci/pipeline_run.rb +0 -57
  68. data/lib/fun_ci/progress_reporter.rb +0 -29
  69. data/lib/fun_ci/project_config.rb +0 -40
  70. data/lib/fun_ci/project_detector.rb +0 -18
  71. data/lib/fun_ci/relative_time.rb +0 -20
  72. data/lib/fun_ci/row_formatter.rb +0 -106
  73. data/lib/fun_ci/screen.rb +0 -95
  74. data/lib/fun_ci/setup_checker.rb +0 -28
  75. data/lib/fun_ci/spinner.rb +0 -22
  76. data/lib/fun_ci/stage_change_detector.rb +0 -56
  77. data/lib/fun_ci/stage_job.rb +0 -44
  78. data/lib/fun_ci/stage_runner.rb +0 -108
  79. data/lib/fun_ci/stale_pipeline_canceller.rb +0 -51
  80. data/lib/fun_ci/streak_counter.rb +0 -30
  81. data/lib/fun_ci/template_writer.rb +0 -51
  82. data/lib/fun_ci/trigger.rb +0 -150
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../persistence/pipeline_run"
4
+
5
+ module FunCi
6
+ module Pipeline
7
+ class StalePipelineCanceller
8
+ def initialize(db:, branch:, stdout:, process_killer: nil)
9
+ @db = db
10
+ @branch = branch
11
+ @stdout = stdout
12
+ @process_killer = process_killer || method(:default_process_killer)
13
+ end
14
+
15
+ def cancel(new_commit_hash:)
16
+ run = Persistence::PipelineRun.find_running_with_pid(@db, @branch)
17
+ return unless run
18
+
19
+ kill_process(run[:pid])
20
+ Persistence::PipelineRun.update_status(@db, run[:id], "cancelled")
21
+ @stdout.puts "Cancelled stale pipeline for #{run[:commit_hash]}. Starting fresh for #{new_commit_hash}."
22
+ end
23
+
24
+ def store_pid(run_id, pid)
25
+ Persistence::PipelineRun.store_pid(@db, run_id, pid)
26
+ end
27
+
28
+ private
29
+
30
+ def kill_process(pid)
31
+ @process_killer.call(0, pid)
32
+ safe_signal("TERM", pid)
33
+ safe_signal("KILL", pid)
34
+ begin
35
+ Process.waitpid(pid)
36
+ rescue Errno::ECHILD, Errno::ESRCH # rubocop:disable Lint/SuppressedException
37
+ end
38
+ rescue Errno::ESRCH
39
+ # Process already dead
40
+ end
41
+
42
+ def safe_signal(signal, pid)
43
+ @process_killer.call(signal, pid)
44
+ rescue Errno::ESRCH
45
+ # Already dead
46
+ end
47
+
48
+ def default_process_killer(signal, pid)
49
+ Process.kill(signal, pid)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "../setup/project_config"
5
+ require_relative "../persistence/pipeline_recorder"
6
+ require_relative "background_wrapper"
7
+ require_relative "stage_runner"
8
+ require_relative "stale_pipeline_canceller"
9
+ require_relative "progress_reporter"
10
+ require_relative "pipeline_forker"
11
+ require_relative "process_runner"
12
+
13
+ module FunCi
14
+ module Pipeline
15
+ class Trigger
16
+ include ProcessRunner
17
+ NULL_SHA = ("0" * 40).freeze
18
+ DEFAULT_BUDGETS = { "lint" => 30, "build" => 30, "fast" => 10, "slow" => 300 }.freeze
19
+
20
+ def self.run_from_args(args, stdout: $stdout, stderr: $stderr, recorder: Persistence::NullRecorder.new, pipeline_forker: nil)
21
+ positional = args.reject { |a| a.start_with?("--") }
22
+ if positional.length < 2
23
+ stderr.puts "fun-ci: commit hash and branch name are required."
24
+ stderr.puts "Usage: fun-ci trigger <commit-hash> <branch>"
25
+ return 1
26
+ end
27
+ commit_hash, branch = positional
28
+ if args.include?("--no-validate")
29
+ db_path = recorder.db_path
30
+ recorder.close
31
+ (pipeline_forker || PipelineForker.method(:fork_pipeline)).call(
32
+ commit_hash: commit_hash, branch: branch, db_path: db_path
33
+ )
34
+ return 0
35
+ end
36
+ new(project_root: Dir.pwd, commit_hash: commit_hash, branch: branch,
37
+ stdout: stdout, stderr: stderr, recorder: recorder).run
38
+ end
39
+
40
+ attr_writer :command_runner
41
+
42
+ def initialize(project_root:, commit_hash:, branch:, stdout: $stdout, stderr: $stderr, command_runner: nil, time_budgets: {}, commit_validator: nil, recorder: Persistence::NullRecorder.new, background_launcher: nil)
43
+ @project_root = project_root
44
+ @commit_hash = commit_hash
45
+ @branch = branch
46
+ @stdout = stdout
47
+ @stderr = stderr
48
+ @command_runner = command_runner
49
+ @time_budgets = DEFAULT_BUDGETS.merge(time_budgets)
50
+ @commit_validator = commit_validator || method(:default_commit_validator)
51
+ @recorder = recorder
52
+ @background_launcher = background_launcher || method(:default_background_launcher)
53
+ end
54
+
55
+ def run
56
+ config = Setup::ProjectConfig.new(@project_root)
57
+ return handle_config_errors(config) if config.validate.any?
58
+ unless @commit_hash == NULL_SHA || @commit_validator.call(@commit_hash)
59
+ @stderr.puts "fun-ci: commit #{@commit_hash} not found in this repository."
60
+ return 1
61
+ end
62
+ cancel_stale_pipelines
63
+ @recorder.create_run(commit_hash: @commit_hash, branch: @branch, project_path: @project_root)
64
+ stage_runner = make_stage_runner
65
+ progress = ProgressReporter.new(stdout: @stdout)
66
+ results = run_phase_one(stage_runner, config)
67
+ progress.phase_one_result(results)
68
+ unless results.values.all?
69
+ @recorder.fail_run
70
+ return 1
71
+ end
72
+ spawn_slow_suite(config)
73
+ progress.slow_launched
74
+ fast_runner = make_stage_runner
75
+ fast_passed = fast_runner.run_stage(config, "fast")
76
+ progress.fast_result(fast_passed)
77
+ unless fast_passed
78
+ @recorder.fail_run
79
+ return 1
80
+ end
81
+ 0
82
+ end
83
+
84
+ private
85
+
86
+ def handle_config_errors(config)
87
+ config.validate.each { |e| @stdout.puts "fun-ci: #{e}" }
88
+ unless config.folder_exists?
89
+ @stdout.puts "Create .fun-ci/lint.sh, build.sh, fast.sh, and slow.sh to set up this project."
90
+ @stdout.puts "Commit will proceed without CI."
91
+ end
92
+ 0
93
+ end
94
+
95
+ def run_phase_one(stage_runner, config)
96
+ results = {}
97
+ threads = %w[lint build].map do |stage|
98
+ Thread.new { results[stage] = stage_runner.run_stage(config, stage) }
99
+ end
100
+ threads.each(&:join)
101
+ results
102
+ end
103
+
104
+ def spawn_slow_suite(config)
105
+ cmd = "#{config.script_path("slow")} #{@commit_hash}"
106
+ job_id = @recorder.start_stage("slow")
107
+ budget = @time_budgets["slow"]
108
+ executor = if @command_runner
109
+ runner = @command_runner
110
+ -> do
111
+ output, status = runner.call(cmd)
112
+ [output, status, false]
113
+ rescue Timeout::Error
114
+ ["", nil, true]
115
+ end
116
+ else
117
+ -> { run_process_with_timeout(cmd, budget) }
118
+ end
119
+ @background_launcher.call(
120
+ db_path: @recorder.db_path, pipeline_run_id: @recorder.pipeline_run_id,
121
+ job_id: job_id, executor: executor
122
+ )
123
+ end
124
+
125
+ def make_stage_runner
126
+ StageRunner.new(commit_hash: @commit_hash, stdout: @stdout,
127
+ command_runner: @command_runner, time_budgets: @time_budgets, recorder: @recorder)
128
+ end
129
+
130
+ def cancel_stale_pipelines
131
+ return unless @recorder.db
132
+ StalePipelineCanceller.new(db: @recorder.db, branch: @branch, stdout: @stdout)
133
+ .cancel(new_commit_hash: @commit_hash)
134
+ end
135
+
136
+ def default_background_launcher(db_path:, pipeline_run_id:, job_id:, executor:)
137
+ @recorder.close
138
+ pid = fork do
139
+ recorder = Persistence::DbRecorder.for_background(db_path, pipeline_run_id)
140
+ BackgroundWrapper.new(recorder: recorder, job_id: job_id, executor: executor).run
141
+ recorder.close
142
+ end
143
+ @recorder = Persistence::DbRecorder.for_background(db_path, pipeline_run_id)
144
+ Process.detach(pid)
145
+ Persistence::PipelineRun.store_pid(@recorder.db, pipeline_run_id, pid)
146
+ end
147
+
148
+ def default_commit_validator(hash)
149
+ Open3.capture2e("git", "cat-file", "-t", hash).last.success?
150
+ end
151
+ end
152
+ end
153
+ end
@@ -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