fun_ci 2.1.1-aarch64-linux → 2.3.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +95 -0
  3. data/README.md +36 -6
  4. data/lib/fun_ci/agent/cancel_command.rb +52 -0
  5. data/lib/fun_ci/agent/command_support.rb +7 -0
  6. data/lib/fun_ci/agent/commands.rb +4 -1
  7. data/lib/fun_ci/agent/due_in.rb +21 -0
  8. data/lib/fun_ci/agent/events_command.rb +14 -7
  9. data/lib/fun_ci/agent/job_events.rb +56 -0
  10. data/lib/fun_ci/agent/job_json.rb +25 -0
  11. data/lib/fun_ci/agent/job_report.rb +89 -0
  12. data/lib/fun_ci/agent/job_why.rb +59 -0
  13. data/lib/fun_ci/agent/job_why_json.rb +31 -0
  14. data/lib/fun_ci/agent/job_why_text.rb +59 -0
  15. data/lib/fun_ci/agent/jobs_command.rb +43 -0
  16. data/lib/fun_ci/agent/jobs_text.rb +55 -0
  17. data/lib/fun_ci/agent/live_pipeline.rb +14 -6
  18. data/lib/fun_ci/agent/options.rb +3 -3
  19. data/lib/fun_ci/agent/output.rb +14 -2
  20. data/lib/fun_ci/agent/snapshots.rb +20 -1
  21. data/lib/fun_ci/agent/span.rb +24 -0
  22. data/lib/fun_ci/agent/stage_summary.rb +10 -6
  23. data/lib/fun_ci/agent/starts_in.rb +14 -0
  24. data/lib/fun_ci/agent/status_command.rb +3 -2
  25. data/lib/fun_ci/agent/status_json.rb +1 -1
  26. data/lib/fun_ci/agent/status_text.rb +32 -6
  27. data/lib/fun_ci/agent/trunk_reading.rb +2 -1
  28. data/lib/fun_ci/agent/wait_command.rb +2 -1
  29. data/lib/fun_ci/agent/why_command.rb +6 -2
  30. data/lib/fun_ci/cli.rb +1 -1
  31. data/lib/fun_ci/cli_help.rb +4 -1
  32. data/lib/fun_ci/console/board_data.rb +26 -13
  33. data/lib/fun_ci/console/console_state.rb +12 -6
  34. data/lib/fun_ci/console/job_message.rb +39 -0
  35. data/lib/fun_ci/console/job_order.rb +23 -0
  36. data/lib/fun_ci/console/job_rows.rb +21 -0
  37. data/lib/fun_ci/console/key_handler.rb +35 -14
  38. data/lib/fun_ci/console/paging.rb +26 -0
  39. data/lib/fun_ci/console/view.rb +18 -9
  40. data/lib/fun_ci/evidence/settings.rb +15 -8
  41. data/lib/fun_ci/evidence/settings_check.rb +14 -3
  42. data/lib/fun_ci/jobs/due.rb +34 -0
  43. data/lib/fun_ci/jobs/due_jobs.rb +50 -0
  44. data/lib/fun_ci/jobs/folders.rb +50 -0
  45. data/lib/fun_ci/jobs/job.rb +31 -0
  46. data/lib/fun_ci/jobs/job_fork.rb +52 -0
  47. data/lib/fun_ci/jobs/job_run.rb +104 -0
  48. data/lib/fun_ci/jobs/locks.rb +30 -0
  49. data/lib/fun_ci/jobs/schedule.rb +25 -0
  50. data/lib/fun_ci/jobs/site.rb +17 -0
  51. data/lib/fun_ci/jobs/standings.rb +50 -0
  52. data/lib/fun_ci/jobs/state.rb +27 -0
  53. data/lib/fun_ci/jobs/wall_clock_wait.rb +29 -0
  54. data/lib/fun_ci/persistence/active_jobs.rb +60 -0
  55. data/lib/fun_ci/persistence/database.rb +14 -4
  56. data/lib/fun_ci/persistence/job_recorder.rb +47 -0
  57. data/lib/fun_ci/persistence/job_runs.rb +80 -0
  58. data/lib/fun_ci/persistence/pipeline_run.rb +2 -1
  59. data/lib/fun_ci/persistence/raw_outputs.rb +3 -0
  60. data/lib/fun_ci/pipeline/budgets.rb +9 -0
  61. data/lib/fun_ci/pipeline/pipeline_forker.rb +42 -10
  62. data/lib/fun_ci/pipeline/priorities.rb +23 -0
  63. data/lib/fun_ci/pipeline/process_runner.rb +18 -6
  64. data/lib/fun_ci/pipeline/progress_reporter.rb +4 -0
  65. data/lib/fun_ci/pipeline/run_canceller.rb +24 -0
  66. data/lib/fun_ci/pipeline/slot.rb +12 -1
  67. data/lib/fun_ci/pipeline/slot_run.rb +19 -4
  68. data/lib/fun_ci/pipeline/stage_execution.rb +9 -4
  69. data/lib/fun_ci/pipeline/trigger.rb +17 -6
  70. data/lib/fun_ci/pipeline/trigger_command.rb +12 -2
  71. data/lib/fun_ci/pipeline/trigger_params.rb +20 -9
  72. data/lib/fun_ci/pipeline/worktree_pool.rb +2 -2
  73. data/lib/fun_ci/pipeline/worktree_prune.rb +8 -6
  74. data/lib/fun_ci/pipeline/worktrees.rb +11 -5
  75. data/lib/fun_ci/setup/agent_instructions.rb +24 -5
  76. data/lib/fun_ci/setup/commands.rb +3 -1
  77. data/lib/fun_ci/setup/hook_script.rb +9 -4
  78. data/lib/fun_ci/setup/hook_writer.rb +13 -5
  79. data/lib/fun_ci/setup/installed_hooks.rb +45 -0
  80. data/lib/fun_ci/setup/installer.rb +3 -2
  81. data/lib/fun_ci/setup/lfs_hook.rb +50 -0
  82. data/lib/fun_ci/setup/project_config.rb +13 -1
  83. data/lib/fun_ci/setup/settings.rb +40 -11
  84. data/lib/fun_ci/setup/setup_checker.rb +20 -3
  85. data/lib/fun_ci/trunk/shown.rb +7 -4
  86. data/lib/fun_ci/version.rb +1 -1
  87. data/libexec/fun-ci-renderer +0 -0
  88. metadata +36 -1
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "stage_summary"
4
+ require_relative "evidence_text"
5
+ require_relative "span"
6
+ require_relative "starts_in"
7
+
8
+ module FunCi
9
+ module Agent
10
+ # What `fun-ci why --job NAME` prints (acceptance-tests.md, AT-13.21): the
11
+ # job and the commit its latest run tested, how that run ended, then
12
+ # everything kept about it.
13
+ module JobWhyText
14
+ PASSED = "Nothing is kept about a job that passed."
15
+ RUNNING = "It is still running; fun-ci wait doesn't wait for jobs, so ask again later."
16
+ CANCELLED = "It was cancelled; it runs again on the next commit."
17
+
18
+ def self.lines(report)
19
+ summary = StageSummary.line(report.stage, seconds: Span.method(:words))
20
+ [header(report), summary, *body(report), *raw(report), *only_tail(report)]
21
+ end
22
+
23
+ def self.header(report) = "fun-ci: job #{report.name} (#{report.cadence}) on #{report.branch} #{report.sha[0, 7]}"
24
+
25
+ def self.never_ran(report)
26
+ "fun-ci: job #{report.name} (#{report.cadence}) has not run yet; it runs on the next commit."
27
+ end
28
+
29
+ def self.body(report)
30
+ stage = report.stage
31
+ return [waiting(report)] if report.starts_in
32
+ return [PASSED] if stage.state == "passed"
33
+ return [RUNNING] if stage.state == "running"
34
+ return [CANCELLED] if stage.state == "cancelled"
35
+
36
+ EvidenceText.lines(stage.evidence)
37
+ end
38
+
39
+ def self.waiting(report)
40
+ "It waits its turn, and #{StartsIn.words(report.starts_in)}; " \
41
+ "fun-ci cancel --job #{report.name} cancels it."
42
+ end
43
+
44
+ def self.raw(report)
45
+ report.stage.raw_bytes ? ["", "The whole output: fun-ci why --job #{report.name} --raw"] : []
46
+ end
47
+
48
+ # So an agent that reads it knows it can write the extractor itself.
49
+ def self.only_tail(report)
50
+ excerpts = report.stage.evidence.excerpts
51
+ return [] if excerpts.empty? || excerpts.any? { |excerpt| excerpt[:extractor] != "output-tail" }
52
+
53
+ ["", "Only the output's last lines were kept; extractors under `evidence: jobs: #{report.name}:` " \
54
+ "in .fun-ci/config keep more."]
55
+ end
56
+ private_class_method :body, :waiting, :raw, :only_tail
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "command_support"
5
+ require_relative "jobs_text"
6
+ require_relative "job_why"
7
+ require_relative "job_json"
8
+
9
+ module FunCi
10
+ module Agent
11
+ # `fun-ci jobs [--json]` (acceptance-tests.md, AT-13.22): the project's
12
+ # daily and weekly jobs and how each stands.
13
+ class JobsCommand
14
+ include CommandSupport
15
+
16
+ NAME = "jobs"
17
+ SCHEMA = 1
18
+
19
+ def initialize(context)
20
+ @context = context
21
+ end
22
+
23
+ def run(args)
24
+ options = Options.parse(args, takes: %i[json])
25
+ say(job_reports.all, options.json)
26
+ 0
27
+ rescue Options::Invalid => e
28
+ usage(e.message)
29
+ end
30
+
31
+ private
32
+
33
+ def say(reports, json)
34
+ return out.puts(JSON.generate(schema: SCHEMA, jobs: reports.map { |report| JobJson.document(report) })) if json
35
+ return out.puts("fun-ci: #{JobWhy::NO_JOBS}") if reports.empty?
36
+
37
+ JobsText.lines(reports, @context.clock.now).each { |line| out.puts line }
38
+ end
39
+
40
+ def out = @context.io.stdout
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "age"
4
+ require_relative "due_in"
5
+ require_relative "starts_in"
6
+ require_relative "span"
7
+
8
+ module FunCi
9
+ module Agent
10
+ # One line per daily or weekly job (acceptance-tests.md, AT-13.22), the
11
+ # names lined up: its cadence, its latest run's state in a word of four
12
+ # letters at most and how long it took, the branch and commit it tested
13
+ # and how long ago it started, as `runs` counts ages, and when the job is
14
+ # due again; then the `why --job`
15
+ # command of each job that failed or ran over budget.
16
+ module JobsText
17
+ WORDS = { "passed" => "ok", "failed" => "FAIL", "lost" => "LOST", "over_budget" => "OVER", "running" => "...",
18
+ "due" => "due", "cancelled" => "x", "scheduled" => "wait" }.freeze
19
+
20
+ # now: the time the ages and due times count from.
21
+ def self.lines(reports, now)
22
+ width = reports.map { |report| report.name.size }.max
23
+ reports.map { |report| line(report, width, now) } + whys(reports)
24
+ end
25
+
26
+ def self.whys(reports)
27
+ reports.select(&:needs_you?).map { |report| "fun-ci why --job #{report.name}" }
28
+ end
29
+
30
+ def self.line(report, width, now)
31
+ head = "#{report.name.ljust(width)} #{report.cadence.ljust(6)} #{WORDS.fetch(report.state,
32
+ report.state).ljust(4)}"
33
+ return "#{head} never ran runs on the next commit" unless report.stage
34
+
35
+ "#{head} #{seconds(report.stage)} #{tested(report, now)} #{due(report, now)}"
36
+ end
37
+
38
+ # `wip/foo 9e0b1d4 1h ago`; a run waiting its turn has not started.
39
+ def self.tested(report, now)
40
+ commit = "#{report.branch} #{report.sha[0, 7]}"
41
+ report.started_at ? "#{commit} #{Age.words(now - report.started_at)}" : commit
42
+ end
43
+
44
+ def self.seconds(stage) = (stage.seconds ? Span.words(stage.seconds) : "").rjust(6)
45
+
46
+ def self.due(report, now)
47
+ return "running" if report.state == "running"
48
+ return StartsIn.words(report.starts_in) if report.starts_in
49
+
50
+ report.due_at ? "due in #{DueIn.words(report.due_at - now)}" : "runs on the next commit"
51
+ end
52
+ private_class_method :whys, :line, :tested, :seconds, :due
53
+ end
54
+ end
55
+ end
@@ -6,10 +6,10 @@ require_relative "../setup/project_config"
6
6
 
