fun_ci 1.2.0 → 2.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.
Files changed (228) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +241 -0
  3. data/README.md +180 -59
  4. data/lib/fun_ci/agent/age.rb +15 -0
  5. data/lib/fun_ci/agent/command_support.rb +27 -0
  6. data/lib/fun_ci/agent/commands.rb +27 -0
  7. data/lib/fun_ci/agent/context.rb +10 -0
  8. data/lib/fun_ci/agent/digest.rb +49 -0
  9. data/lib/fun_ci/agent/events.rb +47 -0
  10. data/lib/fun_ci/agent/events_command.rb +61 -0
  11. data/lib/fun_ci/agent/evidence_text.rb +46 -0
  12. data/lib/fun_ci/agent/exit_code.rb +11 -0
  13. data/lib/fun_ci/agent/git.rb +31 -0
  14. data/lib/fun_ci/agent/live_pipeline.rb +35 -0
  15. data/lib/fun_ci/agent/options.rb +75 -0
  16. data/lib/fun_ci/agent/output.rb +81 -0
  17. data/lib/fun_ci/agent/report_stage.rb +73 -0
  18. data/lib/fun_ci/agent/reports.rb +47 -0
  19. data/lib/fun_ci/agent/run_report.rb +30 -0
  20. data/lib/fun_ci/agent/runs_command.rb +34 -0
  21. data/lib/fun_ci/agent/runs_text.rb +27 -0
  22. data/lib/fun_ci/agent/snapshots.rb +39 -0
  23. data/lib/fun_ci/agent/stage_summary.rb +30 -0
  24. data/lib/fun_ci/agent/status_command.rb +36 -0
  25. data/lib/fun_ci/agent/status_json.rb +27 -0
  26. data/lib/fun_ci/agent/status_text.rb +37 -0
  27. data/lib/fun_ci/agent/system_clock.rb +11 -0
  28. data/lib/fun_ci/agent/verdict.rb +32 -0
  29. data/lib/fun_ci/agent/wait_command.rb +71 -0
  30. data/lib/fun_ci/agent/waiting.rb +32 -0
  31. data/lib/fun_ci/agent/why_command.rb +50 -0
  32. data/lib/fun_ci/agent/why_json.rb +35 -0
  33. data/lib/fun_ci/agent/why_text.rb +43 -0
  34. data/lib/fun_ci/cli.rb +72 -94
  35. data/lib/fun_ci/cli_help.rb +65 -0
  36. data/lib/fun_ci/console/board_data.rb +66 -0
  37. data/lib/fun_ci/console/board_heartbeat.rb +40 -0
  38. data/lib/fun_ci/console/console_events.rb +20 -0
  39. data/lib/fun_ci/console/console_log.rb +26 -0
  40. data/lib/fun_ci/console/console_loop.rb +41 -0
  41. data/lib/fun_ci/console/console_session.rb +82 -0
  42. data/lib/fun_ci/console/console_state.rb +42 -0
  43. data/lib/fun_ci/console/key_handler.rb +73 -0
  44. data/lib/fun_ci/console/launcher.rb +43 -0
  45. data/lib/fun_ci/console/milestone_events.rb +38 -0
  46. data/lib/fun_ci/console/milestones.rb +25 -0
  47. data/lib/fun_ci/console/renderer_lookup.rb +51 -0
  48. data/lib/fun_ci/console/renderer_process.rb +60 -0
  49. data/lib/fun_ci/console/run_message.rb +33 -0
  50. data/lib/fun_ci/console/stage_change_detector.rb +32 -0
  51. data/lib/fun_ci/console/stage_events.rb +31 -0
  52. data/lib/fun_ci/console/stop_report.rb +39 -0
  53. data/lib/fun_ci/console/streak_counter.rb +19 -0
  54. data/lib/fun_ci/console/view.rb +47 -0
  55. data/lib/fun_ci/evidence/about.rb +12 -0
  56. data/lib/fun_ci/evidence/about_stage.rb +29 -0
  57. data/lib/fun_ci/evidence/caps.rb +77 -0
  58. data/lib/fun_ci/evidence/catalog.rb +83 -0
  59. data/lib/fun_ci/evidence/collector.rb +95 -0
  60. data/lib/fun_ci/evidence/command_output.rb +45 -0
  61. data/lib/fun_ci/evidence/command_reading.rb +38 -0
  62. data/lib/fun_ci/evidence/command_runner.rb +75 -0
  63. data/lib/fun_ci/evidence/context.rb +21 -0
  64. data/lib/fun_ci/evidence/context_document.rb +29 -0
  65. data/lib/fun_ci/evidence/deadline.rb +15 -0
  66. data/lib/fun_ci/evidence/detection.rb +82 -0
  67. data/lib/fun_ci/evidence/document.rb +59 -0
  68. data/lib/fun_ci/evidence/extract_command.rb +83 -0
  69. data/lib/fun_ci/evidence/extract_options.rb +45 -0
  70. data/lib/fun_ci/evidence/extraction.rb +54 -0
  71. data/lib/fun_ci/evidence/extractors/command.rb +52 -0
  72. data/lib/fun_ci/evidence/extractors/grep.rb +49 -0
  73. data/lib/fun_ci/evidence/extractors/json_log.rb +45 -0
  74. data/lib/fun_ci/evidence/extractors/junit_files.rb +32 -0
  75. data/lib/fun_ci/evidence/extractors/last_lines.rb +25 -0
  76. data/lib/fun_ci/evidence/extractors/log_file.rb +62 -0
  77. data/lib/fun_ci/evidence/extractors/process_tree.rb +51 -0
  78. data/lib/fun_ci/evidence/extractors/section.rb +49 -0
  79. data/lib/fun_ci/evidence/findings.rb +14 -0
  80. data/lib/fun_ci/evidence/line_ranges.rb +21 -0
  81. data/lib/fun_ci/evidence/line_scan.rb +22 -0
  82. data/lib/fun_ci/evidence/log_record.rb +58 -0
  83. data/lib/fun_ci/evidence/masking.rb +55 -0
  84. data/lib/fun_ci/evidence/option_check.rb +53 -0
  85. data/lib/fun_ci/evidence/outcome.rb +15 -0
  86. data/lib/fun_ci/evidence/patterns.rb +22 -0
  87. data/lib/fun_ci/evidence/presets/README.md +7 -0
  88. data/lib/fun_ci/evidence/presets/bun-test.yml +6 -0
  89. data/lib/fun_ci/evidence/presets/cargo-test.yml +6 -0
  90. data/lib/fun_ci/evidence/presets/dart-test.yml +6 -0
  91. data/lib/fun_ci/evidence/presets/deno-test.yml +6 -0
  92. data/lib/fun_ci/evidence/presets/dotnet-test.yml +6 -0
  93. data/lib/fun_ci/evidence/presets/ecs.yml +3 -0
  94. data/lib/fun_ci/evidence/presets/eslint.yml +6 -0
  95. data/lib/fun_ci/evidence/presets/exunit.yml +6 -0
  96. data/lib/fun_ci/evidence/presets/gcc.yml +6 -0
  97. data/lib/fun_ci/evidence/presets/go-build.yml +5 -0
  98. data/lib/fun_ci/evidence/presets/go-test.yml +6 -0
  99. data/lib/fun_ci/evidence/presets/gradle.yml +6 -0
  100. data/lib/fun_ci/evidence/presets/gtest.yml +6 -0
  101. data/lib/fun_ci/evidence/presets/jest.yml +6 -0
  102. data/lib/fun_ci/evidence/presets/logstash.yml +3 -0
  103. data/lib/fun_ci/evidence/presets/maven.yml +6 -0
  104. data/lib/fun_ci/evidence/presets/minitest.yml +6 -0
  105. data/lib/fun_ci/evidence/presets/mocha.yml +6 -0
  106. data/lib/fun_ci/evidence/presets/mypy.yml +6 -0
  107. data/lib/fun_ci/evidence/presets/node-test.yml +6 -0
  108. data/lib/fun_ci/evidence/presets/phpunit.yml +6 -0
  109. data/lib/fun_ci/evidence/presets/pino.yml +5 -0
  110. data/lib/fun_ci/evidence/presets/prove.yml +6 -0
  111. data/lib/fun_ci/evidence/presets/pytest.yml +6 -0
  112. data/lib/fun_ci/evidence/presets/rspec.yml +6 -0
  113. data/lib/fun_ci/evidence/presets/rubocop.yml +6 -0
  114. data/lib/fun_ci/evidence/presets/ruff.yml +6 -0
  115. data/lib/fun_ci/evidence/presets/rustc.yml +6 -0
  116. data/lib/fun_ci/evidence/presets/shellcheck.yml +5 -0
  117. data/lib/fun_ci/evidence/presets/structlog.yml +4 -0
  118. data/lib/fun_ci/evidence/presets/swift-test.yml +5 -0
  119. data/lib/fun_ci/evidence/presets/tsc.yml +6 -0
  120. data/lib/fun_ci/evidence/presets/unittest.yml +6 -0
  121. data/lib/fun_ci/evidence/presets/vitest.yml +6 -0
  122. data/lib/fun_ci/evidence/presets.rb +30 -0
  123. data/lib/fun_ci/evidence/problem.rb +9 -0
  124. data/lib/fun_ci/evidence/process_table.rb +40 -0
  125. data/lib/fun_ci/evidence/saved_run.rb +22 -0
  126. data/lib/fun_ci/evidence/section_walk.rb +49 -0
  127. data/lib/fun_ci/evidence/settings.rb +64 -0
  128. data/lib/fun_ci/evidence/settings_check.rb +78 -0
  129. data/lib/fun_ci/evidence/source.rb +34 -0
  130. data/lib/fun_ci/evidence/sources.rb +21 -0
  131. data/lib/fun_ci/evidence/stage_context.rb +30 -0
  132. data/lib/fun_ci/evidence/stamp.rb +10 -0
  133. data/lib/fun_ci/evidence/start.rb +36 -0
  134. data/lib/fun_ci/evidence/worktree.rb +62 -0
  135. data/lib/fun_ci/persistence/active_runs.rb +69 -0
  136. data/lib/fun_ci/persistence/database.rb +59 -30
  137. data/lib/fun_ci/persistence/null_recorder.rb +25 -0
  138. data/lib/fun_ci/persistence/output_tail.rb +24 -0
  139. data/lib/fun_ci/persistence/pipeline_recorder.rb +78 -32
  140. data/lib/fun_ci/persistence/pipeline_run.rb +26 -17
  141. data/lib/fun_ci/persistence/project_runs.rb +52 -0
  142. data/lib/fun_ci/persistence/raw_outputs.rb +46 -0
  143. data/lib/fun_ci/persistence/retention.rb +25 -0
  144. data/lib/fun_ci/persistence/run_status.rb +31 -0
  145. data/lib/fun_ci/persistence/stage_job.rb +58 -13
  146. data/lib/fun_ci/persistence/state_dir.rb +26 -0
  147. data/lib/fun_ci/persistence/write_trouble.rb +46 -0
  148. data/lib/fun_ci/pipeline/background_fork.rb +42 -0
  149. data/lib/fun_ci/pipeline/command_executor.rb +58 -0
  150. data/lib/fun_ci/pipeline/gate.rb +35 -0
  151. data/lib/fun_ci/pipeline/git_environment.rb +16 -0
  152. data/lib/fun_ci/pipeline/head_part.rb +40 -0
  153. data/lib/fun_ci/pipeline/in_place.rb +17 -0
  154. data/lib/fun_ci/pipeline/output_window.rb +73 -0
  155. data/lib/fun_ci/pipeline/pipeline_forker.rb +18 -16
  156. data/lib/fun_ci/pipeline/process_runner.rb +90 -17
  157. data/lib/fun_ci/pipeline/run_canceller.rb +75 -0
  158. data/lib/fun_ci/pipeline/slot.rb +30 -0
  159. data/lib/fun_ci/pipeline/slot_lock.rb +13 -0
  160. data/lib/fun_ci/pipeline/slot_run.rb +86 -0
  161. data/lib/fun_ci/pipeline/stage_dir.rb +50 -0
  162. data/lib/fun_ci/pipeline/stage_end.rb +70 -0
  163. data/lib/fun_ci/pipeline/stage_execution.rb +67 -0
  164. data/lib/fun_ci/pipeline/stage_runner.rb +42 -64
  165. data/lib/fun_ci/pipeline/stale_pipeline_canceller.rb +16 -33
  166. data/lib/fun_ci/pipeline/tail_segments.rb +60 -0
  167. data/lib/fun_ci/pipeline/test_report.rb +48 -0
  168. data/lib/fun_ci/pipeline/trigger.rb +55 -115
  169. data/lib/fun_ci/pipeline/trigger_command.rb +62 -0
  170. data/lib/fun_ci/pipeline/trigger_params.rb +48 -0
  171. data/lib/fun_ci/pipeline/workspaces.rb +22 -0
  172. data/lib/fun_ci/pipeline/worktree_pool.rb +66 -0
  173. data/lib/fun_ci/pipeline/worktree_prune.rb +49 -0
  174. data/lib/fun_ci/pipeline/worktrees.rb +45 -0
  175. data/lib/fun_ci/setup/agent_instructions.rb +31 -0
  176. data/lib/fun_ci/setup/commands.rb +49 -0
  177. data/lib/fun_ci/setup/git_hooks.rb +20 -0
  178. data/lib/fun_ci/setup/hook_script.rb +47 -0
  179. data/lib/fun_ci/setup/hook_writer.rb +15 -33
  180. data/lib/fun_ci/setup/installer.rb +39 -23
  181. data/lib/fun_ci/setup/legacy_hooks.rb +38 -0
  182. data/lib/fun_ci/setup/project_config.rb +43 -17
  183. data/lib/fun_ci/setup/project_detector.rb +39 -7
  184. data/lib/fun_ci/setup/settings.rb +36 -0
  185. data/lib/fun_ci/setup/setup_checker.rb +18 -12
  186. data/lib/fun_ci/setup/stage_templates/javascript.rb +18 -0
  187. data/lib/fun_ci/setup/stage_templates/managed.rb +24 -0
  188. data/lib/fun_ci/setup/stage_templates/native.rb +25 -0
  189. data/lib/fun_ci/setup/stage_templates/scripting.rb +27 -0
  190. data/lib/fun_ci/setup/stage_templates/shell.rb +16 -0
  191. data/lib/fun_ci/setup/stage_templates.rb +25 -0
  192. data/lib/fun_ci/setup/template_writer.rb +11 -36
  193. data/lib/fun_ci/version.rb +6 -0
  194. data/lib/fun_ci.rb +1 -3
  195. metadata +212 -43
  196. data/exe/fun-ci-trigger +0 -19
  197. data/exe/fun-ci-tui +0 -23
  198. data/lib/fun_ci/animations/celebrate.rb +0 -210
  199. data/lib/fun_ci/animations/explosion.rb +0 -138
  200. data/lib/fun_ci/animations/flash.rb +0 -205
  201. data/lib/fun_ci/animations/idle.rb +0 -127
  202. data/lib/fun_ci/animations/leprechauns.rb +0 -240
  203. data/lib/fun_ci/animations/running.rb +0 -129
  204. data/lib/fun_ci/animations/success.rb +0 -281
  205. data/lib/fun_ci/animations/yay.rb +0 -169
  206. data/lib/fun_ci/persistence/state_machine.rb +0 -50
  207. data/lib/fun_ci/pipeline/background_wrapper.rb +0 -27
  208. data/lib/fun_ci/tui/admin_tui.rb +0 -90
  209. data/lib/fun_ci/tui/animation.rb +0 -49
  210. data/lib/fun_ci/tui/animation_compositor.rb +0 -107
  211. data/lib/fun_ci/tui/animation_frames.rb +0 -112
  212. data/lib/fun_ci/tui/animation_library.rb +0 -46
  213. data/lib/fun_ci/tui/animation_renderer.rb +0 -144
  214. data/lib/fun_ci/tui/ansi.rb +0 -34
  215. data/lib/fun_ci/tui/board_data.rb +0 -53
  216. data/lib/fun_ci/tui/board_renderer.rb +0 -105
  217. data/lib/fun_ci/tui/duration_formatter.rb +0 -24
  218. data/lib/fun_ci/tui/header_animation_manager.rb +0 -71
  219. data/lib/fun_ci/tui/header_animation_player.rb +0 -45
  220. data/lib/fun_ci/tui/key_handler.rb +0 -86
  221. data/lib/fun_ci/tui/looping_animation_player.rb +0 -45
  222. data/lib/fun_ci/tui/relative_time.rb +0 -22
  223. data/lib/fun_ci/tui/row_formatter.rb +0 -108
  224. data/lib/fun_ci/tui/screen.rb +0 -103
  225. data/lib/fun_ci/tui/spinner.rb +0 -24
  226. data/lib/fun_ci/tui/stage_change_detector.rb +0 -58
  227. data/lib/fun_ci/tui/streak_counter.rb +0 -29
  228. data/lib/fun_ci/tui/terminal_input.rb +0 -69
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ # What `fun-ci --help` and `fun-ci --version` print; each answers the exit code.
5
+ module CliHelp
6
+ def self.help(out)
7
+ out.puts TEXT
8
+ 0
9
+ end
10
+
11
+ def self.version(out)
12
+ out.puts "fun-ci #{FunCi::VERSION}"
13
+ 0
14
+ end
15
+
16
+ TEXT = <<~HELP
17
+ fun-ci - Opinionated, local-first CI for your projects
18
+
19
+ Usage:
20
+ fun-ci <command> [options]
21
+
22
+ Commands:
23
+ trigger Run CI pipeline for a commit
24
+ console Launch the admin TUI dashboard
25
+ init Initialize .fun-ci/ with template scripts
26
+ install-hooks Install post-commit and pre-push git hooks
27
+ check Verify project setup
28
+ prune Remove fun-ci's worktrees when no pipeline is running
29
+ status Say where a commit's run stands, as an exit code an agent can act on
30
+ runs List this project's recent runs, newest first
31
+ wait Wait for a commit's verdict, then exit with it as status does
32
+ events Print this project's runs' events as JSON lines
33
+ why Print everything kept about why a commit's stage failed
34
+ extract Try a stage's evidence extractors on a saved output
35
+
36
+ Options:
37
+ -h, --help Show this help message
38
+ --version Show version
39
+
40
+ Trigger options:
41
+ --background Run the pipeline in the background and return at once
42
+
43
+ Agent options:
44
+ --need LEVEL (status, wait, why) What must pass: build, fast (default) or all
45
+ --within TIME (wait) Give up undecided after 30, 30s or 5m
46
+ --follow-branch (wait) Move on to the newer commit that superseded the run
47
+ -n N (runs) How many runs to list (default 10)
48
+ --branch NAME (runs) Only runs on this branch
49
+ --follow (events) Keep printing events as they happen, until stopped
50
+ --only failures (events) Print only failed stages and superseded runs
51
+ --json Print JSON instead of text (events always print JSON lines)
52
+ Exit codes: 0 passed, 1 failed, 2 over budget, 3 undecided,
53
+ 4 superseded, 5 no run, 64 usage error
54
+
55
+ Init options:
56
+ --everything Run init + install-hooks + check in one step
57
+
58
+ Examples:
59
+ fun-ci init --everything
60
+ fun-ci trigger abc1234 main
61
+ fun-ci trigger --background abc1234 main
62
+ fun-ci console
63
+ HELP
64
+ end
65
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "../persistence/pipeline_run"
5
+ require_relative "../persistence/stage_job"
6
+ require_relative "../persistence/active_runs"
7
+ require_relative "../pipeline/run_canceller"
8
+ require_relative "streak_counter"
9
+
10
+ module FunCi
11
+ module Console
12
+ class BoardData
13
+ def initialize(db, limit: 15, page_size: nil, run_canceller: Pipeline::RunCanceller.new)
14
+ @db = db
15
+ @page_size = page_size || limit
16
+ @limit = @page_size
17
+ @run_canceller = run_canceller
18
+ end
19
+
20
+ def load_more
21
+ @limit += @page_size
22
+ end
23
+
24
+ # Pages by `page_size` from now on, loading at least one such page.
25
+ def resize(page_size)
26
+ @page_size = page_size
27
+ @limit = [@limit, page_size].max
28
+ end
29
+
30
+ # Whether the store holds runs beyond those `runs` loads.
31
+ def more?
32
+ Persistence::PipelineRun.recent(@db, limit: @limit + 1).size > @limit
33
+ end
34
+
35
+ # Records failed each slow suite whose process died (acceptance-tests.md, AT-8.3).
36
+ def record_dead_slow_suites = @run_canceller.record_dead(@db)
37
+
38
+ def runs
39
+ pipeline_runs = Persistence::PipelineRun.recent(@db, limit: @limit)
40
+ pipeline_runs.map { |run| enrich_with_stages(run) }
41
+ end
42
+
43
+ def streak
44
+ pipeline_runs = Persistence::PipelineRun.recent(@db, limit: @limit)
45
+ StreakCounter.count(pipeline_runs)
46
+ end
47
+
48
+ # Stops the run's processes, then records it cancelled. A run that has
49
+ # finished meanwhile is left as it is.
50
+ def cancel_run(run_id)
51
+ Persistence::ActiveRuns.with_id(@db, run_id).each { |run| @run_canceller.cancel(@db, run) }
52
+ end
53
+
54
+ private
55
+
56
+ def enrich_with_stages(run)
57
+ run.merge(stages: Persistence::StageJob.for_run(@db, run[:id]).map { |job| stage(job) })
58
+ end
59
+
60
+ def stage(job)
61
+ { stage: job[:stage], status: job[:status], duration: Persistence::StageJob.elapsed_duration(job),
62
+ started_at: job[:started_at], finished_order: job[:finished_order] }
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Console
5
+ # The port to the renderer, noting in the console log every EVERY seconds
6
+ # how many boards went out and the newest run on the last, so a console
7
+ # that stops showing new runs leaves a record of what Ruby was sending.
8
+ class BoardHeartbeat
9
+ EVERY = 600
10
+
11
+ # `clock` answers seconds; only differences between its answers count.
12
+ def initialize(port, log:, clock:)
13
+ @port = port
14
+ @log = log
15
+ @clock = clock
16
+ @beat = { since: clock.call, boards: 0 }
17
+ end
18
+
19
+ def write(message)
20
+ @port.write(message)
21
+ note(message) if message[:t] == "board"
22
+ end
23
+
24
+ private
25
+
26
+ def note(board)
27
+ now = @clock.call
28
+ @beat = { since: @beat[:since], boards: @beat[:boards] + 1 }
29
+ tell(now, board) if now - @beat[:since] >= EVERY
30
+ end
31
+
32
+ def tell(now, board)
33
+ @log.write("#{@beat[:boards]} boards in the last #{(now - @beat[:since]).round} s; #{newest(board[:runs])}")
34
+ @beat = { since: now, boards: 0 }
35
+ end
36
+
37
+ def newest(runs) = runs.empty? ? "the last showed no runs" : "the newest run on the last: #{runs.first[:id]}"
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "stage_events"
4
+ require_relative "milestone_events"
5
+
6
+ module FunCi
7
+ module Console
8
+ # Every protocol `event` since the last poll of the runs: the stages that
9
+ # changed (for the effects on a stage's column), then the milestones the
10
+ # runs reached (for the header).
11
+ class ConsoleEvents
12
+ def initialize
13
+ @stages = StageEvents.new
14
+ @milestones = MilestoneEvents.new
15
+ end
16
+
17
+ def since_last(runs) = @stages.since_last(runs) + @milestones.since_last(runs)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "time"
5
+
6
+ module FunCi
7
+ module Console
8
+ # The project's .fun-ci/console.log, where the console notes what the
9
+ # renderer got wrong: the terminal is the renderer's, so stderr would
10
+ # draw over it.
11
+ class ConsoleLog
12
+ attr_reader :path
13
+
14
+ def initialize(project_dir:, clock:)
15
+ @path = File.join(project_dir, ".fun-ci", "console.log")
16
+ @clock = clock
17
+ end
18
+
19
+ # Appends `text` as one line, stamped with the clock's time in UTC.
20
+ def write(text)
21
+ FileUtils.mkdir_p(File.dirname(@path))
22
+ File.open(@path, "a") { |file| file.puts("#{@clock.call.utc.iso8601} #{text}") }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "renderer_process"
4
+
5
+ module FunCi
6
+ module Console
7
+ # The console's conversation with its renderer: each line the renderer
8
+ # writes goes to the session, and whenever the renderer has been quiet for
9
+ # POLL_SECONDS the session polls the runs, so what the database holds
10
+ # reaches the screen without waiting for a key.
11
+ class ConsoleLoop
12
+ POLL_SECONDS = 1
13
+
14
+ def initialize(session:, renderer:)
15
+ @session = session
16
+ @renderer = renderer
17
+ end
18
+
19
+ # :quit once the session has quit, :ended if the renderer went away first.
20
+ def run
21
+ @session.start
22
+ loop do
23
+ return :quit if @session.finished?
24
+ return :ended unless step
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ # False once the renderer has closed its output.
31
+ def step
32
+ line = @renderer.next_line(patience: POLL_SECONDS)
33
+ @session.receive(line) if line
34
+ !line.nil?
35
+ rescue RendererProcess::Silent
36
+ @session.refresh
37
+ true
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "console_state"
5
+ require_relative "view"
6
+ require_relative "key_handler"
7
+ require_relative "board_heartbeat"
8
+
9
+ module FunCi
10
+ module Console
11
+ # The console's half of the renderer conversation (renderer-protocol.md):
12
+ # it reads the renderer's lines and writes protocol messages to the port.
13
+ # Ruby decides what is true here; drawing it is the renderer's job.
14
+ class ConsoleSession
15
+ VERSION = 1
16
+ HANDLERS = { "ready" => :ready, "resize" => :resize, "key" => :key, "error" => :renderer_error }.freeze
17
+
18
+ # `log` takes what the renderer got wrong (ConsoleLog), and every ten
19
+ # minutes what the session sent it (BoardHeartbeat).
20
+ def self.build(board_data:, port:, clock:, log:)
21
+ view = View.new(key_handler: KeyHandler.new(board_data: board_data))
22
+ new(state: ConsoleState.new(board_data: board_data, view: view, clock: clock),
23
+ port: BoardHeartbeat.new(port, log: log, clock: clock), log: log)
24
+ end
25
+
26
+ def initialize(state:, port:, log:)
27
+ @state = state
28
+ @port = port
29
+ @log = log
30
+ @phase = :starting
31
+ end
32
+
33
+ def start = @port.write(t: "hello", v: VERSION)
34
+
35
+ # A line the renderer wrote. One it got wrong is logged, never raised.
36
+ def receive(line)
37
+ message = JSON.parse(line)
38
+ message.is_a?(Hash) ? handle(message) : @log.write("the renderer sent a line that is not a message: #{line}")
39
+ rescue JSON::ParserError
40
+ @log.write("the renderer sent a line that is not JSON: #{line.inspect}")
41
+ end
42
+
43
+ # Sends what the runs look like now, as each poll does; nothing before
44
+ # the renderer is ready, since its size decides the page.
45
+ def refresh = @phase == :running && send_updates
46
+
47
+ def finished? = @phase == :finished
48
+
49
+ private
50
+
51
+ def handle(message)
52
+ handler = HANDLERS[message["t"]]
53
+ return @log.write("the renderer sent a message of unknown type #{message["t"].inspect}") unless handler
54
+
55
+ send(handler, message)
56
+ end
57
+
58
+ def ready(message)
59
+ @phase = :running
60
+ resize(message)
61
+ end
62
+
63
+ def resize(message)
64
+ @state.resize(message["rows"])
65
+ send_updates
66
+ end
67
+
68
+ def key(message)
69
+ return send_updates unless @state.press(message["key"]) == :quit
70
+
71
+ @phase = :finished
72
+ @port.write(t: "quit")
73
+ end
74
+
75
+ def renderer_error(message)
76
+ @log.write("the renderer reported a #{message["code"]} error: #{message["detail"]}")
77
+ end
78
+
79
+ def send_updates = @state.updates.each { |update| @port.write(update) }
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "run_message"
4
+ require_relative "console_events"
5
+
6
+ module FunCi
7
+ module Console
8
+ # What the console shows: the page of runs BoardData reads that the View
9
+ # puts on screen, and the clock's time, as a protocol `board` message,
10
+ # after the `event`s for what changed since the last one.
11
+ class ConsoleState
12
+ KEYS = { "up" => :up, "down" => :down, "esc" => :escape, "ctrl_c" => "q", "enter" => :enter }.freeze
13
+
14
+ def initialize(board_data:, view:, clock:)
15
+ @board_data = board_data
16
+ @view = view
17
+ @clock = clock
18
+ @events = ConsoleEvents.new
19
+ end
20
+
21
+ # :quit when the key ends the session.
22
+ def press(key) = @view.press(KEYS.fetch(key, key))
23
+
24
+ # Pages for a terminal of `rows`.
25
+ def resize(rows) = @board_data.resize(@view.resize(rows))
26
+
27
+ def updates
28
+ @board_data.record_dead_slow_suites
29
+ runs = @board_data.runs
30
+ [*@events.since_last(runs), board(runs)]
31
+ end
32
+
33
+ private
34
+
35
+ def board(runs)
36
+ page = @view.page(runs, more: @board_data.more?)
37
+ { t: "board", now: @clock.call.to_i, streak: @board_data.streak, **page,
38
+ runs: page[:runs].map { |run| RunMessage.from(run) } }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Console
5
+ # What a key means for the runs BoardData loads: moving the cursor,
6
+ # quitting, and cancelling the run under the cursor (a running one after
7
+ # the user confirms with `y`).
8
+ class KeyHandler
9
+ CONFIRMATION_ANSWERS = ["y", "n", :escape].freeze
10
+
11
+ attr_reader :cursor_index
12
+
13
+ def initialize(board_data:)
14
+ @board_data = board_data
15
+ @confirm_cancel = nil
16
+ end
17
+
18
+ # :quit when the key ends the session.
19
+ def handle_key(key)
20
+ return confirm(key) if @confirm_cancel
21
+ return :quit if key == "q"
22
+
23
+ act(key)
24
+ end
25
+
26
+ def confirming?
27
+ !@confirm_cancel.nil?
28
+ end
29
+
30
+ def confirmation_run
31
+ @confirm_cancel
32
+ end
33
+
34
+ private
35
+
36
+ def act(key)
37
+ case key
38
+ when "j", :down then move_cursor_down
39
+ when "k", :up then move_cursor_up
40
+ when "c" then initiate_cancel
41
+ end
42
+ end
43
+
44
+ # Onto the last run loaded, which loads the next page.
45
+ def move_cursor_down
46
+ last = @board_data.runs.length - 1
47
+ return if last.negative?
48
+ return @cursor_index = 0 if @cursor_index.nil?
49
+ return unless @cursor_index < last
50
+
51
+ @cursor_index += 1
52
+ @board_data.load_more if @cursor_index == last
53
+ end
54
+
55
+ def move_cursor_up
56
+ @cursor_index -= 1 if @cursor_index&.positive?
57
+ end
58
+
59
+ def initiate_cancel
60
+ run = @cursor_index && @board_data.runs[@cursor_index]
61
+ return unless run
62
+
63
+ @board_data.cancel_run(run[:id]) if run[:status] == "scheduled"
64
+ @confirm_cancel = run if run[:status] == "running"
65
+ end
66
+
67
+ def confirm(key)
68
+ @board_data.cancel_run(@confirm_cancel[:id]) if key == "y"
69
+ @confirm_cancel = nil if CONFIRMATION_ANSWERS.include?(key)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "board_data"
5
+ require_relative "console_log"
6
+ require_relative "console_loop"
7
+ require_relative "console_session"
8
+ require_relative "renderer_process"
9
+ require_relative "stop_report"
10
+
11
+ module FunCi
12
+ module Console
13
+ # `fun-ci console`: starts the renderer, which draws on the terminal, and
14
+ # talks to it about the runs in `db` until the user quits. The renderer's
15
+ # stderr, what it gets wrong, and when and why the console started and
16
+ # stopped go to the project's .fun-ci/console.log.
17
+ module Launcher
18
+ PATIENCE = 5
19
+
20
+ # The exit status: 0 once the user has quit, 1 if the renderer stopped
21
+ # or the console failed.
22
+ def self.run(renderer:, db:, project_dir:, stderr:)
23
+ log = ConsoleLog.new(project_dir: project_dir, clock: -> { Time.now })
24
+ FileUtils.mkdir_p(File.dirname(log.path))
25
+ process = RendererProcess.start([renderer], err: [log.path, "a"])
26
+ log.write("console started; the renderer is pid #{process.pid}")
27
+ outcome = converse(process, BoardData.new(db), log)
28
+ StopReport.new(outcome, process.finish(patience: PATIENCE)).tell(log, stderr)
29
+ end
30
+
31
+ # :quit, :ended, or the error that ended the conversation.
32
+ def self.converse(process, board_data, log)
33
+ session = ConsoleSession.build(board_data: board_data, port: process, clock: -> { Time.now }, log: log)
34
+ ConsoleLoop.new(session: session, renderer: process).run
35
+ rescue Errno::EPIPE
36
+ :ended
37
+ rescue StandardError => e
38
+ e
39
+ end
40
+ private_class_method :converse
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "milestones"
4
+
5
+ module FunCi
6
+ module Console
7
+ # The protocol `event` for each milestone a run reached since the last
8
+ # poll (acceptance-tests.md, AT-7.2). None on the first poll, which has
9
+ # nothing to compare with; none for a cancelled run; and none once a run
10
+ # has failed, so a run fails once however many of its stages fail; and
11
+ # none for an older run that paging brings onto the board.
12
+ class MilestoneEvents
13
+ def initialize
14
+ @previous = nil
15
+ end
16
+
17
+ def since_last(runs)
18
+ events = @previous ? runs.flat_map { |run| new_milestones(run) } : []
19
+ @previous = runs.to_h { |run| [run[:id], Milestones.of(run)] }
20
+ events
21
+ end
22
+
23
+ private
24
+
25
+ def new_milestones(run)
26
+ return [] unless news?(run)
27
+
28
+ before = @previous.fetch(run[:id], [])
29
+ return [] if before.include?("run_failed") || run[:status] == "cancelled"
30
+
31
+ (Milestones.of(run) - before).map { |name| { t: "event", name: name, run_id: run[:id] } }
32
+ end
33
+
34
+ # A run the last poll saw, or one newer than all it saw; run ids only grow.
35
+ def news?(run) = @previous.key?(run[:id]) || run[:id] > @previous.keys.max.to_i
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Console
5
+ # The milestones a run has reached, in the order it reached them
6
+ # (design.md, A run's states): its stages in the order they finished (a
7
+ # stage recorded before that order was kept counts as unordered, and lint,
8
+ # build, fast is the fallback), then the run passing or failing.
9
+ module Milestones
10
+ STAGES = %w[lint build fast].freeze
11
+ OUTCOMES = { "completed" => ["run_passed"], "failed" => ["run_failed"] }.freeze
12
+
13
+ def self.of(run)
14
+ passed = run[:stages].select { |stage| stage[:status] == "completed" && STAGES.include?(stage[:stage]) }
15
+ passed.sort_by { |stage| order(stage) }.map { |stage| "#{stage[:stage]}_passed" } +
16
+ OUTCOMES.fetch(run[:status], [])
17
+ end
18
+
19
+ def self.order(stage)
20
+ [stage[:finished_order].to_i, STAGES.index(stage[:stage])]
21
+ end
22
+ private_class_method :order
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Console
5
+ # Where the renderer binary is (architecture.md, Distribution): the one
6
+ # FUN_CI_RENDERER names, else the one a platform gem bundles in libexec/,
7
+ # else the first on PATH.
8
+ class RendererLookup
9
+ class Missing < StandardError; end
10
+
11
+ NAME = "fun-ci-renderer"
12
+ HOW_TO_GET_ONE = "fun-ci console needs its renderer, #{NAME}, and this installation has none. " \
13
+ "Install it with `cargo install #{NAME}`, or set FUN_CI_RENDERER to its path.".freeze
14
+
15
+ # The installed gem's libexec/, where a platform gem puts the renderer.
16
+ LIBEXEC = File.expand_path("../../../libexec", __dir__)
17
+
18
+ def self.default = new(env: ENV, libexec: LIBEXEC, executable: method(:runnable?))
19
+
20
+ # Whether `path` is a file this user may run.
21
+ def self.runnable?(path) = File.file?(path) && File.executable?(path)
22
+
23
+ def initialize(env:, libexec:, executable:)
24
+ @env = env
25
+ @libexec = libexec
26
+ @executable = executable
27
+ end
28
+
29
+ # The renderer's path. Raises Missing, saying how to get one, if none is found.
30
+ def path
31
+ named = @env["FUN_CI_RENDERER"].to_s
32
+ return runnable(named) unless named.empty?
33
+
34
+ candidates.find { |candidate| @executable.call(candidate) } || raise(Missing, HOW_TO_GET_ONE)
35
+ end
36
+
37
+ private
38
+
39
+ def runnable(named)
40
+ return named if @executable.call(named)
41
+
42
+ raise Missing, "FUN_CI_RENDERER names #{named}, which is not a program fun-ci can run."
43
+ end
44
+
45
+ def candidates
46
+ on_path = @env.fetch("PATH", "").split(File::PATH_SEPARATOR).reject(&:empty?)
47
+ [@libexec, *on_path].map { |dir| File.join(dir, NAME) }
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module FunCi
6
+ module Console
7
+ # The renderer as a child process (renderer-protocol.md): messages go to
8
+ # its stdin as JSON lines and its lines come from its stdout. No wait on
9
+ # it is endless, and one that will not exit is stopped.
10
+ class RendererProcess
11
+ class Silent < StandardError; end
12
+
13
+ # `err` takes the renderer's stderr, which is for diagnostics only.
14
+ def self.start(command, err: File::NULL)
15
+ input, to_renderer = IO.pipe
16
+ from_renderer, output = IO.pipe
17
+ pid = Process.spawn(*command, in: input, out: output, err: err)
18
+ [input, output].each(&:close)
19
+ new(waiter: Process.detach(pid), input: to_renderer, output: from_renderer)
20
+ end
21
+
22
+ def initialize(waiter:, input:, output:)
23
+ @waiter = waiter
24
+ @input = input
25
+ @output = output
26
+ end
27
+
28
+ def pid = @waiter.pid
29
+
30
+ def write(message) = @input.puts(JSON.generate(message))
31
+
32
+ # The renderer's next line, or nil once it has closed its output.
33
+ # Raises Silent when nothing comes within `patience` seconds.
34
+ def next_line(patience:)
35
+ raise Silent, "no word from the renderer in #{patience} s" unless @output.wait_readable(patience)
36
+
37
+ @output.gets&.chomp
38
+ end
39
+
40
+ # Closes the renderer's input, which it takes as `quit`, and answers how
41
+ # it exited; one still running after `patience` seconds is stopped.
42
+ def finish(patience:)
43
+ @input.close unless @input.closed?
44
+ stop(patience) unless @waiter.join(patience)
45
+ @output.close
46
+ @waiter.value
47
+ end
48
+
49
+ private
50
+
51
+ # SIGTERM first, on which the renderer restores the terminal.
52
+ def stop(patience)
53
+ Process.kill("TERM", @waiter.pid)
54
+ Process.kill("KILL", @waiter.pid) unless @waiter.join(patience)
55
+ rescue Errno::ESRCH
56
+ nil
57
+ end
58
+ end
59
+ end
60
+ end