fun_ci 2.0.2 → 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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -0
  3. data/README.md +31 -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/project_config.rb +3 -1
  59. data/lib/fun_ci/setup/settings.rb +35 -3
  60. data/lib/fun_ci/setup/setup_checker.rb +7 -2
  61. data/lib/fun_ci/setup/trunk_report.rb +28 -0
  62. data/lib/fun_ci/trunk/check.rb +56 -0
  63. data/lib/fun_ci/trunk/checker.rb +91 -0
  64. data/lib/fun_ci/trunk/description.rb +52 -0
  65. data/lib/fun_ci/trunk/fetch.rb +58 -0
  66. data/lib/fun_ci/trunk/first_fetch.rb +27 -0
  67. data/lib/fun_ci/trunk/git.rb +102 -0
  68. data/lib/fun_ci/trunk/local.rb +58 -0
  69. data/lib/fun_ci/trunk/merge_check.rb +54 -0
  70. data/lib/fun_ci/trunk/regions.rb +38 -0
  71. data/lib/fun_ci/trunk/resolver.rb +64 -0
  72. data/lib/fun_ci/trunk/shown.rb +47 -0
  73. data/lib/fun_ci/trunk/tip_reader.rb +39 -0
  74. data/lib/fun_ci/version.rb +1 -1
  75. metadata +30 -1
@@ -15,8 +15,13 @@ module FunCi
15
15
  # What a line costs in JSON besides its own bytes: quotes and a comma.
16
16
  LINE_COST = 3
17
17
 
18
- def initialize(limits = REAL)
18
+ # How many bytes of JSON a document makes.
19
+ JSON_SIZE = ->(document) { JSON.generate(document.to_h).bytesize }
20
+
21
+ # size: measures a document, JSON_SIZE but where a test counts the measuring.
22
+ def initialize(limits = REAL, size: JSON_SIZE)
19
23
  @limits = limits
24
+ @size = size
20
25
  end
21
26
 
22
27
  def apply(document)
@@ -36,7 +41,7 @@ module FunCi
36
41
  document
37
42
  end
38
43
 
39
- def over(document) = JSON.generate(document.to_h).bytesize - @limits.bytes
44
+ def over(document) = @size.call(document) - @limits.bytes
40
45
 
41
46
  def few_failures(document)
42
47
  left_out = document.failures.size - @limits.failures
@@ -52,16 +52,17 @@ module FunCi
52
52
  end
53
53
 
54
54
  def self.where(db, clause, *values)
55
- db.execute("SELECT id, commit_hash, trigger_pid, pid, slot_lock FROM pipeline_runs " \
55
+ db.execute("SELECT id, commit_hash, trigger_pid, pid, slot_lock, fetch_pgid FROM pipeline_runs " \
56
56
  "WHERE #{clause} AND #{ACTIVE} ORDER BY id", values).map { |row| active_run(db, row) }
57
57
  end
58
58
  private_class_method :where
59
59
 
60
- def self.active_run(db, (id, commit_hash, trigger_pid, pid, slot_lock))
60
+ # The groups are each running stage's, and the trunk fetch's (architecture.md, Checking against the trunk).
61
+ def self.active_run(db, (id, commit_hash, trigger_pid, pid, slot_lock, fetch_pgid))
61
62
  groups = db.execute("SELECT pid FROM stage_jobs WHERE pipeline_run_id = ? AND #{ACTIVE} AND pid IS NOT NULL",
62
63
  [id]).flatten
63
- ActiveRun.new(id: id, commit_hash: commit_hash, processes: [trigger_pid, pid].compact, stage_groups: groups,
64
- slot_lock: slot_lock)
64
+ ActiveRun.new(id: id, commit_hash: commit_hash, processes: [trigger_pid, pid].compact,
65
+ stage_groups: [*groups, *fetch_pgid], slot_lock: slot_lock)
65
66
  end
66
67
  private_class_method :active_run
67
68
  end
@@ -28,6 +28,24 @@ module FunCi
28
28
  )
