fun_ci 2.0.1-aarch64-linux → 2.1.0-aarch64-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) 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. data/libexec/fun-ci-renderer +0 -0
  84. metadata +32 -1
@@ -3,6 +3,7 @@
3
3
  require_relative "run_report"
4
4
  require_relative "verdict"
5
5
  require_relative "digest"
6
+ require_relative "trunk_text"
6
7
 
7
8
  module FunCi
8
9
  module Agent
@@ -11,10 +12,19 @@ module FunCi
11
12
  module StatusText
12
13
  WORDS = { "failed" => "FAILED", "over_budget" => "OVER BUDGET" }.freeze
13
14
 
14
- def self.lines(report)
15
+ # trunk: whether the agent asked about the trunk, which says so while its check is going.
16
+ def self.lines(report, trunk: false)
15
17
  needed = Verdict::LEVELS.fetch(report.need)
16
18
  [header(report), *report.stages.map { |stage| stage_line(stage, needed) }, *Digest.lines(report.stages, needed),
17
- *footer(report)]
19
+ *trunk(report, trunk), *footer(report)]
20
+ end
21
+
22
+ # The trunk lines; nothing while the check is going, unless asked about
23
+ # (right after a commit it would say nothing useful).
24
+ def self.trunk(report, asked)
25
+ return [] if report.trunk.nil? || (report.trunk.state == "checking" && !asked)
26
+
27
+ TrunkText.lines(report.trunk, branch: report.branch, next_step: report.deciding.nil?)
18
28
  end
19
29
 
