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