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,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require_relative "../pipeline/process_runner"
5
+ require_relative "resolver"
6
+
7
+ module FunCi
8
+ module Trunk
9
+ # How a fetch of the trunk went: nil error when it fetched.
10
+ Fetched = Data.define(:error)
11
+
12
+ # Fetches the trunk into a ref of fun-ci's own (architecture.md, Checking against the trunk,
13
+ # Keeping the trunk fresh). An empty refmap keeps git from moving the
14
+ # developer's remote-tracking ref too, and nothing may ask them anything:
15
+ # ssh runs in batch mode unless they have an ssh command of their own.
16
+ # Started in one thread, so its pid can be recorded, and finished in
17
+ # another, killed with its process group at the deadline.
18
+ class Fetch
19
+ QUIET = { "GIT_TERMINAL_PROMPT" => "0", "SSH_ASKPASS_REQUIRE" => "never", "GCM_INTERACTIVE" => "never" }.freeze
20
+ NO_PROMPT_SSH = 'if [ -z "$GIT_SSH_COMMAND" ] && ! git config --get core.sshCommand >/dev/null; then ' \
21
+ 'export GIT_SSH_COMMAND="ssh -o BatchMode=yes -o ConnectTimeout=10"; fi; '
22
+ OPTIONS = %w[--quiet --no-tags --no-write-fetch-head --no-recurse-submodules --no-auto-maintenance
23
+ --refmap=].freeze
24
+
25
+ def self.ref_for(ref) = "refs/fun-ci/trunk/#{ref.remote}/#{ref.branch}"
26
+
27
+ # Why a fetch failed, from what git printed: its fatal line, else its first, else that it failed.
28
+ def self.reason(output)
29
+ lines = output.lines.map(&:strip).reject(&:empty?)
30
+ (lines.find { |line| line.start_with?("fatal:") } || lines.first || "git fetch failed").to_s
31
+ end
32
+
33
+ # env: what the fetch finds in its environment besides fun-ci's own.
34
+ def initialize(dir, env:)
35
+ @launch = Pipeline::ProcessRunner::Launch.new(chdir: dir, env: QUIET.merge(env))
36
+ @runner = Object.new.extend(Pipeline::ProcessRunner)
37
+ end
38
+
39
+ # Yields the fetch's pid before it runs.
40
+ def start(ref, &) = @runner.start_process(command(ref), @launch, &)
41
+
42
+ def finish(started, deadline:, timer: Pipeline::ProcessRunner::BUDGET)
43
+ output, status, over = @runner.await_process(started, deadline, launch: @launch, timer: timer)
44
+ return Fetched.new(error: "no answer within #{deadline} s") if over
45
+
46
+ Fetched.new(error: status.success? ? nil : self.class.reason(output))
47
+ end
48
+
49
+ private
50
+
51
+ def command(ref)
52
+ refspec = "+refs/heads/#{ref.branch}:#{self.class.ref_for(ref)}"
53
+ fetch = Shellwords.join(["git", "fetch", *OPTIONS, ref.remote, refspec])
54
+ "sh -c #{Shellwords.escape("#{NO_PROMPT_SSH}exec #{fetch}")}"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../setup/project_config"
4
+ require_relative "../persistence/trunk_fetches"
5
+ require_relative "description"
6
+ require_relative "git"
7
+ require_relative "resolver"
8
+
9
+ module FunCi
10
+ module Trunk
11
+ # What to say before a project's first fetch of its trunk, where the
12
+ # developer sees it (architecture.md, Checking against the trunk):
13
+ # the post-commit hook's run is in the background, and what it prints is not.
14
+ module FirstFetch
15
+ # Nil unless the project fetches a remote trunk and never has yet; git lists
16
+ # the project's refs.
17
+ def self.notice(project, db, git: Git.new(project))
18
+ config = Setup::ProjectConfig.new(project)
19
+ interval = config.trunk_fetch
20
+ return nil if interval.nil? || config.trunk == "none" || Persistence::TrunkFetches.new(db, project).last
21
+
22
+ ref = Resolver.pick(config.trunk, git.refs)
23
+ ref&.remote ? Description.first_fetch(ref, interval) : nil
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "shellwords"
5
+ require_relative "../pipeline/process_runner"
6
+ require "tmpdir"
7
+ require_relative "../pipeline/git_environment"
8
+ require_relative "merge_check"
9
+ require_relative "resolver"
10
+
11
+ module FunCi
12
+ module Trunk
13
+ # The git the trunk check asks, in the project's repository. A merge
14
+ # writes its tree and conflicted blobs into a scratch object directory
15
+ # that borrows the repository's objects, so the repository keeps nothing.
16
+ class Git
17
+ REF_PREFIXES = %w[refs/heads/ refs/remotes/].freeze
18
+
19
+ # timer: whether a merge finished within its budget (ProcessRunner's timer), for tests to decide.
20
+ def initialize(dir, timer: Pipeline::ProcessRunner::BUDGET)
21
+ @dir = dir
22
+ @timer = timer
23
+ end
24
+
25
+ def counts(commit, trunk) = run("rev-list", "--left-right", "--count", "#{commit}...#{trunk}")
26
+
27
+ def merge_tree(commit, trunk) = scratch { |env| merged(commit, trunk, env) }
28
+
29
+ # The merge's messages and each conflicted file as the merge leaves it.
30
+ def explain(commit, trunk)
31
+ scratch do |env|
32
+ output = MergeOutput.parse(merged(commit, trunk, env).out)
33
+ files = output.paths.to_h { |path| [path, run("cat-file", "-p", "#{output.tree}:#{path}", env: env).out] }
34
+ Explained.new(messages: output.messages, files: files)
35
+ end
36
+ end
37
+
38
+ # The repository's top directory, or nil outside one.
39
+ def toplevel
40
+ answer = run("rev-parse", "--show-toplevel")
41
+ answer.status.zero? ? answer.out.strip : nil
42
+ end
43
+
44
+ def version = run("--version").out[/\d+(?:\.\d+)+/]
45
+
46
+ def refs
47
+ remotes = run("remote").out.split
48
+ Refs.new(remotes: remotes, branches: branches, heads: remotes.to_h { |remote| [remote, head(remote)] }.compact)
49
+ end
50
+
51
+ # The commit a ref names, or nil when there is no such ref.
52
+ def rev(ref)
53
+ answer = run("rev-parse", "--verify", "--quiet", "#{ref}^{commit}")
54
+ answer.status.zero? ? answer.out.strip : nil
55
+ end
56
+
57
+ # When the ref last moved, from its reflog, or else when its commit was made.
58
+ def moved_at(ref)
59
+ moved = run("reflog", "-1", "--date=unix", "--format=%gd", ref).out[/@\{(\d+)\}/, 1]
60
+ Time.at(Integer(moved || run("log", "-1", "--format=%ct", ref).out.strip))
61
+ end
62
+
63
+ private
64
+
65
+ def branches
66
+ names = run("for-each-ref", "--format=%(refname)", *REF_PREFIXES).out.split("\n")
67
+ names.reject { |name| name.end_with?("/HEAD") }.map { |name| strip_prefix(name) }
68
+ end
69
+
70
+ def strip_prefix(name) = REF_PREFIXES.reduce(name) { |short, prefix| short.delete_prefix(prefix) }
71
+
72
+ def head(remote)
73
+ answer = run("symbolic-ref", "--quiet", "refs/remotes/#{remote}/HEAD")
74
+ answer.status.zero? ? answer.out.strip.delete_prefix("refs/remotes/#{remote}/") : nil
75
+ end
76
+
77
+ # Within the check's budget, killed with its process group past it; what it
78
+ # prints to stderr comes with what it prints, which is all git says when it fails.
79
+ def merged(commit, trunk, env)
80
+ command = Shellwords.join(["git", "merge-tree", "--write-tree", "--name-only", "--messages", commit, trunk])
81
+ launch = Pipeline::ProcessRunner::Launch.new(chdir: @dir, env: env)
82
+ output, status, over = Object.new.extend(Pipeline::ProcessRunner)
83
+ .run_process_with_timeout(command, CHECK_BUDGET, launch: launch, timer: @timer)
84
+ over ? MergeCheck::OVER_BUDGET : MergeCheck::Answer.new(status: status.exitstatus, out: output, err: output)
85
+ end
86
+
87
+ # Yields an environment whose new objects go to a scratch directory that is gone afterwards.
88
+ def scratch
89
+ Dir.mktmpdir("fun-ci-merge") do |dir|
90
+ yield({ "GIT_OBJECT_DIRECTORY" => dir, "GIT_ALTERNATE_OBJECT_DIRECTORIES" => objects })
91
+ end
92
+ end
93
+
94
+ def objects = File.expand_path(File.join(run("rev-parse", "--git-common-dir").out.strip, "objects"), @dir)
95
+
96
+ def run(*, env: {})
97
+ out, err, status = Open3.capture3(Pipeline::GitEnvironment::CLEAN.merge(env), "git", *, chdir: @dir)
98
+ MergeCheck::Answer.new(status: status.exitstatus, out: out, err: err)
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../setup/project_config"
4
+ require_relative "checker"
5
+ require_relative "git"
6
+ require_relative "tip_reader"
7
+
8
+ module FunCi
9
+ module Trunk
10
+ # The trunk as the project's git has it now, for the agent commands
11
+ # (architecture.md, Checking against the trunk): where
12
+ # its ref points, read the way a check reads it, and a check against it
13
+ # that never fetches, since a command an agent runs must not wait on the network.
14
+ class Local
15
+ # Lets no fetch go, and knows the last one.
16
+ NoFetch = Data.define(:fetches)
17
+
18
+ # Reopened rather than given as a block to Data.define, so tools that
19
+ # read the source (mutineer) see these as NoFetch's methods.
20
+ class NoFetch
21
+ def claim(**) = nil
22
+ def last = fetches.last
23
+ end
24
+
25
+ # dir: the project's directory, or one inside it.
26
+ def initialize(dir)
27
+ @dir = dir
28
+ end
29
+
30
+ # The SHA the tip's trunk points at now, or nil when it is gone.
31
+ def now_at(tip)
32
+ ref = Ref.new(remote: tip.remote, branch: tip.branch)
33
+ TipReader.new(git, -> { Time.now }).tip(ref, fetching: !config.trunk_fetch.nil?)&.sha
34
+ end
35
+
36
+ # fetches: the project's (Persistence::TrunkFetches), for when the last fetch was.
37
+ def check(sha, fetches)
38
+ checker = Checker.for(root)
39
+ pending = checker.start(sha, NoFetch.new(fetches: fetches))
40
+ pending && checker.finish(pending).check
41
+ end
42
+
43
+ # The conflict between the commit and the tip it was checked against, merged
44
+ # again; nil when either is gone from the repository (a force push, then gc).
45
+ def explain(sha, tip)
46
+ return nil unless git.rev(sha) && git.rev(tip.sha)
47
+
48
+ git.explain(sha, tip.sha)
49
+ end
50
+
51
+ private
52
+
53
+ def root = @root ||= Git.new(@dir).toplevel || @dir
54
+ def git = Git.new(root)
55
+ def config = Setup::ProjectConfig.new(root)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "check"
4
+
5
+ module FunCi
6
+ module Trunk
7
+ # What merging a commit with a trunk tip would do (architecture.md, Checking against the trunk,
8
+ # How the check works), from what git answers: the commits each side has
9
+ # that the other lacks, then, when both have some, a merge in memory.
10
+ class MergeCheck
11
+ # What a git command answered: its exit status and what it printed.
12
+ Answer = Data.define(:status, :out, :err)
13
+ # A merge stopped at its budget, with its process group.
14
+ OVER_BUDGET = Answer.new(status: :over_budget, out: "", err: "")
15
+ STOPPED = "the merge ran over its #{CHECK_BUDGET} s budget; " \
16
+ "set trunk: none in .fun-ci/config to stop checking".freeze
17
+
18
+ def initialize(git)
19
+ @git = git
20
+ end
21
+
22
+ # name: how the trunk is called in a reason, "origin/main".
23
+ def merge(commit, trunk, name)
24
+ counted = @git.counts(commit, trunk)
25
+ return Merge.unknown("git rev-list: #{first_line(counted)}") unless counted.status.zero?
26
+
27
+ ahead, behind = counted.out.split.map(&:to_i)
28
+ return Merge.clean(ahead: ahead, behind: behind) if ahead.zero? || behind.zero?
29
+
30
+ merged(@git.merge_tree(commit, trunk), name, ahead: ahead, behind: behind)
31
+ end
32
+
33
+ private
34
+
35
+ def merged(answer, name, **counts)
36
+ return Merge.unknown(STOPPED) if answer.equal?(OVER_BUDGET)
37
+ return Merge.clean(**counts) if answer.status.zero?
38
+ return Merge.conflicts(MergeOutput.parse(answer.out).paths, **counts) if answer.status == 1
39
+
40
+ Merge.unknown(reason(answer, name))
41
+ end
42
+
43
+ def reason(answer, name)
44
+ return "#{name} shares no history with this commit; set trunk: in .fun-ci/config" if unrelated?(answer)
45
+ return "needs git 2.38 or later; this is #{@git.version}" if answer.status == 129
46
+
47
+ "git merge-tree: #{first_line(answer)}"
48
+ end
49
+
50
+ def unrelated?(answer) = answer.err.include?("unrelated histories")
51
+ def first_line(answer) = answer.err.lines.first.to_s.chomp
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Trunk
5
+ # The conflicted regions of a file as a merge leaves it (architecture.md, Checking against the trunk,
6
+ # why REV trunk): each conflict from its <<<<<<< line to its >>>>>>> line,
7
+ # with `around` lines either side, regions that touch joined into one.
8
+ module Regions
9
+ # Lines first to last, as [number, text] pairs, numbered from 1.
10
+ Region = Data.define(:first, :last, :lines)
11
+ AROUND = 3
12
+
13
+ def self.of(text, around: AROUND)
14
+ lines = text.lines(chomp: true)
15
+ spans = conflicts(lines).map { |from, to| [[from - around, 0].max, [to + around, lines.size - 1].min] }
16
+ joined(spans).map { |from, to| region(lines, from, to) }
17
+ end
18
+
19
+ def self.region(lines, from, to)
20
+ Region.new(first: from + 1, last: to + 1, lines: (from..to).map { |index| [index + 1, lines[index]] })
21
+ end
22
+
23
+ # [index of <<<<<<<, index of >>>>>>>] for each conflict.
24
+ def self.conflicts(lines)
25
+ marked = ->(marker) { lines.each_index.select { |index| lines[index].start_with?(marker) } }
26
+ marked.call("<<<<<<<").zip(marked.call(">>>>>>>"))
27
+ end
28
+
29
+ def self.joined(spans)
30
+ spans.each_with_object([]) do |(from, to), joined|
31
+ touching = joined.any? && from <= joined.last[1] + 1
32
+ touching ? joined[-1] = [joined.last[0], [joined.last[1], to].max] : joined << [from, to]
33
+ end
34
+ end
35
+ private_class_method :region, :conflicts, :joined
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Trunk
5
+ # A branch that may be the trunk: on a remote, or local when remote is nil.
6
+ Ref = Data.define(:remote, :branch)
7
+
8
+ class Ref
9
+ def to_s = remote ? "#{remote}/#{branch}" : branch
10
+ end
11
+
12
+ # What a repository has to choose a trunk from: its remotes, its branches
13
+ # (a remote's as "origin/main", local ones by name) and each remote's
14
+ # default branch, where git knows it.
15
+ Refs = Data.define(:remotes, :branches, :heads)
16
+
17
+ # Which ref is the trunk (architecture.md, Checking against the trunk):
18
+ # the one configured, else the remote's default branch, else the first
19
+ # usual trunk name on the remote, else locally; nil when none exists.
20
+ module Resolver
21
+ USUAL = %w[main master develop development trunk].freeze
22
+
23
+ def self.pick(setting, refs)
24
+ return configured(setting, refs.remotes) if setting
25
+
26
+ remote = remote(refs.remotes)
27
+ remote ? on_remote(remote, refs) : on_local(refs.branches)
28
+ end
29
+
30
+ # Why pick chose the trunk it did: :configured, :default (the remote's
31
+ # default branch), :usual (a usual trunk name on it) or :local.
32
+ def self.why(setting, refs)
33
+ return :configured if setting
34
+
35
+ remote = remote(refs.remotes)
36
+ return :local unless remote
37
+
38
+ refs.heads[remote] ? :default : :usual
39
+ end
40
+
41
+ def self.configured(setting, remotes)
42
+ remote = remotes.find { |name| setting.start_with?("#{name}/") }
43
+ Ref.new(remote: remote, branch: remote ? setting.delete_prefix("#{remote}/") : setting)
44
+ end
45
+
46
+ def self.remote(remotes)
47
+ return "origin" if remotes.include?("origin")
48
+
49
+ remotes.first if remotes.size == 1
50
+ end
51
+
52
+ def self.on_remote(remote, refs)
53
+ branch = refs.heads[remote] || USUAL.find { |name| refs.branches.include?("#{remote}/#{name}") }
54
+ branch && Ref.new(remote: remote, branch: branch)
55
+ end
56
+
57
+ def self.on_local(branches)
58
+ branch = USUAL.find { |name| branches.include?(name) }
59
+ branch && Ref.new(remote: nil, branch: branch)
60
+ end
61
+ private_class_method :configured, :remote, :on_remote, :on_local
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "check"
4
+
5
+ module FunCi
6
+ module Trunk
7
+ # A check as the developer and agents are shown it: its state, derived
8
+ # rather than stored, how many seconds ago its trunk tip was seen (nil
9
+ # when it had none), the project's last fetch (a LastFetch, nil if none), and
10
+ # the SHA the trunk has moved to since, if it has.
11
+ # A run whose check hasn't been recorded is checking until its fetch and
12
+ # merge are past their time, and then unknown.
13
+ Shown = Data.define(:state, :check, :age, :fetch, :moved_to)
14
+
15
+ class Shown
16
+ # How old a trunk may be before it is stale.
17
+ FRESH_FOR = 3600
18
+
19
+ def self.of(check, now:, fetch: nil, moved_to: nil)
20
+ new(state: state(check.merge), check: check, age: check.tip && (now - check.tip.seen_at),
21
+ fetch: fetch, moved_to: moved_to)
22
+ end
23
+
24
+ def self.unchecked(started:, now:)
25
+ if now - started <= FETCH_DEADLINE + CHECK_BUDGET
26
+ return new(state: "checking", check: nil, age: nil, fetch: nil, moved_to: nil)
27
+ end
28
+
29
+ of(Check.new(commit: nil, tip: nil, merge: Merge.unknown("the check never finished")), now: now)
30
+ end
31
+
32
+ def self.state(merge)
33
+ return merge.outcome if merge.outcome == "unknown"
34
+ return "in_trunk" if merge.ahead.zero?
35
+
36
+ merge.behind.zero? ? "up_to_date" : merge.outcome
37
+ end
38
+ private_class_method :state
39
+
40
+ # Whether the trunk it was checked against may have moved on unseen.
41
+ def stale? = !fetch_error.nil? || (!age.nil? && age > FRESH_FOR)
42
+
43
+ # Why the project's last fetch failed, or nil.
44
+ def fetch_error = fetch&.error
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "check"
4
+ require_relative "fetch"
5
+ require_relative "resolver"
6
+ require_relative "../persistence/trunk_fetches"
7
+
8
+ module FunCi
9
+ module Trunk
10
+ # The tip of the trunk a check reads, and when it counts as seen
11
+ # (architecture.md, Checking against the trunk): fun-ci's own ref once
12
+ # it has fetched one, seen when it was last fetched; else the developer's
13
+ # ref, seen when it last moved.
14
+ class TipReader
15
+ # clock: answers the time now.
16
+ def initialize(git, clock)
17
+ @git = git
18
+ @clock = clock
19
+ end
20
+
21
+ # fetching: whether fun-ci fetches this trunk; fetched: how this run's
22
+ # fetch went, if it made one; last: the fetch before it. Nil when the
23
+ # trunk doesn't exist.
24
+ def tip(ref, fetching:, fetched: nil, last: nil)
25
+ own = fetching && ref.remote && @git.rev(Fetch.ref_for(ref))
26
+ return at(ref, own, seen(fetched, last) || @git.moved_at(Fetch.ref_for(ref))) if own
27
+
28
+ full = ref.remote ? "refs/remotes/#{ref}" : "refs/heads/#{ref.branch}"
29
+ sha = @git.rev(full)
30
+ sha && at(ref, sha, @git.moved_at(full))
31
+ end
32
+
33
+ private
34
+
35
+ def seen(fetched, last) = fetched && !fetched.error ? @clock.call : last&.fetched_at
36
+ def at(ref, sha, seen_at) = Tip.new(remote: ref.remote, branch: ref.branch, sha: sha, seen_at: seen_at)
37
+ end
38
+ end
39
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  # Loaded on its own by the gemspec, so it must not require anything.
4
4
  module FunCi
