shiplog 0.1.0 → 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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/README.md +127 -5
  4. data/app/controllers/shiplog/admin/application_controller.rb +3 -1
  5. data/app/controllers/shiplog/admin/entries/approvals_controller.rb +2 -2
  6. data/app/controllers/shiplog/admin/entries/discards_controller.rb +2 -2
  7. data/app/controllers/shiplog/admin/entries/rewrites_controller.rb +1 -1
  8. data/app/controllers/shiplog/admin/entries_controller.rb +8 -4
  9. data/app/controllers/shiplog/admin/inbox_filters.rb +55 -0
  10. data/app/controllers/shiplog/admin/syncs_controller.rb +3 -2
  11. data/app/controllers/shiplog/changelogs_controller.rb +11 -1
  12. data/app/controllers/shiplog/frontends_controller.rb +2 -1
  13. data/app/controllers/shiplog/public_controller.rb +3 -1
  14. data/app/controllers/shiplog/roadmaps_controller.rb +25 -2
  15. data/app/frontend/shiplog/admin.css +7 -0
  16. data/app/frontend/shiplog/board.js +8 -2
  17. data/app/frontend/shiplog/inbox.js +78 -0
  18. data/app/frontend/shiplog/shiplog.css +121 -6
  19. data/app/helpers/shiplog/callable_helper.rb +12 -0
  20. data/app/helpers/shiplog/frontend_helper.rb +1 -0
  21. data/app/helpers/shiplog/host_routes_helper.rb +37 -0
  22. data/app/helpers/shiplog/roadmap_helper.rb +15 -0
  23. data/app/helpers/shiplog/switcher_helper.rb +101 -0
  24. data/app/jobs/shiplog/refresh_job.rb +1 -0
  25. data/app/jobs/shiplog/scan_changes_job.rb +19 -5
  26. data/app/jobs/shiplog/sync_job.rb +1 -1
  27. data/app/models/shiplog/change_candidates.rb +43 -6
  28. data/app/models/shiplog/drafter.rb +1 -1
  29. data/app/models/shiplog/entry.rb +41 -0
  30. data/app/models/shiplog/pull_request.rb +4 -0
  31. data/app/models/shiplog/pull_request_upgrade.rb +5 -0
  32. data/app/models/shiplog/rewriter/prompt.rb +37 -3
  33. data/app/models/shiplog/rewriter.rb +20 -1
  34. data/app/models/shiplog/sync/pull_request_importer.rb +23 -1
  35. data/app/models/shiplog/sync/repository_sync.rb +2 -0
  36. data/app/models/shiplog/sync/upgrade_detector.rb +203 -0
  37. data/app/views/layouts/shiplog/application.html.erb +1 -0
  38. data/app/views/shiplog/_page_header.html.erb +2 -1
  39. data/app/views/shiplog/_switcher.html.erb +13 -0
  40. data/app/views/shiplog/admin/entries/_entry_row.html.erb +6 -5
  41. data/app/views/shiplog/admin/entries/edit.html.erb +12 -6
  42. data/app/views/shiplog/admin/entries/index.html.erb +4 -0
  43. data/app/views/shiplog/changelogs/_search.html.erb +16 -0
  44. data/app/views/shiplog/changelogs/show.html.erb +11 -3
  45. data/app/views/shiplog/roadmaps/_card.html.erb +12 -1
  46. data/app/views/shiplog/roadmaps/_column.html.erb +2 -2
  47. data/app/views/shiplog/roadmaps/_shipped.html.erb +15 -0
  48. data/app/views/shiplog/roadmaps/show.html.erb +9 -0
  49. data/config/locales/en.yml +28 -0
  50. data/db/migrate/20261027000001_create_shiplog_pull_request_upgrades.rb +30 -0
  51. data/lib/generators/shiplog/install/install_generator.rb +20 -1
  52. data/lib/generators/shiplog/install/templates/initializer.rb.tt +37 -0
  53. data/lib/shiplog/classes.rb +20 -1
  54. data/lib/shiplog/configuration.rb +155 -2
  55. data/lib/shiplog/github/client.rb +42 -9
  56. data/lib/shiplog/github/errors.rb +3 -0
  57. data/lib/shiplog/github/queries.rb +32 -1
  58. data/lib/shiplog/version.rb +1 -1
  59. metadata +12 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f0f17beb628a8bf038407c3a1b0e5caa404db7f51eba9472dbf507df7da84431
4
- data.tar.gz: da38c5e1bc625bcae2ed31ef037db2440bc94a612760c11cc842cb91b02cdf28
3
+ metadata.gz: 429880b5cc28e18781af480d464269bbd82a117a76b23746ef1c2e9c2309a6e2
4
+ data.tar.gz: 418bd44307404a7f9404ae7aed1b444b9c738333b09490cd64027b85a455e231
5
5
  SHA512:
6
- metadata.gz: 588b5d5e87c63b828d0a75ef4aa01591efec6c94a954622d3ad2e36a6cf88deeb46ae72b19b0f865828606f147dfcdb2b6d30f34ce75741acc23543f9a853d15
7
- data.tar.gz: d6a7f52d6df8768e5586a47bbded5e5b867dfaac2ec07e85fd0ac86b37b0a8e4a275db62f7893a1ea8b0f607027924cc50bb4925ff2efb41c9b10e4158bd1a95
6
+ metadata.gz: 7eacb4b715e25cd9899852abef7d4a8e08dc9b18b9ca1e5f9085193e0447158472a82f049d66d66f8da18d12dbed092609248e0b5f5320ac9b07e78174163ac7
7
+ data.tar.gz: 4760676c3cfc7fde8d11ad80423c5e9626a581624913c5950986ca8b8ff1708568c4af179f4b06e7ddd9150425cb26fbe41ed29b70d171112c0df9021347ea7d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
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
+
41
+ ## 0.1.1 (2026-10-26)
42
+
43
+ ### Added
44
+
45
+ - Approve, discard and restore update the inbox in place, without a reload. Without JavaScript they still redirect back to the same filtered list.
46
+ - Admins can switch a changelog entry between New feature and Improvement.
47
+
48
+ ### Fixed
49
+
50
+ - Pull requests whose title starts with a topic (`Importers: …`) are no longer mistaken for a non-changelog Conventional Commits type and skipped; only known types such as `chore:` or `ci:` are.
51
+ - Roadmap drafts are written in the future tense ("You'll be able to…") instead of as if the feature had already shipped, and no longer turn implementation details from the issue into promises such as dates, prices, refunds or limits.
52
+ - Host layouts can render records that the host app routes, such as Active Storage avatars passed to `image_tag`, `link_to` or `url_for`, inside Shiplog's public and admin pages instead of raising `Can't resolve image into URL`.
53
+ - Admin actions return to the filtered list they started from: approving, discarding, restoring or saving an entry keeps the status, kind and page of the inbox.
54
+ - The roadmap board's drag and drop keeps working after Turbo Drive navigation in host apps that use it.
55
+
3
56
  ## 0.1.0 (2026-10-26)
4
57
 
5
58
  Initial release.
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:
@@ -215,7 +266,7 @@ end
215
266
  ## How changelog entries are produced
216
267
 
217
268
  - **Features**: a public top-level issue closed as completed that has at least one merged linked pull request on itself or a sub-issue. The issue form sections (`### Problem`, `### Before`, `### After`), sub-issue titles and linked pull request titles are given to the rewriter. Only issues closed in the last 90 days are drafted, so a first sync doesn't flood the inbox. The release date is the newest merge date.
218
- - **Small changes**: the weekly scan looks at pull requests merged, and stored commits made, since the last scan (seven days on the first run). Commits are only considered when they are on the default branch and have no pull request, and their conventional-commit type (`feat:`, `fix(scope):`, ...) must be in `commit_types`. Pull requests are skipped when their title carries a type outside `commit_types`; untyped titles are left for the rewriter to judge. Bot authors are recorded as `name[bot]`, so the default `excluded_authors` pattern matches them.
269
+ - **Small changes**: the weekly scan looks at pull requests merged, and stored commits made, since the last scan (seven days on the first run). Commits are only considered when they are on the default branch and have no pull request, and their conventional-commit type (`feat:`, `fix(scope):`, ...) must be in `commit_types`. Pull requests are skipped when their title starts with a Conventional Commits type (`chore:`, `ci:`, `docs:`, ...) outside `commit_types`; a topic prefix such as `Importers:` is not a type; untyped titles are left for the rewriter to judge. Bot authors are recorded as `name[bot]`, so the default `excluded_authors` pattern matches them.
219
270
  - **Exclusions**: pull requests with an excluded label or an excluded author, commits from excluded authors, pull requests that belong to a feature's issue tree (the feature entry covers them), and anything that already has an entry.
220
271
 
221
272
  ## Approval workflow
@@ -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
 
@@ -3,6 +3,8 @@ module Shiplog
3
3
  # Parent controller of the admin. The parent class is resolved when this file loads, so
4
4
  # `config.admin_parent_controller` must be set in an initializer (config/initializers/shiplog.rb).
5
5
  class ApplicationController < Shiplog.config.admin_parent_controller.constantize
6
+ include InboxFilters
7
+
6
8
  protect_from_forgery with: :exception
7
9
 
8
10
  helper Shiplog::FrontendHelper
@@ -15,7 +17,7 @@ module Shiplog
15
17
  before_action :authorize_admin
16
18
 