20
30
  def self.header(report) = %(fun-ci: #{report.sha[0, 7]} "#{report.subject}" on #{report.branch})
@@ -27,6 +37,7 @@ module FunCi
27
37
 
28
38
  def self.footer(report)
29
39
  return ["fun-ci why #{report.sha[0, 7]} #{report.deciding}"] if report.deciding
40
+ return ["fun-ci why #{report.sha[0, 7]} trunk"] if report.trunk&.state == "conflicts"
30
41
  return [] unless report.verdict == :superseded && report.superseded_by
31
42
 
32
43
  ["Superseded by #{report.superseded_by[0, 7]}."]
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../trunk/shown"
4
+
5
+ module FunCi
6
+ module Agent
7
+ # A run's trunk as the object of `status --json` (architecture.md, Checking against the trunk,
8
+ # status --json): every field in every state, nil where it doesn't apply.
9
+ # Fields once published keep their names.
10
+ module TrunkJson
11
+ def self.document(shown)
12
+ { state: shown.state, **tip(shown.check&.tip), stale: shown.stale?, fetch: fetch(shown.fetch),
13
+ fetch_error: shown.fetch_error, **merge(shown.check&.merge), moved_to: shown.moved_to }
14
+ end
15
+
16
+ NO_TIP = { ref: nil, sha: nil, as_of: nil }.freeze
17
+
18
+ def self.tip(tip) = tip ? { ref: tip.ref, sha: tip.sha, as_of: tip.seen_at.utc.iso8601 } : NO_TIP
19
+
20
+ def self.merge(merge)
21
+ { ahead: merge&.ahead, behind: merge&.behind, files: merge&.files || [], reason: merge&.reason }
22
+ end
23
+
24
+ def self.fetch(last)
25
+ return "none" unless last
26
+
27
+ last.error ? "failed" : "ok"
28
+ end
29
+ private_class_method :tip, :merge, :fetch
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "../persistence/trunk_checks"
5
+ require_relative "../persistence/trunk_fetches"
6
+ require_relative "../trunk/shown"
7
+
8
+ module FunCi
9
+ module Agent
10
+ # How a run's commit stands against the project's trunk, as recorded:
11
+ # its latest check, how old that is, why the last fetch failed, and
12
+ # where the trunk has moved to since, which it reads and never checks.
13
+ class TrunkReading
14
+ # Where the trunk is now, unread: for a reader polling every second, which mustn't run git each time.
15
+ UNREAD = Object.new.tap { |unread| def unread.now_at(_tip) = nil }.freeze
16
+
17
+ # trunk: where the trunk is now (#now_at(tip)).
18
+ def initialize(db, project, clock, trunk)
19
+ @checks = Persistence::TrunkChecks.new(db, project)
20
+ @fetches = Persistence::TrunkFetches.new(db, project)
21
+ @clock = clock
22
+ @trunk = trunk
23
+ end
24
+
25
+ # A Trunk::Shown, or nil for a run that began no check.
26
+ def for(run)
27
+ check = @checks.latest(run[:commit_hash])
28
+ return shown(check) if check
29
+
30
+ started = run[:trunk_started_at]
31
+ started && Trunk::Shown.unchecked(started: Time.parse(started), now: @clock.now)
32
+ end
33
+
34
+ # Checks the commit against where the trunk is now, without fetching, and keeps the check,
35
+ # unless the project checks no trunk any more.
36
+ def recheck(sha)
37
+ check = @trunk.check(sha, @fetches)
38
+ @checks.record(check, checked_at: @clock.now) if check
39
+ end
40
+
41
+ private
42
+
43
+ def shown(check)
44
+ Trunk::Shown.of(check, now: @clock.now, fetch: @fetches.last, moved_to: moved(check))
45
+ end
46
+
47
+ def moved(check)
48
+ now = check.tip && @trunk.now_at(check.tip)
49
+ now == check.tip&.sha ? nil : now
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../trunk/shown"
4
+ require_relative "age"
5
+ require_relative "next_step"
6
+
7
+ module FunCi
8
+ module Agent
9
+ # How a run's commit stands against the trunk, as `status` and `wait`
10
+ # print it (design.md, The trunk).
11
+ module TrunkText
12
+ # next_step: whether to say how to integrate a conflict; not while a needed stage has failed.
13
+ def self.lines(shown, branch:, next_step:)
14
+ return [" trunk checking"] unless shown.check
15
+
16
+ files = shown.check.merge.files
17
+ [" trunk #{shown.state.tr("_", " ").ljust(12)} #{detail(shown)}".rstrip, *files.map { |file| " #{file}" },
18
+ *moved(shown),
19
+ *(next_step && shown.state == "conflicts" ? [NextStep.line(shown.check.tip, files, branch: branch)] : [])]
20
+ end
21
+
22
+ def self.moved(shown)
23
+ return [] unless shown.moved_to
24
+
25
+ [" #{shown.check.tip.ref} has moved to #{shown.moved_to[0, 7]} since; fun-ci status --trunk checks again"]
26
+ end
27
+
28
+ def self.detail(shown)
29
+ merge = shown.check.merge
30
+ return merge.reason if shown.state == "unknown"
31
+ return "" if shown.state == "in_trunk"
32
+
33
+ seen = seen(shown)
34
+ shown.state == "up_to_date" ? seen : "#{seen}, #{merge.ahead} ahead, #{merge.behind} behind"
35
+ end
36
+
37
+ def self.seen(shown)
38
+ tip = shown.check.tip
39
+ "#{tip.ref} #{tip.sha[0, 7]}, fetched #{Age.words(shown.age)}#{stale(shown)}"
40
+ end
41
+
42
+ def self.stale(shown)
43
+ return "" unless shown.stale?
44
+
45
+ shown.fetch_error ? ", STALE (fetch failed: #{shown.fetch_error})" : ", STALE"
46
+ end
47
+ private_class_method :moved, :detail, :seen, :stale
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Agent
5
+ # The verdict for an agent that asks with --trunk (architecture.md, Checking against the trunk,
6
+ # status and wait): a failure stays one whatever the trunk says; a passed
7
+ # run is undecided while its check is going and in conflict when it
8
+ # conflicts. A trunk that couldn't be checked leaves the verdict alone:
9
+ # its causes are the project's, and would fail every run until fixed.
10
+ module TrunkVerdict
11
+ BY_STATE = { "checking" => :undecided, "conflicts" => :conflicts }.freeze
12
+
13
+ def self.of(verdict, shown)
14
+ return verdict unless verdict == :passed && shown
15
+
16
+ BY_STATE.fetch(shown.state, verdict)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../evidence/document"
4
+ require_relative "../trunk/check"
5
+ require_relative "../trunk/regions"
6
+
7
+ module FunCi
8
+ module Agent
9
+ # A conflict with the trunk, merged again, as the evidence `why REV trunk`
10
+ # prints (design.md, The trunk): git's messages, then
11
+ # each conflicted region under its file, its lines numbered as the file's.
12
+ module TrunkWhy
13
+ EXTRACTOR = "merge-tree"
14
+
15
+ # explained: a Trunk::Explained, or nil when the commits are gone.
16
+ def self.document(explained, tip)
17
+ return gone(tip) unless explained
18
+
19
+ Evidence::Document.new(chosen: [], facts: [], failures: [], problems: [],
20
+ excerpts: [messages(explained.messages), *explained.files.flat_map do |path, text|
21
+ regions(path, text, tip)
22
+ end])
23
+ end
24
+
25
+ def self.messages(lines) = { title: "Git's messages", location: "merge", lines: lines, extractor: EXTRACTOR }
26
+
27
+ MARKER = /\A(<{7}|>{7}) (\h{40})\z/
28
+
29
+ # A conflict marker names its side by the trunk's name or the commit's short SHA, not a full SHA.
30
+ def self.labelled(line, tip)
31
+ marker, sha = MARKER.match(line)&.captures
32
+ return line unless marker
33
+
34
+ "#{marker} #{sha == tip.sha ? tip.ref : sha[0, 7]}"
35
+ end
36
+
37
+ def self.regions(path, text, tip)
38
+ Trunk::Regions.of(text).map do |region|
39
+ { title: path, location: "lines #{region.first}-#{region.last}", extractor: EXTRACTOR,
40
+ lines: region.lines.map { |number, line| "#{number.to_s.rjust(4)} #{labelled(line, tip)}" } }
41
+ end
42
+ end
43
+
44
+ def self.gone(tip)
45
+ problem = { extractor: EXTRACTOR,
46
+ message: "#{tip.ref} #{tip.sha[0, 7]} or the commit is no longer in this repository" }
47
+ Evidence::Document.new(chosen: [], facts: [], failures: [], excerpts: [], problems: [problem])
48
+ end
49
+ private_class_method :messages, :labelled, :regions, :gone
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "run_report"
4
+ require_relative "trunk_json"
5
+ require_relative "../evidence/caps"
6
+
7
+ module FunCi
8
+ module Agent
9
+ # `why REV trunk --json` (design.md, The trunk): the
10
+ # run's trunk, and the conflict as evidence, capped as a stage's is, or
11
+ # why there is none. Fields once published keep their names.
12
+ module TrunkWhyJson
13
+ SCHEMA = 1
14
+
15
+ # evidence: an Evidence::Document, or nil when the run's commit doesn't conflict.
16
+ def self.document(report, evidence)
17
+ { schema: SCHEMA, commit: { sha: report.sha, branch: report.branch, subject: report.subject }, stage: "trunk",
18
+ trunk: report.trunk && TrunkJson.document(report.trunk), evidence: evidence && Evidence::Caps.new.apply(evidence).to_h,
19
+ no_evidence: evidence ? nil : "no conflict" }
20
+ end
21
+ end
22
+ end
23
+ end
@@ -23,9 +23,9 @@ module FunCi
23
23
  end
24
24
 
25
25
  def run(args)
26
- options = Options.parse(args, takes: %i[need json within follow_branch])
26
+ options = Options.parse(args, takes: %i[need json within follow_branch trunk])
27
27
  sha = resolve(options.rev)
28
- output = Output.new(@context.io.stdout, json: options.json)
28
+ output = output(options)
29
29
  answer(sha, wait_for(sha, options, deadline(options), output), output)
30
30
  rescue Options::Invalid => e
31
31
  usage(e.message)
@@ -37,7 +37,7 @@ module FunCi
37
37
 
38
38
  # Follows a superseded run on to the commit that superseded it, when asked to.
39
39
  def wait_for(sha, options, deadline, output)
40
- report = Waiting.new(@context.clock, deadline: deadline) { poll(sha, options.need) }.until_decided
40
+ report = Waiting.new(@context.clock, deadline: deadline) { poll(sha, options) }.until_decided
41
41
  return report unless options.follow_branch && report&.superseded_by
42
42
 
43
43
  output.following(sha, report)
@@ -52,10 +52,10 @@ module FunCi
52
52
  ExitCode::FOR.fetch(report.verdict)
53
53
  end
54
54
 
55
- def poll(sha, need)
55
+ def poll(sha, options)
56
56
  @context.pipeline.watch(@context.db)
57
57
  reports.mark_waited(sha, @context.clock.now)
58
- reports.for(sha, need) || start_after_grace(sha)
58
+ report_for(sha, options) || start_after_grace(sha)
59
59
  end
60
60
 
61
61
  # Nil while waiting for a run; NOT_SET_UP once one can't be started.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "command_support"
4
4
  require_relative "output"
5
+ require_relative "trunk_why"
5
6
 
6
7
  module FunCi
7
8
  module Agent
@@ -33,10 +34,20 @@ module FunCi
33
34
  return output.unknown(sha) unless report
34
35
 
35
36
  stage_name = options.stage || report.deciding
37
+ return why_trunk(report, output) if stage_name == "trunk"
38
+
36
39
  options.raw ? raw(report, stage_name) : output.why(report, stage_name)
37
40
  ExitCode::FOR.fetch(report.verdict)
38
41
  end
39
42
 
43
+ # The conflict merged again; nothing to explain unless the run's commit conflicts.
44
+ def why_trunk(report, output)
45
+ trunk = report.trunk
46
+ tip = trunk&.state == "conflicts" ? trunk.check.tip : nil
47
+ output.why_trunk(report, tip && TrunkWhy.document(@context.trunk.explain(report.sha, tip), tip))
48
+ ExitCode::FOR.fetch(report.verdict)
49
+ end
50
+
40
51
  def raw(report, stage_name)
41
52
  stage = report.stages.find { |candidate| candidate.name == stage_name }
42
53
  text = stage && reports.raw_output(stage)
data/lib/fun_ci/cli.rb CHANGED
@@ -88,7 +88,7 @@ module FunCi
88
88
 
89
89
  def agent_context(db)
90
90
  Agent::Context.new(db: db, git: Agent::Git.new(Dir.pwd), io: @io, clock: Agent::SystemClock.new,
91
- pipeline: Agent::LivePipeline.new(Dir.pwd))
91
+ pipeline: Agent::LivePipeline.new(Dir.pwd), trunk: Trunk::Local.new(Dir.pwd))
92
92
  end
