fun_ci 2.0.2 → 2.1.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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -0
  3. data/README.md +31 -5
  4. data/lib/fun_ci/agent/command_support.rb +14 -1
  5. data/lib/fun_ci/agent/commands.rb +1 -0
  6. data/lib/fun_ci/agent/context.rb +3 -2
  7. data/lib/fun_ci/agent/events.rb +19 -5
  8. data/lib/fun_ci/agent/events_command.rb +1 -1
  9. data/lib/fun_ci/agent/exit_code.rb +1 -1
  10. data/lib/fun_ci/agent/next_step.rb +26 -0
  11. data/lib/fun_ci/agent/options.rb +6 -4
  12. data/lib/fun_ci/agent/output.rb +15 -2
  13. data/lib/fun_ci/agent/report_stage.rb +1 -1
  14. data/lib/fun_ci/agent/reports.rb +13 -9
  15. data/lib/fun_ci/agent/run_details.rb +31 -0
  16. data/lib/fun_ci/agent/run_report.rb +4 -3
  17. data/lib/fun_ci/agent/runs_text.rb +7 -4
  18. data/lib/fun_ci/agent/snapshots.rb +13 -3
  19. data/lib/fun_ci/agent/status_command.rb +2 -2
  20. data/lib/fun_ci/agent/status_json.rb +2 -1
  21. data/lib/fun_ci/agent/status_text.rb +13 -2
  22. data/lib/fun_ci/agent/trunk_json.rb +32 -0
  23. data/lib/fun_ci/agent/trunk_reading.rb +53 -0
  24. data/lib/fun_ci/agent/trunk_text.rb +50 -0
  25. data/lib/fun_ci/agent/trunk_verdict.rb +20 -0
  26. data/lib/fun_ci/agent/trunk_why.rb +52 -0
  27. data/lib/fun_ci/agent/trunk_why_json.rb +23 -0
  28. data/lib/fun_ci/agent/wait_command.rb +5 -5
  29. data/lib/fun_ci/agent/why_command.rb +11 -0
  30. data/lib/fun_ci/cli.rb +1 -1
  31. data/lib/fun_ci/cli_help.rb +4 -2
  32. data/lib/fun_ci/console/board_data.rb +22 -8
  33. data/lib/fun_ci/console/cancelled_folding.rb +19 -0
  34. data/lib/fun_ci/console/console_events.rb +5 -2
  35. data/lib/fun_ci/console/console_log.rb +4 -1
  36. data/lib/fun_ci/console/console_state.rb +7 -1
  37. data/lib/fun_ci/console/row_order.rb +31 -0
  38. data/lib/fun_ci/console/run_message.rb +9 -2
  39. data/lib/fun_ci/console/trunk_events.rb +43 -0
  40. data/lib/fun_ci/console/trunk_marks.rb +53 -0
  41. data/lib/fun_ci/console/view.rb +9 -8
  42. data/lib/fun_ci/evidence/caps.rb +7 -2
  43. data/lib/fun_ci/persistence/active_runs.rb +5 -4
  44. data/lib/fun_ci/persistence/database.rb +23 -2
  45. data/lib/fun_ci/persistence/null_recorder.rb +8 -0
  46. data/lib/fun_ci/persistence/pipeline_recorder.rb +3 -0
  47. data/lib/fun_ci/persistence/pipeline_run.rb +24 -3
  48. data/lib/fun_ci/persistence/trunk_checks.rb +68 -0
  49. data/lib/fun_ci/persistence/trunk_fetches.rb +57 -0
  50. data/lib/fun_ci/persistence/trunk_recording.rb +49 -0
  51. data/lib/fun_ci/pipeline/pipeline_forker.rb +17 -2
  52. data/lib/fun_ci/pipeline/process_runner.rb +18 -4
  53. data/lib/fun_ci/pipeline/trigger.rb +10 -1
  54. data/lib/fun_ci/pipeline/trigger_command.rb +9 -5
  55. data/lib/fun_ci/pipeline/trigger_params.rb +5 -3
  56. data/lib/fun_ci/pipeline/trunk_run.rb +81 -0
  57. data/lib/fun_ci/setup/agent_instructions.rb +3 -0
  58. data/lib/fun_ci/setup/project_config.rb +3 -1
  59. data/lib/fun_ci/setup/settings.rb +35 -3
  60. data/lib/fun_ci/setup/setup_checker.rb +7 -2
  61. data/lib/fun_ci/setup/trunk_report.rb +28 -0
  62. data/lib/fun_ci/trunk/check.rb +56 -0
  63. data/lib/fun_ci/trunk/checker.rb +91 -0
  64. data/lib/fun_ci/trunk/description.rb +52 -0
  65. data/lib/fun_ci/trunk/fetch.rb +58 -0
  66. data/lib/fun_ci/trunk/first_fetch.rb +27 -0
  67. data/lib/fun_ci/trunk/git.rb +102 -0
  68. data/lib/fun_ci/trunk/local.rb +58 -0
  69. data/lib/fun_ci/trunk/merge_check.rb +54 -0
  70. data/lib/fun_ci/trunk/regions.rb +38 -0
  71. data/lib/fun_ci/trunk/resolver.rb +64 -0
  72. data/lib/fun_ci/trunk/shown.rb +47 -0
  73. data/lib/fun_ci/trunk/tip_reader.rb +39 -0
  74. data/lib/fun_ci/version.rb +1 -1
  75. metadata +30 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1ec70009244a286fd23264c7cd99becf5a5a437ee8fb2a636652d1050f2b4d78