29
29
  SQL
30
30
 
31
+ # One row per commit and trunk SHA, written once (architecture.md, Checking against the trunk).
32
+ TRUNK_CHECKS_TABLE = <<~SQL
33
+ CREATE TABLE IF NOT EXISTS trunk_checks (
34
+ id INTEGER PRIMARY KEY,
35
+ project_path TEXT, commit_hash TEXT, trunk_remote TEXT, trunk_branch TEXT, trunk_sha TEXT,
36
+ trunk_seen_at TEXT, checked_at TEXT,
37
+ outcome TEXT, ahead INTEGER, behind INTEGER, files TEXT, reason TEXT,
38
+ UNIQUE (project_path, commit_hash, trunk_sha)
39
+ )
40
+ SQL
41
+
42
+ # When fun-ci last fetched each project's trunk (architecture.md, Checking against the trunk).
43
+ TRUNK_FETCHES_TABLE = <<~SQL
44
+ CREATE TABLE IF NOT EXISTS trunk_fetches (
45
+ project_path TEXT PRIMARY KEY, claimed_at INTEGER, fetched_at TEXT, failures INTEGER, error TEXT
46
+ )
47
+ SQL
48
+
31
49
  # Opening and migrating hold an exclusive lock on a file beside the
32
50
  # database, so fun-ci processes starting at once set it up one at a time.
33
51
  # SQLite's busy timeout doesn't cover the switch to WAL, and a column can
@@ -44,11 +62,14 @@ module FunCi
44
62
  %w[stage_jobs output_tail TEXT], %w[stage_jobs failures TEXT],
45
63
  %w[pipeline_runs waited_at TEXT], %w[stage_jobs exit_status INTEGER],
46
64
  %w[stage_jobs signal TEXT], %w[stage_jobs budget INTEGER], %w[stage_jobs pruned INTEGER],
47
- %w[stage_jobs evidence TEXT]].freeze
65
+ %w[stage_jobs evidence TEXT], %w[pipeline_runs fetch_pgid INTEGER],
66
+ %w[pipeline_runs trunk_started_at TEXT]].freeze
48
67
 
49
68
  def self.migrate!(db)
50
69
  with_setup_lock(db.filename("main")) do
51
- [PIPELINE_RUNS_TABLE, STAGE_JOBS_TABLE].each { |table| db.execute(table) }
70
+ [PIPELINE_RUNS_TABLE, STAGE_JOBS_TABLE, TRUNK_CHECKS_TABLE, TRUNK_FETCHES_TABLE].each do |table|
71
+ db.execute(table)
72
+ end
52
73
  ADDED_COLUMNS.each { |table, column, type| add_column_if_missing(db, table, column, type) }
53
74
  db.execute(NAME_DETACHED)
54
75
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "trunk_recording"
4
+
3
5
  module FunCi
4
6
  module Persistence
5
7
  # Records nothing, for a pipeline run with no database.
@@ -10,6 +12,12 @@ module FunCi
10
12
  def keep_exit(_job_id, _exit_status, _signal); end
11
13
  def keep_evidence(_job_id, _document); end
12
14
  def keep_raw(_job_id, _text); end
15
+ def trunk_checked(_check); end
16
+ def trunk_fetched(_fetched, _tip); end
17
+ def trunk_fetch_process(_pgid); end
18
+ def trunk_check_started; end
19
+ def trunk_heads(**) = {}
20
+ def trunk_fetches = TrunkRecording::NoFetches.new
13
21
  def alongside(_job_id) = []
14
22
  def stage_process(_job_id, _pid); end
15
23
  def slot_taken(_lock_file); end
@@ -8,10 +8,13 @@ require "json"
8
8
  require_relative "null_recorder"
9
9
  require_relative "project_runs"
10
10
  require_relative "retention"
11
+ require_relative "trunk_recording"
11
12
 
12
13
  module FunCi