7
7
  module FunCi
8
8
  module Agent
9
- # The project's pipeline, as `wait` starts and watches it: a run starts
10
- # the way the post-commit hook starts one, with `fun-ci trigger
11
- # --background` in a process of its own, rather than as a fork of the
12
- # waiting process and its open database connection.
9
+ # The project's pipeline, as `wait` starts and watches it and `cancel`
10
+ # stops a job of it: a run starts the way the post-commit hook starts one,
11
+ # with `fun-ci trigger --background` in a process of its own, rather than
12
+ # as a fork of the waiting process and its open database connection.
13
13
  class LivePipeline
14
14
  FUN_CI = File.expand_path("../../../exe/fun-ci", __dir__)
15
15
 
@@ -28,8 +28,16 @@ module FunCi
28
28
  Process.detach(pid).join
29
29
  end
30
30
 
31
- # Records failed each slow suite whose process died (acceptance-tests.md, AT-8.3).
32
- def watch(db) = Pipeline::RunCanceller.new.record_dead(db)
31
+ # Records failed each slow suite and each daily or weekly job whose
32
+ # process died (acceptance-tests.md, AT-8.3, AT-13.6).
33
+ def watch(db)
34
+ canceller = Pipeline::RunCanceller.new
35
+ canceller.record_dead(db)
36
+ canceller.record_dead_jobs(db)
37
+ end
38
+
39
+ # Stops a daily or weekly job's run and records it cancelled, as the console does.
40
+ def cancel_job(db, id) = Pipeline::RunCanceller.new.cancel_job(db, id)
33
41
  end
