fun_ci 2.0.0-arm64-darwin

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 (200) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +294 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +230 -0
  5. data/exe/fun-ci +7 -0
  6. data/lib/fun_ci/agent/age.rb +15 -0
  7. data/lib/fun_ci/agent/command_support.rb +27 -0
  8. data/lib/fun_ci/agent/commands.rb +27 -0
  9. data/lib/fun_ci/agent/context.rb +10 -0
  10. data/lib/fun_ci/agent/digest.rb +49 -0
  11. data/lib/fun_ci/agent/events.rb +47 -0
  12. data/lib/fun_ci/agent/events_command.rb +61 -0
  13. data/lib/fun_ci/agent/evidence_text.rb +46 -0
  14. data/lib/fun_ci/agent/exit_code.rb +11 -0
  15. data/lib/fun_ci/agent/git.rb +31 -0
  16. data/lib/fun_ci/agent/live_pipeline.rb +35 -0
  17. data/lib/fun_ci/agent/options.rb +75 -0
  18. data/lib/fun_ci/agent/output.rb +81 -0
  19. data/lib/fun_ci/agent/report_stage.rb +73 -0
  20. data/lib/fun_ci/agent/reports.rb +47 -0
  21. data/lib/fun_ci/agent/run_report.rb +30 -0
  22. data/lib/fun_ci/agent/runs_command.rb +34 -0
  23. data/lib/fun_ci/agent/runs_text.rb +27 -0
  24. data/lib/fun_ci/agent/snapshots.rb +39 -0
  25. data/lib/fun_ci/agent/stage_summary.rb +30 -0
  26. data/lib/fun_ci/agent/status_command.rb +36 -0
  27. data/lib/fun_ci/agent/status_json.rb +27 -0
  28. data/lib/fun_ci/agent/status_text.rb +37 -0
  29. data/lib/fun_ci/agent/system_clock.rb +11 -0
  30. data/lib/fun_ci/agent/verdict.rb +32 -0
  31. data/lib/fun_ci/agent/wait_command.rb +71 -0
  32. data/lib/fun_ci/agent/waiting.rb +32 -0
  33. data/lib/fun_ci/agent/why_command.rb +50 -0
  34. data/lib/fun_ci/agent/why_json.rb +35 -0
  35. data/lib/fun_ci/agent/why_text.rb +43 -0
  36. data/lib/fun_ci/cli.rb +128 -0
  37. data/lib/fun_ci/cli_help.rb +65 -0
  38. data/lib/fun_ci/console/board_data.rb +66 -0
  39. data/lib/fun_ci/console/board_heartbeat.rb +40 -0
  40. data/lib/fun_ci/console/console_events.rb +20 -0
  41. data/lib/fun_ci/console/console_log.rb +26 -0
  42. data/lib/fun_ci/console/console_loop.rb +41 -0
  43. data/lib/fun_ci/console/console_session.rb +82 -0
  44. data/lib/fun_ci/console/console_state.rb +42 -0
  45. data/lib/fun_ci/console/key_handler.rb +73 -0
  46. data/lib/fun_ci/console/launcher.rb +43 -0
  47. data/lib/fun_ci/console/milestone_events.rb +38 -0
  48. data/lib/fun_ci/console/milestones.rb +25 -0
  49. data/lib/fun_ci/console/renderer_lookup.rb +51 -0
  50. data/lib/fun_ci/console/renderer_process.rb +60 -0
  51. data/lib/fun_ci/console/run_message.rb +33 -0
  52. data/lib/fun_ci/console/stage_change_detector.rb +32 -0
  53. data/lib/fun_ci/console/stage_events.rb +31 -0
  54. data/lib/fun_ci/console/stop_report.rb +39 -0
  55. data/lib/fun_ci/console/streak_counter.rb +19 -0
  56. data/lib/fun_ci/console/view.rb +47 -0
  57. data/lib/fun_ci/evidence/about.rb +12 -0
  58. data/lib/fun_ci/evidence/about_stage.rb +29 -0
  59. data/lib/fun_ci/evidence/caps.rb +77 -0
  60. data/lib/fun_ci/evidence/catalog.rb +83 -0
  61. data/lib/fun_ci/evidence/collector.rb +95 -0
  62. data/lib/fun_ci/evidence/command_output.rb +45 -0
  63. data/lib/fun_ci/evidence/command_reading.rb +38 -0
  64. data/lib/fun_ci/evidence/command_runner.rb +75 -0
  65. data/lib/fun_ci/evidence/context.rb +21 -0
  66. data/lib/fun_ci/evidence/context_document.rb +29 -0
  67. data/lib/fun_ci/evidence/deadline.rb +15 -0
  68. data/lib/fun_ci/evidence/detection.rb +82 -0
  69. data/lib/fun_ci/evidence/document.rb +59 -0
  70. data/lib/fun_ci/evidence/extract_command.rb +83 -0
  71. data/lib/fun_ci/evidence/extract_options.rb +45 -0
  72. data/lib/fun_ci/evidence/extraction.rb +54 -0
  73. data/lib/fun_ci/evidence/extractors/command.rb +52 -0
  74. data/lib/fun_ci/evidence/extractors/grep.rb +49 -0
  75. data/lib/fun_ci/evidence/extractors/json_log.rb +45 -0
  76. data/lib/fun_ci/evidence/extractors/junit_files.rb +32 -0
  77. data/lib/fun_ci/evidence/extractors/last_lines.rb +25 -0
  78. data/lib/fun_ci/evidence/extractors/log_file.rb +62 -0
  79. data/lib/fun_ci/evidence/extractors/process_tree.rb +51 -0
  80. data/lib/fun_ci/evidence/extractors/section.rb +49 -0
  81. data/lib/fun_ci/evidence/findings.rb +14 -0
  82. data/lib/fun_ci/evidence/line_ranges.rb +21 -0
  83. data/lib/fun_ci/evidence/line_scan.rb +22 -0
  84. data/lib/fun_ci/evidence/log_record.rb +58 -0
  85. data/lib/fun_ci/evidence/masking.rb +55 -0
  86. data/lib/fun_ci/evidence/option_check.rb +53 -0
  87. data/lib/fun_ci/evidence/outcome.rb +15 -0
  88. data/lib/fun_ci/evidence/patterns.rb +22 -0
  89. data/lib/fun_ci/evidence/presets/README.md +7 -0
  90. data/lib/fun_ci/evidence/presets/bun-test.yml +6 -0
  91. data/lib/fun_ci/evidence/presets/cargo-test.yml +6 -0
  92. data/lib/fun_ci/evidence/presets/dart-test.yml +6 -0
  93. data/lib/fun_ci/evidence/presets/deno-test.yml +6 -0
  94. data/lib/fun_ci/evidence/presets/dotnet-test.yml +6 -0
  95. data/lib/fun_ci/evidence/presets/ecs.yml +3 -0
  96. data/lib/fun_ci/evidence/presets/eslint.yml +6 -0
  97. data/lib/fun_ci/evidence/presets/exunit.yml +6 -0
  98. data/lib/fun_ci/evidence/presets/gcc.yml +6 -0
  99. data/lib/fun_ci/evidence/presets/go-build.yml +5 -0
  100. data/lib/fun_ci/evidence/presets/go-test.yml +6 -0
  101. data/lib/fun_ci/evidence/presets/gradle.yml +6 -0
  102. data/lib/fun_ci/evidence/presets/gtest.yml +6 -0
  103. data/lib/fun_ci/evidence/presets/jest.yml +6 -0
  104. data/lib/fun_ci/evidence/presets/logstash.yml +3 -0
  105. data/lib/fun_ci/evidence/presets/maven.yml +6 -0
  106. data/lib/fun_ci/evidence/presets/minitest.yml +6 -0
  107. data/lib/fun_ci/evidence/presets/mocha.yml +6 -0
  108. data/lib/fun_ci/evidence/presets/mypy.yml +6 -0
  109. data/lib/fun_ci/evidence/presets/node-test.yml +6 -0
  110. data/lib/fun_ci/evidence/presets/phpunit.yml +6 -0
  111. data/lib/fun_ci/evidence/presets/pino.yml +5 -0
  112. data/lib/fun_ci/evidence/presets/prove.yml +6 -0
  113. data/lib/fun_ci/evidence/presets/pytest.yml +6 -0
  114. data/lib/fun_ci/evidence/presets/rspec.yml +6 -0
  115. data/lib/fun_ci/evidence/presets/rubocop.yml +6 -0
  116. data/lib/fun_ci/evidence/presets/ruff.yml +6 -0
  117. data/lib/fun_ci/evidence/presets/rustc.yml +6 -0
  118. data/lib/fun_ci/evidence/presets/shellcheck.yml +5 -0
  119. data/lib/fun_ci/evidence/presets/structlog.yml +4 -0
  120. data/lib/fun_ci/evidence/presets/swift-test.yml +5 -0
  121. data/lib/fun_ci/evidence/presets/tsc.yml +6 -0
  122. data/lib/fun_ci/evidence/presets/unittest.yml +6 -0
  123. data/lib/fun_ci/evidence/presets/vitest.yml +6 -0
  124. data/lib/fun_ci/evidence/presets.rb +30 -0
  125. data/lib/fun_ci/evidence/problem.rb +9 -0
  126. data/lib/fun_ci/evidence/process_table.rb +40 -0
  127. data/lib/fun_ci/evidence/saved_run.rb +22 -0
  128. data/lib/fun_ci/evidence/section_walk.rb +49 -0
  129. data/lib/fun_ci/evidence/settings.rb +64 -0
  130. data/lib/fun_ci/evidence/settings_check.rb +78 -0
  131. data/lib/fun_ci/evidence/source.rb +34 -0
  132. data/lib/fun_ci/evidence/sources.rb +21 -0
  133. data/lib/fun_ci/evidence/stage_context.rb +30 -0
  134. data/lib/fun_ci/evidence/stamp.rb +10 -0
  135. data/lib/fun_ci/evidence/start.rb +36 -0
  136. data/lib/fun_ci/evidence/worktree.rb +62 -0
  137. data/lib/fun_ci/persistence/active_runs.rb +69 -0
  138. data/lib/fun_ci/persistence/database.rb +81 -0
  139. data/lib/fun_ci/persistence/null_recorder.rb +25 -0
  140. data/lib/fun_ci/persistence/output_tail.rb +24 -0
  141. data/lib/fun_ci/persistence/pipeline_recorder.rb +120 -0
  142. data/lib/fun_ci/persistence/pipeline_run.rb +68 -0
  143. data/lib/fun_ci/persistence/project_runs.rb +52 -0
  144. data/lib/fun_ci/persistence/raw_outputs.rb +46 -0
  145. data/lib/fun_ci/persistence/retention.rb +25 -0
  146. data/lib/fun_ci/persistence/run_status.rb +31 -0
  147. data/lib/fun_ci/persistence/stage_job.rb +91 -0
  148. data/lib/fun_ci/persistence/state_dir.rb +26 -0
  149. data/lib/fun_ci/persistence/write_trouble.rb +46 -0
  150. data/lib/fun_ci/pipeline/background_fork.rb +42 -0
  151. data/lib/fun_ci/pipeline/command_executor.rb +58 -0
  152. data/lib/fun_ci/pipeline/gate.rb +35 -0
  153. data/lib/fun_ci/pipeline/git_environment.rb +16 -0
  154. data/lib/fun_ci/pipeline/head_part.rb +40 -0
  155. data/lib/fun_ci/pipeline/in_place.rb +17 -0
  156. data/lib/fun_ci/pipeline/output_window.rb +73 -0
  157. data/lib/fun_ci/pipeline/pipeline_forker.rb +40 -0
  158. data/lib/fun_ci/pipeline/process_runner.rb +101 -0
  159. data/lib/fun_ci/pipeline/progress_reporter.rb +31 -0
  160. data/lib/fun_ci/pipeline/run_canceller.rb +75 -0
  161. data/lib/fun_ci/pipeline/slot.rb +30 -0
  162. data/lib/fun_ci/pipeline/slot_lock.rb +13 -0
  163. data/lib/fun_ci/pipeline/slot_run.rb +86 -0
  164. data/lib/fun_ci/pipeline/stage_dir.rb +50 -0
  165. data/lib/fun_ci/pipeline/stage_end.rb +70 -0
  166. data/lib/fun_ci/pipeline/stage_execution.rb +67 -0
  167. data/lib/fun_ci/pipeline/stage_runner.rb +63 -0
  168. data/lib/fun_ci/pipeline/stale_pipeline_canceller.rb +36 -0
  169. data/lib/fun_ci/pipeline/tail_segments.rb +60 -0
  170. data/lib/fun_ci/pipeline/test_report.rb +48 -0
  171. data/lib/fun_ci/pipeline/trigger.rb +93 -0
  172. data/lib/fun_ci/pipeline/trigger_command.rb +62 -0
  173. data/lib/fun_ci/pipeline/trigger_params.rb +48 -0
  174. data/lib/fun_ci/pipeline/workspaces.rb +22 -0
  175. data/lib/fun_ci/pipeline/worktree_pool.rb +66 -0
  176. data/lib/fun_ci/pipeline/worktree_prune.rb +49 -0
  177. data/lib/fun_ci/pipeline/worktrees.rb +45 -0
  178. data/lib/fun_ci/setup/agent_instructions.rb +31 -0
  179. data/lib/fun_ci/setup/commands.rb +49 -0
  180. data/lib/fun_ci/setup/git_hooks.rb +20 -0
  181. data/lib/fun_ci/setup/hook_script.rb +47 -0
  182. data/lib/fun_ci/setup/hook_writer.rb +57 -0
  183. data/lib/fun_ci/setup/installer.rb +71 -0
  184. data/lib/fun_ci/setup/legacy_hooks.rb +38 -0
  185. data/lib/fun_ci/setup/maven_linter_detector.rb +26 -0
  186. data/lib/fun_ci/setup/project_config.rb +68 -0
  187. data/lib/fun_ci/setup/project_detector.rb +54 -0
  188. data/lib/fun_ci/setup/settings.rb +36 -0
  189. data/lib/fun_ci/setup/setup_checker.rb +36 -0
  190. data/lib/fun_ci/setup/stage_templates/javascript.rb +18 -0
  191. data/lib/fun_ci/setup/stage_templates/managed.rb +24 -0
  192. data/lib/fun_ci/setup/stage_templates/native.rb +25 -0
  193. data/lib/fun_ci/setup/stage_templates/scripting.rb +27 -0
  194. data/lib/fun_ci/setup/stage_templates/shell.rb +16 -0
  195. data/lib/fun_ci/setup/stage_templates.rb +25 -0
  196. data/lib/fun_ci/setup/template_writer.rb +28 -0
  197. data/lib/fun_ci/version.rb +6 -0
  198. data/lib/fun_ci.rb +13 -0
  199. data/libexec/fun-ci-renderer +0 -0
  200. metadata +286 -0
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../persistence/project_runs"
4
+ require_relative "../persistence/stage_job"
5
+ require_relative "run_report"
6
+ require_relative "events"
7
+
8
+ module FunCi
9
+ module Agent
10
+ # Looks at the newest runs of the project git names, as `events` compares
11
+ # them: { run id => Events::RunState }.
12
+ class Snapshots
13
+ def initialize(db, git)
14
+ @db = db
15
+ @runs = Persistence::ProjectRuns.new(db, git.toplevel)
16
+ end
17
+
18
+ def take(limit:)
19
+ @runs.recent(limit: limit, branch: nil).to_h { |run| [run[:id], state_of(run)] }
20
+ end
21
+
22
+ private
23
+
24
+ def state_of(run)
25
+ superseded_by = run[:status] == "cancelled" ? @runs.superseded_by(run) : nil
26
+ Events::RunState.new(id: run[:id], sha: run[:commit_hash], branch: run[:branch], status: run[:status],
27
+ finished: finished(run), superseded_by: superseded_by)
28
+ end
29
+
30
+ def finished(run)
31
+ jobs = Persistence::StageJob.for_run(@db, run[:id]).select { |job| job[:finished_order] }
32
+ stages = RunReport.stages(jobs).to_h { |stage| [stage.name, stage] }
33
+ jobs.sort_by { |job| job[:finished_order] }.map { |job| facts(stages.fetch(job[:stage])) }
34
+ end
35
+
36
+ def facts(stage) = { stage: stage.name, state: stage.state, seconds: stage.seconds }
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Agent
5
+ # One line saying how a stage ended: its state, its exit status or the
6
+ # signal that ended it, how long it took and its budget, as far as known.
7
+ module StageSummary
8
+ WORDS = { "over_budget" => "ran over budget" }.freeze
9
+
10
+ def self.line(stage)
11
+ "#{stage.name} #{WORDS.fetch(stage.state, stage.state)}#{ending(stage)}#{took(stage)}"
12
+ end
13
+
14
+ def self.ending(stage)
15
+ return " (exit #{stage.exit_status})" if stage.exit_status
16
+ return " (killed by SIG#{stage.signal})" if stage.signal
17
+
18
+ ""
19
+ end
20
+
21
+ def self.took(stage)
22
+ return "" unless stage.seconds
23
+
24
+ budget = stage.budget ? ", budget #{stage.budget}s" : ""
25
+ " after #{stage.seconds}s#{budget}"
26
+ end
27
+ private_class_method :ending, :took
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command_support"
4
+ require_relative "output"
5
+
6
+ module FunCi
7
+ module Agent
8
+ # `fun-ci status [REV] [--need LEVEL] [--json]` (acceptance-tests.md, AT-9.2).
9
+ class StatusCommand
10
+ include CommandSupport
11
+
12
+ NAME = "status"
13
+
14
+ def initialize(context)
15
+ @context = context
16
+ end
17
+
18
+ def run(args)
19
+ options = Options.parse(args, takes: %i[need json])
20
+ sha = resolve(options.rev)
21
+ answer(sha, reports.for(sha, options.need), Output.new(@context.io.stdout, json: options.json))
22
+ rescue Options::Invalid => e
23
+ usage(e.message)
24
+ end
25
+
26
+ private
27
+
28
+ def answer(sha, report, output)
29
+ return output.unknown(sha) unless report
30
+
31
+ output.report(report)
32
+ ExitCode::FOR.fetch(report.verdict)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "run_report"
4
+
5
+ module FunCi
6
+ module Agent
7
+ # A run report as the JSON document of acceptance-tests.md, AT-9.3.
8
+ # Fields once published keep their names; a change bumps SCHEMA.
9
+ module StatusJson
10
+ SCHEMA = 1
11
+
12
+ def self.document(report)
13
+ { schema: SCHEMA, commit: { sha: report.sha, branch: report.branch, subject: report.subject },
14
+ need: report.need, verdict: report.verdict.to_s, stages: report.stages.map { |stage| stage(stage) },
15
+ superseded_by: report.superseded_by }
16
+ end
17
+
18
+ def self.unknown(sha) = { schema: SCHEMA, commit: { sha: sha }, verdict: "unknown" }
19
+
20
+ def self.stage(stage)
21
+ facts = { name: stage.name, state: stage.state, seconds: stage.seconds }
22
+ stage.failures.any? ? facts.merge(failures: stage.failures) : facts
23
+ end
24
+ private_class_method :stage
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "run_report"
4
+ require_relative "verdict"
5
+ require_relative "digest"
6
+
7
+ module FunCi
8
+ module Agent
9
+ # A run report as text: the commit, a line per stage with what went wrong
10
+ # in capitals, then the evidence of each needed stage that failed.
11
+ module StatusText
12
+ WORDS = { "failed" => "FAILED", "over_budget" => "OVER BUDGET" }.freeze
13
+
14
+ def self.lines(report)
15
+ needed = Verdict::LEVELS.fetch(report.need)
16
+ [header(report), *report.stages.map { |stage| stage_line(stage, needed) }, *Digest.lines(report.stages, needed),
17
+ *footer(report)]
18
+ end
19
+
20
+ def self.header(report) = %(fun-ci: #{report.sha[0, 7]} "#{report.subject}" on #{report.branch})
21
+
22
+ def self.stage_line(stage, needed)
23
+ seconds = stage.seconds ? format("%6.1fs", stage.seconds) : " " * 7
24
+ note = needed.include?(stage.name) ? "" : " (not needed)"
25
+ " #{stage.name.ljust(6)} #{WORDS.fetch(stage.state, stage.state).ljust(12)}#{seconds}#{note}".rstrip
26
+ end
27
+
28
+ def self.footer(report)
29
+ return ["fun-ci why #{report.sha[0, 7]} #{report.deciding}"] if report.deciding
30
+ return [] unless report.verdict == :superseded && report.superseded_by
31
+
32
+ ["Superseded by #{report.superseded_by[0, 7]}."]
33
+ end
34
+ private_class_method :stage_line, :footer
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Agent
5
+ # The real time, for commands that wait.
6
+ class SystemClock
7
+ def now = Time.now
8
+ def pause(seconds) = sleep(seconds)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Agent
5
+ # What a run means for the level an agent needs (acceptance-tests.md,
6
+ # §9): the stages it needs decide, in the order they finished, and a
7
+ # cancelled run is superseded only while they haven't.
8
+ module Verdict
9
+ LEVELS = { "build" => %w[lint build], "fast" => %w[lint build fast], "all" => %w[lint build fast slow] }.freeze
10
+ FINISHED_BADLY = { "failed" => :failed, "timed_out" => :over_budget }.freeze
11
+
12
+ def self.decide(run_status:, stages:, need:)
13
+ needed = needed(stages, need)
14
+ first_bad = first_finished_badly(needed)
15
+ return FINISHED_BADLY.fetch(first_bad[:status]) if first_bad
16
+ return :passed if needed.count { |stage| stage[:status] == "completed" } == LEVELS.fetch(need).size
17
+
18
+ run_status == "cancelled" ? :superseded : :undecided
19
+ end
20
+
21
+ # The name of the stage whose failure or overrun decided the verdict, or nil.
22
+ def self.deciding_stage(stages:, need:) = first_finished_badly(needed(stages, need))&.fetch(:stage)
23
+
24
+ def self.needed(stages, need) = stages.select { |stage| LEVELS.fetch(need).include?(stage[:stage]) }
25
+
26
+ def self.first_finished_badly(stages)
27
+ stages.select { |stage| FINISHED_BADLY.key?(stage[:status]) }.min_by { |stage| stage[:finished_order] }
28
+ end
29
+ private_class_method :needed, :first_finished_badly
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command_support"
4
+ require_relative "output"
5
+ require_relative "waiting"
6
+
7
+ module FunCi
8
+ module Agent
9
+ # `fun-ci wait [REV] [--need LEVEL] [--within DURATION] [--follow-branch] [--json]`
10
+ # (acceptance-tests.md, AT-9.9 to AT-9.12).
11
+ class WaitCommand
12
+ include CommandSupport
13
+
14
+ NAME = "wait"
15
+ # How long a commit may go without a run before `wait` starts one; the
16
+ # post-commit hook's run for a commit just made turns up well within it.
17
+ GRACE_SECONDS = 5
18
+ # The answer when no run turned up and the project can't start one.
19
+ NOT_SET_UP = Data.define(:verdict).new(verdict: :unknown)
20
+
21
+ def initialize(context)
22
+ @context = context
23
+ end
24
+
25
+ def run(args)
26
+ options = Options.parse(args, takes: %i[need json within follow_branch])
27
+ sha = resolve(options.rev)
28
+ output = Output.new(@context.io.stdout, json: options.json)
29
+ answer(sha, wait_for(sha, options, deadline(options), output), output)
30
+ rescue Options::Invalid => e
31
+ usage(e.message)
32
+ end
33
+
34
+ private
35
+
36
+ def deadline(options) = options.within && (@context.clock.now + options.within)
37
+
38
+ # Follows a superseded run on to the commit that superseded it, when asked to.
39
+ def wait_for(sha, options, deadline, output)
40
+ report = Waiting.new(@context.clock, deadline: deadline) { poll(sha, options.need) }.until_decided
41
+ return report unless options.follow_branch && report&.superseded_by
42
+
43
+ output.following(sha, report)
44
+ wait_for(report.superseded_by, options, deadline, output)
45
+ end
46
+
47
+ def answer(sha, report, output)
48
+ return output.no_run_yet(sha) unless report
49
+ return output.not_set_up(sha) if report.equal?(NOT_SET_UP)
50
+
51
+ output.report(report)
52
+ ExitCode::FOR.fetch(report.verdict)
53
+ end
54
+
55
+ def poll(sha, need)
56
+ @context.pipeline.watch(@context.db)
57
+ reports.mark_waited(sha, @context.clock.now)
58
+ reports.for(sha, need) || start_after_grace(sha)
59
+ end
60
+
61
+ # Nil while waiting for a run; NOT_SET_UP once one can't be started.
62
+ def start_after_grace(sha)
63
+ @missing_since ||= @context.clock.now
64
+ return if @started || @context.clock.now - @missing_since < GRACE_SECONDS
65
+
66
+ @started = true
67
+ @context.pipeline.start(sha, @context.git.branch) ? nil : NOT_SET_UP
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Agent
5
+ # Polls until the answer is decided, or the deadline (a Time; nil for none) has passed
6
+ # (acceptance-tests.md, AT-9.9, AT-9.10). An answer is decided once its
7
+ # verdict is anything but undecided; nil (no run yet) is undecided.
8
+ class Waiting
9
+ POLL_SECONDS = 1
10
+
11
+ def initialize(clock, deadline:, &poll)
12
+ @clock = clock
13
+ @deadline = deadline
14
+ @poll = poll
15
+ end
16
+
17
+ def until_decided
18
+ loop do
19
+ answer = @poll.call
20
+ return answer if decided?(answer) || out_of_time?
21
+
22
+ @clock.pause(POLL_SECONDS)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def decided?(answer) = answer && answer.verdict != :undecided
29
+ def out_of_time? = @deadline && @clock.now >= @deadline
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command_support"
4
+ require_relative "output"
5
+
6
+ module FunCi
7
+ module Agent
8
+ # `fun-ci why [REV] [STAGE] [--need LEVEL] [--json] [--raw]`
9
+ # (acceptance-tests.md, AT-10.1, AT-10.6): everything kept about the stage
10
+ # named, or else about the stage that decided the verdict; with --raw, the
11
+ # raw output it kept, and nothing else.
12
+ class WhyCommand
13
+ include CommandSupport
14
+
15
+ NAME = "why"
16
+
17
+ def initialize(context)
18
+ @context = context
19
+ end
20
+
21
+ def run(args)
22
+ options = Options.parse(args, takes: %i[need json stage raw])
23
+ sha = resolve(options.rev)
24
+ answer(sha, reports.for(sha, options.need), options)
25
+ rescue Options::Invalid => e
26
+ usage(e.message)
27
+ end
28
+
29
+ private
30
+
31
+ def answer(sha, report, options)
32
+ output = Output.new(@context.io.stdout, json: options.json)
33
+ return output.unknown(sha) unless report
34
+
35
+ stage_name = options.stage || report.deciding
36
+ options.raw ? raw(report, stage_name) : output.why(report, stage_name)
37
+ ExitCode::FOR.fetch(report.verdict)
38
+ end
39
+
40
+ def raw(report, stage_name)
41
+ stage = report.stages.find { |candidate| candidate.name == stage_name }
42
+ text = stage && reports.raw_output(stage)
43
+ return @context.io.stdout.write(text) if text
44
+
45
+ @context.io.stderr.puts "fun-ci why: no raw output is kept for #{stage_name || "any stage"} of #{report.sha[0,
46
+ 7]}"
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "run_report"
4
+
5
+ module FunCi
6
+ module Agent
7
+ # `fun-ci why --json` (acceptance-tests.md, AT-10.2): the stage and how it
8
+ # ended, its evidence, or else why there is none. Fields once published
9
+ # keep their names; a change bumps SCHEMA.
10
+ module WhyJson
11
+ SCHEMA = 1
12
+ FAILED = %w[failed over_budget].freeze
13
+
14
+ def self.document(report, stage)
15
+ reason = no_evidence(report, stage)
16
+ { schema: SCHEMA, commit: { sha: report.sha, branch: report.branch, subject: report.subject },
17
+ **ending(stage), evidence: reason ? nil : stage.evidence.to_h, no_evidence: reason,
18
+ raw_output: { kept: !stage&.raw_bytes.nil?, bytes: stage&.raw_bytes || 0 } }
19
+ end
20
+
21
+ def self.ending(stage)
22
+ { stage: stage&.name, state: stage&.state, exit_status: stage&.exit_status, signal: stage&.signal,
23
+ seconds: stage&.seconds, budget: stage&.budget }
24
+ end
25
+
26
+ def self.no_evidence(report, stage)
27
+ return report.verdict == :passed ? "passed" : "running" unless stage
28
+ return stage.state unless FAILED.include?(stage.state)
29
+
30
+ stage.pruned? ? "pruned" : nil
31
+ end
32
+ private_class_method :ending, :no_evidence
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "status_text"
4
+ require_relative "stage_summary"
5
+ require_relative "evidence_text"
6
+ require_relative "../persistence/pipeline_recorder"
7
+
8
+ module FunCi
9
+ module Agent
10
+ # What `fun-ci why` prints about one stage (acceptance-tests.md, AT-10.1):
11
+ # the commit, how the stage ended, then everything kept about it.
12
+ module WhyText
13
+ PASSED = "Nothing is kept about a stage that passed."
14
+ PRUNED = "Its evidence is no longer kept: fun-ci keeps it for a project's " \
15
+ "#{Persistence::DbRecorder::KEPT_RUNS} newest runs.".freeze
16
+
17
+ ONLY_TAIL = "Only the output's last lines were kept; extractors under `evidence:` in .fun-ci/config keep more."
18
+
19
+ def self.lines(report, stage)
20
+ [StatusText.header(report), StageSummary.line(stage), *body(stage), *raw(report, stage), *only_tail(stage)]
21
+ end
22
+
23
+ # So an agent that reads it knows it can write the extractor itself.
24
+ def self.only_tail(stage)
25
+ evidence = stage.evidence
26
+ kept_more = evidence.failures.any? || evidence.excerpts.any? { |excerpt| excerpt[:extractor] != "output-tail" }
27
+ kept_more || evidence.excerpts.empty? ? [] : ["", ONLY_TAIL]
28
+ end
29
+
30
+ def self.raw(report, stage)
31
+ stage.raw_bytes ? ["", "The whole output: fun-ci why #{report.sha[0, 7]} #{stage.name} --raw"] : []
32
+ end
33
+
34
+ def self.body(stage)
35
+ return [PASSED] if stage.state == "passed"
36
+ return [PRUNED] if stage.pruned?
37
+
38
+ EvidenceText.lines(stage.evidence)
39
+ end
40
+ private_class_method :body, :raw, :only_tail
41
+ end
42
+ end
43
+ end
data/lib/fun_ci/cli.rb ADDED
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../fun_ci"
4
+ require_relative "cli_help"
5
+ require_relative "pipeline/trigger_params"
6
+ require_relative "persistence/state_dir"
7
+ require_relative "setup/commands"
8
+
9
+ module FunCi
10
+ class Cli
11
+ ROUTES = {
12
+ "trigger" => :run_trigger,
13
+ "console" => :run_console,
14
+ "init" => :run_init,
15
+ "install-hooks" => :run_install_hooks,
16
+ "check" => :run_check,
17
+ "prune" => :run_prune,
18
+ "extract" => :run_extract
19
+ }.freeze
20
+ AGENT_COMMANDS = %w[status runs wait events why].freeze
21
+
22
+ def self.default_db_dir = Persistence::StateDir.path(ENV)
23
+
24
+ def self.run(args, io: Pipeline::Io.new, handlers: {}, db_dir: default_db_dir)
25
+ new(io: io, handlers: handlers, db_dir: db_dir).run(args)
26
+ end
27
+
28
+ def initialize(io:, handlers:, db_dir:)
29
+ @io = io
30
+ @db_dir = db_dir
31
+ @handlers = handlers
32
+ end
33
+
34
+ def run(args)
35
+ subcommand = args.first
36
+ return CliHelp.help(@io.stdout) if %w[-h --help].include?(subcommand)
37
+ return CliHelp.version(@io.stdout) if subcommand == "--version"
38
+
39
+ return unknown_command(subcommand) unless ROUTES.key?(subcommand) || AGENT_COMMANDS.include?(subcommand)
40
+
41
+ dispatch(subcommand, args.drop(1))
42
+ end
43
+
44
+ private
45
+
46
+ def dispatch(subcommand, args)
47
+ return @handlers[subcommand].call(args) if @handlers.key?(subcommand)
48
+ return run_agent(subcommand, args) if AGENT_COMMANDS.include?(subcommand)
49
+
50
+ send(ROUTES[subcommand], args)
51
+ end
52
+
53
+ def run_trigger(args)
54
+ require_relative "pipeline/trigger"
55
+ require_relative "persistence/pipeline_recorder"
56
+ db = setup_db
57
+ recorder = Persistence::DbRecorder.new(db)
58
+ Pipeline::Trigger.run_from_args(args, io: @io, recorder: recorder)
59
+ end
60
+
61
+ # Only `console` needs the renderer, so only it fails without one.
62
+ def run_console(_args)
63
+ require_relative "console/launcher"
64
+ require_relative "console/renderer_lookup"
65
+ launch_console(Console::RendererLookup.default.path)
66
+ rescue Console::RendererLookup::Missing => e
67
+ @io.stderr.puts e.message
68
+ 1
69
+ end
70
+
71
+ def launch_console(renderer)
72
+ db = setup_db
73
+ Console::Launcher.run(renderer: renderer, db: db, project_dir: Dir.pwd, stderr: @io.stderr)
74
+ ensure
75
+ db&.close
76
+ end
77
+
78
+ # The mutation lane loads all of lib/ before it mutates, so it can't see
79
+ # a lazy require go missing (here and in run_prune); the process tests
80
+ # that run these commands load the files unmutated.
81
+ def run_agent(name, args)
82
+ require_relative "agent/commands" # mutineer:disable-line statement_removal
83
+ db = setup_db
84
+ Agent::Commands.run(name, args, agent_context(db))
85
+ ensure
86
+ db&.close
87
+ end
88
+
89
+ def agent_context(db)
90
+ Agent::Context.new(db: db, git: Agent::Git.new(Dir.pwd), io: @io, clock: Agent::SystemClock.new,
91
+ pipeline: Agent::LivePipeline.new(Dir.pwd))
92
+ end
93
+
94
+ def run_init(args) = setup_commands.init(args)
95
+ def run_install_hooks(args) = setup_commands.install_hooks(args)
96
+ def run_check(args) = setup_commands.check(args)
97
+ def setup_commands = Setup::Commands.new(Dir.pwd, @io.stdout)
98
+
99
+ def run_extract(args)
100
+ require_relative "evidence/extract_command"
101
+ Evidence::ExtractCommand.new(Dir.pwd, @io).run(args)
102
+ end
103
+
104
+ def run_prune(_args)
105
+ require_relative "pipeline/worktree_prune" # mutineer:disable-line statement_removal
106
+ removed = Pipeline::WorktreePrune.new(Pipeline::Worktrees.new(Dir.pwd)).run
107
+ @io.stdout.puts "Removed #{removed} fun-ci worktree#{"s" unless removed == 1}."
108
+ 0
109
+ rescue Pipeline::WorktreePrune::Busy, Pipeline::Worktrees::GitError => e
110
+ @io.stderr.puts "fun-ci prune: #{e.message}"
111
+ 1
112
+ end
113
+
114
+ def setup_db
115
+ Persistence::StateDir.prepare(@db_dir)
116
+ db_path = File.join(@db_dir, "db.sqlite3")
117
+ db = Persistence::Database.connection(db_path)
118
+ Persistence::Database.migrate!(db)
119
+ db
120
+ end
121
+
122
+ def unknown_command(subcommand)
123
+ @io.stderr.puts "fun-ci: unknown command '#{subcommand}'", "" if subcommand
124
+ @io.stderr.puts "Usage: fun-ci <command> [options]", "", "Run 'fun-ci --help' for available commands."
125
+ 1
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ # What `fun-ci --help` and `fun-ci --version` print; each answers the exit code.
5
+ module CliHelp
6
+ def self.help(out)
7
+ out.puts TEXT
8
+ 0
9
+ end
10
+
11
+ def self.version(out)
12
+ out.puts "fun-ci #{FunCi::VERSION}"
13
+ 0
14
+ end
15
+
16
+ TEXT = <<~HELP
17
+ fun-ci - Opinionated, local-first CI for your projects
18
+
19
+ Usage:
20
+ fun-ci <command> [options]
21
+
22
+ Commands:
23
+ trigger Run CI pipeline for a commit
24
+ console Launch the admin TUI dashboard
25
+ init Initialize .fun-ci/ with template scripts
26
+ install-hooks Install post-commit and pre-push git hooks
27
+ check Verify project setup
28
+ prune Remove fun-ci's worktrees when no pipeline is running
29
+ status Say where a commit's run stands, as an exit code an agent can act on
30
+ runs List this project's recent runs, newest first
31
+ wait Wait for a commit's verdict, then exit with it as status does
32
+ events Print this project's runs' events as JSON lines
33
+ why Print everything kept about why a commit's stage failed
34
+ extract Try a stage's evidence extractors on a saved output
35
+
36
+ Options:
37
+ -h, --help Show this help message
38
+ --version Show version
39
+
40
+ Trigger options:
41
+ --background Run the pipeline in the background and return at once
42
+
43
+ Agent options:
44
+ --need LEVEL (status, wait, why) What must pass: build, fast (default) or all
45
+ --within TIME (wait) Give up undecided after 30, 30s or 5m
46
+ --follow-branch (wait) Move on to the newer commit that superseded the run
47
+ -n N (runs) How many runs to list (default 10)
48
+ --branch NAME (runs) Only runs on this branch
49
+ --follow (events) Keep printing events as they happen, until stopped
50
+ --only failures (events) Print only failed stages and superseded runs
51
+ --json Print JSON instead of text (events always print JSON lines)
52
+ Exit codes: 0 passed, 1 failed, 2 over budget, 3 undecided,
53
+ 4 superseded, 5 no run, 64 usage error
54
+
55
+ Init options:
56
+ --everything Run init + install-hooks + check in one step
57
+
58
+ Examples:
59
+ fun-ci init --everything
60
+ fun-ci trigger abc1234 main
61
+ fun-ci trigger --background abc1234 main
62
+ fun-ci console
63
+ HELP
64
+ end
65
+ end