93
93
 
94
94
  def run_init(args) = setup_commands.init(args)
@@ -30,7 +30,7 @@ module FunCi
30
30
  runs List this project's recent runs, newest first
31
31
  wait Wait for a commit's verdict, then exit with it as status does
32
32
  events Print this project's runs' events as JSON lines
33
- why Print everything kept about why a commit's stage failed
33
+ why Print everything kept about why a commit's stage failed, or its conflict with the trunk
34
34
  extract Try a stage's evidence extractors on a saved output
35
35
 
36
36
  Options:
@@ -44,13 +44,15 @@ module FunCi
44
44
  --need LEVEL (status, wait, why) What must pass: build, fast (default) or all
45
45
  --within TIME (wait) Give up undecided after 30, 30s or 5m
46
46
  --follow-branch (wait) Move on to the newer commit that superseded the run
47
+ --trunk (status, wait) Exit 6 when the run passed but conflicts with the trunk
48
+ --raw (why) Print the stage's whole output as it was kept
47
49
  -n N (runs) How many runs to list (default 10)
48
50
  --branch NAME (runs) Only runs on this branch
49
51
  --follow (events) Keep printing events as they happen, until stopped
50
52
  --only failures (events) Print only failed stages and superseded runs
51
53
  --json Print JSON instead of text (events always print JSON lines)
