fun_ci 2.1.0 → 2.2.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +27 -4
  4. data/lib/fun_ci/agent/command_support.rb +7 -0
  5. data/lib/fun_ci/agent/commands.rb +2 -1
  6. data/lib/fun_ci/agent/due_in.rb +21 -0
  7. data/lib/fun_ci/agent/events_command.rb +14 -7
  8. data/lib/fun_ci/agent/job_events.rb +37 -0
  9. data/lib/fun_ci/agent/job_json.rb +16 -0
  10. data/lib/fun_ci/agent/job_report.rb +85 -0
  11. data/lib/fun_ci/agent/job_why.rb +59 -0
  12. data/lib/fun_ci/agent/job_why_json.rb +31 -0
  13. data/lib/fun_ci/agent/job_why_text.rb +51 -0
  14. data/lib/fun_ci/agent/jobs_command.rb +43 -0
  15. data/lib/fun_ci/agent/jobs_text.rb +50 -0
  16. data/lib/fun_ci/agent/live_pipeline.rb +7 -2
  17. data/lib/fun_ci/agent/options.rb +3 -3
  18. data/lib/fun_ci/agent/output.rb +14 -2
  19. data/lib/fun_ci/agent/snapshots.rb +16 -1
  20. data/lib/fun_ci/agent/span.rb +24 -0
  21. data/lib/fun_ci/agent/stage_summary.rb +10 -6
  22. data/lib/fun_ci/agent/status_command.rb +3 -2
  23. data/lib/fun_ci/agent/status_text.rb +19 -5
  24. data/lib/fun_ci/agent/why_command.rb +6 -2
  25. data/lib/fun_ci/cli.rb +1 -1
  26. data/lib/fun_ci/cli_help.rb +4 -2
  27. data/lib/fun_ci/console/board_data.rb +26 -13
  28. data/lib/fun_ci/console/console_state.rb +12 -6
  29. data/lib/fun_ci/console/job_message.rb +33 -0
  30. data/lib/fun_ci/console/job_order.rb +22 -0
  31. data/lib/fun_ci/console/job_rows.rb +21 -0
  32. data/lib/fun_ci/console/key_handler.rb +33 -14
  33. data/lib/fun_ci/console/paging.rb +26 -0
  34. data/lib/fun_ci/console/view.rb +18 -9
  35. data/lib/fun_ci/evidence/settings.rb +14 -7
  36. data/lib/fun_ci/evidence/settings_check.rb +14 -3
  37. data/lib/fun_ci/jobs/due.rb +33 -0
  38. data/lib/fun_ci/jobs/due_jobs.rb +26 -0
  39. data/lib/fun_ci/jobs/folders.rb +50 -0
  40. data/lib/fun_ci/jobs/job.rb +31 -0
  41. data/lib/fun_ci/jobs/job_fork.rb +45 -0
  42. data/lib/fun_ci/jobs/job_run.rb +85 -0
  43. data/lib/fun_ci/jobs/locks.rb +30 -0
  44. data/lib/fun_ci/jobs/site.rb +10 -0
  45. data/lib/fun_ci/jobs/standings.rb +46 -0
  46. data/lib/fun_ci/jobs/state.rb +26 -0
  47. data/lib/fun_ci/persistence/active_jobs.rb +54 -0
  48. data/lib/fun_ci/persistence/database.rb +14 -4
  49. data/lib/fun_ci/persistence/job_recorder.rb +47 -0
  50. data/lib/fun_ci/persistence/job_runs.rb +68 -0
  51. data/lib/fun_ci/persistence/raw_outputs.rb +3 -0
  52. data/lib/fun_ci/pipeline/pipeline_forker.rb +39 -10
  53. data/lib/fun_ci/pipeline/process_runner.rb +15 -5
  54. data/lib/fun_ci/pipeline/run_canceller.rb +24 -0
  55. data/lib/fun_ci/pipeline/slot.rb +1 -1
  56. data/lib/fun_ci/pipeline/stage_execution.rb +9 -4
  57. data/lib/fun_ci/pipeline/trigger_command.rb +7 -1
  58. data/lib/fun_ci/pipeline/worktree_prune.rb +8 -6
  59. data/lib/fun_ci/pipeline/worktrees.rb +8 -4
  60. data/lib/fun_ci/setup/agent_instructions.rb +3 -0
  61. data/lib/fun_ci/setup/project_config.rb +6 -0
  62. data/lib/fun_ci/setup/setup_checker.rb +7 -1
  63. data/lib/fun_ci/version.rb +1 -1
  64. metadata +28 -1
