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,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../evidence/outcome"
4
+ require_relative "../evidence/document"
5
+
6
+ module FunCi
7
+ module Pipeline
8
+ # Records a stage that has finished, whichever process ran it: a failed
9
+ # stage's evidence first, so whoever sees the outcome can read why, then
10
+ # how it exited, then the outcome. Called while the stage still holds its
11
+ # slot and stage directory, which the collector reads.
12
+ class StageEnd
13
+ # What running a stage answered: its output, its Process::Status (nil
14
+ # when it was killed over budget), and for an overrun, what was found
15
+ # before the kill.
16
+ Finished = Data.define(:output, :status, :timed_out, :overrun)
17
+
18
+ class Finished
19
+ def initialize(overrun: nil, **) = super
20
+ end
21
+
22
+ # A failed stage's state in the words `status --json` uses.
23
+ STATES = { "failed" => "failed", "timed_out" => "over_budget" }.freeze
24
+
25
+ def initialize(recorder, job_id, collector)
26
+ @recorder = recorder
27
+ @job_id = job_id
28
+ @collector = collector
29
+ end
30
+
31
+ # Answers the outcome recorded: completed, failed or timed_out.
32
+ def record(finished)
33
+ result = outcome(finished)
34
+ keep_evidence(finished) unless result == "completed"
35
+ keep_exit(finished.status) if finished.status
36
+ @recorder.end_stage(@job_id, result)
37
+ result
38
+ end
39
+
40
+ private
41
+
42
+ def outcome(finished)
43
+ return "timed_out" if finished.timed_out
44
+
45
+ finished.status.success? ? "completed" : "failed"
46
+ end
47
+
48
+ # Whatever goes wrong collecting the evidence is kept as a problem, and
49
+ # the outcome is recorded all the same.
50
+ def keep_evidence(finished)
51
+ @recorder.keep_evidence(@job_id, @collector.collect(finished.output, ending(finished)))
52
+ @recorder.keep_raw(@job_id, @collector.masked(finished.output))
53
+ rescue StandardError => e
54
+ @recorder.keep_evidence(@job_id, Evidence::Document.broken("#{e.class}: #{e.message}"))
55
+ end
56
+
57
+ def ending(finished)
58
+ status = finished.status
59
+ Evidence::Outcome.new(state: STATES.fetch(outcome(finished)), exit_status: status&.exitstatus,
60
+ signal: status&.termsig && Signal.signame(status.termsig),
61
+ alongside: @recorder.alongside(@job_id), overrun: finished.overrun)
62
+ end
63
+
64
+ def keep_exit(status)
65
+ signal = status.termsig && Signal.signame(status.termsig)
66
+ @recorder.keep_exit(@job_id, status.exitstatus, signal)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "stage_end"
4
+ require_relative "../evidence/start"
5
+
6
+ module FunCi
7
+ module Pipeline
8
+ # Runs a stage that has been started, in `dir`, with a stage directory of
9
+ # its own, and records how it ended while it still holds both. The same
10
+ # for every stage, the slow suite in its forked child included.
11
+ class StageExecution
12
+ def initialize(seams:, dir:, commit:)
13
+ @seams = seams
14
+ @dir = dir
15
+ @commit = commit
16
+ end
17
+
18
+ # Answers [the outcome recorded, what the stage printed].
19
+ def run(stage, command, recorder, job_id)
20
+ with_stage_dir do |stage_dir|
21
+ collector = collector(stage, stage_dir)
22
+ finished = finished(stage, command, stage_dir, collector) { |pid| recorder.stage_process(job_id, pid) }
23
+ [StageEnd.new(recorder, job_id, collector).record(finished), finished.output]
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ # A stage over budget is looked at before the kill (acceptance-tests.md, AT-10.17).
30
+ def finished(stage, command, stage_dir, collector, &)
31
+ overrun = nil
32
+ output, status, timed_out = execute(stage, command, stage_dir, lambda { |pgid|
33
+ overrun = collector.before_kill(pgid)
34
+ },
35
+ &)
36
+ StageEnd::Finished.new(output: output, status: status, timed_out: timed_out, overrun: overrun)
37
+ end
38
+
39
+ # The stage learns its name from FUN_CI_STAGE (acceptance-tests.md, AT-10.7).
40
+ def execute(stage, command, stage_dir, before_kill, &)
41
+ window = stage_dir.window
42
+ launch = ProcessRunner::Launch.new(env: { "FUN_CI_STAGE" => stage }, output: window,
43
+ before_kill: before_kill)
44
+ @seams.executor(@dir).call(command, @seams.budgets[stage], launch, &)
45
+ ensure
46
+ window&.close
47
+ end
48
+
49
+ def with_stage_dir
50
+ stage_dir = @seams.stage_dir.call
51
+ yield stage_dir
52
+ ensure
53
+ stage_dir&.remove
54
+ end
55
+
56
+ # Made before the stage runs, which is when its watched files are stamped.
57
+ def collector(stage, stage_dir)
58
+ sources = Evidence::Sources.new(stage: stage, worktree: @dir, stage_dir: stage_dir,
59
+ environment: @seams.environment,
60
+ budget: @seams.budgets[stage], commit: @commit.to_h, started: Time.now,
61
+ processes: @seams.process_table)
62
+ commands = @seams.extractor_runner.call(dir: @dir, scratch: stage_dir.scratch)
63
+ Evidence::Start.collector(sources, @seams.clock, commands)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -1,85 +1,63 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "timeout"
4
- require_relative "process_runner"
3
+ require_relative "trigger_params"
4
+ require_relative "stage_execution"
5
5
 
