fun_ci 2.1.1 → 2.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +95 -0
- data/README.md +36 -6
- data/lib/fun_ci/agent/cancel_command.rb +52 -0
- data/lib/fun_ci/agent/command_support.rb +7 -0
- data/lib/fun_ci/agent/commands.rb +4 -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 +56 -0
- data/lib/fun_ci/agent/job_json.rb +25 -0
- data/lib/fun_ci/agent/job_report.rb +89 -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 +59 -0
- data/lib/fun_ci/agent/jobs_command.rb +43 -0
- data/lib/fun_ci/agent/jobs_text.rb +55 -0
- data/lib/fun_ci/agent/live_pipeline.rb +14 -6
- 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 +20 -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/starts_in.rb +14 -0
- data/lib/fun_ci/agent/status_command.rb +3 -2
- data/lib/fun_ci/agent/status_json.rb +1 -1
- data/lib/fun_ci/agent/status_text.rb +32 -6
- data/lib/fun_ci/agent/trunk_reading.rb +2 -1
- data/lib/fun_ci/agent/wait_command.rb +2 -1
- 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 -1
- 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 +39 -0
- data/lib/fun_ci/console/job_order.rb +23 -0
- data/lib/fun_ci/console/job_rows.rb +21 -0
- data/lib/fun_ci/console/key_handler.rb +35 -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 +15 -8
- data/lib/fun_ci/evidence/settings_check.rb +14 -3
- data/lib/fun_ci/jobs/due.rb +34 -0
- data/lib/fun_ci/jobs/due_jobs.rb +50 -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 +52 -0
- data/lib/fun_ci/jobs/job_run.rb +104 -0
- data/lib/fun_ci/jobs/locks.rb +30 -0
- data/lib/fun_ci/jobs/schedule.rb +25 -0
- data/lib/fun_ci/jobs/site.rb +17 -0
- data/lib/fun_ci/jobs/standings.rb +50 -0
- data/lib/fun_ci/jobs/state.rb +27 -0
- data/lib/fun_ci/jobs/wall_clock_wait.rb +29 -0
- data/lib/fun_ci/persistence/active_jobs.rb +60 -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 +80 -0
- data/lib/fun_ci/persistence/pipeline_run.rb +2 -1
- data/lib/fun_ci/persistence/raw_outputs.rb +3 -0
- data/lib/fun_ci/pipeline/budgets.rb +9 -0
- data/lib/fun_ci/pipeline/pipeline_forker.rb +42 -10
- data/lib/fun_ci/pipeline/priorities.rb +23 -0
- data/lib/fun_ci/pipeline/process_runner.rb +18 -6
- data/lib/fun_ci/pipeline/progress_reporter.rb +4 -0
- data/lib/fun_ci/pipeline/run_canceller.rb +24 -0
- data/lib/fun_ci/pipeline/slot.rb +12 -1
- data/lib/fun_ci/pipeline/slot_run.rb +19 -4
- data/lib/fun_ci/pipeline/stage_execution.rb +9 -4
- data/lib/fun_ci/pipeline/trigger.rb +17 -6
- data/lib/fun_ci/pipeline/trigger_command.rb +12 -2
- data/lib/fun_ci/pipeline/trigger_params.rb +20 -9
- data/lib/fun_ci/pipeline/worktree_pool.rb +2 -2
- data/lib/fun_ci/pipeline/worktree_prune.rb +8 -6
- data/lib/fun_ci/pipeline/worktrees.rb +11 -5
- data/lib/fun_ci/setup/agent_instructions.rb +24 -5
- data/lib/fun_ci/setup/commands.rb +3 -1
- data/lib/fun_ci/setup/hook_script.rb +9 -4
- data/lib/fun_ci/setup/hook_writer.rb +13 -5
- data/lib/fun_ci/setup/installed_hooks.rb +45 -0
- data/lib/fun_ci/setup/installer.rb +3 -2
- data/lib/fun_ci/setup/lfs_hook.rb +50 -0
- data/lib/fun_ci/setup/project_config.rb +13 -1
- data/lib/fun_ci/setup/settings.rb +40 -11
- data/lib/fun_ci/setup/setup_checker.rb +20 -3
- data/lib/fun_ci/trunk/shown.rb +7 -4
- data/lib/fun_ci/version.rb +1 -1
- 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
|
|
10
|
-
# the way the post-commit hook starts one,
|
|
11
|
-
# --background` in a process of its own, rather than
|
|
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
|
|
32
|
-
|
|
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
|
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,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
|
-
|
|
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
|
|
@@ -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,
|
|
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
|
|
16
|
-
|
|
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
|
|
data/lib/fun_ci/cli_help.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|