52
54
  Exit codes: 0 passed, 1 failed, 2 over budget, 3 undecided,
53
- 4 superseded, 5 no run, 64 usage error
55
+ 4 superseded, 5 no run, 6 conflicts with the trunk (--trunk), 64 usage error
54
56
 
55
57
  Init options:
56
58
  --everything Run init + install-hooks + check in one step
@@ -6,10 +6,17 @@ require_relative "../persistence/stage_job"
6
6
  require_relative "../persistence/active_runs"
7
7
  require_relative "../pipeline/run_canceller"
8
8
  require_relative "streak_counter"
9
+ require_relative "trunk_marks"
10
+ require_relative "cancelled_folding"
11
+ require_relative "row_order"
9
12
 
10
13
  module FunCi
11
14
  module Console
12
15
  class BoardData
16
+ # How many of a branch's runs are read to fold its cancelled ones and
17
+ # find its standing against the trunk.
18
+ BRANCH_WINDOW = 100
19
+
13
20
  def initialize(db, limit: 15, page_size: nil, run_canceller: Pipeline::RunCanceller.new)
14
21
  @db = db
15
22
  @page_size = page_size || limit
@@ -27,18 +34,19 @@ module FunCi
27
34
  @limit = [@limit, page_size].max
28
35
  end
29
36
 
