fun_ci 2.0.1 → 2.1.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +101 -0
  3. data/README.md +33 -5
  4. data/lib/fun_ci/agent/command_support.rb +14 -1
  5. data/lib/fun_ci/agent/commands.rb +1 -0
  6. data/lib/fun_ci/agent/context.rb +3 -2
  7. data/lib/fun_ci/agent/events.rb +19 -5
  8. data/lib/fun_ci/agent/events_command.rb +1 -1
  9. data/lib/fun_ci/agent/exit_code.rb +1 -1
  10. data/lib/fun_ci/agent/next_step.rb +26 -0
  11. data/lib/fun_ci/agent/options.rb +6 -4
  12. data/lib/fun_ci/agent/output.rb +15 -2
  13. data/lib/fun_ci/agent/report_stage.rb +1 -1
  14. data/lib/fun_ci/agent/reports.rb +13 -9
  15. data/lib/fun_ci/agent/run_details.rb +31 -0
  16. data/lib/fun_ci/agent/run_report.rb +4 -3
  17. data/lib/fun_ci/agent/runs_text.rb +7 -4
  18. data/lib/fun_ci/agent/snapshots.rb +13 -3
  19. data/lib/fun_ci/agent/status_command.rb +2 -2
  20. data/lib/fun_ci/agent/status_json.rb +2 -1
  21. data/lib/fun_ci/agent/status_text.rb +13 -2
  22. data/lib/fun_ci/agent/trunk_json.rb +32 -0
  23. data/lib/fun_ci/agent/trunk_reading.rb +53 -0
  24. data/lib/fun_ci/agent/trunk_text.rb +50 -0
  25. data/lib/fun_ci/agent/trunk_verdict.rb +20 -0
  26. data/lib/fun_ci/agent/trunk_why.rb +52 -0
  27. data/lib/fun_ci/agent/trunk_why_json.rb +23 -0
  28. data/lib/fun_ci/agent/wait_command.rb +5 -5
  29. data/lib/fun_ci/agent/why_command.rb +11 -0
  30. data/lib/fun_ci/cli.rb +1 -1
  31. data/lib/fun_ci/cli_help.rb +4 -2
  32. data/lib/fun_ci/console/board_data.rb +22 -8
  33. data/lib/fun_ci/console/cancelled_folding.rb +19 -0
  34. data/lib/fun_ci/console/console_events.rb +5 -2
  35. data/lib/fun_ci/console/console_log.rb +4 -1
  36. data/lib/fun_ci/console/console_state.rb +7 -1
  37. data/lib/fun_ci/console/row_order.rb +31 -0
  38. data/lib/fun_ci/console/run_message.rb +9 -2
  39. data/lib/fun_ci/console/trunk_events.rb +43 -0
  40. data/lib/fun_ci/console/trunk_marks.rb +53 -0
  41. data/lib/fun_ci/console/view.rb +9 -8
  42. data/lib/fun_ci/evidence/caps.rb +7 -2
  43. data/lib/fun_ci/persistence/active_runs.rb +5 -4
  44. data/lib/fun_ci/persistence/database.rb +23 -2
  45. data/lib/fun_ci/persistence/null_recorder.rb +8 -0
  46. data/lib/fun_ci/persistence/pipeline_recorder.rb +3 -0
  47. data/lib/fun_ci/persistence/pipeline_run.rb +24 -3
  48. data/lib/fun_ci/persistence/trunk_checks.rb +68 -0
  49. data/lib/fun_ci/persistence/trunk_fetches.rb +57 -0
  50. data/lib/fun_ci/persistence/trunk_recording.rb +49 -0
  51. data/lib/fun_ci/pipeline/pipeline_forker.rb +17 -2
  52. data/lib/fun_ci/pipeline/process_runner.rb +18 -4
  53. data/lib/fun_ci/pipeline/trigger.rb +10 -1
  54. data/lib/fun_ci/pipeline/trigger_command.rb +9 -5
  55. data/lib/fun_ci/pipeline/trigger_params.rb +5 -3
  56. data/lib/fun_ci/pipeline/trunk_run.rb +81 -0
  57. data/lib/fun_ci/setup/agent_instructions.rb +3 -0
  58. data/lib/fun_ci/setup/gradle_linter_detector.rb +23 -0
  59. data/lib/fun_ci/setup/installer.rb +10 -10
  60. data/lib/fun_ci/setup/lint_detection.rb +32 -0
  61. data/lib/fun_ci/setup/maven_linter_detector.rb +4 -2
  62. data/lib/fun_ci/setup/project_config.rb +3 -1
  63. data/lib/fun_ci/setup/settings.rb +35 -3
  64. data/lib/fun_ci/setup/setup_checker.rb +7 -2
  65. data/lib/fun_ci/setup/stage_templates/managed.rb +12 -7
  66. data/lib/fun_ci/setup/stage_templates/native.rb +1 -1
  67. data/lib/fun_ci/setup/stage_templates/shell.rb +12 -3
  68. data/lib/fun_ci/setup/stage_templates.rb +1 -1
  69. data/lib/fun_ci/setup/trunk_report.rb +28 -0
  70. data/lib/fun_ci/trunk/check.rb +56 -0
  71. data/lib/fun_ci/trunk/checker.rb +91 -0
  72. data/lib/fun_ci/trunk/description.rb +52 -0
  73. data/lib/fun_ci/trunk/fetch.rb +58 -0
  74. data/lib/fun_ci/trunk/first_fetch.rb +27 -0
  75. data/lib/fun_ci/trunk/git.rb +102 -0
  76. data/lib/fun_ci/trunk/local.rb +58 -0
  77. data/lib/fun_ci/trunk/merge_check.rb +54 -0
  78. data/lib/fun_ci/trunk/regions.rb +38 -0
  79. data/lib/fun_ci/trunk/resolver.rb +64 -0
  80. data/lib/fun_ci/trunk/shown.rb +47 -0
  81. data/lib/fun_ci/trunk/tip_reader.rb +39 -0
  82. data/lib/fun_ci/version.rb +1 -1
  83. metadata +32 -1
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../trunk/check"
4
+ require_relative "../trunk/checker"
5
+
6
+ module FunCi
7
+ module Pipeline
8
+ # A run's check against the trunk (architecture.md, Checking against the
9
+ # trunk). It begins before the stages, where the database may be used
10
+ # (claiming a fetch, recording its process, reading the other branches'
11
+ # heads); finishes in a thread beside them, which touches no database,
12
+ # since the slow suite's fork closes the recorder, checking the other
13
+ # heads too when the trunk has moved; and is recorded once the stages are
14
+ # done, through whichever recorder the run holds then. Whatever goes wrong
15
+ # is recorded as an unknown check rather than raised into the run.
16
+ class TrunkRun
17
+ Result = Trunk::Checker::Result
18
+ # What the thread found: the run's own result, and the other heads' checks.
19
+ Found = Data.define(:result, :rechecks)
20
+
21
+ # For a project that checks no trunk.
22
+ class Skipped
23
+ def finish(_recorder) = nil
24
+ def notice = nil
25
+ end
26
+
27
+ # trunk: begins a check (#start(sha, fetches), nil for none), finishes it
28
+ # (#finish(pending)) and checks other heads against a tip (#recheck(heads, tip)).
29
+ def self.start(trunk, sha, recorder)
30
+ pending = trunk.start(sha, recorder.trunk_fetches) { |pid| recorder.trunk_fetch_process(pid) }
31
+ return Skipped.new unless pending
32
+
33
+ recorder.trunk_check_started
34
+ heads = recorder.trunk_heads(except: sha)
35
+ new(Thread.new { found(trunk, pending, sha, heads) }, trunk.notice(pending))
36
+ rescue StandardError => e
37
+ new(Thread.new { Found.new(result: failed(sha, e), rechecks: []) })
38
+ end
39
+
40
+ def self.found(trunk, pending, sha, heads)
41
+ rechecked(trunk, heads, Found.new(result: finished(trunk, pending, sha), rechecks: []))
42
+ end
43
+
44
+ def self.finished(trunk, pending, sha)
45
+ trunk.finish(pending)
46
+ rescue StandardError => e
47
+ failed(sha, e)
48
+ end
49
+
50
+ # A recheck that goes wrong costs the other heads a fresher check, never the run its own.
51
+ def self.rechecked(trunk, heads, found)
52
+ tip = found.result.check&.tip
53
+ tip ? found.with(rechecks: trunk.recheck(heads, tip)) : found
54
+ rescue StandardError
55
+ found
56
+ end
57
+
58
+ def self.failed(sha, error)
59
+ check = Trunk::Check.new(commit: sha, tip: nil,
60
+ merge: Trunk::Merge.unknown("the check failed: #{error.message}"))
61
+ Result.new(check: check, fetched: nil)
62
+ end
63
+ private_class_method :found, :finished, :rechecked, :failed
64
+
65
+ attr_reader :notice
66
+
67
+ # notice: what to tell the developer of a first fetch, or nil.
68
+ def initialize(thread, notice = nil)
69
+ @thread = thread
70
+ @notice = notice
71
+ end
72
+
73
+ def finish(recorder)
74
+ found = @thread.value
75
+ result = found.result
76
+ recorder.trunk_fetched(result.fetched, result.check&.tip) if result.fetched
77
+ [result.check, *found.rechecks].compact.each { |check| recorder.trunk_checked(check) }
78
+ end
79
+ end
80
+ end
81
+ end
@@ -15,6 +15,9 @@ module FunCi
15
15
  `fun-ci wait` command it prints, in the background, and act on its exit