6
6
  module FunCi
7
7
  module Pipeline
8
8
  class StageRunner
9
- include ProcessRunner
10
-
11
- def initialize(commit_hash:, stdout:, command_runner: nil, time_budgets: {}, recorder: FunCi::NullRecorder.new)
12
- @commit_hash = commit_hash
9
+ LABELS = { "lint" => "Lint", "build" => "Build", "fast" => "Fast suite", "slow" => "Slow suite" }.freeze
10
+ ADVICE = {
11
+ "lint" => "Trim your linter config or split into stages.",
12
+ "build" => "Keep your build efficient and not let it become a bottleneck.",
13
+ "fast" => "Your fast tests have gotten too slow. Split or speed them up.",
14
+ "slow" => "Pare down integration tests, parallelise, or raise the budget."
15
+ }.freeze
16
+ FIX_TESTS = "Fix the failing tests above, then try again."
17
+ NEXT_STEPS = {
18
+ "lint" => "Fix what the linter reported above, then try again.",
19
+ "build" => "Fix the build errors above, then try again.",
20
+ "fast" => FIX_TESTS,
21
+ "slow" => FIX_TESTS
22
+ }.freeze
23
+
24
+ def initialize(commit:, stdout:, seams: Seams.new, dir: Dir.pwd)
25
+ @commit = commit
13
26
  @stdout = stdout
14
- @command_runner = command_runner
15
- @time_budgets = time_budgets
16
- @recorder = recorder
27
+ @seams = seams
28
+ @dir = dir
17
29
  end
18
30
 
19
- def run_stage(config, stage)
20
- script = config.script_path(stage)
21
- budget = @time_budgets[stage]
22
-
23
- job_id = @recorder.start_stage(stage)
24
- output, status, timed_out = run_with_timeout(script, budget)
25
-
26
- if timed_out
27
- @recorder.end_stage(job_id, "timed_out")
28
- @stdout.puts "#{stage_label(stage)} killed -- exceeded #{budget}s time budget."
29
- @stdout.puts budget_advice(stage)
30
- return false
31
- end
32
-
33
- unless status.success?
34
- @recorder.end_stage(job_id, "failed")
35
- @stdout.puts output unless output.empty?
36
- @stdout.puts "#{stage_label(stage)} failed."
37
- return false
38
- end
39
-
40
- @recorder.end_stage(job_id, "completed")
41
- true
31
+ # A failed stage's evidence is kept before its outcome is recorded, so
32
+ # whoever sees the outcome can read why.
33
+ def passes?(config, stage)
34
+ job_id = @seams.recorder.start_stage(stage, budget: @seams.budgets[stage])
35
+ command = config.stage_command(stage, @commit.sha)
36
+ result, output = StageExecution.new(seams: @seams, dir: @dir, commit: @commit).run(stage, command,
37
+ @seams.recorder, job_id)
38
+ tell(stage, output, result)
39
+ result == "completed"
42
40
  end
43
41
 
44
42
  private
45
43
 
