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.
@@ -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