13
14
  module Persistence
14
15
  class DbRecorder
16
+ include TrunkRecording
17
+
15
18
  KEPT_RUNS = Retention::KEPT_RUNS
16
19
 
17
20
  attr_reader :db, :db_path, :pipeline_run_id, :trouble
@@ -5,7 +5,7 @@ require "time"
5
5
  module FunCi
6
6
  module Persistence
7
7
  module PipelineRun
8
- COLUMNS = %i[id commit_hash branch status pid project_path created_at updated_at].freeze
8
+ COLUMNS = %i[id commit_hash branch status pid project_path created_at updated_at trunk_started_at].freeze
9
9
  SELECT = "SELECT #{COLUMNS.join(", ")} FROM pipeline_runs".freeze
10
10
 
11
11
  def self.create(db, commit_hash:, branch:, project_path: nil)
@@ -50,6 +50,11 @@ module FunCi
50
50
  db.execute("UPDATE pipeline_runs SET waited_at = ? WHERE id = ?", [at, id])
51
51
  end
52
52
 
53
+ # When the run began checking its commit against the trunk (architecture.md, Checking against the trunk).
54
+ def self.trunk_started(db, id, at)
55
+ db.execute("UPDATE pipeline_runs SET trunk_started_at = ? WHERE id = ?", [at.utc.iso8601, id])
56
+ end
57
+
53
58
  def self.update_status(db, id, new_status)
54
59
  now = Time.now.utc.iso8601
55
60
  db.execute("UPDATE pipeline_runs SET status = ?, updated_at = ? WHERE id = ?", [new_status, now, id])
@@ -59,8 +64,24 @@ module FunCi
59
64
  query(db, "ORDER BY id DESC LIMIT ?", limit)
60
65
  end
61
66
 
62
- def self.query(db, clause, param)
63
- db.execute("#{SELECT} #{clause}", [param]).map { |row| COLUMNS.zip(row).to_h }
67
+ # How much a branch whose newest run has a status needs you: a failure most.
68
+ NEEDING = "CASE status WHEN 'failed' THEN 0 WHEN 'timed_out' THEN 1 WHEN 'running' THEN 2 " \
69
+ "WHEN 'scheduled' THEN 3 WHEN 'completed' THEN 4 ELSE 5 END"
70
+
71
+ # The newest run of each of `limit` branches: those that most need you,
72
+ # then those run most recently, in that order.
73
+ def self.branch_heads(db, limit:)
74
+ query(db, "WHERE id IN (SELECT MAX(id) FROM pipeline_runs GROUP BY project_path, branch) " \
75
+ "ORDER BY #{NEEDING}, id DESC LIMIT ?", limit)
76
+ end
77
+
78
+ # A project's branch's runs, newest first.
79
+ def self.of_branch(db, project_path, branch, limit:)
80
+ query(db, "WHERE project_path IS ? AND branch = ? ORDER BY id DESC LIMIT ?", project_path, branch, limit)
81
+ end
82
+
83
+ def self.query(db, clause, *params)
84
+ db.execute("#{SELECT} #{clause}", params).map { |row| COLUMNS.zip(row).to_h }
64
85
  end
65
86
  private_class_method :query