30
- # Whether the store holds runs beyond those `runs` loads.
31
- def more?
32
- Persistence::PipelineRun.recent(@db, limit: @limit + 1).size > @limit
33
- end
37
+ # Whether there are branches beyond those `runs` loads.
38
+ def more? = Persistence::PipelineRun.branch_heads(@db, limit: @limit + 1).size > @limit
34
39
 
35
40
  # Records failed each slow suite whose process died (acceptance-tests.md, AT-8.3).
36
41
  def record_dead_slow_suites = @run_canceller.record_dead(@db)
37
42
 
38
- def runs
39
- pipeline_runs = Persistence::PipelineRun.recent(@db, limit: @limit)
40
- pipeline_runs.map { |run| enrich_with_stages(run) }
41
- end
43
+ # One row per branch, its newest run, for the branches that most need you
44
+ # and then those run most recently, in RowOrder: a run of cancelled
45
+ # runs folded into one, with its stages and the branch's standing against the trunk.
46
+ def runs = RowOrder.of(Persistence::PipelineRun.branch_heads(@db, limit: @limit).map { |head| branch_row(head) })
47
+
48
+ # The projects among `runs` whose trunk is stale.
49
+ def stale_trunks(runs, now:) = TrunkMarks.new(@db).stale(runs, now: now)
42
50
 
43
51
  def streak
44
52
  pipeline_runs = Persistence::PipelineRun.recent(@db, limit: @limit)
@@ -53,6 +61,12 @@ module FunCi
53
61
 
54
62
  private
55
63
 
64
+ def branch_row(head)
65
+ branch_runs = Persistence::PipelineRun.of_branch(@db, head[:project_path], head[:branch], limit: BRANCH_WINDOW)
66
+ row = CancelledFolding.fold(branch_runs).first
67
+ enrich_with_stages(row.merge(trunk: TrunkMarks.new(@db).mark(branch_runs).first[:trunk]))
68
+ end
69
+
56
70
  def enrich_with_stages(run)
57
71
  run.merge(stages: Persistence::StageJob.for_run(@db, run[:id]).map { |job| stage(job) })
