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,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../persistence/project_runs"
|
|
4
|
+
require_relative "../persistence/stage_job"
|
|
5
|
+
require_relative "run_report"
|
|
6
|
+
require_relative "events"
|
|
7
|
+
|
|
8
|
+
module FunCi
|
|
9
|
+
module Agent
|
|
10
|
+
# Looks at the newest runs of the project git names, as `events` compares
|
|
11
|
+
# them: { run id => Events::RunState }.
|
|
12
|
+
class Snapshots
|
|
13
|
+
def initialize(db, git)
|
|
14
|
+
@db = db
|
|
15
|
+
@runs = Persistence::ProjectRuns.new(db, git.toplevel)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def take(limit:)
|
|
19
|
+
@runs.recent(limit: limit, branch: nil).to_h { |run| [run[:id], state_of(run)] }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def state_of(run)
|
|
25
|
+
superseded_by = run[:status] == "cancelled" ? @runs.superseded_by(run) : nil
|
|
26
|
+
Events::RunState.new(id: run[:id], sha: run[:commit_hash], branch: run[:branch], status: run[:status],
|
|
27
|
+
finished: finished(run), superseded_by: superseded_by)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def finished(run)
|
|
31
|
+
jobs = Persistence::StageJob.for_run(@db, run[:id]).select { |job| job[:finished_order] }
|
|
32
|
+
stages = RunReport.stages(jobs).to_h { |stage| [stage.name, stage] }
|
|
33
|
+
jobs.sort_by { |job| job[:finished_order] }.map { |job| facts(stages.fetch(job[:stage])) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def facts(stage) = { stage: stage.name, state: stage.state, seconds: stage.seconds }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Agent
|
|
5
|
+
# One line saying how a stage ended: its state, its exit status or the
|
|
6
|
+
# signal that ended it, how long it took and its budget, as far as known.
|
|
7
|
+
module StageSummary
|
|
8
|
+
WORDS = { "over_budget" => "ran over budget" }.freeze
|
|
9
|
+
|
|
10
|
+
def self.line(stage)
|
|
11
|
+
"#{stage.name} #{WORDS.fetch(stage.state, stage.state)}#{ending(stage)}#{took(stage)}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.ending(stage)
|
|
15
|
+
return " (exit #{stage.exit_status})" if stage.exit_status
|
|
16
|
+
return " (killed by SIG#{stage.signal})" if stage.signal
|
|
17
|
+
|
|
18
|
+
""
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.took(stage)
|
|
22
|
+
return "" unless stage.seconds
|
|
23
|
+
|
|
24
|
+
budget = stage.budget ? ", budget #{stage.budget}s" : ""
|
|
25
|
+
" after #{stage.seconds}s#{budget}"
|
|
26
|
+
end
|
|
27
|
+
private_class_method :ending, :took
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "command_support"
|
|
4
|
+
require_relative "output"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Agent
|
|
8
|
+
# `fun-ci status [REV] [--need LEVEL] [--json]` (acceptance-tests.md, AT-9.2).
|
|
9
|
+
class StatusCommand
|
|
10
|
+
include CommandSupport
|
|
11
|
+
|
|
12
|
+
NAME = "status"
|
|
13
|
+
|
|
14
|
+
def initialize(context)
|
|
15
|
+
@context = context
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run(args)
|
|
19
|
+
options = Options.parse(args, takes: %i[need json])
|
|
20
|
+
sha = resolve(options.rev)
|
|
21
|
+
answer(sha, reports.for(sha, options.need), Output.new(@context.io.stdout, json: options.json))
|
|
22
|
+
rescue Options::Invalid => e
|
|
23
|
+
usage(e.message)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def answer(sha, report, output)
|
|
29
|
+
return output.unknown(sha) unless report
|
|
30
|
+
|
|
31
|
+
output.report(report)
|
|
32
|
+
ExitCode::FOR.fetch(report.verdict)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "run_report"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Agent
|
|
7
|
+
# A run report as the JSON document of acceptance-tests.md, AT-9.3.
|
|
8
|
+
# Fields once published keep their names; a change bumps SCHEMA.
|
|
9
|
+
module StatusJson
|
|
10
|
+
SCHEMA = 1
|
|
11
|
+
|
|
12
|
+
def self.document(report)
|
|
13
|
+
{ schema: SCHEMA, commit: { sha: report.sha, branch: report.branch, subject: report.subject },
|
|
14
|
+
need: report.need, verdict: report.verdict.to_s, stages: report.stages.map { |stage| stage(stage) },
|
|
15
|
+
superseded_by: report.superseded_by }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.unknown(sha) = { schema: SCHEMA, commit: { sha: sha }, verdict: "unknown" }
|
|
19
|
+
|
|
20
|
+
def self.stage(stage)
|
|
21
|
+
facts = { name: stage.name, state: stage.state, seconds: stage.seconds }
|
|
22
|
+
stage.failures.any? ? facts.merge(failures: stage.failures) : facts
|
|
23
|
+
end
|
|
24
|
+
private_class_method :stage
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "run_report"
|
|
4
|
+
require_relative "verdict"
|
|
5
|
+
require_relative "digest"
|
|
6
|
+
|
|
7
|
+
module FunCi
|
|
8
|
+
module Agent
|
|
9
|
+
# A run report as text: the commit, a line per stage with what went wrong
|
|
10
|
+
# in capitals, then the evidence of each needed stage that failed.
|
|
11
|
+
module StatusText
|
|
12
|
+
WORDS = { "failed" => "FAILED", "over_budget" => "OVER BUDGET" }.freeze
|
|
13
|
+
|
|
14
|
+
def self.lines(report)
|
|
15
|
+
needed = Verdict::LEVELS.fetch(report.need)
|
|
16
|
+
[header(report), *report.stages.map { |stage| stage_line(stage, needed) }, *Digest.lines(report.stages, needed),
|
|
17
|
+
*footer(report)]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.header(report) = %(fun-ci: #{report.sha[0, 7]} "#{report.subject}" on #{report.branch})
|
|
21
|
+
|
|
22
|
+
def self.stage_line(stage, needed)
|
|
23
|
+
seconds = stage.seconds ? format("%6.1fs", stage.seconds) : " " * 7
|
|
24
|
+
note = needed.include?(stage.name) ? "" : " (not needed)"
|
|
25
|
+
" #{stage.name.ljust(6)} #{WORDS.fetch(stage.state, stage.state).ljust(12)}#{seconds}#{note}".rstrip
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.footer(report)
|
|
29
|
+
return ["fun-ci why #{report.sha[0, 7]} #{report.deciding}"] if report.deciding
|
|
30
|
+
return [] unless report.verdict == :superseded && report.superseded_by
|
|
31
|
+
|
|
32
|
+
["Superseded by #{report.superseded_by[0, 7]}."]
|
|
33
|
+
end
|
|
34
|
+
private_class_method :stage_line, :footer
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Agent
|
|
5
|
+
# What a run means for the level an agent needs (acceptance-tests.md,
|
|
6
|
+
# §9): the stages it needs decide, in the order they finished, and a
|
|
7
|
+
# cancelled run is superseded only while they haven't.
|
|
8
|
+
module Verdict
|
|
9
|
+
LEVELS = { "build" => %w[lint build], "fast" => %w[lint build fast], "all" => %w[lint build fast slow] }.freeze
|
|
10
|
+
FINISHED_BADLY = { "failed" => :failed, "timed_out" => :over_budget }.freeze
|
|
11
|
+
|
|
12
|
+
def self.decide(run_status:, stages:, need:)
|
|
13
|
+
needed = needed(stages, need)
|
|
14
|
+
first_bad = first_finished_badly(needed)
|
|
15
|
+
return FINISHED_BADLY.fetch(first_bad[:status]) if first_bad
|
|
16
|
+
return :passed if needed.count { |stage| stage[:status] == "completed" } == LEVELS.fetch(need).size
|
|
17
|
+
|
|
18
|
+
run_status == "cancelled" ? :superseded : :undecided
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The name of the stage whose failure or overrun decided the verdict, or nil.
|
|
22
|
+
def self.deciding_stage(stages:, need:) = first_finished_badly(needed(stages, need))&.fetch(:stage)
|
|
23
|
+
|
|
24
|
+
def self.needed(stages, need) = stages.select { |stage| LEVELS.fetch(need).include?(stage[:stage]) }
|
|
25
|
+
|
|
26
|
+
def self.first_finished_badly(stages)
|
|
27
|
+
stages.select { |stage| FINISHED_BADLY.key?(stage[:status]) }.min_by { |stage| stage[:finished_order] }
|
|
28
|
+
end
|
|
29
|
+
private_class_method :needed, :first_finished_badly
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "command_support"
|
|
4
|
+
require_relative "output"
|
|
5
|
+
require_relative "waiting"
|
|
6
|
+
|
|
7
|
+
module FunCi
|
|
8
|
+
module Agent
|
|
9
|
+
# `fun-ci wait [REV] [--need LEVEL] [--within DURATION] [--follow-branch] [--json]`
|
|
10
|
+
# (acceptance-tests.md, AT-9.9 to AT-9.12).
|
|
11
|
+
class WaitCommand
|
|
12
|
+
include CommandSupport
|
|
13
|
+
|
|
14
|
+
NAME = "wait"
|
|
15
|
+
# How long a commit may go without a run before `wait` starts one; the
|
|
16
|
+
# post-commit hook's run for a commit just made turns up well within it.
|
|
17
|
+
GRACE_SECONDS = 5
|
|
18
|
+
# The answer when no run turned up and the project can't start one.
|
|
19
|
+
NOT_SET_UP = Data.define(:verdict).new(verdict: :unknown)
|
|
20
|
+
|
|
21
|
+
def initialize(context)
|
|
22
|
+
@context = context
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run(args)
|
|
26
|
+
options = Options.parse(args, takes: %i[need json within follow_branch])
|
|
27
|
+
sha = resolve(options.rev)
|
|
28
|
+
output = Output.new(@context.io.stdout, json: options.json)
|
|
29
|
+
answer(sha, wait_for(sha, options, deadline(options), output), output)
|
|
30
|
+
rescue Options::Invalid => e
|
|
31
|
+
usage(e.message)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def deadline(options) = options.within && (@context.clock.now + options.within)
|
|
37
|
+
|
|
38
|
+
# Follows a superseded run on to the commit that superseded it, when asked to.
|
|
39
|
+
def wait_for(sha, options, deadline, output)
|
|
40
|
+
report = Waiting.new(@context.clock, deadline: deadline) { poll(sha, options.need) }.until_decided
|
|
41
|
+
return report unless options.follow_branch && report&.superseded_by
|
|
42
|
+
|
|
43
|
+
output.following(sha, report)
|
|
44
|
+
wait_for(report.superseded_by, options, deadline, output)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def answer(sha, report, output)
|
|
48
|
+
return output.no_run_yet(sha) unless report
|
|
49
|
+
return output.not_set_up(sha) if report.equal?(NOT_SET_UP)
|
|
50
|
+
|
|
51
|
+
output.report(report)
|
|
52
|
+
ExitCode::FOR.fetch(report.verdict)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def poll(sha, need)
|
|
56
|
+
@context.pipeline.watch(@context.db)
|
|
57
|
+
reports.mark_waited(sha, @context.clock.now)
|
|
58
|
+
reports.for(sha, need) || start_after_grace(sha)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Nil while waiting for a run; NOT_SET_UP once one can't be started.
|
|
62
|
+
def start_after_grace(sha)
|
|
63
|
+
@missing_since ||= @context.clock.now
|
|
64
|
+
return if @started || @context.clock.now - @missing_since < GRACE_SECONDS
|
|
65
|
+
|
|
66
|
+
@started = true
|
|
67
|
+
@context.pipeline.start(sha, @context.git.branch) ? nil : NOT_SET_UP
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Agent
|
|
5
|
+
# Polls until the answer is decided, or the deadline (a Time; nil for none) has passed
|
|
6
|
+
# (acceptance-tests.md, AT-9.9, AT-9.10). An answer is decided once its
|
|
7
|
+
# verdict is anything but undecided; nil (no run yet) is undecided.
|
|
8
|
+
class Waiting
|
|
9
|
+
POLL_SECONDS = 1
|
|
10
|
+
|
|
11
|
+
def initialize(clock, deadline:, &poll)
|
|
12
|
+
@clock = clock
|
|
13
|
+
@deadline = deadline
|
|
14
|
+
@poll = poll
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def until_decided
|
|
18
|
+
loop do
|
|
19
|
+
answer = @poll.call
|
|
20
|
+
return answer if decided?(answer) || out_of_time?
|
|
21
|
+
|
|
22
|
+
@clock.pause(POLL_SECONDS)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def decided?(answer) = answer && answer.verdict != :undecided
|
|
29
|
+
def out_of_time? = @deadline && @clock.now >= @deadline
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "command_support"
|
|
4
|
+
require_relative "output"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Agent
|
|
8
|
+
# `fun-ci why [REV] [STAGE] [--need LEVEL] [--json] [--raw]`
|
|
9
|
+
# (acceptance-tests.md, AT-10.1, AT-10.6): everything kept about the stage
|
|
10
|
+
# named, or else about the stage that decided the verdict; with --raw, the
|
|
11
|
+
# raw output it kept, and nothing else.
|
|
12
|
+
class WhyCommand
|
|
13
|
+
include CommandSupport
|
|
14
|
+
|
|
15
|
+
NAME = "why"
|
|
16
|
+
|
|
17
|
+
def initialize(context)
|
|
18
|
+
@context = context
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run(args)
|
|
22
|
+
options = Options.parse(args, takes: %i[need json stage raw])
|
|
23
|
+
sha = resolve(options.rev)
|
|
24
|
+
answer(sha, reports.for(sha, options.need), options)
|
|
25
|
+
rescue Options::Invalid => e
|
|
26
|
+
usage(e.message)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def answer(sha, report, options)
|
|
32
|
+
output = Output.new(@context.io.stdout, json: options.json)
|
|
33
|
+
return output.unknown(sha) unless report
|
|
34
|
+
|
|
35
|
+
stage_name = options.stage || report.deciding
|
|
36
|
+
options.raw ? raw(report, stage_name) : output.why(report, stage_name)
|
|
37
|
+
ExitCode::FOR.fetch(report.verdict)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def raw(report, stage_name)
|
|
41
|
+
stage = report.stages.find { |candidate| candidate.name == stage_name }
|
|
42
|
+
text = stage && reports.raw_output(stage)
|
|
43
|
+
return @context.io.stdout.write(text) if text
|
|
44
|
+
|
|
45
|
+
@context.io.stderr.puts "fun-ci why: no raw output is kept for #{stage_name || "any stage"} of #{report.sha[0,
|
|
46
|
+
7]}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "run_report"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Agent
|
|
7
|
+
# `fun-ci why --json` (acceptance-tests.md, AT-10.2): the stage and how it
|
|
8
|
+
# ended, its evidence, or else why there is none. Fields once published
|
|
9
|
+
# keep their names; a change bumps SCHEMA.
|
|
10
|
+
module WhyJson
|
|
11
|
+
SCHEMA = 1
|
|
12
|
+
FAILED = %w[failed over_budget].freeze
|
|
13
|
+
|
|
14
|
+
def self.document(report, stage)
|
|
15
|
+
reason = no_evidence(report, stage)
|
|
16
|
+
{ schema: SCHEMA, commit: { sha: report.sha, branch: report.branch, subject: report.subject },
|
|
17
|
+
**ending(stage), evidence: reason ? nil : stage.evidence.to_h, no_evidence: reason,
|
|
18
|
+
raw_output: { kept: !stage&.raw_bytes.nil?, bytes: stage&.raw_bytes || 0 } }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.ending(stage)
|
|
22
|
+
{ stage: stage&.name, state: stage&.state, exit_status: stage&.exit_status, signal: stage&.signal,
|
|
23
|
+
seconds: stage&.seconds, budget: stage&.budget }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.no_evidence(report, stage)
|
|
27
|
+
return report.verdict == :passed ? "passed" : "running" unless stage
|
|
28
|
+
return stage.state unless FAILED.include?(stage.state)
|
|
29
|
+
|
|
30
|
+
stage.pruned? ? "pruned" : nil
|
|
31
|
+
end
|
|
32
|
+
private_class_method :ending, :no_evidence
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "status_text"
|
|
4
|
+
require_relative "stage_summary"
|
|
5
|
+
require_relative "evidence_text"
|
|
6
|
+
require_relative "../persistence/pipeline_recorder"
|
|
7
|
+
|
|
8
|
+
module FunCi
|
|
9
|
+
module Agent
|
|
10
|
+
# What `fun-ci why` prints about one stage (acceptance-tests.md, AT-10.1):
|
|
11
|
+
# the commit, how the stage ended, then everything kept about it.
|
|
12
|
+
module WhyText
|
|
13
|
+
PASSED = "Nothing is kept about a stage that passed."
|
|
14
|
+
PRUNED = "Its evidence is no longer kept: fun-ci keeps it for a project's " \
|
|
15
|
+
"#{Persistence::DbRecorder::KEPT_RUNS} newest runs.".freeze
|
|
16
|
+
|
|
17
|
+
ONLY_TAIL = "Only the output's last lines were kept; extractors under `evidence:` in .fun-ci/config keep more."
|
|
18
|
+
|
|
19
|
+
def self.lines(report, stage)
|
|
20
|
+
[StatusText.header(report), StageSummary.line(stage), *body(stage), *raw(report, stage), *only_tail(stage)]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# So an agent that reads it knows it can write the extractor itself.
|
|
24
|
+
def self.only_tail(stage)
|
|
25
|
+
evidence = stage.evidence
|
|
26
|
+
kept_more = evidence.failures.any? || evidence.excerpts.any? { |excerpt| excerpt[:extractor] != "output-tail" }
|
|
27
|
+
kept_more || evidence.excerpts.empty? ? [] : ["", ONLY_TAIL]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.raw(report, stage)
|
|
31
|
+
stage.raw_bytes ? ["", "The whole output: fun-ci why #{report.sha[0, 7]} #{stage.name} --raw"] : []
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.body(stage)
|
|
35
|
+
return [PASSED] if stage.state == "passed"
|
|
36
|
+
return [PRUNED] if stage.pruned?
|
|
37
|
+
|
|
38
|
+
EvidenceText.lines(stage.evidence)
|
|
39
|
+
end
|
|
40
|
+
private_class_method :body, :raw, :only_tail
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/fun_ci/cli.rb
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../fun_ci"
|
|
4
|
+
require_relative "cli_help"
|
|
5
|
+
require_relative "pipeline/trigger_params"
|
|
6
|
+
require_relative "persistence/state_dir"
|
|
7
|
+
require_relative "setup/commands"
|
|
8
|
+
|
|
9
|
+
module FunCi
|
|
10
|
+
class Cli
|
|
11
|
+
ROUTES = {
|
|
12
|
+
"trigger" => :run_trigger,
|
|
13
|
+
"console" => :run_console,
|
|
14
|
+
"init" => :run_init,
|
|
15
|
+
"install-hooks" => :run_install_hooks,
|
|
16
|
+
"check" => :run_check,
|
|
17
|
+
"prune" => :run_prune,
|
|
18
|
+
"extract" => :run_extract
|
|
19
|
+
}.freeze
|
|
20
|
+
AGENT_COMMANDS = %w[status runs wait events why].freeze
|
|
21
|
+
|
|
22
|
+
def self.default_db_dir = Persistence::StateDir.path(ENV)
|
|
23
|
+
|
|
24
|
+
def self.run(args, io: Pipeline::Io.new, handlers: {}, db_dir: default_db_dir)
|
|
25
|
+
new(io: io, handlers: handlers, db_dir: db_dir).run(args)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(io:, handlers:, db_dir:)
|
|
29
|
+
@io = io
|
|
30
|
+
@db_dir = db_dir
|
|
31
|
+
@handlers = handlers
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def run(args)
|
|
35
|
+
subcommand = args.first
|
|
36
|
+
return CliHelp.help(@io.stdout) if %w[-h --help].include?(subcommand)
|
|
37
|
+
return CliHelp.version(@io.stdout) if subcommand == "--version"
|
|
38
|
+
|
|
39
|
+
return unknown_command(subcommand) unless ROUTES.key?(subcommand) || AGENT_COMMANDS.include?(subcommand)
|
|
40
|
+
|
|
41
|
+
dispatch(subcommand, args.drop(1))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def dispatch(subcommand, args)
|
|
47
|
+
return @handlers[subcommand].call(args) if @handlers.key?(subcommand)
|
|
48
|
+
return run_agent(subcommand, args) if AGENT_COMMANDS.include?(subcommand)
|
|
49
|
+
|
|
50
|
+
send(ROUTES[subcommand], args)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def run_trigger(args)
|
|
54
|
+
require_relative "pipeline/trigger"
|
|
55
|
+
require_relative "persistence/pipeline_recorder"
|
|
56
|
+
db = setup_db
|
|
57
|
+
recorder = Persistence::DbRecorder.new(db)
|
|
58
|
+
Pipeline::Trigger.run_from_args(args, io: @io, recorder: recorder)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Only `console` needs the renderer, so only it fails without one.
|
|
62
|
+
def run_console(_args)
|
|
63
|
+
require_relative "console/launcher"
|
|
64
|
+
require_relative "console/renderer_lookup"
|
|
65
|
+
launch_console(Console::RendererLookup.default.path)
|
|
66
|
+
rescue Console::RendererLookup::Missing => e
|
|
67
|
+
@io.stderr.puts e.message
|
|
68
|
+
1
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def launch_console(renderer)
|
|
72
|
+
db = setup_db
|
|
73
|
+
Console::Launcher.run(renderer: renderer, db: db, project_dir: Dir.pwd, stderr: @io.stderr)
|
|
74
|
+
ensure
|
|
75
|
+
db&.close
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The mutation lane loads all of lib/ before it mutates, so it can't see
|
|
79
|
+
# a lazy require go missing (here and in run_prune); the process tests
|
|
80
|
+
# that run these commands load the files unmutated.
|
|
81
|
+
def run_agent(name, args)
|
|
82
|
+
require_relative "agent/commands" # mutineer:disable-line statement_removal
|
|
83
|
+
db = setup_db
|
|
84
|
+
Agent::Commands.run(name, args, agent_context(db))
|
|
85
|
+
ensure
|
|
86
|
+
db&.close
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def agent_context(db)
|
|
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))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def run_init(args) = setup_commands.init(args)
|
|
95
|
+
def run_install_hooks(args) = setup_commands.install_hooks(args)
|
|
96
|
+
def run_check(args) = setup_commands.check(args)
|
|
97
|
+
def setup_commands = Setup::Commands.new(Dir.pwd, @io.stdout)
|
|
98
|
+
|
|
99
|
+
def run_extract(args)
|
|
100
|
+
require_relative "evidence/extract_command"
|
|
101
|
+
Evidence::ExtractCommand.new(Dir.pwd, @io).run(args)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def run_prune(_args)
|
|
105
|
+
require_relative "pipeline/worktree_prune" # mutineer:disable-line statement_removal
|
|
106
|
+
removed = Pipeline::WorktreePrune.new(Pipeline::Worktrees.new(Dir.pwd)).run
|
|
107
|
+
@io.stdout.puts "Removed #{removed} fun-ci worktree#{"s" unless removed == 1}."
|
|
108
|
+
0
|
|
109
|
+
rescue Pipeline::WorktreePrune::Busy, Pipeline::Worktrees::GitError => e
|
|
110
|
+
@io.stderr.puts "fun-ci prune: #{e.message}"
|
|
111
|
+
1
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def setup_db
|
|
115
|
+
Persistence::StateDir.prepare(@db_dir)
|
|
116
|
+
db_path = File.join(@db_dir, "db.sqlite3")
|
|
117
|
+
db = Persistence::Database.connection(db_path)
|
|
118
|
+
Persistence::Database.migrate!(db)
|
|
119
|
+
db
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def unknown_command(subcommand)
|
|
123
|
+
@io.stderr.puts "fun-ci: unknown command '#{subcommand}'", "" if subcommand
|
|
124
|
+
@io.stderr.puts "Usage: fun-ci <command> [options]", "", "Run 'fun-ci --help' for available commands."
|
|
125
|
+
1
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
# What `fun-ci --help` and `fun-ci --version` print; each answers the exit code.
|
|
5
|
+
module CliHelp
|
|
6
|
+
def self.help(out)
|
|
7
|
+
out.puts TEXT
|
|
8
|
+
0
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.version(out)
|
|
12
|
+
out.puts "fun-ci #{FunCi::VERSION}"
|
|
13
|
+
0
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
TEXT = <<~HELP
|
|
17
|
+
fun-ci - Opinionated, local-first CI for your projects
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
fun-ci <command> [options]
|
|
21
|
+
|
|
22
|
+
Commands:
|
|
23
|
+
trigger Run CI pipeline for a commit
|
|
24
|
+
console Launch the admin TUI dashboard
|
|
25
|
+
init Initialize .fun-ci/ with template scripts
|
|
26
|
+
install-hooks Install post-commit and pre-push git hooks
|
|
27
|
+
check Verify project setup
|
|
28
|
+
prune Remove fun-ci's worktrees when no pipeline is running
|
|
29
|
+
status Say where a commit's run stands, as an exit code an agent can act on
|
|
30
|
+
runs List this project's recent runs, newest first
|
|
31
|
+
wait Wait for a commit's verdict, then exit with it as status does
|
|
32
|
+
events Print this project's runs' events as JSON lines
|
|
33
|
+
why Print everything kept about why a commit's stage failed
|
|
34
|
+
extract Try a stage's evidence extractors on a saved output
|
|
35
|
+
|
|
36
|
+
Options:
|
|
37
|
+
-h, --help Show this help message
|
|
38
|
+
--version Show version
|
|
39
|
+
|
|
40
|
+
Trigger options:
|
|
41
|
+
--background Run the pipeline in the background and return at once
|
|
42
|
+
|
|
43
|
+
Agent options:
|
|
44
|
+
--need LEVEL (status, wait, why) What must pass: build, fast (default) or all
|
|
45
|
+
--within TIME (wait) Give up undecided after 30, 30s or 5m
|
|
46
|
+
--follow-branch (wait) Move on to the newer commit that superseded the run
|
|
47
|
+
-n N (runs) How many runs to list (default 10)
|
|
48
|
+
--branch NAME (runs) Only runs on this branch
|
|
49
|
+
--follow (events) Keep printing events as they happen, until stopped
|
|
50
|
+
--only failures (events) Print only failed stages and superseded runs
|
|
51
|
+
--json Print JSON instead of text (events always print JSON lines)
|
|
52
|
+
Exit codes: 0 passed, 1 failed, 2 over budget, 3 undecided,
|
|
53
|
+
4 superseded, 5 no run, 64 usage error
|
|
54
|
+
|
|
55
|
+
Init options:
|
|
56
|
+
--everything Run init + install-hooks + check in one step
|
|
57
|
+
|
|
58
|
+
Examples:
|
|
59
|
+
fun-ci init --everything
|
|
60
|
+
fun-ci trigger abc1234 main
|
|
61
|
+
fun-ci trigger --background abc1234 main
|
|
62
|
+
fun-ci console
|
|
63
|
+
HELP
|
|
64
|
+
end
|
|
65
|
+
end
|