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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: bacc0733c3648d5b5422304a7c319be52f1531e98f1c802e4a3d2a5280928ba9
|
|
4
|
+
data.tar.gz: '097d7eaea4e663259c0b745724e6a4d281af1aa60b0ebb3bd78a14c8add5bb57'
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: feb14f7ff5ba1b67c66b3aa9cddeb9a160320aab302abf9aa60f3cb58b88395e0493abd50dd44db2ddd4030680d0a3c77e99f50cd7408309fe7e3cec9f692163
|
|
7
|
+
data.tar.gz: 8c3cf91d027932fbb998bb3f341cfced2752de43d926a10310f1900ce672de041f75b830379c665b3286daf93bb0c0b0364af47ba1dd116af8c1cc393481ea85
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [1.0.0] - 2026-02-21
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Four-stage local CI pipeline (lint, build, fast, slow) with strict time budgets (30s/30s/10s/5min)
|
|
12
|
+
- Unified CLI (`fun-ci`) with subcommands: trigger, console, init, install-hooks, check
|
|
13
|
+
- Pre-commit hook (background fork, non-blocking) and pre-push hook (synchronous, blocking)
|
|
14
|
+
- TUI dashboard with vim-style navigation (j/k/c/q), pagination, and project path display
|
|
15
|
+
- Project scaffolding with built-in templates for Ruby (Bundler), JVM (Gradle Kotlin/Groovy, Maven)
|
|
16
|
+
- SQLite persistence with WAL mode for pipeline results
|
|
17
|
+
- Parallel lint + build stages with background slow suite
|
|
18
|
+
- Smart Maven linter detection for template generation
|
|
19
|
+
- Stale pipeline auto-cancellation
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Erik T. Madsen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Fun-CI
|
|
2
|
+
|
|
3
|
+
Opinionated local CI that checks your code before it leaves your machine. Runs a four-stage pipeline on every commit with strict time budgets, so your feedback loop stays fast.
|
|
4
|
+
|
|
5
|
+
## How It Works
|
|
6
|
+
|
|
7
|
+
Fun-CI hooks into git and runs your pipeline locally:
|
|
8
|
+
|
|
9
|
+
1. **Lint + Build** (parallel, 30s budget each) -- static analysis and compilation
|
|
10
|
+
2. **Slow suite** (spawned in background, 5min budget) -- integration/end-to-end tests
|
|
11
|
+
3. **Fast suite** (synchronous, 10s budget) -- unit tests
|
|
12
|
+
|
|
13
|
+
On **pre-commit**, the entire pipeline forks to the background so your commit is not blocked. On **pre-push**, lint, build, and fast suite must pass before the push proceeds. The slow suite always runs in the background.
|
|
14
|
+
|
|
15
|
+
Results are stored in a local SQLite database. A terminal dashboard lets you monitor pipeline status across branches.
|
|
16
|
+
|
|
17
|
+
## Getting Started
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
gem install fun_ci
|
|
21
|
+
cd your-project
|
|
22
|
+
fun-ci init --everything
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
This does three things:
|
|
26
|
+
1. Detects your project type and creates `.fun-ci/` with template scripts
|
|
27
|
+
2. Installs pre-commit and pre-push git hooks
|
|
28
|
+
3. Verifies the setup is valid
|
|
29
|
+
|
|
30
|
+
`fun-ci init` has built-in templates for Ruby (Bundler), JVM (Gradle Kotlin, Gradle Groovy, Maven), but Fun-CI works with any project -- just write your own shell scripts.
|
|
31
|
+
|
|
32
|
+
### Manual Setup
|
|
33
|
+
|
|
34
|
+
If you prefer to set things up step by step:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
fun-ci init # Create .fun-ci/ with template scripts
|
|
38
|
+
fun-ci install-hooks # Install git hooks
|
|
39
|
+
fun-ci check # Verify everything is configured
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Then edit the generated scripts in `.fun-ci/` to match your project:
|
|
43
|
+
- `lint.sh` -- linter/static analysis
|
|
44
|
+
- `build.sh` -- build/compile step
|
|
45
|
+
- `fast.sh` -- fast test suite (unit tests)
|
|
46
|
+
- `slow.sh` -- slow test suite (integration tests)
|
|
47
|
+
|
|
48
|
+
Each script receives the commit hash as its first argument.
|
|
49
|
+
|
|
50
|
+
## Commands
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
fun-ci trigger <commit> <branch> Run the full pipeline
|
|
54
|
+
fun-ci trigger --no-validate <commit> <branch> Fork pipeline to background (used by pre-commit)
|
|
55
|
+
fun-ci console Launch the TUI dashboard
|
|
56
|
+
fun-ci init Scaffold .fun-ci/ for detected project type
|
|
57
|
+
fun-ci init --everything init + install-hooks + check in one step
|
|
58
|
+
fun-ci install-hooks Install pre-commit and pre-push hooks
|
|
59
|
+
fun-ci install-hooks pre-commit Install a single hook type
|
|
60
|
+
fun-ci check Verify .fun-ci/ setup
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Git Hooks
|
|
64
|
+
|
|
65
|
+
After `fun-ci install-hooks`, two hooks are active:
|
|
66
|
+
|
|
67
|
+
**pre-commit** -- Runs `fun-ci trigger --no-validate <commit> <branch>`. This forks the pipeline into a background process and returns immediately, so commits are never blocked.
|
|
68
|
+
|
|
69
|
+
**pre-push** -- Runs `fun-ci trigger <commit> <branch>`. This validates the project config, runs lint + build + fast suite synchronously, and blocks the push if any stage fails. The slow suite runs in the background.
|
|
70
|
+
|
|
71
|
+
## Admin Dashboard
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
fun-ci console
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Opens a terminal UI showing pipeline status across all branches. Navigation:
|
|
78
|
+
|
|
79
|
+
- `j` / `k` -- scroll up/down
|
|
80
|
+
- `c` -- cancel a running pipeline
|
|
81
|
+
- `q` -- quit
|
|
82
|
+
|
|
83
|
+
## Time Budgets
|
|
84
|
+
|
|
85
|
+
| Stage | Budget | Blocks |
|
|
86
|
+
|-------|--------|--------|
|
|
87
|
+
| Lint | 30s | push |
|
|
88
|
+
| Build | 30s | push |
|
|
89
|
+
| Fast | 10s | push |
|
|
90
|
+
| Slow | 5min | nothing (background) |
|
|
91
|
+
|
|
92
|
+
If a stage exceeds its budget, it is killed and reported as timed out.
|
|
93
|
+
|
|
94
|
+
## Development
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
bundle install
|
|
98
|
+
rake test
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Changelog
|
|
102
|
+
|
|
103
|
+
See [CHANGELOG.md](CHANGELOG.md) for release history.
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
data/exe/fun-ci
ADDED
data/exe/fun-ci-trigger
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../lib/fun_ci"
|
|
5
|
+
require_relative "../lib/fun_ci/trigger"
|
|
6
|
+
require_relative "../lib/fun_ci/pipeline_recorder"
|
|
7
|
+
|
|
8
|
+
require "tmpdir"
|
|
9
|
+
|
|
10
|
+
db_dir = File.join(Dir.tmpdir, "fun-ci")
|
|
11
|
+
Dir.mkdir(db_dir) unless Dir.exist?(db_dir)
|
|
12
|
+
db_path = File.join(db_dir, "db.sqlite3")
|
|
13
|
+
|
|
14
|
+
db = FunCi::Database.connection(db_path)
|
|
15
|
+
FunCi::Database.migrate!(db)
|
|
16
|
+
|
|
17
|
+
recorder = FunCi::DbRecorder.new(db)
|
|
18
|
+
|
|
19
|
+
exit FunCi::Trigger.run_from_args(ARGV, recorder: recorder)
|
data/exe/fun-ci-tui
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../lib/fun_ci"
|
|
5
|
+
require_relative "../lib/fun_ci/admin_tui"
|
|
6
|
+
|
|
7
|
+
require "tmpdir"
|
|
8
|
+
require "io/console"
|
|
9
|
+
|
|
10
|
+
db_dir = File.join(Dir.tmpdir, "fun-ci")
|
|
11
|
+
Dir.mkdir(db_dir) unless Dir.exist?(db_dir)
|
|
12
|
+
db_path = File.join(db_dir, "db.sqlite3")
|
|
13
|
+
|
|
14
|
+
db = FunCi::Database.connection(db_path)
|
|
15
|
+
FunCi::Database.migrate!(db)
|
|
16
|
+
|
|
17
|
+
tui = FunCi::AdminTui.new(db: db, width_provider: -> { IO.console&.winsize&.dig(1) || 80 })
|
|
18
|
+
tui.run
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "board_data"
|
|
4
|
+
require_relative "row_formatter"
|
|
5
|
+
require_relative "screen"
|
|
6
|
+
require_relative "spinner"
|
|
7
|
+
require_relative "streak_counter"
|
|
8
|
+
require "io/console"
|
|
9
|
+
|
|
10
|
+
module FunCi
|
|
11
|
+
class AdminTui
|
|
12
|
+
FAST_REFRESH = 0.1 # seconds (spinner + timer)
|
|
13
|
+
SLOW_REFRESH = 5.0 # seconds (settled board)
|
|
14
|
+
|
|
15
|
+
def initialize(db:, output: $stdout, input: $stdin, width: 80, width_provider: nil, page_size: nil)
|
|
16
|
+
@board_data = BoardData.new(db, page_size: page_size)
|
|
17
|
+
@screen = Screen.new(output: output, width: width)
|
|
18
|
+
@output = output
|
|
19
|
+
@input = input
|
|
20
|
+
@width_provider = width_provider
|
|
21
|
+
@spinner = Spinner.new
|
|
22
|
+
@cursor_index = nil
|
|
23
|
+
@running = false
|
|
24
|
+
@confirm_cancel = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def render_once
|
|
28
|
+
update_width_from_provider
|
|
29
|
+
runs = @board_data.runs
|
|
30
|
+
streak = @board_data.streak
|
|
31
|
+
streak_text = StreakCounter.format_text(streak)
|
|
32
|
+
|
|
33
|
+
@screen.render_header(streak_text: streak_text)
|
|
34
|
+
@screen.println
|
|
35
|
+
|
|
36
|
+
if runs.empty?
|
|
37
|
+
@screen.render_empty_state
|
|
38
|
+
@screen.render_footer(empty: true)
|
|
39
|
+
else
|
|
40
|
+
rows = runs.map { |run| format_run(run) }
|
|
41
|
+
@screen.render_board(rows, cursor_index: @cursor_index)
|
|
42
|
+
@screen.println
|
|
43
|
+
@screen.render_footer(empty: false, confirming: confirming?)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
@screen.clear_below
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def run
|
|
50
|
+
@running = true
|
|
51
|
+
@screen.clear
|
|
52
|
+
|
|
53
|
+
begin
|
|
54
|
+
setup_raw_mode
|
|
55
|
+
setup_sigwinch_trap
|
|
56
|
+
loop do
|
|
57
|
+
render_frame
|
|
58
|
+
break unless @running
|
|
59
|
+
key = read_key_with_timeout(refresh_interval)
|
|
60
|
+
handle_key(key) if key
|
|
61
|
+
end
|
|
62
|
+
ensure
|
|
63
|
+
restore_terminal
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def resize(new_width)
|
|
68
|
+
@screen.width = new_width
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def confirming?
|
|
72
|
+
!@confirm_cancel.nil?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def confirmation_run
|
|
76
|
+
@confirm_cancel
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def handle_key(key)
|
|
80
|
+
if @confirm_cancel
|
|
81
|
+
handle_confirm_key(key)
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
case key
|
|
86
|
+
when "q"
|
|
87
|
+
@running = false
|
|
88
|
+
when "j", :down
|
|
89
|
+
move_cursor_down
|
|
90
|
+
when "k", :up
|
|
91
|
+
move_cursor_up
|
|
92
|
+
when "c"
|
|
93
|
+
initiate_cancel
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def format_run(run)
|
|
100
|
+
opts = {}
|
|
101
|
+
if run[:status] == "running"
|
|
102
|
+
active_stage = run[:stages].find { |s| s[:status] == "running" }
|
|
103
|
+
if active_stage && active_stage[:started_at]
|
|
104
|
+
opts[:elapsed_seconds] = Time.now - Time.parse(active_stage[:started_at])
|
|
105
|
+
end
|
|
106
|
+
opts[:spinner_frame] = @spinner.current_frame
|
|
107
|
+
end
|
|
108
|
+
RowFormatter.format(run, **opts)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def render_frame
|
|
112
|
+
@screen.move_cursor_home
|
|
113
|
+
@spinner.advance!
|
|
114
|
+
render_once
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def refresh_interval
|
|
118
|
+
runs = @board_data.runs
|
|
119
|
+
any_running = runs.any? { |r| r[:status] == "running" }
|
|
120
|
+
any_running ? FAST_REFRESH : SLOW_REFRESH
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def move_cursor_down
|
|
124
|
+
runs = @board_data.runs
|
|
125
|
+
return if runs.empty?
|
|
126
|
+
|
|
127
|
+
if @cursor_index.nil?
|
|
128
|
+
@cursor_index = 0
|
|
129
|
+
elsif @cursor_index < runs.length - 1
|
|
130
|
+
@cursor_index += 1
|
|
131
|
+
@board_data.load_more if @cursor_index == runs.length - 1
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def move_cursor_up
|
|
136
|
+
return if @cursor_index.nil?
|
|
137
|
+
|
|
138
|
+
if @cursor_index > 0
|
|
139
|
+
@cursor_index -= 1
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def initiate_cancel
|
|
144
|
+
return if @cursor_index.nil?
|
|
145
|
+
|
|
146
|
+
runs = @board_data.runs
|
|
147
|
+
return if @cursor_index >= runs.length
|
|
148
|
+
|
|
149
|
+
run = runs[@cursor_index]
|
|
150
|
+
case run[:status]
|
|
151
|
+
when "scheduled"
|
|
152
|
+
@board_data.cancel_run(run[:id])
|
|
153
|
+
when "running"
|
|
154
|
+
@confirm_cancel = run
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def handle_confirm_key(key)
|
|
159
|
+
case key
|
|
160
|
+
when "y"
|
|
161
|
+
@board_data.cancel_run(@confirm_cancel[:id])
|
|
162
|
+
@confirm_cancel = nil
|
|
163
|
+
when "n", :escape
|
|
164
|
+
@confirm_cancel = nil
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def update_width_from_provider
|
|
169
|
+
return unless @width_provider
|
|
170
|
+
|
|
171
|
+
new_width = @width_provider.call
|
|
172
|
+
resize(new_width) if new_width
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def setup_sigwinch_trap
|
|
176
|
+
return unless @width_provider
|
|
177
|
+
|
|
178
|
+
@signal_read, @signal_write = IO.pipe
|
|
179
|
+
Signal.trap("WINCH") do
|
|
180
|
+
@signal_write.write_nonblock(".") rescue nil
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def setup_raw_mode
|
|
185
|
+
@input.raw! if @input.respond_to?(:raw!)
|
|
186
|
+
rescue Errno::ENOTTY
|
|
187
|
+
# Not a terminal (testing)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def restore_terminal
|
|
191
|
+
@input.cooked! if @input.respond_to?(:cooked!)
|
|
192
|
+
rescue Errno::ENOTTY
|
|
193
|
+
# Not a terminal
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def read_key_with_timeout(timeout)
|
|
197
|
+
return nil unless @input.respond_to?(:read_nonblock)
|
|
198
|
+
|
|
199
|
+
watched = [@input]
|
|
200
|
+
watched << @signal_read if @signal_read
|
|
201
|
+
ready = IO.select(watched, nil, nil, timeout)
|
|
202
|
+
return nil unless ready
|
|
203
|
+
|
|
204
|
+
# Drain signal pipe if it woke us
|
|
205
|
+
if @signal_read && ready[0].include?(@signal_read)
|
|
206
|
+
@signal_read.read_nonblock(1) rescue nil
|
|
207
|
+
return nil unless ready[0].include?(@input)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
byte = @input.read_nonblock(1)
|
|
211
|
+
if byte == "\e"
|
|
212
|
+
# Escape sequence
|
|
213
|
+
seq = @input.read_nonblock(2) rescue ""
|
|
214
|
+
case seq
|
|
215
|
+
when "[A" then :up
|
|
216
|
+
when "[B" then :down
|
|
217
|
+
else :escape
|
|
218
|
+
end
|
|
219
|
+
else
|
|
220
|
+
byte
|
|
221
|
+
end
|
|
222
|
+
rescue IO::WaitReadable, EOFError
|
|
223
|
+
nil
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
data/lib/fun_ci/ansi.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Ansi
|
|
5
|
+
RESET = "\e[0m"
|
|
6
|
+
|
|
7
|
+
def self.green(text) = "\e[32m#{text}#{RESET}"
|
|
8
|
+
def self.bold_green(text) = "\e[1;32m#{text}#{RESET}"
|
|
9
|
+
def self.bold_red(text) = "\e[1;31m#{text}#{RESET}"
|
|
10
|
+
def self.bold_yellow(text) = "\e[1;33m#{text}#{RESET}"
|
|
11
|
+
def self.cyan(text) = "\e[36m#{text}#{RESET}"
|
|
12
|
+
def self.bold_cyan(text) = "\e[1;36m#{text}#{RESET}"
|
|
13
|
+
def self.dim(text) = "\e[2m#{text}#{RESET}"
|
|
14
|
+
def self.white(text) = "\e[37m#{text}#{RESET}"
|
|
15
|
+
def self.bg_charcoal(text) = "\e[48;5;236m#{text}#{RESET}"
|
|
16
|
+
|
|
17
|
+
def self.strip(text)
|
|
18
|
+
text.gsub(/\e\[[0-9;]*[A-Za-z]/, "")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
class BackgroundWrapper
|
|
7
|
+
def initialize(recorder:, job_id:, executor:)
|
|
8
|
+
@recorder = recorder
|
|
9
|
+
@job_id = job_id
|
|
10
|
+
@executor = executor
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run
|
|
14
|
+
_output, status = @executor.call
|
|
15
|
+
if status.success?
|
|
16
|
+
@recorder.end_stage(@job_id, "completed")
|
|
17
|
+
@recorder.complete_run
|
|
18
|
+
else
|
|
19
|
+
@recorder.end_stage(@job_id, "failed")
|
|
20
|
+
@recorder.fail_run
|
|
21
|
+
end
|
|
22
|
+
rescue Timeout::Error
|
|
23
|
+
@recorder.end_stage(@job_id, "timed_out")
|
|
24
|
+
@recorder.fail_run
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "pipeline_run"
|
|
5
|
+
require_relative "stage_job"
|
|
6
|
+
require_relative "streak_counter"
|
|
7
|
+
|
|
8
|
+
module FunCi
|
|
9
|
+
class BoardData
|
|
10
|
+
def initialize(db, limit: 15, page_size: nil)
|
|
11
|
+
@db = db
|
|
12
|
+
@page_size = page_size || limit
|
|
13
|
+
@limit = @page_size
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def load_more
|
|
17
|
+
@limit += @page_size
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def runs
|
|
21
|
+
pipeline_runs = PipelineRun.recent(@db, limit: @limit)
|
|
22
|
+
pipeline_runs.map { |run| enrich_with_stages(run) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def streak
|
|
26
|
+
pipeline_runs = PipelineRun.recent(@db, limit: @limit)
|
|
27
|
+
StreakCounter.count(pipeline_runs)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def cancel_run(run_id)
|
|
31
|
+
PipelineRun.update_status(@db, run_id, "cancelled")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def enrich_with_stages(run)
|
|
37
|
+
rows = @db.execute(
|
|
38
|
+
"SELECT id, pipeline_run_id, stage, status, started_at, completed_at FROM stage_jobs WHERE pipeline_run_id = ? ORDER BY id",
|
|
39
|
+
[run[:id]]
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
stages = rows.map do |row|
|
|
43
|
+
job = { id: row[0], pipeline_run_id: row[1], stage: row[2], status: row[3], started_at: row[4], completed_at: row[5] }
|
|
44
|
+
duration = StageJob.elapsed_duration(job)
|
|
45
|
+
{ stage: job[:stage], status: job[:status], duration: duration, started_at: job[:started_at] }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
run.merge(stages: stages)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/fun_ci/cli.rb
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require_relative "../fun_ci"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
class Cli
|
|
8
|
+
ROUTES = {
|
|
9
|
+
"trigger" => :run_trigger,
|
|
10
|
+
"console" => :run_console,
|
|
11
|
+
"init" => :run_init,
|
|
12
|
+
"install-hooks" => :run_install_hooks,
|
|
13
|
+
"check" => :run_check
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def self.run(args, stdout: $stdout, stderr: $stderr, handlers: {})
|
|
17
|
+
new(stdout: stdout, stderr: stderr, handlers: handlers).run(args)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(stdout:, stderr:, handlers:)
|
|
21
|
+
@stdout = stdout
|
|
22
|
+
@stderr = stderr
|
|
23
|
+
@handlers = handlers
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run(args)
|
|
27
|
+
subcommand = args.first
|
|
28
|
+
return help(0) if %w[-h --help].include?(subcommand)
|
|
29
|
+
return version if subcommand == "--version"
|
|
30
|
+
unless ROUTES.key?(subcommand)
|
|
31
|
+
print_error(subcommand)
|
|
32
|
+
return 1
|
|
33
|
+
end
|
|
34
|
+
dispatch(subcommand, args.drop(1))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def dispatch(subcommand, args)
|
|
40
|
+
return @handlers[subcommand].call(args) if @handlers.key?(subcommand)
|
|
41
|
+
send(ROUTES[subcommand], args)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def run_trigger(args)
|
|
45
|
+
require_relative "trigger"
|
|
46
|
+
require_relative "pipeline_recorder"
|
|
47
|
+
db = setup_db
|
|
48
|
+
recorder = DbRecorder.new(db)
|
|
49
|
+
Trigger.run_from_args(args, stdout: @stdout, stderr: @stderr, recorder: recorder)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def run_console(_args)
|
|
53
|
+
require_relative "admin_tui"
|
|
54
|
+
require "io/console"
|
|
55
|
+
db = setup_db
|
|
56
|
+
tui = AdminTui.new(db: db, width_provider: -> { IO.console&.winsize&.dig(1) || 80 })
|
|
57
|
+
tui.run
|
|
58
|
+
0
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def run_init(args)
|
|
62
|
+
require_relative "installer"
|
|
63
|
+
code = Installer.run(project_root: Dir.pwd, stdout: @stdout)
|
|
64
|
+
return code if code != 0 || !args.include?("--everything")
|
|
65
|
+
code = run_install_hooks([])
|
|
66
|
+
return code unless code == 0
|
|
67
|
+
run_check([])
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def run_install_hooks(args)
|
|
71
|
+
require_relative "hook_writer"
|
|
72
|
+
types = args.any? ? [args.first] : %w[pre-commit pre-push]
|
|
73
|
+
types.each do |type|
|
|
74
|
+
code = HookWriter.run(project_root: Dir.pwd, hook_type: type, stdout: @stdout)
|
|
75
|
+
return code unless code == 0
|
|
76
|
+
end
|
|
77
|
+
0
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def run_check(_args)
|
|
81
|
+
require_relative "setup_checker"
|
|
82
|
+
SetupChecker.run(project_root: Dir.pwd, stdout: @stdout, stderr: @stderr)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def setup_db
|
|
86
|
+
db_dir = File.join(Dir.tmpdir, "fun-ci")
|
|
87
|
+
Dir.mkdir(db_dir) unless Dir.exist?(db_dir)
|
|
88
|
+
db_path = File.join(db_dir, "db.sqlite3")
|
|
89
|
+
db = Database.connection(db_path)
|
|
90
|
+
Database.migrate!(db)
|
|
91
|
+
db
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
HELP_TEXT = <<~HELP
|
|
95
|
+
fun-ci - Opinionated, local-first CI for your projects
|
|
96
|
+
|
|
97
|
+
Usage:
|
|
98
|
+
fun-ci <command> [options]
|
|
99
|
+
|
|
100
|
+
Commands:
|
|
101
|
+
trigger Run CI pipeline for a commit
|
|
102
|
+
console Launch the admin TUI dashboard
|
|
103
|
+
init Initialize .fun-ci/ with template scripts
|
|
104
|
+
install-hooks Install pre-commit and pre-push git hooks
|
|
105
|
+
check Verify project setup
|
|
106
|
+
|
|
107
|
+
Options:
|
|
108
|
+
-h, --help Show this help message
|
|
109
|
+
--version Show version
|
|
110
|
+
|
|
111
|
+
Trigger options:
|
|
112
|
+
--no-validate Fork pipeline to background and return immediately
|
|
113
|
+
|
|
114
|
+
Init options:
|
|
115
|
+
--everything Run init + install-hooks + check in one step
|
|
116
|
+
|
|
117
|
+
Examples:
|
|
118
|
+
fun-ci init --everything
|
|
119
|
+
fun-ci trigger abc1234 main
|
|
120
|
+
fun-ci trigger --no-validate abc1234 main
|
|
121
|
+
fun-ci console
|
|
122
|
+
HELP
|
|
123
|
+
|
|
124
|
+
def help(exit_code)
|
|
125
|
+
@stdout.puts HELP_TEXT
|
|
126
|
+
exit_code
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def version
|
|
130
|
+
@stdout.puts "fun-ci #{FunCi::VERSION}"
|
|
131
|
+
0
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def print_error(subcommand)
|
|
135
|
+
if subcommand
|
|
136
|
+
@stderr.puts "fun-ci: unknown command '#{subcommand}'"
|
|
137
|
+
@stderr.puts ""
|
|
138
|
+
end
|
|
139
|
+
@stderr.puts "Usage: fun-ci <command> [options]"
|
|
140
|
+
@stderr.puts ""
|
|
141
|
+
@stderr.puts "Run 'fun-ci --help' for available commands."
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|