58
72
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Console
5
+ # Runs of one project's branch cancelled one after another, as a rebase or
6
+ # a burst of commits leaves them, as one: the newest of them, with
7
+ # `folded:` saying how many it stands for (design.md, The console).
8
+ module CancelledFolding
9
+ # `runs` newest first, each run of cancelled ones folded into its newest.
10
+ def self.fold(runs) = runs.slice_when { |newer, older| !one_stream?(newer, older) }.map { |group| kept(group) }
11
+
12
+ def self.one_stream?(newer, older) = cancelled?(newer) && cancelled?(older) && key(newer) == key(older)
13
+ def self.cancelled?(run) = run[:status] == "cancelled"
14
+ def self.key(run) = [run[:project_path], run[:branch]]
15
+ def self.kept(group) = group.size > 1 ? group.first.merge(folded: group.size) : group.first
16
+ private_class_method :one_stream?, :cancelled?, :key, :kept
17
+ end
18
+ end
19
+ end
@@ -2,19 +2,22 @@
2
2
 
3
3
  require_relative "stage_events"
4
4
  require_relative "milestone_events"
5
+ require_relative "trunk_events"
5
6
 
6
7
  module FunCi
7
8
  module Console
8
9
  # Every protocol `event` since the last poll of the runs: the stages that
9
10
  # changed (for the effects on a stage's column), then the milestones the
10
- # runs reached (for the header).
11
+ # runs reached, then branches starting or stopping conflicting with the
12
+ # trunk (both for the header).
11
13
  class ConsoleEvents
12
14
  def initialize
13
15
  @stages = StageEvents.new
14
16
  @milestones = MilestoneEvents.new
17
+ @trunk = TrunkEvents.new
15
18
  end
16
19
 
17
- def since_last(runs) = @stages.since_last(runs) + @milestones.since_last(runs)
20
+ def since_last(runs) = @stages.since_last(runs) + @milestones.since_last(runs) + @trunk.since_last(runs)
18
21
  end
19
22
  end
20
23
  end
@@ -7,7 +7,8 @@ module FunCi
7
7
  module Console
8
8
  # The project's .fun-ci/console.log, where the console notes what the
9
9
  # renderer got wrong: the terminal is the renderer's, so stderr would
10
- # draw over it.
10
+ # draw over it. A .gitignore beside it, unless the project has one there,
11
+ # keeps the log out of the project's commits.
11
12
  class ConsoleLog
12
13
  attr_reader :path
13
14
 
@@ -19,6 +20,8 @@ module FunCi
19
20
  # Appends `text` as one line, stamped with the clock's time in UTC.
20
21
  def write(text)
21
22
  FileUtils.mkdir_p(File.dirname(@path))
23
+ ignore_file = File.join(File.dirname(@path), ".gitignore")
24
+ File.write(ignore_file, "console.log\n") unless File.exist?(ignore_file)
22
25
  File.open(@path, "a") { |file| file.puts("#{@clock.call.utc.iso8601} #{text}") }
23
26
  end
24
27
  end
@@ -35,7 +35,13 @@ module FunCi
35
35
  def board(runs)
36
36
  page = @view.page(runs, more: @board_data.more?)
37
37
  { t: "board", now: @clock.call.to_i, streak: @board_data.streak, **page,
38
- runs: page[:runs].map { |run| RunMessage.from(run) } }
38
+ runs: page[:runs].map { |run| RunMessage.from(run) }, **stale_trunks(runs) }
39
+ end
40
+
41
+ # Only when a trunk is stale, as the protocol lets a board leave it out.
42
+ def stale_trunks(runs)
43
+ stale = @board_data.stale_trunks(runs, now: @clock.call)
44
+ stale.empty? ? {} : { stale_trunks: stale }
39
45
  end
40
46
  end