17
19
  rescue_from Shiplog::Entry::InvalidTransition do
18
- redirect_to edit_entry_path(params[:entry_id]), alert: t("shiplog.admin.entries.invalid_transition")
20
+ respond_inline_error(t("shiplog.admin.entries.invalid_transition"), edit_entry_path(params[:entry_id], inbox_filter_params))
19
21
  end
20
22
 
21
23
  private
@@ -5,9 +5,9 @@ module Shiplog
5
5
  def create
6
6
  entry = Entry.find(params[:entry_id])
7
7
  entry.approve!(by: current_admin_name)
8
- redirect_to root_path, notice: t("shiplog.admin.entries.approved")
8
+ respond_inline(entry, :approved)
9
9
  rescue ActiveRecord::RecordInvalid => error
10
- redirect_to edit_entry_path(entry), alert: error.record.errors.full_messages.to_sentence
10
+ respond_inline_error(error.record.errors.full_messages.to_sentence, edit_entry_path(entry, inbox_filter_params))
11
11
  end
12
12
  end
13
13
  end
@@ -6,12 +6,12 @@ module Shiplog
6
6
 
7
7
  def create
8
8
  @entry.discard!(reason: params[:reason].to_s.strip.presence)
9
- redirect_to root_path, notice: t("shiplog.admin.entries.discarded")
9
+ respond_inline(@entry, :discarded)
10
10
  end
11
11
 
12
12
  def destroy
13
13
  @entry.restore!
14
- redirect_to root_path, notice: t("shiplog.admin.entries.restored")
14
+ respond_inline(@entry, :restored)
15
15
  end
16
16
 
17
17
  private
@@ -5,7 +5,7 @@ module Shiplog
5
5
  def create
6
6
  entry = Entry.find(params[:entry_id])
7
7
  RewriteEntryJob.perform_later(entry.id, force: true)
8
- redirect_to edit_entry_path(entry), notice: t("shiplog.admin.entries.rewrite_queued")
8
+ redirect_to edit_entry_path(entry, inbox_filter_params), notice: t("shiplog.admin.entries.rewrite_queued")
9
9
  end
10
10
  end
11
11
  end
@@ -7,8 +7,8 @@ module Shiplog
7
7
  before_action :set_entry, only: %i[edit update]
8
8
 
9
9
  def index
10
- @status = Entry.statuses.key(params[:status].to_s) || "draft"
11
- @kind = Entry.kinds.key(params[:kind].to_s)
10
+ @status = inbox_filter_params[:status] || "draft"
11
+ @kind = inbox_filter_params[:kind]
12
12
  @page = page_number
13
13
  scope = Entry.where(status: @status)
14
14
  scope = scope.where(kind: @kind) if @kind
@@ -24,10 +24,14 @@ module Shiplog
24
24
 
25
25
  def update
26
26
  if @entry.update(entry_params)
27
- redirect_to root_path, notice: t("shiplog.admin.entries.saved")
27
+ redirect_to root_path(inbox_filter_params), notice: t("shiplog.admin.entries.saved")
28
28
  else
29
29
  render :edit, status: :unprocessable_content
30
30
  end
31
+ rescue ActiveRecord::RecordNotUnique
32
+ # Two concurrent kind switches can both pass the validation; the unique index has the last word.
33
+ @entry.errors.add(:changelog_kind, :taken)
34
+ render :edit, status: :unprocessable_content
31
35
  end
32
36
 
33
37
  private
@@ -44,7 +48,7 @@ module Shiplog
44
48
  end
45
49
 
46
50
  def entry_params
47
- params.expect(entry: %i[title body released_on])
51
+ params.expect(entry: %i[title body released_on changelog_kind])
48
52
  end
49
53
 
50
54
  def page_number
