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,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module FunCi
6
+ module Console
7
+ # A run as BoardData reads it from SQLite, as the protocol's `board`
8
+ # message carries it: protocol status names, epoch seconds and
9
+ # milliseconds. Formatting them is the renderer's job.
10
+ module RunMessage
11
+ STATUS = { "scheduled" => "pending", "completed" => "passed", "timed_out" => "timeout" }.freeze
12
+
13
+ def self.from(run)
14
+ { id: run[:id], sha: run[:commit_hash], branch: run[:branch], project: run[:project_path],
15
+ status: status(run[:status]), started_at: epoch(run[:created_at]), updated_at: epoch(run[:updated_at]),
16
+ stages: run[:stages].map { |stage| stage(stage) } }.compact
17
+ end
18
+
19
+ def self.stage(stage)
20
+ { stage: stage[:stage], status: status(stage[:status]),
21
+ duration_ms: stage[:duration] && (stage[:duration] * 1000).round,
22
+ started_at: (epoch(stage[:started_at]) if stage[:status] == "running") }.compact
23
+ end
24
+ private_class_method :stage
25
+
26
+ def self.status(name) = STATUS.fetch(name, name)
27
+ private_class_method :status
28
+
29
+ def self.epoch(iso) = iso && Time.parse(iso).to_i
30
+ private_class_method :epoch
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Console
5
+ # The stages whose status differs between two polls of the runs, for the
6
+ # runs both polls saw; none when there was no earlier poll.
7
+ module StageChangeDetector
8
+ Change = Data.define(:run_id, :stage, :from, :to)
9
+
10
+ def self.detect(previous_runs, current_runs)
11
+ current_by_id = current_runs.to_h { |run| [run[:id], run] }
12
+ previous_runs.select { |run| current_by_id.key?(run[:id]) }
13
+ .flat_map { |run| compare_stages(run, current_by_id.fetch(run[:id])) }
14
+ end
15
+
16
+ def self.compare_stages(previous_run, current_run)
17
+ before = statuses(previous_run[:stages])
18
+ current_run[:stages].reject { |stage| before[stage[:stage]] == stage[:status] }
19
+ .map { |stage| change(current_run[:id], stage, before[stage[:stage]]) }
20
+ end
21
+ private_class_method :compare_stages
22
+
23
+ def self.change(run_id, stage, from)
24
+ Change.new(run_id: run_id, stage: stage[:stage], from: from, to: stage[:status])
25
+ end
26
+ private_class_method :change
27
+
28
+ def self.statuses(stages) = (stages || []).to_h { |stage| [stage[:stage], stage[:status]] }
29
+ private_class_method :statuses
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "stage_change_detector"
4
+
5
+ module FunCi
6
+ module Console
7
+ # The protocol `event` messages for the stages that changed since the
8
+ # last poll of the runs: one that failed or timed out, or one that
9
+ # passed. The renderer chooses the animation (renderer-protocol.md).
10
+ class StageEvents
11
+ NAMES = { "failed" => "stage_failed", "timed_out" => "stage_failed", "completed" => "stage_passed" }.freeze
12
+
13
+ def initialize
14
+ @previous = []
15
+ end
16
+
17
+ # None on the first poll, which has nothing to compare with.
18
+ def since_last(runs)
19
+ changes = StageChangeDetector.detect(@previous, runs)
20
+ @previous = runs
21
+ changes.select { |change| NAMES.key?(change.to) }.map { |change| event(change) }
22
+ end
23
+
24
+ private
25
+
26
+ def event(change)
27
+ { t: "event", name: NAMES.fetch(change.to), run_id: change.run_id, stage: change.stage }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Console
5
+ # Why the console stopped, in the console log and, unless the user quit
6
+ # and the renderer exited cleanly, on stderr: the terminal is the
7
+ # renderer's until it exits, so an error is only told once it has.
8
+ class StopReport
9
+ CAUSES = { quit: "the user quit", ended: "the renderer went away" }.freeze
10
+
11
+ # `outcome`: :quit, :ended, or the error that stopped the console.
12
+ def initialize(outcome, status)
13
+ @outcome = outcome
14
+ @status = status
15
+ end
16
+
17
+ # The exit status: 0 once the user has quit, 1 otherwise.
18
+ def tell(log, stderr)
19
+ log.write("console stopped: #{cause}; the renderer exited (#{@status})")
20
+ return 0 if @outcome == :quit && @status.success?
21
+
22
+ stderr.puts "#{failure}; see .fun-ci/console.log"
23
+ 1
24
+ end
25
+
26
+ private
27
+
28
+ def failed? = @outcome.is_a?(Exception)
29
+
30
+ def cause = failed? ? "it failed: #{error}\n#{trace}" : CAUSES.fetch(@outcome)
31
+
32
+ def error = "#{@outcome.class}: #{@outcome.message}"
33
+
34
+ def trace = @outcome.backtrace.map { |line| " #{line}" }.join("\n")
35
+
36
+ def failure = failed? ? "fun-ci console failed (#{error})" : "fun-ci console: the renderer stopped (#{@status})"
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Console
5
+ module StreakCounter
6
+ PASS_STATUS = "completed"
7
+ ACTIVE_STATUSES = %w[running scheduled].freeze
8
+
9
+ # Passed runs since the latest finished run that did not pass; nil
10
+ # while no run has finished.
11
+ def self.count(runs)
12
+ finished = runs.reject { |run| ACTIVE_STATUSES.include?(run[:status]) }
13
+ return nil if finished.empty?
14
+
15
+ finished.take_while { |run| run[:status] == PASS_STATUS }.size
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Console
5
+ # What the user sees of the runs and does with them: KeyHandler's cursor
6
+ # and cancel confirmation, and one page of runs, as many as the terminal
7
+ # fits, scrolled so the run under the cursor is on it (renderer-protocol.md,
8
+ # `board`).
9
+ class View
10
+ # The renderer's 14-row header, a blank line and the footer.
11
+ CHROME_ROWS = 16
12
+ ROWS_PER_RUN = 2
13
+
14
+ def initialize(key_handler:)
15
+ @key_handler = key_handler
16
+ @page_size = 0
17
+ end
18
+
19
+ # The number of runs a terminal of `rows` fits.
20
+ def resize(rows)
21
+ @page_size = [(rows - CHROME_ROWS) / ROWS_PER_RUN, 0].max
22
+ end
23
+
24
+ # :quit when the key ends the session.
25
+ def press(key) = @key_handler.handle_key(key)
26
+
27
+ # `more`: whether the store holds runs beyond `runs`.
28
+ def page(runs, more:)
29
+ first = first_shown
30
+ { cursor: cursor_on_page(first), confirming: @key_handler.confirming?,
31
+ has_more: more || first + @page_size < runs.size, runs: runs[first, @page_size] || [] }
32
+ end
33
+
34
+ private
35
+
36
+ def first_shown
37
+ cursor = @key_handler.cursor_index
38
+ cursor ? [cursor - @page_size + 1, 0].max : 0
39
+ end
40
+
41
+ def cursor_on_page(first)
42
+ cursor = @key_handler.cursor_index
43
+ cursor - first if cursor && @page_size.positive?
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Evidence
5
+ # What is known about a failed stage when its evidence is collected, as a
6
+ # project's command is told it: how it ended, its budget, the stages
7
+ # alongside it, the commit, where it ran, when it started, and where its
8
+ # output is.
9
+ About = Data.define(:stage, :state, :exit_status, :signal, :seconds, :budget, :alongside, :commit, :worktree,
10
+ :started_at, :output)
11
+ end
12
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "about"
5
+
6
+ module FunCi
7
+ module Evidence
8
+ # What is known About a stage, from what was known when it started and
9
+ # how it ended. Its output is written to its directory for a project's
10
+ # command to read.
11
+ module AboutStage
12
+ def self.of(sources, outcome, output)
13
+ About.new(**outcome.to_h.except(:overrun), **started(sources), **where(sources, output),
14
+ stage: sources.stage, budget: sources.budget, commit: sources.commit)
15
+ end
16
+
17
+ def self.where(sources, output)
18
+ { worktree: sources.worktree, output: sources.stage_dir.output_file(output) }
19
+ end
20
+
21
+ def self.started(sources)
22
+ return { seconds: nil, started_at: nil } unless sources.started
23
+
24
+ { seconds: (Time.now - sources.started).round(1), started_at: sources.started.utc.iso8601(3) }
25
+ end
26
+ private_class_method :started, :where
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module FunCi
6
+ module Evidence
7
+ # How much evidence fun-ci keeps (architecture.md, "Evidence of a failed stage"): at
8
+ # most `failures` failures, the rest counted in a fact; then, over
9
+ # `bytes` of JSON, excerpts cut from the last backwards, then messages
10
+ # to their first `message_lines`, each cut marked `truncated`. Facts and
11
+ # problems are small and never cut.
12
+ class Caps
13
+ Limits = Data.define(:bytes, :failures, :message_lines)
14
+ REAL = Limits.new(bytes: 262_144, failures: 100, message_lines: 50)
15
+ # What a line costs in JSON besides its own bytes: quotes and a comma.
16
+ LINE_COST = 3
17
+
18
+ def initialize(limits = REAL)
19
+ @limits = limits
20
+ end
21
+
22
+ def apply(document)
23
+ document = shorter(few_failures(document))
24
+ over(document).positive? ? shorter_messages(document) : document
25
+ end
26
+
27
+ private
28
+
29
+ # Each pass cuts at least a line, so there are never more passes than lines.
30
+ def shorter(document)
31
+ document.excerpts.sum { |excerpt| excerpt[:lines].size }.times do
32
+ break unless over(document).positive?
33
+
34
+ document = shorter_excerpts(document)
35
+ end
36
+ document
37
+ end
38
+
39
+ def over(document) = JSON.generate(document.to_h).bytesize - @limits.bytes
40
+
41
+ def few_failures(document)
42
+ left_out = document.failures.size - @limits.failures
43
+ return document unless left_out.positive?
44
+
45
+ count = { name: "failures not kept", value: left_out.to_s, extractor: "fun-ci" }
46
+ document.with(failures: document.failures.first(@limits.failures), facts: document.facts + [count])
47
+ end
48
+
49
+ def shorter_excerpts(document)
50
+ excess = over(document)
51
+ excerpts = document.excerpts.reverse.map do |excerpt|
52
+ cut, excess = cut_lines(excerpt, excess)
53
+ cut
54
+ end
55
+ document.with(excerpts: excerpts.reverse)
56
+ end
57
+
58
+ # The excerpt without as many of its last lines as cover `excess` bytes, and what is left of `excess`.
59
+ def cut_lines(excerpt, excess)
60
+ lines = excerpt[:lines].dup
61
+ excess -= lines.pop.bytesize + LINE_COST while excess.positive? && lines.any?
62
+ [lines.size == excerpt[:lines].size ? excerpt : excerpt.merge(lines: lines, truncated: true), excess]
63
+ end
64
+
65
+ def shorter_messages(document)
66
+ document.with(failures: document.failures.map { |failure| short_message(failure) })
67
+ end
68
+
69
+ def short_message(failure)
70
+ lines = failure[:message].to_s.lines
71
+ return failure if lines.size <= @limits.message_lines
72
+
73
+ failure.merge(message: lines.first(@limits.message_lines).join.chomp, truncated: true)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "option_check"
4
+ require_relative "presets"
5
+ require_relative "extractors/grep"
6
+ require_relative "extractors/section"
7
+ require_relative "extractors/log_file"
8
+ require_relative "extractors/json_log"
9
+ require_relative "extractors/junit_files"
10
+ require_relative "extractors/command"
11
+ require_relative "extractors/process_tree"
12
+
13
+ module FunCi
14
+ module Evidence
15
+ # The built-in extractors by name, and how an entry of .fun-ci/config
16
+ # becomes one to run (architecture.md, "Evidence of a failed stage"). An entry that can't is
17
+ # Refused, with what is wrong with it.
18
+ module Catalog
19
+ class Refused < StandardError; end
20
+
21
+ # name: what the evidence credits (`grep`, `section:rspec`); on: nil, or "overrun".
22
+ Entry = Data.define(:name, :extractor, :on)
23
+
24
+ BUILT_INS = { "grep" => Extractors::Grep, "section" => Extractors::Section,
25
+ "log-file" => Extractors::LogFile, "json-log" => Extractors::JsonLog,
26
+ "junit-files" => Extractors::JunitFiles, "process-tree" => Extractors::ProcessTree }.freeze
27
+ SHARED = %w[use on preset].freeze
28
+
29
+ def self.entry(raw)
30
+ raise Refused, "an entry must be a mapping with use: or run:, not #{raw.inspect}" unless raw.is_a?(Hash)
31
+
32
+ raw.key?("run") ? command(raw) : built_in(raw)
33
+ end
34
+
35
+ def self.built_in(raw)
36
+ built_in = BUILT_INS[raw["use"]] || raise(Refused, "unknown extractor '#{raw["use"]}'")
37
+ Entry.new(name: [raw["use"], raw["preset"]].compact.join(":"),
38
+ extractor: built_in.new(checked_options(raw, built_in)), on: raw["on"])
39
+ end
40
+
41
+ def self.checked_options(raw, built_in)
42
+ options = preset_options(raw).merge(raw.except(*SHARED))
43
+ mistake = OptionCheck.mistake(raw["use"], built_in, options) || on_mistake(raw)
44
+ raise Refused, mistake if mistake
45
+
46
+ options
47
+ end
48
+
49
+ # A project's own extractor. Whatever the entry says besides run:,
50
+ # format:, watch: and on: is passed on to it.
51
+ def self.command(raw)
52
+ mistake = command_mistake(raw) || on_mistake(raw)
53
+ raise Refused, mistake if mistake
54
+
55
+ Entry.new(name: "run:#{raw["run"]}", extractor: Extractors::Command.new(raw.except("on")), on: raw["on"])
56
+ end
57
+
58
+ def self.command_mistake(raw)
59
+ unless raw["run"].is_a?(String) && !raw["run"].empty?
60
+ return "run: must name a command, not #{raw["run"].inspect}"
61
+ end
62
+ return nil if [nil, "text", "json"].include?(raw["format"])
63
+
64
+ "run:#{raw["run"]}: 'format' must be text or json, not #{raw["format"].inspect}"
65
+ end
66
+
67
+ # The options of the preset an entry names, which its own replace.
68
+ def self.preset_options(raw)
69
+ return {} unless raw.key?("preset")
70
+
71
+ preset = Presets.find(raw["use"], raw["preset"])
72
+ preset ? preset.options : raise(Refused, "#{raw["use"]} has no preset '#{raw["preset"]}'")
73
+ end
74
+
75
+ def self.on_mistake(raw)
76
+ return nil if [nil, "overrun"].include?(raw["on"])
77
+
78
+ "#{raw["use"] || "run:#{raw["run"]}"}: 'on' must be overrun, not #{raw["on"].inspect}"
79
+ end
80
+ private_class_method :built_in, :command, :command_mistake, :checked_options, :preset_options, :on_mistake
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sources"
4
+ require_relative "document"
5
+ require_relative "masking"
6
+ require_relative "settings"
7
+ require_relative "extraction"
8
+ require_relative "findings"
9
+ require_relative "outcome"
10
+ require_relative "stage_context"
11
+ require_relative "detection"
12
+ require_relative "source"
13
+ require_relative "caps"
14
+ require_relative "../persistence/output_tail"
15
+
16
+ module FunCi
17
+ module Evidence
18
+ # Picks out what fun-ci keeps about a stage that failed (architecture.md,
19
+ # "Evidence of a failed stage"): for an overrun, what process-tree and the
20
+ # entries for an overrun found before the kill; the entries configured for
21
+ # the stage and the presets detected, within the budget; then the output's
22
+ # last lines; all masked.
23
+ class Collector
24
+ MONOTONIC = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
25
+ FAILED = Outcome.new(state: "failed")
26
+ # How long past its budget a stage runs while its overrun is looked at.
27
+ GRACE = 2
28
+ NOTHING = Extraction::Result.new(parts: [], problems: [], chosen: [])
29
+
30
+ # commands: runs a project's own extractors (a CommandRunner).
31
+ def initialize(sources, settings: Settings.new(nil), clock: MONOTONIC, commands: nil)
32
+ @sources = sources
33
+ @settings = settings
34
+ @contexts = StageContext.new(sources, clock, commands)
35
+ end
36
+
37
+ def collect(output, outcome = FAILED)
38
+ result = extracted(output, outcome)
39
+ overrun = outcome.overrun || NOTHING
40
+ parts = [["fun-ci", own_facts(outcome.alongside)], *overrun.parts, *result.parts, ["output-tail", tail(output)]]
41
+ Caps.new.apply(masking.document(assemble(parts, [overrun, result])))
42
+ end
43
+
44
+ # What a stage over budget was doing, looked at before the kill.
45
+ def before_kill(pgid)
46
+ Extraction.new(@contexts.before_kill(pgid, GRACE), GRACE)
47
+ .run([[{ "use" => "process-tree" }, "overrun"], *entries("overrun")])
48
+ end
49
+
50
+ # The raw output, masked as the evidence is.
51
+ def masked(output) = masking.mask(output)
52
+
53
+ private
54
+
55
+ # The configured entries, then the detected presets, within the budget.
56
+ def extracted(output, outcome)
57
+ context = @contexts.after(output, outcome, @settings.budget)
58
+ Extraction.new(context, @settings.budget).run(entries(nil) + detected(output, context.deadline))
59
+ end
60
+
61
+ # The candidates' presets whose signatures the output matched, or that have none.
62
+ def detected(output, deadline)
63
+ Detection.chosen(output, @sources.candidates, deadline).map do |found|
64
+ [{ "use" => found.preset.use, "preset" => found.preset.name }, found.because]
65
+ end
66
+ end
67
+
68
+ def assemble(parts, results)
69
+ Document.assemble(parts, problems: results.flat_map(&:problems), chosen: results.flat_map(&:chosen))
70
+ end
71
+
72
+ # The stage's entries that run on `on`: nil, when it fails, or "overrun".
73
+ def entries(on)
74
+ @settings.entries(@sources.stage).select { |raw| on_of(raw) == on }.map { |raw| [raw, "configured"] }
75
+ end
76
+
77
+ def on_of(raw) = raw.is_a?(Hash) ? raw["on"] : nil
78
+
79
+ def own_facts(alongside)
80
+ Findings.new(facts: alongside.empty? ? [] : [{ name: "alongside", value: alongside.join(", ") }])
81
+ end
82
+
83
+ def tail(output)
84
+ text = Persistence::OutputTail.of(output, mask: masking.method(:mask))
85
+ Findings.new(excerpts: [{ title: Document::TAIL_TITLE, location: "output", lines: text.lines(chomp: true) }])
86
+ end
87
+
88
+ def masking
89
+ return Masking.none unless @settings.masking?
90
+
91
+ Masking.new(@sources.environment, patterns: @settings.mask_patterns)
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "findings"
5
+ require_relative "problem"
6
+
7
+ module FunCi
8
+ module Evidence
9
+ # What a project's command printed, as findings: with `format: text`,
10
+ # one excerpt; with `format: json`, the document of `contract/evidence/`, whose fields
11
+ # fun-ci doesn't know are ignored.
12
+ module CommandOutput
13
+ SCHEMA = 1
14
+ FIELDS = { facts: %i[name value], failures: %i[test file line message output],
15
+ excerpts: %i[title location lines] }.freeze
16
+
17
+ def self.text(title, stdout)
18
+ Findings.new(excerpts: [{ title: title, location: "stdout", lines: stdout.lines(chomp: true) }])
19
+ end
20
+
21
+ def self.json(stdout)
22
+ document = parse(stdout)
23
+ schema = document.fetch("schema", SCHEMA)
24
+ raise Problem, "printed schema #{schema}, and this fun-ci reads schema #{SCHEMA}" if schema.to_i > SCHEMA
25
+
26
+ Findings.new(**FIELDS.to_h { |list, keys| [list, items(document, list, keys)] })
27
+ end
28
+
29
+ def self.parse(stdout)
30
+ document = JSON.parse(stdout)
31
+ document.is_a?(Hash) ? document : raise(Problem, "printed JSON that isn't an object")
32
+ rescue JSON::ParserError => e
33
+ raise Problem, "printed JSON that doesn't parse: #{e.message.lines.first.to_s.strip}"
34
+ end
35
+
36
+ def self.items(document, list, keys)
37
+ given = document.fetch(list.to_s, [])
38
+ raise Problem, "printed '#{list}' that isn't a list of objects" unless given.is_a?(Array) && given.all?(Hash)
39
+
40
+ given.map { |item| keys.to_h { |key| [key, item[key.to_s]] }.compact }
41
+ end
42
+ private_class_method :parse, :items
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Evidence
5
+ # Reads a command's stdout in a thread, up to `limit` bytes; past that it
6
+ # calls `stop`, which kills the command.
7
+ class CommandReading
8
+ def self.start(reader, limit, stop) = new(reader, limit, stop).tap(&:begin)
9
+
10
+ def initialize(reader, limit, stop)
11
+ @reader = reader
12
+ @limit = limit
13
+ @stop = stop
14
+ end
15
+
16
+ def begin = @thread = Thread.new { read(+"".b) }
17
+
18
+ # All that was read, once reading ends or the drain is over.
19
+ def finish(drain)
20
+ @thread.join(drain)
21
+ @reader.close
22
+ @thread.value
23
+ end
24
+
25
+ private
26
+
27
+ def read(buffer)
28
+ loop do
29
+ buffer << @reader.readpartial(65_536)
30
+ break @stop.call if buffer.bytesize > @limit
31
+ end
32
+ buffer
33
+ rescue IOError
34
+ buffer
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../pipeline/git_environment"
4
+ require_relative "command_reading"
5
+
6
+ module FunCi
7
+ module Evidence
8
+ # Runs a project's extractor command as stage scripts are run: in the
9
+ # worktree, with the clean git environment, in a process group of its
10
+ # own, killed with everything it started when `seconds` run out or when it
11
+ # prints more than `limit` bytes. Its stdin is a file in `scratch`, and
12
+ # its stderr another. A process that leaves the group (setsid) can't be
13
+ # killed with it, so reading stops once the time and the drain are over.
14
+ class CommandRunner
15
+ Ran = Data.define(:stdout, :stderr, :exit_status, :killed)
16
+ # bytes: the most of stdout kept; drain: seconds to read on after the command ends.
17
+ Limits = Data.define(:bytes, :drain)
18
+ LIMITS = Limits.new(bytes: 262_144, drain: 1)
19
+
20
+ def initialize(dir:, scratch:, limits: LIMITS)
21
+ @dir = dir
22
+ @scratch = scratch
23
+ @limit = limits.bytes
24
+ @drain = limits.drain
25
+ end
26
+
27
+ # env: added to the command's environment for this run.
28
+ def call(command, stdin:, seconds:, env: {})
29
+ File.write(path("context.json"), stdin)
30
+ reader, writer = IO.pipe
31
+ pid = spawn(command, writer, env)
32
+ writer.close
33
+ finish(pid, CommandReading.start(reader, @limit, -> { kill(pid) }), seconds)
34
+ end
35
+
36
+ private
37
+
38
+ def path(name) = File.join(@scratch, name)
39
+
40
+ def spawn(command, writer, env)
41
+ Process.spawn(Pipeline::GitEnvironment::CLEAN.merge(env), command,
42
+ in: path("context.json"), out: writer, err: path("stderr"), pgroup: true, chdir: @dir)
43
+ end
44
+
45
+ def finish(pid, reading, seconds)
46
+ waiter = Process.detach(pid)
47
+ out_of_time = waiter.join(seconds).nil?
48
+ kill(pid) if out_of_time
49
+ status = waiter.value
50
+ stdout = reading.finish(@drain)
51
+ Ran.new(stdout: text(stdout.byteslice(0, @limit)), stderr: text(File.binread(path("stderr"))),
52
+ exit_status: status.exitstatus, killed: killed(out_of_time, stdout))
53
+ end
54
+
55
+ def killed(out_of_time, stdout)
56
+ return :budget if out_of_time
57
+
58
+ stdout.bytesize > @limit ? :overflow : nil
59
+ end
60
+
61
+ # Kills the command and all it started in its group.
62
+ def kill(pid)
63
+ %w[TERM KILL].each { |signal| ignoring_gone { Process.kill(signal, -pid) } }
64
+ end
65
+
66
+ def ignoring_gone
67
+ yield
68
+ rescue Errno::ESRCH, Errno::EPERM
69
+ # The group has gone, or what is left of it is no longer ours.
70
+ end
71
+
72
+ def text(bytes) = bytes.force_encoding(Encoding::UTF_8).scrub("?")
73
+ end
74
+ end
75
+ end