4
- data.tar.gz: c354caa2011c5d7d8d39d2637f9c3423cae3c7e8a8c6fcce83232aa83d169cd5
3
+ metadata.gz: e5ea6a4ee9221da8020682980de5e05ad481540f66f9b187620ce121e16ec399
4
+ data.tar.gz: 27d73d17a2a6089d219f5bceaabe1a39385392c25d6e7ec095862eb84d637135
5
5
  SHA512:
6
- metadata.gz: aa78fbd513340af9d0cb8cd1c0d071a6eb2c487f4fb2216a7108acdc3b71f1ba19682cb7746f5fdb024a1a0a7ea8f32d15c76f41f64a900f16ab6fc8594beaec
7
- data.tar.gz: efb2b61788bd0e2ba361646a2391976c4fd29de3b91f1256d9425a5efc0687b6b731ccc6bbeeca22f52e12a091d705f10035298e2a622be63ef714a8f55f59d9
6
+ metadata.gz: eebf4b0852e2ecd06abcee7c8a5ce1b72d29705767b55771b349b4882f1f0d0655d5f96eb49ad6b11eee17216b5d5fa7f6f7db35c9f6da69a81d5151987f8d92
7
+ data.tar.gz: 3a3d674d245e9cd4fae2ecd6afbd39a645e36f06ea305f9454cd4676462da3128903cd559252b500cb187a12d398a8fe6b654d2c90df192680d5ed2ef6788f03
data/CHANGELOG.md CHANGED
@@ -7,6 +7,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.1.0] - 2026-09-30
11
+
12
+ ### Added
13
+ - Each run checks whether its commit would merge cleanly with the trunk:
14
+ `trunk:` in `.fun-ci/config`, or else the remote's default branch.
15
+ `status` and `wait` print where the commit stands, with its age; for a
16
+ conflict, the files and when and how to integrate. A conflict is not a
17
+ failure and changes no exit code, unless you ask with `--trunk`, which exits
18
+ 6 for a run that passed but conflicts and waits for the check.
19
+ `fun-ci why REV trunk` prints each conflicted region. `status --json`,
20
+ `runs` and `events` carry the check; `fun-ci check` names the trunk.
21
+ - fun-ci fetches the trunk itself, at most every five minutes, into
22
+ `refs/fun-ci/trunk/`, touching none of your refs, and says so the first time.
23
+ `trunk_fetch: false` stops it, and `trunk: none` turns the check off.
24
+ - The console says when a branch conflicts with the trunk, `conflicts with
25
+ main` under it, and plays a knot tying, and later untying, in the header.
26
+ This needs renderer 2.1.0, which the platform gems bring; with an older
27
+ renderer, as `cargo install` left it, the console works but shows no
28
+ conflicts.
29
+ - `fun-ci --help` lists `--raw`, which it left out.
30
+
31
+ ### Changed
32
+ - The console's table is new. Each branch has one row, its newest run's,
33
+ under its project's name, and reads as one phrase: the branch, a mark each
34
+ for lint, the build, the fast suite and the slow suite, joined into a track
35
+ that fills from left to right (`✓─✓─◆┄·`), what happened (`the fast suite
36
+ failed after 1.4s`, `running the fast and slow suites · 7s`) and when. A
37
+ legend at the top of the table names each stage and says what it is for,
38
+ with a line from each name down through every project to its column of
39
+ marks; when there is no room for it, one line above the keys names the
40
+ marks. What needs you, a failure, a timeout or a conflict, comes first, with
41
+ a stripe in the colour of why, and the first of it, or the row under the
42
+ cursor, sits in one card that breathes: wine when its row needs you, indigo
43
+ when the cursor only rests there. Passed rows are pale, and fold into one
44
+ line per project when nothing needs you. The header's night sky carries on
45
+ below the table, and the keys sit on caps. On a wide terminal the table
46
+ widens a little and sits in the middle; on a short one it folds and closes
47
+ up before it leaves anything out, and says how many passed rows it left out.
48
+ `c cancel` shows only when there is something to cancel.
49
+ - A stage's mark lights up and fades back when something happens to it. A
50
+ pass glows gold and eases back to the mark's own colour, a timeout pulses
51
+ amber twice, and a failure flares white on red and cools into the row. They
52
+ last as long however often the console redraws; they used to run faster
53
+ when boards arrived quickly.
54
+ - A run that ends washes its row in teal, coral or amber, draining from left
55
+ to right, and a band of light travels along each running row.
56
+ - The failure and pass banners keep the sky behind them, take their colours
57
+ from the table's, and name the stage in full (`>>> THE FAST SUITE FAILED
58
+ <<<`).
59
+
60
+ ### Deprecated
61
+ - `fun-ci trigger --no-validate`, the old name for `--background`, now goes
62
+ in 3.0 rather than 2.1 as 2.0 said: removing a flag in a minor release
63
+ would break the scripts and hooks that still pass it. It still works, and
64
+ says so on stderr.
65
+
66
+ ### Fixed
67
+ - A console row wider than the terminal, from a long branch name or a
68
+ failure word, wrapped onto the next line and pushed the board up, hiding
69
+ the newest run under the header. Rows are now cut to fit, the branch first,
70
+ and never wrap; the footer too.
71
+ - A branch named in Chinese, Japanese or Korean fills two columns a
72
+ character, but the console counted one, so its marks slid out of line with
73
+ the other rows' and a long name ran into them. Names are now measured, and
74
+ cut, by the columns they fill.
75
+ - `.fun-ci/console.log`, where the console notes what went wrong between it
76
+ and the renderer, was left for `git add` to sweep into a commit. The console
77
+ now writes a `.fun-ci/.gitignore` naming it, unless the project has one there.
78
+
10
79
  ## [2.0.2] - 2026-09-29
11
80
 
12
81
  ### Fixed
data/README.md CHANGED
@@ -59,7 +59,9 @@ Results are kept in SQLite under `$XDG_STATE_HOME/fun-ci/` (`~/.local/state/fun-
59
59
  fun-ci console