16
16
  code: 0 passed, 1 failed, 2 over budget, 3 undecided, 4 superseded.
17
17
  `fun-ci wait --need all` must exit 0 before the work is called done.
18
+ Before calling work done, run `fun-ci wait --need all --trunk`; 6 means
19
+ the commit conflicts with the trunk, which has to be integrated first,
20
+ as it prints.
18
21
  `fun-ci runs` lists recent runs, and `fun-ci status` says where one stands.
19
22
  MD
20
23
 
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Setup
5
+ # The source linter a Gradle build applies, whose task lint.sh runs. Lint
6
+ # runs beside the build on the source alone (design.md, Stages side by
7
+ # side), so only linters that compile nothing are named here.
8
+ class GradleLinterDetector
9
+ LINTERS = [
10
+ { plugin: "org.jlleitschuh.gradle.ktlint", command: "./gradlew ktlintCheck" },
11
+ { plugin: "com.diffplug.spotless", command: "./gradlew spotlessCheck" },
12
+ { plugin: "io.gitlab.arturbosch.detekt", command: "./gradlew detekt" }
13
+ ].freeze
14
+
15
+ def initialize(build_file)
16
+ @build_file = build_file
17
+ end
18
+
19
+ # The linter's command, or nil for a build that applies none.
20
+ def lint_command = LINTERS.find { |linter| @build_file.include?(linter[:plugin]) }&.fetch(:command)
21
+ end
22
+ end
23
+ end
@@ -2,12 +2,18 @@
2
2
 
