fun_ci 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +39 -0
- data/README.md +27 -4
- data/lib/fun_ci/agent/command_support.rb +7 -0
- data/lib/fun_ci/agent/commands.rb +2 -1
- data/lib/fun_ci/agent/due_in.rb +21 -0
- data/lib/fun_ci/agent/events_command.rb +14 -7
- data/lib/fun_ci/agent/job_events.rb +37 -0
- data/lib/fun_ci/agent/job_json.rb +16 -0
- data/lib/fun_ci/agent/job_report.rb +85 -0
- data/lib/fun_ci/agent/job_why.rb +59 -0
- data/lib/fun_ci/agent/job_why_json.rb +31 -0
- data/lib/fun_ci/agent/job_why_text.rb +51 -0
- data/lib/fun_ci/agent/jobs_command.rb +43 -0
- data/lib/fun_ci/agent/jobs_text.rb +50 -0
- data/lib/fun_ci/agent/live_pipeline.rb +7 -2
- data/lib/fun_ci/agent/options.rb +3 -3
- data/lib/fun_ci/agent/output.rb +14 -2
- data/lib/fun_ci/agent/snapshots.rb +16 -1
- data/lib/fun_ci/agent/span.rb +24 -0
- data/lib/fun_ci/agent/stage_summary.rb +10 -6
- data/lib/fun_ci/agent/status_command.rb +3 -2
- data/lib/fun_ci/agent/status_text.rb +19 -5
- data/lib/fun_ci/agent/why_command.rb +6 -2
- data/lib/fun_ci/cli.rb +1 -1
- data/lib/fun_ci/cli_help.rb +4 -2
- data/lib/fun_ci/console/board_data.rb +26 -13
- data/lib/fun_ci/console/console_state.rb +12 -6
- data/lib/fun_ci/console/job_message.rb +33 -0
- data/lib/fun_ci/console/job_order.rb +22 -0
- data/lib/fun_ci/console/job_rows.rb +21 -0
- data/lib/fun_ci/console/key_handler.rb +33 -14
- data/lib/fun_ci/console/paging.rb +26 -0
- data/lib/fun_ci/console/view.rb +18 -9
- data/lib/fun_ci/evidence/settings.rb +14 -7
- data/lib/fun_ci/evidence/settings_check.rb +14 -3
- data/lib/fun_ci/jobs/due.rb +33 -0
- data/lib/fun_ci/jobs/due_jobs.rb +26 -0
- data/lib/fun_ci/jobs/folders.rb +50 -0
- data/lib/fun_ci/jobs/job.rb +31 -0
- data/lib/fun_ci/jobs/job_fork.rb +45 -0
- data/lib/fun_ci/jobs/job_run.rb +85 -0
- data/lib/fun_ci/jobs/locks.rb +30 -0
- data/lib/fun_ci/jobs/site.rb +10 -0
- data/lib/fun_ci/jobs/standings.rb +46 -0
- data/lib/fun_ci/jobs/state.rb +26 -0
- data/lib/fun_ci/persistence/active_jobs.rb +54 -0
- data/lib/fun_ci/persistence/database.rb +14 -4
- data/lib/fun_ci/persistence/job_recorder.rb +47 -0
- data/lib/fun_ci/persistence/job_runs.rb +68 -0
- data/lib/fun_ci/persistence/raw_outputs.rb +3 -0
- data/lib/fun_ci/pipeline/pipeline_forker.rb +39 -10
- data/lib/fun_ci/pipeline/process_runner.rb +15 -5
- data/lib/fun_ci/pipeline/run_canceller.rb +24 -0
- data/lib/fun_ci/pipeline/slot.rb +1 -1
- data/lib/fun_ci/pipeline/stage_execution.rb +9 -4
- data/lib/fun_ci/pipeline/trigger_command.rb +7 -1
- data/lib/fun_ci/pipeline/worktree_prune.rb +8 -6
- data/lib/fun_ci/pipeline/worktrees.rb +8 -4
- data/lib/fun_ci/setup/agent_instructions.rb +3 -0
- data/lib/fun_ci/setup/project_config.rb +6 -0
- data/lib/fun_ci/setup/setup_checker.rb +7 -1
- data/lib/fun_ci/version.rb +1 -1
- metadata +28 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 363808a79c7bf82b45f577fdc241e10ab866a11d2966fdc6b52ad583e1a35ce7
|
|
4
|
+
data.tar.gz: 9932aa6e0b9b1c2cf506032bca183046e02b71ea583f4cd66affd1dbe2311df9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a0b58e24f2d166894afa311971af5b42992535a9befbbbbdcdcd4a9afe6e6c3f7ff396d5c63bc9e31262c5e4b8a0fed95572bbca63b6f32749fcaba75beeee80
|
|
7
|
+
data.tar.gz: 16be19fcf7a864a7d573f6b64bfbbad7024dd0bbfd371c0b515ca304202135d6f37046f7e09ad64b27606565e7ac71345a828dd761e195cbc61c1303107ee976
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.2.0] - 2026-10-01
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Daily and weekly jobs, for checks too long for a stage's budget. A script
|
|
14
|
+
in `.fun-ci/daily/` or `.fun-ci/weekly/` runs at most once a day or once a
|
|
15
|
+
week, started by your commits, in a worktree of its own, with 24 hours to
|
|
16
|
+
finish. The console shows them in a section of their own under the table,
|
|
17
|
+
and `c` cancels a running one; on a screen too short for both, the jobs
|
|
18
|
+
give way to the branches. `fun-ci check` lists them.
|
|
19
|
+
- The console's job section needs renderer 2.2.0, which the platform gems
|
|
20
|
+
bring. With an older renderer, as `cargo install` left it, the console
|
|
21
|
+
works but shows no jobs.
|
|
22
|
+
- `fun-ci prune` removes the jobs' worktrees too.
|
|
23
|
+
- Agents ask about jobs: `fun-ci jobs` lists them, `fun-ci why --job NAME`
|
|
24
|
+
prints what was kept about one's last run, `fun-ci status` names the jobs
|
|
25
|
+
that tested the commit and any failing on another, and `fun-ci events`
|
|
26
|
+
says when a job starts and finishes. The instructions `fun-ci init` writes
|
|
27
|
+
for agents mention them.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- On a short screen, a project whose branches had all passed and were left
|
|
31
|
+
out (counted beside the keys) kept its label, with nothing under it. The
|
|
32
|
+
label now goes with them.
|
|
33
|
+
|
|
34
|
+
## [2.1.1] - 2026-09-30
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
- The console's legend belongs to every project, but after a failure,
|
|
38
|
+
when the projects below it had folded their passed branches onto one line
|
|
39
|
+
each, its lines stopped at the failed branch and it looked like that
|
|
40
|
+
project's alone. They now run down to the last project, whatever it shows.
|
|
41
|
+
- `fun-ci --help` called the console "the admin TUI dashboard". It says what
|
|
42
|
+
it is for: watching your runs as they happen, in the terminal.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
- The README's pictures of the console are drawn in a real terminal, in a
|
|
46
|
+
real font, 1500 pixels wide instead of 800, and its animation is an
|
|
47
|
+
animated WebP.
|
|
48
|
+
|
|
10
49
|
## [2.1.0] - 2026-09-30
|
|
11
50
|
|
|
12
51
|
### Added
|
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Opinionated local CI that checks your code before it leaves your machine. Every commit runs lint, build, a fast suite and a slow suite on your own computer, each held to a strict time budget, so the feedback stays fast.
|
|
6
6
|
|
|
7
|
-