@@ -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,18 @@ 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
+ end
42
+
28
43
  def state_of(run)
29
44
  superseded_by = run[:status] == "cancelled" ? @runs.superseded_by(run) : nil
30
45
  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
@@ -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
@@ -4,19 +4,33 @@ 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"
7
8
 
8
9
  module FunCi
9
10
  module Agent
10
11
  # A run report as text: the commit, a line per stage with what went wrong
11
12
  # in capitals, then the evidence of each needed stage that failed.
12
13
  module StatusText
13
- WORDS = { "failed" => "FAILED", "over_budget" => "OVER BUDGET" }.freeze
14
+ WORDS = { "failed" => "FAILED", "over_budget" => "OVER BUDGET", "lost" => "LOST" }.freeze
14
15
 
15
- # trunk: whether the agent asked about the trunk, which says so while its check is going.
16
- def self.lines(report, trunk: false)
16
+ # trunk: whether the agent asked about the trunk, which says so while its
17
+ # check is going; jobs: the commit's CommitJobs, which change nothing
18
+ # about the verdict.
19
+ def self.lines(report, trunk: false, jobs: CommitJobs::NONE)
17
20
  needed = Verdict::LEVELS.fetch(report.need)
18
21
  [header(report), *report.stages.map { |stage| stage_line(stage, needed) }, *Digest.lines(report.stages, needed),
19
- *trunk(report, trunk), *footer(report)]
22
+ *job_lines(jobs), *trunk(report, trunk), *footer(report)]
23
+ end
24
+
25
+ def self.job_lines(jobs)
26
+ jobs.on_commit.map { |job| job_line(job) } + jobs.failing.map { |job| job_line(job, " on #{job.sha[0, 7]}") }
27
+ end
28
+
29
+ # ` soak (weekly job) FAILED fun-ci why --job soak`; `where` names the
30
+ # commit it tested when that is another.
31
+ def self.job_line(job, where = "")
32
+ said = " #{job.name} (#{job.cadence} job) #{WORDS.fetch(job.state, job.state)}#{where}"
33
+ job.needs_you? ? "#{said} fun-ci why --job #{job.name}" : said
20
34
  end
21
35
 
22
36
  # The trunk lines; nothing while the check is going, unless asked about
@@ -42,7 +56,7 @@ module FunCi
42
56
 
43
57
  ["Superseded by #{report.superseded_by[0, 7]}."]
44
58
  end
45
- private_class_method :stage_line, :footer
59
+ private_class_method :stage_line, :footer, :job_lines, :job_line
46
60
  end
47
61
  end
48
62
  end
@@ -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].freeze
21
21
 
22
22
  def self.default_db_dir = Persistence::StateDir.path(ENV)
23
23
 
@@ -21,7 +21,7 @@ module FunCi
21
21
 
22
22
  Commands:
23
23
  trigger Run CI pipeline for a commit
24
- console Launch the admin TUI dashboard
24
+ console Watch your runs as they happen, in the terminal
25
25
  init Initialize .fun-ci/ with template scripts
26
26
  install-hooks Install post-commit and pre-push git hooks
27
27
  check Verify project setup
@@ -31,6 +31,7 @@ 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
34
35
  extract Try a stage's evidence extractors on a saved output
35
36
 
36
37
  Options:
@@ -46,10 +47,11 @@ module FunCi
46
47
  --follow-branch (wait) Move on to the newer commit that superseded the run
47
48
  --trunk (status, wait) Exit 6 when the run passed but conflicts with the trunk
48
49
  --raw (why) Print the stage's whole output as it was kept