66
87
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require_relative "../trunk/check"
6
+
7
+ module FunCi
8
+ module Persistence
9
+ # A project's checks against its trunk. A check is a fact about a commit
10
+ # and a trunk SHA, so each pair is written once and never changed.
11
+ class TrunkChecks
12
+ TIP = %i[trunk_remote trunk_branch trunk_sha trunk_seen_at].freeze
13
+ MERGE = %i[outcome ahead behind files reason].freeze
14
+ COLUMNS = [:commit_hash, *TIP, *MERGE].freeze
15
+ SELECT = "SELECT #{COLUMNS.join(", ")} FROM trunk_checks".freeze
16
+
17
+ def initialize(db, project)
18
+ @db = db
19
+ @project = project
20
+ end
21
+
22
+ def record(check, checked_at:)
23
+ @db.execute("INSERT OR IGNORE INTO trunk_checks (project_path, checked_at, #{COLUMNS.join(", ")}) " \
24
+ "VALUES (?, ?, #{(["?"] * COLUMNS.size).join(", ")})",
25
+ [@project, checked_at.utc.iso8601, check.commit, *tip_row(check.tip), *merge_row(check.merge)])
26
+ end
27
+
28
+ # A fetch found the trunk still at `sha`: the checks against it are as fresh as that fetch.
29
+ def seen(sha, at:)
30
+ @db.execute("UPDATE trunk_checks SET trunk_seen_at = ? WHERE project_path = ? AND trunk_sha = ?",
31
+ [at.utc.iso8601, @project, sha])
32
+ end
33
+
34
+ # The commit's check against the trunk SHA fun-ci saw last, or nil.
35
+ def latest(sha)
36
+ found = @db.execute("#{SELECT} WHERE project_path = ? AND commit_hash = ? " \
37
+ "ORDER BY COALESCE(trunk_seen_at, checked_at) DESC, id DESC LIMIT 1", [@project, sha]).first
38
+ found && Trunk::Check.new(commit: found.first, tip: tip(found[1, TIP.size]),
39
+ merge: merge(found.last(MERGE.size)))
40
+ end
41
+
42
+ # { commit => trunk SHA } for each branch's newest run, other than `except`,
43
+ # whose commit has been checked against a trunk tip.
44
+ def branch_heads(except:)
45
+ heads = @db.execute("SELECT commit_hash FROM pipeline_runs WHERE id IN (SELECT MAX(id) FROM pipeline_runs " \
46
+ "WHERE project_path = ? GROUP BY branch) AND commit_hash != ?", [@project, except]).flatten
47
+ heads.to_h { |sha| [sha, latest(sha)&.tip&.sha] }.compact
48
+ end
49
+
50
+ private
51
+
52
+ def tip_row(tip) = tip ? [tip.remote, tip.branch, tip.sha, tip.seen_at.utc.iso8601] : [nil] * TIP.size
53
+ def merge_row(merge) = [merge.outcome, merge.ahead, merge.behind, JSON.generate(merge.files), merge.reason]
54
+
55
+ def tip(values)
56
+ remote, branch, sha, seen_at = values
57
+ return nil unless sha
58
+
59
+ Trunk::Tip.new(remote: remote, branch: branch, sha: sha, seen_at: Time.parse(seen_at))
60
+ end
61
+
62
+ def merge(values)
63
+ outcome, ahead, behind, files, reason = values
64
+ Trunk::Merge.new(outcome: outcome, ahead: ahead, behind: behind, files: JSON.parse(files), reason: reason)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "../trunk/fetch"
5
+ require_relative "../trunk/check"
6
+
7
+ module FunCi
8
+ module Persistence
9
+ # When fun-ci last fetched each project's trunk, and how that went
10
+ # (architecture.md, Checking against the trunk). A fetch is claimed
11
+ # in one statement, so of two processes claiming at once only one
12
+ # fetches; after failures the interval doubles, up to an hour.
13
+ class TrunkFetches
14
+ LONGEST = 3600
15
+ Last = Trunk::LastFetch
16
+
17
+ def initialize(db, project)
18
+ @db = db
19
+ @project = project
20
+ end
21
+
22
+ # `now` when this process may fetch now, and the interval starts again; nil otherwise.
23
+ def claim(now:, interval:)
24
+ ensure_row
25
+ @db.execute("UPDATE trunk_fetches SET claimed_at = ? WHERE project_path = ? AND (claimed_at IS NULL " \
26
+ "OR claimed_at + MIN(? * (1 << failures), ?) <= ?)",
27
+ [now.to_i, @project, interval, LONGEST, now.to_i])
28
+ now if @db.changes == 1
29
+ end
30
+
31
+ def finished(fetched, at:)
32
+ ensure_row
33
+ return failed(fetched.error) if fetched.error
34
+
35
+ @db.execute("UPDATE trunk_fetches SET fetched_at = ?, failures = 0, error = NULL WHERE project_path = ?",
36
+ [at.utc.iso8601, @project])
37
+ end
38
+
39
+ # The last fetch tried, or nil when there has been none.
40
+ def last
41
+ row = @db.execute("SELECT fetched_at, error FROM trunk_fetches WHERE project_path = ?", [@project]).first
42
+ row && Last.new(fetched_at: row[0] && Time.parse(row[0]), error: row[1])
43
+ end
44
+
45
+ private
46
+
47
+ def ensure_row
48
+ @db.execute("INSERT OR IGNORE INTO trunk_fetches (project_path, failures) VALUES (?, 0)", [@project])
49
+ end
50
+
51
+ def failed(error)
52
+ @db.execute("UPDATE trunk_fetches SET failures = failures + 1, error = ? WHERE project_path = ?",
53
+ [error, @project])
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pipeline_run"
4
+ require_relative "trunk_checks"
5
+ require_relative "trunk_fetches"
6
+
7
+ module FunCi
8
+ module Persistence
9
+ # What a run records about the trunk (architecture.md, Checking against the trunk), under the
10
+ # run's project. Expects @db and @pipeline_run_id, and #tolerating.
11
+ module TrunkRecording
12
+ # A fetch that never happens, for a run whose project can't be read.
13
+ class NoFetches
14
+ def claim(now:, **) = now
15
+ def last = nil
16
+ end
17
+
18
+ def trunk_check_started
19
+ tolerating { PipelineRun.trunk_started(@db, @pipeline_run_id, Time.now) }
20
+ end
21
+
22
+ def trunk_fetches = tolerating { TrunkFetches.new(@db, project) } || NoFetches.new
23
+
24
+ # The fetch's process group, which cancelling the run kills.
25
+ def trunk_fetch_process(pgid)
26
+ tolerating { @db.execute("UPDATE pipeline_runs SET fetch_pgid = ? WHERE id = ?", [pgid, @pipeline_run_id]) }
27
+ end
28
+
29
+ # How the fetch went; a tip it fetched counts as seen now in every check against it.
30
+ def trunk_fetched(fetched, tip)
31
+ tolerating do
32
+ TrunkFetches.new(@db, project).finished(fetched, at: Time.now)
33
+ TrunkChecks.new(@db, project).seen(tip.sha, at: Time.now) if tip && !fetched.error
34
+ end
35
+ end
36
+
37
+ # { commit => trunk SHA } for the other branches' newest checked runs.
38
+ def trunk_heads(except:) = tolerating { TrunkChecks.new(@db, project).branch_heads(except: except) } || {}
39
+
40
+ def trunk_checked(check)
41
+ tolerating { TrunkChecks.new(@db, project).record(check, checked_at: Time.now) }
42
+ end
43
+
44
+ private
45
+
46
+ def project = PipelineRun.find(@db, @pipeline_run_id)[:project_path]
47
+ end
48
+ end
49
+ end
@@ -4,18 +4,33 @@ require_relative "../persistence/database"
4
4
  require_relative "../persistence/pipeline_recorder"