5
- VERSION = "2.0.1"
5
+ VERSION = "2.1.0"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fun_ci
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.1
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Erik Thyge Madsen
@@ -73,10 +73,12 @@ files:
73
73
  - lib/fun_ci/agent/exit_code.rb
74
74
  - lib/fun_ci/agent/git.rb
75
75
  - lib/fun_ci/agent/live_pipeline.rb
76
+ - lib/fun_ci/agent/next_step.rb
76
77
  - lib/fun_ci/agent/options.rb
77
78
  - lib/fun_ci/agent/output.rb
78
79
  - lib/fun_ci/agent/report_stage.rb
79
80
  - lib/fun_ci/agent/reports.rb
81
+ - lib/fun_ci/agent/run_details.rb
80
82
  - lib/fun_ci/agent/run_report.rb
81
83
  - lib/fun_ci/agent/runs_command.rb
82
84
  - lib/fun_ci/agent/runs_text.rb
@@ -86,6 +88,12 @@ files:
86
88
  - lib/fun_ci/agent/status_json.rb
87
89
  - lib/fun_ci/agent/status_text.rb
88
90
  - lib/fun_ci/agent/system_clock.rb
91
+ - lib/fun_ci/agent/trunk_json.rb
92
+ - lib/fun_ci/agent/trunk_reading.rb
93
+ - lib/fun_ci/agent/trunk_text.rb
94
+ - lib/fun_ci/agent/trunk_verdict.rb
95
+ - lib/fun_ci/agent/trunk_why.rb
96
+ - lib/fun_ci/agent/trunk_why_json.rb
89
97
  - lib/fun_ci/agent/verdict.rb