41
47
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Console
5
+ # The order of the console's rows (design.md, The console): a project's
6
+ # branches together, the project that most needs you first, and within it
7
+ # the branch that most needs you first, newest first among equals. Ruby
8
+ # decides it, since the cursor moves through the rows in this order.
9
+ module RowOrder
10
+ URGENCY = { "failed" => 0, "timed_out" => 1, "running" => 2, "scheduled" => 3, "completed" => 4 }.freeze
11
+ # A conflict with the trunk needs you as much as a timeout.
12
+ CONFLICT = 1
13
+ LEAST = 5
14
+
15
+ # `rows`, one per branch and newest first, in the order the console shows them.
16
+ def self.of(rows)
17
+ projects = rows.group_by { |row| row[:project_path] }.values
18
+ projects.sort_by.with_index { |branches, i| [most(branches), i] }.flat_map { |branches| ordered(branches) }
19
+ end
20
+
21
+ def self.ordered(branches) = branches.sort_by.with_index { |row, i| [urgency(row), i] }
22
+ def self.most(branches) = branches.map { |row| urgency(row) }.min
23
+
24
+ def self.urgency(row)
25
+ by_status = URGENCY.fetch(row[:status], LEAST)
26
+ row.dig(:trunk, :branch_state) == "conflicts" ? [by_status, CONFLICT].min : by_status
27
+ end
28
+ private_class_method :ordered, :most, :urgency
29
+ end
30
+ end
31
+ end
@@ -13,15 +13,22 @@ module FunCi
13
13
  def self.from(run)
14
14
  { id: run[:id], sha: run[:commit_hash], branch: run[:branch], project: run[:project_path],
15
15
  status: status(run[:status]), started_at: epoch(run[:created_at]), updated_at: epoch(run[:updated_at]),
16
- stages: run[:stages].map { |stage| stage(stage) } }.compact
16
+ stages: run[:stages].map { |stage| stage(stage) }, **marks(run) }.compact
17
17
  end
18
18
 
19
+ # What the row marks beyond the run itself: a conflict with the trunk, and
20
+ # how many cancelled runs it stands for.
21
+ def self.marks(run) = { trunk: conflict(run[:trunk]), folded: run[:folded] }
22
+
23
+ # The branch's standing against the trunk, sent only where the row shows it: a conflict.
24
+ def self.conflict(trunk) = trunk && trunk[:branch_state] == "conflicts" ? trunk : nil
25
+
19
26
  def self.stage(stage)
20
27
  { stage: stage[:stage], status: status(stage[:status]),
21
28
  duration_ms: stage[:duration] && (stage[:duration] * 1000).round,
22
29
  started_at: (epoch(stage[:started_at]) if stage[:status] == "running") }.compact
23
30
  end
24
- private_class_method :stage
31
+ private_class_method :stage, :conflict, :marks
25
32
 
26
33
  def self.status(name) = STATUS.fetch(name, name)
27
34
  private_class_method :status
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Console
5
+ # The header's events for the trunk (design.md, The
6
+ # trunk): trunk_conflict when a branch's standing becomes conflicts,
7
+ # trunk_clear when it moves from conflicts to another settled standing,
8
+ # each at most once a poll however many branches changed, naming one
9
+ # branch's newest run and how many. A branch keeps its standing through
10
+ # a poll that shows none, and the first poll has nothing to compare with.
11
+ class TrunkEvents
12
+ def initialize
13
+ @standing = nil
14
+ end
15
+
16
+ # runs: as TrunkMarks marks them, newest first.
17
+ def since_last(runs)
18
+ now = standings(runs)
19
+ events = @standing ? [started(now), stopped(now)].compact : []
20
+ @standing = (@standing || {}).merge(now)
21
+ events
22
+ end
23
+
24
+ private
25
+
26
+ # { [project, branch] => [newest run id, standing] }
27
+ def standings(runs)
28
+ runs.select { |run| run[:trunk] }.to_h do |run|
29
+ [run.values_at(:project_path, :branch), [run[:id], run[:trunk][:branch_state]]]
30
+ end
31
+ end
32
+
33
+ def started(now) = event("trunk_conflict", now.select { |key, (_, state)| conflicts?(state) && !was?(key) })
34
+ def stopped(now) = event("trunk_clear", now.select { |key, (_, state)| !conflicts?(state) && was?(key) })
35
+ def was?(key) = conflicts?(@standing.dig(key, 1))
36
+ def conflicts?(state) = state == "conflicts"
37
+
38
+ def event(name, branches)
39
+ branches.empty? ? nil : { t: "event", name: name, run_id: branches.values.first.first, branches: branches.size }
40
+ end
41
+ end
42
+ end
43
+ end