3
3
  require_relative "project_detector"
4
4
  require_relative "template_writer"
5
- require_relative "maven_linter_detector"
5
+ require_relative "lint_detection"
6
6
  require_relative "agent_instructions"
7
7
 
8
8
  module FunCi
9
9
  module Setup
10
10
  class Installer
11
+ # Which stages run side by side, and so what each may touch (design.md,
12
+ # Stages side by side).
13
+ SIDE_BY_SIDE = ["lint.sh runs beside build.sh, so it reads the source alone.",
14
+ "Then fast.sh runs beside slow.sh, both on what build.sh built: have build.sh " \
15
+ "compile everything they need, test code included."].freeze
16
+
11
17
  def self.run(project_root:, stdout: $stdout)
12
18
  new(project_root: project_root, stdout: stdout).run
13
19
  end
@@ -51,21 +57,15 @@ module FunCi
51
57
 
52
58
  def write_templates(detected)
53
59
  @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)
60
+ TemplateWriter.new(detected, @project_root, lint_override: LintDetection.command(detected, @project_root)).write
61
+ @stdout.puts "Created .fun-ci/ with template scripts.", *SIDE_BY_SIDE
62
+ 0
56
63
  end
57
64
 
58
65
  def report(message, exit_code)
59
66
  @stdout.puts message
60
67
  exit_code
61
68
  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
69
  end
70
70
  end