50
+ --job NAME (why) Explain the daily or weekly job's latest run instead of a commit's stage
49
51
  -n N (runs) How many runs to list (default 10)
50
52
  --branch NAME (runs) Only runs on this branch
51
53
  --follow (events) Keep printing events as they happen, until stopped
52
- --only failures (events) Print only failed stages and superseded runs
54
+ --only failures (events) Print only failed stages, superseded runs and failed jobs
53
55
  --json Print JSON instead of text (events always print JSON lines)
54
56
  Exit codes: 0 passed, 1 failed, 2 over budget, 3 undecided,
55
57
  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
 
@@ -2,12 +2,14 @@
2
2
 
3
3
  require_relative "run_message"
4
4
  require_relative "console_events"
5
+ require_relative "job_message"
5
6
 
6
7
  module FunCi
7
8
  module Console
8
9
  # What the console shows: the page of runs BoardData reads that the View
9
- # puts on screen, and the clock's time, as a protocol `board` message,
10
- # after the `event`s for what changed since the last one.
10
+ # puts on screen, the daily and weekly jobs below it, and the clock's
11
+ # time, as a protocol `board` message, after the `event`s for what changed
12
+ # since the last one.
11
13
  class ConsoleState
12
14
  KEYS = { "up" => :up, "down" => :down, "esc" => :escape, "ctrl_c" => "q", "enter" => :enter }.freeze
13
15
 
@@ -26,18 +28,22 @@ module FunCi
26
28
 
27
29
  def updates
28
30
  @board_data.record_dead_slow_suites
31
+ @board_data.record_dead_jobs
29
32
  runs = @board_data.runs
30
- [*@events.since_last(runs), board(runs)]
33
+ [*@events.since_last(runs), board(runs, @board_data.jobs(runs))]
31
34
  end
32
35
 
33
36
  private
34
37
 
35
- def board(runs)
36
- page = @view.page(runs, more: @board_data.more?)
38
+ def board(runs, jobs)
39
+ page = @view.page(runs, more: @board_data.more?, jobs: jobs)
37
40
  { t: "board", now: @clock.call.to_i, streak: @board_data.streak, **page,
38
- runs: page[:runs].map { |run| RunMessage.from(run) }, **stale_trunks(runs) }
41
+ runs: page[:runs].map { |run| RunMessage.from(run) }, **job_section(jobs), **stale_trunks(runs) }
39
42
  end
40
43
 
44
+ # Only when there are jobs, as the protocol lets a board leave them out.
45
+ def job_section(jobs) = jobs.empty? ? {} : { jobs: jobs.map { |job| JobMessage.from(job) } }
46
+
41
47
  # Only when a trunk is stale, as the protocol lets a board leave it out.
42
48
  def stale_trunks(runs)
