fun_ci 2.1.1-aarch64-linux → 2.2.0-aarch64-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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +24 -1
  4. data/lib/fun_ci/agent/command_support.rb +7 -0
  5. data/lib/fun_ci/agent/commands.rb +2 -1
  6. data/lib/fun_ci/agent/due_in.rb +21 -0
  7. data/lib/fun_ci/agent/events_command.rb +14 -7
  8. data/lib/fun_ci/agent/job_events.rb +37 -0
  9. data/lib/fun_ci/agent/job_json.rb +16 -0
  10. data/lib/fun_ci/agent/job_report.rb +85 -0
  11. data/lib/fun_ci/agent/job_why.rb +59 -0
  12. data/lib/fun_ci/agent/job_why_json.rb +31 -0
  13. data/lib/fun_ci/agent/job_why_text.rb +51 -0
  14. data/lib/fun_ci/agent/jobs_command.rb +43 -0
  15. data/lib/fun_ci/agent/jobs_text.rb +50 -0
  16. data/lib/fun_ci/agent/live_pipeline.rb +7 -2
  17. data/lib/fun_ci/agent/options.rb +3 -3
  18. data/lib/fun_ci/agent/output.rb +14 -2
  19. data/lib/fun_ci/agent/snapshots.rb +16 -1
  20. data/lib/fun_ci/agent/span.rb +24 -0
  21. data/lib/fun_ci/agent/stage_summary.rb +10 -6
  22. data/lib/fun_ci/agent/status_command.rb +3 -2
  23. data/lib/fun_ci/agent/status_text.rb +19 -5
  24. data/lib/fun_ci/agent/why_command.rb +6 -2
  25. data/lib/fun_ci/cli.rb +1 -1
  26. data/lib/fun_ci/cli_help.rb +3 -1
  27. data/lib/fun_ci/console/board_data.rb +26 -13
  28. data/lib/fun_ci/console/console_state.rb +12 -6
  29. data/lib/fun_ci/console/job_message.rb +33 -0
  30. data/lib/fun_ci/console/job_order.rb +22 -0
  31. data/lib/fun_ci/console/job_rows.rb +21 -0
  32. data/lib/fun_ci/console/key_handler.rb +33 -14
  33. data/lib/fun_ci/console/paging.rb +26 -0
  34. data/lib/fun_ci/console/view.rb +18 -9
  35. data/lib/fun_ci/evidence/settings.rb +14 -7
  36. data/lib/fun_ci/evidence/settings_check.rb +14 -3
  37. data/lib/fun_ci/jobs/due.rb +33 -0
  38. data/lib/fun_ci/jobs/due_jobs.rb +26 -0
  39. data/lib/fun_ci/jobs/folders.rb +50 -0
  40. data/lib/fun_ci/jobs/job.rb +31 -0
  41. data/lib/fun_ci/jobs/job_fork.rb +45 -0
  42. data/lib/fun_ci/jobs/job_run.rb +85 -0
  43. data/lib/fun_ci/jobs/locks.rb +30 -0
  44. data/lib/fun_ci/jobs/site.rb +10 -0
  45. data/lib/fun_ci/jobs/standings.rb +46 -0
  46. data/lib/fun_ci/jobs/state.rb +26 -0
  47. data/lib/fun_ci/persistence/active_jobs.rb +54 -0
  48. data/lib/fun_ci/persistence/database.rb +14 -4
  49. data/lib/fun_ci/persistence/job_recorder.rb +47 -0
  50. data/lib/fun_ci/persistence/job_runs.rb +68 -0
  51. data/lib/fun_ci/persistence/raw_outputs.rb +3 -0
  52. data/lib/fun_ci/pipeline/pipeline_forker.rb +39 -10
  53. data/lib/fun_ci/pipeline/process_runner.rb +15 -5
  54. data/lib/fun_ci/pipeline/run_canceller.rb +24 -0
  55. data/lib/fun_ci/pipeline/slot.rb +1 -1
  56. data/lib/fun_ci/pipeline/stage_execution.rb +9 -4
  57. data/lib/fun_ci/pipeline/trigger_command.rb +7 -1
  58. data/lib/fun_ci/pipeline/worktree_prune.rb +8 -6
  59. data/lib/fun_ci/pipeline/worktrees.rb +8 -4
  60. data/lib/fun_ci/setup/agent_instructions.rb +3 -0
  61. data/lib/fun_ci/setup/project_config.rb +6 -0
  62. data/lib/fun_ci/setup/setup_checker.rb +7 -1
  63. data/lib/fun_ci/version.rb +1 -1
  64. data/libexec/fun-ci-renderer +0 -0
  65. metadata +28 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9749650269dc0a0cbdfaaf5d8c5cf699506bf2f74616cd7559e9b2053ce2afb3
4
- data.tar.gz: 2157ecd89e3e75ad0aa9ee0a85b69a74560616446d088605433913a516049caa
3
+ metadata.gz: 3ffca67ae0d98ad5e4ca3f79a1780e476694e02faddca85821bbbfb05b0e5883
4
+ data.tar.gz: 4ab9b5ebd8d926121840f4831c8c0fd42d43db31c633375f28187fe1e253460b
5
5
  SHA512:
6
- metadata.gz: d18c609aed8b49e1e5d9275d49f5e7ddf3fe773aa96b52b396b7cdc2c912c28c4fe8cdca0dc269d949e2ca6f9e9022ff9ede7b551f765e19b8f31578f5304376
7
- data.tar.gz: f092c4242f2bd1ab1df43c5e20a7a079ba4b238779cb3cdd3e85e7a87bf2ec3ea80073a87d6f317fc36363e84ae763073bea4c49dfae430ed8ad925cce0a1168
6
+ metadata.gz: ac4468eaca795bbe2e91ebe364b25bf5df9c90a921adc343451566a281635169e210954bfc129e9cad7f3ba00131e42b556f661da50bc850fe69d8c8b1ca18bb
7
+ data.tar.gz: a33fcce540b66de69cd392b12aecc117347ce6beefe6267c7b1fe1e65453d97615db9108b6f63dbb2c490671bdbf2d42d6f3980cf5a274e79f5fcdae3714e1c8
data/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ 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
+
10
34
  ## [2.1.1] - 2026-09-30
11
35
 
12
36
  ### Fixed
data/README.md CHANGED
@@ -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,12 +105,16 @@ Five minutes after the latest run finished, the header goes quiet: a starry nigh
88
105
  |---|---|
89
106
  | ![A fire in a stone hearth, with a green lamp in the corner](docs/screenshots/quiet-fireplace.png) | ![A moonlit island with a palm, with a red lamp in the corner](docs/screenshots/quiet-island.png) |
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
+ ![The daily and weekly jobs under the table: soak failed a day ago on wip/foo, with a red stripe, mutation running for over an hour, with a blue one, deps-audit passed and due again in 14 hours, and lint-deep due on the next commit](docs/screenshots/jobs.png)
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
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.
@@ -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
@@ -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, then,
13
- # with --follow, each new one as it happens, until interrupted.
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({}, options.only)
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`, 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
+ # 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 whose process died (acceptance-tests.md, AT-8.3).
32
- def watch(db) = Pipeline::RunCanceller.new.record_dead(db)
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
@@ -12,12 +12,12 @@ module FunCi
12
12
  class Invalid < StandardError; end
13
13
 
14
14
  Parsed = Data.define(:rev, :stage, :need, :json, :within, :follow_branch, :limit, :branch, :follow, :only, :raw,
15
- :trunk)
15
+ :trunk, :job)
16
16
  DEFAULTS = { need: "fast", json: false, within: nil, follow_branch: false, limit: 10, branch: nil,
17
- follow: false, only: nil, raw: false, trunk: false }.freeze
17
+ follow: false, only: nil, raw: false, trunk: false, job: nil }.freeze
18
18
  SWITCHES = { need: ["--need LEVEL"], json: ["--json"], within: ["--within DURATION"], raw: ["--raw"],
19
19
  follow_branch: ["--follow-branch"], limit: ["-n N", Integer], branch: ["--branch NAME"],
20
- follow: ["--follow"], only: ["--only KIND"], trunk: ["--trunk"] }.freeze
20
+ follow: ["--follow"], only: ["--only KIND"], trunk: ["--trunk"], job: ["--job NAME"] }.freeze
21
21
  FILTERS = %w[failures].freeze
22
22
  MULTIPLIERS = { "" => 1, "s" => 1, "m" => 60 }.freeze
23
23
 
@@ -9,6 +9,8 @@ require_relative "runs_text"
9
9
  require_relative "exit_code"
10
10
  require_relative "evidence_text"
11
11
  require_relative "trunk_why_json"
12
+ require_relative "job_json"
13
+ require_relative "job_report"
12
14
 
13
15
  module FunCi
14
16
  module Agent
@@ -24,8 +26,11 @@ module FunCi
24
26
  @trunk = trunk
25
27
  end
26
28
 
27
- def report(report)
28
- @json ? print_json(StatusJson.document(report)) : print_lines(StatusText.lines(report, trunk: @trunk))
29
+ # jobs: the commit's CommitJobs, when asked about.
30
+ def report(report, jobs: nil)
31
+ return print_json(with_jobs(StatusJson.document(report), jobs)) if @json
32
+
33
+ print_lines(StatusText.lines(report, trunk: @trunk, jobs: jobs || CommitJobs::NONE))
29
34
  end
30
35
 
31
36
  # Everything kept about the stage named, or says no stage decided the verdict.
@@ -87,6 +92,13 @@ module FunCi
87
92
 
88
93
  private
89
94
 
95
+ def with_jobs(document, jobs)
96
+ return document unless jobs
97
+
98
+ document.merge(jobs: jobs.on_commit.map { |job| JobJson.document(job) },
99
+ failing_jobs: jobs.failing.map { |job| JobJson.document(job) })
100
+ end
101
+
90
102
  def print_json(document) = @stdout.puts(JSON.generate(document))
91
103
  def print_lines(lines) = lines.each { |line| @stdout.puts line }
92
104
  end