46
- def run_with_timeout(script, budget)
47
- cmd = "#{script} #{@commit_hash}"
48
-
49
- if @command_runner
50
- begin
51
- output, status = @command_runner.call(cmd)
52
- [output, status, false]
53
- rescue Timeout::Error
54
- ["", nil, true]
55
- end
56
- else
57
- run_process_with_timeout(cmd, budget)
58
- end
44
+ def tell(stage, output, result)
45
+ report_timeout(stage) if result == "timed_out"
46
+ report_failure(stage, output) if result == "failed"
59
47
  end
60
48
 
61
- def stage_label(stage)
62
- case stage
63
- when "lint" then "Lint"
64
- when "build" then "Build"
65
- when "fast" then "Fast suite"
66
- when "slow" then "Slow suite"
67
- else stage
68
- end
49
+ def report_timeout(stage)
50
+ @stdout.puts "#{label(stage)} killed -- exceeded #{@seams.budgets[stage]}s time budget."
51
+ @stdout.puts ADVICE[stage]
69
52
  end
70
53
 
71
- def budget_advice(stage)
72
- case stage
73
- when "lint"
74
- "Trim your linter config or split into stages."
75
- when "build"
76
- "Keep your build efficient and not let it become a bottleneck."
77
- when "fast"
78
- "Your fast tests have gotten too slow. Split or speed them up."
79
- when "slow"
80
- "Pare down integration tests, parallelise, or raise the budget."
81
- end
54
+ def report_failure(stage, output)
55
+ @stdout.puts output unless output.empty?
56
+ @stdout.puts "#{label(stage)} failed."
57
+ @stdout.puts NEXT_STEPS[stage]
82
58
  end
59
+
60
+ def label(stage) = LABELS.fetch(stage, stage)
83
61
  end
84
62
  end
85
63
  end
@@ -1,52 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../persistence/pipeline_run"
3
+ require_relative "../persistence/active_runs"
4
+ require_relative "run_canceller"
4
5
 
5
6
  module FunCi
6
7
  module Pipeline
8
+ # A newer commit on a branch cancels every run still going for an older
9
+ # one, unless an agent is waiting on it: its processes are stopped and it
10
+ # is recorded cancelled (acceptance-tests.md, AT-1.6, AT-9.12).
7
11
  class StalePipelineCanceller
8
- def initialize(db:, branch:, stdout:, process_killer: nil)
12
+ def initialize(db:, branch:, stdout:, run_canceller: RunCanceller.new)
9
13
  @db = db
10
14
  @branch = branch
11
15
  @stdout = stdout
12
- @process_killer = process_killer || method(:default_process_killer)
16
+ @run_canceller = run_canceller
13
17
  end
14
18
 
15
- def cancel(new_commit_hash:)
16
- run = Persistence::PipelineRun.find_running_with_pid(@db, @branch)
17
- return unless run
18
-
19
- kill_process(run[:pid])
20
- Persistence::PipelineRun.update_status(@db, run[:id], "cancelled")
21
- @stdout.puts "Cancelled stale pipeline for #{run[:commit_hash]}. Starting fresh for #{new_commit_hash}."
22
- end
19
+ # How long an agent's last poll keeps a run it waits on from being cancelled.
20
+ WAITED_ON_SECONDS = 10
23
21
 
24
- def store_pid(run_id, pid)
25
- Persistence::PipelineRun.store_pid(@db, run_id, pid)
22
+ def cancel(new_commit_hash:)
23
+ waited_before = (Time.now - WAITED_ON_SECONDS).utc.iso8601
24
+ Persistence::ActiveRuns.cancellable(@db, @branch, new_commit: new_commit_hash, waited_before: waited_before)
25
+ .each { |run| cancel_run(run, new_commit_hash) }
26
26
  end
27
27
 
28
28
  private
29
29
 
30
- def kill_process(pid)
31
- @process_killer.call(0, pid)
32
- safe_signal("TERM", pid)
33
- safe_signal("KILL", pid)
34
- begin
35
- Process.waitpid(pid)
36
- rescue Errno::ECHILD, Errno::ESRCH # rubocop:disable Lint/SuppressedException
37
- end
38
- rescue Errno::ESRCH
39
- # Process already dead
40
- end
41
-
42
- def safe_signal(signal, pid)
43
- @process_killer.call(signal, pid)
44
- rescue Errno::ESRCH
45
- # Already dead
46
- end
47
-
48
- def default_process_killer(signal, pid)
49
- Process.kill(signal, pid)
30
+ def cancel_run(run, new_commit_hash)
31
+ @run_canceller.cancel(@db, run)
32
+ @stdout.puts "Cancelled stale pipeline for #{run.commit_hash}. Starting fresh for #{new_commit_hash}."
50
33
  end
