fun_ci 2.0.0-x86_64-linux

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,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Setup
5
+ # What `fun-ci init` tells a coding agent working in the project, in the
6
+ # instructions file agents read (acceptance-tests.md, AT-9.15): AGENTS.md,
7
+ # or CLAUDE.md when that is the only one. The marker keeps it to once.
8
+ module AgentInstructions
9
+ MARKER = "<!-- fun-ci: agent instructions -->"
10
+ SECTION = <<~MD.freeze
11
+ ## fun-ci
12
+
13
+ #{MARKER}
14
+ This project runs fun-ci on every commit. After each commit, run the
15
+ `fun-ci wait` command it prints, in the background, and act on its exit
16
+ code: 0 passed, 1 failed, 2 over budget, 3 undecided, 4 superseded.
17
+ `fun-ci wait --need all` must exit 0 before the work is called done.
18
+ `fun-ci runs` lists recent runs, and `fun-ci status` says where one stands.
19
+ MD
20
+
21
+ def self.file_for(names) = names.include?("CLAUDE.md") && !names.include?("AGENTS.md") ? "CLAUDE.md" : "AGENTS.md"
22
+
23
+ # The text with the section added, or nil when it has it already.
24
+ def self.merged(text)
25
+ return nil if text.include?(MARKER)
26
+
27
+ text.strip.empty? ? SECTION : "#{text.rstrip}\n\n#{SECTION}"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "installer"
4
+ require_relative "hook_writer"
5
+ require_relative "git_hooks"
6
+ require_relative "setup_checker"
7
+ require_relative "legacy_hooks"
8
+
9
+ module FunCi
10
+ module Setup
11
+ # `fun-ci init`, `install-hooks` and `check`, in the project directory.
12
+ # `hooks_dir` answers where git runs a project's hooks from.
13
+ class Commands
14
+ def initialize(project_root, stdout, hooks_dir: GitHooks.method(:dir))
15
+ @project_root = project_root
16
+ @stdout = stdout
17
+ @hooks_dir = hooks_dir
18
+ end
19
+
20
+ def init(args)
21
+ code = Installer.run(project_root: @project_root, stdout: @stdout)
22
+ return code if !code.zero? || !args.include?("--everything")
23
+
24
+ code = install_hooks([])
25
+ return code unless code.zero?
26
+
27
+ check([])
28
+ end
29
+
30
+ def install_hooks(args)
31
+ LegacyHooks.new(@project_root).remove(@stdout)
32
+ install(args.any? ? [args.first] : HookScript.types)
33
+ end
34
+
35
+ def check(_args) = SetupChecker.run(project_root: @project_root, stdout: @stdout)
36
+
37
+ private
38
+
39
+ def install(types)
40
+ hooks_dir = @hooks_dir.call(@project_root)
41
+ types.each do |type|
42
+ code = HookWriter.run(hooks_dir: hooks_dir, hook_type: type, stdout: @stdout)
43
+ return code unless code.zero?
44
+ end
45
+ 0
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "../pipeline/git_environment"
5
+
6
+ module FunCi
7
+ module Setup
8
+ # Where git runs a project's hooks from: its git directory's hooks, or
9
+ # where core.hooksPath points, and from a linked worktree the
10
+ # repository's own. Git is asked, as .git may be a file or be ignored.
11
+ module GitHooks
12
+ # The directory, or nil outside a git repository.
13
+ def self.dir(project_root)
14
+ out, status = Open3.capture2(Pipeline::GitEnvironment::CLEAN, "git", "rev-parse", "--git-path", "hooks",
15
+ chdir: project_root, err: File::NULL)
16
+ status.success? ? File.expand_path(out.strip, project_root) : nil
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Setup
5
+ # The script fun-ci writes into each git hook it manages.
6
+ module HookScript
7
+ MARKER = "# fun-ci-managed-hook"
8
+ NULL_SHA = "0" * 40
9
+
10
+ GUARD = <<~SH.freeze
11
+ #!/bin/sh
12
+ #{MARKER}
13
+ if ! command -v fun-ci >/dev/null 2>&1; then
14
+ echo "fun-ci is not installed or not on PATH, so this runs without CI. Install it with: gem install fun_ci" >&2
15
+ exit 0
16
+ fi
17
+ SH
18
+
19
+ # After a commit: the commit just made, in the background.
20
+ POST_COMMIT = <<~SH.freeze
21
+ COMMIT=$(git rev-parse HEAD 2>/dev/null || echo "#{NULL_SHA}")
22
+ BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
23
+ fun-ci trigger --background "$COMMIT" "$BRANCH"
24
+ SH
25
+
26
+ # Before a push: the fast verdict of each commit git says it pushes
27
+ # (acceptance-tests.md, AT-9.13). A deleted ref pushes the null SHA, and
28
+ # exit 5 is a project not set up for fun-ci, which pushes without CI.
29
+ PRE_PUSH = <<~SH.freeze
30
+ status=0
31
+ while read -r local_ref local_sha remote_ref remote_sha; do
32
+ [ "$local_sha" = "#{NULL_SHA}" ] && continue
33
+ fun-ci wait "$local_sha" --need fast
34
+ code=$?
35
+ [ "$code" -eq 0 ] || [ "$code" -eq 5 ] || status=1
36
+ done
37
+ exit $status
38
+ SH
39
+
40
+ BODIES = { "post-commit" => POST_COMMIT, "pre-push" => PRE_PUSH }.freeze
41
+
42
+ def self.types = BODIES.keys
43
+ def self.for(hook_type) = GUARD + BODIES.fetch(hook_type)
44
+ def self.managed?(script) = script.include?(MARKER)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "hook_script"
5
+
6
+ module FunCi
7
+ module Setup
8
+ # Writes a hook into `hooks_dir`, where git runs the project's hooks
9
+ # from (GitHooks.dir); nil outside a git repository.
10
+ class HookWriter
11
+ def self.run(hooks_dir:, hook_type:, stdout: $stdout)
12
+ new(hooks_dir: hooks_dir, hook_type: hook_type, stdout: stdout).run
13
+ end
14
+
15
+ def initialize(hooks_dir:, hook_type:, stdout:)
16
+ @hooks_dir = hooks_dir
17
+ @hook_type = hook_type
18
+ @stdout = stdout
19
+ end
20
+
21
+ def run
22
+ return reject("Not a git repository: no .git/ found.") unless git_repo?
23
+ return reject("Unknown hook type: #{@hook_type}") unless HookScript.types.include?(@hook_type)
24
+ return skip("Hook #{@hook_type} already exists from another tool, so it was left alone.") if foreign_hook?
25
+
26
+ write_hook
27
+ @stdout.puts "Installed #{@hook_type} hook."
28
+ 0
29
+ end
30
+
31
+ private
32
+
33
+ def git_repo? = !@hooks_dir.nil?
34
+ def hook_path = File.join(@hooks_dir, @hook_type)
35
+
36
+ def foreign_hook?
37
+ File.exist?(hook_path) && !HookScript.managed?(File.read(hook_path))
38
+ end
39
+
40
+ def write_hook
41
+ FileUtils.mkdir_p(@hooks_dir)
42
+ File.write(hook_path, HookScript.for(@hook_type))
43
+ File.chmod(0o755, hook_path)
44
+ end
45
+
46
+ def reject(message)
47
+ @stdout.puts message
48
+ 1
49
+ end
50
+
51
+ def skip(message)
52
+ @stdout.puts message
53
+ 0
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "project_detector"
4
+ require_relative "template_writer"
5
+ require_relative "maven_linter_detector"
6
+ require_relative "agent_instructions"
7
+
8
+ module FunCi
9
+ module Setup
10
+ class Installer
11
+ def self.run(project_root:, stdout: $stdout)
12
+ new(project_root: project_root, stdout: stdout).run
13
+ end
14
+
15
+ def initialize(project_root:, stdout:)
16
+ @project_root = project_root
17
+ @stdout = stdout
18
+ end
19
+
20
+ # A project set up for fun-ci, new or not, also tells agents what to do.
21
+ def run
22
+ code = set_up
23
+ tell_agents if code.zero?
24
+ code
25
+ end
26
+
27
+ private
28
+
29
+ def set_up
30
+ return report(".fun-ci/ already exists, so init left it as it is.", 0) if initialised?
31
+
32
+ detected = ProjectDetector.new(Dir.children(@project_root)).detect
33
+ return report("Could not detect project type. Create .fun-ci/ manually.", 1) if detected == :unknown
34
+
35
+ write_templates(detected)
36
+ end
37
+
38
+ def tell_agents
39
+ file = AgentInstructions.file_for(Dir.children(@project_root))
40
+ path = File.join(@project_root, file)
41
+ merged = AgentInstructions.merged(File.exist?(path) ? File.read(path) : "")
42
+ return unless merged
43
+
44
+ File.write(path, merged)
45
+ @stdout.puts "Told agents what to do after a commit, in #{file}."
46
+ end
47
+
48
+ def initialised?
49
+ Dir.exist?(File.join(@project_root, ".fun-ci"))
50
+ end
51
+
52
+ def write_templates(detected)
53
+ @stdout.puts "Detected: #{detected.to_s.tr("_", " ")}"
54
+ TemplateWriter.new(detected, @project_root, lint_override: detect_maven_linter(detected)).write
55
+ report("Created .fun-ci/ with template scripts.", 0)
56
+ end
57
+
58
+ def report(message, exit_code)
59
+ @stdout.puts message
60
+ exit_code
61
+ end
62
+
63
+ def detect_maven_linter(detected)
64
+ return nil unless detected == :jvm_maven
65
+
66
+ command = MavenLinterDetector.new(File.read(File.join(@project_root, "pom.xml"))).lint_command
67
+ command == MavenLinterDetector::DEFAULT_COMMAND ? nil : command
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hook_script"
4
+
5
+ module FunCi
6
+ module Setup
7
+ # fun-ci 1.x ran the background pipeline from a pre-commit hook, before
8
+ # the commit existed. The hook fun-ci wrote goes on upgrade, and is warned
9
+ # about until then; one someone wrote by hand that still calls fun-ci is
10
+ # warned about, never touched.
11
+ class LegacyHooks
12
+ WARNING = ".git/hooks/pre-commit calls fun-ci, which now runs after the commit: " \
13
+ "take fun-ci out of it and run `fun-ci install-hooks`"
14
+ NOT_UPGRADED = "fun-ci 1.x's pre-commit hook is still installed, so each run tests the commit before yours " \
15
+ "and yours gets none: run `fun-ci install-hooks`"
16
+
17
+ def initialize(project_root)
18
+ @hook = File.join(project_root, ".git", "hooks", "pre-commit")
19
+ end
20
+
21
+ def remove(stdout)
22
+ return unless File.exist?(@hook) && HookScript.managed?(File.read(@hook))
23
+
24
+ File.delete(@hook)
25
+ stdout.puts "Removed fun-ci's 1.x pre-commit hook; the background pipeline now runs from post-commit."
26
+ end
27
+
28
+ def warnings
29
+ return [] unless File.exist?(@hook)
30
+
31
+ script = File.read(@hook)
32
+ return [NOT_UPGRADED] if HookScript.managed?(script)
33
+
34
+ script.include?("fun-ci") ? [WARNING] : []
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Setup
5
+ class MavenLinterDetector
6
+ LINTERS = [
7
+ { artifact_id: "detekt-maven-plugin", command: "mvn detekt:check" },
8
+ { artifact_id: "ktlint-maven-plugin", command: "mvn ktlint:check" },
9
+ { artifact_id: "maven-checkstyle-plugin", command: "mvn checkstyle:check" },
10
+ { artifact_id: "spotbugs-maven-plugin", command: "mvn spotbugs:check" },
11
+ { artifact_id: "maven-pmd-plugin", command: "mvn pmd:check" }
12
+ ].freeze
13
+
14
+ DEFAULT_COMMAND = "mvn verify -DskipTests"
15
+
16
+ def initialize(pom_content)
17
+ @pom_content = pom_content
18
+ end
19
+
20
+ def lint_command
21
+ match = LINTERS.find { |linter| @pom_content.include?(linter[:artifact_id]) }
22
+ match ? match[:command] : DEFAULT_COMMAND
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require_relative "settings"
5
+ require_relative "../evidence/settings"
6
+ require_relative "../evidence/start"
7
+
8
+ module FunCi
9
+ module Setup
10
+ class ProjectConfig
11
+ REQUIRED_SCRIPTS = %w[lint.sh build.sh fast.sh slow.sh].freeze
12
+
13
+ def initialize(project_root)
14
+ @project_root = project_root
15
+ @fun_ci_dir = File.join(project_root, ".fun-ci")
16
+ end
17
+
18
+ def folder_exists?
19
+ Dir.exist?(@fun_ci_dir)
20
+ end
21
+
22
+ def validate
23
+ return ["No .fun-ci/ folder found in #{@project_root}"] unless folder_exists?
24
+
25
+ REQUIRED_SCRIPTS.flat_map { |script| script_errors(script) } + settings.errors
26
+ end
27
+
28
+ # Mistakes in the `evidence` key, which `fun-ci check` reports but which
29
+ # never stop a pipeline: the entry with the mistake is left out instead.
30
+ def evidence_errors = Evidence::Settings.load(File.join(@fun_ci_dir, "config")).errors(root: @project_root)
31
+
32
+ # The presets whose markers the project has, which run when a stage fails
33
+ # and its output shows their tool.
34
+ def presets = preset_candidates.select(&:because).map { |found| found.preset.name }
35
+
36
+ # The presets without markers, which read what any project's output
37
+ # may hold, such as JSON logs.
38
+ def any_project_presets = preset_candidates.reject(&:because).map { |found| found.preset.name }
39
+
40
+ def worktree_slots = settings.worktree_slots
41
+
42
+ def script_path(stage)
43
+ File.join(@fun_ci_dir, "#{stage}.sh")
44
+ end
45
+
46
+ # The shell command that runs a stage's script on a commit, its path
47
+ # quoted, since a project's path may hold spaces.
48
+ def stage_command(stage, sha) = "#{Shellwords.escape(script_path(stage))} #{sha}"
49
+
50
+ private
51
+
52
+ def settings = Settings.new(File.join(@fun_ci_dir, "config"))
53
+
54
+ def preset_candidates
55
+ evidence = Evidence::Settings.load(File.join(@fun_ci_dir, "config"))
56
+ Evidence::Start.candidates(Evidence::Worktree.new(@project_root), evidence, "all")
57
+ end
58
+
59
+ def script_errors(script)
60
+ path = File.join(@fun_ci_dir, script)
61
+ return [".fun-ci/#{script} is not found"] unless File.exist?(path)
62
+ return [".fun-ci/#{script} is not executable"] unless File.executable?(path)
63
+
64
+ []
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Setup
5
+ # Which kind of project a directory is, from the names at its top: the
6
+ # first stack, in this order, any of whose markers is there. A marker is a
7
+ # name, a glob, or a list of them that must all be there. A language
8
+ # comes before the tooling that sits beside it (Node beside Python, a
9
+ # Makefile around Go), and a lockfile picks the package manager.
10
+ class ProjectDetector
11
+ STACKS = {
12
+ ruby_rspec: [%w[Gemfile spec], %w[Gemfile .rspec]],
13
+ ruby_bundler: ["Gemfile"],
14
+ jvm_gradle_kotlin: %w[build.gradle.kts settings.gradle.kts],
15
+ jvm_gradle_groovy: %w[build.gradle settings.gradle],
16
+ jvm_maven: ["pom.xml"],
17
+ rust_cargo: ["Cargo.toml"],
18
+ go: ["go.mod"],
19
+ elixir_mix: ["mix.exs"],
20
+ dart: ["pubspec.yaml"],
21
+ swift: ["Package.swift"],
22
+ php_composer: ["composer.json"],
23
+ dotnet: %w[*.sln *.csproj *.fsproj],
24
+ python_uv: ["uv.lock"],
25
+ python_poetry: ["poetry.lock"],
26
+ python: %w[pyproject.toml setup.py setup.cfg requirements.txt],
27
+ deno: %w[deno.json deno.jsonc],
28
+ bun: %w[bun.lock bun.lockb bunfig.toml],
29
+ node_pnpm: ["pnpm-lock.yaml"],
30
+ node_yarn: ["yarn.lock"],
31
+ node_npm: ["package.json"],
32
+ perl: %w[cpanfile Makefile.PL Build.PL dist.ini],
33
+ cmake: ["CMakeLists.txt"],
34
+ make: %w[Makefile makefile GNUmakefile]
35
+ }.freeze
36
+
37
+ def initialize(filenames)
38
+ @filenames = filenames
39
+ end
40
+
41
+ def detect
42
+ STACKS.find { |_, markers| markers.any? { |marker| present?(marker) } }&.first || :unknown
43
+ end
44
+
45
+ private
46
+
47
+ def present?(marker)
48
+ Array(marker).all? { |part| @filenames.any? { |name| names?(part, name) } }
49
+ end
50
+
51
+ def names?(part, name) = File.fnmatch?(part, name)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module FunCi
6
+ module Setup
7
+ # .fun-ci/config: settings for this machine's runs, as YAML. Each one left
8
+ # out takes its default; one that is wrong is reported and the default used,
9
+ # as are all of them when the file isn't YAML.
10
+ class Settings
11
+ DEFAULTS = { "worktree_slots" => 2 }.freeze
12
+
13
+ def initialize(path)
14
+ @path = path
15
+ end
16
+
17
+ def worktree_slots = errors.empty? ? values.fetch("worktree_slots") : DEFAULTS.fetch("worktree_slots")
18
+
19
+ def errors
20
+ return [".fun-ci/config must be a mapping such as `worktree_slots: 2`"] unless raw.is_a?(Hash)
21
+
22
+ slots = values["worktree_slots"]
23
+ return [] if slots.is_a?(Integer) && slots.positive?
24
+
25
+ [".fun-ci/config: worktree_slots must be a whole number above 0, not #{slots.inspect}"]
26
+ rescue Psych::SyntaxError => e
27
+ [".fun-ci/config is not YAML: #{e.problem} (line #{e.line})"]
28
+ end
29
+
30
+ private
31
+
32
+ def raw = File.exist?(@path) ? YAML.safe_load_file(@path) || {} : {}
33
+ def values = DEFAULTS.merge(raw)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "project_config"
4
+ require_relative "legacy_hooks"
5
+
6
+ module FunCi
7
+ module Setup
8
+ class SetupChecker
9
+ def self.run(project_root:, stdout: $stdout)
10
+ new(config: ProjectConfig.new(project_root), hooks: LegacyHooks.new(project_root), stdout: stdout).run
11
+ end
12
+
13
+ def initialize(config:, hooks:, stdout:)
14
+ @config = config
15
+ @hooks = hooks
16
+ @stdout = stdout
17
+ end
18
+
19
+ # Warnings are printed but don't fail the check.
20
+ def run
21
+ errors = @config.validate + @config.evidence_errors
22
+ @stdout.puts(errors.empty? ? "All OK. The project is configured." : errors)
23
+ list_presets
24
+ @hooks.warnings.each { |warning| @stdout.puts "Warning: #{warning}" }
25
+ errors.empty? ? 0 : 1
26
+ end
27
+
28
+ private
29
+
30
+ def list_presets
31
+ lists = { "this project" => @config.presets, "any project's output" => @config.any_project_presets }
32
+ lists.each { |what, names| @stdout.puts "Evidence presets for #{what}: #{names.join(", ")}" if names.any? }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shell"
4
+
5
+ module FunCi
6
+ module Setup
7
+ # JavaScript and TypeScript: Deno, Bun, and Node with its package managers.
8
+ module StageTemplates
9
+ JAVASCRIPT = {
10
+ deno: stages("deno lint", "deno check", "deno test", "deno task test:slow"),
11
+ bun: stages("bunx eslint .", "bun install", "bun test", "bun run test:slow"),
12
+ node_pnpm: stages("pnpm exec eslint .", "pnpm install", "pnpm test", "pnpm run test:slow"),
13
+ node_yarn: stages("yarn eslint .", "yarn install", "yarn test", "yarn run test:slow"),
14
+ node_npm: stages("npx eslint .", "npm install", "npm test", "npm run test:slow")
15
+ }.freeze
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shell"
4
+
5
+ module FunCi
6
+ module Setup
7
+ # Stacks that run on a virtual machine: the JVM, .NET, the BEAM and Dart's.
8
+ module StageTemplates
9
+ GRADLE = stages("./gradlew check -x test", "./gradlew assemble", "./gradlew test", "./gradlew integrationTest")
10
+ .freeze
11
+
12
+ MANAGED = {
13
+ jvm_gradle_kotlin: GRADLE,
14
+ jvm_gradle_groovy: GRADLE,
15
+ jvm_maven: stages("mvn verify -DskipTests", "mvn compile", "mvn test", "mvn verify"),
16
+ dotnet: stages("dotnet format --verify-no-changes", "dotnet build",
17
+ 'dotnet test --filter "Category!=Slow"', 'dotnet test --filter "Category=Slow"'),
18
+ elixir_mix: stages("mix format --check-formatted", "mix deps.get && mix compile",
19
+ "mix test --exclude slow", "mix test --only slow"),
20
+ dart: stages("dart analyze", "dart pub get", "dart test --exclude-tags slow", "dart test --tags slow")
21
+ }.freeze
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shell"
4
+
5
+ module FunCi
6
+ module Setup
7
+ # Stacks that compile to the machine: Rust, Go, Swift, and C or C++ built
8
+ # with CMake or make.
9
+ module StageTemplates
10
+ CTEST = "ctest --test-dir build --output-on-failure --no-tests=error"
11
+ CLANG_FORMAT = "git ls-files -z '*.c' '*.cc' '*.cpp' '*.h' '*.hpp' | xargs -0 clang-format --dry-run --Werror"
12
+
13
+ NATIVE = {
14
+ rust_cargo: stages("cargo clippy --all-targets -- -D warnings", "cargo build --all-targets",
15
+ "cargo test", "cargo test -- --ignored"),
16
+ go: stages("go vet ./...", "go build ./...", "go test -short ./...", "go test ./..."),
17
+ swift: stages("swift format lint --recursive --strict .", "swift build --build-tests",
18
+ "swift test --skip Slow", "swift test --filter Slow"),
19
+ cmake: stages(CLANG_FORMAT, "cmake -S . -B build && cmake --build build",
20
+ "#{CTEST} -LE slow", "#{CTEST} -L slow"),
21
+ make: stages("make lint", "make", "make test", "make test-slow")
22
+ }.freeze
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shell"
4
+
5
+ module FunCi
6
+ module Setup
7
+ # Interpreted stacks: Ruby, Python, PHP and Perl.
8
+ module StageTemplates
9
+ def self.python(runner, build)
10
+ stages("#{runner}ruff check .", build, "#{runner}pytest -m \"not slow\"", "#{runner}pytest -m slow")
11
+ end
12
+
13
+ SCRIPTING = {
14
+ ruby_rspec: stages("bundle exec rubocop", "bundle install --quiet",
15
+ "bundle exec rspec --tag ~slow", "bundle exec rspec --tag slow"),
16
+ ruby_bundler: stages("bundle exec rubocop", "bundle install --quiet",
17
+ "bundle exec rake test", "bundle exec rake test:slow"),
18
+ php_composer: stages("vendor/bin/phpcs", "composer install --no-interaction --no-progress",
19
+ "vendor/bin/phpunit --exclude-group slow", "vendor/bin/phpunit --group slow"),
20
+ python_uv: python("uv run ", "uv sync"),
21
+ python_poetry: python("poetry run ", "poetry install"),
22
+ python: python("python3 -m ", "python3 -m compileall -q ."),
23
+ perl: stages("perlcritic lib", "cpanm --installdeps --notest .", "prove -lr t", "prove -lr xt")
24
+ }.freeze
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Setup
5
+ # What every stack's stage scripts are made of.
6
+ module StageTemplates
7
+ def self.script(body) = "#!/bin/sh\n#{body}\n"
8
+
9
+ def self.stages(lint, build, fast, slow)
10
+ { "lint.sh" => lint, "build.sh" => build, "fast.sh" => fast, "slow.sh" => slow }.transform_values do |body|
11
+ script(body)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end