34
42
  end
35
43
  end
@@ -12,12 +12,12 @@ module FunCi
12
12
  class Invalid < StandardError; end
13
13
 
14
14
  Parsed = Data.define(:rev, :stage, :need, :json, :within, :follow_branch, :limit, :branch, :follow, :only, :raw,
15
- :trunk)
15
+ :trunk, :job)
16
16
  DEFAULTS = { need: "fast", json: false, within: nil, follow_branch: false, limit: 10, branch: nil,
17
- follow: false, only: nil, raw: false, trunk: false }.freeze
17
+ follow: false, only: nil, raw: false, trunk: false, job: nil }.freeze
18
18
  SWITCHES = { need: ["--need LEVEL"], json: ["--json"], within: ["--within DURATION"], raw: ["--raw"],
19
19
  follow_branch: ["--follow-branch"], limit: ["-n N", Integer], branch: ["--branch NAME"],
20
- follow: ["--follow"], only: ["--only KIND"], trunk: ["--trunk"] }.freeze
20
+ follow: ["--follow"], only: ["--only KIND"], trunk: ["--trunk"], job: ["--job NAME"] }.freeze
21
21
  FILTERS = %w[failures].freeze
22
22
  MULTIPLIERS = { "" => 1, "s" => 1, "m" => 60 }.freeze