51
34
  end
52
35
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Pipeline
5
+ # The last `size` bytes of a stream, kept in two IOs of `size` bytes that
6
+ # take turns: when one fills, the other is emptied and written next.
7
+ class TailSegments
8
+ def initialize(open, size)
9
+ @open = open
10
+ @size = size
11
+ @filled = 0
12
+ @segments = nil
13
+ end
14
+
15
+ def <<(bytes)
16
+ until bytes.empty?
17
+ rotate if @filled == @size
18
+ part = bytes.byteslice(0, @size - @filled)
19
+ segments.last.write(part)
20
+ @filled += part.bytesize
21
+ bytes = bytes.byteslice(part.bytesize..)
22
+ end
23
+ end
24
+
25
+ def read
26
+ return +"".b unless @segments
27
+
28
+ earlier(@size - @filled) + whole(segments.last)
29
+ end
30
+
31
+ def close = @segments&.each(&:close)
32
+
33
+ private
34
+
35
+ def segments = @segments ||= [@open.call("tail-0"), @open.call("tail-1")]
36
+
37
+ def rotate
38
+ @segments = segments.reverse
39
+ segments.last.truncate(0)
40
+ segments.last.rewind
41
+ @filled = 0
42
+ end
43
+
44
+ # The earlier segment is full once the tail has wrapped; before that,
45
+ # nothing came earlier.
46
+ def earlier(count)
47
+ io = segments.first
48
+ return +"".b if count.zero? || io.size < @size
49
+
50
+ io.seek(io.size - count)
51
+ io.read(count)
52
+ end
53
+
54
+ def whole(io)
55
+ io.rewind
56
+ io.read
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rexml/document"
4
+
5
+ module FunCi
6
+ module Pipeline
7
+ # The failures a JUnit XML report names (acceptance-tests.md, AT-9.7),
8
+ # each as { file:, line:, test:, message: }, with output: when the failure
9
+ # kept its own (AT-10.14), its last 4 KB; nil for a report that can't be
10
+ # read.
11
+ module TestReport
12
+ OUTPUT_BYTES = 4096
13
+
14
+ def self.junit(xml)
15
+ REXML::XPath.match(REXML::Document.new(xml), "//testcase[failure or error]").map { |test| junit_failure(test) }
16
+ rescue REXML::ParseException
17
+ nil
18
+ end
19
+
20
+ def self.junit_failure(test)
21
+ with_output({ file: test.attributes["file"], line: test.attributes["line"]&.to_i, test: junit_name(test),
22
+ message: junit_message(test.elements["failure"] || test.elements["error"]) },
23
+ junit_output(test))
24
+ end
25
+
26
+ # The failure's text, or its message attribute when it has none.
27
+ def self.junit_message(problem)
28
+ text = problem.text.to_s.strip
29
+ text.empty? ? problem.attributes["message"].to_s : text
30
+ end
31
+
32
+ def self.junit_name(test)
33
+ [test.attributes["classname"], test.attributes["name"]].compact.join("#")
34
+ end
35
+
36
+ def self.junit_output(test)
37
+ %w[system-out system-err].filter_map { |name| test.elements[name]&.text&.strip }.reject(&:empty?).join("\n")
38
+ end
39
+
40
+ def self.with_output(failure, output)
41
+ return failure if output.empty?
42
+
43
+ failure.merge(output: output.byteslice([output.bytesize - OUTPUT_BYTES, 0].max..).scrub(""))
44
+ end
45
+ private_class_method :junit_failure, :junit_message, :junit_name, :junit_output, :with_output
46
+ end
47
+ end
48
+ end
@@ -1,152 +1,92 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "open3"
4
3
  require_relative "../setup/project_config"
5
4
  require_relative "../persistence/pipeline_recorder"
6
- require_relative "background_wrapper"
7
- require_relative "stage_runner"
5
+ require_relative "trigger_params"
6
+ require_relative "trigger_command"
7
+ require_relative "slot_run"
8
8
  require_relative "stale_pipeline_canceller"
9
- require_relative "progress_reporter"
10
- require_relative "pipeline_forker"
11
- require_relative "process_runner"
9
+ require_relative "workspaces"
12
10
 
13
11
  module FunCi
14
12
  module Pipeline