90
98
  - lib/fun_ci/agent/wait_command.rb
91
99
  - lib/fun_ci/agent/waiting.rb
@@ -96,6 +104,7 @@ files:
96
104
  - lib/fun_ci/cli_help.rb
97
105
  - lib/fun_ci/console/board_data.rb
98
106
  - lib/fun_ci/console/board_heartbeat.rb
107
+ - lib/fun_ci/console/cancelled_folding.rb
99
108
  - lib/fun_ci/console/console_events.rb
100
109
  - lib/fun_ci/console/console_log.rb
101
110
  - lib/fun_ci/console/console_loop.rb
@@ -107,11 +116,14 @@ files:
107
116
  - lib/fun_ci/console/milestones.rb
108
117
  - lib/fun_ci/console/renderer_lookup.rb
109
118
  - lib/fun_ci/console/renderer_process.rb
119
+ - lib/fun_ci/console/row_order.rb
110
120
  - lib/fun_ci/console/run_message.rb
111
121
  - lib/fun_ci/console/stage_change_detector.rb
112
122
  - lib/fun_ci/console/stage_events.rb
113
123
  - lib/fun_ci/console/stop_report.rb
114
124
  - lib/fun_ci/console/streak_counter.rb
125
+ - lib/fun_ci/console/trunk_events.rb
126
+ - lib/fun_ci/console/trunk_marks.rb
115
127
  - lib/fun_ci/console/view.rb