71
71
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "maven_linter_detector"
4
+ require_relative "gradle_linter_detector"
5
+
6
+ module FunCi
7
+ module Setup
8
+ # The lint command init writes in place of the template's, from the source
9
+ # linter the project's build configures; nil keeps the template's.
10
+ module LintDetection
11
+ GRADLE_FILES = %w[build.gradle.kts build.gradle].freeze
12
+
13
+ def self.command(detected, project_root)
14
+ case detected
15
+ when :jvm_maven then maven(project_root)
16
+ when :jvm_gradle_kotlin, :jvm_gradle_groovy then gradle(project_root)
17
+ end
18
+ end
19
+
20
+ def self.maven(project_root)
21
+ command = MavenLinterDetector.new(File.read(File.join(project_root, "pom.xml"))).lint_command
22
+ command == MavenLinterDetector::DEFAULT_COMMAND ? nil : command
23
+ end
24
+
25
+ def self.gradle(project_root)
26
+ file = GRADLE_FILES.map { File.join(project_root, _1) }.find { File.exist?(_1) }
27
+ file && GradleLinterDetector.new(File.read(file)).lint_command
28
+ end
29
+ private_class_method :maven, :gradle
30
+ end
31
+ end
32
+ end
@@ -2,16 +2,18 @@
2
2
 
3
3
  module FunCi
4
4
  module Setup
5
+ # The linter a POM configures, whose goal lint.sh runs. Lint runs beside the
6
+ # build on the source alone (design.md, Stages side by side), so linters that
7
+ # read compiled classes, such as spotbugs, are left to build.sh or fast.sh.
5
8
  class MavenLinterDetector
6
9
  LINTERS = [
7
10
  { artifact_id: "detekt-maven-plugin", command: "mvn detekt:check" },
8
11
  { artifact_id: "ktlint-maven-plugin", command: "mvn ktlint:check" },
9
12
  { artifact_id: "maven-checkstyle-plugin", command: "mvn checkstyle:check" },
10
- { artifact_id: "spotbugs-maven-plugin", command: "mvn spotbugs:check" },
11
13
  { artifact_id: "maven-pmd-plugin", command: "mvn pmd:check" }
12
14
  ].freeze
13
15
 
14
- DEFAULT_COMMAND = "mvn verify -DskipTests"
16
+ DEFAULT_COMMAND = "mvn validate"
15
17
 
16
18
  def initialize(pom_content)
17
19
  @pom_content = pom_content
@@ -38,6 +38,8 @@ module FunCi
38
38
  def any_project_presets = preset_candidates.reject(&:because).map { |found| found.preset.name }
39
39
 
40
40
  def worktree_slots = settings.worktree_slots
41
+ def trunk = settings.trunk
42
+ def trunk_fetch = settings.trunk_fetch
41
43
 
42
44
  def script_path(stage)
43
45
  File.join(@fun_ci_dir, "#{stage}.sh")
@@ -49,7 +51,7 @@ module FunCi
49
51
 
50
52
  private
51
53
 
52
- def settings = Settings.new(File.join(@fun_ci_dir, "config"))
54
+ def settings = Settings.at(File.join(@fun_ci_dir, "config"))
53
55
 
54
56
  def preset_candidates
55
57
  evidence = Evidence::Settings.load(File.join(@fun_ci_dir, "config"))
@@ -9,13 +9,34 @@ module FunCi
9
9
  # as are all of them when the file isn't YAML.
10
10
  class Settings
11
11
  DEFAULTS = { "worktree_slots" => 2 }.freeze
12
+ FETCH_EVERY = 300
13
+ UNITS = { "" => 1, "s" => 1, "m" => 60, "h" => 3600 }.freeze
12
14
 
13
- def initialize(path)
14
- @path = path
15
+ # The settings in the file at `path`, which may not exist.
16
+ def self.at(path) = new(File.exist?(path) ? File.read(path) : nil)
17
+
18
+ # text: the YAML, or nil for none.
19
+ def initialize(text)
20
+ @text = text
15
21
  end
16
22
 
17
23
  def worktree_slots = errors.empty? ? values.fetch("worktree_slots") : DEFAULTS.fetch("worktree_slots")
18
24
 