60
60
  ```
61
61
 
62
- The console lists your runs across branches, newest first, one row each: the commit, the branch, each stage with its time, and the outcome. Above them is a picture that tells you what is happening without reading a word.
62
+ The console shows where each branch stands, one row per branch under its project's name: a mark for each stage (lint, the build, the fast suite and the slow suite, named in a legend at the top of the table), what happened (`the fast suite failed after 1.4s`) and when. What needs you comes first, in one deep block; passed rows stay pale. Above them is a picture that tells you what is happening without reading a word.
63
+
64
+ ![The console's table on a busy board: a legend naming lint, the build, the fast suite and the slow suite over their columns of marks, three projects, a failed branch that conflicts with main in a deep red card, a timeout, a running branch and two pale passed ones](docs/screenshots/table.png)
63
65
 
64
66
  While a run runs, a rocket flies. Each milestone a run passes has its own set of scenes, so you can tell which one it was from across the room, and a failure has its own.
65
67
 
@@ -86,10 +88,12 @@ Five minutes after the latest run finished, the header goes quiet: a starry nigh
86
88
  |---|---|
87
89
  | ![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) |
88
90
 
91
+ 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
+
89
93
  Keys:
90
94
 
91
95
  - `j` and `k`, or the arrow keys, move the cursor down and up
92
- - `c` cancels the run under the cursor: a scheduled one at once, a running one once you answer `y` (`n` or `Esc` keeps it running)
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
93
97
  - `q` quits
94
98
 
95
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 come from the renderer's headless mode, drawn with its own bitmap font; in your terminal the text is in your terminal's font.
@@ -116,6 +120,7 @@ The section `fun-ci init` writes into `AGENTS.md` tells an agent to run that com
116
120
  | 3 | Still undecided (`wait --within 30s` gives up at your deadline) |
117
121
  | 4 | Superseded by a newer commit on the branch (`wait --follow-branch` moves on to it) |
118
122
  | 5 | No run for the commit |
123
+ | 6 | Passed, but conflicts with the trunk (only with `--trunk`) |
119
124
  | 64 | Usage error |
120
125
 
121
126
  A failed stage comes with its evidence. `status` and `wait` end with a digest and the command that shows the rest:
@@ -161,6 +166,27 @@ The whole output: fun-ci why 2330979 fast --raw
161
166
 
162
167
  `fun-ci runs` lists recent runs one line each, and `fun-ci events --follow` prints each run's events as JSON lines as they happen, for a supervising agent or a status bar.
163
168
 
169
+ ### Where your commit stands against the trunk
170
+
171
+ Each run also checks whether its commit would merge cleanly with your trunk: `trunk:` in `.fun-ci/config` if you name one, otherwise your remote's default branch. `status` and `wait` print where it stands, and for a conflict, the files and what to do about it:
172
+
173
+ ```
174
+ $ fun-ci status
175
+ fun-ci: f473fdb "Add shipping to the cart total" on feat/cart
176
+ lint passed 0.2s
177
+ build passed 0.3s
178
+ fast passed 0.1s
179
+ slow passed 0.2s (not needed)
180
+ trunk conflicts origin/main 0e15acf, fetched just now, 1 ahead, 1 behind
181
+ lib/cart.rb
182
+ Conflicts with origin/main in 1 file. When the task is done, integrate: git pull origin main (or git pull --rebase origin main if the branch isn't shared)
183
+ fun-ci why f473fdb trunk
184
+ ```
185
+
186
+ A conflict is not a failure, so that run still exits 0. An agent that should integrate before calling its work done asks with `--trunk`, and gets 6 for a run that passed but conflicts; `fun-ci init` tells agents to. `fun-ci why f473fdb trunk` merges again and prints each conflicted region with the lines around it.
187
+
188
+ fun-ci fetches the trunk itself, at most every five minutes, into a ref of its own (`refs/fun-ci/trunk/...`), so your `origin/main` and `git status` are left as they were. The first fetch says so. `trunk_fetch: false` in `.fun-ci/config` stops it, `trunk_fetch: 30m` spaces it out, and `trunk: none` turns the check off. A trunk fetched over an hour ago, or whose last fetch failed, is marked STALE wherever it is shown.
189
+
164
190
  ### What fun-ci keeps when a stage fails
165
191
 
166
192
  With no configuration, fun-ci runs the presets for the tools your project has when a failure shows them. There are presets for the test runners, compilers, linters and type checkers of every stack `fun-ci init` knows, and for JSON logs from logstash-logback-encoder, ECS, pino and structlog; [docs/stacks.md](docs/stacks.md#presets) lists each one with what it picks out and when it runs. Each preset is checked against the recorded output of a real failing run of its tool. `fun-ci check` lists the ones that apply to your project.
@@ -197,9 +223,9 @@ fun-ci install-hooks Install post-commit and pre-push
197
223
  fun-ci install-hooks post-commit Install a single hook type
198
224
  fun-ci check Verify .fun-ci/ setup
199
225
  fun-ci console Watch the runs
200
- fun-ci status [commit] Where a commit's run stands; the verdict is the exit code
226
+ fun-ci status [commit] [--trunk] Where a commit's run stands; the verdict is the exit code
201
227
  fun-ci wait [commit] Wait until that verdict is decided, then exit with it
202
- fun-ci why [commit] [stage] Everything kept about why a stage failed
228
+ fun-ci why [commit] [stage|trunk] Everything kept about why a stage failed, or the conflict with the trunk
203
229
  fun-ci runs This project's recent runs, newest first
204
230
  fun-ci events [--follow] The runs' events as JSON lines
205
231
  fun-ci extract <stage> --output <file> Try a stage's extractors on a saved output
@@ -215,7 +241,7 @@ fun-ci prune Remove fun-ci's worktrees when n
215
241
  - Run `fun-ci install-hooks` again. The background pipeline now runs after the commit (`post-commit`), so it tests the commit you just made; the `pre-commit` hook tested the one before it. Until you do, each run tests the commit before yours, and `fun-ci check` warns you. Installing removes the `pre-commit` hook fun-ci 1.x wrote and leaves a hook of your own alone (`fun-ci check` warns if yours still calls fun-ci). The new `pre-push` hook waits for the verdict instead of running the pipeline again.
216
242
  - The stages run in a worktree at the commit, not in your checkout. A script that relied on uncommitted files in your checkout won't see them any more.
217
243
  - The database moved from `$TMPDIR` to `$XDG_STATE_HOME/fun-ci/`. Runs recorded by 1.x are not carried over.
218
- - `fun-ci-trigger` and `fun-ci-tui` are gone; use `fun-ci trigger` and `fun-ci console`. `fun-ci trigger --no-validate` still works until 2.1, as the old name for `--background`.
244
+ - `fun-ci-trigger` and `fun-ci-tui` are gone; use `fun-ci trigger` and `fun-ci console`. `fun-ci trigger --no-validate` still works until 3.0, as the old name for `--background`.
219
245
  - The console needs `fun-ci-renderer`, which the platform gems include (see [Watching: the console](#watching-the-console)).
220
246
 
221
247
  The whole list is in [CHANGELOG.md](CHANGELOG.md).
@@ -3,6 +3,7 @@
3
3
  require_relative "options"
4
4
  require_relative "reports"
5
5
  require_relative "exit_code"
6
+ require_relative "trunk_verdict"
6
7
 
7
8
  module FunCi
8
9
  module Agent
@@ -16,7 +17,19 @@ module FunCi
16
17
  @context.git.resolve(rev) || raise(Options::Invalid, "git can't find the commit '#{rev}'")
17
18
  end
18
19
 
19
- def reports = @reports ||= Reports.new(@context.db, @context.git)
20
+ # The commit's newest run, judged on the trunk too when the agent asked
21
+ # (--trunk), checked again first when the trunk has moved since.
22
+ def report_for(sha, options)
23
+ report = reports.for(sha, options.need)
24
+ return report unless options.trunk && report
25
+
26
+ report = reports.recheck_trunk(sha).then { reports.for(sha, options.need) } if report.trunk&.moved_to
27
+ report.with(verdict: TrunkVerdict.of(report.verdict, report.trunk))
28
+ end
29
+
30
+ def output(options) = Output.new(@context.io.stdout, json: options.json, trunk: options.trunk)
31
+
32
+ def reports = @reports ||= Reports.new(@context.db, @context.git, @context.clock, @context.trunk)
20
33
 
21
34
  def usage(message)
22
35
  @context.io.stderr.puts "fun-ci #{self.class::NAME}: #{message}"
@@ -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 "../trunk/local"
13
14
 
14
15
  module FunCi
15
16
  module Agent
@@ -4,7 +4,8 @@ module FunCi
4
4
  module Agent
5
5
  # What every agent command answers from: the database, the project's git,
6
6
  # where to print, the time (#now, #pause) and the pipeline (#start a run,
7
- # #watch the database for slow suites that died).
8
- Context = Data.define(:db, :git, :io, :clock, :pipeline)
7
+ # #watch the database for slow suites that died) and the trunk as the
8
+ # project's git has it now (#now_at(tip), #check(sha, fetches)).
9
+ Context = Data.define(:db, :git, :io, :clock, :pipeline, :trunk)
9
10
  end
10
11
  end
@@ -5,10 +5,16 @@ module FunCi
5
5
  # What happened to a project's runs between two looks at them
6
6
  # (acceptance-tests.md, AT-9.16), oldest run first: a run started, each
7
7
  # stage that finished in the order it finished, a run that passed or
8
- # failed, a run a newer commit superseded. The first look is at nothing.
8
+ # failed, a run a newer commit superseded, a check of its commit against
9
+ # the trunk recorded or made again. The first look is at nothing.
9
10
  module Events
10
- # finished: [{ stage:, state:, seconds: }, ...] in the order they finished.
11
- RunState = Data.define(:id, :sha, :branch, :status, :finished, :superseded_by)
11
+ # finished: [{ stage:, state:, seconds: }, ...] in the order they finished;
12
+ # trunk: the latest check of its commit, as status --json gives it, or nil.
13
+ RunState = Data.define(:id, :sha, :branch, :status, :finished, :superseded_by, :trunk)
14
+
15
+ class RunState
16
+ def initialize(trunk: nil, **) = super
17
+ end
12
18
  OUTCOMES = { "completed" => "passed", "failed" => "failed" }.freeze
13
19
  FAILED_STATES = %w[failed over_budget].freeze
14
20
 
@@ -27,7 +33,7 @@ module FunCi
27
33
  about = { commit: run.sha, branch: run.branch }
28
34
  [*(was ? [] : [{ event: "run_started", **about }]),
29
35
  *newly_finished(run, was).map { |stage| { event: "stage_finished", **about, **stage } },
30
- *ended(run, was, about)]
36
+ *checked(run, was, about), *ended(run, was, about)]
31
37
  end
32
38
 
33
39
  def self.newly_finished(run, was)
@@ -35,13 +41,21 @@ module FunCi
35
41
  run.finished.reject { |stage| seen.include?(stage[:stage]) }
36
42
  end
37
43
 
44
+ # A check is new when its state or its trunk tip is.
45
+ def self.checked(run, was, about)
46
+ trunk = run.trunk
47
+ return [] if trunk.nil? || trunk.values_at(:state, :sha) == was&.trunk&.values_at(:state, :sha)
48
+
49
+ [{ event: "trunk_checked", **about, trunk: trunk }]
50
+ end
51
+
38
52
  def self.ended(run, was, about)
39
53
  return [] if was&.status == run.status
40
54
  return [{ event: "run_superseded", **about, by_commit: run.superseded_by }] if run.status == "cancelled"
41
55
 
42
56
  OUTCOMES.key?(run.status) ? [{ event: "run_finished", **about, state: OUTCOMES.fetch(run.status) }] : []
43
57
  end
44
- private_class_method :of, :newly_finished, :ended
58
+ private_class_method :of, :newly_finished, :checked, :ended
45
59
  end
46
60
  end
47
61
  end
@@ -55,7 +55,7 @@ module FunCi
55
55
  @context.io.stdout.flush
56
56
  end
57
57
 
58
- def snapshots = @snapshots ||= Snapshots.new(@context.db, @context.git)
58
+ def snapshots = @snapshots ||= Snapshots.new(@context.db, @context.git, @context.clock)
59
59
  end
60
60
  end
61
61
  end
@@ -4,7 +4,7 @@ module FunCi
4
4
  module Agent
5
5
  # What `status` and `wait` exit with (acceptance-tests.md, §9).
6
6
  module ExitCode
7
- FOR = { passed: 0, failed: 1, over_budget: 2, undecided: 3, superseded: 4, unknown: 5 }.freeze
7
+ FOR = { passed: 0, failed: 1, over_budget: 2, undecided: 3, superseded: 4, unknown: 5, conflicts: 6 }.freeze
8
8
  USAGE = 64
9
9
  end
10
10
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Agent
5
+ # What to do about a conflict with the trunk, built from the trunk that was
6
+ # resolved: a pull that fetches first, so the trunk integrated is never
7
+ # older than the one checked. A rebase of a pushed branch needs a force
8
+ # push, so off the trunk branch a merge comes first.
9
+ module NextStep
10
+ def self.line(tip, files, branch:)
11
+ count = files.size == 1 ? "1 file" : "#{files.size} files"
12
+ "Conflicts with #{tip.ref} in #{count}. When the task is done, integrate: #{command(tip, branch)}"
13
+ end
14
+
15
+ def self.command(tip, branch)
16
+ return "git merge #{tip.branch}" unless tip.remote
17
+
18
+ pull = "#{tip.remote} #{tip.branch}"
19
+ return "git pull --rebase #{pull}" if branch == tip.branch
20
+
21
+ "git pull #{pull} (or git pull --rebase #{pull} if the branch isn't shared)"
22
+ end
23
+ private_class_method :command
24
+ end
25
+ end
26
+ end
@@ -11,16 +11,18 @@ module FunCi
11
11
  module Options
12
12
  class Invalid < StandardError; end
13
13
 
14
- Parsed = Data.define(:rev, :stage, :need, :json, :within, :follow_branch, :limit, :branch, :follow, :only, :raw)
14
+ Parsed = Data.define(:rev, :stage, :need, :json, :within, :follow_branch, :limit, :branch, :follow, :only, :raw,
15
+ :trunk)
15
16
  DEFAULTS = { need: "fast", json: false, within: nil, follow_branch: false, limit: 10, branch: nil,
16
- follow: false, only: nil, raw: false }.freeze
17
+ follow: false, only: nil, raw: false, trunk: false }.freeze
17
18
  SWITCHES = { need: ["--need LEVEL"], json: ["--json"], within: ["--within DURATION"], raw: ["--raw"],
18
19
  follow_branch: ["--follow-branch"], limit: ["-n N", Integer], branch: ["--branch NAME"],
19
- follow: ["--follow"], only: ["--only KIND"] }.freeze
20
+ follow: ["--follow"], only: ["--only KIND"], trunk: ["--trunk"] }.freeze
20
21
  FILTERS = %w[failures].freeze
21
22
  MULTIPLIERS = { "" => 1, "s" => 1, "m" => 60 }.freeze
22
23
 
23
- STAGES = %w[lint build fast slow].freeze
24
+ # What `why` explains: a stage, or the run's conflict with the trunk.
25
+ STAGES = %w[lint build fast slow trunk].freeze
24
26
 
25
27
  def self.parse(args, takes:)
26
28
  values = DEFAULTS.dup
@@ -7,20 +7,25 @@ require_relative "why_json"
7
7
  require_relative "status_json"
8
8
  require_relative "runs_text"
9
9
  require_relative "exit_code"
10
+ require_relative "evidence_text"
11
+ require_relative "trunk_why_json"
10
12
 
11
13
  module FunCi
12
14
  module Agent
13
15
  # Prints what an agent command found, as text or, with --json, as JSON.
14
16
  class Output
17
+ NO_CONFLICT = "No conflict with the trunk to explain."
15
18
  NO_STAGE = "No stage it needs failed or ran over budget; name one to see it: fun-ci why REV STAGE"
16
19
 
17
- def initialize(stdout, json:)
20
+ # trunk: whether the agent asked about the trunk (--trunk).
21
+ def initialize(stdout, json:, trunk: false)
18
22
  @stdout = stdout
19
23
  @json = json
24
+ @trunk = trunk
20
25
  end
21
26
 
22
27
  def report(report)
23
- @json ? print_json(StatusJson.document(report)) : print_lines(StatusText.lines(report))
28
+ @json ? print_json(StatusJson.document(report)) : print_lines(StatusText.lines(report, trunk: @trunk))
24
29
  end
25
30
 
26
31
  # Everything kept about the stage named, or says no stage decided the verdict.
@@ -32,6 +37,14 @@ module FunCi
32
37
  print_lines(WhyText.lines(report, stage))
33
38
  end
34
39
 
40
+ # The run's conflict with the trunk; document nil when there is none to explain.
41
+ def why_trunk(report, document)
42
+ return print_json(TrunkWhyJson.document(report, document)) if @json
43
+
44
+ print_lines([StatusText.header(report), *StatusText.trunk(report, true),
45
+ *(document ? EvidenceText.lines(document) : ["", NO_CONFLICT])])
46
+ end
47
+
35
48
  # Says the commit has no run, and answers the exit code for that.
36
49
  def unknown(sha)
37
50
  @json ? print_json(StatusJson.unknown(sha)) : print_lines(["fun-ci: no run for #{sha[0, 7]} in this project."])
@@ -6,7 +6,7 @@ require_relative "../persistence/stage_job"
6
6
 
7
7
  module FunCi
8
8
  module Agent
9
- RunReport = Data.define(:sha, :subject, :branch, :need, :stages, :verdict, :deciding, :superseded_by)
9
+ RunReport = Data.define(:sha, :subject, :branch, :need, :stages, :verdict, :deciding, :superseded_by, :trunk)
10
10
 
11
11
  # One stage of a run, as an agent is told it.
12
12
  class RunReport
@@ -1,23 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../persistence/project_runs"
4
- require_relative "../persistence/stage_job"
5
- require_relative "../persistence/raw_outputs"
4
+ require_relative "run_details"
6
5
  require_relative "run_report"
7
6
 
8
7
  module FunCi
9
8
  module Agent
10
9
  # The runs of the project git names, as an agent is told them.
11
10
  class Reports
12
- def initialize(db, git)
11
+ # trunk: where the project's trunk is now (#now_at(tip)), and checks against it (#check(sha, fetches)).
12
+ def initialize(db, git, clock, trunk)
13
13
  @db = db
14
14
  @git = git
15
- @runs = Persistence::ProjectRuns.new(db, git.toplevel)
16
- @raw = Persistence::RawOutputs.beside(db.filename("main"))
15
+ project = git.toplevel
16
+ @runs = Persistence::ProjectRuns.new(db, project)
17
+ @details = RunDetails.new(db, TrunkReading.new(db, project, clock, trunk))
17
18
  end
18
19
 
19
20
  # What the stage kept of its raw output, or nil.
20
- def raw_output(stage) = stage.id && @raw.read(stage.id)
21
+ def raw_output(stage) = @details.raw_output(stage)
21
22
 
22
23
  # The commit's newest run, or nil.
23
24
  def for(sha, need)
@@ -25,6 +26,9 @@ module FunCi
25
26
  run && of(run, need)
26
27
  end
27
28
 
29
+ # Checks the commit against where the trunk is now, and keeps the check.
30
+ def recheck_trunk(sha) = @details.recheck_trunk(sha)
31
+
28
32
  # Notes that an agent is waiting on the commit's newest run, as of `at`.
29
33
  def mark_waited(sha, at)
30
34
  run = @runs.latest_of_commit(sha)
@@ -38,9 +42,9 @@ module FunCi
38
42
 
39
43
  def of(run, need)
40
44
  superseded_by = run[:status] == "cancelled" ? @runs.superseded_by(run) : nil
41
- jobs = Persistence::StageJob.for_run(@db, run[:id]).map { |job| job.merge(raw_bytes: @raw.bytes(job[:id])) }
42
- RunReport.build(run: run, jobs: jobs, need: need,
43
- commit: { subject: @git.subject(run[:commit_hash]), superseded_by: superseded_by })
45
+ RunReport.build(run: run, jobs: @details.jobs(run), need: need,
46
+ commit: { subject: @git.subject(run[:commit_hash]), superseded_by: superseded_by,
47
+ trunk: @details.trunk(run) })
44
48
  end
45
49
  end
46
50
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../persistence/stage_job"
4
+ require_relative "../persistence/raw_outputs"
5
+ require_relative "trunk_reading"
6
+
7
+ module FunCi
8
+ module Agent
9
+ # What is recorded about one of a project's runs beyond its row: its
10
+ # stages, the raw output they kept, and its commit's check against the trunk.
11
+ class RunDetails
12
+ # trunk: reads the run's trunk (a TrunkReading).
13
+ def initialize(db, trunk)
14
+ @db = db
15
+ @raw = Persistence::RawOutputs.beside(db.filename("main"))
16
+ @trunk = trunk
17
+ end
18
+
19
+ def jobs(run)
20
+ Persistence::StageJob.for_run(@db, run[:id]).map { |job| job.merge(raw_bytes: @raw.bytes(job[:id])) }
21
+ end
22
+
23
+ # What the stage kept of its raw output, or nil.
24
+ def raw_output(stage) = stage.id && @raw.read(stage.id)
25
+
26
+ # How the run's commit stands against the trunk, or nil for a run that began no check.
27
+ def trunk(run) = @trunk.for(run)
28
+ def recheck_trunk(sha) = @trunk.recheck(sha)
29
+ end
30
+ end
31
+ end
@@ -10,10 +10,11 @@ module FunCi
10
10
  class RunReport
11
11
  STAGES = %w[lint build fast slow].freeze
12
12
 
13
- # deciding: the stage whose failure or overrun decided the verdict, if one did.
14
- def initialize(deciding: nil, **) = super
13
+ # deciding: the stage whose failure or overrun decided the verdict, if one did;
14
+ # trunk: how its commit stands against the trunk (a Trunk::Shown), if it was checked.
15
+ def initialize(deciding: nil, trunk: nil, **) = super
15
16
 
16
- # commit: the run's subject and the commit that superseded it, if any.
17
+ # commit: the run's subject, the commit that superseded it, if any, and its trunk check.
17
18
  def self.build(run:, jobs:, need:, commit:)
18
19
  verdict = Verdict.decide(run_status: run[:status], stages: jobs, need: need)
19
20
  new(sha: run[:commit_hash], branch: run[:branch], need: need, stages: stages(jobs), verdict: verdict,
@@ -5,7 +5,8 @@ require_relative "run_report"
5
5
  module FunCi
6
6
  module Agent
7
7
  # One line per run, the columns lined up: short SHA, branch, age, each
8
- # stage's outcome in a word of four letters at most, and the subject.
8
+ # stage's outcome in a word of four letters at most, a conflict with the
9
+ # trunk, as of the run's check, and the subject.
9
10
  module RunsText
10
11
  WORDS = { "passed" => "ok", "failed" => "FAIL", "over_budget" => "OVER", "running" => "...", "waiting" => "-",
11
12
  "cancelled" => "x" }.freeze
@@ -18,10 +19,12 @@ module FunCi
18
19
 
19
20
  def self.line(report, age, (branch_width, age_width))
20
21
  stages = report.stages.map { |stage| "#{stage.name} #{WORDS.fetch(stage.state).ljust(4)}" }.join(" ")
21
- "#{report.sha[0,
22
- 7]} #{report.branch.ljust(branch_width)} #{age.ljust(age_width)} #{stages} #{report.subject}"
22
+ "#{report.sha[0, 7]} #{report.branch.ljust(branch_width)} #{age.ljust(age_width)} #{stages} " \
23
+ "#{conflict(report.trunk)}#{report.subject}"
23
24
  end
24
- private_class_method :line
25
+
26
+ def self.conflict(trunk) = trunk&.state == "conflicts" ? "conflicts #{trunk.check.tip.ref} " : ""
27
+ private_class_method :line, :conflict
25
28
  end
26
29
  end
27
30
  end
@@ -4,15 +4,19 @@ require_relative "../persistence/project_runs"
4
4
  require_relative "../persistence/stage_job"
5
5
  require_relative "run_report"
6
6
  require_relative "events"
7
+ require_relative "trunk_reading"
8
+ require_relative "trunk_json"
7
9
 
8
10
  module FunCi
9
11
  module Agent
10
12
  # Looks at the newest runs of the project git names, as `events` compares
11
13
  # them: { run id => Events::RunState }.
12
14
  class Snapshots
13
- def initialize(db, git)
15
+ def initialize(db, git, clock)
16
+ project = git.toplevel
14
17
  @db = db
15
- @runs = Persistence::ProjectRuns.new(db, git.toplevel)
18
+ @runs = Persistence::ProjectRuns.new(db, project)
19
+ @trunk = TrunkReading.new(db, project, clock, TrunkReading::UNREAD)
16
20
  end
17
21
 
18
22
  def take(limit:)
@@ -24,7 +28,13 @@ module FunCi
24
28
  def state_of(run)
25
29
  superseded_by = run[:status] == "cancelled" ? @runs.superseded_by(run) : nil
26
30
  Events::RunState.new(id: run[:id], sha: run[:commit_hash], branch: run[:branch], status: run[:status],
27
- finished: finished(run), superseded_by: superseded_by)
31
+ finished: finished(run), superseded_by: superseded_by, trunk: trunk(run))
32
+ end
33
+
34
+ # The latest check recorded; a check still going has none yet.
35
+ def trunk(run)
36
+ shown = @trunk.for(run)
37
+ shown&.check ? TrunkJson.document(shown) : nil
28
38
  end
29
39
 
30
40
  def finished(run)
@@ -16,9 +16,9 @@ module FunCi
16
16
  end
17
17
 
18
18
  def run(args)
19
- options = Options.parse(args, takes: %i[need json])
19
+ options = Options.parse(args, takes: %i[need json trunk])
20
20
  sha = resolve(options.rev)
21
- answer(sha, reports.for(sha, options.need), Output.new(@context.io.stdout, json: options.json))
21
+ answer(sha, report_for(sha, options), output(options))
22
22
  rescue Options::Invalid => e
23
23
  usage(e.message)
24
24
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "run_report"
4
+ require_relative "trunk_json"
4
5
 
5
6
  module FunCi
6
7
  module Agent
@@ -12,7 +13,7 @@ module FunCi
12
13
  def self.document(report)
13
14
  { schema: SCHEMA, commit: { sha: report.sha, branch: report.branch, subject: report.subject },
14
15
  need: report.need, verdict: report.verdict.to_s, stages: report.stages.map { |stage| stage(stage) },
15
- superseded_by: report.superseded_by }
16
+ superseded_by: report.superseded_by, trunk: report.trunk && TrunkJson.document(report.trunk) }
16
17
  end
17
18
 
18
19
  def self.unknown(sha) = { schema: SCHEMA, commit: { sha: sha }, verdict: "unknown" }
@@ -3,6 +3,7 @@
3
3
  require_relative "run_report"
4
4
  require_relative "verdict"
5
5
  require_relative "digest"
6
+ require_relative "trunk_text"
6
7
 
7
8
  module FunCi
8
9
  module Agent
@@ -11,10 +12,19 @@ module FunCi
11
12
  module StatusText
12
13
  WORDS = { "failed" => "FAILED", "over_budget" => "OVER BUDGET" }.freeze
13
14
 
14
- def self.lines(report)
15
+ # trunk: whether the agent asked about the trunk, which says so while its check is going.
16
+ def self.lines(report, trunk: false)
15
17
  needed = Verdict::LEVELS.fetch(report.need)
16
18
  [header(report), *report.stages.map { |stage| stage_line(stage, needed) }, *Digest.lines(report.stages, needed),
17
- *footer(report)]
19
+ *trunk(report, trunk), *footer(report)]
20
+ end
21
+
22
+ # The trunk lines; nothing while the check is going, unless asked about
23
+ # (right after a commit it would say nothing useful).
24
+ def self.trunk(report, asked)
25
+ return [] if report.trunk.nil? || (report.trunk.state == "checking" && !asked)
26
+
27
+ TrunkText.lines(report.trunk, branch: report.branch, next_step: report.deciding.nil?)
18
28
  end
19
29
 
20
30
  def self.header(report) = %(fun-ci: #{report.sha[0, 7]} "#{report.subject}" on #{report.branch})
@@ -27,6 +37,7 @@ module FunCi
27
37
 
28
38
  def self.footer(report)
29
39
  return ["fun-ci why #{report.sha[0, 7]} #{report.deciding}"] if report.deciding
40
+ return ["fun-ci why #{report.sha[0, 7]} trunk"] if report.trunk&.state == "conflicts"
30
41
  return [] unless report.verdict == :superseded && report.superseded_by
31
42
 
32
43
  ["Superseded by #{report.superseded_by[0, 7]}."]