fun_ci 2.1.0-aarch64-linux → 2.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +39 -0
- data/README.md +27 -4
- data/lib/fun_ci/agent/command_support.rb +7 -0
- data/lib/fun_ci/agent/commands.rb +2 -1
- data/lib/fun_ci/agent/due_in.rb +21 -0
- data/lib/fun_ci/agent/events_command.rb +14 -7
- data/lib/fun_ci/agent/job_events.rb +37 -0
- data/lib/fun_ci/agent/job_json.rb +16 -0
- data/lib/fun_ci/agent/job_report.rb +85 -0
- data/lib/fun_ci/agent/job_why.rb +59 -0
- data/lib/fun_ci/agent/job_why_json.rb +31 -0
- data/lib/fun_ci/agent/job_why_text.rb +51 -0
- data/lib/fun_ci/agent/jobs_command.rb +43 -0
- data/lib/fun_ci/agent/jobs_text.rb +50 -0
- data/lib/fun_ci/agent/live_pipeline.rb +7 -2
- data/lib/fun_ci/agent/options.rb +3 -3
- data/lib/fun_ci/agent/output.rb +14 -2
- data/lib/fun_ci/agent/snapshots.rb +16 -1
- data/lib/fun_ci/agent/span.rb +24 -0
- data/lib/fun_ci/agent/stage_summary.rb +10 -6
- data/lib/fun_ci/agent/status_command.rb +3 -2
- data/lib/fun_ci/agent/status_text.rb +19 -5
- data/lib/fun_ci/agent/why_command.rb +6 -2
- data/lib/fun_ci/cli.rb +1 -1
- data/lib/fun_ci/cli_help.rb +4 -2
- data/lib/fun_ci/console/board_data.rb +26 -13
- data/lib/fun_ci/console/console_state.rb +12 -6
- data/lib/fun_ci/console/job_message.rb +33 -0
- data/lib/fun_ci/console/job_order.rb +22 -0
- data/lib/fun_ci/console/job_rows.rb +21 -0
- data/lib/fun_ci/console/key_handler.rb +33 -14
- data/lib/fun_ci/console/paging.rb +26 -0
- data/lib/fun_ci/console/view.rb +18 -9
- data/lib/fun_ci/evidence/settings.rb +14 -7
- data/lib/fun_ci/evidence/settings_check.rb +14 -3
- data/lib/fun_ci/jobs/due.rb +33 -0
- data/lib/fun_ci/jobs/due_jobs.rb +26 -0
- data/lib/fun_ci/jobs/folders.rb +50 -0
- data/lib/fun_ci/jobs/job.rb +31 -0
- data/lib/fun_ci/jobs/job_fork.rb +45 -0
- data/lib/fun_ci/jobs/job_run.rb +85 -0
- data/lib/fun_ci/jobs/locks.rb +30 -0
- data/lib/fun_ci/jobs/site.rb +10 -0
- data/lib/fun_ci/jobs/standings.rb +46 -0
- data/lib/fun_ci/jobs/state.rb +26 -0
- data/lib/fun_ci/persistence/active_jobs.rb +54 -0
- data/lib/fun_ci/persistence/database.rb +14 -4
- data/lib/fun_ci/persistence/job_recorder.rb +47 -0
- data/lib/fun_ci/persistence/job_runs.rb +68 -0
- data/lib/fun_ci/persistence/raw_outputs.rb +3 -0
- data/lib/fun_ci/pipeline/pipeline_forker.rb +39 -10
- data/lib/fun_ci/pipeline/process_runner.rb +15 -5
- data/lib/fun_ci/pipeline/run_canceller.rb +24 -0
- data/lib/fun_ci/pipeline/slot.rb +1 -1
- data/lib/fun_ci/pipeline/stage_execution.rb +9 -4
- data/lib/fun_ci/pipeline/trigger_command.rb +7 -1
- data/lib/fun_ci/pipeline/worktree_prune.rb +8 -6
- data/lib/fun_ci/pipeline/worktrees.rb +8 -4
- data/lib/fun_ci/setup/agent_instructions.rb +3 -0
- data/lib/fun_ci/setup/project_config.rb +6 -0
- data/lib/fun_ci/setup/setup_checker.rb +7 -1
- data/lib/fun_ci/version.rb +1 -1
- data/libexec/fun-ci-renderer +0 -0
- metadata +28 -1
data/lib/fun_ci/agent/options.rb
CHANGED
|
@@ -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
|
|
data/lib/fun_ci/agent/output.rb
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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}
|
|
25
|
-
" after #{stage.seconds}
|
|
25
|
+
budget = stage.budget ? ", budget #{budget(stage.budget)}" : ""
|
|
26
|
+
" after #{seconds.call(stage.seconds)}#{budget}"
|
|
26
27
|
end
|
|
27
|
-
|
|
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,
|
|
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
|
|
16
|
-
|
|
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
|
|
data/lib/fun_ci/cli_help.rb
CHANGED
|
@@ -21,7 +21,7 @@ module FunCi
|
|
|
21
21
|
|
|
22
22
|
Commands:
|
|
23
23
|
trigger Run CI pipeline for a commit
|
|
24
|
-
console
|
|
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
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
|
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
|
|
10
|
-
# after the `event`s for what changed
|
|
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
|
|
6
|
-
#
|
|
7
|
-
# the
|
|
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
|
-
|
|
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 ==
|
|
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
|
-
|
|
61
|
-
return unless
|
|
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
|
-
|
|
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
|
data/lib/fun_ci/console/view.rb
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
+
return 0 unless cursor
|
|
45
|
+
|
|
46
|
+
[[cursor, count - 1].min - size + 1, 0].max
|
|
40
47
|
end
|
|
41
48
|
|
|
42
|
-
|
|
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 &&
|
|
53
|
+
cursor - first if cursor && size.positive?
|
|
45
54
|
end
|
|
46
55
|
end
|
|
47
56
|
end
|