23
23
 
@@ -9,6 +9,8 @@ require_relative "runs_text"
9
9
  require_relative "exit_code"
10
10
  require_relative "evidence_text"
11
11
  require_relative "trunk_why_json"
12
+ require_relative "job_json"
13
+ require_relative "job_report"
12
14
 
13
15
  module FunCi
14
16
  module Agent
@@ -24,8 +26,11 @@ module FunCi
24
26
  @trunk = trunk
25
27
  end
26
28
 
27
- def report(report)
28
- @json ? print_json(StatusJson.document(report)) : print_lines(StatusText.lines(report, trunk: @trunk))
29
+ # jobs: the commit's CommitJobs, when asked about.
30
+ def report(report, jobs: nil)
31
+ return print_json(with_jobs(StatusJson.document(report), jobs)) if @json
32
+
33
+ print_lines(StatusText.lines(report, trunk: @trunk, jobs: jobs || CommitJobs::NONE))
29
34
  end
30
35
 
31
36
  # Everything kept about the stage named, or says no stage decided the verdict.
@@ -87,6 +92,13 @@ module FunCi
87
92
 
88
93
  private
89
94
 
95
+ def with_jobs(document, jobs)
96
+ return document unless jobs
97
+
98
+ document.merge(jobs: jobs.on_commit.map { |job| JobJson.document(job) },
99
+ failing_jobs: jobs.failing.map { |job| JobJson.document(job) })
100
+ end
101
+
90
102
  def print_json(document) = @stdout.puts(JSON.generate(document))
91
103
  def print_lines(lines) = lines.each { |line| @stdout.puts line }
92
104
  end
@@ -6,16 +6,21 @@ require_relative "run_report"
6
6
  require_relative "events"
7
7
  require_relative "trunk_reading"
8
8
  require_relative "trunk_json"
9
+ require_relative "job_events"
10
+ require_relative "../jobs/state"
11
+ require_relative "../persistence/job_runs"
9
12
 
10
13
  module FunCi
11
14
  module Agent
12
15
  # Looks at the newest runs of the project git names, as `events` compares
13
- # them: { run id => Events::RunState }.
16
+ # them: { run id => Events::RunState }; and at its newest daily and weekly
17
+ # job runs: { job run id => JobEvents::JobState }.
14
18
  class Snapshots
15
19
  def initialize(db, git, clock)
16
20
  project = git.toplevel