43
49
  stale = @board_data.stale_trunks(runs, now: @clock.call)
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module FunCi
6
+ module Console
7
+ # Where a job stands (Jobs::Standing) as the protocol's `board` carries it
8
+ # under `jobs`: protocol status names and epoch seconds. Formatting them
9
+ # is the renderer's job.
10
+ module JobMessage
11
+ # The protocol's word for a Jobs::State, where it has one of its own:
12
+ # a cancelled job is due again, and one it can't read is shown due.
13
+ STATUS = { "over_budget" => "timeout", "cancelled" => "due", "unknown" => "due" }.freeze
14
+
15
+ def self.from(standing)
16
+ { project: standing.project, name: standing.name, cadence: standing.cadence,
17
+ status: STATUS.fetch(standing.state, standing.state), **run(standing.run),
18
+ due_at: standing.due_at&.to_i }.compact
19
+ end
20
+
21
+ # What the latest run says: which, on what, and when.
22
+ def self.run(run)
23
+ return {} unless run
24
+
25
+ { run_id: run[:id], sha: run[:commit_hash], branch: run[:branch], started_at: epoch(run[:started_at]),
26
+ updated_at: epoch(run[:completed_at] || run[:started_at]) }
27
+ end
28
+
29
+ def self.epoch(iso) = Time.parse(iso).to_i
30
+ private_class_method :run, :epoch
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Console
5
+ # The order of the job section's rows (design.md, The console): what
6
+ # needs you first, then running, due and passed; among equals, the
7
+ # board's projects in its order, then each project's jobs by name. Rows
8
+ # answer #state (Jobs::State), #project and #name.
9
+ module JobOrder
10
+ URGENCY = { "failed" => 0, "lost" => 0, "over_budget" => 1, "running" => 2, "due" => 3, "cancelled" => 3,
11
+ "passed" => 4 }.freeze
12
+ # A state this fun-ci doesn't know, which a newer one sharing the database may have written.
13
+ UNKNOWN = 5
14
+
15
+ def self.of(rows, projects:)
16
+ rows.sort_by do |row|
17
+ [URGENCY.fetch(row.state, UNKNOWN), projects.index(row.project) || projects.size, row.name]
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "job_order"
4
+ require_relative "../jobs/standings"
5
+
6
+ module FunCi
7
+ module Console
8
+ # The job section's rows (design.md, The console): where each daily and
9
+ # weekly job of the board's projects stands (Jobs::Standing), in JobOrder.
10
+ class JobRows
11
+ def initialize(db)
12
+ @db = db
13
+ end
14
+
15
+ def of(projects, now:)
16
+ standings = projects.flat_map { |project| Jobs::Standings.new(@db, project, now: now).all }
17
+ JobOrder.of(standings, projects: projects)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -2,9 +2,10 @@
2
2
 
3
3
  module FunCi
4
4
  module Console
5
- # What a key means for the runs BoardData loads: moving the cursor,
6
- # quitting, and cancelling the run under the cursor (a running one after
7
- # the user confirms with `y`).
5
+ # What a key means for the runs BoardData loads, and the daily and weekly
6
+ # jobs after them: moving the cursor through both, quitting, and
7
+ # cancelling the run or job under the cursor (a running one after the
8
+ # user confirms with `y`).
8
9
  class KeyHandler
9
10
  CONFIRMATION_ANSWERS = ["y", "n", :escape].freeze
10
11
 
@@ -27,10 +28,6 @@ module FunCi
27
28
  !@confirm_cancel.nil?
28
29
  end
29
30
 
30
- def confirmation_run
31
- @confirm_cancel
32
- end
33
-
34
31
  private
35
32
 
36
33
  def act(key)
@@ -41,15 +38,16 @@ module FunCi
41
38
  end
42
39
  end
43
40
 
44
- # Onto the last run loaded, which loads the next page.
41
+ # Onto the last run loaded, which loads the next page, then into the jobs.
45
42
  def move_cursor_down
46
- last = @board_data.runs.length - 1
43
+ runs = @board_data.runs
44
+ last = runs.length + @board_data.jobs(runs).length - 1
47
45
  return if last.negative?
48
46
  return @cursor_index = 0 if @cursor_index.nil?
49
47
  return unless @cursor_index < last
50
48
 
51
49
  @cursor_index += 1
52
- @board_data.load_more if @cursor_index == last
50
+ @board_data.load_more if @cursor_index == runs.length - 1
53
51
  end
54
52
 
55
53
  def move_cursor_up
@@ -57,17 +55,38 @@ module FunCi
57
55
  end
58
56
 
59
57
  def initiate_cancel
60
- run = @cursor_index && @board_data.runs[@cursor_index]
61
- return unless run
58
+ kind, row = under_cursor
59
+ return unless row
62
60
 
61
+ kind == :job ? offer_job_cancel(row) : offer_run_cancel(row)
62
+ end
63
+
64
+ # A run waiting to start is cancelled at once; a running one once the user confirms.
65
+ def offer_run_cancel(run)
63
66
  @board_data.cancel_run(run[:id]) if run[:status] == "scheduled"