15
13
  class Trigger
16
- include ProcessRunner
17
- NULL_SHA = ("0" * 40).freeze
18
- DEFAULT_BUDGETS = { "lint" => 30, "build" => 30, "fast" => 10, "slow" => 300 }.freeze
19
-
20
- def self.run_from_args(args, stdout: $stdout, stderr: $stderr, recorder: Persistence::NullRecorder.new, pipeline_forker: nil)
21
- positional = args.reject { |a| a.start_with?("--") }
22
- if positional.length < 2
23
- stderr.puts "fun-ci: commit hash and branch name are required."
24
- stderr.puts "Usage: fun-ci trigger <commit-hash> <branch>"
25
- return 1
26
- end
27
- commit_hash, branch = positional
28
- if args.include?("--no-validate")
29
- db_path = recorder.db_path
30
- recorder.close
31
- (pipeline_forker || PipelineForker.method(:fork_pipeline)).call(
32
- commit_hash: commit_hash, branch: branch, db_path: db_path
33
- )
34
- return 0
35
- end
36
- new(project_root: Dir.pwd, commit_hash: commit_hash, branch: branch,
37
- stdout: stdout, stderr: stderr, recorder: recorder).run
14
+ NULL_SHA = Workspaces::NULL_SHA
15
+
16
+ def self.run_from_args(args, io: Io.new, recorder: Persistence::NullRecorder.new, pipeline_forker: nil)
17
+ TriggerCommand.new(io: io, recorder: recorder, pipeline_forker: pipeline_forker).run(args)
38
18
  end
39
19
 
40
- attr_writer :command_runner
41
-
42
- def initialize(project_root:, commit_hash:, branch:, stdout: $stdout, stderr: $stderr, command_runner: nil, time_budgets: {}, commit_validator: nil, recorder: Persistence::NullRecorder.new, background_launcher: nil)
43
- @project_root = project_root
44
- @commit_hash = commit_hash
45
- @branch = branch
46
- @stdout = stdout
47
- @stderr = stderr
48
- @command_runner = command_runner
49
- @time_budgets = DEFAULT_BUDGETS.merge(time_budgets)
50
- @commit_validator = commit_validator || method(:default_commit_validator)
51
- @recorder = recorder
52
- @background_launcher = background_launcher || method(:default_background_launcher)
20
+ def initialize(project:, commit:, io: Io.new, seams: Seams.new)
21
+ @project = project
22
+ @commit = commit
23
+ @io = io
24
+ @seams = seams
53
25
  end
54
26
 
55
27
  def run
56
- config = Setup::ProjectConfig.new(@project_root)
28
+ config = Setup::ProjectConfig.new(@project)
57
29
  return handle_config_errors(config) if config.validate.any?
58
- unless @commit_hash == NULL_SHA || @commit_validator.call(@commit_hash)
59
- @stderr.puts "fun-ci: commit #{@commit_hash} not found in this repository."
60
- return 1
61
- end
62
- cancel_stale_pipelines
63
- @recorder.create_run(commit_hash: @commit_hash, branch: @branch, project_path: @project_root)
64
- stage_runner = make_stage_runner
65
- progress = ProgressReporter.new(stdout: @stdout)
66
- results = run_phase_one(stage_runner, config)
67
- progress.phase_one_result(results)
68
- unless results.values.all?
69
- @recorder.fail_run
70
- return 1
71
- end
72
- spawn_slow_suite(config)
73
- progress.slow_launched
74
- fast_runner = make_stage_runner
75
- fast_passed = fast_runner.run_stage(config, "fast")
76
- progress.fast_result(fast_passed)
77
- unless fast_passed
78
- @recorder.fail_run
79
- return 1
80
- end
81
- 0
30
+ return unknown_commit unless known_commit?
31
+
32
+ start_run
33
+ run_in(workspace.acquire(@commit.sha))
34
+ end
35
+
36
+ # The background launcher swaps in a fresh recorder after forking, so
37
+ # callers release the connection through the trigger, not their own copy.
38
+ def close
39
+ recorder.close
82
40
  end
83
41
 
84
42
  private
85
43
 