17
21
  @db = db
18
22
  @runs = Persistence::ProjectRuns.new(db, project)
23
+ @job_runs = Persistence::JobRuns.new(db, project)
19
24
  @trunk = TrunkReading.new(db, project, clock, TrunkReading::UNREAD)
20
25
  end
21
26
 
@@ -23,8 +28,22 @@ module FunCi
23
28
  @runs.recent(limit: limit, branch: nil).to_h { |run| [run[:id], state_of(run)] }
24
29
  end
25
30
 
31
+ def jobs(limit:)
32
+ @job_runs.recent(limit: limit).to_h { |run| [run[:id], job_state(run)] }
33
+ end
34
+
26
35
  private
27
36
 
37
+ def job_state(run)
38
+ JobEvents::JobState.new(id: run[:id], job: run[:job], cadence: run[:cadence], sha: run[:commit_hash],
39
+ branch: run[:branch], state: Jobs::State.of(run),
40
+ seconds: Persistence::StageJob.elapsed_duration(run)&.round(1),
41
+ starts_at: starts_at(run))
42
+ end
43
+
44
+ # When a run waiting its turn starts, as an ISO time; nil for any other.
45
+ def starts_at(run) = run[:status] == "scheduled" ? Time.parse(run[:started_at]).utc.iso8601 : nil
46
+
28
47
  def state_of(run)
29
48
  superseded_by = run[:status] == "cancelled" ? @runs.superseded_by(run) : nil