25
+ # The trunk's name, or nil when none is given or it is no name. A mistake
26
+ # in the trunk settings never stops a pipeline, so none is among `errors`.
27
+ def trunk
28
+ given = setting("trunk")
29
+ given.is_a?(String) ? given : nil
30
+ end
31
+
32
+ # Seconds between fetches of the trunk, or nil when fun-ci doesn't fetch it.
33
+ def trunk_fetch
34
+ given = setting("trunk_fetch")
35
+ return nil if given == false
36
+
37
+ seconds(given) || FETCH_EVERY
38
+ end
39
+
19
40
  def errors
20
41
  return [".fun-ci/config must be a mapping such as `worktree_slots: 2`"] unless raw.is_a?(Hash)
21
42
 
@@ -29,7 +50,18 @@ module FunCi
29
50
 
30
51
  private
31
52
 
32
- def raw = File.exist?(@path) ? YAML.safe_load_file(@path) || {} : {}
53
+ def setting(key)
54
+ raw.is_a?(Hash) ? raw[key] : nil
55
+ rescue Psych::SyntaxError
56
+ nil
57
+ end
58
+
59
+ def seconds(given)
60
+ match = /\A(\d+)([smh]?)\z/.match(given.to_s)
61
+ match && (match[1].to_i * UNITS.fetch(match[2]))
62
+ end
63
+
64
+ def raw = (@text && YAML.safe_load(@text)) || {}
33
65
  def values = DEFAULTS.merge(raw)
34
66
  end
35
67
  end
@@ -2,24 +2,29 @@
2
2
 
3
3
  require_relative "project_config"
4
4
  require_relative "legacy_hooks"
5
+ require_relative "trunk_report"
5
6
 
6
7
  module FunCi
7
8
  module Setup
8
9
  class SetupChecker
9
10
  def self.run(project_root:, stdout: $stdout)
10
- new(config: ProjectConfig.new(project_root), hooks: LegacyHooks.new(project_root), stdout: stdout).run
11
+ new(config: ProjectConfig.new(project_root), hooks: LegacyHooks.new(project_root), stdout: stdout,
12
+ trunk: TrunkReport.new(project_root)).run
11
13
  end
12
14
 
13
- def initialize(config:, hooks:, stdout:)
15
+ # trunk: describes the project's trunk (#lines).
16
+ def initialize(config:, hooks:, stdout:, trunk:)
14
17
  @config = config
15
18
  @hooks = hooks
16
19
  @stdout = stdout
20
+ @trunk = trunk
17
21
  end
18
22
 
19
23
  # Warnings are printed but don't fail the check.
20
24
  def run
21
25
  errors = @config.validate + @config.evidence_errors
22
26
  @stdout.puts(errors.empty? ? "All OK. The project is configured." : errors)
27
+ @stdout.puts(@trunk.lines)
23
28
  list_presets
24
29
  @hooks.warnings.each { |warning| @stdout.puts "Warning: #{warning}" }
25
30
  errors.empty? ? 0 : 1
@@ -6,18 +6,23 @@ module FunCi
6
6
  module Setup
7
7
  # Stacks that run on a virtual machine: the JVM, .NET, the BEAM and Dart's.
8
8
  module StageTemplates
9
- GRADLE = stages("./gradlew check -x test", "./gradlew assemble", "./gradlew test", "./gradlew integrationTest")
10
- .freeze
9
+ NO_GRADLE_LINTER = 'echo "No source linter in the build: put ktlint, spotless or detekt in .fun-ci/lint.sh"'
10
+
11
+ GRADLE = stages(NO_GRADLE_LINTER, "./gradlew assemble testClasses",
12
+ "./gradlew test", "./gradlew integrationTest").freeze
11
13
 
12
14
  MANAGED = {
13
15
  jvm_gradle_kotlin: GRADLE,
14
16
  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",
17
+ jvm_maven: stages("mvn validate", "mvn test-compile", "mvn surefire:test",
18
+ "mvn failsafe:integration-test failsafe:verify"),
19
+ dotnet: stages("dotnet format whitespace --folder --verify-no-changes", "dotnet build",
20
+ 'dotnet test --no-build --filter "Category!=Slow"',
21
+ 'dotnet test --no-build --filter "Category=Slow"'),
22
+ elixir_mix: stages("mix format --check-formatted", "mix deps.get && MIX_ENV=test mix compile",
19
23
  "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")
24
+ dart: stages("dart analyze", "dart pub get --precompile", "dart test --exclude-tags slow",
25
+ "dart test --compiler source --tags slow")
21
26
  }.freeze