116
128
  - lib/fun_ci/evidence/about.rb
117
129
  - lib/fun_ci/evidence/about_stage.rb
@@ -205,6 +217,9 @@ files:
205
217
  - lib/fun_ci/persistence/run_status.rb
206
218
  - lib/fun_ci/persistence/stage_job.rb
207
219
  - lib/fun_ci/persistence/state_dir.rb
220
+ - lib/fun_ci/persistence/trunk_checks.rb
221
+ - lib/fun_ci/persistence/trunk_fetches.rb
222
+ - lib/fun_ci/persistence/trunk_recording.rb
208
223
  - lib/fun_ci/persistence/write_trouble.rb
209
224
  - lib/fun_ci/pipeline/background_fork.rb
210
225
  - lib/fun_ci/pipeline/command_executor.rb
@@ -230,6 +245,7 @@ files:
230
245
  - lib/fun_ci/pipeline/trigger.rb
231
246
  - lib/fun_ci/pipeline/trigger_command.rb
232
247
  - lib/fun_ci/pipeline/trigger_params.rb
248
+ - lib/fun_ci/pipeline/trunk_run.rb
233
249
  - lib/fun_ci/pipeline/workspaces.rb
234
250
  - lib/fun_ci/pipeline/worktree_pool.rb
235
251
  - lib/fun_ci/pipeline/worktree_prune.rb