30
49
  Events::RunState.new(id: run[:id], sha: run[:commit_hash], branch: run[:branch], status: run[:status],
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Agent
5
+ # How long a daily or weekly job's run took, in the words the console uses
6
+ # for it: `42s`, `50m`, `3h12m`, `1d`, `1d2h`.
7
+ module Span
8
+ def self.words(seconds)
9
+ whole = seconds.round
10
+ return "#{whole}s" if whole < 60
11
+ return "#{whole / 60}m" if whole < 3600
12
+ return joined(whole / 3600, "h", (whole % 3600 / 60).to_s.rjust(2, "0"), "m") if whole < 86_400
13
+
14
+ joined(whole / 86_400, "d", (whole % 86_400 / 3600).to_s, "h")
15
+ end
16
+
17
+ # `3h12m`, or `3h` when there is nothing of the smaller unit.
18
+ def self.joined(big, big_unit, small, small_unit)
19
+ small.to_i.zero? ? "#{big}#{big_unit}" : "#{big}#{big_unit}#{small}#{small_unit}"
20
+ end
21
+ private_class_method :joined
22
+ end
23
+ end
24
+ end
@@ -7,8 +7,9 @@ module FunCi
7
7
  module StageSummary
8
8
  WORDS = { "over_budget" => "ran over budget" }.freeze
9
9
 
10
- def self.line(stage)
11
- "#{stage.name} #{WORDS.fetch(stage.state, stage.state)}#{ending(stage)}#{took(stage)}"
10
+ # seconds: how a length of time is said; in seconds by default, `1.4s`.
11
+ def self.line(stage, seconds: ->(time) { "#{time}s" })
12
+ "#{stage.name} #{WORDS.fetch(stage.state, stage.state)}#{ending(stage)}#{took(stage, seconds)}"
12
13
  end
13
14
 
14
15
  def self.ending(stage)
@@ -18,13 +19,16 @@ module FunCi
18
19
  ""
19
20
  end
20
21
 
21
- def self.took(stage)
22
+ def self.took(stage, seconds)
22
23
  return "" unless stage.seconds
23
24
 
24
- budget = stage.budget ? ", budget #{stage.budget}s" : ""
25
- " after #{stage.seconds}s#{budget}"
25
+ budget = stage.budget ? ", budget #{budget(stage.budget)}" : ""
26
+ " after #{seconds.call(stage.seconds)}#{budget}"
26
27
  end
27
- private_class_method :ending, :took
28
+
29
+ # In hours when it is whole hours, as a job's day is: `24h`; else in seconds, `10s`.
30
+ def self.budget(seconds) = seconds >= 3600 && (seconds % 3600).zero? ? "#{seconds / 3600}h" : "#{seconds}s"
31
+ private_class_method :ending, :took, :budget
28
32
  end
29
33
  end
30
34
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "due_in"
4
+
5
+ module FunCi
6
+ module Agent
7
+ # When a job waiting its turn (Jobs::Schedule) starts, as agents are
8
+ # told it: `starts in 6h`, or `starts now` once its time has come and its
9
+ # process has yet to start it.
10
+ module StartsIn
11
+ def self.words(seconds) = seconds.positive? ? "starts in #{DueIn.words(seconds)}" : "starts now"
12
+ end
13
+ end
14
+ end
@@ -5,7 +5,8 @@ require_relative "output"
5
5
 
6
6
  module FunCi
7
7
  module Agent
8
- # `fun-ci status [REV] [--need LEVEL] [--json]` (acceptance-tests.md, AT-9.2).
8
+ # `fun-ci status [REV] [--need LEVEL] [--json]` (acceptance-tests.md,
9
+ # AT-9.2): the commit's run, and the jobs whose latest run tested it (AT-13.24).
9
10
  class StatusCommand
10
11
  include CommandSupport
11
12
 
@@ -28,7 +29,7 @@ module FunCi
28
29
  def answer(sha, report, output)
29
30
  return output.unknown(sha) unless report
30
31
 
31
- output.report(report)
32
+ output.report(report, jobs: job_reports.of_commit(sha))
32
33
  ExitCode::FOR.fetch(report.verdict)
33
34
  end
34
35
  end
@@ -19,7 +19,7 @@ module FunCi
19
19
  def self.unknown(sha) = { schema: SCHEMA, commit: { sha: sha }, verdict: "unknown" }
20
20
 
21
21
  def self.stage(stage)
22
- facts = { name: stage.name, state: stage.state, seconds: stage.seconds }
22
+ facts = { name: stage.name, state: stage.state, seconds: stage.seconds, budget: stage.budget }
23
23
  stage.failures.any? ? facts.merge(failures: stage.failures) : facts
24
24
  end
25
25
  private_class_method :stage
@@ -4,21 +4,40 @@ require_relative "run_report"
4
4
  require_relative "verdict"
5
5
  require_relative "digest"
6
6
  require_relative "trunk_text"
7
+ require_relative "job_report"
8
+ require_relative "starts_in"
9
+ require_relative "../pipeline/budgets"
7
10
 
8
11
  module FunCi
9
12
  module Agent
10
13
  # A run report as text: the commit, a line per stage with what went wrong
11
14
  # in capitals, then the evidence of each needed stage that failed.
12
15
  module StatusText
13
- WORDS = { "failed" => "FAILED", "over_budget" => "OVER BUDGET" }.freeze
16
+ WORDS = { "failed" => "FAILED", "over_budget" => "OVER BUDGET", "lost" => "LOST" }.freeze
14
17
 
15
- # trunk: whether the agent asked about the trunk, which says so while its check is going.
16
- def self.lines(report, trunk: false)
18
+ # trunk: whether the agent asked about the trunk, which says so while its
19
+ # check is going; jobs: the commit's CommitJobs, which change nothing
20
+ # about the verdict.
21
+ def self.lines(report, trunk: false, jobs: CommitJobs::NONE)
17
22
  needed = Verdict::LEVELS.fetch(report.need)
18
23
  [header(report), *report.stages.map { |stage| stage_line(stage, needed) }, *Digest.lines(report.stages, needed),
19
- *trunk(report, trunk), *footer(report)]
24
+ *job_lines(jobs), *trunk(report, trunk), *footer(report)]
20
25
  end
21
26
 
27
+ def self.job_lines(jobs)
28
+ jobs.on_commit.map { |job| job_line(job) } + jobs.failing.map { |job| job_line(job, " on #{job.sha[0, 7]}") }
29
+ end
30
+
31
+ # ` soak (weekly job) FAILED fun-ci why --job soak`; `where` names the
32
+ # commit it tested when that is another.
33
+ def self.job_line(job, where = "")
34
+ said = " #{job.name} (#{job.cadence} job) #{WORDS.fetch(job.state, job.state)}#{starts(job)}#{where}"
35
+ job.needs_you? ? "#{said} fun-ci why --job #{job.name}" : said
36
+ end
37
+
38
+ # `, starts in 8m` for a job waiting its turn.
39
+ def self.starts(job) = job.starts_in ? ", #{StartsIn.words(job.starts_in)}" : ""
40
+
22
41
  # The trunk lines; nothing while the check is going, unless asked about
23
42
  # (right after a commit it would say nothing useful).
24
43
  def self.trunk(report, asked)
@@ -31,10 +50,17 @@ module FunCi
31
50
 
32
51
  def self.stage_line(stage, needed)
33
52
  seconds = stage.seconds ? format("%6.1fs", stage.seconds) : " " * 7
