shiplog 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +126 -4
  4. data/app/controllers/shiplog/admin/syncs_controller.rb +3 -2
  5. data/app/controllers/shiplog/changelogs_controller.rb +11 -1
  6. data/app/controllers/shiplog/public_controller.rb +3 -1
  7. data/app/controllers/shiplog/roadmaps_controller.rb +25 -2
  8. data/app/frontend/shiplog/shiplog.css +121 -6
  9. data/app/helpers/shiplog/callable_helper.rb +12 -0
  10. data/app/helpers/shiplog/frontend_helper.rb +1 -0
  11. data/app/helpers/shiplog/roadmap_helper.rb +15 -0
  12. data/app/helpers/shiplog/switcher_helper.rb +101 -0
  13. data/app/jobs/shiplog/refresh_job.rb +1 -0
  14. data/app/jobs/shiplog/scan_changes_job.rb +19 -5
  15. data/app/jobs/shiplog/sync_job.rb +1 -1
  16. data/app/models/shiplog/change_candidates.rb +39 -4
  17. data/app/models/shiplog/entry.rb +17 -0
  18. data/app/models/shiplog/pull_request.rb +4 -0
  19. data/app/models/shiplog/pull_request_upgrade.rb +5 -0
  20. data/app/models/shiplog/rewriter/prompt.rb +27 -3
  21. data/app/models/shiplog/rewriter.rb +20 -1
  22. data/app/models/shiplog/sync/pull_request_importer.rb +23 -1
  23. data/app/models/shiplog/sync/repository_sync.rb +2 -0
  24. data/app/models/shiplog/sync/upgrade_detector.rb +203 -0
  25. data/app/views/layouts/shiplog/application.html.erb +1 -0
  26. data/app/views/shiplog/_page_header.html.erb +2 -1
  27. data/app/views/shiplog/_switcher.html.erb +13 -0
  28. data/app/views/shiplog/admin/entries/_entry_row.html.erb +1 -0
  29. data/app/views/shiplog/changelogs/_search.html.erb +16 -0
  30. data/app/views/shiplog/changelogs/show.html.erb +11 -3
  31. data/app/views/shiplog/roadmaps/_card.html.erb +12 -1
  32. data/app/views/shiplog/roadmaps/_column.html.erb +2 -2
  33. data/app/views/shiplog/roadmaps/_shipped.html.erb +15 -0
  34. data/app/views/shiplog/roadmaps/show.html.erb +9 -0
  35. data/config/locales/en.yml +20 -0
  36. data/db/migrate/20261027000001_create_shiplog_pull_request_upgrades.rb +30 -0
  37. data/lib/generators/shiplog/install/install_generator.rb +20 -1
  38. data/lib/generators/shiplog/install/templates/initializer.rb.tt +37 -0
  39. data/lib/shiplog/classes.rb +20 -1
  40. data/lib/shiplog/configuration.rb +155 -2
  41. data/lib/shiplog/github/client.rb +42 -9
  42. data/lib/shiplog/github/errors.rb +3 -0
  43. data/lib/shiplog/github/queries.rb +32 -1
  44. data/lib/shiplog/version.rb +1 -1
  45. metadata +10 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58c17b51ed2b752958414ce03688b5c055681502c6ac9304c2238aab2144e521
4
- data.tar.gz: 50ceed3676ed0cb48cb4d8aa3da190456aced0347d62404d3cfa4d095c4cd69e
3
+ metadata.gz: 429880b5cc28e18781af480d464269bbd82a117a76b23746ef1c2e9c2309a6e2
4
+ data.tar.gz: 418bd44307404a7f9404ae7aed1b444b9c738333b09490cd64027b85a455e231
5
5
  SHA512:
6
- metadata.gz: 875076c4feb976490743235f4f56355548518640b15c1fbdb0183afaec7161617a93acb5f199741a6b94c5206d20623aa95b1f9cb63eed5b3823a8190373ab5f
7
- data.tar.gz: a16a92cb22802351b8074211400ca12e3ae28f572efffc5fd07fc63fb081a8a27b9c5aa8f530595ebf2f8e53bf5b3a88e62ebcd1f5a24898fb3e2751db602238
6
+ metadata.gz: 7eacb4b715e25cd9899852abef7d4a8e08dc9b18b9ca1e5f9085193e0447158472a82f049d66d66f8da18d12dbed092609248e0b5f5320ac9b07e78174163ac7
7
+ data.tar.gz: 4760676c3cfc7fde8d11ad80423c5e9626a581624913c5950986ca8b8ff1708568c4af179f4b06e7ddd9150425cb26fbe41ed29b70d171112c0df9021347ea7d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 (2026-10-26)
4
+
5
+ ### Added
6
+
7
+ - A Roadmap / Changelog switcher in the default page header. Add your own pages with `config.extra_pages` (`[{ label:, path: }]`, each a String or a callable evaluated in the view), and render the same switcher on host pages with `helper Shiplog::SwitcherHelper` and `shiplog_switcher`.
8
+ - Roadmap status by default: a rule and marker per column, a card tag with the issue's repository (`config.repository_labels`), "Includes N parts" on cards, a "Recently shipped" section (`config.roadmap_shipped_count`, default 4, `0` hides it) and an optional footnote with a "Suggest a feature" link (`shiplog.roadmap.footnote`, `config.feedback_path`).
9
+ - New `config.classes` keys: `switcher`, `switcher_link`, `column_marker`, `card_meta`, `card_tag`, `shipped`, `shipped_title`, `shipped_list`, `shipped_item`, `shipped_date`, `shipped_link`, `footnote`, `footnote_link`.
10
+ - Per-repository options in `config.repositories`: `{ name:, changelog: false, gem: }`. `changelog: false` keeps a repository on the roadmap only. `gem:` names the gem in your lockfile so that upgrading it produces a single changelog entry, written from the gem's own merged pull requests, instead of one entry per repository. `config.lockfile_path` changes the lockfile that is read.
11
+ - A Dependabot author, a `chore(deps)` title or the `dependencies` label no longer excludes a pull request that carries gem changes; other excluded labels, such as `internal`, still do.
12
+ - `config.cache_version`, to refresh cached public pages when the output of a configured callable changes.
13
+ - Search on the public changelog: a GET form (`/changelog?q=csv`) that lists approved features and changes whose title or body contain every word of the query, with a result count, a "Clear search" link and pagination that keeps `q`. Turn it off with `config.changelog_search = false`; override `shiplog/changelogs/_search.html.erb` to change it. New `config.classes` keys: `search`, `search_input`, `search_button`, `search_summary`, `search_clear`, `search_empty`. Search pages set `content_for :shiplog_robots, "noindex"` (emitted as a robots meta tag by Shiplog's own layout; add it to your layout, see the README).
14
+ - A Discard button on draft rows of the admin inbox, next to Edit and Approve. It updates the list in place; give a reason from the entry's page.
15
+
16
+ ### Changed
17
+
18
+ - Roadmap drafts are shorter (about two sentences) and lead with what the customer gets, instead of opening with "We're working on".
19
+ - The roadmap's ETag also covers its recently shipped entries and each card's repository and sub-issues, so those changes reach cached pages.
20
+ - The migration `create_shiplog_pull_request_upgrades` also adds an index on `shiplog_entries (status, released_on)`.
21
+ - Gem upgrade detection: a failure on one pull request no longer blocks the others or keeps the scan window from advancing. Temporary GitHub errors are retried and given up after three attempts; other errors give up at once, with a warning. Pull requests merged before the next scan's start are marked checked without being fetched.
22
+ - Only the last commit's lockfile change is seen for a rebase merge; Dependabot's single-commit pull requests are unaffected.
23
+
24
+ ### Fixed
25
+
26
+ - The scan window could move back to the oldest pending lockfile pull request after a first sync of a long history, drafting every old pull request and commit in that span. It now never ends before its start.
27
+ - Hosts that mount the engine with another name (`as: :updates`) no longer get a `NameError` from the switcher.
28
+ - The public pages' ETag used a per-process `Hash#hash`, so workers of a multi-process host answered with different ETags for the same page. It is now a stable digest.
29
+
30
+ ### Upgrade notes
31
+
32
+ - Copy and run the new migration: `bin/rails shiplog:install:migrations`, then `bin/rails db:migrate`. Run it before the job workers start on the new code, because the importer writes the new columns.
33
+ - New I18n keys, all with English defaults, so a locale without translations shows English: `shiplog.switcher.label`, `.roadmap`, `.changelog`; `shiplog.roadmap.parts` (with `count`), `.shipped_heading`, `.shipped_date_format`, `.full_changelog`, `.suggest`; `shiplog.changelog.search.label`, `.submit`, `.summary` (with `count` and `query`), `.clear`, `.empty` (with `query`).
34
+ - The default `shiplog/_page_header` now renders the switcher. Hosts that override `_page_header` keep theirs; render `shiplog_switcher` (or the `shiplog/switcher` partial) in it to get the links.
35
+ - If your app overrides `roadmaps/show`, `_column` or `_card` only to add column status, card tags or recently shipped, delete them and use the new defaults and `config.classes` keys.
36
+ - Style the new elements with the new class keys above if you use `config.classes` with `config.stylesheet = false`.
37
+ - `extra_pages` paths must be absolute (`/…`), `http(s)://` or `mailto:`. A `feedback_path` that is not one of these renders no link.
38
+ - Repository names are validated: anything other than `owner/name` raises `ArgumentError` when `config.repositories` is assigned.
39
+ - For gem upgrades the GitHub token needs read access to **Contents** and **Pull requests** on the gem repositories. Only pull requests synced by 0.2.0 or later are inspected; earlier merges are not backfilled.
40
+
3
41
  ## 0.1.1 (2026-10-26)
4
42
 
5
43
  ### Added
data/README.md CHANGED
@@ -82,7 +82,8 @@ All settings live in `config/initializers/shiplog.rb`:
82
82
 
83
83
  | Key | Default | Description |
84
84
  |---|---|---|
85
- | `repositories` | `[]` | GitHub repositories to sync, as `"owner/name"`. Sub-issues may cross repositories of the same owner. |
85
+ | `repositories` | `[]` | GitHub repositories to sync, as `"owner/name"` or a Hash with options. Sub-issues may cross repositories of the same owner. See [Repositories](#repositories). |
86
+ | `lockfile_path` | `"Gemfile.lock"` | Lockfile read in merged pull requests to detect [gem upgrades](#gem-upgrades). Change it for a monorepo. |
86
87
  | `github_token` | `nil` | A String, or a callable evaluated on every GitHub request. |
87
88
  | `webhook_secret` | `nil` | Secret of the GitHub webhook. While blank, the webhook endpoint answers 503. |
88
89
  | `public_label` | `"public"` | Only issues with this label reach the roadmap. |
@@ -107,6 +108,12 @@ All settings live in `config/initializers/shiplog.rb`:
107
108
  | `current_admin` | `->(controller) { controller.try(:current_user)&.try(:email) }` | Stored as the approver of an entry. |
108
109
  | `roadmap_path` | `"roadmap"` | Path of the public roadmap. |
109
110
  | `changelog_path` | `"changelog"` | Path of the public changelog. |
111
+ | `extra_pages` | `[]` | Pages added to the switcher after Roadmap and Changelog: `[{ label:, path: }]`, each a String or a callable evaluated in the view. See [Product updates navigation](#product-updates-navigation). Anything else raises `ArgumentError`. |
112
+ | `repository_labels` | `{}` | Tag shown on a roadmap card for the repository of its issue: `{ "owner/name" => "iPhone & iPad" }`. Values are Strings or callables evaluated in the view; repositories without one show no tag. |
113
+ | `roadmap_shipped_count` | `4` | How many recently shipped changelog entries (features and changes) the roadmap lists below its columns. `0` hides the section. |
114
+ | `cache_version` | `nil` | A String added to the public pages' ETag. Callables (`extra_pages`, `repository_labels`, `feedback_path`) cannot be fingerprinted, so bump it when the output of a callable you configured changes. |
115
+ | `feedback_path` | `nil` | Target of the roadmap's "Suggest a feature" link, a String or a callable evaluated in the view. No link while `nil`. |
116
+ | `changelog_search` | `true` | Search box on the public changelog and `?q=` filtering. `false` hides the form and ignores `q`. See [Changelog search](#changelog-search). |
110
117
 
111
118
  Example:
112
119
 
@@ -121,6 +128,50 @@ Shiplog.configure do |config|
121
128
  end
122
129
  ```
123
130
 
131
+ ## Repositories
132
+
133
+ Each entry of `config.repositories` is a String (`"owner/name"`) or a Hash:
134
+
135
+ ```ruby
136
+ config.repositories = [
137
+ "acme/app",
138
+ "acme/app-ios",
139
+ { name: "acme/market_data", changelog: false, gem: "market_data" },
140
+ { name: "acme/produce", changelog: false, gem: "acme-produce" }
141
+ ]
142
+ ```
143
+
144
+ | Option | Default | Description |
145
+ |---|---|---|
146
+ | `name` | required | `"owner/name"`. |
147
+ | `changelog` | `true` | `false` makes the repository roadmap-only: its issues still feed the roadmap, but its pull requests and commits never become changelog entries. |
148
+ | `gem` | `nil` | The gem's name in your `Gemfile.lock`, for [gem upgrades](#gem-upgrades). |
149
+
150
+ Unknown options and invalid values raise `ArgumentError` when you assign `repositories`, and so does a name that is not `owner/name` (letters, digits, `.`, `_` and `-`; a segment cannot be only dots). A repository with both `gem:` and `changelog: true` is accepted, but a warning is logged: its changes would be drafted twice, from its own pull requests and from the upgrade. Set `changelog: false`.
151
+
152
+ `gem:` must be set explicitly because gem names and repository names differ (`acme-produce` lives in `acme/produce`), and a public gem can share a name with one of your repositories without being it: the `holidays` gem from RubyGems is not `acme/holidays`. Matching on names would attach someone else's changes to your changelog, so only gems listed with `gem:` are tracked.
153
+
154
+ `config.lockfile_path` (default `"Gemfile.lock"`) is the lockfile Shiplog reads in the repositories where `changelog` is true.
155
+
156
+ ## Gem upgrades
157
+
158
+ A change made in a gem you depend on should appear in the changelog once, when your customers get it: the day your app upgraded the gem. Shiplog does this with the repositories you listed with `gem:`.
159
+
160
+ For each merged pull request in a repository with `changelog: true` that changes the lockfile:
161
+
162
+ 1. Shiplog reads the lockfile at the merge commit's first parent and at the merge commit itself, and compares the tracked gems with `Bundler::LockfileParser`.
163
+ 2. For a gem whose version changed it resolves the tag in the gem's repository (`v<version>` and `<version>`, trying the matching styles first and then the mixed ones). For a git source it uses the locked revision. Shiplog then compares the two refs.
164
+ 3. It lists the pull requests merged between the two refs (GitHub's compare API, then the pull requests associated with those commits, the newest 50) and stores them with the upgrade.
165
+ 4. The pull request gets one change draft, dated at its merge. The gem's changes (up to 20 titles with excerpts) go into the rewrite prompt, and the rewriter writes one customer-facing entry, or marks it not customer-facing.
166
+
167
+ A Dependabot bump, a `chore(deps):` title or the `dependencies` label does not exclude a pull request that carries an upgrade with changes; without one it is skipped as usual. Other excluded labels (such as `internal`) still exclude it. If a tag cannot be found, or the gem's repository is not readable by the token, the upgrade is recorded without changes and a warning is logged; the rewriter then works from the pull request alone.
168
+
169
+ The GitHub token needs read access to the gem repositories as well (**Contents** and **Pull requests**). Detection is capped per sync (50 pull requests), so a large repository's first sync finishes over several syncs.
170
+
171
+ Only pull requests synced by 0.2.0 or later are inspected: merges imported earlier are not backfilled, and their gem changes are not turned into entries. Pull requests merged before the next scan's start (the last scan, or seven days ago for a repository never scanned) are marked checked without being fetched, since nothing that old would be drafted. A pull request whose inspection fails with a temporary GitHub error is retried on the next syncs and given up after three attempts; any other error gives it up at once, with a warning.
172
+
173
+ **Rebase merges.** Shiplog reads the lockfile at the merge commit and at its first parent. For a rebase merge, GitHub creates no merge commit: the pull request's "merge commit" is the last rebased commit, so only that commit's lockfile change is seen. Dependabot's pull requests are a single commit, so they are unaffected; a rebased branch that bumps a gem in an earlier commit is missed.
174
+
124
175
  ## GitHub token
125
176
 
126
177
  Shiplog talks to the GitHub GraphQL API. Provide either:
@@ -226,6 +277,57 @@ Approved changelog entries do not react to later GitHub changes (a reverted pull
226
277
 
227
278
  Drafts whose rewrite job was lost (no result and no error after an hour) are queued again by the next sync, and the admin marks them "Not rewritten yet" until then.
228
279
 
280
+ ## Product updates navigation
281
+
282
+ The default page header shows a switcher under the lede: **Roadmap**, **Changelog**, then `config.extra_pages`. The current page carries `aria-current="page"`: of the pages whose path equals or contains the request path, the one with the longest path is current. Add your own pages, for example a news section:
283
+
284
+ ```ruby
285
+ config.extra_pages = [
286
+ { label: "News", path: "/learn/news" },
287
+ # Callables are evaluated in the view, so route helpers and I18n work:
288
+ { label: -> { t("pages.docs") }, path: -> { main_app.docs_path } }
289
+ ]
290
+ ```
291
+
292
+ To show the same switcher on your own pages, add the helper to the controller and call it in the view. It links the stylesheet (unless `config.stylesheet` is `false`) and finds the engine's route proxy in your routes whatever name you gave the mount (`as: :updates`), so it works in main-app views. It raises an error if `Shiplog::Engine` is not mounted:
293
+
294
+ ```ruby
295
+ class NewsController < ApplicationController
296
+ helper Shiplog::SwitcherHelper
297
+ end
298
+ ```
299
+
300
+ ```erb
301
+ <%= shiplog_switcher %>
302
+ ```
303
+
304
+ A `path` String must start with `/` (not `//`, `/\` or `/` followed by whitespace), `http://`, `https://` or `mailto:`, and contain no control characters; anything else, such as `"news"`, raises `ArgumentError` when you assign `extra_pages`. An absolute `http(s)` page is marked current only on its own host (`request.host`). A callable that returns another scheme renders the label as text, without a link, and logs a warning. `feedback_path` follows the same rule at render time: an unsafe value shows no link.
305
+
306
+ Labels come from `shiplog.switcher.roadmap`, `.changelog` and `.label` (the `nav` label); every Shiplog key has an English default, so a locale without translations shows English. Style it with the `switcher` and `switcher_link` keys of [`config.classes`](#3-configclasses-tailwind-and-other-utility-frameworks); the current link also has `shiplog-switcher__link--current`. Hosts that override `shiplog/_page_header` keep their own header; render `shiplog_switcher` or the `shiplog/switcher` partial in it to get the links.
307
+
308
+ ### Roadmap details
309
+
310
+ - **Status:** each column has a rule on top and a marker (`shiplog-column__marker--in-progress` / `--coming-soon` / `--later`).
311
+ - **Card meta:** the label of the issue's repository (`config.repository_labels`) and "Includes N parts" when the issue has more than one sub-issue (`shiplog.roadmap.parts`). A card with neither has no meta element.
312
+ - **Recently shipped:** the last `config.roadmap_shipped_count` approved feature and change entries (default 4), each linking to its month on the changelog, plus a "See the full changelog" link. The roadmap's ETag follows changelog approvals.
313
+ - **Footnote:** set the `shiplog.roadmap.footnote` translation (empty by default, nothing renders while it is empty) and `config.feedback_path` for a "Suggest a feature" link (`shiplog.roadmap.suggest`).
314
+
315
+ ## Changelog search
316
+
317
+ The public changelog has a search form above its months: `GET /changelog?q=csv` lists the approved features and changes whose title or body contain every word of the query (case-insensitive `ILIKE`, so PostgreSQL; `%`, `_` and `\` are matched literally). Only the first 5 words of the first 100 characters count. Results keep the changelog's order and month grouping and are paginated; the pagination links keep `q`. With a query the page shows "N results for “csv”" and a "Clear search" link, or a "no entries match" message. A blank `q` is the same as no `q`. The form is a plain GET form (`role="search"`, a `type="search"` input named `q` with an `aria-label`), so it needs no JavaScript.
318
+
319
+ - Turn it off with `config.changelog_search = false`: no form, and `q` is ignored.
320
+ - Override `shiplog/changelogs/_search.html.erb` (locals `query:` and `count:`, both `nil` without a query) to change the markup. Style it with the `search*` keys of [`config.classes`](#3-configclasses-tailwind-and-other-utility-frameworks).
321
+ - Texts: `shiplog.changelog.search.label`, `.submit`, `.summary` (`one` / `other`, with `count` and `query`), `.clear` and `.empty` (with `query`); all have English defaults.
322
+ - The ETag includes the normalised query.
323
+ - The model scope is `Shiplog::Entry.search(query)`.
324
+
325
+ **Keeping search pages out of search engines.** A page with a query sets `content_for :shiplog_robots, "noindex"`. Shiplog's own layout turns that into `<meta name="robots" content="noindex">`. A host layout (`config.public_layout`) controls its own `<head>`, so emit it there:
326
+
327
+ ```erb
328
+ <%= tag.meta name: "robots", content: content_for(:shiplog_robots) if content_for?(:shiplog_robots) %>
329
+ ```
330
+
229
331
  ## Styling
230
332
 
231
333
  The public pages ship plain semantic HTML with stable [BEM](https://getbem.com) classes (`shiplog-card`, `shiplog-entry__title`, ...) and a small, framework-free stylesheet. Pick the level that fits your app; the same views work in all of them.
@@ -300,9 +402,12 @@ Without it, `shiplog` is first seen when `shiplog.css` loads, which can put it a
300
402
  | `page_header` | `shiplog-page__header` | Header with title and lede |
301
403
  | `page_title` | `shiplog-page__title` | `h1` |
302
404
  | `page_lede` | `shiplog-page__lede` | Intro paragraph |
405
+ | `switcher` | `shiplog-switcher` | Switcher `nav` (Roadmap, Changelog, extra pages) |
406
+ | `switcher_link` | `shiplog-switcher__link` (+ `--current`) | One switcher link |
303
407
  | `columns` | `shiplog-columns` | Roadmap column grid |
304
408
  | `column` | `shiplog-column` (+ `--in-progress` / `--coming-soon` / `--later`) | One roadmap column |
305
409
  | `column_header` | `shiplog-column__header` | Column heading block |
410
+ | `column_marker` | `shiplog-column__marker` (+ `--in-progress` / `--coming-soon` / `--later`) | Status dot inside the column `h2`. The column's rule is its `border-top`, set by the column modifier (`shiplog-column--later`); with utility classes, target it with a variant such as `in-[.shiplog-column--later]:bg-gray-300`. |
306
411
  | `column_title` | `shiplog-column__title` | Column `h2` |
307
412
  | `column_count` | `shiplog-column__count` | Number of items |
308
413
  | `column_description` | `shiplog-column__description` | One-line description |
@@ -311,6 +416,16 @@ Without it, `shiplog` is first seen when `shiplog.css` loads, which can put it a
311
416
  | `card` | `shiplog-card` | Roadmap card |
312
417
  | `card_title` | `shiplog-card__title` | Card `h3` |
313
418
  | `card_body` | `shiplog-card__body` | Card text |
419
+ | `card_meta` | `shiplog-card__meta` | Line under the card text (omitted when empty) |
420
+ | `card_tag` | `shiplog-card__tag` | Repository label |
421
+ | `shipped` | `shiplog-shipped` | "Recently shipped" section |
422
+ | `shipped_title` | `shiplog-shipped__title` | Its `h2` |
423
+ | `shipped_list` | `shiplog-shipped__list` | Its list |
424
+ | `shipped_item` | `shiplog-shipped__item` | One entry |
425
+ | `shipped_date` | `shiplog-shipped__date` | Release date of the entry |
426
+ | `shipped_link` | `shiplog-shipped__link` (+ `--all` on the changelog link) | Links to the changelog |
427
+ | `footnote` | `shiplog-footnote` | Footnote paragraph |
428
+ | `footnote_link` | `shiplog-footnote__link` | "Suggest a feature" link |
314
429
  | `months` | `shiplog-months` | Changelog months wrapper |
315
430
  | `month` | `shiplog-month` | One month section |
316
431
  | `month_title` | `shiplog-month__title` | Month `h2` |
@@ -324,16 +439,23 @@ Without it, `shiplog` is first seen when `shiplog.css` loads, which can put it a
324
439
  | `pagination` | `shiplog-pagination` | Pagination `nav` |
325
440
  | `pagination_link` | `shiplog-pagination__link` (+ `--newer` / `--older`) | Newer/older links |
326
441
  | `empty` | `shiplog-empty` | "Nothing here yet" notice |
442
+ | `search` | `shiplog-search` | Changelog search `form` |
443
+ | `search_input` | `shiplog-search__input` | The `type="search"` input |
444
+ | `search_button` | `shiplog-search__button` | Submit button |
445
+ | `search_summary` | `shiplog-search__summary` | "N results for ..." line |
446
+ | `search_clear` | `shiplog-search__clear` | "Clear search" link |
447
+ | `search_empty` | `shiplog-search__empty` | "No entries match" notice |
327
448
 
328
- Values may be strings or arrays. The list is `Shiplog::Classes::KEYS`; assigning `config.classes` raises `ArgumentError` (listing the valid keys) for any other key, in every environment. String keys are accepted and normalised to symbols, and the hash is stored frozen. Texts come from I18n (`shiplog.roadmap.title`, `shiplog.roadmap.lede`, `shiplog.changelog.*`, `shiplog.categories.feature` / `change`, `shiplog.date_format` (default `%-d %B %Y`, used for entry dates), `shiplog.columns.<column>_description`), so override them in your locale files.
449
+ Values may be strings or arrays. The list is `Shiplog::Classes::KEYS`; assigning `config.classes` raises `ArgumentError` (listing the valid keys) for any other key, in every environment. String keys are accepted and normalised to symbols, and the hash is stored frozen. Texts come from I18n (`shiplog.roadmap.title`, `shiplog.roadmap.lede`, `shiplog.roadmap.*` (`parts`, `shipped_heading`, `full_changelog`, `footnote`, `suggest`), `shiplog.switcher.*`, `shiplog.changelog.*`, `shiplog.categories.feature` / `change`, `shiplog.date_format` (default `%-d %B %Y`, used for entry dates), `shiplog.columns.<column>_description`), so override them in your locale files.
329
450
 
330
451
  ### 4. Override the partials
331
452
 
332
453
  Copy any view from the engine's `app/views/shiplog/` into your app with the same path and edit it. Each partial documents its locals in a comment on its first lines:
333
454
 
334
455
  - `shiplog/_page.html.erb`, `shiplog/_page_header.html.erb`, `shiplog/_pagination.html.erb`, `shiplog/_empty.html.erb`
335
- - `shiplog/roadmaps/_column.html.erb`, `shiplog/roadmaps/_card.html.erb`
336
- - `shiplog/changelogs/_month.html.erb`, `shiplog/changelogs/_entry.html.erb`
456
+ - `shiplog/_switcher.html.erb`
457
+ - `shiplog/roadmaps/_column.html.erb`, `shiplog/roadmaps/_card.html.erb`, `shiplog/roadmaps/_shipped.html.erb`
458
+ - `shiplog/changelogs/_month.html.erb`, `shiplog/changelogs/_entry.html.erb`, `shiplog/changelogs/_search.html.erb`
337
459
 
338
460
  `shiplog/_page.html.erb` is what links the stylesheet (so it also works in layouts you don't control); keep that line if you copy it and still want the bundled CSS.
339
461
 
@@ -4,8 +4,9 @@ module Shiplog
4
4
  DELIVERIES_SHOWN = 20
5
5
 
6
6
  def show
7
- repositories = Repository.where(full_name: Shiplog.config.repositories).index_by(&:full_name)
8
- @repositories = Shiplog.config.repositories.map { |full_name| [ full_name, repositories[full_name] ] }
7
+ names = Shiplog.config.repository_names
8
+ repositories = Repository.where(full_name: names).index_by(&:full_name)
9
+ @repositories = names.map { |full_name| [ full_name, repositories[full_name] ] }
9
10
  @deliveries = WebhookDelivery.order(received_at: :desc, id: :desc).limit(DELIVERIES_SHOWN)
10
11
  @webhook_secret_missing = Shiplog.config.webhook_secret.blank?
11
12
  @github_token_missing = Shiplog.config.resolved_github_token.blank?
@@ -7,16 +7,26 @@ module Shiplog
7
7
  @page = page_number
8
8
  scope = Entry.approved.where(kind: %w[feature change]).where.not(released_on: nil)
9
9
  .order(released_on: :desc, approved_at: :desc, id: :desc)
10
+ @query = search_query
11
+ scope = scope.search(@query) if @query
10
12
  records = scope.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
11
13
  return head(:not_found) if @page > 1 && records.empty?
12
14
 
13
15
  @older = records.size > PER_PAGE
14
16
  @months = records.first(PER_PAGE).group_by { |entry| entry.released_on.beginning_of_month }
15
- fresh_when etag: [ *shiplog_etag_parts, @page, @older, @months.values.flatten.map { |entry| [ entry.id, entry.updated_at ] } ]
17
+ @result_count = scope.count if @query
18
+ fresh_when etag: [ *shiplog_etag_parts, @query, @page, @older, @months.values.flatten.map { |entry| [ entry.id, entry.updated_at ] } ]
16
19
  end
17
20
 
18
21
  private
19
22
 
23
+ # The normalised search query (words joined by one space), or nil when search is off or the query is blank.
24
+ def search_query
25
+ return unless Shiplog.config.changelog_search && params[:q].is_a?(String)
26
+
27
+ Entry.search_terms(params[:q]).join(" ").presence
28
+ end
29
+
20
30
  def page_number
21
31
  Integer(params[:page].to_s, 10, exception: false).to_i.clamp(1, MAX_PAGE)
22
32
  end
@@ -5,6 +5,8 @@ module Shiplog
5
5
  class PublicController < Shiplog.config.public_parent_controller.constantize
6
6
  helper Shiplog::FrontendHelper
7
7
  helper Shiplog::StylingHelper
8
+ helper Shiplog::SwitcherHelper
9
+ helper Shiplog::RoadmapHelper
8
10
  helper Shiplog::HostRoutesHelper
9
11
  layout -> { Shiplog.config.public_layout }
10
12
 
@@ -13,7 +15,7 @@ module Shiplog
13
15
  # What the rendered markup depends on besides the data: the locale, the gem version and the styling
14
16
  # config. Host parent controllers rendering per-user layout content should declare `etag { current_user&.id }`.
15
17
  def shiplog_etag_parts
16
- [ I18n.locale, Shiplog::VERSION, Shiplog.config.classes.hash, Shiplog.config.stylesheet ]
18
+ [ I18n.locale, Shiplog::VERSION, Shiplog.config.render_digest ]
17
19
  end
18
20
  end
19
21
  end
@@ -2,13 +2,36 @@ module Shiplog
2
2
  class RoadmapsController < PublicController
3
3
  def show
4
4
  @columns = Roadmap.new(published_only: true).columns
5
- fresh_when etag: [ *shiplog_etag_parts, etag_for(@columns) ]
5
+ preload_cards(@columns)
6
+ @shipped = recently_shipped
7
+ fresh_when etag: [ *shiplog_etag_parts, etag_for(@columns), shipped_etag(@shipped) ]
6
8
  end
7
9
 
8
10
  private
9
11
 
12
+ # The cards read each entry's issue, its repository (label) and its sub-issues (parts).
13
+ def preload_cards(columns)
14
+ entries = columns.values.flatten.map(&:entry)
15
+ ActiveRecord::Associations::Preloader.new(records: entries, associations: { subject: [ :repository, :children ] }).call
16
+ end
17
+
18
+ def recently_shipped
19
+ count = Shiplog.config.roadmap_shipped_count
20
+ return Entry.none if count.zero?
21
+
22
+ Entry.approved.where(kind: Entry::CHANGELOG_KINDS).where.not(released_on: nil)
23
+ .order(released_on: :desc, approved_at: :desc, id: :desc).limit(count).load
24
+ end
25
+
10
26
  def etag_for(columns)
11
- columns.transform_values { |items| items.map { |item| [ item.entry.id, item.entry.updated_at ] } }
27
+ columns.transform_values do |items|
28
+ items.map do |item|
29
+ issue = item.entry.subject
30
+ [ item.entry.id, item.entry.updated_at, issue.updated_at, issue.children.size, issue.repository.full_name ]
31
+ end
32
+ end
12
33
  end
34
+
35
+ def shipped_etag(entries) = entries.map { |entry| [ entry.id, entry.updated_at ] }
13
36
  end
14
37
  end
@@ -67,6 +67,37 @@
67
67
  .shiplog-page__title { font-size: 1.875rem; line-height: 1.2; margin: 0; }
68
68
  .shiplog-page__lede { color: var(--sl-muted); margin: 0.5rem 0 0; }
69
69
 
70
+ /* Switcher: Roadmap / Changelog / host pages. It can sit outside .shiplog-page (host pages), so it
71
+ falls back to the host-safe palette when the page variables are not inherited. */
72
+ .shiplog-switcher {
73
+ --sw-border: var(--sl-border, var(--shiplog-border, color-mix(in srgb, currentColor 22%, transparent)));
74
+ --sw-card: var(--sl-card, var(--shiplog-card, color-mix(in srgb, currentColor 5%, transparent)));
75
+ --sw-muted: var(--sl-muted, var(--shiplog-muted, color-mix(in srgb, currentColor 65%, transparent)));
76
+ --sw-accent: var(--sl-accent, var(--shiplog-accent, #2f81f7));
77
+ background: var(--sw-card);
78
+ border: 1px solid var(--sw-border);
79
+ border-radius: 999px;
80
+ display: inline-flex;
81
+ flex-wrap: wrap;
82
+ font-size: 0.875rem;
83
+ font-weight: 500;
84
+ gap: 0.25rem;
85
+ margin-top: var(--sl-space, 1rem);
86
+ padding: 0.25rem;
87
+ }
88
+ .shiplog-switcher__link {
89
+ border-radius: 999px;
90
+ color: var(--sw-muted);
91
+ padding: 0.25rem 1rem;
92
+ text-decoration: none;
93
+ transition: background-color 0.15s, color 0.15s;
94
+ }
95
+ .shiplog-switcher__link:hover { color: inherit; }
96
+ .shiplog-switcher__link:focus-visible { outline: 2px solid var(--sw-accent); outline-offset: 2px; }
97
+ .shiplog-switcher__link--current,
98
+ .shiplog-switcher__link--current:hover { background: var(--sw-accent); color: #fff; }
99
+ @media (prefers-reduced-motion: reduce) { .shiplog-switcher__link { transition: none; } }
100
+
70
101
  .shiplog-empty,
71
102
  .shiplog-column__empty { color: var(--sl-muted); margin: 0; }
72
103
 
@@ -77,17 +108,21 @@
77
108
  grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
78
109
  }
79
110
 
111
+ /* A rule on top and a marker carry the column's place in time: in progress is solid, coming soon
112
+ is the accent, later is neutral. */
80
113
  .shiplog-column {
81
114
  --sl-dot: var(--sl-muted);
115
+ --sl-rule: var(--sl-border);
116
+ border-top: 2px solid var(--sl-rule);
82
117
  min-width: 0;
118
+ padding-top: 1rem;
83
119
  }
84
- .shiplog-column--in-progress { --sl-dot: #1a7f37; }
85
- .shiplog-column--coming-soon { --sl-dot: var(--sl-accent); }
86
- .shiplog-column--later { --sl-dot: var(--sl-muted); }
120
+ .shiplog-column--in-progress { --sl-dot: #1a7f37; --sl-rule: var(--sl-dot); }
121
+ .shiplog-column--coming-soon { --sl-dot: var(--sl-accent); --sl-rule: var(--sl-dot); }
122
+ .shiplog-column--later { --sl-dot: var(--sl-muted); --sl-rule: var(--sl-border); }
87
123
 
88
124
  .shiplog-column__header {
89
125
  align-items: baseline;
90
- border-bottom: 1px solid var(--sl-border);
91
126
  display: flex;
92
127
  flex-wrap: wrap;
93
128
  gap: 0 0.5rem;
@@ -95,13 +130,14 @@
95
130
  padding-bottom: 0.75rem;
96
131
  }
97
132
  .shiplog-column__title { align-items: center; display: flex; font-size: 1.125rem; gap: 0.5rem; margin: 0; }
98
- .shiplog-column__title::before {
133
+ .shiplog-column__marker {
99
134
  background: var(--sl-dot);
100
135
  border-radius: 50%;
101
- content: "";
136
+ flex-shrink: 0;
102
137
  height: 0.625rem;
103
138
  width: 0.625rem;
104
139
  }
140
+ .shiplog-column__marker--in-progress { box-shadow: 0 0 0 4px color-mix(in srgb, var(--sl-dot) 20%, transparent); }
105
141
  .shiplog-column__count {
106
142
  background: var(--sl-card);
107
143
  border: 1px solid var(--sl-border);
@@ -132,6 +168,58 @@
132
168
  .shiplog-entry__body p { margin: 0.25rem 0; }
133
169
  .shiplog-card__body { color: var(--sl-muted); font-size: 0.9375rem; }
134
170
 
171
+ .shiplog-card__meta {
172
+ align-items: center;
173
+ color: var(--sl-muted);
174
+ display: flex;
175
+ flex-wrap: wrap;
176
+ font-size: 0.75rem;
177
+ font-weight: 500;
178
+ gap: 0.5rem;
179
+ margin: 0.75rem 0 0;
180
+ }
181
+ .shiplog-card__tag {
182
+ border: 1px solid var(--sl-border);
183
+ border-radius: 0.375rem;
184
+ padding: 0 0.5rem;
185
+ }
186
+
187
+ /* Recently shipped: a title, a link to the full changelog, then a grid of the latest entries */
188
+ .shiplog-shipped {
189
+ align-items: baseline;
190
+ border-top: 1px solid var(--sl-border);
191
+ display: grid;
192
+ gap: 0.5rem 1rem;
193
+ grid-template-columns: 1fr auto;
194
+ margin-top: calc(var(--sl-space) * 2.5);
195
+ padding-top: calc(var(--sl-space) * 1.5);
196
+ }
197
+ .shiplog-shipped__title { font-size: 1.125rem; margin: 0; }
198
+ .shiplog-shipped__list {
199
+ display: grid;
200
+ gap: var(--sl-space) calc(var(--sl-space) * 1.5);
201
+ grid-column: 1 / -1;
202
+ grid-template-columns: repeat(auto-fit, minmax(min(12rem, 100%), 1fr));
203
+ list-style: none;
204
+ margin: 0;
205
+ padding: 0;
206
+ }
207
+ .shiplog-shipped__item { display: grid; gap: 0.25rem; align-content: start; }
208
+ .shiplog-shipped__date { color: var(--sl-muted); font-size: 0.75rem; }
209
+ .shiplog-shipped__link { color: inherit; font-weight: 600; text-decoration: none; }
210
+ .shiplog-shipped__link:hover { color: var(--sl-accent); }
211
+ .shiplog-shipped__link--all { color: var(--sl-accent); font-size: 0.875rem; font-weight: 500; }
212
+
213
+ .shiplog-footnote {
214
+ border-top: 1px solid var(--sl-border);
215
+ color: var(--sl-muted);
216
+ font-size: 0.875rem;
217
+ margin: calc(var(--sl-space) * 2) 0 0;
218
+ padding-top: var(--sl-space);
219
+ text-align: center;
220
+ }
221
+ .shiplog-footnote__link { color: var(--sl-accent); font-weight: 500; }
222
+
135
223
  /* Changelog: months hang off a vertical line */
136
224
  .shiplog-months { display: grid; gap: calc(var(--sl-space) * 2); }
137
225
 
@@ -176,6 +264,33 @@
176
264
  .shiplog-pagination__link { color: var(--sl-accent); }
177
265
  .shiplog-pagination__link--older { margin-left: auto; }
178
266
 
267
+ .shiplog-search { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: var(--sl-space) 0; }
268
+ .shiplog-search__input {
269
+ flex: 1 1 14rem;
270
+ min-width: 0;
271
+ font: inherit;
272
+ color: inherit;
273
+ background: var(--sl-card);
274
+ border: 1px solid var(--sl-border);
275
+ border-radius: var(--sl-radius);
276
+ padding: 0.4rem 0.75rem;
277
+ }
278
+ .shiplog-search__input:focus-visible,
279
+ .shiplog-search__button:focus-visible,
280
+ .shiplog-search__clear:focus-visible { outline: 2px solid var(--sl-accent); outline-offset: 2px; }
281
+ .shiplog-search__button {
282
+ font: inherit;
283
+ color: #fff;
284
+ background: var(--sl-accent);
285
+ border: 1px solid var(--sl-accent);
286
+ border-radius: var(--sl-radius);
287
+ padding: 0.4rem 1rem;
288
+ cursor: pointer;
289
+ }
290
+ .shiplog-search__summary { color: var(--sl-muted); margin: 0 0 var(--sl-space); }
291
+ .shiplog-search__clear { color: var(--sl-accent); margin-left: 0.5rem; }
292
+ .shiplog-search__empty { color: var(--sl-muted); margin: 0; }
293
+
179
294
  @media (max-width: 40rem) {
180
295
  .shiplog-page { padding: var(--sl-space) 0.75rem; }
181
296
  .shiplog-page__title { font-size: 1.5rem; }
@@ -0,0 +1,12 @@
1
+ module Shiplog
2
+ module CallableHelper
3
+ # A config value that is a String, or a callable evaluated in the view so it can use route helpers
4
+ # and I18n (`-> { main_app.news_path }`, `-> { t("...") }`).
5
+ def shiplog_resolve(value)
6
+ case value
7
+ when Proc then instance_exec(&value)
8
+ else value.respond_to?(:call) ? value.call : value
9
+ end
10
+ end
11
+ end
12
+ end
@@ -1,6 +1,7 @@
1
1
  module Shiplog
2
2
  module FrontendHelper
3
3
  def shiplog_stylesheet_tag(name)
4
+ @_shiplog_stylesheet_emitted = true if name == "shiplog" # shiplog_switcher then skips its own link
4
5
  tag.link rel: "stylesheet", href: shiplog_asset_path("#{name}.css"), nonce: content_security_policy_nonce
5
6
  end
6
7
 
@@ -0,0 +1,15 @@
1
+ module Shiplog
2
+ module RoadmapHelper
3
+ include Shiplog::SwitcherHelper
4
+
5
+ # The configured label of a repository (config.repository_labels, matched case-insensitively), or nil.
6
+ def shiplog_repository_label(repository)
7
+ labels = Shiplog.config.repository_labels
8
+ label = labels[repository.full_name] || labels.find { |name, _| name.casecmp?(repository.full_name) }&.last
9
+ shiplog_resolve(label).presence
10
+ end
11
+
12
+ # The "suggest a feature" target (String or callable), or nil.
13
+ def shiplog_feedback_path = shiplog_safe_url(shiplog_resolve(Shiplog.config.feedback_path))
14
+ end
15
+ end