fun_ci 2.0.1-aarch64-linux → 2.1.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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +101 -0
  3. data/README.md +33 -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/gradle_linter_detector.rb +23 -0
  59. data/lib/fun_ci/setup/installer.rb +10 -10
  60. data/lib/fun_ci/setup/lint_detection.rb +32 -0
  61. data/lib/fun_ci/setup/maven_linter_detector.rb +4 -2
  62. data/lib/fun_ci/setup/project_config.rb +3 -1
  63. data/lib/fun_ci/setup/settings.rb +35 -3
  64. data/lib/fun_ci/setup/setup_checker.rb +7 -2
  65. data/lib/fun_ci/setup/stage_templates/managed.rb +12 -7
  66. data/lib/fun_ci/setup/stage_templates/native.rb +1 -1
  67. data/lib/fun_ci/setup/stage_templates/shell.rb +12 -3
  68. data/lib/fun_ci/setup/stage_templates.rb +1 -1
  69. data/lib/fun_ci/setup/trunk_report.rb +28 -0
  70. data/lib/fun_ci/trunk/check.rb +56 -0
  71. data/lib/fun_ci/trunk/checker.rb +91 -0
  72. data/lib/fun_ci/trunk/description.rb +52 -0
  73. data/lib/fun_ci/trunk/fetch.rb +58 -0
  74. data/lib/fun_ci/trunk/first_fetch.rb +27 -0
  75. data/lib/fun_ci/trunk/git.rb +102 -0
  76. data/lib/fun_ci/trunk/local.rb +58 -0
  77. data/lib/fun_ci/trunk/merge_check.rb +54 -0
  78. data/lib/fun_ci/trunk/regions.rb +38 -0
  79. data/lib/fun_ci/trunk/resolver.rb +64 -0
  80. data/lib/fun_ci/trunk/shown.rb +47 -0
  81. data/lib/fun_ci/trunk/tip_reader.rb +39 -0
  82. data/lib/fun_ci/version.rb +1 -1
  83. data/libexec/fun-ci-renderer +0 -0
  84. metadata +32 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6076317a7c3787ec1407a627184d14153927889a82ffbe6e7ba8281fb23cf3a0
4
- data.tar.gz: 4ac1e0047ec448423d4feaa53836354377c05c21385b75f13dc6e96a8e235fb3
3
+ metadata.gz: 2bb4d24e9a9219546727c463c5df0f702cdf7a08d5be7da0c73983764e1e35c4
4
+ data.tar.gz: 8a1ce072e13d6f147b78698cfbb6f0ad27a1a876f9de9c40ca7756cfee8a0239
5
5
  SHA512:
6
- metadata.gz: 0b692292fe3d327ec48f238053a8c52ad6e724a556f11996ecd4d45e3267ea75494926aa0531ea1872737510d98ec3ea400c9e9a2ae642dac3e955b0a125712c
7
- data.tar.gz: 90c71c3421408a1fa57e893d4c162f6f698a9098d145be9b159a0792f862ab8c1ac24d073d6de60e66b4343cc1955364eeba257b0c1ed072a63ca23424ccaabb
6
+ metadata.gz: 8363c78eb29639cca19887eea9cf0001405e8ced3e7ed4f39ac62a72fe72f72bd7bd4cae119cfc2db11030954008fdcf58017ab6950d0999a2d11d0ecbda388c
7
+ data.tar.gz: 03673b7c256a5cac45ded403f299c231db52853634086672e5aad6e4610f87b7ee04e440bba15e158772b1ee045d9ec32c3b03a70c51dc1cdcec5b6dee8422db
data/CHANGELOG.md CHANGED
@@ -7,6 +7,107 @@ 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
+
79
+ ## [2.0.2] - 2026-09-29
80
+
81
+ ### Fixed
82
+ - In a Gradle project set up by `fun-ci init`, the fast suite could fail
83
+ before running a test, with Gradle unable to hash a file in its own build
84
+ directory (`build/kotlin/compileTestKotlin/...`). The fast and slow suites
85
+ run side by side in one worktree, and both compiled the test code into the
86
+ same place, each deleting what the other was reading. The build script now
87
+ compiles the test code (`./gradlew assemble testClasses`), so neither suite
88
+ has to. Maven, .NET, Elixir, Dart and Swift projects could clash the same
89
+ way, and their scripts change too: Maven builds with `mvn test-compile` and
90
+ runs `mvn surefire:test` and `mvn failsafe:integration-test
91
+ failsafe:verify`, so its slow suite now runs only the integration tests
92
+ failsafe finds (the classes named `*IT`) rather than every test again;
93
+ Elixir compiles its test environment in the build; Dart precompiles its test
94
+ runner and runs the slow suite from source; the .NET and Swift suites run
95
+ with `--no-build` and `--skip-build`.
96
+ - Lint runs side by side with the build, and the lint `fun-ci init` wrote for
97
+ Maven (`mvn verify -DskipTests`) and .NET (`dotnet format`) compiled into
98
+ the same directories as the build. Lint now works on the source code alone.
99
+ Maven's runs the linter the POM configures (detekt, ktlint, checkstyle or
100
+ PMD) or else `mvn validate`, and no longer picks spotbugs, which reads
101
+ compiled classes. .NET's checks whitespace with `dotnet format whitespace
102
+ --folder`. Gradle's runs ktlint, spotless or detekt when the build applies
103
+ one, and otherwise says there is none, where it ran `check`, which compiles.
104
+ A linter that needs compiled code goes in `build.sh` or `fast.sh`.
105
+ - Which stages run side by side, and so what each may read and write, is now
106
+ spelled out: in a new section of `docs/design.md`, in the README, in what
107
+ `fun-ci init` prints, and in a comment at the top of each script it writes.
108
+ Scripts already in `.fun-ci/` are left as they are, so an existing project
109
+ picks up these fixes by editing them to match `docs/stacks.md`.
110
+
10
111
  ## [2.0.1] - 2026-09-29
11
112
 
12
113
  ### Fixed
data/README.md CHANGED
@@ -43,6 +43,8 @@ Upgrading from 1.x? Read [Upgrading to 2.0](#upgrading-to-20) first.
43
43
  | Fast | 10 s | after lint and build pass | the push |
44
44
  | Slow | 5 min | in the background, beside the fast suite | nothing |
45
45
 
46
+ Lint and build run side by side on the commit's checkout, and then the fast and slow suites run side by side on what the build left, all in one worktree. Two stages running at once must not write the same files, so `lint.sh` works on the source code only and `build.sh` compiles everything the suites need, test code included. A linter that needs compiled code belongs in `build.sh` or `fast.sh`. The scripts `fun-ci init` writes keep to this; [Stages side by side](docs/design.md#stages-side-by-side) has the details.
47
+
46
48
  A stage that overruns its budget is killed and reported as over budget, in yellow rather than red: a fast suite that takes 12 seconds has grown too heavy, which is a different problem from a failing test.
47
49
 
48
50
  After each commit, the `post-commit` hook starts the pipeline in the background and returns at once, so a commit is never held up. The `pre-push` hook waits for the verdict of each commit you push and stops the push if lint, build or the fast suite failed. A commit whose run has already finished goes straight through. If fun-ci isn't installed, both hooks say so and let git carry on.
@@ -57,7 +59,9 @@ Results are kept in SQLite under `$XDG_STATE_HOME/fun-ci/` (`~/.local/state/fun-
57
59
  fun-ci console
58
60
  ```
59
61
 
60
- 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)
61
65
 
62
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.
63
67
 
@@ -84,10 +88,12 @@ Five minutes after the latest run finished, the header goes quiet: a starry nigh
84
88
  |---|---|
85
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) |
86
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
+
87
93
  Keys:
88
94
 
89
95
  - `j` and `k`, or the arrow keys, move the cursor down and up
90
- - `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
91
97
  - `q` quits
92
98
 
93
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.
@@ -114,6 +120,7 @@ The section `fun-ci init` writes into `AGENTS.md` tells an agent to run that com
114
120
  | 3 | Still undecided (`wait --within 30s` gives up at your deadline) |
115
121
  | 4 | Superseded by a newer commit on the branch (`wait --follow-branch` moves on to it) |
116
122
  | 5 | No run for the commit |
123
+ | 6 | Passed, but conflicts with the trunk (only with `--trunk`) |
117
124
  | 64 | Usage error |
118
125
 
119
126
  A failed stage comes with its evidence. `status` and `wait` end with a digest and the command that shows the rest:
@@ -159,6 +166,27 @@ The whole output: fun-ci why 2330979 fast --raw
159
166
 
160
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.
161
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
+
162
190
  ### What fun-ci keeps when a stage fails
163
191
 
164
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.
@@ -195,9 +223,9 @@ fun-ci install-hooks Install post-commit and pre-push
195
223
  fun-ci install-hooks post-commit Install a single hook type
196
224
  fun-ci check Verify .fun-ci/ setup
197
225
  fun-ci console Watch the runs
198
- 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
199
227
  fun-ci wait [commit] Wait until that verdict is decided, then exit with it
200
- 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
201
229
  fun-ci runs This project's recent runs, newest first
202
230
  fun-ci events [--follow] The runs' events as JSON lines
203
231
  fun-ci extract <stage> --output <file> Try a stage's extractors on a saved output
@@ -213,7 +241,7 @@ fun-ci prune Remove fun-ci's worktrees when n
213
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.
214
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.
215
243
  - The database moved from `$TMPDIR` to `$XDG_STATE_HOME/fun-ci/`. Runs recorded by 1.x are not carried over.
216
- - `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`.
217
245
  - The console needs `fun-ci-renderer`, which the platform gems include (see [Watching: the console](#watching-the-console)).
218
246
 
219
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" }