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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +101 -0
- data/README.md +33 -5
- data/lib/fun_ci/agent/command_support.rb +14 -1
- data/lib/fun_ci/agent/commands.rb +1 -0
- data/lib/fun_ci/agent/context.rb +3 -2
- data/lib/fun_ci/agent/events.rb +19 -5
- data/lib/fun_ci/agent/events_command.rb +1 -1
- data/lib/fun_ci/agent/exit_code.rb +1 -1
- data/lib/fun_ci/agent/next_step.rb +26 -0
- data/lib/fun_ci/agent/options.rb +6 -4
- data/lib/fun_ci/agent/output.rb +15 -2
- data/lib/fun_ci/agent/report_stage.rb +1 -1
- data/lib/fun_ci/agent/reports.rb +13 -9
- data/lib/fun_ci/agent/run_details.rb +31 -0
- data/lib/fun_ci/agent/run_report.rb +4 -3
- data/lib/fun_ci/agent/runs_text.rb +7 -4
- data/lib/fun_ci/agent/snapshots.rb +13 -3
- data/lib/fun_ci/agent/status_command.rb +2 -2
- data/lib/fun_ci/agent/status_json.rb +2 -1
- data/lib/fun_ci/agent/status_text.rb +13 -2
- data/lib/fun_ci/agent/trunk_json.rb +32 -0
- data/lib/fun_ci/agent/trunk_reading.rb +53 -0
- data/lib/fun_ci/agent/trunk_text.rb +50 -0
- data/lib/fun_ci/agent/trunk_verdict.rb +20 -0
- data/lib/fun_ci/agent/trunk_why.rb +52 -0
- data/lib/fun_ci/agent/trunk_why_json.rb +23 -0
- data/lib/fun_ci/agent/wait_command.rb +5 -5
- data/lib/fun_ci/agent/why_command.rb +11 -0
- data/lib/fun_ci/cli.rb +1 -1
- data/lib/fun_ci/cli_help.rb +4 -2
- data/lib/fun_ci/console/board_data.rb +22 -8
- data/lib/fun_ci/console/cancelled_folding.rb +19 -0
- data/lib/fun_ci/console/console_events.rb +5 -2
- data/lib/fun_ci/console/console_log.rb +4 -1
- data/lib/fun_ci/console/console_state.rb +7 -1
- data/lib/fun_ci/console/row_order.rb +31 -0
- data/lib/fun_ci/console/run_message.rb +9 -2
- data/lib/fun_ci/console/trunk_events.rb +43 -0
- data/lib/fun_ci/console/trunk_marks.rb +53 -0
- data/lib/fun_ci/console/view.rb +9 -8
- data/lib/fun_ci/evidence/caps.rb +7 -2
- data/lib/fun_ci/persistence/active_runs.rb +5 -4
- data/lib/fun_ci/persistence/database.rb +23 -2
- data/lib/fun_ci/persistence/null_recorder.rb +8 -0
- data/lib/fun_ci/persistence/pipeline_recorder.rb +3 -0
- data/lib/fun_ci/persistence/pipeline_run.rb +24 -3
- data/lib/fun_ci/persistence/trunk_checks.rb +68 -0
- data/lib/fun_ci/persistence/trunk_fetches.rb +57 -0
- data/lib/fun_ci/persistence/trunk_recording.rb +49 -0
- data/lib/fun_ci/pipeline/pipeline_forker.rb +17 -2
- data/lib/fun_ci/pipeline/process_runner.rb +18 -4
- data/lib/fun_ci/pipeline/trigger.rb +10 -1
- data/lib/fun_ci/pipeline/trigger_command.rb +9 -5
- data/lib/fun_ci/pipeline/trigger_params.rb +5 -3
- data/lib/fun_ci/pipeline/trunk_run.rb +81 -0
- data/lib/fun_ci/setup/agent_instructions.rb +3 -0
- data/lib/fun_ci/setup/gradle_linter_detector.rb +23 -0
- data/lib/fun_ci/setup/installer.rb +10 -10
- data/lib/fun_ci/setup/lint_detection.rb +32 -0
- data/lib/fun_ci/setup/maven_linter_detector.rb +4 -2
- data/lib/fun_ci/setup/project_config.rb +3 -1
- data/lib/fun_ci/setup/settings.rb +35 -3
- data/lib/fun_ci/setup/setup_checker.rb +7 -2
- data/lib/fun_ci/setup/stage_templates/managed.rb +12 -7
- data/lib/fun_ci/setup/stage_templates/native.rb +1 -1
- data/lib/fun_ci/setup/stage_templates/shell.rb +12 -3
- data/lib/fun_ci/setup/stage_templates.rb +1 -1
- data/lib/fun_ci/setup/trunk_report.rb +28 -0
- data/lib/fun_ci/trunk/check.rb +56 -0
- data/lib/fun_ci/trunk/checker.rb +91 -0
- data/lib/fun_ci/trunk/description.rb +52 -0
- data/lib/fun_ci/trunk/fetch.rb +58 -0
- data/lib/fun_ci/trunk/first_fetch.rb +27 -0
- data/lib/fun_ci/trunk/git.rb +102 -0
- data/lib/fun_ci/trunk/local.rb +58 -0
- data/lib/fun_ci/trunk/merge_check.rb +54 -0
- data/lib/fun_ci/trunk/regions.rb +38 -0
- data/lib/fun_ci/trunk/resolver.rb +64 -0
- data/lib/fun_ci/trunk/shown.rb +47 -0
- data/lib/fun_ci/trunk/tip_reader.rb +39 -0
- data/lib/fun_ci/version.rb +1 -1
- 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 "
|
|
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:
|
|
55
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
14
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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",
|
|
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
|
-
|
|
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 }.
|
|
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
|