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
@@ -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
@@ -1,150 +0,0 @@
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