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,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Agent
5
+ # Why a needed stage failed, as the short text `status` and `wait` print
6
+ # (acceptance-tests.md, AT-9.6, AT-10.17), drawn from its evidence: for an
7
+ # overrun, what it was running; then the failures, or else the first
8
+ # excerpt, or else the output's last lines.
9
+ module Digest
10
+ HEADINGS = { "failed" => "failed", "over_budget" => "ran over budget" }.freeze
11
+ LINES = 20
12
+ FAILURES = 10
13
+ MESSAGE_LINES = 5
14
+
15
+ def self.lines(stages, needed)
16
+ stages.select { |stage| needed.include?(stage.name) && HEADINGS.key?(stage.state) }
17
+ .flat_map { |stage| of(stage) }
18
+ end
19
+
20
+ def self.of(stage)
21
+ evidence = stage.evidence
22
+ [heading(stage, evidence), *(evidence.failures.any? ? failure_lines(evidence.failures) : excerpt(evidence))]
23
+ end
24
+
25
+ def self.heading(stage, evidence)
26
+ running = evidence.facts.find { |fact| fact[:name] == "running" }
27
+ "#{stage.name} #{HEADINGS.fetch(stage.state)}:#{" running #{running[:value]}" if running}"
28
+ end
29
+
30
+ def self.failure_lines(failures)
31
+ failures.first(FAILURES).flat_map do |failure|
32
+ [" #{place(failure)}", *failure[:message].to_s.lines.first(MESSAGE_LINES).map { |line| " #{line.chomp}" }]
33
+ end
34
+ end
35
+
36
+ def self.place(failure)
37
+ failure[:file] ? "#{failure[:file]}:#{failure[:line]} #{failure[:test]}" : failure[:test]
38
+ end
39
+
40
+ # The first excerpt's first lines, or the output's last lines.
41
+ def self.excerpt(evidence)
42
+ first = evidence.excerpts.find { |excerpt| excerpt[:extractor] != "output-tail" }
43
+ lines = first ? first[:lines].first(LINES) : evidence.excerpts.flat_map { |e| e[:lines] }.last(LINES)
44
+ lines.map { |line| " #{line}" }
45
+ end
46
+ private_class_method :of, :heading, :failure_lines, :place, :excerpt
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Agent
5
+ # What happened to a project's runs between two looks at them
6
+ # (acceptance-tests.md, AT-9.16), oldest run first: a run started, each
7
+ # stage that finished in the order it finished, a run that passed or
8
+ # failed, a run a newer commit superseded. The first look is at nothing.
9
+ module Events
10
+ # finished: [{ stage:, state:, seconds: }, ...] in the order they finished.
11
+ RunState = Data.define(:id, :sha, :branch, :status, :finished, :superseded_by)
12
+ OUTCOMES = { "completed" => "passed", "failed" => "failed" }.freeze
13
+ FAILED_STATES = %w[failed over_budget].freeze
14
+
15
+ # before, after: { run id => RunState }.
16
+ def self.between(before, after)
17
+ after.values.sort_by(&:id).flat_map { |run| of(run, before[run.id]) }
18
+ end
19
+
20
+ def self.failure?(event)
21
+ return true if event[:event] == "run_superseded"
22
+
23
+ event[:event] == "stage_finished" && FAILED_STATES.include?(event[:state])
24
+ end
25
+
26
+ def self.of(run, was)
27
+ about = { commit: run.sha, branch: run.branch }
28
+ [*(was ? [] : [{ event: "run_started", **about }]),
29
+ *newly_finished(run, was).map { |stage| { event: "stage_finished", **about, **stage } },
30
+ *ended(run, was, about)]
31
+ end
32
+
33
+ def self.newly_finished(run, was)
34
+ seen = (was&.finished || []).map { |stage| stage[:stage] }
35
+ run.finished.reject { |stage| seen.include?(stage[:stage]) }
36
+ end
37
+
38
+ def self.ended(run, was, about)
39
+ return [] if was&.status == run.status
40
+ return [{ event: "run_superseded", **about, by_commit: run.superseded_by }] if run.status == "cancelled"
41
+
42
+ OUTCOMES.key?(run.status) ? [{ event: "run_finished", **about, state: OUTCOMES.fetch(run.status) }] : []
43
+ end
44
+ private_class_method :of, :newly_finished, :ended
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "command_support"
5
+ require_relative "snapshots"
6
+ require_relative "events"
7
+ require_relative "waiting"
8
+
9
+ module FunCi
10
+ module Agent
11
+ # `fun-ci events [--follow] [--only failures]` (acceptance-tests.md,
12
+ # AT-9.16): one JSON line per event of the project's recent runs, then,
13
+ # with --follow, each new one as it happens, until interrupted.
14
+ class EventsCommand
15
+ include CommandSupport
16
+
17
+ NAME = "events"
18
+ SCHEMA = 1
19
+ RECENT_RUNS = 10
20
+
21
+ def initialize(context)
22
+ @context = context
23
+ end
24
+
25
+ def run(args)
26
+ options = Options.parse(args, takes: %i[follow only json])
27
+ seen = emit({}, options.only)
28
+ follow(seen, options.only) if options.follow
29
+ 0
30
+ rescue Options::Invalid => e
31
+ usage(e.message)
32
+ end
33
+
34
+ private
35
+
36
+ def follow(seen, only)
37
+ loop do
38
+ @context.clock.pause(Waiting::POLL_SECONDS)
39
+ @context.pipeline.watch(@context.db)
40
+ seen = emit(seen, only)
41
+ end
42
+ rescue Interrupt
43
+ # Following ends when whoever follows stops it.
44
+ end
45
+
46
+ # Prints what happened since `before`, and answers the look it took.
47
+ def emit(before, only)
48
+ after = snapshots.take(limit: RECENT_RUNS)
49
+ Events.between(before, after).select { |event| !only || Events.failure?(event) }.each { |event| print(event) }
50
+ after
51
+ end
52
+
53
+ def print(event)
54
+ @context.io.stdout.puts JSON.generate({ schema: SCHEMA, **event })
55
+ @context.io.stdout.flush
56
+ end
57
+
58
+ def snapshots = @snapshots ||= Snapshots.new(@context.db, @context.git)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Agent
5
+ # A stage's evidence as text: its facts, its failures, each with its whole
6
+ # message, each excerpt under its title, each naming the extractor that
7
+ # found it, then the problems extractors had.
8
+ module EvidenceText
9
+ def self.lines(document)
10
+ facts(document.facts) + failures(document.failures) +
11
+ document.excerpts.flat_map { |excerpt| excerpt(excerpt) } + problems(document.problems)
12
+ end
13
+
14
+ def self.facts(facts)
15
+ facts.empty? ? [] : ["", "Facts:", *facts.map { |fact| " #{fact[:name]}: #{fact[:value]}" }]
16
+ end
17
+
18
+ def self.problems(problems)
19
+ return [] if problems.empty?
20
+
21
+ lines = problems.map { |problem| " #{problem[:extractor]}: #{problem[:message]}" }
22
+ ["", "Problems collecting the evidence:", *lines]
23
+ end
24
+
25
+ def self.failures(failures)
26
+ failures.group_by { |failure| failure[:extractor] }.flat_map do |extractor, found|
27
+ ["", "Failures, from #{extractor}:", *found.flat_map { |failure| failure(failure) }]
28
+ end
29
+ end
30
+
31
+ def self.failure(failure)
32
+ place = failure[:file] ? "#{failure[:file]}:#{failure[:line]} #{failure[:test]}" : failure[:test]
33
+ [" #{place}", *indented(failure[:message], " "), *own_output(failure[:output])]
34
+ end
35
+
36
+ def self.own_output(output) = output.to_s.empty? ? [] : [" Output:", *indented(output, " ")]
37
+ def self.indented(text, margin) = text.to_s.lines(chomp: true).map { |line| "#{margin}#{line}" }
38
+
39
+ def self.excerpt(excerpt)
40
+ place = excerpt[:location] == "output" ? "" : " (#{excerpt[:location]})"
41
+ ["", "#{excerpt[:title]}#{place}, from #{excerpt[:extractor]}:", *excerpt[:lines].map { |line| " #{line}" }]
42
+ end
43
+ private_class_method :facts, :problems, :failures, :failure, :own_output, :indented, :excerpt
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Agent
5
+ # What `status` and `wait` exit with (acceptance-tests.md, §9).
6
+ module ExitCode
7
+ FOR = { passed: 0, failed: 1, over_budget: 2, undecided: 3, superseded: 4, unknown: 5 }.freeze
8
+ USAGE = 64
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "../pipeline/git_environment"
5
+
6
+ module FunCi
7
+ module Agent
8
+ # The project's git, as agent commands ask it: which commit a revision
9
+ # names, that commit's subject, and the directory the project is in.
10
+ class Git
11
+ def initialize(dir)
12
+ @dir = dir
13
+ end
14
+
15
+ def resolve(rev) = git("rev-parse", "--verify", "--quiet", "#{rev}^{commit}")
16
+ def subject(sha) = git("log", "-1", "--format=%s", sha).to_s
17
+ def toplevel = File.realpath(git("rev-parse", "--show-toplevel"))
18
+
19
+ # The branch checked out, as the git hooks name it; "unknown" when HEAD is detached.
20
+ def branch = git("branch", "--show-current").to_s.then { |name| name.empty? ? "unknown" : name }
21
+
22
+ private
23
+
24
+ # Its first line of output, or nil when git fails.
25
+ def git(*)
26
+ output, status = Open3.capture2(Pipeline::GitEnvironment::CLEAN, "git", *, chdir: @dir, err: File::NULL)
27
+ status.success? ? output.lines.first&.chomp : nil
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require_relative "../pipeline/run_canceller"
5
+ require_relative "../setup/project_config"
6
+
7
+ module FunCi
8
+ module Agent
9
+ # The project's pipeline, as `wait` starts and watches it: a run starts
10
+ # the way the post-commit hook starts one, with `fun-ci trigger
11
+ # --background` in a process of its own, rather than as a fork of the
12
+ # waiting process and its open database connection.
13
+ class LivePipeline
14
+ FUN_CI = File.expand_path("../../../exe/fun-ci", __dir__)
15
+
16
+ # command: what runs fun-ci, the gem's own executable by default.
17
+ def initialize(project_dir, command: [RbConfig.ruby, FUN_CI])
18
+ @project_dir = project_dir
19
+ @command = command
20
+ end
21
+
22
+ # Answers the finished starter, or nil in a project not set up for fun-ci.
23
+ def start(sha, branch)
24
+ return unless Setup::ProjectConfig.new(@project_dir).validate.empty?
25
+
26
+ pid = Process.spawn(*@command, "trigger", "--background", sha, branch,
27
+ chdir: @project_dir, %i[out err] => File::NULL)
28
+ Process.detach(pid).join
29
+ end
30
+
31
+ # Records failed each slow suite whose process died (acceptance-tests.md, AT-8.3).
32
+ def watch(db) = Pipeline::RunCanceller.new.record_dead(db)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "verdict"
5
+
6
+ module FunCi
7
+ module Agent
8
+ # The arguments of an agent command: at most one revision, then, for a
9
+ # command that takes :stage, a stage's name, and whichever of the switches
10
+ # below the command takes.
11
+ module Options
12
+ class Invalid < StandardError; end
13
+
14
+ Parsed = Data.define(:rev, :stage, :need, :json, :within, :follow_branch, :limit, :branch, :follow, :only, :raw)
15
+ DEFAULTS = { need: "fast", json: false, within: nil, follow_branch: false, limit: 10, branch: nil,
16
+ follow: false, only: nil, raw: false }.freeze
17
+ SWITCHES = { need: ["--need LEVEL"], json: ["--json"], within: ["--within DURATION"], raw: ["--raw"],
18
+ follow_branch: ["--follow-branch"], limit: ["-n N", Integer], branch: ["--branch NAME"],
19
+ follow: ["--follow"], only: ["--only KIND"] }.freeze
20
+ FILTERS = %w[failures].freeze
21
+ MULTIPLIERS = { "" => 1, "s" => 1, "m" => 60 }.freeze
22
+
23
+ STAGES = %w[lint build fast slow].freeze
24
+
25
+ def self.parse(args, takes:)
26
+ values = DEFAULTS.dup
27
+ revs = parser(values, takes).parse(args)
28
+ stage = takes.include?(:stage) && STAGES.include?(revs.last) ? revs.pop : nil
29
+ raise Invalid, "one revision at a time, not #{revs.join(" ")}" if revs.size > 1
30
+
31
+ Parsed.new(rev: revs.first || "HEAD", stage: stage, **values)
32
+ rescue OptionParser::ParseError => e
33
+ raise Invalid, e.message
34
+ end
35
+
36
+ # Without OptionParser's own --help and --version (long options only), which print and exit.
37
+ def self.parser(values, takes)
38
+ parser = OptionParser.new
39
+ parser.base.long.clear
40
+ (takes & SWITCHES.keys).each do |key|
41
+ parser.on(*SWITCHES.fetch(key)) { |value| values[key] = convert(key, value) }
42
+ end
43
+ parser
44
+ end
45
+
46
+ def self.convert(key, value)
47
+ return level(value) if key == :need
48
+ return seconds(value) if key == :within
49
+ return filter(value) if key == :only
50
+
51
+ value
52
+ end
53
+
54
+ def self.level(value)
55
+ return value if Verdict::LEVELS.key?(value)
56
+
57
+ raise Invalid, "unknown level '#{value}': use build, fast or all"
58
+ end
59
+
60
+ def self.filter(value)
61
+ return value if FILTERS.include?(value)
62
+
63
+ raise Invalid, "unknown filter '#{value}': use #{FILTERS.join(", ")}"
64
+ end
65
+
66
+ def self.seconds(value)
67
+ match = /\A(\d+)([sm]?)\z/.match(value)
68
+ raise Invalid, "can't read the deadline '#{value}': use 30, 30s or 5m" unless match
69
+
70
+ match[1].to_i * MULTIPLIERS.fetch(match[2])
71
+ end
72
+ private_class_method :parser, :convert, :level, :filter, :seconds
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "status_text"
5
+ require_relative "why_text"
6
+ require_relative "why_json"
7
+ require_relative "status_json"
8
+ require_relative "runs_text"
9
+ require_relative "exit_code"
10
+
11
+ module FunCi
12
+ module Agent
13
+ # Prints what an agent command found, as text or, with --json, as JSON.
14
+ class Output
15
+ NO_STAGE = "No stage it needs failed or ran over budget; name one to see it: fun-ci why REV STAGE"
16
+
17
+ def initialize(stdout, json:)
18
+ @stdout = stdout
19
+ @json = json
20
+ end
21
+
22
+ def report(report)
23
+ @json ? print_json(StatusJson.document(report)) : print_lines(StatusText.lines(report))
24
+ end
25
+
26
+ # Everything kept about the stage named, or says no stage decided the verdict.
27
+ def why(report, stage_name)
28
+ stage = report.stages.find { |candidate| candidate.name == stage_name }
29
+ return print_json(WhyJson.document(report, stage)) if @json
30
+ return print_lines([StatusText.header(report), NO_STAGE]) unless stage
31
+
32
+ print_lines(WhyText.lines(report, stage))
33
+ end
34
+
35
+ # Says the commit has no run, and answers the exit code for that.
36
+ def unknown(sha)
37
+ @json ? print_json(StatusJson.unknown(sha)) : print_lines(["fun-ci: no run for #{sha[0, 7]} in this project."])
38
+ ExitCode::FOR.fetch(:unknown)
39
+ end
40
+
41
+ # Says the commit has no run and none can start, which leaves it unknown.
42
+ def not_set_up(sha)
43
+ if @json
44
+ print_json(StatusJson.unknown(sha))
45
+ else
46
+ print_lines(["fun-ci: no run for #{sha[0, 7]}, and this project isn't set up to start one."])
47
+ end
48
+ ExitCode::FOR.fetch(:unknown)
49
+ end
50
+
51
+ # Says the commit has no run yet, which leaves it undecided.
52
+ def no_run_yet(sha)
53
+ if @json
54
+ print_json(StatusJson.unknown(sha).merge(verdict: "undecided"))
55
+ else
56
+ print_lines(["fun-ci: no run for #{sha[0, 7]} yet."])
57
+ end
58
+ ExitCode::FOR.fetch(:undecided)
59
+ end
60
+
61
+ # Says the wait moves on to the commit that superseded `sha`; JSON has only the answer.
62
+ def following(sha, report)
63
+ return if @json
64
+
65
+ @stdout.puts "fun-ci: #{sha[0, 7]} was superseded; following #{report.branch} to #{report.superseded_by[0, 7]}."
66
+ end
67
+
68
+ # entries: [[report, age in words], ...], newest first.
69
+ def runs(entries)
70
+ return print_json(entries.map { |report, _| StatusJson.document(report) }) if @json
71
+
72
+ print_lines(RunsText.lines(entries))
73
+ end
74
+
75
+ private
76
+
77
+ def print_json(document) = @stdout.puts(JSON.generate(document))
78
+ def print_lines(lines) = lines.each { |line| @stdout.puts line }
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require_relative "../evidence/document"
6
+
7
+ module FunCi
8
+ module Agent
9
+ RunReport = Data.define(:sha, :subject, :branch, :need, :stages, :verdict, :deciding, :superseded_by)
10
+
11
+ # One stage of a run, as an agent is told it.
12
+ class RunReport
13
+ STATES = { "completed" => "passed", "timed_out" => "over_budget", "scheduled" => "waiting" }.freeze
14
+
15
+ # What a failed stage left to explain itself: the end of its output,
16
+ # and the failures it reported, each { file:, line:, test:, message: },
17
+ # unless they were pruned; the evidence document, if it kept one; and
18
+ # how many bytes of raw output are kept, if any.
19
+ Kept = Data.define(:tail, :failures, :pruned, :evidence, :raw_bytes)
20
+
21
+ class Kept
22
+ def initialize(pruned: false, evidence: nil, raw_bytes: nil, **) = super
23
+
24
+ def document
25
+ evidence ? Evidence::Document.from_json(evidence) : Evidence::Document.legacy(tail: tail, failures: failures)
26
+ end
27
+ end
28
+
29
+ NOTHING_KEPT = Kept.new(tail: nil, failures: [])
30
+
31
+ # How a finished stage's process ended, and the budget it had.
32
+ Exit = Data.define(:exit_status, :signal, :budget)
33
+ NO_EXIT = Exit.new(exit_status: nil, signal: nil, budget: nil)
34
+
35
+ # id: the stage's row, nil for a stage that hasn't started.
36
+ Stage = Data.define(:name, :state, :seconds, :kept, :exit, :id)
37
+
38
+ class Stage
39
+ def initialize(kept: NOTHING_KEPT, exit: NO_EXIT, id: nil, **) = super
40
+ def exit_status = exit.exit_status
41
+ def signal = exit.signal
42
+ def budget = exit.budget
43
+ def evidence = kept.document
44
+ def pruned? = kept.pruned
45
+ def raw_bytes = kept.raw_bytes
46
+ def tail = kept.tail
47
+ # The failures its evidence names, from whichever extractor found them.
48
+ def failures = evidence.failures.map { |failure| failure.except(:extractor) }
49
+
50
+ def self.from_row(name, job)
51
+ return new(name: name, state: "waiting", seconds: nil) unless job
52
+
53
+ ended = Exit.new(exit_status: job[:exit_status], signal: job[:signal], budget: job[:budget])
54
+ new(name: name, state: STATES.fetch(job[:status], job[:status]), seconds: seconds(job), id: job[:id],
55
+ kept: kept(job), exit: ended)
56
+ end
57
+
58
+ def self.kept(job)
59
+ failures = job[:failures] ? JSON.parse(job[:failures], symbolize_names: true) : []
60
+ Kept.new(tail: job[:output_tail], failures: failures, pruned: job[:pruned] == 1, evidence: job[:evidence],
61
+ raw_bytes: job[:raw_bytes])
62
+ end
63
+
64
+ def self.seconds(job)
65
+ return nil unless job[:started_at] && job[:completed_at]
66
+
67
+ (Time.parse(job[:completed_at]) - Time.parse(job[:started_at])).round(1)
68
+ end
69
+ private_class_method :kept, :seconds
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../persistence/project_runs"
4
+ require_relative "../persistence/stage_job"
5
+ require_relative "../persistence/raw_outputs"
6
+ require_relative "run_report"
7
+
8
+ module FunCi
9
+ module Agent
10
+ # The runs of the project git names, as an agent is told them.
11
+ class Reports
12
+ def initialize(db, git)
13
+ @db = db
14
+ @git = git
15
+ @runs = Persistence::ProjectRuns.new(db, git.toplevel)
16
+ @raw = Persistence::RawOutputs.beside(db.filename("main"))
17
+ end
18
+
19
+ # What the stage kept of its raw output, or nil.
20
+ def raw_output(stage) = stage.id && @raw.read(stage.id)
21
+
22
+ # The commit's newest run, or nil.
23
+ def for(sha, need)
24
+ run = @runs.latest_of_commit(sha)
25
+ run && of(run, need)
26
+ end
27
+
28
+ # Notes that an agent is waiting on the commit's newest run, as of `at`.
29
+ def mark_waited(sha, at)
30
+ run = @runs.latest_of_commit(sha)
31
+ Persistence::PipelineRun.mark_waited(@db, run[:id], at.utc.iso8601) if run
32
+ end
33
+
34
+ # [[run, its report for the whole pipeline], ...], newest first.
35
+ def recent(limit:, branch:)
36
+ @runs.recent(limit: limit, branch: branch).map { |run| [run, of(run, "all")] }
37
+ end
38
+
39
+ def of(run, need)
40
+ superseded_by = run[:status] == "cancelled" ? @runs.superseded_by(run) : nil
41
+ jobs = Persistence::StageJob.for_run(@db, run[:id]).map { |job| job.merge(raw_bytes: @raw.bytes(job[:id])) }
42
+ RunReport.build(run: run, jobs: jobs, need: need,
43
+ commit: { subject: @git.subject(run[:commit_hash]), superseded_by: superseded_by })
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "verdict"
4
+ require_relative "report_stage"
5
+
6
+ module FunCi
7
+ module Agent
8
+ # What an agent is told about one run: its commit, each of the four stages
9
+ # in pipeline order, and the verdict for the level it needs.
10
+ class RunReport
11
+ STAGES = %w[lint build fast slow].freeze
12
+
13
+ # deciding: the stage whose failure or overrun decided the verdict, if one did.
14
+ def initialize(deciding: nil, **) = super
15
+
16
+ # commit: the run's subject and the commit that superseded it, if any.
17
+ def self.build(run:, jobs:, need:, commit:)
18
+ verdict = Verdict.decide(run_status: run[:status], stages: jobs, need: need)
19
+ new(sha: run[:commit_hash], branch: run[:branch], need: need, stages: stages(jobs), verdict: verdict,
20
+ deciding: Verdict.deciding_stage(stages: jobs, need: need), **commit)
21
+ end
22
+
23
+ # The four stages in pipeline order, from the run's stage rows.
24
+ def self.stages(jobs)
25
+ by_name = jobs.to_h { |job| [job[:stage], job] }
26
+ STAGES.map { |name| Stage.from_row(name, by_name[name]) }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "command_support"
5
+ require_relative "output"
6
+ require_relative "age"
7
+
8
+ module FunCi
9
+ module Agent
10
+ # `fun-ci runs [-n N] [--branch NAME] [--json]` (acceptance-tests.md, AT-9.4).
11
+ class RunsCommand
12
+ include CommandSupport
13
+
14
+ NAME = "runs"
15
+
16
+ def initialize(context)
17
+ @context = context
18
+ end
19
+
20
+ def run(args)
21
+ options = Options.parse(args, takes: %i[limit branch json])
22
+ entries = reports.recent(limit: options.limit, branch: options.branch).map { |run, report| [report, age(run)] }
23
+ Output.new(@context.io.stdout, json: options.json).runs(entries)
24
+ 0
25
+ rescue Options::Invalid => e
26
+ usage(e.message)
27
+ end
28
+
29
+ private
30
+
31
+ def age(run) = Age.words(@context.clock.now - Time.parse(run[:created_at]))
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "run_report"
4
+
5
+ module FunCi
6
+ module Agent
7
+ # One line per run, the columns lined up: short SHA, branch, age, each
8
+ # stage's outcome in a word of four letters at most, and the subject.
9
+ module RunsText
10
+ WORDS = { "passed" => "ok", "failed" => "FAIL", "over_budget" => "OVER", "running" => "...", "waiting" => "-",
11
+ "cancelled" => "x" }.freeze
12
+
13
+ # entries: [[report, age in words], ...], newest first.
14
+ def self.lines(entries)
15
+ widths = [entries.map { |report, _| report.branch.size }.max, entries.map { |_, age| age.size }.max]
16
+ entries.map { |report, age| line(report, age, widths) }
17
+ end
18
+
19
+ def self.line(report, age, (branch_width, age_width))
20
+ stages = report.stages.map { |stage| "#{stage.name} #{WORDS.fetch(stage.state).ljust(4)}" }.join(" ")
21
+ "#{report.sha[0,
22
+ 7]} #{report.branch.ljust(branch_width)} #{age.ljust(age_width)} #{stages} #{report.subject}"
23
+ end
24
+ private_class_method :line
25
+ end
26
+ end
27
+ end