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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +3 -1
  4. data/exe/fun-ci-trigger +6 -6
  5. data/exe/fun-ci-tui +9 -4
  6. data/lib/fun_ci/animations/celebrate.rb +210 -0
  7. data/lib/fun_ci/animations/explosion.rb +138 -0
  8. data/lib/fun_ci/animations/flash.rb +205 -0
  9. data/lib/fun_ci/animations/idle.rb +127 -0
  10. data/lib/fun_ci/animations/leprechauns.rb +240 -0
  11. data/lib/fun_ci/animations/running.rb +129 -0
  12. data/lib/fun_ci/animations/success.rb +281 -0
  13. data/lib/fun_ci/animations/yay.rb +169 -0
  14. data/lib/fun_ci/cli.rb +20 -14
  15. data/lib/fun_ci/persistence/database.rb +52 -0
  16. data/lib/fun_ci/persistence/pipeline_recorder.rb +74 -0
  17. data/lib/fun_ci/persistence/pipeline_run.rb +59 -0
  18. data/lib/fun_ci/persistence/stage_job.rb +46 -0
  19. data/lib/fun_ci/{state_machine.rb → persistence/state_machine.rb} +3 -12
  20. data/lib/fun_ci/pipeline/background_wrapper.rb +27 -0
  21. data/lib/fun_ci/pipeline/pipeline_forker.rb +38 -0
  22. data/lib/fun_ci/pipeline/process_runner.rb +28 -0
  23. data/lib/fun_ci/pipeline/progress_reporter.rb +31 -0
  24. data/lib/fun_ci/pipeline/stage_runner.rb +85 -0
  25. data/lib/fun_ci/pipeline/stale_pipeline_canceller.rb +53 -0
  26. data/lib/fun_ci/pipeline/trigger.rb +153 -0
  27. data/lib/fun_ci/setup/hook_writer.rb +75 -0
  28. data/lib/fun_ci/setup/installer.rb +55 -0
  29. data/lib/fun_ci/setup/maven_linter_detector.rb +26 -0
  30. data/lib/fun_ci/setup/project_config.rb +42 -0
  31. data/lib/fun_ci/setup/project_detector.rb +22 -0
  32. data/lib/fun_ci/setup/setup_checker.rb +30 -0
  33. data/lib/fun_ci/setup/template_writer.rb +53 -0
  34. data/lib/fun_ci/tui/admin_tui.rb +90 -0
  35. data/lib/fun_ci/tui/animation.rb +49 -0
  36. data/lib/fun_ci/tui/animation_compositor.rb +107 -0
  37. data/lib/fun_ci/tui/animation_frames.rb +112 -0
  38. data/lib/fun_ci/tui/animation_library.rb +46 -0
  39. data/lib/fun_ci/tui/animation_renderer.rb +144 -0
  40. data/lib/fun_ci/tui/ansi.rb +34 -0
  41. data/lib/fun_ci/tui/board_data.rb +53 -0
  42. data/lib/fun_ci/tui/board_renderer.rb +105 -0
  43. data/lib/fun_ci/tui/duration_formatter.rb +24 -0
  44. data/lib/fun_ci/tui/header_animation_manager.rb +71 -0
  45. data/lib/fun_ci/tui/header_animation_player.rb +45 -0
  46. data/lib/fun_ci/tui/key_handler.rb +86 -0
  47. data/lib/fun_ci/tui/looping_animation_player.rb +45 -0
  48. data/lib/fun_ci/tui/relative_time.rb +22 -0
  49. data/lib/fun_ci/tui/row_formatter.rb +108 -0
  50. data/lib/fun_ci/tui/screen.rb +103 -0
  51. data/lib/fun_ci/tui/spinner.rb +24 -0
  52. data/lib/fun_ci/tui/stage_change_detector.rb +58 -0
  53. data/lib/fun_ci/tui/streak_counter.rb +29 -0
  54. data/lib/fun_ci/tui/terminal_input.rb +69 -0
  55. data/lib/fun_ci.rb +6 -6
  56. metadata +49 -28
  57. data/lib/fun_ci/admin_tui.rb +0 -226
  58. data/lib/fun_ci/ansi.rb +0 -21
  59. data/lib/fun_ci/background_wrapper.rb +0 -27
  60. data/lib/fun_ci/board_data.rb +0 -51
  61. data/lib/fun_ci/database.rb +0 -50
  62. data/lib/fun_ci/duration_formatter.rb +0 -23
  63. data/lib/fun_ci/hook_writer.rb +0 -73
  64. data/lib/fun_ci/installer.rb +0 -53
  65. data/lib/fun_ci/maven_linter_detector.rb +0 -24
  66. data/lib/fun_ci/pipeline_forker.rb +0 -36
  67. data/lib/fun_ci/pipeline_recorder.rb +0 -72
  68. data/lib/fun_ci/pipeline_run.rb +0 -57
  69. data/lib/fun_ci/progress_reporter.rb +0 -29
  70. data/lib/fun_ci/project_config.rb +0 -40
  71. data/lib/fun_ci/project_detector.rb +0 -18
  72. data/lib/fun_ci/relative_time.rb +0 -20
  73. data/lib/fun_ci/row_formatter.rb +0 -106
  74. data/lib/fun_ci/screen.rb +0 -81
  75. data/lib/fun_ci/setup_checker.rb +0 -28
  76. data/lib/fun_ci/spinner.rb +0 -22
  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,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite3"