@@ -0,0 +1,55 @@
1
+ module Shiplog
2
+ module Admin
3
+ # The drafts inbox state (status, kind, page) carried through every action that leaves the list, so
4
+ # the admin lands back on the same filtered list. Values are rebuilt from a whitelist and never taken
5
+ # from a URL or the Referer, so they cannot be used for an open redirect.
6
+ module InboxFilters
7
+ extend ActiveSupport::Concern
8
+
9
+ MAX_PAGE = 100_000
10
+
11
+ included do
12
+ helper_method :inbox_filter_params
13
+ end
14
+
15
+ # Only present, valid values: { status:, kind:, page: } with any unknown or invalid key dropped.
16
+ def inbox_filter_params
17
+ @inbox_filter_params ||= { status: inbox_status, kind: inbox_kind, page: inbox_page }.compact
18
+ end
19
+
20
+ private
21
+ # Inline (script) requests get the re-rendered row; plain requests redirect back to the filtered list.
22
+ def respond_inline(entry, message_key)
23
+ message = t("shiplog.admin.entries.#{message_key}")
24
+ return redirect_to(root_path(inbox_filter_params), notice: message) unless request.format.json?
25
+
26
+ render json: { row: render_inbox_row(entry), matches_filter: inbox_match?(entry), message: message }
27
+ end
28
+
29
+ def respond_inline_error(message, fallback)
30
+ return render(json: { error: message }, status: :unprocessable_content) if request.format.json?
31
+
32
+ redirect_to fallback, alert: message
33
+ end
34
+
35
+ def render_inbox_row(entry)
36
+ render_to_string(partial: "shiplog/admin/entries/entry_row", formats: :html, locals: { entry: })
37
+ end
38
+
39
+ # Mirrors EntriesController#index: drafts unless a status was given, any kind unless one was.
40
+ def inbox_match?(entry)
41
+ entry.status == (inbox_filter_params[:status] || "draft") &&
42
+ (inbox_filter_params[:kind].nil? || entry.kind == inbox_filter_params[:kind])
43
+ end
44
+
45
+ def inbox_status = Entry.statuses.key(params[:status].to_s) && params[:status].to_s
46
+
47
+ def inbox_kind = Entry.kinds.key(params[:kind].to_s) && params[:kind].to_s
48
+
49
+ def inbox_page
50
+ page = Integer(params[:page].to_s, 10, exception: false)
51
+ page.clamp(1, MAX_PAGE) if page&.positive?
52
+ end
53
+ end
54
+ end
55
+ end
@@ -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
@@ -4,7 +4,8 @@ module Shiplog
4
4
  ASSETS = {
5
5
  "shiplog.css" => { path: ASSET_DIRECTORY.join("shiplog.css"), type: "text/css" },
6
6
  "admin.css" => { path: ASSET_DIRECTORY.join("admin.css"), type: "text/css" },
7
- "board.js" => { path: ASSET_DIRECTORY.join("board.js"), type: "text/javascript" }
7
+ "board.js" => { path: ASSET_DIRECTORY.join("board.js"), type: "text/javascript" },
8
+ "inbox.js" => { path: ASSET_DIRECTORY.join("inbox.js"), type: "text/javascript" }
8
9
  }.freeze
9
10
 
10
11
  # The version segment busts caches; stale versions are 404 so they are never cached as the current file.
@@ -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
@@ -70,8 +70,15 @@
70
70
  .shiplog-admin .shiplog-admin__flash { border: 1px solid var(--sa-border); border-radius: 0.375rem; padding: 0.5rem 0.75rem; }
71
71
  .shiplog-admin .shiplog-admin__flash--alert { border-color: #cf222e; }
72
72
 
73
+ .shiplog-admin .shiplog-admin__live { border: 1px solid var(--sa-border); border-radius: 0.375rem; padding: 0.5rem 0.75rem; }
74
+ .shiplog-admin .shiplog-admin__live[hidden] { display: none; }
75
+ .shiplog-admin .shiplog-admin__live--alert { border-color: #cf222e; }
76
+
73
77
  .shiplog-admin .shiplog-admin__title { font-size: 1.5rem; margin: 0 0 1rem; }
74
78
  .shiplog-admin .shiplog-admin__muted { color: var(--sa-muted); font-size: 0.875rem; }
79
+ .shiplog-admin .shiplog-entry-row { transition: opacity 0.2s; }
80
+ .shiplog-admin .shiplog-entry-row--leaving { opacity: 0; }
81
+ @media (prefers-reduced-motion: reduce) { .shiplog-admin .shiplog-entry-row { transition: none; } }
75
82
  .shiplog-admin .shiplog-admin__empty { color: var(--sa-muted); }
76
83
 
77
84
  .shiplog-admin .shiplog-admin-filters { display: flex; flex-wrap: wrap; gap: 0.5rem 2rem; margin-bottom: 1rem; }
@@ -1,6 +1,9 @@
1
- const board = document.querySelector("[data-board-url]")
1
+ // Idempotent: runs now and again on turbo:load (inert without Turbo), binding each board once.
2
+ const init = () => {
3
+ const board = document.querySelector("[data-board-url]")
4
+ if (!board || board.dataset.shiplogBound) return
5
+ board.dataset.shiplogBound = "true"
2
6
 
3
- if (board) {
4
7
  // Host layouts may not render csrf_meta_tags, so the board element carries the token as a fallback.
5
8
  const csrfToken = document.querySelector("meta[name=csrf-token]")?.content ?? board.dataset.csrfToken
6
9
  let dragged = null
@@ -75,3 +78,6 @@ if (board) {
75
78
  save(list, dragged).catch(() => window.location.reload())
76
79
  })
77
80
  }
81
+
82
+ init()
83
+ document.addEventListener("turbo:load", init)