|
|
8
8
|
|
|
9
9
|
Fun-CI has two goals. It should be extremely easy to tell whether all is well, and it should be fun. The design behind both is in [docs/design.md](docs/design.md).
|
|
10
10
|
|
|
@@ -53,6 +53,23 @@ Each run happens in a git worktree of its own under `.git/fun-ci/worktrees/`, ch
|
|
|
53
53
|
|
|
54
54
|
Results are kept in SQLite under `$XDG_STATE_HOME/fun-ci/` (`~/.local/state/fun-ci/` by default), shared by every project on the machine.
|
|
55
55
|
|
|
56
|
+
## Daily and weekly jobs
|
|
57
|
+
|
|
58
|
+
Some checks take longer than the time between two commits: a mutation run, a soak test, a full audit of your dependencies. Put each in a script of its own under `.fun-ci/daily/` or `.fun-ci/weekly/`:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
.fun-ci/daily/mutation.sh
|
|
62
|
+
.fun-ci/weekly/soak.sh
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The script's name is the job's name. Like a stage, it gets the commit's hash as its first argument and passes when it exits 0, and `FUN_CI_JOB` tells it which job it is. There is nothing else to configure.
|
|
66
|
+
|
|
67
|
+
Commits start jobs. When the `post-commit` hook runs a pipeline, it also starts each of the project's jobs that is due, beside the pipeline, on the commit just made. A job is due when it has never run, when its last run was cancelled, or a day (daily) or a week (weekly) after its last run started. A project nobody commits to runs no jobs. Each job runs once at a time, in a worktree of its own under `.git/fun-ci/jobs/`, so it never holds up a commit's run, and has 24 hours before it is stopped. A newer commit never cancels a job. A job's result changes no run, no streak and no exit code.
|
|
68
|
+
|
|
69
|
+
A failed job keeps its evidence as a failed stage does; add entries under `evidence: jobs: <name>:` in `.fun-ci/config` to keep more. `fun-ci check` lists the jobs it found, and says which scripts it can't run.
|
|
70
|
+
|
|
71
|
+
`fun-ci jobs` lists the jobs, each with how its last run went, on which commit, and when it is due again. `fun-ci why --job soak` prints everything kept about the job's last run (`--raw` for its whole output, `--json` for a document), and exits as `why` does for a stage. `fun-ci status` names the jobs whose last run tested the commit you ask about, and any job failing on another commit, and `fun-ci events` says when a job starts and finishes. None of them changes a commit's verdict. A job's name is letters, digits, `.`, `_` and `-`, so those commands can be pasted as they are printed.
|
|
72
|
+
|
|
56
73
|
## Watching: the console
|
|
57
74
|
|
|
58
75
|
```bash
|
|
@@ -88,15 +105,19 @@ Five minutes after the latest run finished, the header goes quiet: a starry nigh
|
|
|
88
105
|
|---|---|
|
|
89
106
|
|  |  |
|
|
90
107
|
|
|
108
|
+
The daily and weekly jobs have a section of their own under the last project: each job's name, how often it runs, its mark, what its last run did on which branch and commit, and when it is due again (`passed on main 3a1f9c2 · due in 14h`), or that it runs on your next commit. When none of them failed or runs, the section folds into one pale line.
|
|
109
|
+
|
|
110
|
+

|
|
111
|
+
|
|
91
112
|
A branch that conflicts with the trunk says so on the line under its name, `conflicts with main`, and the header plays two strands braiding into a knot; when a later commit on the branch merges cleanly, the knot unties. A project whose trunk fun-ci last fetched over an hour ago, or couldn't fetch, says so beside its name.
|
|
92
113
|
|
|
93
114
|
Keys:
|
|
94
115
|
|
|
95
116
|
- `j` and `k`, or the arrow keys, move the cursor down and up
|
|
96
|
-
- `c` cancels the run under the cursor: one waiting to start at once, a running one once you answer `y` (`n` or `Esc` keeps it running); the footer offers it only while a run is running or waits to start
|
|
117
|
+
- `c` cancels the run under the cursor: one waiting to start at once, a running one once you answer `y` (`n` or `Esc` keeps it running); the footer offers it only while a run is running or waits to start. The cursor moves on past the last branch into the jobs, and `c` cancels a running job the same way; a cancelled job runs again on your next commit
|
|
97
118
|
- `q` quits
|
|
98
119
|
|
|
99
|
-
The console is drawn in 24-bit colour when `COLORTERM` says the terminal has it (`truecolor` or `24bit`), and in 256 colours otherwise. The pictures above
|
|
120
|
+
The console is drawn in 24-bit colour when `COLORTERM` says the terminal has it (`truecolor` or `24bit`), and in 256 colours otherwise. The pictures above are the renderer's output replayed in a terminal emulator, set in Menlo; in your terminal the text is in your terminal's font.
|
|
100
121
|
|
|
101
122
|
The drawing is done by a separate program, `fun-ci-renderer`, written in Rust. The gem for Linux (x86_64, aarch64, musl) and macOS (arm64, x86_64) includes it. On other Unix-like systems you get the plain gem, where every command except `console` works; install the renderer with `cargo install fun-ci-renderer` (Rust 1.88 or newer), or point `FUN_CI_RENDERER` at a copy you built. Windows is not supported, because fun-ci forks its background pipeline, but WSL runs the Linux gem. When something goes wrong between the two, the details are in `.fun-ci/console.log`.
|
|
102
123
|
|
|
@@ -227,6 +248,8 @@ fun-ci status [commit] [--trunk] Where a commit's run stands; the
|
|
|
227
248
|
fun-ci wait [commit] Wait until that verdict is decided, then exit with it
|
|
228
249
|
fun-ci why [commit] [stage|trunk] Everything kept about why a stage failed, or the conflict with the trunk
|
|
229
250
|
fun-ci runs This project's recent runs, newest first
|
|
251
|
+
fun-ci jobs This project's daily and weekly jobs and how each stands
|
|
252
|
+
fun-ci why --job <name> Everything kept about a job's latest run
|
|
230
253
|
fun-ci events [--follow] The runs' events as JSON lines
|
|
231
254
|
fun-ci extract <stage> --output <file> Try a stage's extractors on a saved output
|
|
232
255
|
fun-ci trigger <commit> <branch> Run the whole pipeline in the foreground
|
|
@@ -253,7 +276,7 @@ bundle install
|
|
|
253
276
|
bundle exec rake # the gate: tests, the renderer's tests, the binary contract, rubocop, clippy
|
|
254
277
|
```
|
|
255
278
|
|
|
256
|
-
How it is built is in [docs/architecture.md](docs/architecture.md), and the requirements are in [docs/acceptance-tests.md](docs/acceptance-tests.md). The renderer is written in Rust; install Rust with [rustup](https://rustup.rs), which picks up the version the repository pins in `rust-toolchain.toml`. `ruby script/readme_screenshots.rb` draws the pictures in this README again.
|
|
279
|
+
How it is built is in [docs/architecture.md](docs/architecture.md), and the requirements are in [docs/acceptance-tests.md](docs/acceptance-tests.md). The renderer is written in Rust; install Rust with [rustup](https://rustup.rs), which picks up the version the repository pins in `rust-toolchain.toml`. `ruby script/readme_screenshots.rb` draws the pictures in this README again, on macOS, after `npm run --prefix script/screenshots/terminal setup` once.
|
|
257
280
|
|
|
258
281
|
## License
|
|
259
282
|
|
|
@@ -4,6 +4,7 @@ require_relative "options"
|
|
|
4
4
|
require_relative "reports"
|
|
5
5
|
require_relative "exit_code"
|
|
6
6
|
require_relative "trunk_verdict"
|
|
7
|
+
require_relative "job_report"
|
|
7
8
|
|
|
8
9
|
module FunCi
|
|
9
10
|
module Agent
|
|
@@ -31,6 +32,12 @@ module FunCi
|
|
|
31
32
|
|
|
32
33
|
def reports = @reports ||= Reports.new(@context.db, @context.git, @context.clock, @context.trunk)
|
|
33
34
|
|
|
35
|
+
# After recording failed the jobs whose process died, which would otherwise read as running.
|
|
36
|
+
def job_reports
|
|
37
|
+
@context.pipeline.watch(@context.db)
|
|
38
|
+
JobReports.new(@context.db, @context.git.toplevel, @context.clock)
|
|
39
|
+
end
|
|
40
|
+
|
|
34
41
|
def usage(message)
|
|
35
42
|
@context.io.stderr.puts "fun-ci #{self.class::NAME}: #{message}"
|
|
36
43
|
ExitCode::USAGE
|
|
@@ -10,6 +10,7 @@ require_relative "runs_command"
|
|
|
10
10
|
require_relative "wait_command"
|
|
11
11
|
require_relative "events_command"
|
|
12
12
|
require_relative "why_command"
|
|
13
|
+
require_relative "jobs_command"
|
|
13
14
|
require_relative "../trunk/local"
|
|
14
15
|
|
|
15
16
|
module FunCi
|
|
@@ -18,7 +19,7 @@ module FunCi
|
|
|
18
19
|
# database and the project's git.
|
|
19
20
|
module Commands
|
|
20
21
|
ALL = { "status" => StatusCommand, "runs" => RunsCommand, "wait" => WaitCommand,
|
|
21
|
-
"events" => EventsCommand, "why" => WhyCommand }.freeze
|
|
22
|
+
"events" => EventsCommand, "why" => WhyCommand, "jobs" => JobsCommand }.freeze
|
|
22
23
|
|
|
23
24
|
def self.run(name, args, context)
|
|
24
25
|
ALL.fetch(name).new(context).run(args)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Agent
|
|
5
|
+
# How long until something, rounded up, since it is still to come, in the
|
|
6
|
+
# largest unit it reaches once rounded: `45m`, `22h`, `7d`; as the console says it.
|
|
7
|
+
module DueIn
|
|
8
|
+
def self.words(seconds)
|
|
9
|
+
minutes = up(seconds, 60).clamp(1..)
|
|
10
|
+
hours = up(seconds, 3600)
|
|
11
|
+
return "#{minutes}m" if minutes < 60
|
|
12
|
+
return "#{hours}h" if hours < 24
|
|
13
|
+
|
|
14
|
+
"#{up(seconds, 86_400)}d"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.up(seconds, unit) = (seconds.to_r / unit).ceil
|
|
18
|
+
private_class_method :up
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -4,19 +4,23 @@ require "json"
|
|
|
4
4
|
require_relative "command_support"
|
|
5
5
|
require_relative "snapshots"
|
|
6
6
|
require_relative "events"
|
|
7
|
+
require_relative "job_events"
|
|
7
8
|
require_relative "waiting"
|
|
8
9
|
|
|
9
10
|
module FunCi
|
|
10
11
|
module Agent
|
|
11
12
|
# `fun-ci events [--follow] [--only failures]` (acceptance-tests.md,
|
|
12
|
-
# AT-9.16): one JSON line per event of the project's recent runs
|
|
13
|
-
# with --follow, each new one as it happens, until
|
|
13
|
+
# AT-9.16, AT-13.23): one JSON line per event of the project's recent runs
|
|
14
|
+
# and job runs, then, with --follow, each new one as it happens, until
|
|
15
|
+
# interrupted.
|
|
14
16
|
class EventsCommand
|
|
15
17
|
include CommandSupport
|
|
16
18
|
|
|
17
19
|
NAME = "events"
|
|
18
20
|
SCHEMA = 1
|
|
19
21
|
RECENT_RUNS = 10
|
|
22
|
+
# The first look: no runs, no job runs.
|
|
23
|
+
NOTHING_SEEN = [{}, {}].freeze
|
|
20
24
|
|
|
21
25
|
def initialize(context)
|
|
22
26
|
@context = context
|
|
@@ -24,7 +28,7 @@ module FunCi
|
|
|
24
28
|
|
|
25
29
|
def run(args)
|
|
26
30
|
options = Options.parse(args, takes: %i[follow only json])
|
|
27
|
-
seen = emit(
|
|
31
|
+
seen = emit(NOTHING_SEEN, options.only)
|
|
28
32
|
follow(seen, options.only) if options.follow
|
|
29
33
|
0
|
|
30
34
|
rescue Options::Invalid => e
|
|
@@ -43,13 +47,16 @@ module FunCi
|
|
|
43
47
|
# Following ends when whoever follows stops it.
|
|
44
48
|
end
|
|
45
49
|
|
|
46
|
-
# Prints what happened since `before
|
|
47
|
-
def emit(
|
|
48
|
-
after = snapshots.take(limit: RECENT_RUNS)
|
|
49
|
-
Events.between(
|
|
50
|
+
# Prints what happened since `before` ([runs, job runs]), and answers the look it took.
|
|
51
|
+
def emit((runs, jobs), only)
|
|
52
|
+
after = [snapshots.take(limit: RECENT_RUNS), snapshots.jobs(limit: RECENT_RUNS)]
|
|
53
|
+
events = Events.between(runs, after.first) + JobEvents.between(jobs, after.last)
|
|
54
|
+
events.select { |event| !only || failure?(event) }.each { |event| print(event) }
|
|
50
55
|
after
|
|
51
56
|
end
|
|
52
57
|
|
|
58
|
+
def failure?(event) = Events.failure?(event) || JobEvents.failure?(event)
|
|
59
|
+
|
|
53
60
|
def print(event)
|
|
54
61
|
@context.io.stdout.puts JSON.generate({ schema: SCHEMA, **event })
|
|
55
62
|
@context.io.stdout.flush
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../jobs/state"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Agent
|
|
7
|
+
# What happened to a project's daily and weekly job runs between two
|
|
8
|
+
# looks at them (acceptance-tests.md, AT-13.23), oldest first: a job run
|
|
9
|
+
# started, and ended with its state and seconds. The first look is at nothing.
|
|
10
|
+
module JobEvents
|
|
11
|
+
# state: as Jobs::State says it; seconds: how long it ran, once it ended.
|
|
12
|
+
JobState = Data.define(:id, :job, :cadence, :sha, :branch, :state, :seconds)
|
|
13
|
+
|
|
14
|
+
# The states of a run that has not ended.
|
|
15
|
+
GOING = %w[running due].freeze
|
|
16
|
+
|
|
17
|
+
# before, after: { job run id => JobState }.
|
|
18
|
+
def self.between(before, after)
|
|
19
|
+
after.values.sort_by(&:id).flat_map { |run| of(run, before[run.id]) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.failure?(event) = event[:event] == "job_finished" && Jobs::State.needs_you?(event[:state])
|
|
23
|
+
|
|
24
|
+
def self.of(run, was)
|
|
25
|
+
about = { job: run.job, cadence: run.cadence, commit: run.sha, branch: run.branch }
|
|
26
|
+
[*(was ? [] : [{ event: "job_started", **about }]), *ended(run, was, about)]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.ended(run, was, about)
|
|
30
|
+
return [] if was&.state == run.state || GOING.include?(run.state)
|
|
31
|
+
|
|
32
|
+
[{ event: "job_finished", **about, state: run.state, seconds: run.seconds }]
|
|
33
|
+
end
|
|
34
|
+
private_class_method :of, :ended
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Agent
|
|
5
|
+
# One job as `jobs --json` and `status --json` give it. Fields once
|
|
6
|
+
# published keep their names.
|
|
7
|
+
module JobJson
|
|
8
|
+
def self.document(report)
|
|
9
|
+
stage = report.stage
|
|
10
|
+
{ name: report.name, cadence: report.cadence, state: report.state,
|
|
11
|
+
commit: stage && { sha: report.sha, branch: report.branch }, seconds: stage&.seconds,
|
|
12
|
+
started_at: report.started_at&.utc&.iso8601, due_at: report.due_at&.utc&.iso8601, due: report.due? }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "report_stage"
|
|
5
|
+
require_relative "verdict"
|
|
6
|
+
require_relative "../jobs/standings"
|
|
7
|
+
require_relative "../persistence/raw_outputs"
|
|
8
|
+
|
|
9
|
+
module FunCi
|
|
10
|
+
module Agent
|
|
11
|
+
# A daily or weekly job as an agent is told it: where it stands
|
|
12
|
+
# (Jobs::Standing), and its latest run as a RunReport::Stage named after
|
|
13
|
+
# the job, nil while it never ran.
|
|
14
|
+
JobReport = Data.define(:standing, :stage)
|
|
15
|
+
|
|
16
|
+
# The jobs `status` names for a commit: those whose latest run tested it,
|
|
17
|
+
# and those failing on another commit, which an agent would not hear of otherwise.
|
|
18
|
+
CommitJobs = Data.define(:on_commit, :failing)
|
|
19
|
+
|
|
20
|
+
class CommitJobs
|
|
21
|
+
NONE = new(on_commit: [], failing: [])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class JobReport
|
|
25
|
+
VERDICTS = { "passed" => :passed, "failed" => :failed, "lost" => :failed, "over_budget" => :over_budget,
|
|
26
|
+
"running" => :undecided }.freeze
|
|
27
|
+
|
|
28
|
+
def name = standing.name
|
|
29
|
+
def cadence = standing.cadence
|
|
30
|
+
def state = standing.state
|
|
31
|
+
def needs_you? = standing.needs_you?
|
|
32
|
+
def due_at = standing.due_at
|
|
33
|
+
|
|
34
|
+
# Whether the next commit starts it.
|
|
35
|
+
def due? = standing.due_now?
|
|
36
|
+
|
|
37
|
+
# The commit and branch its latest run tested, and when that started.
|
|
38
|
+
def sha = standing.run&.dig(:commit_hash)
|
|
39
|
+
def branch = standing.run&.dig(:branch)
|
|
40
|
+
def started_at = standing.run && Time.parse(standing.run[:started_at])
|
|
41
|
+
|
|
42
|
+
# As `status` would exit for a stage in this state; a job that never ran, or was cancelled, has none.
|
|
43
|
+
def verdict = VERDICTS.fetch(state, :unknown)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The daily and weekly jobs of a project, as JobReports.
|
|
47
|
+
class JobReports
|
|
48
|
+
def initialize(db, project, clock)
|
|
49
|
+
@db = db
|
|
50
|
+
@project = project
|
|
51
|
+
@clock = clock
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# By name.
|
|
55
|
+
def all = Jobs::Standings.new(@db, @project, now: @clock.now).all.map { |standing| report(standing) }
|
|
56
|
+
|
|
57
|
+
# The job named, or nil when the project has no such job.
|
|
58
|
+
def named(name) = all.find { |report| report.name == name }
|
|
59
|
+
|
|
60
|
+
# The jobs whose latest run tested `sha`, and those failing on another commit.
|
|
61
|
+
def of_commit(sha)
|
|
62
|
+
reports = all
|
|
63
|
+
CommitJobs.new(on_commit: reports.select { |report| report.sha == sha },
|
|
64
|
+
failing: reports.select { |report| report.needs_you? && report.sha != sha })
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# What the job's latest run kept of its raw output, or nil.
|
|
68
|
+
def raw_output(report) = report.stage && raw.read(report.stage.id)
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def raw = Persistence::RawOutputs.for_jobs(@db.filename("main"))
|
|
73
|
+
|
|
74
|
+
def report(standing)
|
|
75
|
+
run = standing.run
|
|
76
|
+
JobReport.new(standing: standing, stage: run && stage(standing.name, run))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def stage(name, run)
|
|
80
|
+
RunReport::Stage.from_row(name,
|
|
81
|
+
run.merge(budget: run[:budget] || Jobs::Job::BUDGET, raw_bytes: raw.bytes(run[:id])))
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "job_report"
|
|
5
|
+
require_relative "job_why_text"
|
|
6
|
+
require_relative "job_why_json"
|
|
7
|
+
require_relative "exit_code"
|
|
8
|
+
require_relative "options"
|
|
9
|
+
|
|
10
|
+
module FunCi
|
|
11
|
+
module Agent
|
|
12
|
+
# `fun-ci why --job NAME [--json] [--raw]` (acceptance-tests.md, AT-13.21):
|
|
13
|
+
# everything kept about the job's latest run, exiting as `why` does for a
|
|
14
|
+
# stage in the same state; a job that never ran has no verdict.
|
|
15
|
+
class JobWhy
|
|
16
|
+
NO_JOBS = "this project has no daily or weekly jobs; put their scripts in .fun-ci/daily/ or .fun-ci/weekly/"
|
|
17
|
+
|
|
18
|
+
def initialize(context, jobs)
|
|
19
|
+
@context = context
|
|
20
|
+
@jobs = jobs
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Raises Options::Invalid for a name that is no job of the project.
|
|
24
|
+
def answer(options)
|
|
25
|
+
report = @jobs.named(options.job) || raise(Options::Invalid, no_job(options.job))
|
|
26
|
+
return never_ran(report, options.json) unless report.stage
|
|
27
|
+
|
|
28
|
+
options.raw ? raw(report) : say(report, options.json)
|
|
29
|
+
ExitCode::FOR.fetch(report.verdict)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def say(report, json) = json ? print_json(report) : print_lines(JobWhyText.lines(report))
|
|
35
|
+
|
|
36
|
+
def never_ran(report, json)
|
|
37
|
+
json ? print_json(report) : print_lines([JobWhyText.never_ran(report)])
|
|
38
|
+
ExitCode::FOR.fetch(:unknown)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def raw(report)
|
|
42
|
+
text = @jobs.raw_output(report)
|
|
43
|
+
return @context.io.stdout.write(text) if text
|
|
44
|
+
|
|
45
|
+
@context.io.stderr.puts "fun-ci why: no raw output is kept for job #{report.name}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def no_job(name)
|
|
49
|
+
names = @jobs.all.map(&:name)
|
|
50
|
+
return NO_JOBS if names.empty?
|
|
51
|
+
|
|
52
|
+
"no job '#{name}' in this project: its jobs are #{names.join(", ")}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def print_json(report) = @context.io.stdout.puts(JSON.generate(JobWhyJson.document(report)))
|
|
56
|
+
def print_lines(lines) = lines.each { |line| @context.io.stdout.puts line }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Agent
|
|
5
|
+
# `fun-ci why --job NAME --json` (acceptance-tests.md, AT-13.21): the job,
|
|
6
|
+
# how its latest run ended, its evidence, or else why there is none, as
|
|
7
|
+
# `why --json` gives them for a stage. Fields once published keep their
|
|
8
|
+
# names; a change bumps SCHEMA.
|
|
9
|
+
module JobWhyJson
|
|
10
|
+
SCHEMA = 1
|
|
11
|
+
|
|
12
|
+
def self.document(report)
|
|
13
|
+
stage = report.stage
|
|
14
|
+
{ schema: SCHEMA, name: report.name, cadence: report.cadence, state: report.state,
|
|
15
|
+
commit: stage && { sha: report.sha, branch: report.branch }, **ending(stage), **evidence(report),
|
|
16
|
+
raw_output: { kept: !stage&.raw_bytes.nil?, bytes: stage&.raw_bytes || 0 } }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The evidence of a run that failed or ran over budget, or else why there is none.
|
|
20
|
+
def self.evidence(report)
|
|
21
|
+
failed = report.needs_you?
|
|
22
|
+
{ evidence: failed ? report.stage.evidence.to_h : nil, no_evidence: failed ? nil : report.state }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.ending(stage)
|
|
26
|
+
{ exit_status: stage&.exit_status, signal: stage&.signal, seconds: stage&.seconds, budget: stage&.budget }
|
|
27
|
+
end
|
|
28
|
+
private_class_method :ending, :evidence
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "stage_summary"
|
|
4
|
+
require_relative "evidence_text"
|
|
5
|
+
require_relative "span"
|
|
6
|
+
|
|
7
|
+
module FunCi
|
|
8
|
+
module Agent
|
|
9
|
+
# What `fun-ci why --job NAME` prints (acceptance-tests.md, AT-13.21): the
|
|
10
|
+
# job and the commit its latest run tested, how that run ended, then
|
|
11
|
+
# everything kept about it.
|
|
12
|
+
module JobWhyText
|
|
13
|
+
PASSED = "Nothing is kept about a job that passed."
|
|
14
|
+
RUNNING = "It is still running; fun-ci wait doesn't wait for jobs, so ask again later."
|
|
15
|
+
CANCELLED = "It was cancelled; it runs again on the next commit."
|
|
16
|
+
|
|
17
|
+
def self.lines(report)
|
|
18
|
+
summary = StageSummary.line(report.stage, seconds: Span.method(:words))
|
|
19
|
+
[header(report), summary, *body(report.stage), *raw(report), *only_tail(report)]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.header(report) = "fun-ci: job #{report.name} (#{report.cadence}) on #{report.branch} #{report.sha[0, 7]}"
|
|
23
|
+
|
|
24
|
+
def self.never_ran(report)
|
|
25
|
+
"fun-ci: job #{report.name} (#{report.cadence}) has not run yet; it runs on the next commit."
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.body(stage)
|
|
29
|
+
return [PASSED] if stage.state == "passed"
|
|
30
|
+
return [RUNNING] if stage.state == "running"
|
|
31
|
+
return [CANCELLED] if stage.state == "cancelled"
|
|
32
|
+
|
|
33
|
+
EvidenceText.lines(stage.evidence)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.raw(report)
|
|
37
|
+
report.stage.raw_bytes ? ["", "The whole output: fun-ci why --job #{report.name} --raw"] : []
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# So an agent that reads it knows it can write the extractor itself.
|
|
41
|
+
def self.only_tail(report)
|
|
42
|
+
excerpts = report.stage.evidence.excerpts
|
|
43
|
+
return [] if excerpts.empty? || excerpts.any? { |excerpt| excerpt[:extractor] != "output-tail" }
|
|
44
|
+
|
|
45
|
+
["", "Only the output's last lines were kept; extractors under `evidence: jobs: #{report.name}:` " \
|
|
46
|
+
"in .fun-ci/config keep more."]
|
|
47
|
+
end
|
|
48
|
+
private_class_method :body, :raw, :only_tail
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "command_support"
|
|
5
|
+
require_relative "jobs_text"
|
|
6
|
+
require_relative "job_why"
|
|
7
|
+
require_relative "job_json"
|
|
8
|
+
|
|
9
|
+
module FunCi
|
|
10
|
+
module Agent
|
|
11
|
+
# `fun-ci jobs [--json]` (acceptance-tests.md, AT-13.22): the project's
|
|
12
|
+
# daily and weekly jobs and how each stands.
|
|
13
|
+
class JobsCommand
|
|
14
|
+
include CommandSupport
|
|
15
|
+
|
|
16
|
+
NAME = "jobs"
|
|
17
|
+
SCHEMA = 1
|
|
18
|
+
|
|
19
|
+
def initialize(context)
|
|
20
|
+
@context = context
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run(args)
|
|
24
|
+
options = Options.parse(args, takes: %i[json])
|
|
25
|
+
say(job_reports.all, options.json)
|
|
26
|
+
0
|
|
27
|
+
rescue Options::Invalid => e
|
|
28
|
+
usage(e.message)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def say(reports, json)
|
|
34
|
+
return out.puts(JSON.generate(schema: SCHEMA, jobs: reports.map { |report| JobJson.document(report) })) if json
|
|
35
|
+
return out.puts("fun-ci: #{JobWhy::NO_JOBS}") if reports.empty?
|
|
36
|
+
|
|
37
|
+
JobsText.lines(reports, @context.clock.now).each { |line| out.puts line }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def out = @context.io.stdout
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "age"
|
|
4
|
+
require_relative "due_in"
|
|
5
|
+
require_relative "span"
|
|
6
|
+
|
|
7
|
+
module FunCi
|
|
8
|
+
module Agent
|
|
9
|
+
# One line per daily or weekly job (acceptance-tests.md, AT-13.22), the
|
|
10
|
+
# names lined up: its cadence, its latest run's state in a word of four
|
|
11
|
+
# letters at most and how long it took, the branch and commit it tested
|
|
12
|
+
# and how long ago it started, as `runs` counts ages, and when the job is
|
|
13
|
+
# due again; then the `why --job`
|
|
14
|
+
# command of each job that failed or ran over budget.
|
|
15
|
+
module JobsText
|
|
16
|
+
WORDS = { "passed" => "ok", "failed" => "FAIL", "lost" => "LOST", "over_budget" => "OVER", "running" => "...",
|
|
17
|
+
"due" => "due", "cancelled" => "x" }.freeze
|
|
18
|
+
|
|
19
|
+
# now: the time the ages and due times count from.
|
|
20
|
+
def self.lines(reports, now)
|
|
21
|
+
width = reports.map { |report| report.name.size }.max
|
|
22
|
+
reports.map { |report| line(report, width, now) } + whys(reports)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.whys(reports)
|
|
26
|
+
reports.select(&:needs_you?).map { |report| "fun-ci why --job #{report.name}" }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.line(report, width, now)
|
|
30
|
+
head = "#{report.name.ljust(width)} #{report.cadence.ljust(6)} #{WORDS.fetch(report.state,
|
|
31
|
+
report.state).ljust(4)}"
|
|
32
|
+
return "#{head} never ran runs on the next commit" unless report.stage
|
|
33
|
+
|
|
34
|
+
"#{head} #{seconds(report.stage)} #{tested(report, now)} #{due(report, now)}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# `wip/foo 9e0b1d4 1h ago`
|
|
38
|
+
def self.tested(report, now) = "#{report.branch} #{report.sha[0, 7]} #{Age.words(now - report.started_at)}"
|
|
39
|
+
|
|
40
|
+
def self.seconds(stage) = (stage.seconds ? Span.words(stage.seconds) : "").rjust(6)
|
|
41
|
+
|
|
42
|
+
def self.due(report, now)
|
|
43
|
+
return "running" if report.state == "running"
|
|
44
|
+
|
|
45
|
+
report.due_at ? "due in #{DueIn.words(report.due_at - now)}" : "runs on the next commit"
|
|
46
|
+
end
|
|
47
|
+
private_class_method :whys, :line, :tested, :seconds, :due
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -28,8 +28,13 @@ module FunCi
|
|
|
28
28
|
Process.detach(pid).join
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
# Records failed each slow suite
|
|
32
|
-
|
|
31
|
+
# Records failed each slow suite and each daily or weekly job whose
|
|
32
|
+
# process died (acceptance-tests.md, AT-8.3, AT-13.6).
|
|
33
|
+
def watch(db)
|
|
34
|
+
canceller = Pipeline::RunCanceller.new
|
|
35
|
+
canceller.record_dead(db)
|
|
36
|
+
canceller.record_dead_jobs(db)
|
|
37
|
+
end
|
|
33
38
|
end
|
|
34
39
|
end
|
|
35
40
|
end
|