fun_ci 1.0.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 +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE.txt +21 -0
- data/README.md +107 -0
- data/exe/fun-ci +7 -0
- data/exe/fun-ci-trigger +19 -0
- data/exe/fun-ci-tui +18 -0
- data/lib/fun_ci/admin_tui.rb +226 -0
- data/lib/fun_ci/ansi.rb +21 -0
- data/lib/fun_ci/background_wrapper.rb +27 -0
- data/lib/fun_ci/board_data.rb +51 -0
- data/lib/fun_ci/cli.rb +144 -0
- data/lib/fun_ci/database.rb +50 -0
- data/lib/fun_ci/duration_formatter.rb +23 -0
- data/lib/fun_ci/hook_writer.rb +73 -0
- data/lib/fun_ci/installer.rb +53 -0
- data/lib/fun_ci/maven_linter_detector.rb +24 -0
- data/lib/fun_ci/pipeline_forker.rb +36 -0
- data/lib/fun_ci/pipeline_recorder.rb +72 -0
- data/lib/fun_ci/pipeline_run.rb +57 -0
- data/lib/fun_ci/progress_reporter.rb +29 -0
- data/lib/fun_ci/project_config.rb +40 -0
- data/lib/fun_ci/project_detector.rb +18 -0
- data/lib/fun_ci/relative_time.rb +20 -0
- data/lib/fun_ci/row_formatter.rb +106 -0
- data/lib/fun_ci/screen.rb +81 -0
- data/lib/fun_ci/setup_checker.rb +28 -0
- data/lib/fun_ci/spinner.rb +22 -0
- data/lib/fun_ci/stage_job.rb +44 -0
- data/lib/fun_ci/stage_runner.rb +108 -0
- data/lib/fun_ci/stale_pipeline_canceller.rb +51 -0
- data/lib/fun_ci/state_machine.rb +59 -0
- data/lib/fun_ci/streak_counter.rb +30 -0
- data/lib/fun_ci/template_writer.rb +51 -0
- data/lib/fun_ci/trigger.rb +150 -0
- data/lib/fun_ci.rb +15 -0
- metadata +95 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sqlite3"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Database
|
|
7
|
+
def self.connection(db_path)
|
|
8
|
+
db = SQLite3::Database.new(db_path)
|
|
9
|
+
db.busy_timeout = 5000
|
|
10
|
+
db.execute("PRAGMA journal_mode=WAL")
|
|
11
|
+
db.execute("PRAGMA foreign_keys=ON")
|
|
12
|
+
db
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.migrate!(db)
|
|
16
|
+
db.execute(<<~SQL)
|
|
17
|
+
CREATE TABLE IF NOT EXISTS pipeline_runs (
|
|
18
|
+
id INTEGER PRIMARY KEY,
|
|
19
|
+
commit_hash TEXT,
|
|
20
|
+
branch TEXT,
|
|
21
|
+
status TEXT DEFAULT 'scheduled',
|
|
22
|
+
pid INTEGER,
|
|
23
|
+
created_at TEXT,
|
|
24
|
+
updated_at TEXT
|
|
25
|
+
)
|
|
26
|
+
SQL
|
|
27
|
+
|
|
28
|
+
add_column_if_missing(db, "pipeline_runs", "pid", "INTEGER")
|
|
29
|
+
add_column_if_missing(db, "pipeline_runs", "project_path", "TEXT")
|
|
30
|
+
|
|
31
|
+
db.execute(<<~SQL)
|
|
32
|
+
CREATE TABLE IF NOT EXISTS stage_jobs (
|
|
33
|
+
id INTEGER PRIMARY KEY,
|
|
34
|
+
pipeline_run_id INTEGER REFERENCES pipeline_runs(id),
|
|
35
|
+
stage TEXT,
|
|
36
|
+
status TEXT DEFAULT 'scheduled',
|
|
37
|
+
started_at TEXT,
|
|
38
|
+
completed_at TEXT
|
|
39
|
+
)
|
|
40
|
+
SQL
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.add_column_if_missing(db, table, column, type)
|
|
44
|
+
columns = db.execute("PRAGMA table_info(#{table})").map { |row| row[1] }
|
|
45
|
+
return if columns.include?(column)
|
|
46
|
+
db.execute("ALTER TABLE #{table} ADD COLUMN #{column} #{type}")
|
|
47
|
+
end
|
|
48
|
+
private_class_method :add_column_if_missing
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module DurationFormatter
|
|
5
|
+
def self.format(seconds)
|
|
6
|
+
if seconds >= 60
|
|
7
|
+
mins = (seconds / 60).to_i
|
|
8
|
+
secs = (seconds % 60).to_i
|
|
9
|
+
"#{mins}m#{secs.to_s.rjust(2, "0")}"
|
|
10
|
+
elsif seconds == seconds.to_i
|
|
11
|
+
"#{seconds.to_i}s"
|
|
12
|
+
else
|
|
13
|
+
"#{format_decimal(seconds)}s"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.format_decimal(value)
|
|
18
|
+
# Show one decimal place, remove trailing zeros
|
|
19
|
+
sprintf("%.1f", value)
|
|
20
|
+
end
|
|
21
|
+
private_class_method :format_decimal
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
class HookWriter
|
|
7
|
+
ALLOWED_HOOKS = %w[pre-commit pre-push].freeze
|
|
8
|
+
MARKER = "# fun-ci-managed-hook"
|
|
9
|
+
|
|
10
|
+
HOOK_COMMANDS = {
|
|
11
|
+
"pre-commit" => "fun-ci trigger --no-validate",
|
|
12
|
+
"pre-push" => "fun-ci trigger"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
HOOK_TEMPLATE = <<~SH
|
|
16
|
+
#!/bin/sh
|
|
17
|
+
#{MARKER}
|
|
18
|
+
COMMIT=$(git rev-parse HEAD 2>/dev/null || echo "0000000000000000000000000000000000000000")
|
|
19
|
+
BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
|
|
20
|
+
%<command>s "$COMMIT" "$BRANCH"
|
|
21
|
+
SH
|
|
22
|
+
|
|
23
|
+
def self.run(project_root:, hook_type:, stdout: $stdout)
|
|
24
|
+
new(project_root: project_root, hook_type: hook_type, stdout: stdout).run
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(project_root:, hook_type:, stdout:)
|
|
28
|
+
@project_root = project_root
|
|
29
|
+
@hook_type = hook_type
|
|
30
|
+
@stdout = stdout
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def run
|
|
34
|
+
return reject("Not a git repository — no .git/ found.") unless git_repo?
|
|
35
|
+
return reject("Unknown hook type: #{@hook_type}") unless ALLOWED_HOOKS.include?(@hook_type)
|
|
36
|
+
return skip("Hook #{@hook_type} already exists from another tool — skipping.") if foreign_hook?
|
|
37
|
+
|
|
38
|
+
write_hook
|
|
39
|
+
@stdout.puts "Installed #{@hook_type} hook."
|
|
40
|
+
0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def git_repo?
|
|
46
|
+
Dir.exist?(File.join(@project_root, ".git"))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def hook_path
|
|
50
|
+
File.join(@project_root, ".git", "hooks", @hook_type)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def foreign_hook?
|
|
54
|
+
File.exist?(hook_path) && !File.read(hook_path).include?(MARKER)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def write_hook
|
|
58
|
+
FileUtils.mkdir_p(File.join(@project_root, ".git", "hooks"))
|
|
59
|
+
File.write(hook_path, format(HOOK_TEMPLATE, command: HOOK_COMMANDS[@hook_type]))
|
|
60
|
+
File.chmod(0o755, hook_path)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def reject(message)
|
|
64
|
+
@stdout.puts message
|
|
65
|
+
1
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def skip(message)
|
|
69
|
+
@stdout.puts message
|
|
70
|
+
0
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "project_detector"
|
|
4
|
+
require_relative "template_writer"
|
|
5
|
+
require_relative "maven_linter_detector"
|
|
6
|
+
|
|
7
|
+
module FunCi
|
|
8
|
+
class Installer
|
|
9
|
+
def self.run(project_root:, stdout: $stdout, pom_reader: nil)
|
|
10
|
+
new(project_root: project_root, stdout: stdout, pom_reader: pom_reader).run
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(project_root:, stdout:, pom_reader: nil)
|
|
14
|
+
@project_root = project_root
|
|
15
|
+
@stdout = stdout
|
|
16
|
+
@pom_reader = pom_reader || ->(path) { File.read(path) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run
|
|
20
|
+
if Dir.exist?(File.join(@project_root, ".fun-ci"))
|
|
21
|
+
@stdout.puts ".fun-ci/ already exists — skipping init."
|
|
22
|
+
return 0
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
filenames = Dir.children(@project_root)
|
|
26
|
+
detected = ProjectDetector.new(filenames).detect
|
|
27
|
+
|
|
28
|
+
if detected == :unknown
|
|
29
|
+
@stdout.puts "Could not detect project type. Create .fun-ci/ manually."
|
|
30
|
+
return 1
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@stdout.puts "Detected: #{detected.to_s.tr("_", " ")}"
|
|
34
|
+
|
|
35
|
+
lint_override = detect_maven_linter(detected)
|
|
36
|
+
TemplateWriter.new(detected, @project_root, lint_override: lint_override).write
|
|
37
|
+
|
|
38
|
+
@stdout.puts "Created .fun-ci/ with template scripts."
|
|
39
|
+
0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def detect_maven_linter(detected)
|
|
45
|
+
return nil unless detected == :jvm_maven
|
|
46
|
+
|
|
47
|
+
pom_path = File.join(@project_root, "pom.xml")
|
|
48
|
+
pom_content = @pom_reader.call(pom_path)
|
|
49
|
+
command = MavenLinterDetector.new(pom_content).lint_command
|
|
50
|
+
command == MavenLinterDetector::DEFAULT_COMMAND ? nil : command
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
class MavenLinterDetector
|
|
5
|
+
LINTERS = [
|
|
6
|
+
{ artifact_id: "detekt-maven-plugin", command: "mvn detekt:check" },
|
|
7
|
+
{ artifact_id: "ktlint-maven-plugin", command: "mvn ktlint:check" },
|
|
8
|
+
{ artifact_id: "maven-checkstyle-plugin", command: "mvn checkstyle:check" },
|
|
9
|
+
{ artifact_id: "spotbugs-maven-plugin", command: "mvn spotbugs:check" },
|
|
10
|
+
{ artifact_id: "maven-pmd-plugin", command: "mvn pmd:check" }
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
DEFAULT_COMMAND = "mvn verify -DskipTests"
|
|
14
|
+
|
|
15
|
+
def initialize(pom_content)
|
|
16
|
+
@pom_content = pom_content
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def lint_command
|
|
20
|
+
match = LINTERS.find { |linter| @pom_content.include?(linter[:artifact_id]) }
|
|
21
|
+
match ? match[:command] : DEFAULT_COMMAND
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "database"
|
|
4
|
+
require_relative "pipeline_recorder"
|
|
5
|
+
require_relative "background_wrapper"
|
|
6
|
+
|
|
7
|
+
module FunCi
|
|
8
|
+
class PipelineForker
|
|
9
|
+
def self.fork_pipeline(commit_hash:, branch:, db_path:)
|
|
10
|
+
pid = fork do
|
|
11
|
+
run_in_child(commit_hash: commit_hash, branch: branch, db_path: db_path)
|
|
12
|
+
end
|
|
13
|
+
Process.detach(pid)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.run_in_child(commit_hash:, branch:, db_path:)
|
|
17
|
+
db = Database.connection(db_path)
|
|
18
|
+
recorder = DbRecorder.new(db)
|
|
19
|
+
Trigger.new(
|
|
20
|
+
project_root: Dir.pwd,
|
|
21
|
+
commit_hash: commit_hash,
|
|
22
|
+
branch: branch,
|
|
23
|
+
stdout: File.open(File::NULL, "w"),
|
|
24
|
+
recorder: recorder,
|
|
25
|
+
background_launcher: method(:sync_launcher)
|
|
26
|
+
).run
|
|
27
|
+
recorder.close
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.sync_launcher(db_path:, pipeline_run_id:, job_id:, executor:)
|
|
31
|
+
recorder = DbRecorder.for_background(db_path, pipeline_run_id)
|
|
32
|
+
BackgroundWrapper.new(recorder: recorder, job_id: job_id, executor: executor).run
|
|
33
|
+
recorder.close
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "pipeline_run"
|
|
4
|
+
require_relative "stage_job"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
class NullRecorder
|
|
8
|
+
def create_run(commit_hash:, branch:, project_path: nil) = nil
|
|
9
|
+
def start_stage(stage) = nil
|
|
10
|
+
def end_stage(job_id, status) = nil
|
|
11
|
+
def complete_run = nil
|
|
12
|
+
def fail_run = nil
|
|
13
|
+
def db = nil
|
|
14
|
+
def db_path = nil
|
|
15
|
+
def pipeline_run_id = nil
|
|
16
|
+
def close = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class DbRecorder
|
|
20
|
+
attr_reader :db, :db_path, :pipeline_run_id
|
|
21
|
+
|
|
22
|
+
def self.for_background(db_path, pipeline_run_id)
|
|
23
|
+
db = Database.connection(db_path)
|
|
24
|
+
new(db, pipeline_run_id: pipeline_run_id)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(db, pipeline_run_id: nil)
|
|
28
|
+
@db = db
|
|
29
|
+
@db_path = db.filename("main")
|
|
30
|
+
@pipeline_run_id = pipeline_run_id
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def close
|
|
34
|
+
@db.close rescue nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def create_run(commit_hash:, branch:, project_path: nil)
|
|
38
|
+
@pipeline_run_id = PipelineRun.create(@db, commit_hash: commit_hash, branch: branch, project_path: project_path)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def start_stage(stage)
|
|
42
|
+
return nil unless @pipeline_run_id
|
|
43
|
+
ensure_running
|
|
44
|
+
job_id = StageJob.create(@db, pipeline_run_id: @pipeline_run_id, stage: stage)
|
|
45
|
+
StageJob.update_status(@db, job_id, "running")
|
|
46
|
+
job_id
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def end_stage(job_id, status)
|
|
50
|
+
return unless job_id
|
|
51
|
+
StageJob.update_status(@db, job_id, status)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def complete_run
|
|
55
|
+
return unless @pipeline_run_id
|
|
56
|
+
PipelineRun.update_status(@db, @pipeline_run_id, "completed")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def fail_run
|
|
60
|
+
return unless @pipeline_run_id
|
|
61
|
+
PipelineRun.update_status(@db, @pipeline_run_id, "failed")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def ensure_running
|
|
67
|
+
run = PipelineRun.find(@db, @pipeline_run_id)
|
|
68
|
+
return unless run && run[:status] == "scheduled"
|
|
69
|
+
PipelineRun.update_status(@db, @pipeline_run_id, "running")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module PipelineRun
|
|
7
|
+
def self.create(db, commit_hash:, branch:, project_path: nil)
|
|
8
|
+
now = Time.now.utc.iso8601
|
|
9
|
+
db.execute(
|
|
10
|
+
"INSERT INTO pipeline_runs (commit_hash, branch, project_path, status, created_at, updated_at) VALUES (?, ?, ?, 'scheduled', ?, ?)",
|
|
11
|
+
[commit_hash, branch, project_path, now, now]
|
|
12
|
+
)
|
|
13
|
+
db.last_insert_row_id
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.find(db, id)
|
|
17
|
+
row = db.execute("SELECT id, commit_hash, branch, status, pid, project_path, created_at, updated_at FROM pipeline_runs WHERE id = ?", [id]).first
|
|
18
|
+
return nil unless row
|
|
19
|
+
row_to_hash(row)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.find_by_branch(db, branch)
|
|
23
|
+
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])
|
|
24
|
+
rows.map { |row| row_to_hash(row) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.find_by_commit(db, commit_hash)
|
|
28
|
+
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])
|
|
29
|
+
rows.map { |row| row_to_hash(row) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.store_pid(db, id, pid)
|
|
33
|
+
db.execute("UPDATE pipeline_runs SET pid = ? WHERE id = ?", [pid, id])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.find_running_with_pid(db, branch)
|
|
37
|
+
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
|
|
38
|
+
return nil unless row
|
|
39
|
+
row_to_hash(row)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.update_status(db, id, new_status)
|
|
43
|
+
now = Time.now.utc.iso8601
|
|
44
|
+
db.execute("UPDATE pipeline_runs SET status = ?, updated_at = ? WHERE id = ?", [new_status, now, id])
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.recent(db, limit: 20)
|
|
48
|
+
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])
|
|
49
|
+
rows.map { |row| row_to_hash(row) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.row_to_hash(row)
|
|
53
|
+
{ 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] }
|
|
54
|
+
end
|
|
55
|
+
private_class_method :row_to_hash
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
class ProgressReporter
|
|
5
|
+
def initialize(stdout:)
|
|
6
|
+
@stdout = stdout
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def phase_one_result(results)
|
|
10
|
+
markers = results.map { |stage, passed| "#{stage} #{result_marker(passed)}" }
|
|
11
|
+
summary = results.values.all? ? "phase 1 passed" : "phase 1 failed"
|
|
12
|
+
@stdout.puts "fun-ci: #{markers.join(" ")} (#{summary})"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def fast_result(passed)
|
|
16
|
+
@stdout.puts "fun-ci: fast #{result_marker(passed)}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def slow_launched
|
|
20
|
+
@stdout.puts "fun-ci: slow (running in background)"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def result_marker(passed)
|
|
26
|
+
passed ? "ok" : "FAIL"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
class ProjectConfig
|
|
5
|
+
REQUIRED_SCRIPTS = %w[lint.sh build.sh fast.sh slow.sh].freeze
|
|
6
|
+
|
|
7
|
+
def initialize(project_root)
|
|
8
|
+
@project_root = project_root
|
|
9
|
+
@fun_ci_dir = File.join(project_root, ".fun-ci")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def folder_exists?
|
|
13
|
+
Dir.exist?(@fun_ci_dir)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def validate
|
|
17
|
+
errors = []
|
|
18
|
+
|
|
19
|
+
unless folder_exists?
|
|
20
|
+
errors << "No .fun-ci/ folder found in #{@project_root}"
|
|
21
|
+
return errors
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
REQUIRED_SCRIPTS.each do |script|
|
|
25
|
+
path = File.join(@fun_ci_dir, script)
|
|
26
|
+
if !File.exist?(path)
|
|
27
|
+
errors << ".fun-ci/#{script} is not found"
|
|
28
|
+
elsif !File.executable?(path)
|
|
29
|
+
errors << ".fun-ci/#{script} is not executable"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
errors
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def script_path(stage)
|
|
37
|
+
File.join(@fun_ci_dir, "#{stage}.sh")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
class ProjectDetector
|
|
5
|
+
def initialize(filenames)
|
|
6
|
+
@filenames = filenames
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def detect
|
|
10
|
+
return :ruby_bundler if @filenames.include?("Gemfile")
|
|
11
|
+
return :jvm_gradle_kotlin if @filenames.include?("build.gradle.kts")
|
|
12
|
+
return :jvm_gradle_groovy if @filenames.include?("build.gradle")
|
|
13
|
+
return :jvm_maven if @filenames.include?("pom.xml")
|
|
14
|
+
|
|
15
|
+
:unknown
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module RelativeTime
|
|
7
|
+
def self.format(iso_timestamp, now: Time.now)
|
|
8
|
+
elapsed = now - Time.parse(iso_timestamp)
|
|
9
|
+
seconds = elapsed.to_i
|
|
10
|
+
|
|
11
|
+
if seconds < 60
|
|
12
|
+
"just now"
|
|
13
|
+
elsif seconds < 3600
|
|
14
|
+
"#{seconds / 60}m ago"
|
|
15
|
+
else
|
|
16
|
+
"#{seconds / 3600}h ago"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ansi"
|
|
4
|
+
require_relative "duration_formatter"
|
|
5
|
+
require_relative "relative_time"
|
|
6
|
+
|
|
7
|
+
module FunCi
|
|
8
|
+
module RowFormatter
|
|
9
|
+
STAGE_NAMES = { "lint" => "Lint", "build" => "Build", "fast" => "Fast", "slow" => "Slow" }.freeze
|
|
10
|
+
STATUS_LABELS = {
|
|
11
|
+
"completed" => "PASSED", "failed" => "FAILED", "timed_out" => "TIMED OUT",
|
|
12
|
+
"running" => "RUNNING", "scheduled" => "Scheduled...", "cancelled" => "CANCELLED"
|
|
13
|
+
}.freeze
|
|
14
|
+
PROJECT_COLORS = %w[31 32 33 34 35 36 91 92 93 94].freeze
|
|
15
|
+
|
|
16
|
+
def self.format(run, now: Time.now, spinner_frame: nil, elapsed_seconds: nil)
|
|
17
|
+
commit = run[:commit_hash][0, 7]
|
|
18
|
+
branch = run[:branch]
|
|
19
|
+
status = run[:status]
|
|
20
|
+
project = format_project(run[:project_path])
|
|
21
|
+
|
|
22
|
+
case status
|
|
23
|
+
when "scheduled"
|
|
24
|
+
format_scheduled(commit, branch, project, run[:updated_at], now)
|
|
25
|
+
when "cancelled"
|
|
26
|
+
format_cancelled(commit, branch, project, run[:stages], run[:updated_at], now)
|
|
27
|
+
else
|
|
28
|
+
stages_text = format_stages(run[:stages], status, spinner_frame, elapsed_seconds)
|
|
29
|
+
status_text = format_status(status)
|
|
30
|
+
time_text = Ansi.dim(RelativeTime.format(run[:updated_at], now: now))
|
|
31
|
+
" #{commit} #{branch}#{project} #{stages_text} #{status_text} #{time_text}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.format_project(project_path)
|
|
36
|
+
return "" unless project_path
|
|
37
|
+
name = File.basename(project_path)
|
|
38
|
+
code = PROJECT_COLORS[name.hash.abs % PROJECT_COLORS.size]
|
|
39
|
+
" \e[#{code}m#{name}#{Ansi::RESET}"
|
|
40
|
+
end
|
|
41
|
+
private_class_method :format_project
|
|
42
|
+
|
|
43
|
+
def self.format_scheduled(commit, branch, project, updated_at, now)
|
|
44
|
+
time_text = RelativeTime.format(updated_at, now: now)
|
|
45
|
+
Ansi.dim(" #{commit} #{branch}#{project} Scheduled... #{time_text}")
|
|
46
|
+
end
|
|
47
|
+
private_class_method :format_scheduled
|
|
48
|
+
|
|
49
|
+
def self.format_cancelled(commit, branch, project, stages, updated_at, now)
|
|
50
|
+
stages_text = stages.map { |s| format_cancelled_stage(s) }.join(" ")
|
|
51
|
+
time_text = RelativeTime.format(updated_at, now: now)
|
|
52
|
+
Ansi.dim(" #{commit} #{branch}#{project} #{stages_text} CANCELLED #{time_text}")
|
|
53
|
+
end
|
|
54
|
+
private_class_method :format_cancelled
|
|
55
|
+
|
|
56
|
+
def self.format_cancelled_stage(stage)
|
|
57
|
+
name = STAGE_NAMES[stage[:stage]]
|
|
58
|
+
if stage[:duration]
|
|
59
|
+
"#{name} #{DurationFormatter.format(stage[:duration])}"
|
|
60
|
+
else
|
|
61
|
+
"#{name} --"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
private_class_method :format_cancelled_stage
|
|
65
|
+
|
|
66
|
+
def self.format_stages(stages, run_status, spinner_frame, elapsed_seconds)
|
|
67
|
+
stages.map { |s| format_stage(s, spinner_frame, elapsed_seconds) }.join(" ")
|
|
68
|
+
end
|
|
69
|
+
private_class_method :format_stages
|
|
70
|
+
|
|
71
|
+
def self.format_stage(stage, spinner_frame, elapsed_seconds)
|
|
72
|
+
name = STAGE_NAMES[stage[:stage]]
|
|
73
|
+
|
|
74
|
+
case stage[:status]
|
|
75
|
+
when "completed"
|
|
76
|
+
time = DurationFormatter.format(stage[:duration])
|
|
77
|
+
Ansi.green("#{name} #{time}")
|
|
78
|
+
when "failed"
|
|
79
|
+
time = DurationFormatter.format(stage[:duration])
|
|
80
|
+
Ansi.bold_red("#{name} FAIL #{time}")
|
|
81
|
+
when "timed_out"
|
|
82
|
+
time = DurationFormatter.format(stage[:duration])
|
|
83
|
+
Ansi.bold_yellow("#{name} TIMEOUT #{time}")
|
|
84
|
+
when "running"
|
|
85
|
+
frame = spinner_frame || "\u2800"
|
|
86
|
+
time = elapsed_seconds ? "#{elapsed_seconds.to_i}s" : "--"
|
|
87
|
+
Ansi.cyan("#{name} #{frame} #{time}")
|
|
88
|
+
else
|
|
89
|
+
Ansi.dim("#{name} --")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
private_class_method :format_stage
|
|
93
|
+
|
|
94
|
+
def self.format_status(status)
|
|
95
|
+
label = STATUS_LABELS[status]
|
|
96
|
+
case status
|
|
97
|
+
when "completed" then Ansi.bold_green(label)
|
|
98
|
+
when "failed" then Ansi.bold_red(label)
|
|
99
|
+
when "timed_out" then Ansi.bold_yellow(label)
|
|
100
|
+
when "running" then Ansi.bold_cyan(label)
|
|
101
|
+
else Ansi.dim(label)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
private_class_method :format_status
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ansi"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
class Screen
|
|
7
|
+
def initialize(output: $stdout, width: 80)
|
|
8
|
+
@output = output
|
|
9
|
+
@width = width
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def width=(new_width)
|
|
13
|
+
return if new_width == @width
|
|
14
|
+
|
|
15
|
+
clear
|
|
16
|
+
@width = new_width
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def render_header(streak_text:)
|
|
20
|
+
title = " fun-ci"
|
|
21
|
+
if streak_text
|
|
22
|
+
streak_display = Ansi.green(streak_text)
|
|
23
|
+
plain_streak = streak_text
|
|
24
|
+
padding = @width - title.length - plain_streak.length
|
|
25
|
+
padding = 1 if padding < 1
|
|
26
|
+
line = "#{title}#{" " * padding}#{streak_display}"
|
|
27
|
+
else
|
|
28
|
+
line = title.ljust(@width)
|
|
29
|
+
end
|
|
30
|
+
println Ansi.bg_charcoal(Ansi.white(line))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def render_footer(empty: false, confirming: false)
|
|
34
|
+
if confirming
|
|
35
|
+
println Ansi.dim(" Cancel running pipeline? y/n")
|
|
36
|
+
elsif empty
|
|
37
|
+
println Ansi.dim(" q quit")
|
|
38
|
+
else
|
|
39
|
+
println Ansi.dim(" j/k move c cancel q quit")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def render_empty_state
|
|
44
|
+
println ""
|
|
45
|
+
println ""
|
|
46
|
+
println " No runs yet."
|
|
47
|
+
println ""
|
|
48
|
+
println " Trigger one: fun-ci trigger HEAD"
|
|
49
|
+
println " Or hook it: fun-ci install-hook pre-push"
|
|
50
|
+
println ""
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def render_board(rows, cursor_index:)
|
|
54
|
+
rows.each_with_index do |row, i|
|
|
55
|
+
if cursor_index == i
|
|
56
|
+
# Replace leading spaces with cursor marker
|
|
57
|
+
println "> #{row.lstrip}"
|
|
58
|
+
else
|
|
59
|
+
println row
|
|
60
|
+
end
|
|
61
|
+
println unless i == rows.length - 1
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def println(text = "")
|
|
66
|
+
@output.print "#{text}\e[K\r\n"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def clear
|
|
70
|
+
@output.print "\e[2J\e[H"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def move_cursor_home
|
|
74
|
+
@output.print "\e[H"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def clear_below
|
|
78
|
+
@output.print "\e[J"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|