fun_ci 1.2.1 → 2.0.1
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 +236 -0
- data/README.md +182 -59
- 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 +32 -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 +71 -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 +72 -94
- 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 +63 -30
- 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 +78 -32
- data/lib/fun_ci/persistence/pipeline_run.rb +26 -17
- 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 +61 -14
- 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 +18 -16
- data/lib/fun_ci/pipeline/process_runner.rb +90 -17
- 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 +42 -65
- data/lib/fun_ci/pipeline/stale_pipeline_canceller.rb +16 -33
- 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 +55 -115
- data/lib/fun_ci/pipeline/trigger_command.rb +62 -0
- data/lib/fun_ci/pipeline/trigger_params.rb +56 -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 +13 -31
- data/lib/fun_ci/setup/installer.rb +39 -23
- data/lib/fun_ci/setup/legacy_hooks.rb +38 -0
- data/lib/fun_ci/setup/project_config.rb +43 -17
- data/lib/fun_ci/setup/project_detector.rb +39 -7
- data/lib/fun_ci/setup/settings.rb +36 -0
- data/lib/fun_ci/setup/setup_checker.rb +18 -12
- 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 +11 -36
- data/lib/fun_ci/version.rb +1 -1
- data/lib/fun_ci.rb +0 -1
- metadata +205 -41
- data/exe/fun-ci-trigger +0 -19
- data/exe/fun-ci-tui +0 -23
- data/lib/fun_ci/animations/celebrate.rb +0 -210
- data/lib/fun_ci/animations/explosion.rb +0 -138
- data/lib/fun_ci/animations/flash.rb +0 -205
- data/lib/fun_ci/animations/idle.rb +0 -127
- data/lib/fun_ci/animations/leprechauns.rb +0 -240
- data/lib/fun_ci/animations/running.rb +0 -129
- data/lib/fun_ci/animations/success.rb +0 -281
- data/lib/fun_ci/animations/yay.rb +0 -169
- data/lib/fun_ci/persistence/state_machine.rb +0 -50
- data/lib/fun_ci/pipeline/background_wrapper.rb +0 -27
- data/lib/fun_ci/tui/admin_tui.rb +0 -90
- data/lib/fun_ci/tui/animation.rb +0 -49
- data/lib/fun_ci/tui/animation_compositor.rb +0 -107
- data/lib/fun_ci/tui/animation_frames.rb +0 -112
- data/lib/fun_ci/tui/animation_library.rb +0 -46
- data/lib/fun_ci/tui/animation_renderer.rb +0 -144
- data/lib/fun_ci/tui/ansi.rb +0 -34
- data/lib/fun_ci/tui/board_data.rb +0 -53
- data/lib/fun_ci/tui/board_renderer.rb +0 -105
- data/lib/fun_ci/tui/duration_formatter.rb +0 -24
- data/lib/fun_ci/tui/header_animation_manager.rb +0 -71
- data/lib/fun_ci/tui/header_animation_player.rb +0 -45
- data/lib/fun_ci/tui/key_handler.rb +0 -86
- data/lib/fun_ci/tui/looping_animation_player.rb +0 -45
- data/lib/fun_ci/tui/relative_time.rb +0 -22
- data/lib/fun_ci/tui/row_formatter.rb +0 -108
- data/lib/fun_ci/tui/screen.rb +0 -103
- data/lib/fun_ci/tui/spinner.rb +0 -24
- data/lib/fun_ci/tui/stage_change_detector.rb +0 -58
- data/lib/fun_ci/tui/streak_counter.rb +0 -29
- data/lib/fun_ci/tui/terminal_input.rb +0 -69
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Agent
|
|
5
|
+
# Why a needed stage failed, as the short text `status` and `wait` print
|
|
6
|
+
# (acceptance-tests.md, AT-9.6, AT-10.17), drawn from its evidence: for an
|
|
7
|
+
# overrun, what it was running; then the failures, or else the first
|
|
8
|
+
# excerpt, or else the output's last lines.
|
|
9
|
+
module Digest
|
|
10
|
+
HEADINGS = { "failed" => "failed", "over_budget" => "ran over budget" }.freeze
|
|
11
|
+
LINES = 20
|
|
12
|
+
FAILURES = 10
|
|
13
|
+
MESSAGE_LINES = 5
|
|
14
|
+
|
|
15
|
+
def self.lines(stages, needed)
|
|
16
|
+
stages.select { |stage| needed.include?(stage.name) && HEADINGS.key?(stage.state) }
|
|
17
|
+
.flat_map { |stage| of(stage) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.of(stage)
|
|
21
|
+
evidence = stage.evidence
|
|
22
|
+
[heading(stage, evidence), *(evidence.failures.any? ? failure_lines(evidence.failures) : excerpt(evidence))]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.heading(stage, evidence)
|
|
26
|
+
running = evidence.facts.find { |fact| fact[:name] == "running" }
|
|
27
|
+
"#{stage.name} #{HEADINGS.fetch(stage.state)}:#{" running #{running[:value]}" if running}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.failure_lines(failures)
|
|
31
|
+
failures.first(FAILURES).flat_map do |failure|
|
|
32
|
+
[" #{place(failure)}", *failure[:message].to_s.lines.first(MESSAGE_LINES).map { |line| " #{line.chomp}" }]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.place(failure)
|
|
37
|
+
failure[:file] ? "#{failure[:file]}:#{failure[:line]} #{failure[:test]}" : failure[:test]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The first excerpt's first lines, or the output's last lines.
|
|
41
|
+
def self.excerpt(evidence)
|
|
42
|
+
first = evidence.excerpts.find { |excerpt| excerpt[:extractor] != "output-tail" }
|
|
43
|
+
lines = first ? first[:lines].first(LINES) : evidence.excerpts.flat_map { |e| e[:lines] }.last(LINES)
|
|
44
|
+
lines.map { |line| " #{line}" }
|
|
45
|
+
end
|
|
46
|
+
private_class_method :of, :heading, :failure_lines, :place, :excerpt
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Agent
|
|
5
|
+
# What happened to a project's runs between two looks at them
|
|
6
|
+
# (acceptance-tests.md, AT-9.16), oldest run first: a run started, each
|
|
7
|
+
# stage that finished in the order it finished, a run that passed or
|
|
8
|
+
# failed, a run a newer commit superseded. The first look is at nothing.
|
|
9
|
+
module Events
|
|
10
|
+
# finished: [{ stage:, state:, seconds: }, ...] in the order they finished.
|
|
11
|
+
RunState = Data.define(:id, :sha, :branch, :status, :finished, :superseded_by)
|
|
12
|
+
OUTCOMES = { "completed" => "passed", "failed" => "failed" }.freeze
|
|
13
|
+
FAILED_STATES = %w[failed over_budget].freeze
|
|
14
|
+
|
|
15
|
+
# before, after: { run id => RunState }.
|
|
16
|
+
def self.between(before, after)
|
|
17
|
+
after.values.sort_by(&:id).flat_map { |run| of(run, before[run.id]) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.failure?(event)
|
|
21
|
+
return true if event[:event] == "run_superseded"
|
|
22
|
+
|
|
23
|
+
event[:event] == "stage_finished" && FAILED_STATES.include?(event[:state])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.of(run, was)
|
|
27
|
+
about = { commit: run.sha, branch: run.branch }
|
|
28
|
+
[*(was ? [] : [{ event: "run_started", **about }]),
|
|
29
|
+
*newly_finished(run, was).map { |stage| { event: "stage_finished", **about, **stage } },
|
|
30
|
+
*ended(run, was, about)]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.newly_finished(run, was)
|
|
34
|
+
seen = (was&.finished || []).map { |stage| stage[:stage] }
|
|
35
|
+
run.finished.reject { |stage| seen.include?(stage[:stage]) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.ended(run, was, about)
|
|
39
|
+
return [] if was&.status == run.status
|
|
40
|
+
return [{ event: "run_superseded", **about, by_commit: run.superseded_by }] if run.status == "cancelled"
|
|
41
|
+
|
|
42
|
+
OUTCOMES.key?(run.status) ? [{ event: "run_finished", **about, state: OUTCOMES.fetch(run.status) }] : []
|
|
43
|
+
end
|
|
44
|
+
private_class_method :of, :newly_finished, :ended
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "command_support"
|
|
5
|
+
require_relative "snapshots"
|
|
6
|
+
require_relative "events"
|
|
7
|
+
require_relative "waiting"
|
|
8
|
+
|
|
9
|
+
module FunCi
|
|
10
|
+
module Agent
|
|
11
|
+
# `fun-ci events [--follow] [--only failures]` (acceptance-tests.md,
|
|
12
|
+
# AT-9.16): one JSON line per event of the project's recent runs, then,
|
|
13
|
+
# with --follow, each new one as it happens, until interrupted.
|
|
14
|
+
class EventsCommand
|
|
15
|
+
include CommandSupport
|
|
16
|
+
|
|
17
|
+
NAME = "events"
|
|
18
|
+
SCHEMA = 1
|
|
19
|
+
RECENT_RUNS = 10
|
|
20
|
+
|
|
21
|
+
def initialize(context)
|
|
22
|
+
@context = context
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run(args)
|
|
26
|
+
options = Options.parse(args, takes: %i[follow only json])
|
|
27
|
+
seen = emit({}, options.only)
|
|
28
|
+
follow(seen, options.only) if options.follow
|
|
29
|
+
0
|
|
30
|
+
rescue Options::Invalid => e
|
|
31
|
+
usage(e.message)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def follow(seen, only)
|
|
37
|
+
loop do
|
|
38
|
+
@context.clock.pause(Waiting::POLL_SECONDS)
|
|
39
|
+
@context.pipeline.watch(@context.db)
|
|
40
|
+
seen = emit(seen, only)
|
|
41
|
+
end
|
|
42
|
+
rescue Interrupt
|
|
43
|
+
# Following ends when whoever follows stops it.
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Prints what happened since `before`, and answers the look it took.
|
|
47
|
+
def emit(before, only)
|
|
48
|
+
after = snapshots.take(limit: RECENT_RUNS)
|
|
49
|
+
Events.between(before, after).select { |event| !only || Events.failure?(event) }.each { |event| print(event) }
|
|
50
|
+
after
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def print(event)
|
|
54
|
+
@context.io.stdout.puts JSON.generate({ schema: SCHEMA, **event })
|
|
55
|
+
@context.io.stdout.flush
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def snapshots = @snapshots ||= Snapshots.new(@context.db, @context.git)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Agent
|
|
5
|
+
# A stage's evidence as text: its facts, its failures, each with its whole
|
|
6
|
+
# message, each excerpt under its title, each naming the extractor that
|
|
7
|
+
# found it, then the problems extractors had.
|
|
8
|
+
module EvidenceText
|
|
9
|
+
def self.lines(document)
|
|
10
|
+
facts(document.facts) + failures(document.failures) +
|
|
11
|
+
document.excerpts.flat_map { |excerpt| excerpt(excerpt) } + problems(document.problems)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.facts(facts)
|
|
15
|
+
facts.empty? ? [] : ["", "Facts:", *facts.map { |fact| " #{fact[:name]}: #{fact[:value]}" }]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.problems(problems)
|
|
19
|
+
return [] if problems.empty?
|
|
20
|
+
|
|
21
|
+
lines = problems.map { |problem| " #{problem[:extractor]}: #{problem[:message]}" }
|
|
22
|
+
["", "Problems collecting the evidence:", *lines]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.failures(failures)
|
|
26
|
+
failures.group_by { |failure| failure[:extractor] }.flat_map do |extractor, found|
|
|
27
|
+
["", "Failures, from #{extractor}:", *found.flat_map { |failure| failure(failure) }]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.failure(failure)
|
|
32
|
+
place = failure[:file] ? "#{failure[:file]}:#{failure[:line]} #{failure[:test]}" : failure[:test]
|
|
33
|
+
[" #{place}", *indented(failure[:message], " "), *own_output(failure[:output])]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.own_output(output) = output.to_s.empty? ? [] : [" Output:", *indented(output, " ")]
|
|
37
|
+
def self.indented(text, margin) = text.to_s.lines(chomp: true).map { |line| "#{margin}#{line}" }
|
|
38
|
+
|
|
39
|
+
def self.excerpt(excerpt)
|
|
40
|
+
place = excerpt[:location] == "output" ? "" : " (#{excerpt[:location]})"
|
|
41
|
+
["", "#{excerpt[:title]}#{place}, from #{excerpt[:extractor]}:", *excerpt[:lines].map { |line| " #{line}" }]
|
|
42
|
+
end
|
|
43
|
+
private_class_method :facts, :problems, :failures, :failure, :own_output, :indented, :excerpt
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Agent
|
|
5
|
+
# What `status` and `wait` exit with (acceptance-tests.md, §9).
|
|
6
|
+
module ExitCode
|
|
7
|
+
FOR = { passed: 0, failed: 1, over_budget: 2, undecided: 3, superseded: 4, unknown: 5 }.freeze
|
|
8
|
+
USAGE = 64
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require_relative "../pipeline/git_environment"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Agent
|
|
8
|
+
# The project's git, as agent commands ask it: which commit a revision
|
|
9
|
+
# names, that commit's subject, and the directory the project is in.
|
|
10
|
+
class Git
|
|
11
|
+
def initialize(dir)
|
|
12
|
+
@dir = dir
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def resolve(rev) = git("rev-parse", "--verify", "--quiet", "#{rev}^{commit}")
|
|
16
|
+
def subject(sha) = git("log", "-1", "--format=%s", sha).to_s
|
|
17
|
+
def toplevel = File.realpath(git("rev-parse", "--show-toplevel"))
|
|
18
|
+
|
|
19
|
+
# The branch checked out, as the git hooks name it: "" when HEAD is
|
|
20
|
+
# detached, which trigger names (Pipeline::Commit).
|
|
21
|
+
def branch = git("branch", "--show-current").to_s
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# Its first line of output, or nil when git fails.
|
|
26
|
+
def git(*)
|
|
27
|
+
output, status = Open3.capture2(Pipeline::GitEnvironment::CLEAN, "git", *, chdir: @dir, err: File::NULL)
|
|
28
|
+
status.success? ? output.lines.first&.chomp : nil
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
require_relative "../pipeline/run_canceller"
|
|
5
|
+
require_relative "../setup/project_config"
|
|
6
|
+
|
|
7
|
+
module FunCi
|
|
8
|
+
module Agent
|
|
9
|
+
# The project's pipeline, as `wait` starts and watches it: a run starts
|
|
10
|
+
# the way the post-commit hook starts one, with `fun-ci trigger
|
|
11
|
+
# --background` in a process of its own, rather than as a fork of the
|
|
12
|
+
# waiting process and its open database connection.
|
|
13
|
+
class LivePipeline
|
|
14
|
+
FUN_CI = File.expand_path("../../../exe/fun-ci", __dir__)
|
|
15
|
+
|
|
16
|
+
# command: what runs fun-ci, the gem's own executable by default.
|
|
17
|
+
def initialize(project_dir, command: [RbConfig.ruby, FUN_CI])
|
|
18
|
+
@project_dir = project_dir
|
|
19
|
+
@command = command
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Answers the finished starter, or nil in a project not set up for fun-ci.
|
|
23
|
+
def start(sha, branch)
|
|
24
|
+
return unless Setup::ProjectConfig.new(@project_dir).validate.empty?
|
|
25
|
+
|
|
26
|
+
pid = Process.spawn(*@command, "trigger", "--background", sha, branch,
|
|
27
|
+
chdir: @project_dir, %i[out err] => File::NULL)
|
|
28
|
+
Process.detach(pid).join
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Records failed each slow suite whose process died (acceptance-tests.md, AT-8.3).
|
|
32
|
+
def watch(db) = Pipeline::RunCanceller.new.record_dead(db)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "verdict"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Agent
|
|
8
|
+
# The arguments of an agent command: at most one revision, then, for a
|
|
9
|
+
# command that takes :stage, a stage's name, and whichever of the switches
|
|
10
|
+
# below the command takes.
|
|
11
|
+
module Options
|
|
12
|
+
class Invalid < StandardError; end
|
|
13
|
+
|
|
14
|
+
Parsed = Data.define(:rev, :stage, :need, :json, :within, :follow_branch, :limit, :branch, :follow, :only, :raw)
|
|
15
|
+
DEFAULTS = { need: "fast", json: false, within: nil, follow_branch: false, limit: 10, branch: nil,
|
|
16
|
+
follow: false, only: nil, raw: false }.freeze
|
|
17
|
+
SWITCHES = { need: ["--need LEVEL"], json: ["--json"], within: ["--within DURATION"], raw: ["--raw"],
|
|
18
|
+
follow_branch: ["--follow-branch"], limit: ["-n N", Integer], branch: ["--branch NAME"],
|
|
19
|
+
follow: ["--follow"], only: ["--only KIND"] }.freeze
|
|
20
|
+
FILTERS = %w[failures].freeze
|
|
21
|
+
MULTIPLIERS = { "" => 1, "s" => 1, "m" => 60 }.freeze
|
|
22
|
+
|
|
23
|
+
STAGES = %w[lint build fast slow].freeze
|
|
24
|
+
|
|
25
|
+
def self.parse(args, takes:)
|
|
26
|
+
values = DEFAULTS.dup
|
|
27
|
+
revs = parser(values, takes).parse(args)
|
|
28
|
+
stage = takes.include?(:stage) && STAGES.include?(revs.last) ? revs.pop : nil
|
|
29
|
+
raise Invalid, "one revision at a time, not #{revs.join(" ")}" if revs.size > 1
|
|
30
|
+
|
|
31
|
+
Parsed.new(rev: revs.first || "HEAD", stage: stage, **values)
|
|
32
|
+
rescue OptionParser::ParseError => e
|
|
33
|
+
raise Invalid, e.message
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Without OptionParser's own --help and --version (long options only), which print and exit.
|
|
37
|
+
def self.parser(values, takes)
|
|
38
|
+
parser = OptionParser.new
|
|
39
|
+
parser.base.long.clear
|
|
40
|
+
(takes & SWITCHES.keys).each do |key|
|
|
41
|
+
parser.on(*SWITCHES.fetch(key)) { |value| values[key] = convert(key, value) }
|
|
42
|
+
end
|
|
43
|
+
parser
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.convert(key, value)
|
|
47
|
+
return level(value) if key == :need
|
|
48
|
+
return seconds(value) if key == :within
|
|
49
|
+
return filter(value) if key == :only
|
|
50
|
+
|
|
51
|
+
value
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.level(value)
|
|
55
|
+
return value if Verdict::LEVELS.key?(value)
|
|
56
|
+
|
|
57
|
+
raise Invalid, "unknown level '#{value}': use build, fast or all"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.filter(value)
|
|
61
|
+
return value if FILTERS.include?(value)
|
|
62
|
+
|
|
63
|
+
raise Invalid, "unknown filter '#{value}': use #{FILTERS.join(", ")}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.seconds(value)
|
|
67
|
+
match = /\A(\d+)([sm]?)\z/.match(value)
|
|
68
|
+
raise Invalid, "can't read the deadline '#{value}': use 30, 30s or 5m" unless match
|
|
69
|
+
|
|
70
|
+
match[1].to_i * MULTIPLIERS.fetch(match[2])
|
|
71
|
+
end
|
|
72
|
+
private_class_method :parser, :convert, :level, :filter, :seconds
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "status_text"
|
|
5
|
+
require_relative "why_text"
|
|
6
|
+
require_relative "why_json"
|
|
7
|
+
require_relative "status_json"
|
|
8
|
+
require_relative "runs_text"
|
|
9
|
+
require_relative "exit_code"
|
|
10
|
+
|
|
11
|
+
module FunCi
|
|
12
|
+
module Agent
|
|
13
|
+
# Prints what an agent command found, as text or, with --json, as JSON.
|
|
14
|
+
class Output
|
|
15
|
+
NO_STAGE = "No stage it needs failed or ran over budget; name one to see it: fun-ci why REV STAGE"
|
|
16
|
+
|
|
17
|
+
def initialize(stdout, json:)
|
|
18
|
+
@stdout = stdout
|
|
19
|
+
@json = json
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def report(report)
|
|
23
|
+
@json ? print_json(StatusJson.document(report)) : print_lines(StatusText.lines(report))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Everything kept about the stage named, or says no stage decided the verdict.
|
|
27
|
+
def why(report, stage_name)
|
|
28
|
+
stage = report.stages.find { |candidate| candidate.name == stage_name }
|
|
29
|
+
return print_json(WhyJson.document(report, stage)) if @json
|
|
30
|
+
return print_lines([StatusText.header(report), NO_STAGE]) unless stage
|
|
31
|
+
|
|
32
|
+
print_lines(WhyText.lines(report, stage))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Says the commit has no run, and answers the exit code for that.
|
|
36
|
+
def unknown(sha)
|
|
37
|
+
@json ? print_json(StatusJson.unknown(sha)) : print_lines(["fun-ci: no run for #{sha[0, 7]} in this project."])
|
|
38
|
+
ExitCode::FOR.fetch(:unknown)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Says the commit has no run and none can start, which leaves it unknown.
|
|
42
|
+
def not_set_up(sha)
|
|
43
|
+
if @json
|
|
44
|
+
print_json(StatusJson.unknown(sha))
|
|
45
|
+
else
|
|
46
|
+
print_lines(["fun-ci: no run for #{sha[0, 7]}, and this project isn't set up to start one."])
|
|
47
|
+
end
|
|
48
|
+
ExitCode::FOR.fetch(:unknown)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Says the commit has no run yet, which leaves it undecided.
|
|
52
|
+
def no_run_yet(sha)
|
|
53
|
+
if @json
|
|
54
|
+
print_json(StatusJson.unknown(sha).merge(verdict: "undecided"))
|
|
55
|
+
else
|
|
56
|
+
print_lines(["fun-ci: no run for #{sha[0, 7]} yet."])
|
|
57
|
+
end
|
|
58
|
+
ExitCode::FOR.fetch(:undecided)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Says the wait moves on to the commit that superseded `sha`; JSON has only the answer.
|
|
62
|
+
def following(sha, report)
|
|
63
|
+
return if @json
|
|
64
|
+
|
|
65
|
+
@stdout.puts "fun-ci: #{sha[0, 7]} was superseded; following #{report.branch} to #{report.superseded_by[0, 7]}."
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# entries: [[report, age in words], ...], newest first.
|
|
69
|
+
def runs(entries)
|
|
70
|
+
return print_json(entries.map { |report, _| StatusJson.document(report) }) if @json
|
|
71
|
+
|
|
72
|
+
print_lines(RunsText.lines(entries))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def print_json(document) = @stdout.puts(JSON.generate(document))
|
|
78
|
+
def print_lines(lines) = lines.each { |line| @stdout.puts line }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../evidence/document"
|
|
5
|
+
require_relative "../persistence/stage_job"
|
|
6
|
+
|
|
7
|
+
module FunCi
|
|
8
|
+
module Agent
|
|
9
|
+
RunReport = Data.define(:sha, :subject, :branch, :need, :stages, :verdict, :deciding, :superseded_by)
|
|
10
|
+
|
|
11
|
+
# One stage of a run, as an agent is told it.
|
|
12
|
+
class RunReport
|
|
13
|
+
STATES = { "completed" => "passed", "timed_out" => "over_budget", "scheduled" => "waiting" }.freeze
|
|
14
|
+
|
|
15
|
+
# What a failed stage left to explain itself: the end of its output,
|
|
16
|
+
# and the failures it reported, each { file:, line:, test:, message: },
|
|
17
|
+
# unless they were pruned; the evidence document, if it kept one; and
|
|
18
|
+
# how many bytes of raw output are kept, if any.
|
|
19
|
+
Kept = Data.define(:tail, :failures, :pruned, :evidence, :raw_bytes)
|
|
20
|
+
|
|
21
|
+
class Kept
|
|
22
|
+
def initialize(pruned: false, evidence: nil, raw_bytes: nil, **) = super
|
|
23
|
+
|
|
24
|
+
def document
|
|
25
|
+
evidence ? Evidence::Document.from_json(evidence) : Evidence::Document.legacy(tail: tail, failures: failures)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
NOTHING_KEPT = Kept.new(tail: nil, failures: [])
|
|
30
|
+
|
|
31
|
+
# How a finished stage's process ended, and the budget it had.
|
|
32
|
+
Exit = Data.define(:exit_status, :signal, :budget)
|
|
33
|
+
NO_EXIT = Exit.new(exit_status: nil, signal: nil, budget: nil)
|
|
34
|
+
|
|
35
|
+
# id: the stage's row, nil for a stage that hasn't started.
|
|
36
|
+
Stage = Data.define(:name, :state, :seconds, :kept, :exit, :id)
|
|
37
|
+
|
|
38
|
+
class Stage
|
|
39
|
+
def initialize(kept: NOTHING_KEPT, exit: NO_EXIT, id: nil, **) = super
|
|
40
|
+
def exit_status = exit.exit_status
|
|
41
|
+
def signal = exit.signal
|
|
42
|
+
def budget = exit.budget
|
|
43
|
+
def evidence = kept.document
|
|
44
|
+
def pruned? = kept.pruned
|
|
45
|
+
def raw_bytes = kept.raw_bytes
|
|
46
|
+
def tail = kept.tail
|
|
47
|
+
# The failures its evidence names, from whichever extractor found them.
|
|
48
|
+
def failures = evidence.failures.map { |failure| failure.except(:extractor) }
|
|
49
|
+
|
|
50
|
+
def self.from_row(name, job)
|
|
51
|
+
return new(name: name, state: "waiting", seconds: nil) unless job
|
|
52
|
+
|
|
53
|
+
ended = Exit.new(exit_status: job[:exit_status], signal: job[:signal], budget: job[:budget])
|
|
54
|
+
new(name: name, state: STATES.fetch(job[:status], job[:status]), seconds: seconds(job), id: job[:id],
|
|
55
|
+
kept: kept(job), exit: ended)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.kept(job)
|
|
59
|
+
failures = job[:failures] ? JSON.parse(job[:failures], symbolize_names: true) : []
|
|
60
|
+
Kept.new(tail: job[:output_tail], failures: failures, pruned: job[:pruned] == 1, evidence: job[:evidence],
|
|
61
|
+
raw_bytes: job[:raw_bytes])
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.seconds(job)
|
|
65
|
+
Persistence::StageJob.elapsed_duration(job)&.round(1)
|
|
66
|
+
end
|
|
67
|
+
private_class_method :kept, :seconds
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../persistence/project_runs"
|
|
4
|
+
require_relative "../persistence/stage_job"
|
|
5
|
+
require_relative "../persistence/raw_outputs"
|
|
6
|
+
require_relative "run_report"
|
|
7
|
+
|
|
8
|
+
module FunCi
|
|
9
|
+
module Agent
|
|
10
|
+
# The runs of the project git names, as an agent is told them.
|
|
11
|
+
class Reports
|
|
12
|
+
def initialize(db, git)
|
|
13
|
+
@db = db
|
|
14
|
+
@git = git
|
|
15
|
+
@runs = Persistence::ProjectRuns.new(db, git.toplevel)
|
|
16
|
+
@raw = Persistence::RawOutputs.beside(db.filename("main"))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# What the stage kept of its raw output, or nil.
|
|
20
|
+
def raw_output(stage) = stage.id && @raw.read(stage.id)
|
|
21
|
+
|
|
22
|
+
# The commit's newest run, or nil.
|
|
23
|
+
def for(sha, need)
|
|
24
|
+
run = @runs.latest_of_commit(sha)
|
|
25
|
+
run && of(run, need)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Notes that an agent is waiting on the commit's newest run, as of `at`.
|
|
29
|
+
def mark_waited(sha, at)
|
|
30
|
+
run = @runs.latest_of_commit(sha)
|
|
31
|
+
Persistence::PipelineRun.mark_waited(@db, run[:id], at.utc.iso8601) if run
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# [[run, its report for the whole pipeline], ...], newest first.
|
|
35
|
+
def recent(limit:, branch:)
|
|
36
|
+
@runs.recent(limit: limit, branch: branch).map { |run| [run, of(run, "all")] }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def of(run, need)
|
|
40
|
+
superseded_by = run[:status] == "cancelled" ? @runs.superseded_by(run) : nil
|
|
41
|
+
jobs = Persistence::StageJob.for_run(@db, run[:id]).map { |job| job.merge(raw_bytes: @raw.bytes(job[:id])) }
|
|
42
|
+
RunReport.build(run: run, jobs: jobs, need: need,
|
|
43
|
+
commit: { subject: @git.subject(run[:commit_hash]), superseded_by: superseded_by })
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "verdict"
|
|
4
|
+
require_relative "report_stage"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Agent
|
|
8
|
+
# What an agent is told about one run: its commit, each of the four stages
|
|
9
|
+
# in pipeline order, and the verdict for the level it needs.
|
|
10
|
+
class RunReport
|
|
11
|
+
STAGES = %w[lint build fast slow].freeze
|
|
12
|
+
|
|
13
|
+
# deciding: the stage whose failure or overrun decided the verdict, if one did.
|
|
14
|
+
def initialize(deciding: nil, **) = super
|
|
15
|
+
|
|
16
|
+
# commit: the run's subject and the commit that superseded it, if any.
|
|
17
|
+
def self.build(run:, jobs:, need:, commit:)
|
|
18
|
+
verdict = Verdict.decide(run_status: run[:status], stages: jobs, need: need)
|
|
19
|
+
new(sha: run[:commit_hash], branch: run[:branch], need: need, stages: stages(jobs), verdict: verdict,
|
|
20
|
+
deciding: Verdict.deciding_stage(stages: jobs, need: need), **commit)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The four stages in pipeline order, from the run's stage rows.
|
|
24
|
+
def self.stages(jobs)
|
|
25
|
+
by_name = jobs.to_h { |job| [job[:stage], job] }
|
|
26
|
+
STAGES.map { |name| Stage.from_row(name, by_name[name]) }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "command_support"
|
|
5
|
+
require_relative "output"
|
|
6
|
+
require_relative "age"
|
|
7
|
+
|
|
8
|
+
module FunCi
|
|
9
|
+
module Agent
|
|
10
|
+
# `fun-ci runs [-n N] [--branch NAME] [--json]` (acceptance-tests.md, AT-9.4).
|
|
11
|
+
class RunsCommand
|
|
12
|
+
include CommandSupport
|
|
13
|
+
|
|
14
|
+
NAME = "runs"
|
|
15
|
+
|
|
16
|
+
def initialize(context)
|
|
17
|
+
@context = context
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run(args)
|
|
21
|
+
options = Options.parse(args, takes: %i[limit branch json])
|
|
22
|
+
entries = reports.recent(limit: options.limit, branch: options.branch).map { |run, report| [report, age(run)] }
|
|
23
|
+
Output.new(@context.io.stdout, json: options.json).runs(entries)
|
|
24
|
+
0
|
|
25
|
+
rescue Options::Invalid => e
|
|
26
|
+
usage(e.message)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def age(run) = Age.words(@context.clock.now - Time.parse(run[:created_at]))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "run_report"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Agent
|
|
7
|
+
# One line per run, the columns lined up: short SHA, branch, age, each
|
|
8
|
+
# stage's outcome in a word of four letters at most, and the subject.
|
|
9
|
+
module RunsText
|
|
10
|
+
WORDS = { "passed" => "ok", "failed" => "FAIL", "over_budget" => "OVER", "running" => "...", "waiting" => "-",
|
|
11
|
+
"cancelled" => "x" }.freeze
|
|
12
|
+
|
|
13
|
+
# entries: [[report, age in words], ...], newest first.
|
|
14
|
+
def self.lines(entries)
|
|
15
|
+
widths = [entries.map { |report, _| report.branch.size }.max, entries.map { |_, age| age.size }.max]
|
|
16
|
+
entries.map { |report, age| line(report, age, widths) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.line(report, age, (branch_width, age_width))
|
|
20
|
+
stages = report.stages.map { |stage| "#{stage.name} #{WORDS.fetch(stage.state).ljust(4)}" }.join(" ")
|
|
21
|
+
"#{report.sha[0,
|
|
22
|
+
7]} #{report.branch.ljust(branch_width)} #{age.ljust(age_width)} #{stages} #{report.subject}"
|
|
23
|
+
end
|
|
24
|
+
private_class_method :line
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|