5
5
  require_relative "trigger_params"
6
6
  require_relative "../setup/project_config"
7
+ require_relative "../trunk/first_fetch"
7
8
 
8
9
  module FunCi
9
10
  module Pipeline
10
11
  class PipelineForker
11
- # Answers whether it started a run: none in a project not set up for fun-ci.
12
+ # A run started, and what to say before the project's first fetch of its trunk, if this is it.
13
+ Forked = Data.define(:notice)
14
+
15
+ # Answers a Forked, or false in a project not set up for fun-ci. What the
16
+ # run prints goes nowhere, so the notice is worked out here, before the fork.
12
17
  def self.fork_pipeline(commit_hash:, branch:, db_path:)
13
18
  return false unless Setup::ProjectConfig.new(Dir.pwd).validate.empty?
14
19
 
20
+ notice = first_fetch(db_path)
15
21
  pid = fork do
16
22
  run_in_child(commit_hash: commit_hash, branch: branch, db_path: db_path)
17
23
  end
18
24
  Process.detach(pid)
25
+ Forked.new(notice: notice)
26
+ end
27
+
28
+ # The connection closes before the fork, which must not inherit it.
29
+ def self.first_fetch(db_path)
30
+ db = Persistence::Database.connection(db_path)
31
+ Trunk::FirstFetch.notice(Dir.pwd, db)
32
+ ensure
33
+ db&.close
19
34
  end