4
+
5
+ module FunCi
6
+ module Persistence
7
+ module Database
8
+ def self.connection(db_path)
9
+ db = SQLite3::Database.new(db_path)
10
+ db.busy_timeout = 5000
11
+ db.execute("PRAGMA journal_mode=WAL")
12
+ db.execute("PRAGMA foreign_keys=ON")
13
+ db
14
+ end
15
+
16
+ def self.migrate!(db)
17
+ db.execute(<<~SQL)
18
+ CREATE TABLE IF NOT EXISTS pipeline_runs (
19
+ id INTEGER PRIMARY KEY,
20
+ commit_hash TEXT,
21
+ branch TEXT,
22
+ status TEXT DEFAULT 'scheduled',
23
+ pid INTEGER,
24
+ created_at TEXT,
25
+ updated_at TEXT
26
+ )
27
+ SQL
28
+
29
+ add_column_if_missing(db, "pipeline_runs", "pid", "INTEGER")
30
+ add_column_if_missing(db, "pipeline_runs", "project_path", "TEXT")
31
+
32
+ db.execute(<<~SQL)
33
+ CREATE TABLE IF NOT EXISTS stage_jobs (
34
+ id INTEGER PRIMARY KEY,
35
+ pipeline_run_id INTEGER REFERENCES pipeline_runs(id),
36
+ stage TEXT,
37
+ status TEXT DEFAULT 'scheduled',
38
+ started_at TEXT,
39
+ completed_at TEXT
40
+ )
41
+ SQL
42
+ end
43
+
44
+ def self.add_column_if_missing(db, table, column, type)
45
+ columns = db.execute("PRAGMA table_info(#{table})").map { |row| row[1] }
46
+ return if columns.include?(column)
47
+ db.execute("ALTER TABLE #{table} ADD COLUMN #{column} #{type}")
48
+ end
49
+ private_class_method :add_column_if_missing
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pipeline_run"
4
+ require_relative "stage_job"
5
+
6
+ module FunCi
7
+ module Persistence
8
+ class NullRecorder
9
+ def create_run(commit_hash:, branch:, project_path: nil) = nil
10
+ def start_stage(stage) = nil
11
+ def end_stage(job_id, status) = nil
12
+ def complete_run = nil
13
+ def fail_run = nil
14
+ def db = nil
15
+ def db_path = nil
16
+ def pipeline_run_id = nil
17
+ def close = nil
18
+ end
19
+
20
+ class DbRecorder
21
+ attr_reader :db, :db_path, :pipeline_run_id
22
+
23
+ def self.for_background(db_path, pipeline_run_id)
24
+ db = Database.connection(db_path)
25
+ new(db, pipeline_run_id: pipeline_run_id)
26
+ end
27
+
28
+ def initialize(db, pipeline_run_id: nil)
29
+ @db = db
30
+ @db_path = db.filename("main")
31
+ @pipeline_run_id = pipeline_run_id
32
+ end
33
+
34
+ def close
35
+ @db.close rescue nil
36
+ end
37
+
38
+ def create_run(commit_hash:, branch:, project_path: nil)
39
+ @pipeline_run_id = PipelineRun.create(@db, commit_hash: commit_hash, branch: branch, project_path: project_path)
40
+ end
41
+
42
+ def start_stage(stage)
43
+ return nil unless @pipeline_run_id
44
+ ensure_running
45
+ job_id = StageJob.create(@db, pipeline_run_id: @pipeline_run_id, stage: stage)
46
+ StageJob.update_status(@db, job_id, "running")
47
+ job_id
48
+ end
49
+
50
+ def end_stage(job_id, status)
51
+ return unless job_id
52
+ StageJob.update_status(@db, job_id, status)
53
+ end
54
+
55
+ def complete_run
56
+ return unless @pipeline_run_id
57
+ PipelineRun.update_status(@db, @pipeline_run_id, "completed")
58
+ end
59
+
60
+ def fail_run
61
+ return unless @pipeline_run_id
62
+ PipelineRun.update_status(@db, @pipeline_run_id, "failed")
63
+ end
64
+
65
+ private
66
+
67
+ def ensure_running
68
+ run = PipelineRun.find(@db, @pipeline_run_id)
69
+ return unless run && run[:status] == "scheduled"
70
+ PipelineRun.update_status(@db, @pipeline_run_id, "running")
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module FunCi
6
+ module Persistence
7
+ module PipelineRun
8
+ def self.create(db, commit_hash:, branch:, project_path: nil)
9
+ now = Time.now.utc.iso8601
10
+ db.execute(
11
+ "INSERT INTO pipeline_runs (commit_hash, branch, project_path, status, created_at, updated_at) VALUES (?, ?, ?, 'scheduled', ?, ?)",
12
+ [commit_hash, branch, project_path, now, now]
13
+ )
14
+ db.last_insert_row_id
15
+ end
16
+
17
+ def self.find(db, id)
18
+ row = db.execute("SELECT id, commit_hash, branch, status, pid, project_path, created_at, updated_at FROM pipeline_runs WHERE id = ?", [id]).first
19
+ return nil unless row
20
+ row_to_hash(row)
21
+ end
22
+
23
+ def self.find_by_branch(db, branch)
24
+ 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])
25
+ rows.map { |row| row_to_hash(row) }
26
+ end
27
+
28
+ def self.find_by_commit(db, commit_hash)
29
+ 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])
30
+ rows.map { |row| row_to_hash(row) }
31
+ end
32
+
33
+ def self.store_pid(db, id, pid)
34
+ db.execute("UPDATE pipeline_runs SET pid = ? WHERE id = ?", [pid, id])
35
+ end
36
+
37
+ def self.find_running_with_pid(db, branch)
38
+ 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
39
+ return nil unless row
40
+ row_to_hash(row)
41
+ end
42
+
43
+ def self.update_status(db, id, new_status)
44
+ now = Time.now.utc.iso8601
45
+ db.execute("UPDATE pipeline_runs SET status = ?, updated_at = ? WHERE id = ?", [new_status, now, id])
46
+ end
47
+
48
+ def self.recent(db, limit: 20)
49
+ 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])
50
+ rows.map { |row| row_to_hash(row) }
51
+ end
52
+
53
+ def self.row_to_hash(row)
54
+ { 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] }
55
+ end
56
+ private_class_method :row_to_hash
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module FunCi
6
+ module Persistence
7
+ module StageJob
8
+ TERMINAL_STATUSES = %w[completed failed timed_out cancelled].freeze
9
+
10
+ def self.create(db, pipeline_run_id:, stage:)
11
+ db.execute(
12
+ "INSERT INTO stage_jobs (pipeline_run_id, stage, status) VALUES (?, ?, 'scheduled')",
13
+ [pipeline_run_id, stage]
14
+ )
15
+ db.last_insert_row_id
16
+ end
17
+
18
+ def self.find(db, id)
19
+ row = db.execute("SELECT id, pipeline_run_id, stage, status, started_at, completed_at FROM stage_jobs WHERE id = ?", [id]).first
20
+ return nil unless row
21
+ row_to_hash(row)
22
+ end
23
+
24
+ def self.update_status(db, id, new_status)
25
+ now = Time.now.utc.iso8601
26
+ if new_status == "running"
27
+ db.execute("UPDATE stage_jobs SET status = ?, started_at = ? WHERE id = ?", [new_status, now, id])
28
+ elsif TERMINAL_STATUSES.include?(new_status)
29
+ db.execute("UPDATE stage_jobs SET status = ?, completed_at = ? WHERE id = ?", [new_status, now, id])
30
+ else
31
+ db.execute("UPDATE stage_jobs SET status = ? WHERE id = ?", [new_status, id])
32
+ end
33
+ end
34
+
35
+ def self.elapsed_duration(job)
36
+ return nil unless job[:started_at] && job[:completed_at]
37
+ Time.parse(job[:completed_at]) - Time.parse(job[:started_at])
38
+ end
39
+
40
+ def self.row_to_hash(row)
41
+ { id: row[0], pipeline_run_id: row[1], stage: row[2], status: row[3], started_at: row[4], completed_at: row[5] }
42
+ end
43
+ private_class_method :row_to_hash
44
+ end
45
+ end
46
+ end
@@ -1,18 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FunCi
4
- # Enforced state machine for pipeline runs and stage jobs.
5
- #
6
- # States: scheduled, running, completed, failed, timed_out, cancelled
7
- #
8
- # Valid transitions:
9
- # scheduled -> running
10
- # scheduled -> cancelled
11
- # running -> completed
12
- # running -> failed
13
- # running -> timed_out
14
- # running -> cancelled
15
- class StateMachine
4
+ module Persistence
5
+ class StateMachine
16
6
  class InvalidTransition < StandardError; end
17
7
 
18
8
  STATES = %i[scheduled running completed failed timed_out cancelled].freeze
@@ -55,5 +45,6 @@ module FunCi
55
45
 
56
46
  raise ArgumentError, "Unknown state: #{state}. Valid states: #{STATES.join(", ")}"
57
47
  end
48
+ end
58
49
  end
59
50
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Pipeline
5
+ class BackgroundWrapper
6
+ def initialize(recorder:, job_id:, executor:)
7
+ @recorder = recorder
8
+ @job_id = job_id
9
+ @executor = executor
10
+ end
11
+
12
+ def run
13
+ _output, status, timed_out = @executor.call
14
+ if timed_out
15
+ @recorder.end_stage(@job_id, "timed_out")
16
+ @recorder.fail_run
17
+ elsif status.success?
18
+ @recorder.end_stage(@job_id, "completed")
19
+ @recorder.complete_run
20
+ else
21
+ @recorder.end_stage(@job_id, "failed")
22
+ @recorder.fail_run
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../persistence/database"
4
+ require_relative "../persistence/pipeline_recorder"
5
+ require_relative "background_wrapper"
6
+
7
+ module FunCi
8
+ module Pipeline
9
+ class PipelineForker
10
+ def self.fork_pipeline(commit_hash:, branch:, db_path:)
11
+ pid = fork do
12
+ run_in_child(commit_hash: commit_hash, branch: branch, db_path: db_path)
13
+ end
14
+ Process.detach(pid)
15
+ end
16
+
17
+ def self.run_in_child(commit_hash:, branch:, db_path:)
18
+ db = Persistence::Database.connection(db_path)
19
+ recorder = Persistence::DbRecorder.new(db)
20
+ Trigger.new(
21
+ project_root: Dir.pwd,
22
+ commit_hash: commit_hash,
23
+ branch: branch,
24
+ stdout: File.open(File::NULL, "w"),
25
+ recorder: recorder,
26
+ background_launcher: method(:sync_launcher)
27
+ ).run
28
+ recorder.close
29
+ end
30
+
31
+ def self.sync_launcher(db_path:, pipeline_run_id:, job_id:, executor:)
32
+ recorder = Persistence::DbRecorder.for_background(db_path, pipeline_run_id)
33
+ BackgroundWrapper.new(recorder: recorder, job_id: job_id, executor: executor).run
34
+ recorder.close
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Pipeline
5
+ module ProcessRunner
6
+ def run_process_with_timeout(cmd, budget)
7
+ r, w = IO.pipe
8
+ pid = Process.spawn(cmd, out: w, err: w, pgroup: true)
9
+ w.close
10
+
11
+ reader = Thread.new { r.read }
12
+
13
+ if reader.join(budget)
14
+ output = reader.value
15
+ _, status = Process.waitpid2(pid)
16
+ [output, status, false]
17
+ else
18
+ Process.kill("TERM", -pid) rescue nil
19
+ Process.kill("KILL", -pid) rescue nil
20
+ Process.waitpid(pid) rescue nil
21
+ ["", nil, true]
22
+ end
23
+ ensure
24
+ r&.close rescue nil
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Pipeline
5
+ class ProgressReporter
6
+ def initialize(stdout:)
7
+ @stdout = stdout
8
+ end
9
+
10
+ def phase_one_result(results)
11
+ markers = results.map { |stage, passed| "#{stage} #{result_marker(passed)}" }
12
+ summary = results.values.all? ? "phase 1 passed" : "phase 1 failed"
13
+ @stdout.puts "fun-ci: #{markers.join(" ")} (#{summary})"
14
+ end
15
+
16
+ def fast_result(passed)
17
+ @stdout.puts "fun-ci: fast #{result_marker(passed)}"
18
+ end
19
+
20
+ def slow_launched
21
+ @stdout.puts "fun-ci: slow (running in background)"
22
+ end
23
+
24
+ private
25
+
26
+ def result_marker(passed)
27
+ passed ? "ok" : "FAIL"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+ require_relative "process_runner"
5
+
6
+ module FunCi
7
+ module Pipeline
8
+ class StageRunner
9
+ include ProcessRunner
10
+
11
+ def initialize(commit_hash:, stdout:, command_runner: nil, time_budgets: {}, recorder: FunCi::NullRecorder.new)
12
+ @commit_hash = commit_hash
13
+ @stdout = stdout
14
+ @command_runner = command_runner
15
+ @time_budgets = time_budgets
16
+ @recorder = recorder
17
+ end
18
+
19
+ def run_stage(config, stage)
20
+ script = config.script_path(stage)
21
+ budget = @time_budgets[stage]
22
+
23
+ job_id = @recorder.start_stage(stage)
24
+ output, status, timed_out = run_with_timeout(script, budget)
25
+
26
+ if timed_out
27
+ @recorder.end_stage(job_id, "timed_out")
28
+ @stdout.puts "#{stage_label(stage)} killed -- exceeded #{budget}s time budget."
29
+ @stdout.puts budget_advice(stage)
30
+ return false
31
+ end
32
+
33
+ unless status.success?
34
+ @recorder.end_stage(job_id, "failed")
35
+ @stdout.puts output unless output.empty?
36
+ @stdout.puts "#{stage_label(stage)} failed."
37
+ return false
38
+ end
39
+
40
+ @recorder.end_stage(job_id, "completed")
41
+ true
42
+ end
43
+
44
+ private
45
+
46
+ def run_with_timeout(script, budget)
47
+ cmd = "#{script} #{@commit_hash}"
48
+
49
+ if @command_runner
50
+ begin
51
+ output, status = @command_runner.call(cmd)
52
+ [output, status, false]
53
+ rescue Timeout::Error
54
+ ["", nil, true]
55
+ end
56
+ else
57
+ run_process_with_timeout(cmd, budget)
58
+ end
59
+ end
60
+
61
+ def stage_label(stage)
62
+ case stage
63
+ when "lint" then "Lint"
64
+ when "build" then "Build"
65
+ when "fast" then "Fast suite"
66
+ when "slow" then "Slow suite"
67
+ else stage
68
+ end
69
+ end
70
+
71
+ def budget_advice(stage)
72
+ case stage
73
+ when "lint"
74
+ "Trim your linter config or split into stages."
75
+ when "build"
76
+ "Keep your build efficient and not let it become a bottleneck."
77
+ when "fast"
78
+ "Your fast tests have gotten too slow. Split or speed them up."
79
+ when "slow"
80
+ "Pare down integration tests, parallelise, or raise the budget."
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -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