22
27
  end
23
28
  end
@@ -15,7 +15,7 @@ module FunCi
15
15
  "cargo test", "cargo test -- --ignored"),
16
16
  go: stages("go vet ./...", "go build ./...", "go test -short ./...", "go test ./..."),
17
17
  swift: stages("swift format lint --recursive --strict .", "swift build --build-tests",
18
- "swift test --skip Slow", "swift test --filter Slow"),
18
+ "swift test --skip-build --skip Slow", "swift test --skip-build --filter Slow"),
19
19
  cmake: stages(CLANG_FORMAT, "cmake -S . -B build && cmake --build build",
20
20
  "#{CTEST} -LE slow", "#{CTEST} -L slow"),
21
21
  make: stages("make lint", "make", "make test", "make test-slow")
@@ -4,11 +4,20 @@ module FunCi
4
4
  module Setup
5
5
  # What every stack's stage scripts are made of.
6
6
  module StageTemplates
7
- def self.script(body) = "#!/bin/sh\n#{body}\n"
7
+ # What each stage runs beside, said where its script is edited (design.md,
8
+ # Stages side by side).
9
+ SIDE_BY_SIDE = {
10
+ "lint.sh" => "Runs beside build.sh, so it reads the source alone.",
11
+ "build.sh" => "Runs beside lint.sh; then fast.sh and slow.sh run side by side on what it builds.",
12
+ "fast.sh" => "Runs beside slow.sh, on what build.sh built.",
13
+ "slow.sh" => "Runs beside fast.sh, on what build.sh built."
14
+ }.freeze
15
+
16
+ def self.script(name, body) = "#!/bin/sh\n# #{SIDE_BY_SIDE.fetch(name)}\n#{body}\n"
8
17
 
9
18
  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)
19
+ { "lint.sh" => lint, "build.sh" => build, "fast.sh" => fast, "slow.sh" => slow }.to_h do |name, body|
20
+ [name, script(name, body)]
12
21
  end
13
22
  end
14
23
  end
@@ -18,7 +18,7 @@ module FunCi
18
18
  # { "lint.sh" => script, ... }; +lint_override+ replaces the lint command.
19
19
  def self.scripts(template_id, lint_override: nil)
20
20
  scripts = TEMPLATES.fetch(template_id)
21
- lint_override ? scripts.merge("lint.sh" => script(lint_override)) : scripts
21
+ lint_override ? scripts.merge("lint.sh" => script("lint.sh", lint_override)) : scripts
22
22
  end
23
23
  end