64
- @confirm_cancel = run if run[:status] == "running"
67
+ @confirm_cancel = [:run, run] if run[:status] == "running"
68
+ end
69
+
70
+ # A running job (a Jobs::Standing) once the user confirms.
71
+ def offer_job_cancel(job)
72
+ @confirm_cancel = [:job, job] if job.state == "running"
73
+ end
74
+
75
+ # [:run, the run] or [:job, the job row] under the cursor, or none.
76
+ def under_cursor
77
+ runs = @board_data.runs
78
+ return [] unless @cursor_index
79
+ return [:run, runs[@cursor_index]] if @cursor_index < runs.length
80
+
81
+ [:job, @board_data.jobs(runs)[@cursor_index - runs.length]]
65
82
  end
66
83
 
67
84
  def confirm(key)
68
- @board_data.cancel_run(@confirm_cancel[:id]) if key == "y"
85
+ cancel(*@confirm_cancel) if key == "y"
69
86
  @confirm_cancel = nil if CONFIRMATION_ANSWERS.include?(key)
70
87
  end
88
+
89
+ def cancel(kind, row) = kind == :job ? @board_data.cancel_job(row.run[:id]) : @board_data.cancel_run(row[:id])
71
90
  end
72
91
  end
73
92
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Console
5
+ # How many branches BoardData loads: a page of `page_size` at first, a
6
+ # page more each time the cursor reaches the last one loaded.
7
+ class Paging
8
+ attr_reader :limit
9
+
10
+ def initialize(page_size)
11
+ @page_size = page_size
12
+ @limit = page_size
13
+ end
14
+
15
+ def load_more
16
+ @limit += @page_size
17
+ end
18
+
19
+ # Pages by `page_size` from now on, loading at least one such page.
20
+ def resize(page_size)
21
+ @page_size = page_size
22
+ @limit = [@limit, page_size].max
23
+ end
24
+ end
25
+ end
26
+ end
@@ -11,6 +11,8 @@ module FunCi
11
11
  # table: a page this many rows short of the terminal fits one line per
12
12
  # row, however the renderer spaces them (renderer-protocol.md, `board`).
13
13
  CHROME_ROWS = 18
14
+ # A blank line and one for the job section, the least the renderer folds it to.
15
+ JOB_LINES = 2
14
16
 
15
17
  def initialize(key_handler:)
16
18
  @key_handler = key_handler
@@ -25,23 +27,30 @@ module FunCi
25
27
  # :quit when the key ends the session.
26
28
  def press(key) = @key_handler.handle_key(key)
27
29
 
28
- # `more`: whether the store holds runs beyond `runs`.
29
- def page(runs, more:)
30
- first = first_shown
31
- { cursor: cursor_on_page(first), confirming: @key_handler.confirming?,
32
- has_more: more || first + @page_size < runs.size, runs: runs[first, @page_size] || [] }
30
+ # `more`: whether the store holds runs beyond `runs`. `jobs`: the job
31
+ # section's rows, below the page, which the cursor reaches after the runs.
32
+ def page(runs, more:, jobs: [])
33
+ size = jobs.empty? ? @page_size : [@page_size - JOB_LINES, 0].max
34
+ first = first_shown(runs.size, size)
35
+ { cursor: cursor_on_page(first, size), confirming: @key_handler.confirming?,
36
+ has_more: more || first + size < runs.size, runs: runs[first, size] || [] }
33
37
  end
34
38
 
35
39
  private
36
40
 
37
- def first_shown
41
+ # The last page of runs while the cursor is on a job.
42
+ def first_shown(count, size)
38
43
  cursor = @key_handler.cursor_index
39
- cursor ? [cursor - @page_size + 1, 0].max : 0
44
+ return 0 unless cursor
45
+
46
+ [[cursor, count - 1].min - size + 1, 0].max
40
47
  end
41
48
 
42
- def cursor_on_page(first)
49
+ # Past the page's runs while the cursor is on a job: a page that holds
50
+ # a job's cursor ends at the last run.
51
+ def cursor_on_page(first, size)
43
52
  cursor = @key_handler.cursor_index
44
- cursor - first if cursor && @page_size.positive?
53
+ cursor - first if cursor && size.positive?
45
54
  end
46
55
  end
47
56
  end