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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 432ab6458948397326b4ee6e80846066d59d3548e54a3a3d189f42740562e613
4
- data.tar.gz: 8da12eaeb1c172b64825a2d9c214c56143e47a9d82ba7b5054091662fa8edce1
3
+ metadata.gz: 8de275f366537fcf143b9dccdd10002a9cbff42632ab7e2549b695b4a3ba6240
4
+ data.tar.gz: 8723aee6ea508e2f9bf44c59cb717a83a08dc60381f389550f714b0b0f0de722
5
5
  SHA512:
6
- metadata.gz: e1de07fd9136f7648663a57832d31197435343c6c599c8b77c17e06a72b94a7d4a021205810944e0f3ccae4257bc440138bf2ffdc775f569dd7bc323c532c23e
7
- data.tar.gz: 1a92c980274f3ff0040429ae5efc4b124f1c8b6706d49a2993d1d27f03fc9211d1b8475c81a7a30a8d37cb5b611fbb46d8dd5a90bbcac2a4672b0fbc600eaee3
6
+ metadata.gz: f4122b18c73b6434eedbe7124fa19b79c64b03fce502a34fdc978aa0d97006091477495d6d9ccfe1cb3ca8eba8be0fa9d53341d328a3bd20ecff43e00c482ddc
7
+ data.tar.gz: fde4e5346d6bb6038ad35137456a6b1188a61c16f75973e9a55e44b73bc751afed551e1ea063a6a58a38adfbde4eefc1919297d870810ec8ddadfabd6106ce38
data/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.0] - 2026-02-23
9
+
10
+ ### Bug Fixes
11
+ - Replaced unreliable `Timeout.timeout` with process groups (`pgroup: true`) and `Thread#join` for reliable time budget enforcement on lint/build/fast/slow stages
12
+ - Fixed off-by-one in TUI row truncation that caused the top pipeline row to be hidden behind the header in short terminals
13
+ - Added `Screen#height=` to clear screen on terminal height changes, preventing the header from scrolling off-screen when tmux panes resize
14
+ - Fixed header flicker by skipping `println` when animation is active
15
+ - Allowed null SHA on root commits so pipeline still runs
16
+ - Detected multi-module Gradle projects from `settings.gradle`
17
+
18
+ ### Refactoring
19
+ - Extracted `TerminalInput`, `KeyHandler`, `BoardRenderer` from `AdminTui` (was 257 lines/12 ivars, now 4 focused classes all under 150 lines/4 ivars)
20
+ - Organized flat `lib/fun_ci/` into cohesive module subdirectories: `persistence/`, `pipeline/`, `setup/`, `tui/`
21
+
22
+ ### Internal
23
+ - Extracted shared `ProcessRunner` module for consistent process spawning across stages
24
+ - Updated `BackgroundWrapper` to use `[output, status, timed_out]` triples instead of exception-based timeout signaling
25
+ - Truncated TUI board rows to fit terminal height
26
+
8
27
  ## [1.1.0] - 2026-02-22
9
28
 
10
29
  ### Added
data/exe/fun-ci-trigger CHANGED
@@ -2,8 +2,8 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require_relative "../lib/fun_ci"
5
- require_relative "../lib/fun_ci/trigger"
6
- require_relative "../lib/fun_ci/pipeline_recorder"
5
+ require_relative "../lib/fun_ci/pipeline/trigger"
6
+ require_relative "../lib/fun_ci/persistence/pipeline_recorder"
7
7
 
8
8
  require "tmpdir"
9
9
 
@@ -11,9 +11,9 @@ db_dir = File.join(Dir.tmpdir, "fun-ci")
11
11
  Dir.mkdir(db_dir) unless Dir.exist?(db_dir)
12
12
  db_path = File.join(db_dir, "db.sqlite3")
13
13
 
14
- db = FunCi::Database.connection(db_path)
15
- FunCi::Database.migrate!(db)
14
+ db = FunCi::Persistence::Database.connection(db_path)
15
+ FunCi::Persistence::Database.migrate!(db)
16
16
 
17
- recorder = FunCi::DbRecorder.new(db)
17
+ recorder = FunCi::Persistence::DbRecorder.new(db)
18
18
 
19
- exit FunCi::Trigger.run_from_args(ARGV, recorder: recorder)
19
+ exit FunCi::Pipeline::Trigger.run_from_args(ARGV, recorder: recorder)
data/exe/fun-ci-tui CHANGED
@@ -2,8 +2,8 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require_relative "../lib/fun_ci"
5
- require_relative "../lib/fun_ci/admin_tui"
6
- require_relative "../lib/fun_ci/animation_renderer"
5
+ require_relative "../lib/fun_ci/tui/admin_tui"
6
+ require_relative "../lib/fun_ci/tui/animation_renderer"
7
7
 
8
8
  require "tmpdir"
9
9
  require "io/console"
@@ -12,12 +12,12 @@ db_dir = File.join(Dir.tmpdir, "fun-ci")
12
12
  Dir.mkdir(db_dir) unless Dir.exist?(db_dir)
13
13
  db_path = File.join(db_dir, "db.sqlite3")
14
14
 
15
- db = FunCi::Database.connection(db_path)
16
- FunCi::Database.migrate!(db)
15
+ db = FunCi::Persistence::Database.connection(db_path)
16
+ FunCi::Persistence::Database.migrate!(db)
17
17
 
18
- tui = FunCi::AdminTui.new(
18
+ tui = FunCi::Tui::AdminTui.new(
19
19
  db: db,
20
20
  width_provider: -> { IO.console&.winsize&.dig(1) || 80 },
21
- animation_renderer: FunCi::AnimationRenderer.new
21
+ animation_renderer: FunCi::Tui::AnimationRenderer.new
22
22
  )
23
23
  tui.run
data/lib/fun_ci/cli.rb CHANGED
@@ -42,30 +42,31 @@ module FunCi
42
42
  end
43
43
 
44
44
  def run_trigger(args)
45
- require_relative "trigger"
46
- require_relative "pipeline_recorder"
45
+ require_relative "pipeline/trigger"
46
+ require_relative "persistence/pipeline_recorder"
47
47
  db = setup_db
48
- recorder = DbRecorder.new(db)
49
- Trigger.run_from_args(args, stdout: @stdout, stderr: @stderr, recorder: recorder)
48
+ recorder = Persistence::DbRecorder.new(db)
49
+ Pipeline::Trigger.run_from_args(args, stdout: @stdout, stderr: @stderr, recorder: recorder)
50
50
  end
51
51
 
52
52
  def run_console(_args)
53
- require_relative "admin_tui"
54
- require_relative "animation_renderer"
53
+ require_relative "tui/admin_tui"
54
+ require_relative "tui/animation_renderer"
55
55
  require "io/console"
56
56
  db = setup_db
57
- tui = AdminTui.new(
57
+ tui = Tui::AdminTui.new(
58
58
  db: db,
59
59
  width_provider: -> { IO.console&.winsize&.dig(1) || 80 },
60
- animation_renderer: AnimationRenderer.new
60
+ height_provider: -> { IO.console&.winsize&.dig(0) },
61
+ animation_renderer: Tui::AnimationRenderer.new
61
62
  )
62
63
  tui.run
63
64
  0
64
65
  end
65
66
 
66
67
  def run_init(args)
67
- require_relative "installer"
68
- code = Installer.run(project_root: Dir.pwd, stdout: @stdout)
68
+ require_relative "setup/installer"
69
+ code = Setup::Installer.run(project_root: Dir.pwd, stdout: @stdout)
69
70
  return code if code != 0 || !args.include?("--everything")
70
71
  code = run_install_hooks([])
71
72
  return code unless code == 0
@@ -73,26 +74,26 @@ module FunCi
73
74
  end
74
75
 
75
76
  def run_install_hooks(args)
76
- require_relative "hook_writer"
77
+ require_relative "setup/hook_writer"
77
78
  types = args.any? ? [args.first] : %w[pre-commit pre-push]
78
79
  types.each do |type|
79
- code = HookWriter.run(project_root: Dir.pwd, hook_type: type, stdout: @stdout)
80
+ code = Setup::HookWriter.run(project_root: Dir.pwd, hook_type: type, stdout: @stdout)
80
81
  return code unless code == 0
81
82
  end
82
83
  0
83
84
  end
84
85
 
85
86
  def run_check(_args)
86
- require_relative "setup_checker"
87
- SetupChecker.run(project_root: Dir.pwd, stdout: @stdout, stderr: @stderr)
87
+ require_relative "setup/setup_checker"
88
+ Setup::SetupChecker.run(project_root: Dir.pwd, stdout: @stdout, stderr: @stderr)
88
89
  end
89
90
 
90
91
  def setup_db
91
92
  db_dir = File.join(Dir.tmpdir, "fun-ci")
92
93
  Dir.mkdir(db_dir) unless Dir.exist?(db_dir)
93
94
  db_path = File.join(db_dir, "db.sqlite3")
94
- db = Database.connection(db_path)
95
- Database.migrate!(db)
95
+ db = Persistence::Database.connection(db_path)
96
+ Persistence::Database.migrate!(db)
96
97
  db
97
98
  end
98
99
 
@@ -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