fun_ci 2.0.0-x86_64-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 +7 -0
- data/CHANGELOG.md +294 -0
- data/LICENSE.txt +21 -0
- data/README.md +230 -0
- data/exe/fun-ci +7 -0
- data/lib/fun_ci/agent/age.rb +15 -0
- data/lib/fun_ci/agent/command_support.rb +27 -0
- data/lib/fun_ci/agent/commands.rb +27 -0
- data/lib/fun_ci/agent/context.rb +10 -0
- data/lib/fun_ci/agent/digest.rb +49 -0
- data/lib/fun_ci/agent/events.rb +47 -0
- data/lib/fun_ci/agent/events_command.rb +61 -0
- data/lib/fun_ci/agent/evidence_text.rb +46 -0
- data/lib/fun_ci/agent/exit_code.rb +11 -0
- data/lib/fun_ci/agent/git.rb +31 -0
- data/lib/fun_ci/agent/live_pipeline.rb +35 -0
- data/lib/fun_ci/agent/options.rb +75 -0
- data/lib/fun_ci/agent/output.rb +81 -0
- data/lib/fun_ci/agent/report_stage.rb +73 -0
- data/lib/fun_ci/agent/reports.rb +47 -0
- data/lib/fun_ci/agent/run_report.rb +30 -0
- data/lib/fun_ci/agent/runs_command.rb +34 -0
- data/lib/fun_ci/agent/runs_text.rb +27 -0
- data/lib/fun_ci/agent/snapshots.rb +39 -0
- data/lib/fun_ci/agent/stage_summary.rb +30 -0
- data/lib/fun_ci/agent/status_command.rb +36 -0
- data/lib/fun_ci/agent/status_json.rb +27 -0
- data/lib/fun_ci/agent/status_text.rb +37 -0
- data/lib/fun_ci/agent/system_clock.rb +11 -0
- data/lib/fun_ci/agent/verdict.rb +32 -0
- data/lib/fun_ci/agent/wait_command.rb +71 -0
- data/lib/fun_ci/agent/waiting.rb +32 -0
- data/lib/fun_ci/agent/why_command.rb +50 -0
- data/lib/fun_ci/agent/why_json.rb +35 -0
- data/lib/fun_ci/agent/why_text.rb +43 -0
- data/lib/fun_ci/cli.rb +128 -0
- data/lib/fun_ci/cli_help.rb +65 -0
- data/lib/fun_ci/console/board_data.rb +66 -0
- data/lib/fun_ci/console/board_heartbeat.rb +40 -0
- data/lib/fun_ci/console/console_events.rb +20 -0
- data/lib/fun_ci/console/console_log.rb +26 -0
- data/lib/fun_ci/console/console_loop.rb +41 -0
- data/lib/fun_ci/console/console_session.rb +82 -0
- data/lib/fun_ci/console/console_state.rb +42 -0
- data/lib/fun_ci/console/key_handler.rb +73 -0
- data/lib/fun_ci/console/launcher.rb +43 -0
- data/lib/fun_ci/console/milestone_events.rb +38 -0
- data/lib/fun_ci/console/milestones.rb +25 -0
- data/lib/fun_ci/console/renderer_lookup.rb +51 -0
- data/lib/fun_ci/console/renderer_process.rb +60 -0
- data/lib/fun_ci/console/run_message.rb +33 -0
- data/lib/fun_ci/console/stage_change_detector.rb +32 -0
- data/lib/fun_ci/console/stage_events.rb +31 -0
- data/lib/fun_ci/console/stop_report.rb +39 -0
- data/lib/fun_ci/console/streak_counter.rb +19 -0
- data/lib/fun_ci/console/view.rb +47 -0
- data/lib/fun_ci/evidence/about.rb +12 -0
- data/lib/fun_ci/evidence/about_stage.rb +29 -0
- data/lib/fun_ci/evidence/caps.rb +77 -0
- data/lib/fun_ci/evidence/catalog.rb +83 -0
- data/lib/fun_ci/evidence/collector.rb +95 -0
- data/lib/fun_ci/evidence/command_output.rb +45 -0
- data/lib/fun_ci/evidence/command_reading.rb +38 -0
- data/lib/fun_ci/evidence/command_runner.rb +75 -0
- data/lib/fun_ci/evidence/context.rb +21 -0
- data/lib/fun_ci/evidence/context_document.rb +29 -0
- data/lib/fun_ci/evidence/deadline.rb +15 -0
- data/lib/fun_ci/evidence/detection.rb +82 -0
- data/lib/fun_ci/evidence/document.rb +59 -0
- data/lib/fun_ci/evidence/extract_command.rb +83 -0
- data/lib/fun_ci/evidence/extract_options.rb +45 -0
- data/lib/fun_ci/evidence/extraction.rb +54 -0
- data/lib/fun_ci/evidence/extractors/command.rb +52 -0
- data/lib/fun_ci/evidence/extractors/grep.rb +49 -0
- data/lib/fun_ci/evidence/extractors/json_log.rb +45 -0
- data/lib/fun_ci/evidence/extractors/junit_files.rb +32 -0
- data/lib/fun_ci/evidence/extractors/last_lines.rb +25 -0
- data/lib/fun_ci/evidence/extractors/log_file.rb +62 -0
- data/lib/fun_ci/evidence/extractors/process_tree.rb +51 -0
- data/lib/fun_ci/evidence/extractors/section.rb +49 -0
- data/lib/fun_ci/evidence/findings.rb +14 -0
- data/lib/fun_ci/evidence/line_ranges.rb +21 -0
- data/lib/fun_ci/evidence/line_scan.rb +22 -0
- data/lib/fun_ci/evidence/log_record.rb +58 -0
- data/lib/fun_ci/evidence/masking.rb +55 -0
- data/lib/fun_ci/evidence/option_check.rb +53 -0
- data/lib/fun_ci/evidence/outcome.rb +15 -0
- data/lib/fun_ci/evidence/patterns.rb +22 -0
- data/lib/fun_ci/evidence/presets/README.md +7 -0
- data/lib/fun_ci/evidence/presets/bun-test.yml +6 -0
- data/lib/fun_ci/evidence/presets/cargo-test.yml +6 -0
- data/lib/fun_ci/evidence/presets/dart-test.yml +6 -0
- data/lib/fun_ci/evidence/presets/deno-test.yml +6 -0
- data/lib/fun_ci/evidence/presets/dotnet-test.yml +6 -0
- data/lib/fun_ci/evidence/presets/ecs.yml +3 -0
- data/lib/fun_ci/evidence/presets/eslint.yml +6 -0
- data/lib/fun_ci/evidence/presets/exunit.yml +6 -0
- data/lib/fun_ci/evidence/presets/gcc.yml +6 -0
- data/lib/fun_ci/evidence/presets/go-build.yml +5 -0
- data/lib/fun_ci/evidence/presets/go-test.yml +6 -0
- data/lib/fun_ci/evidence/presets/gradle.yml +6 -0
- data/lib/fun_ci/evidence/presets/gtest.yml +6 -0
- data/lib/fun_ci/evidence/presets/jest.yml +6 -0
- data/lib/fun_ci/evidence/presets/logstash.yml +3 -0
- data/lib/fun_ci/evidence/presets/maven.yml +6 -0
- data/lib/fun_ci/evidence/presets/minitest.yml +6 -0
- data/lib/fun_ci/evidence/presets/mocha.yml +6 -0
- data/lib/fun_ci/evidence/presets/mypy.yml +6 -0
- data/lib/fun_ci/evidence/presets/node-test.yml +6 -0
- data/lib/fun_ci/evidence/presets/phpunit.yml +6 -0
- data/lib/fun_ci/evidence/presets/pino.yml +5 -0
- data/lib/fun_ci/evidence/presets/prove.yml +6 -0
- data/lib/fun_ci/evidence/presets/pytest.yml +6 -0
- data/lib/fun_ci/evidence/presets/rspec.yml +6 -0
- data/lib/fun_ci/evidence/presets/rubocop.yml +6 -0
- data/lib/fun_ci/evidence/presets/ruff.yml +6 -0
- data/lib/fun_ci/evidence/presets/rustc.yml +6 -0
- data/lib/fun_ci/evidence/presets/shellcheck.yml +5 -0
- data/lib/fun_ci/evidence/presets/structlog.yml +4 -0
- data/lib/fun_ci/evidence/presets/swift-test.yml +5 -0
- data/lib/fun_ci/evidence/presets/tsc.yml +6 -0
- data/lib/fun_ci/evidence/presets/unittest.yml +6 -0
- data/lib/fun_ci/evidence/presets/vitest.yml +6 -0
- data/lib/fun_ci/evidence/presets.rb +30 -0
- data/lib/fun_ci/evidence/problem.rb +9 -0
- data/lib/fun_ci/evidence/process_table.rb +40 -0
- data/lib/fun_ci/evidence/saved_run.rb +22 -0
- data/lib/fun_ci/evidence/section_walk.rb +49 -0
- data/lib/fun_ci/evidence/settings.rb +64 -0
- data/lib/fun_ci/evidence/settings_check.rb +78 -0
- data/lib/fun_ci/evidence/source.rb +34 -0
- data/lib/fun_ci/evidence/sources.rb +21 -0
- data/lib/fun_ci/evidence/stage_context.rb +30 -0
- data/lib/fun_ci/evidence/stamp.rb +10 -0
- data/lib/fun_ci/evidence/start.rb +36 -0
- data/lib/fun_ci/evidence/worktree.rb +62 -0
- data/lib/fun_ci/persistence/active_runs.rb +69 -0
- data/lib/fun_ci/persistence/database.rb +81 -0
- data/lib/fun_ci/persistence/null_recorder.rb +25 -0
- data/lib/fun_ci/persistence/output_tail.rb +24 -0
- data/lib/fun_ci/persistence/pipeline_recorder.rb +120 -0
- data/lib/fun_ci/persistence/pipeline_run.rb +68 -0
- data/lib/fun_ci/persistence/project_runs.rb +52 -0
- data/lib/fun_ci/persistence/raw_outputs.rb +46 -0
- data/lib/fun_ci/persistence/retention.rb +25 -0
- data/lib/fun_ci/persistence/run_status.rb +31 -0
- data/lib/fun_ci/persistence/stage_job.rb +91 -0
- data/lib/fun_ci/persistence/state_dir.rb +26 -0
- data/lib/fun_ci/persistence/write_trouble.rb +46 -0
- data/lib/fun_ci/pipeline/background_fork.rb +42 -0
- data/lib/fun_ci/pipeline/command_executor.rb +58 -0
- data/lib/fun_ci/pipeline/gate.rb +35 -0
- data/lib/fun_ci/pipeline/git_environment.rb +16 -0
- data/lib/fun_ci/pipeline/head_part.rb +40 -0
- data/lib/fun_ci/pipeline/in_place.rb +17 -0
- data/lib/fun_ci/pipeline/output_window.rb +73 -0
- data/lib/fun_ci/pipeline/pipeline_forker.rb +40 -0
- data/lib/fun_ci/pipeline/process_runner.rb +101 -0
- data/lib/fun_ci/pipeline/progress_reporter.rb +31 -0
- data/lib/fun_ci/pipeline/run_canceller.rb +75 -0
- data/lib/fun_ci/pipeline/slot.rb +30 -0
- data/lib/fun_ci/pipeline/slot_lock.rb +13 -0
- data/lib/fun_ci/pipeline/slot_run.rb +86 -0
- data/lib/fun_ci/pipeline/stage_dir.rb +50 -0
- data/lib/fun_ci/pipeline/stage_end.rb +70 -0
- data/lib/fun_ci/pipeline/stage_execution.rb +67 -0
- data/lib/fun_ci/pipeline/stage_runner.rb +63 -0
- data/lib/fun_ci/pipeline/stale_pipeline_canceller.rb +36 -0
- data/lib/fun_ci/pipeline/tail_segments.rb +60 -0
- data/lib/fun_ci/pipeline/test_report.rb +48 -0
- data/lib/fun_ci/pipeline/trigger.rb +93 -0
- data/lib/fun_ci/pipeline/trigger_command.rb +62 -0
- data/lib/fun_ci/pipeline/trigger_params.rb +48 -0
- data/lib/fun_ci/pipeline/workspaces.rb +22 -0
- data/lib/fun_ci/pipeline/worktree_pool.rb +66 -0
- data/lib/fun_ci/pipeline/worktree_prune.rb +49 -0
- data/lib/fun_ci/pipeline/worktrees.rb +45 -0
- data/lib/fun_ci/setup/agent_instructions.rb +31 -0
- data/lib/fun_ci/setup/commands.rb +49 -0
- data/lib/fun_ci/setup/git_hooks.rb +20 -0
- data/lib/fun_ci/setup/hook_script.rb +47 -0
- data/lib/fun_ci/setup/hook_writer.rb +57 -0
- data/lib/fun_ci/setup/installer.rb +71 -0
- data/lib/fun_ci/setup/legacy_hooks.rb +38 -0
- data/lib/fun_ci/setup/maven_linter_detector.rb +26 -0
- data/lib/fun_ci/setup/project_config.rb +68 -0
- data/lib/fun_ci/setup/project_detector.rb +54 -0
- data/lib/fun_ci/setup/settings.rb +36 -0
- data/lib/fun_ci/setup/setup_checker.rb +36 -0
- data/lib/fun_ci/setup/stage_templates/javascript.rb +18 -0
- data/lib/fun_ci/setup/stage_templates/managed.rb +24 -0
- data/lib/fun_ci/setup/stage_templates/native.rb +25 -0
- data/lib/fun_ci/setup/stage_templates/scripting.rb +27 -0
- data/lib/fun_ci/setup/stage_templates/shell.rb +16 -0
- data/lib/fun_ci/setup/stage_templates.rb +25 -0
- data/lib/fun_ci/setup/template_writer.rb +28 -0
- data/lib/fun_ci/version.rb +6 -0
- data/lib/fun_ci.rb +13 -0
- data/libexec/fun-ci-renderer +0 -0
- metadata +286 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "../persistence/pipeline_run"
|
|
5
|
+
require_relative "../persistence/stage_job"
|
|
6
|
+
require_relative "../persistence/active_runs"
|
|
7
|
+
require_relative "../pipeline/run_canceller"
|
|
8
|
+
require_relative "streak_counter"
|
|
9
|
+
|
|
10
|
+
module FunCi
|
|
11
|
+
module Console
|
|
12
|
+
class BoardData
|
|
13
|
+
def initialize(db, limit: 15, page_size: nil, run_canceller: Pipeline::RunCanceller.new)
|
|
14
|
+
@db = db
|
|
15
|
+
@page_size = page_size || limit
|
|
16
|
+
@limit = @page_size
|
|
17
|
+
@run_canceller = run_canceller
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def load_more
|
|
21
|
+
@limit += @page_size
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Pages by `page_size` from now on, loading at least one such page.
|
|
25
|
+
def resize(page_size)
|
|
26
|
+
@page_size = page_size
|
|
27
|
+
@limit = [@limit, page_size].max
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Whether the store holds runs beyond those `runs` loads.
|
|
31
|
+
def more?
|
|
32
|
+
Persistence::PipelineRun.recent(@db, limit: @limit + 1).size > @limit
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Records failed each slow suite whose process died (acceptance-tests.md, AT-8.3).
|
|
36
|
+
def record_dead_slow_suites = @run_canceller.record_dead(@db)
|
|
37
|
+
|
|
38
|
+
def runs
|
|
39
|
+
pipeline_runs = Persistence::PipelineRun.recent(@db, limit: @limit)
|
|
40
|
+
pipeline_runs.map { |run| enrich_with_stages(run) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def streak
|
|
44
|
+
pipeline_runs = Persistence::PipelineRun.recent(@db, limit: @limit)
|
|
45
|
+
StreakCounter.count(pipeline_runs)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Stops the run's processes, then records it cancelled. A run that has
|
|
49
|
+
# finished meanwhile is left as it is.
|
|
50
|
+
def cancel_run(run_id)
|
|
51
|
+
Persistence::ActiveRuns.with_id(@db, run_id).each { |run| @run_canceller.cancel(@db, run) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def enrich_with_stages(run)
|
|
57
|
+
run.merge(stages: Persistence::StageJob.for_run(@db, run[:id]).map { |job| stage(job) })
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def stage(job)
|
|
61
|
+
{ stage: job[:stage], status: job[:status], duration: Persistence::StageJob.elapsed_duration(job),
|
|
62
|
+
started_at: job[:started_at], finished_order: job[:finished_order] }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Console
|
|
5
|
+
# The port to the renderer, noting in the console log every EVERY seconds
|
|
6
|
+
# how many boards went out and the newest run on the last, so a console
|
|
7
|
+
# that stops showing new runs leaves a record of what Ruby was sending.
|
|
8
|
+
class BoardHeartbeat
|
|
9
|
+
EVERY = 600
|
|
10
|
+
|
|
11
|
+
# `clock` answers seconds; only differences between its answers count.
|
|
12
|
+
def initialize(port, log:, clock:)
|
|
13
|
+
@port = port
|
|
14
|
+
@log = log
|
|
15
|
+
@clock = clock
|
|
16
|
+
@beat = { since: clock.call, boards: 0 }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def write(message)
|
|
20
|
+
@port.write(message)
|
|
21
|
+
note(message) if message[:t] == "board"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def note(board)
|
|
27
|
+
now = @clock.call
|
|
28
|
+
@beat = { since: @beat[:since], boards: @beat[:boards] + 1 }
|
|
29
|
+
tell(now, board) if now - @beat[:since] >= EVERY
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def tell(now, board)
|
|
33
|
+
@log.write("#{@beat[:boards]} boards in the last #{(now - @beat[:since]).round} s; #{newest(board[:runs])}")
|
|
34
|
+
@beat = { since: now, boards: 0 }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def newest(runs) = runs.empty? ? "the last showed no runs" : "the newest run on the last: #{runs.first[:id]}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "stage_events"
|
|
4
|
+
require_relative "milestone_events"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Console
|
|
8
|
+
# Every protocol `event` since the last poll of the runs: the stages that
|
|
9
|
+
# changed (for the effects on a stage's column), then the milestones the
|
|
10
|
+
# runs reached (for the header).
|
|
11
|
+
class ConsoleEvents
|
|
12
|
+
def initialize
|
|
13
|
+
@stages = StageEvents.new
|
|
14
|
+
@milestones = MilestoneEvents.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def since_last(runs) = @stages.since_last(runs) + @milestones.since_last(runs)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Console
|
|
8
|
+
# The project's .fun-ci/console.log, where the console notes what the
|
|
9
|
+
# renderer got wrong: the terminal is the renderer's, so stderr would
|
|
10
|
+
# draw over it.
|
|
11
|
+
class ConsoleLog
|
|
12
|
+
attr_reader :path
|
|
13
|
+
|
|
14
|
+
def initialize(project_dir:, clock:)
|
|
15
|
+
@path = File.join(project_dir, ".fun-ci", "console.log")
|
|
16
|
+
@clock = clock
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Appends `text` as one line, stamped with the clock's time in UTC.
|
|
20
|
+
def write(text)
|
|
21
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
22
|
+
File.open(@path, "a") { |file| file.puts("#{@clock.call.utc.iso8601} #{text}") }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "renderer_process"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Console
|
|
7
|
+
# The console's conversation with its renderer: each line the renderer
|
|
8
|
+
# writes goes to the session, and whenever the renderer has been quiet for
|
|
9
|
+
# POLL_SECONDS the session polls the runs, so what the database holds
|
|
10
|
+
# reaches the screen without waiting for a key.
|
|
11
|
+
class ConsoleLoop
|
|
12
|
+
POLL_SECONDS = 1
|
|
13
|
+
|
|
14
|
+
def initialize(session:, renderer:)
|
|
15
|
+
@session = session
|
|
16
|
+
@renderer = renderer
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# :quit once the session has quit, :ended if the renderer went away first.
|
|
20
|
+
def run
|
|
21
|
+
@session.start
|
|
22
|
+
loop do
|
|
23
|
+
return :quit if @session.finished?
|
|
24
|
+
return :ended unless step
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# False once the renderer has closed its output.
|
|
31
|
+
def step
|
|
32
|
+
line = @renderer.next_line(patience: POLL_SECONDS)
|
|
33
|
+
@session.receive(line) if line
|
|
34
|
+
!line.nil?
|
|
35
|
+
rescue RendererProcess::Silent
|
|
36
|
+
@session.refresh
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "console_state"
|
|
5
|
+
require_relative "view"
|
|
6
|
+
require_relative "key_handler"
|
|
7
|
+
require_relative "board_heartbeat"
|
|
8
|
+
|
|
9
|
+
module FunCi
|
|
10
|
+
module Console
|
|
11
|
+
# The console's half of the renderer conversation (renderer-protocol.md):
|
|
12
|
+
# it reads the renderer's lines and writes protocol messages to the port.
|
|
13
|
+
# Ruby decides what is true here; drawing it is the renderer's job.
|
|
14
|
+
class ConsoleSession
|
|
15
|
+
VERSION = 1
|
|
16
|
+
HANDLERS = { "ready" => :ready, "resize" => :resize, "key" => :key, "error" => :renderer_error }.freeze
|
|
17
|
+
|
|
18
|
+
# `log` takes what the renderer got wrong (ConsoleLog), and every ten
|
|
19
|
+
# minutes what the session sent it (BoardHeartbeat).
|
|
20
|
+
def self.build(board_data:, port:, clock:, log:)
|
|
21
|
+
view = View.new(key_handler: KeyHandler.new(board_data: board_data))
|
|
22
|
+
new(state: ConsoleState.new(board_data: board_data, view: view, clock: clock),
|
|
23
|
+
port: BoardHeartbeat.new(port, log: log, clock: clock), log: log)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(state:, port:, log:)
|
|
27
|
+
@state = state
|
|
28
|
+
@port = port
|
|
29
|
+
@log = log
|
|
30
|
+
@phase = :starting
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def start = @port.write(t: "hello", v: VERSION)
|
|
34
|
+
|
|
35
|
+
# A line the renderer wrote. One it got wrong is logged, never raised.
|
|
36
|
+
def receive(line)
|
|
37
|
+
message = JSON.parse(line)
|
|
38
|
+
message.is_a?(Hash) ? handle(message) : @log.write("the renderer sent a line that is not a message: #{line}")
|
|
39
|
+
rescue JSON::ParserError
|
|
40
|
+
@log.write("the renderer sent a line that is not JSON: #{line.inspect}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Sends what the runs look like now, as each poll does; nothing before
|
|
44
|
+
# the renderer is ready, since its size decides the page.
|
|
45
|
+
def refresh = @phase == :running && send_updates
|
|
46
|
+
|
|
47
|
+
def finished? = @phase == :finished
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def handle(message)
|
|
52
|
+
handler = HANDLERS[message["t"]]
|
|
53
|
+
return @log.write("the renderer sent a message of unknown type #{message["t"].inspect}") unless handler
|
|
54
|
+
|
|
55
|
+
send(handler, message)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def ready(message)
|
|
59
|
+
@phase = :running
|
|
60
|
+
resize(message)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def resize(message)
|
|
64
|
+
@state.resize(message["rows"])
|
|
65
|
+
send_updates
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def key(message)
|
|
69
|
+
return send_updates unless @state.press(message["key"]) == :quit
|
|
70
|
+
|
|
71
|
+
@phase = :finished
|
|
72
|
+
@port.write(t: "quit")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def renderer_error(message)
|
|
76
|
+
@log.write("the renderer reported a #{message["code"]} error: #{message["detail"]}")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def send_updates = @state.updates.each { |update| @port.write(update) }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "run_message"
|
|
4
|
+
require_relative "console_events"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Console
|
|
8
|
+
# What the console shows: the page of runs BoardData reads that the View
|
|
9
|
+
# puts on screen, and the clock's time, as a protocol `board` message,
|
|
10
|
+
# after the `event`s for what changed since the last one.
|
|
11
|
+
class ConsoleState
|
|
12
|
+
KEYS = { "up" => :up, "down" => :down, "esc" => :escape, "ctrl_c" => "q", "enter" => :enter }.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(board_data:, view:, clock:)
|
|
15
|
+
@board_data = board_data
|
|
16
|
+
@view = view
|
|
17
|
+
@clock = clock
|
|
18
|
+
@events = ConsoleEvents.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# :quit when the key ends the session.
|
|
22
|
+
def press(key) = @view.press(KEYS.fetch(key, key))
|
|
23
|
+
|
|
24
|
+
# Pages for a terminal of `rows`.
|
|
25
|
+
def resize(rows) = @board_data.resize(@view.resize(rows))
|
|
26
|
+
|
|
27
|
+
def updates
|
|
28
|
+
@board_data.record_dead_slow_suites
|
|
29
|
+
runs = @board_data.runs
|
|
30
|
+
[*@events.since_last(runs), board(runs)]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def board(runs)
|
|
36
|
+
page = @view.page(runs, more: @board_data.more?)
|
|
37
|
+
{ t: "board", now: @clock.call.to_i, streak: @board_data.streak, **page,
|
|
38
|
+
runs: page[:runs].map { |run| RunMessage.from(run) } }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Console
|
|
5
|
+
# What a key means for the runs BoardData loads: moving the cursor,
|
|
6
|
+
# quitting, and cancelling the run under the cursor (a running one after
|
|
7
|
+
# the user confirms with `y`).
|
|
8
|
+
class KeyHandler
|
|
9
|
+
CONFIRMATION_ANSWERS = ["y", "n", :escape].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :cursor_index
|
|
12
|
+
|
|
13
|
+
def initialize(board_data:)
|
|
14
|
+
@board_data = board_data
|
|
15
|
+
@confirm_cancel = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# :quit when the key ends the session.
|
|
19
|
+
def handle_key(key)
|
|
20
|
+
return confirm(key) if @confirm_cancel
|
|
21
|
+
return :quit if key == "q"
|
|
22
|
+
|
|
23
|
+
act(key)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def confirming?
|
|
27
|
+
!@confirm_cancel.nil?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def confirmation_run
|
|
31
|
+
@confirm_cancel
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def act(key)
|
|
37
|
+
case key
|
|
38
|
+
when "j", :down then move_cursor_down
|
|
39
|
+
when "k", :up then move_cursor_up
|
|
40
|
+
when "c" then initiate_cancel
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Onto the last run loaded, which loads the next page.
|
|
45
|
+
def move_cursor_down
|
|
46
|
+
last = @board_data.runs.length - 1
|
|
47
|
+
return if last.negative?
|
|
48
|
+
return @cursor_index = 0 if @cursor_index.nil?
|
|
49
|
+
return unless @cursor_index < last
|
|
50
|
+
|
|
51
|
+
@cursor_index += 1
|
|
52
|
+
@board_data.load_more if @cursor_index == last
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def move_cursor_up
|
|
56
|
+
@cursor_index -= 1 if @cursor_index&.positive?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def initiate_cancel
|
|
60
|
+
run = @cursor_index && @board_data.runs[@cursor_index]
|
|
61
|
+
return unless run
|
|
62
|
+
|
|
63
|
+
@board_data.cancel_run(run[:id]) if run[:status] == "scheduled"
|
|
64
|
+
@confirm_cancel = run if run[:status] == "running"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def confirm(key)
|
|
68
|
+
@board_data.cancel_run(@confirm_cancel[:id]) if key == "y"
|
|
69
|
+
@confirm_cancel = nil if CONFIRMATION_ANSWERS.include?(key)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "board_data"
|
|
5
|
+
require_relative "console_log"
|
|
6
|
+
require_relative "console_loop"
|
|
7
|
+
require_relative "console_session"
|
|
8
|
+
require_relative "renderer_process"
|
|
9
|
+
require_relative "stop_report"
|
|
10
|
+
|
|
11
|
+
module FunCi
|
|
12
|
+
module Console
|
|
13
|
+
# `fun-ci console`: starts the renderer, which draws on the terminal, and
|
|
14
|
+
# talks to it about the runs in `db` until the user quits. The renderer's
|
|
15
|
+
# stderr, what it gets wrong, and when and why the console started and
|
|
16
|
+
# stopped go to the project's .fun-ci/console.log.
|
|
17
|
+
module Launcher
|
|
18
|
+
PATIENCE = 5
|
|
19
|
+
|
|
20
|
+
# The exit status: 0 once the user has quit, 1 if the renderer stopped
|
|
21
|
+
# or the console failed.
|
|
22
|
+
def self.run(renderer:, db:, project_dir:, stderr:)
|
|
23
|
+
log = ConsoleLog.new(project_dir: project_dir, clock: -> { Time.now })
|
|
24
|
+
FileUtils.mkdir_p(File.dirname(log.path))
|
|
25
|
+
process = RendererProcess.start([renderer], err: [log.path, "a"])
|
|
26
|
+
log.write("console started; the renderer is pid #{process.pid}")
|
|
27
|
+
outcome = converse(process, BoardData.new(db), log)
|
|
28
|
+
StopReport.new(outcome, process.finish(patience: PATIENCE)).tell(log, stderr)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# :quit, :ended, or the error that ended the conversation.
|
|
32
|
+
def self.converse(process, board_data, log)
|
|
33
|
+
session = ConsoleSession.build(board_data: board_data, port: process, clock: -> { Time.now }, log: log)
|
|
34
|
+
ConsoleLoop.new(session: session, renderer: process).run
|
|
35
|
+
rescue Errno::EPIPE
|
|
36
|
+
:ended
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
e
|
|
39
|
+
end
|
|
40
|
+
private_class_method :converse
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "milestones"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Console
|
|
7
|
+
# The protocol `event` for each milestone a run reached since the last
|
|
8
|
+
# poll (acceptance-tests.md, AT-7.2). None on the first poll, which has
|
|
9
|
+
# nothing to compare with; none for a cancelled run; and none once a run
|
|
10
|
+
# has failed, so a run fails once however many of its stages fail; and
|
|
11
|
+
# none for an older run that paging brings onto the board.
|
|
12
|
+
class MilestoneEvents
|
|
13
|
+
def initialize
|
|
14
|
+
@previous = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def since_last(runs)
|
|
18
|
+
events = @previous ? runs.flat_map { |run| new_milestones(run) } : []
|
|
19
|
+
@previous = runs.to_h { |run| [run[:id], Milestones.of(run)] }
|
|
20
|
+
events
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def new_milestones(run)
|
|
26
|
+
return [] unless news?(run)
|
|
27
|
+
|
|
28
|
+
before = @previous.fetch(run[:id], [])
|
|
29
|
+
return [] if before.include?("run_failed") || run[:status] == "cancelled"
|
|
30
|
+
|
|
31
|
+
(Milestones.of(run) - before).map { |name| { t: "event", name: name, run_id: run[:id] } }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A run the last poll saw, or one newer than all it saw; run ids only grow.
|
|
35
|
+
def news?(run) = @previous.key?(run[:id]) || run[:id] > @previous.keys.max.to_i
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Console
|
|
5
|
+
# The milestones a run has reached, in the order it reached them
|
|
6
|
+
# (design.md, A run's states): its stages in the order they finished (a
|
|
7
|
+
# stage recorded before that order was kept counts as unordered, and lint,
|
|
8
|
+
# build, fast is the fallback), then the run passing or failing.
|
|
9
|
+
module Milestones
|
|
10
|
+
STAGES = %w[lint build fast].freeze
|
|
11
|
+
OUTCOMES = { "completed" => ["run_passed"], "failed" => ["run_failed"] }.freeze
|
|
12
|
+
|
|
13
|
+
def self.of(run)
|
|
14
|
+
passed = run[:stages].select { |stage| stage[:status] == "completed" && STAGES.include?(stage[:stage]) }
|
|
15
|
+
passed.sort_by { |stage| order(stage) }.map { |stage| "#{stage[:stage]}_passed" } +
|
|
16
|
+
OUTCOMES.fetch(run[:status], [])
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.order(stage)
|
|
20
|
+
[stage[:finished_order].to_i, STAGES.index(stage[:stage])]
|
|
21
|
+
end
|
|
22
|
+
private_class_method :order
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Console
|
|
5
|
+
# Where the renderer binary is (architecture.md, Distribution): the one
|
|
6
|
+
# FUN_CI_RENDERER names, else the one a platform gem bundles in libexec/,
|
|
7
|
+
# else the first on PATH.
|
|
8
|
+
class RendererLookup
|
|
9
|
+
class Missing < StandardError; end
|
|
10
|
+
|
|
11
|
+
NAME = "fun-ci-renderer"
|
|
12
|
+
HOW_TO_GET_ONE = "fun-ci console needs its renderer, #{NAME}, and this installation has none. " \
|
|
13
|
+
"Install it with `cargo install #{NAME}`, or set FUN_CI_RENDERER to its path.".freeze
|
|
14
|
+
|
|
15
|
+
# The installed gem's libexec/, where a platform gem puts the renderer.
|
|
16
|
+
LIBEXEC = File.expand_path("../../../libexec", __dir__)
|
|
17
|
+
|
|
18
|
+
def self.default = new(env: ENV, libexec: LIBEXEC, executable: method(:runnable?))
|
|
19
|
+
|
|
20
|
+
# Whether `path` is a file this user may run.
|
|
21
|
+
def self.runnable?(path) = File.file?(path) && File.executable?(path)
|
|
22
|
+
|
|
23
|
+
def initialize(env:, libexec:, executable:)
|
|
24
|
+
@env = env
|
|
25
|
+
@libexec = libexec
|
|
26
|
+
@executable = executable
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The renderer's path. Raises Missing, saying how to get one, if none is found.
|
|
30
|
+
def path
|
|
31
|
+
named = @env["FUN_CI_RENDERER"].to_s
|
|
32
|
+
return runnable(named) unless named.empty?
|
|
33
|
+
|
|
34
|
+
candidates.find { |candidate| @executable.call(candidate) } || raise(Missing, HOW_TO_GET_ONE)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def runnable(named)
|
|
40
|
+
return named if @executable.call(named)
|
|
41
|
+
|
|
42
|
+
raise Missing, "FUN_CI_RENDERER names #{named}, which is not a program fun-ci can run."
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def candidates
|
|
46
|
+
on_path = @env.fetch("PATH", "").split(File::PATH_SEPARATOR).reject(&:empty?)
|
|
47
|
+
[@libexec, *on_path].map { |dir| File.join(dir, NAME) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Console
|
|
7
|
+
# The renderer as a child process (renderer-protocol.md): messages go to
|
|
8
|
+
# its stdin as JSON lines and its lines come from its stdout. No wait on
|
|
9
|
+
# it is endless, and one that will not exit is stopped.
|
|
10
|
+
class RendererProcess
|
|
11
|
+
class Silent < StandardError; end
|
|
12
|
+
|
|
13
|
+
# `err` takes the renderer's stderr, which is for diagnostics only.
|
|
14
|
+
def self.start(command, err: File::NULL)
|
|
15
|
+
input, to_renderer = IO.pipe
|
|
16
|
+
from_renderer, output = IO.pipe
|
|
17
|
+
pid = Process.spawn(*command, in: input, out: output, err: err)
|
|
18
|
+
[input, output].each(&:close)
|
|
19
|
+
new(waiter: Process.detach(pid), input: to_renderer, output: from_renderer)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(waiter:, input:, output:)
|
|
23
|
+
@waiter = waiter
|
|
24
|
+
@input = input
|
|
25
|
+
@output = output
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def pid = @waiter.pid
|
|
29
|
+
|
|
30
|
+
def write(message) = @input.puts(JSON.generate(message))
|
|
31
|
+
|
|
32
|
+
# The renderer's next line, or nil once it has closed its output.
|
|
33
|
+
# Raises Silent when nothing comes within `patience` seconds.
|
|
34
|
+
def next_line(patience:)
|
|
35
|
+
raise Silent, "no word from the renderer in #{patience} s" unless @output.wait_readable(patience)
|
|
36
|
+
|
|
37
|
+
@output.gets&.chomp
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Closes the renderer's input, which it takes as `quit`, and answers how
|
|
41
|
+
# it exited; one still running after `patience` seconds is stopped.
|
|
42
|
+
def finish(patience:)
|
|
43
|
+
@input.close unless @input.closed?
|
|
44
|
+
stop(patience) unless @waiter.join(patience)
|
|
45
|
+
@output.close
|
|
46
|
+
@waiter.value
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# SIGTERM first, on which the renderer restores the terminal.
|
|
52
|
+
def stop(patience)
|
|
53
|
+
Process.kill("TERM", @waiter.pid)
|
|
54
|
+
Process.kill("KILL", @waiter.pid) unless @waiter.join(patience)
|
|
55
|
+
rescue Errno::ESRCH
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Console
|
|
7
|
+
# A run as BoardData reads it from SQLite, as the protocol's `board`
|
|
8
|
+
# message carries it: protocol status names, epoch seconds and
|
|
9
|
+
# milliseconds. Formatting them is the renderer's job.
|
|
10
|
+
module RunMessage
|
|
11
|
+
STATUS = { "scheduled" => "pending", "completed" => "passed", "timed_out" => "timeout" }.freeze
|
|
12
|
+
|
|
13
|
+
def self.from(run)
|
|
14
|
+
{ id: run[:id], sha: run[:commit_hash], branch: run[:branch], project: run[:project_path],
|
|
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
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.stage(stage)
|
|
20
|
+
{ stage: stage[:stage], status: status(stage[:status]),
|
|
21
|
+
duration_ms: stage[:duration] && (stage[:duration] * 1000).round,
|
|
22
|
+
started_at: (epoch(stage[:started_at]) if stage[:status] == "running") }.compact
|
|
23
|
+
end
|
|
24
|
+
private_class_method :stage
|
|
25
|
+
|
|
26
|
+
def self.status(name) = STATUS.fetch(name, name)
|
|
27
|
+
private_class_method :status
|
|
28
|
+
|
|
29
|
+
def self.epoch(iso) = iso && Time.parse(iso).to_i
|
|
30
|
+
private_class_method :epoch
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Console
|
|
5
|
+
# The stages whose status differs between two polls of the runs, for the
|
|
6
|
+
# runs both polls saw; none when there was no earlier poll.
|
|
7
|
+
module StageChangeDetector
|
|
8
|
+
Change = Data.define(:run_id, :stage, :from, :to)
|
|
9
|
+
|
|
10
|
+
def self.detect(previous_runs, current_runs)
|
|
11
|
+
current_by_id = current_runs.to_h { |run| [run[:id], run] }
|
|
12
|
+
previous_runs.select { |run| current_by_id.key?(run[:id]) }
|
|
13
|
+
.flat_map { |run| compare_stages(run, current_by_id.fetch(run[:id])) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.compare_stages(previous_run, current_run)
|
|
17
|
+
before = statuses(previous_run[:stages])
|
|
18
|
+
current_run[:stages].reject { |stage| before[stage[:stage]] == stage[:status] }
|
|
19
|
+
.map { |stage| change(current_run[:id], stage, before[stage[:stage]]) }
|
|
20
|
+
end
|
|
21
|
+
private_class_method :compare_stages
|
|
22
|
+
|
|
23
|
+
def self.change(run_id, stage, from)
|
|
24
|
+
Change.new(run_id: run_id, stage: stage[:stage], from: from, to: stage[:status])
|
|
25
|
+
end
|
|
26
|
+
private_class_method :change
|
|
27
|
+
|
|
28
|
+
def self.statuses(stages) = (stages || []).to_h { |stage| [stage[:stage], stage[:status]] }
|
|
29
|
+
private_class_method :statuses
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|