@@ -237,10 +253,12 @@ files:
237
253
  - lib/fun_ci/setup/agent_instructions.rb
238
254
  - lib/fun_ci/setup/commands.rb
239
255
  - lib/fun_ci/setup/git_hooks.rb
256
+ - lib/fun_ci/setup/gradle_linter_detector.rb
240
257
  - lib/fun_ci/setup/hook_script.rb
241
258
  - lib/fun_ci/setup/hook_writer.rb
242
259
  - lib/fun_ci/setup/installer.rb
243
260
  - lib/fun_ci/setup/legacy_hooks.rb
261
+ - lib/fun_ci/setup/lint_detection.rb
244
262
  - lib/fun_ci/setup/maven_linter_detector.rb
245
263
  - lib/fun_ci/setup/project_config.rb
246
264
  - lib/fun_ci/setup/project_detector.rb
@@ -253,6 +271,19 @@ files:
253
271
  - lib/fun_ci/setup/stage_templates/scripting.rb
254
272
  - lib/fun_ci/setup/stage_templates/shell.rb
255
273
  - lib/fun_ci/setup/template_writer.rb
274
+ - lib/fun_ci/setup/trunk_report.rb
275
+ - lib/fun_ci/trunk/check.rb
276
+ - lib/fun_ci/trunk/checker.rb
277
+ - lib/fun_ci/trunk/description.rb
278
+ - lib/fun_ci/trunk/fetch.rb
279
+ - lib/fun_ci/trunk/first_fetch.rb
280
+ - lib/fun_ci/trunk/git.rb
281
+ - lib/fun_ci/trunk/local.rb
282
+ - lib/fun_ci/trunk/merge_check.rb
283
+ - lib/fun_ci/trunk/regions.rb
284
+ - lib/fun_ci/trunk/resolver.rb
285
+ - lib/fun_ci/trunk/shown.rb
286
+ - lib/fun_ci/trunk/tip_reader.rb
256
287
  - lib/fun_ci/version.rb
257
288
  homepage: https://github.com/beatmadsen/fun-ci
258
289
  licenses: