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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/exe/fun-ci-trigger +6 -6
- data/exe/fun-ci-tui +6 -6
- data/lib/fun_ci/cli.rb +17 -16
- 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 +41 -37
- data/lib/fun_ci/admin_tui.rb +0 -238
- data/lib/fun_ci/animation.rb +0 -47
- data/lib/fun_ci/animation_compositor.rb +0 -105
- data/lib/fun_ci/animation_frames.rb +0 -111
- data/lib/fun_ci/animation_library.rb +0 -44
- data/lib/fun_ci/animation_renderer.rb +0 -142
- data/lib/fun_ci/ansi.rb +0 -32
- 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/header_animation_manager.rb +0 -69
- data/lib/fun_ci/header_animation_player.rb +0 -43
- data/lib/fun_ci/hook_writer.rb +0 -73
- data/lib/fun_ci/installer.rb +0 -53
- data/lib/fun_ci/looping_animation_player.rb +0 -43
- 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 -95
- data/lib/fun_ci/setup_checker.rb +0 -28
- data/lib/fun_ci/spinner.rb +0 -22
- data/lib/fun_ci/stage_change_detector.rb +0 -56
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8de275f366537fcf143b9dccdd10002a9cbff42632ab7e2549b695b4a3ba6240
|
|
4
|
+
data.tar.gz: 8723aee6ea508e2f9bf44c59cb717a83a08dc60381f389550f714b0b0f0de722
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|