34
- note = needed.include?(stage.name) ? "" : " (not needed)"
53
+ note = (needed.include?(stage.name) ? "" : " (not needed)") + longer(stage)
35
54
  " #{stage.name.ljust(6)} #{WORDS.fetch(stage.state, stage.state).ljust(12)}#{seconds}#{note}".rstrip
36
55
  end
37
56
 
57
+ # Only a worktree just made gives a stage more than its usual budget
58
+ # (AT-1.14), which a run in the background says nowhere else.
59
+ def self.longer(stage)
60
+ usual = Pipeline::DEFAULT_BUDGETS[stage.name]
61
+ stage.budget && usual && stage.budget > usual ? " (new worktree, budget #{stage.budget}s)" : ""
62
+ end
63
+
38
64
  def self.footer(report)
39
65
  return ["fun-ci why #{report.sha[0, 7]} #{report.deciding}"] if report.deciding
40
66
  return ["fun-ci why #{report.sha[0, 7]} trunk"] if report.trunk&.state == "conflicts"
@@ -42,7 +68,7 @@ module FunCi
42
68
 
43
69
  ["Superseded by #{report.superseded_by[0, 7]}."]
44
70
  end
45
- private_class_method :stage_line, :footer
71
+ private_class_method :stage_line, :longer, :footer, :job_lines, :job_line, :starts
46
72
  end
47
73
  end
48
74
  end
@@ -28,7 +28,8 @@ module FunCi
28
28
  return shown(check) if check
29
29
 
30
30
  started = run[:trunk_started_at]
31
- started && Trunk::Shown.unchecked(started: Time.parse(started), now: @clock.now)
31
+ started && Trunk::Shown.unchecked(started: Time.parse(started), now: @clock.now,
32
+ stages_running: !run[:trigger_pid].nil?)
32
33
  end
33
34
 
34
35
  # Checks the commit against where the trunk is now, without fetching, and keeps the check,
@@ -52,9 +52,10 @@ module FunCi
52
52
  ExitCode::FOR.fetch(report.verdict)
53
53
  end
54
54
 
55
+ # A waiter keeps its run from being superseded, unless it follows the branch to the newer commit.
55
56
  def poll(sha, options)
56
57
  @context.pipeline.watch(@context.db)
57
- reports.mark_waited(sha, @context.clock.now)
58
+ reports.mark_waited(sha, @context.clock.now) unless options.follow_branch
58
59
  report_for(sha, options) || start_after_grace(sha)
59
60
  end
60
61
 
@@ -3,13 +3,15 @@
3
3
  require_relative "command_support"
4
4
  require_relative "output"
5
5
  require_relative "trunk_why"
6
+ require_relative "job_why"
6
7
 
7
8
  module FunCi
8
9
  module Agent
9
10
  # `fun-ci why [REV] [STAGE] [--need LEVEL] [--json] [--raw]`
10
11
  # (acceptance-tests.md, AT-10.1, AT-10.6): everything kept about the stage
11
12
  # named, or else about the stage that decided the verdict; with --raw, the
12
- # raw output it kept, and nothing else.
13
+ # raw output it kept, and nothing else. With `--job NAME`, the same about
14
+ # a daily or weekly job's latest run (JobWhy).
13
15
  class WhyCommand
14
16
  include CommandSupport
15
17
 
@@ -20,7 +22,9 @@ module FunCi
20
22
  end
21
23
 
22
24
  def run(args)
23
- options = Options.parse(args, takes: %i[need json stage raw])
25
+ options = Options.parse(args, takes: %i[need json stage raw job])
26
+ return JobWhy.new(@context, job_reports).answer(options) if options.job
27
+
24
28
  sha = resolve(options.rev)
25
29
  answer(sha, reports.for(sha, options.need), options)
26
30
  rescue Options::Invalid => e
data/lib/fun_ci/cli.rb CHANGED
@@ -17,7 +17,7 @@ module FunCi
17
17
  "prune" => :run_prune,
18
18
  "extract" => :run_extract
19
19
  }.freeze
20
- AGENT_COMMANDS = %w[status runs wait events why].freeze
20
+ AGENT_COMMANDS = %w[status runs wait events why jobs cancel].freeze
21
21
 
22
22
  def self.default_db_dir = Persistence::StateDir.path(ENV)
23
23
 
@@ -31,6 +31,8 @@ module FunCi
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
33
  why Print everything kept about why a commit's stage failed, or its conflict with the trunk
34
+ jobs List this project's daily and weekly jobs and how each stands
35
+ cancel Cancel a daily or weekly job's run: cancel --job NAME
34
36
  extract Try a stage's evidence extractors on a saved output