20
35
 
21
36
  # Trigger requires this file, through TriggerCommand, so it is loaded
@@ -34,7 +49,7 @@ module FunCi
34
49
  Trigger.new(project: Dir.pwd, commit: Commit.new(sha: commit_hash, branch: branch),
35
50
  io: Io.new(stdout: File.open(File::NULL, "w")), seams: Seams.new(recorder: recorder))
36
51
  end
37
- private_class_method :trigger
52
+ private_class_method :trigger, :first_fetch
38
53
  end
39
54
  end
40
55
  end
@@ -33,14 +33,28 @@ module FunCi
33
33
  end
34
34
  end
35
35
 
36
+ # A command started and let go, and the pipe that carries what it prints.
37
+ Started = Data.define(:pid, :reader)
38
+
36
39
  def run_process_with_timeout(cmd, budget, launch: Launch.new, timer: BUDGET, &)
40
+ await_process(start_process(cmd, launch, &), budget, launch: launch, timer: timer)
41
+ end
42
+
43
+ # Starts the command and yields its pid before letting it run.
44
+ def start_process(cmd, launch = Launch.new, &)
37
45
  reader, writer = IO.pipe
38
- pid = start(cmd, writer, launch, &)
46
+ Started.new(pid: start(cmd, writer, launch, &), reader: reader)
47
+ end
48
+
49
+ # [what it printed, its status (nil when killed), whether it ran over budget].
50
+ def await_process(started, budget, launch: Launch.new, timer: BUDGET)
39
51
  printed = launch.output
40
- reading = Thread.new { read_until_closed(reader, printed) }
41
- timer.call(reading, budget) ? process_finished(pid, text(reading.value)) : over_budget(pid, reading, launch)
52
+ reading = Thread.new { read_until_closed(started.reader, printed) }
53
+ return process_finished(started.pid, text(reading.value)) if timer.call(reading, budget)
54
+
55
+ over_budget(started.pid, reading, launch)
42
56
  ensure
43
- ignoring_errors { reader&.close }
57
+ ignoring_errors { started.reader.close }
44
58
  end
45
59
 
46
60
  private
@@ -7,6 +7,8 @@ require_relative "trigger_command"
7
7
  require_relative "slot_run"
8
8
  require_relative "stale_pipeline_canceller"
9
9
  require_relative "workspaces"
10
+ require_relative "trunk_run"
11
+ require_relative "../trunk/checker"
10
12
 
11
13
  module FunCi
12
14
  module Pipeline
@@ -30,7 +32,7 @@ module FunCi
30
32
  return unknown_commit unless known_commit?
31
33
 
32
34
  start_run
33
- run_in(workspace.acquire(@commit.sha))
35
+ checking_the_trunk { run_in(workspace.acquire(@commit.sha)) }
34
36
  end
35
37
 
36
38
  # The background launcher swaps in a fresh recorder after forking, so
@@ -54,6 +56,13 @@ module FunCi
54
56
  committed.folder_exists? ? committed : Setup::ProjectConfig.new(@project)
55
57
  end
56
58
 