24
24
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "project_config"
4
+ require_relative "../trunk/description"
5
+ require_relative "../trunk/git"
6
+
7
+ module FunCi
8
+ module Setup
9
+ # The project's trunk as `fun-ci check` describes it. Git is asked only
10
+ # inside a repository: outside one there is no trunk to find.
11
+ class TrunkReport
12
+ NO_REFS = Trunk::Refs.new(remotes: [], branches: [], heads: {})
13
+
14
+ def initialize(project_root)
15
+ @project_root = project_root
16
+ end
17
+
18
+ def lines
19
+ config = ProjectConfig.new(@project_root)
20
+ Trunk::Description.lines(setting: config.trunk, interval: config.trunk_fetch, refs: refs)
21
+ end
22
+
23
+ private
24
+
25
+ def refs = File.exist?(File.join(@project_root, ".git")) ? Trunk::Git.new(@project_root).refs : NO_REFS
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Trunk
5
+ # Seconds a run's fetch of the trunk may take, and then its merge.
6
+ FETCH_DEADLINE = 20
7
+ CHECK_BUDGET = 5
8
+
9
+ # The trunk as fun-ci saw it: its branch, on a remote or (remote nil)
10
+ # local, the SHA at its tip, and when fun-ci first saw that SHA.
11
+ Tip = Data.define(:remote, :branch, :sha, :seen_at)
12
+
13
+ class Tip
14
+ def ref = remote ? "#{remote}/#{branch}" : branch
15
+ end
16
+
17
+ # What merging a commit with a trunk tip would do: ahead and behind count
18
+ # the commits each has that the other lacks, and outcome is "clean",
19
+ # "conflicts" (in `files`) or "unknown" (for `reason`).
20
+ Merge = Data.define(:outcome, :ahead, :behind, :files, :reason)
21
+
22
+ class Merge
23
+ def self.clean(ahead:, behind:) = new(outcome: "clean", ahead: ahead, behind: behind, files: [], reason: nil)
24
+
25
+ def self.conflicts(files, ahead:, behind:)
26
+ new(outcome: "conflicts", ahead: ahead, behind: behind, files: files, reason: nil)
27
+ end
28
+
29
+ def self.unknown(reason) = new(outcome: "unknown", ahead: nil, behind: nil, files: [], reason: reason)
30
+ end
31
+
32
+ # The last fetch of a project's trunk fun-ci tried: when one last
33
+ # succeeded (nil if none has), and why the last one failed, if it did.
34
+ LastFetch = Data.define(:fetched_at, :error)
35
+
36
+ # A conflict with the trunk explained: git's messages about the merge, and
37
+ # each conflicted file as the merge leaves it, by path.
38
+ Explained = Data.define(:messages, :files)
39
+
40
+ # What merge-tree prints: the merged tree's SHA, the conflicted paths, and
41
+ # after a blank line git's messages.
42
+ MergeOutput = Data.define(:tree, :paths, :messages)
43
+
44
+ class MergeOutput
45
+ def self.parse(out)
46
+ lines = out.lines(chomp: true)
47
+ paths = lines.drop(1).take_while { |line| !line.empty? }
48
+ new(tree: lines.first, paths: paths, messages: lines.drop(paths.size + 2).reject(&:empty?))
49
+ end
50
+ end
51
+
52
+ # One check of a commit against one trunk tip (architecture.md, Checking against the trunk);
53
+ # the tip is nil when there was none to check against.
54
+ Check = Data.define(:commit, :tip, :merge)
55
+ end
56
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../setup/project_config"
4
+ require_relative "check"
5
+ require_relative "fetch"
6
+ require_relative "git"
7
+ require_relative "merge_check"
8
+ require_relative "resolver"
9
+ require_relative "tip_reader"
10
+ require_relative "description"
11
+
12
+ module FunCi
13
+ module Trunk
14
+ # Checks a commit against the project's trunk (architecture.md, Checking against the trunk):
15
+ # the ref `trunk:` names in .fun-ci/config, or the one the repository
16
+ # has; none at all when it says `none`. A check starts where the database
17
+ # may be used (resolving the trunk, claiming a fetch, recording its pid)
18
+ # and finishes where it may not (waiting for the fetch, merging).
19
+ class Checker
20
+ SET_IT = "set trunk: in .fun-ci/config"
21
+
22
+ # What a check found, or nil for none, and how its fetch went, or nil when it made none.
23
+ Result = Data.define(:check, :fetched)
24
+ # A check begun: the trunk it found (nil for none), its fetch if started, and the fetch before it.
25
+ Pending = Data.define(:sha, :ref, :started, :last)
26
+
27
+ def self.for(project, env: ENV.to_h)
28
+ new(Git.new(project), Setup::ProjectConfig.new(project), Fetch.new(project, env: env), -> { Time.now })
29
+ end
30
+
31
+ # config: answers #trunk and #trunk_fetch; fetch: starts and finishes a Fetch; clock: answers the time.
32
+ def initialize(git, config, fetch, clock)
33
+ @git = git
34
+ @config = config
35
+ @fetch = fetch
36
+ @clock = clock
37
+ end
38
+
39
+ # fetches: claims a fetch and knows the last (Persistence::TrunkFetches). Yields a fetch's pid.
40
+ # Nil when the project checks no trunk.
41
+ def start(sha, fetches, &)
42
+ setting = @config.trunk
43
+ return nil if setting == "none"
44
+
45
+ ref = Resolver.pick(setting, @git.refs)
46
+ started = fetch?(ref, fetches) ? @fetch.start(ref, &) : nil
47
+ Pending.new(sha: sha, ref: ref, started: started, last: fetches.last)
48
+ end
49
+
50
+ # What to tell the developer of a project's first fetch, so it never comes as a surprise; else nil.
51
+ def notice(pending)
52
+ return nil unless pending&.started && pending.last.nil?
53
+
54
+ Description.first_fetch(pending.ref, @config.trunk_fetch)
55
+ end
56
+
57
+ def finish(pending)
58
+ return Result.new(check: unknown(pending.sha, "no trunk found; #{SET_IT}"), fetched: nil) unless pending.ref
59
+
60
+ fetched = pending.started && @fetch.finish(pending.started, deadline: FETCH_DEADLINE)
61
+ Result.new(check: against(pending, fetched), fetched: fetched)
62
+ end
63
+
64
+ # heads: { commit => trunk SHA it was last checked against }. Each checked
65
+ # against another SHA is checked against `tip`, which it hasn't met yet.
66
+ def recheck(heads, tip)
67
+ heads.reject { |_, sha| sha == tip.sha }.keys.map do |commit|
68
+ Check.new(commit: commit, tip: tip, merge: MergeCheck.new(@git).merge(commit, tip.sha, tip.ref))
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def fetch?(ref, fetches)
75
+ interval = @config.trunk_fetch
76
+ ref&.remote && interval && fetches.claim(now: @clock.call, interval: interval)
77
+ end
78
+
79
+ def against(pending, fetched)
80
+ ref = pending.ref
81
+ tip = TipReader.new(@git, @clock).tip(ref, fetching: !@config.trunk_fetch.nil?, fetched: fetched,
82
+ last: pending.last)
83
+ return unknown(pending.sha, "#{ref} doesn't exist; #{SET_IT}") unless tip
84
+
85
+ Check.new(commit: pending.sha, tip: tip, merge: MergeCheck.new(@git).merge(pending.sha, tip.sha, ref.to_s))
86
+ end
87
+
88
+ def unknown(sha, reason) = Check.new(commit: sha, tip: nil, merge: Merge.unknown(reason))
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resolver"
4
+
5
+ module FunCi
6
+ module Trunk
7
+ # What `fun-ci check` says about the trunk (design.md, The
8
+ # trunk): which ref it is, why that one, and how fun-ci keeps it fresh,
9
+ # so the network access never comes as a surprise; or a warning, which
10
+ # never fails the check, when there is none.
11
+ module Description
12
+ WHY = { configured: "trunk: in .fun-ci/config", default: "%s's default branch",
13
+ usual: "the first usual trunk name on %s", local: "a local branch" }.freeze
14
+ NONE = "Trunk: none (trunk: none in .fun-ci/config), so runs check none."
15
+ NOT_FOUND = "Warning: no trunk found, so runs check none; set trunk: in .fun-ci/config"
16
+ UNITS = [[3600, "hour"], [60, "minute"], [1, "second"]].freeze
17
+
18
+ # interval: seconds between fetches, nil when fun-ci doesn't fetch.
19
+ def self.lines(setting:, interval:, refs:)
20
+ return [NONE] if setting == "none"
21
+
22
+ ref = Resolver.pick(setting, refs)
23
+ return [NOT_FOUND] unless ref
24
+
25
+ ["Trunk: #{ref} (#{WHY.fetch(Resolver.why(setting, refs)).sub("%s",
26
+ ref.remote.to_s)}).#{fetching(ref, interval)}"]
27
+ end
28
+
29
+ # What a project's first fetch says, so it never comes as a surprise.
30
+ def self.first_fetch(ref, interval)
31
+ "fun-ci: fetching #{ref} into refs/fun-ci/ now and at most every #{every(interval)}, " \
32
+ "touching none of your refs; set trunk_fetch: false in .fun-ci/config to stop."
33
+ end
34
+
35
+ def self.fetching(ref, interval)
36
+ return "" unless ref.remote
37
+ return " fun-ci doesn't fetch it (trunk_fetch: false)." unless interval
38
+
39
+ " fun-ci fetches it in the background, at most every #{every(interval)}; " \
40
+ "set trunk_fetch: false in .fun-ci/config to stop."
41
+ end
42
+
43
+ # "5 minutes", "1 hour".
44
+ def self.every(seconds)
45
+ size, unit = UNITS.find { |unit_seconds, _| (seconds % unit_seconds).zero? }
46
+ count = seconds / size
47
+ "#{count} #{unit}#{"s" unless count == 1}"
48
+ end
49
+ private_class_method :fetching
50
+ end
51
+ end
52
+ end