35
37
 
36
38
  Options:
@@ -46,10 +48,11 @@ module FunCi
46
48
  --follow-branch (wait) Move on to the newer commit that superseded the run
47
49
  --trunk (status, wait) Exit 6 when the run passed but conflicts with the trunk
48
50
  --raw (why) Print the stage's whole output as it was kept
51
+ --job NAME (why, cancel) The daily or weekly job to explain or cancel, instead of a commit
49
52
  -n N (runs) How many runs to list (default 10)
50
53
  --branch NAME (runs) Only runs on this branch
51
54
  --follow (events) Keep printing events as they happen, until stopped
52
- --only failures (events) Print only failed stages and superseded runs
55
+ --only failures (events) Print only failed stages, superseded runs and failed jobs
53
56
  --json Print JSON instead of text (events always print JSON lines)
54
57
  Exit codes: 0 passed, 1 failed, 2 over budget, 3 undecided,
55
58
  4 superseded, 5 no run, 6 conflicts with the trunk (--trunk), 64 usage error
@@ -9,6 +9,9 @@ require_relative "streak_counter"
9
9
  require_relative "trunk_marks"
10
10
  require_relative "cancelled_folding"
11
11
  require_relative "row_order"
12
+ require_relative "paging"
13
+ require_relative "job_rows"
14
+ require_relative "../persistence/active_jobs"
12
15
 
13
16
  module FunCi
14
17
  module Console
@@ -17,39 +20,49 @@ module FunCi
17
20
  # find its standing against the trunk.
18
21
  BRANCH_WINDOW = 100
19
22
 
20
- def initialize(db, limit: 15, page_size: nil, run_canceller: Pipeline::RunCanceller.new)
23
+ # `clock` answers the time now, which decides which jobs are due.
24
+ def initialize(db, page_size: 15, run_canceller: Pipeline::RunCanceller.new, clock: -> { Time.now })
21
25
  @db = db
22
- @page_size = page_size || limit
23
- @limit = @page_size
26
+ @paging = Paging.new(page_size)
24
27
  @run_canceller = run_canceller
28
+ @clock = clock
25
29
  end
26
30
 
27
- def load_more
28
- @limit += @page_size
29
- end
31
+ def load_more = @paging.load_more
30
32
 
31
33
  # Pages by `page_size` from now on, loading at least one such page.
32
- def resize(page_size)
33
- @page_size = page_size
34
- @limit = [@limit, page_size].max
35
- end
34
+ def resize(page_size) = @paging.resize(page_size)
36
35
 
37
36
  # Whether there are branches beyond those `runs` loads.
38
- def more? = Persistence::PipelineRun.branch_heads(@db, limit: @limit + 1).size > @limit
37
+ def more? = Persistence::PipelineRun.branch_heads(@db, limit: @paging.limit + 1).size > @paging.limit
39
38
 
40
39
  # Records failed each slow suite whose process died (acceptance-tests.md, AT-8.3).
41
40
  def record_dead_slow_suites = @run_canceller.record_dead(@db)
42
41
 
42
+ # The job section's rows (JobRows): the jobs of the projects among `runs`.
43
+ def jobs(runs)
44
+ JobRows.new(@db).of(runs.map { |run| run[:project_path] }.uniq.compact, now: @clock.call)
45
+ end
46
+
47
+ # Records failed each job whose process died (acceptance-tests.md, AT-13.6).
48
+ def record_dead_jobs = @run_canceller.record_dead_jobs(@db)
49
+
50
+ # Stops a daily or weekly job's processes, then records it cancelled,
51
+ # unless it has finished meanwhile.
52
+ def cancel_job(id) = @run_canceller.cancel_job(@db, id)
53
+
43
54
  # One row per branch, its newest run, for the branches that most need you
44
55
  # and then those run most recently, in RowOrder: a run of cancelled
45
56
  # 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) })
57
+ def runs
58
+ RowOrder.of(Persistence::PipelineRun.branch_heads(@db, limit: @paging.limit).map { |head| branch_row(head) })
59
+ end
47
60
 
48
61
  # The projects among `runs` whose trunk is stale.
49
62
  def stale_trunks(runs, now:) = TrunkMarks.new(@db).stale(runs, now: now)
50
63
 
51
64
  def streak
52
- pipeline_runs = Persistence::PipelineRun.recent(@db, limit: @limit)
65
+ pipeline_runs = Persistence::PipelineRun.recent(@db, limit: @paging.limit)
53
66
  StreakCounter.count(pipeline_runs)
54
67
  end
55
68