44
+ def recorder = @seams.recorder
45
+ def known_commit? = @commit.sha == NULL_SHA || @seams.commit_validator.call(@commit.sha)
46
+
47
+ def workspace = @seams.workspace || Workspaces.for(@project, @commit.sha)
48
+ def branch = Persistence::Branch.new(project: @project, name: @commit.branch)
49
+
50
+ # The scripts come from the commit when it has them, so they match the
51
+ # code they test; a project that keeps .fun-ci/ out of git uses its own.
52
+ def config_for(slot)
53
+ committed = Setup::ProjectConfig.new(slot.path)
54
+ committed.folder_exists? ? committed : Setup::ProjectConfig.new(@project)
55
+ end
56
+
86
57
  def handle_config_errors(config)
87
- config.validate.each { |e| @stdout.puts "fun-ci: #{e}" }
58
+ config.validate.each { |e| @io.stdout.puts "fun-ci: #{e}" }
88
59
  unless config.folder_exists?
89
- @stdout.puts "Create .fun-ci/lint.sh, build.sh, fast.sh, and slow.sh to set up this project."
90
- @stdout.puts "Commit will proceed without CI."
60
+ @io.stdout.puts "Create .fun-ci/lint.sh, build.sh, fast.sh, and slow.sh to set up this project."
61
+ @io.stdout.puts "Commit will proceed without CI."
91
62
  end
92
63
  0
93
64
  end
94
65
 
95
- def run_phase_one(stage_runner, config)
96
- results = {}
97
- threads = %w[lint build].map do |stage|
98
- Thread.new { results[stage] = stage_runner.run_stage(config, stage) }
99
- end
100
- threads.each(&:join)
101
- results
66
+ def unknown_commit
67
+ @io.stderr.puts "fun-ci: commit #{@commit.sha} not found in this repository."
68
+ 1
102
69
  end
103
70
 
104
- def spawn_slow_suite(config)
105
- cmd = "#{config.script_path("slow")} #{@commit_hash}"
106
- job_id = @recorder.start_stage("slow")
107
- budget = @time_budgets["slow"]
108
- executor = if @command_runner
109
- runner = @command_runner
110
- -> do
111
- output, status = runner.call(cmd)
112
- [output, status, false]
113
- rescue Timeout::Error
114
- ["", nil, true]
115
- end
116
- else
117
- -> { run_process_with_timeout(cmd, budget) }
118
- end
119
- @background_launcher.call(
120
- db_path: @recorder.db_path, pipeline_run_id: @recorder.pipeline_run_id,
121
- job_id: job_id, executor: executor
122
- )
71
+ def start_run
72
+ recorder.report_trouble_to(@io.stdout)
73
+ cancel_stale_pipelines
74
+ recorder.create_run(commit_hash: @commit.sha, branch: @commit.branch, project_path: @project)
123
75
  end
124
76
 
125
- def make_stage_runner
126
- StageRunner.new(commit_hash: @commit_hash, stdout: @stdout,
127
- command_runner: @command_runner, time_budgets: @time_budgets, recorder: @recorder)
77
+ # After forking the slow suite the run holds a recorder of its own.
78
+ def run_in(slot)
79
+ slot_run = SlotRun.new(commit: @commit, io: @io, seams: @seams, slot: slot)
80
+ slot_run.run(config_for(slot)).tap { @seams = slot_run.seams }
128
81
  end
129
82
 
130
83
  def cancel_stale_pipelines
131
- return unless @recorder.db
132
- StalePipelineCanceller.new(db: @recorder.db, branch: @branch, stdout: @stdout)
133
- .cancel(new_commit_hash: @commit_hash)
134
- end
84
+ return unless recorder.db
135
85
 
136
- def default_background_launcher(db_path:, pipeline_run_id:, job_id:, executor:)
137
- @recorder.close
138
- pid = fork do
139
- recorder = Persistence::DbRecorder.for_background(db_path, pipeline_run_id)
140
- BackgroundWrapper.new(recorder: recorder, job_id: job_id, executor: executor).run
141
- recorder.close
86
+ recorder.tolerating do
87
+ StalePipelineCanceller.new(db: recorder.db, branch: branch, stdout: @io.stdout)
88
+ .cancel(new_commit_hash: @commit.sha)
142
89
  end
143
- @recorder = Persistence::DbRecorder.for_background(db_path, pipeline_run_id)
144
- Process.detach(pid)
145
- Persistence::PipelineRun.store_pid(@recorder.db, pipeline_run_id, pid)
146
- end
147
-
148
- def default_commit_validator(hash)
149
- Open3.capture2e("git", "cat-file", "-t", hash).last.success?
150
90
  end
151
91
  end
152
92
  end