59
+ # The check is recorded by the recorder the run holds once its stages are done.
60
+ def checking_the_trunk
61
+ trunk = TrunkRun.start(@seams.trunk || Trunk::Checker.for(@project), @commit.sha, recorder)
62
+ @io.stdout.puts(trunk.notice) if trunk.notice
63
+ yield.tap { trunk.finish(recorder) }
64
+ end
65
+
57
66
  def handle_config_errors(config)
58
67
  config.validate.each { |e| @io.stdout.puts "fun-ci: #{e}" }
59
68
  unless config.folder_exists?
@@ -6,10 +6,10 @@ require_relative "pipeline_forker"
6
6
  module FunCi
7
7
  module Pipeline
8
8
  # `fun-ci trigger [--background] <commit-hash> <branch>`. --no-validate is
9
- # the 1.x name for --background, kept for one release.
9
+ # the 1.x name for --background, kept until 3.0.
10
10
  class TriggerCommand
11
11
  BACKGROUND = %w[--background --no-validate].freeze
12
- DEPRECATED = "fun-ci: --no-validate is now --background; the old name goes in 2.1."
12
+ DEPRECATED = "fun-ci: --no-validate is now --background; the old name goes in 3.0."
13
13
 
14
14
  def initialize(io:, recorder:, pipeline_forker:, project: Dir.pwd)
15
15
  @io = io
@@ -47,12 +47,16 @@ module FunCi
47
47
  end
48
48
 
49
49
  # The forker answers whether it started a run; one that did says how an
50
- # agent gets its verdict (acceptance-tests.md, AT-9.14).
50
+ # agent gets its verdict (acceptance-tests.md, AT-9.14), and what the
51
+ # forker says of a first fetch of the trunk.
51
52
  def fork_pipeline(commit)
52
53
  db_path = @recorder.db_path
53
54
  @recorder.close
54
- started = @pipeline_forker.call(commit_hash: commit.sha, branch: commit.branch, db_path: db_path)
55
- say_how_to_wait(commit.sha[0, 7]) if started
55
+ forked = @pipeline_forker.call(commit_hash: commit.sha, branch: commit.branch, db_path: db_path)
56
+ return 0 unless forked
57
+
58
+ say_how_to_wait(commit.sha[0, 7])
59
+ @io.stdout.puts(forked.notice) if forked.notice
56
60
  0
57
61
  end
58
62
 
@@ -31,9 +31,11 @@ module FunCi
31
31
  # which masking and injected command runners see; `clock` answers seconds
32
32
  # for the evidence budget; `extractor_runner` makes what runs a project's
33
33
  # own extractors (`run:` entries), given dir:, env: and scratch:;
34
- # `process_table` answers what ps lists, for a stage over budget.
34
+ # `process_table` answers what ps lists, for a stage over budget; `trunk`
35
+ # checks a commit against the trunk (#check(sha) answers a Trunk::Check,
36
+ # or nil for no check), nil for the project's own.
35
37
  Seams = Data.define(:command_runner, :time_budgets, :commit_validator, :recorder, :background_launcher, :workspace,
36
- :stage_dir, :environment, :clock, :extractor_runner, :process_table)
38
+ :stage_dir, :environment, :clock, :extractor_runner, :process_table, :trunk)
37
39
 
38
40
  # Reopened rather than given as a block to Data.define, so tools that read
39
41
  # the source (mutineer) see these as Seams' methods.
@@ -43,7 +45,7 @@ module FunCi
43
45
  workspace: nil, commit_validator: method(:commit_exists?), stage_dir: StageDir.method(:create),
44
46
  environment: ENV.to_h, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
45
47
  extractor_runner: Evidence::CommandRunner.method(:new),
46
- process_table: Evidence::ProcessTable.method(:now) }
48
+ process_table: Evidence::ProcessTable.method(:now), trunk: nil }
47
49
  end
48
50
 
49
51
  def self.commit_exists?(sha) = Open3.capture2e(GitEnvironment::CLEAN, "git", "cat-file", "-t", sha).last.success?
@@ -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
 
@@ -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"))