shiplog 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +15 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +410 -0
  5. data/app/controllers/shiplog/admin/application_controller.rb +37 -0
  6. data/app/controllers/shiplog/admin/boards_controller.rb +60 -0
  7. data/app/controllers/shiplog/admin/entries/approvals_controller.rb +15 -0
  8. data/app/controllers/shiplog/admin/entries/discards_controller.rb +25 -0
  9. data/app/controllers/shiplog/admin/entries/rewrites_controller.rb +13 -0
  10. data/app/controllers/shiplog/admin/entries_controller.rb +55 -0
  11. data/app/controllers/shiplog/admin/syncs_controller.rb +21 -0
  12. data/app/controllers/shiplog/application_controller.rb +5 -0
  13. data/app/controllers/shiplog/changelogs_controller.rb +24 -0
  14. data/app/controllers/shiplog/frontends_controller.rb +21 -0
  15. data/app/controllers/shiplog/public_controller.rb +19 -0
  16. data/app/controllers/shiplog/roadmaps_controller.rb +14 -0
  17. data/app/controllers/shiplog/webhooks_controller.rb +121 -0
  18. data/app/frontend/shiplog/admin.css +137 -0
  19. data/app/frontend/shiplog/board.js +77 -0
  20. data/app/frontend/shiplog/shiplog.css +184 -0
  21. data/app/helpers/shiplog/admin/entries_helper.rb +27 -0
  22. data/app/helpers/shiplog/application_helper.rb +14 -0
  23. data/app/helpers/shiplog/frontend_helper.rb +17 -0
  24. data/app/helpers/shiplog/host_routes_helper.rb +25 -0
  25. data/app/helpers/shiplog/styling_helper.rb +24 -0
  26. data/app/jobs/shiplog/application_job.rb +14 -0
  27. data/app/jobs/shiplog/refresh_job.rb +38 -0
  28. data/app/jobs/shiplog/rewrite_entry_job.rb +9 -0
  29. data/app/jobs/shiplog/scan_changes_job.rb +36 -0
  30. data/app/jobs/shiplog/sync_job.rb +20 -0
  31. data/app/models/shiplog/application_record.rb +5 -0
  32. data/app/models/shiplog/board_arrangement.rb +38 -0
  33. data/app/models/shiplog/change_candidates.rb +77 -0
  34. data/app/models/shiplog/commit.rb +9 -0
  35. data/app/models/shiplog/drafter.rb +69 -0
  36. data/app/models/shiplog/entry.rb +42 -0
  37. data/app/models/shiplog/issue.rb +68 -0
  38. data/app/models/shiplog/issue_form.rb +43 -0
  39. data/app/models/shiplog/issue_link.rb +6 -0
  40. data/app/models/shiplog/pull_request.rb +11 -0
  41. data/app/models/shiplog/repository.rb +9 -0
  42. data/app/models/shiplog/rewriter/prompt.rb +71 -0
  43. data/app/models/shiplog/rewriter.rb +89 -0
  44. data/app/models/shiplog/roadmap/column_resolver.rb +46 -0
  45. data/app/models/shiplog/roadmap.rb +34 -0
  46. data/app/models/shiplog/sync/commit_scanner.rb +39 -0
  47. data/app/models/shiplog/sync/issue_importer.rb +50 -0
  48. data/app/models/shiplog/sync/node_attributes.rb +50 -0
  49. data/app/models/shiplog/sync/pull_request_importer.rb +54 -0
  50. data/app/models/shiplog/sync/repository_sync.rb +80 -0
  51. data/app/models/shiplog/webhook_delivery.rb +5 -0
  52. data/app/models/shiplog/webhook_signature.rb +16 -0
  53. data/app/views/layouts/shiplog/admin.html.erb +16 -0
  54. data/app/views/layouts/shiplog/application.html.erb +15 -0
  55. data/app/views/shiplog/_empty.html.erb +3 -0
  56. data/app/views/shiplog/_page.html.erb +6 -0
  57. data/app/views/shiplog/_page_header.html.erb +6 -0
  58. data/app/views/shiplog/_pagination.html.erb +12 -0
  59. data/app/views/shiplog/admin/_assets.html.erb +3 -0
  60. data/app/views/shiplog/admin/_custom_head.html.erb +0 -0
  61. data/app/views/shiplog/admin/_nav.html.erb +9 -0
  62. data/app/views/shiplog/admin/_page.html.erb +14 -0
  63. data/app/views/shiplog/admin/boards/_card.html.erb +22 -0
  64. data/app/views/shiplog/admin/boards/show.html.erb +17 -0
  65. data/app/views/shiplog/admin/entries/_badges.html.erb +10 -0
  66. data/app/views/shiplog/admin/entries/_entry_row.html.erb +21 -0
  67. data/app/views/shiplog/admin/entries/edit.html.erb +61 -0
  68. data/app/views/shiplog/admin/entries/index.html.erb +47 -0
  69. data/app/views/shiplog/admin/syncs/show.html.erb +71 -0
  70. data/app/views/shiplog/changelogs/_entry.html.erb +8 -0
  71. data/app/views/shiplog/changelogs/_month.html.erb +9 -0
  72. data/app/views/shiplog/changelogs/show.html.erb +16 -0
  73. data/app/views/shiplog/roadmaps/_card.html.erb +6 -0
  74. data/app/views/shiplog/roadmaps/_column.html.erb +17 -0
  75. data/app/views/shiplog/roadmaps/show.html.erb +9 -0
  76. data/config/admin_routes.rb +13 -0
  77. data/config/locales/en.yml +106 -0
  78. data/config/routes.rb +6 -0
  79. data/db/migrate/20261026000001_create_shiplog_tables.rb +102 -0
  80. data/lib/generators/shiplog/install/install_generator.rb +94 -0
  81. data/lib/generators/shiplog/install/templates/initializer.rb.tt +86 -0
  82. data/lib/generators/shiplog/install/templates/shiplog-feature.yml +23 -0
  83. data/lib/shiplog/admin/engine.rb +9 -0
  84. data/lib/shiplog/classes.rb +38 -0
  85. data/lib/shiplog/configuration.rb +92 -0
  86. data/lib/shiplog/engine.rb +5 -0
  87. data/lib/shiplog/github/client.rb +96 -0
  88. data/lib/shiplog/github/errors.rb +17 -0
  89. data/lib/shiplog/github/queries.rb +108 -0
  90. data/lib/shiplog/version.rb +3 -0
  91. data/lib/shiplog.rb +16 -0
  92. metadata +154 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f0f17beb628a8bf038407c3a1b0e5caa404db7f51eba9472dbf507df7da84431
4
+ data.tar.gz: da38c5e1bc625bcae2ed31ef037db2440bc94a612760c11cc842cb91b02cdf28
5
+ SHA512:
6
+ metadata.gz: 588b5d5e87c63b828d0a75ef4aa01591efec6c94a954622d3ad2e36a6cf88deeb46ae72b19b0f865828606f147dfcdb2b6d30f34ce75741acc23543f9a853d15
7
+ data.tar.gz: d6a7f52d6df8768e5586a47bbded5e5b867dfaac2ec07e85fd0ac86b37b0a8e4a275db62f7893a1ea8b0f607027924cc50bb4925ff2efb41c9b10e4158bd1a95
data/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-10-26)
4
+
5
+ Initial release.
6
+
7
+ - Public roadmap and changelog engine (`Shiplog::Engine`) and a separate admin engine (`Shiplog::Admin::Engine`) with a drafts inbox, entry editing, a roadmap board with drag and drop, and sync status.
8
+ - GitHub GraphQL sync across several repositories, a signed webhook endpoint (`issues`, `pull_request`, `sub_issues`), and scheduled jobs (`Shiplog::SyncJob`, `Shiplog::ScanChangesJob`).
9
+ - Roadmap columns derived from GitHub state (open PR or assignee, milestone or `soon` label), with admins able to pin a card to a column.
10
+ - Changelog drafts for shipped features (from the issue form's Problem / Before / After) and for small merged PRs and commits, skipping bots, internal conventional-commit types and excluded labels.
11
+ - Host-supplied LLM rewriter. Nothing is public until approved, and approved text is never silently replaced.
12
+ - Styling: `config.classes` (per-element classes next to stable `shiplog-*` BEM classes), `config.stylesheet`, and a bundled stylesheet in `@layer shiplog` with `--shiplog-*` variables. Public views are split into overridable partials.
13
+ - Rendering inside host layouts: `public_parent_controller` / `public_layout`, `admin_parent_controller` / `admin_layout` / `admin_chrome` / `admin_flash`, and unknown `*_path` / `*_url` helpers forwarded to `main_app`.
14
+ - Admin access fails closed (`config.authorize_admin` defaults to deny).
15
+ - `rails generate shiplog:install` (with `--tailwind`) and a `shiplog-feature.yml` issue form.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2026 Trackberry
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,410 @@
1
+ # Shiplog
2
+
3
+ A mountable Rails engine that turns your GitHub issues, pull requests and commits into a public roadmap and changelog written for customers, not developers. An LLM that you supply drafts the plain-language text. Nothing is public until an admin approves it.
4
+
5
+ - **Roadmap**: issues labelled `public` appear in three columns (In progress, Coming soon, Later) once approved.
6
+ - **Changelog**: shipped features and small changes, grouped by month.
7
+ - **Admin**: drafts inbox, entry editing, a drag-and-drop roadmap board and a sync status page.
8
+
9
+ ## Requirements
10
+
11
+ - Ruby 4.0 or newer
12
+ - Rails 8.1.4 or newer (< 9)
13
+ - PostgreSQL (tables use `jsonb`)
14
+ - A job backend for Active Job (GoodJob, Solid Queue, ...)
15
+
16
+ ## Installation
17
+
18
+ ```ruby
19
+ # Gemfile
20
+ gem "shiplog"
21
+ ```
22
+
23
+ ```sh
24
+ bundle install
25
+ bin/rails generate shiplog:install
26
+ bin/rails db:migrate
27
+ ```
28
+
29
+ The generator:
30
+
31
+ - creates `config/initializers/shiplog.rb` with every setting, commented;
32
+ - adds the public mount (and a commented admin mount) to `config/routes.rb`;
33
+ - runs `shiplog:install:migrations` (all tables are prefixed `shiplog_`);
34
+ - copies the issue form to `.github/ISSUE_TEMPLATE/shiplog-feature.yml`. It has Problem / Before / After fields and applies the `public` label.
35
+
36
+ With `--tailwind` the initializer also sets `config.stylesheet = false` and a `config.classes` hash of Tailwind classes, and the generator adds the `@source` line to `app/assets/tailwind/application.css`. See [Styling](#styling).
37
+
38
+ ## Mounting
39
+
40
+ Shiplog ships two engines so that the admin can sit behind your own authentication while the public pages and the GitHub webhook stay open (the same approach as GoodJob):
41
+
42
+ ```ruby
43
+ # config/routes.rb
44
+ mount Shiplog::Engine => "/" # /roadmap, /changelog, /shiplog/github/webhook, engine assets
45
+
46
+ authenticate :user, ->(user) { user.admin? } do # Devise shown; use whatever fits your app
47
+ mount Shiplog::Admin::Engine => "/admin/shiplog"
48
+ end
49
+ ```
50
+
51
+ The generator writes the admin mount as a comment because authentication differs between apps. Uncomment it once it is wrapped in a constraint.
52
+
53
+ `config.authorize_admin` is checked by the admin controllers and a falsy result returns 403. It is fail-closed: the default denies everyone, so a mount that is not configured is never open. The generated initializer sets `config.authorize_admin = ->(controller) { true }` because the route constraint around the mount does the authentication. If you do not wrap the mount in a constraint, replace it with a real check (for example `controller.current_user&.admin?`).
54
+
55
+ The public paths come from `config.roadmap_path` and `config.changelog_path`, read when routes are drawn.
56
+
57
+ ### Customising the admin layout
58
+
59
+ By default the admin uses its own layout (`layouts/shiplog/admin`) and its own CSS and JS, served by the engine (and aware of your CSP nonce). To change it, create the same file in your app, which takes precedence over the engine's:
60
+
61
+ ```erb
62
+ <%# app/views/layouts/shiplog/admin.html.erb %>
63
+ ```
64
+
65
+ To render the admin inside your own admin layout instead, see [Rendering inside your layouts](#rendering-inside-your-layouts).
66
+
67
+ For smaller tweaks override a partial instead:
68
+
69
+ - `app/views/shiplog/admin/_custom_head.html.erb`: rendered inside `<head>` of the engine layout (extra stylesheets, favicon, ...).
70
+ - `app/views/shiplog/admin/_nav.html.erb`: the navigation bar (a compact sub-nav when a host layout is used).
71
+ - `app/views/shiplog/admin/_assets.html.erb`: links `admin.css`; every admin page renders it, so it works in any layout.
72
+
73
+ Engine views use the engine's route helpers, so inside a host layout or partial rendered by a Shiplog controller your own routes are available as `main_app.` helpers, or directly (see [host route helpers](#host-route-helpers)).
74
+
75
+ ### Public pages
76
+
77
+ The public controllers inherit from `config.public_parent_controller` and render with `config.public_layout`.
78
+
79
+ ## Configuration
80
+
81
+ All settings live in `config/initializers/shiplog.rb`:
82
+
83
+ | Key | Default | Description |
84
+ |---|---|---|
85
+ | `repositories` | `[]` | GitHub repositories to sync, as `"owner/name"`. Sub-issues may cross repositories of the same owner. |
86
+ | `github_token` | `nil` | A String, or a callable evaluated on every GitHub request. |
87
+ | `webhook_secret` | `nil` | Secret of the GitHub webhook. While blank, the webhook endpoint answers 503. |
88
+ | `public_label` | `"public"` | Only issues with this label reach the roadmap. |
89
+ | `soon_labels` | `["soon"]` | Labels that put an issue in "Coming soon". A milestone does the same. |
90
+ | `excluded_labels` | `["internal", "dependencies", "refactor", "chore"]` | Pull requests with one of these labels never become changelog entries. |
91
+ | `excluded_authors` | `[/\[bot\]\z/]` | Patterns (matched with `===`) for authors whose pull requests and commits are skipped. |
92
+ | `commit_types` | `["feat", "fix", "perf"]` | Conventional-commit types eligible for the changelog. Commits need one of these types; pull requests whose title has a different type (`ci:`, `chore(deps):`, ...) are skipped, untyped titles are kept. |
93
+ | `rewriter` | `nil` | Callable that rewrites text with an LLM. See [The rewriter](#the-rewriter). |
94
+ | `product_name` | `nil` | Your product's name, used in the prompts. |
95
+ | `audience` | `nil` | Who your customers are, used in the prompts. |
96
+ | `tone` | `"Friendly, concise, no jargon. Address the customer as 'you'."` | Writing style used in the prompts. |
97
+ | `prompt_builder` | `nil` | Optional `->(kind:, context:) { String }` replacing the built-in prompts. `kind` is `"roadmap"`, `"feature"` or `"change"`. |
98
+ | `public_parent_controller` | `"ActionController::Base"` | Parent class of the public controllers. Read once when they load, so set it in the initializer; assigning it after the controllers loaded raises `ArgumentError`. |
99
+ | `public_layout` | `"shiplog/application"` | Layout of the public pages. |
100
+ | `admin_parent_controller` | `"ActionController::Base"` | Parent class of the admin controllers. Read once when they load, so set it in the initializer; assigning it after the controllers loaded raises `ArgumentError`. |
101
+ | `admin_layout` | `"shiplog/admin"` | Layout of the admin pages. |
102
+ | `admin_chrome` | `:standalone` | `:standalone` wraps the admin in Shiplog's own page chrome (nav bar, flash, background). `:hosted` is for an `admin_layout` of your own: Shiplog renders only a compact sub-nav inside your content. Anything else raises `ArgumentError`. The layout name is never inspected. |
103
+ | `admin_flash` | `true` | In `:hosted` mode, render Shiplog's flash messages inside the admin content. Set to `false` if your layout already shows the flash. Standalone always shows it. |
104
+ | `stylesheet` | `true` | Include the bundled `shiplog.css` in the public pages. Set to `false` when you style them yourself. |
105
+ | `classes` | `{}` | Extra classes per element, added to the stable `shiplog-*` classes. See [Styling](#styling). |
106
+ | `authorize_admin` | `->(controller) { false }` | Admin check; return false for a 403. Denies everyone until set; the generated initializer opens it, relying on your route constraint. |
107
+ | `current_admin` | `->(controller) { controller.try(:current_user)&.try(:email) }` | Stored as the approver of an entry. |
108
+ | `roadmap_path` | `"roadmap"` | Path of the public roadmap. |
109
+ | `changelog_path` | `"changelog"` | Path of the public changelog. |
110
+
111
+ Example:
112
+
113
+ ```ruby
114
+ Shiplog.configure do |config|
115
+ config.repositories = %w[acme/app acme/app-ios]
116
+ config.github_token = -> { Rails.application.credentials.dig(:github, :shiplog_token) }
117
+ config.webhook_secret = Rails.application.credentials.dig(:github, :shiplog_webhook_secret)
118
+ config.product_name = "Acme"
119
+ config.audience = "small business owners who invoice customers"
120
+ config.rewriter = ->(prompt) { RubyLLM.chat.ask(prompt).content }
121
+ end
122
+ ```
123
+
124
+ ## GitHub token
125
+
126
+ Shiplog talks to the GitHub GraphQL API. Provide either:
127
+
128
+ - a **fine-grained personal access token** with access to the configured repositories and read-only permissions for **Issues**, **Pull requests**, **Contents** and **Metadata**; or
129
+ - a **GitHub App installation token** with the same permissions. Because `github_token` accepts a callable, you can mint a fresh token on each call:
130
+
131
+ ```ruby
132
+ config.github_token = -> { MyApp::GithubApp.installation_token }
133
+ ```
134
+
135
+ ## Webhook setup
136
+
137
+ Webhooks keep Shiplog fresh between scheduled syncs. In each repository (or at organisation level) add a webhook:
138
+
139
+ - **Payload URL**: `https://your-app.example.com/shiplog/github/webhook` (prefix it if you mounted `Shiplog::Engine` below `/`)
140
+ - **Content type**: `application/json`
141
+ - **Secret**: the value of `config.webhook_secret`
142
+ - **Events**: *Issues*, *Pull requests* and *Sub-issues*
143
+
144
+ Requests are verified with `X-Hub-Signature-256`; an invalid signature returns 401. Each `X-GitHub-Delivery` id is recorded, so redeliveries are harmless. Valid events enqueue a `Shiplog::RefreshJob` that refetches the current state of the issue or pull request and respond 202. `ping` answers 200 and other events are ignored. Recent deliveries are listed on the admin Sync page.
145
+
146
+ ## Scheduling
147
+
148
+ Webhooks can be missed, so schedule two jobs:
149
+
150
+ - `Shiplog::SyncJob`: reconciles issues and pull requests and creates missing drafts. Every 30 minutes is a good start. The first run backfills everything.
151
+ - `Shiplog::ScanChangesJob`: looks at default-branch commits without a pull request, then drafts small changes. Run it weekly.
152
+
153
+ GoodJob (`config/initializers/good_job.rb` or `config/application.rb`):
154
+
155
+ ```ruby
156
+ config.good_job.enable_cron = true
157
+ config.good_job.cron = {
158
+ shiplog_sync: { cron: "*/30 * * * *", class: "Shiplog::SyncJob" },
159
+ shiplog_scan_changes: { cron: "0 6 * * 1", class: "Shiplog::ScanChangesJob" }
160
+ }
161
+ ```
162
+
163
+ Solid Queue (`config/recurring.yml`):
164
+
165
+ ```yaml
166
+ production:
167
+ shiplog_sync:
168
+ class: Shiplog::SyncJob
169
+ schedule: every 30 minutes
170
+ shiplog_scan_changes:
171
+ class: Shiplog::ScanChangesJob
172
+ schedule: every monday at 6am
173
+ ```
174
+
175
+ You can also trigger a sync from the admin Sync page. Rewriting always runs in `Shiplog::RewriteEntryJob`, never in a request.
176
+
177
+ ## The rewriter
178
+
179
+ `config.rewriter` is a callable that receives the prompt (a String) and returns a String containing a JSON object:
180
+
181
+ ```json
182
+ {"title": "...", "body": "...", "customer_facing": true}
183
+ ```
184
+
185
+ Shiplog extracts the first valid JSON object from the reply, so models that wrap it in prose or code fences still work. `customer_facing: false` means the model thinks the change is irrelevant for customers; such small changes are stored as discarded and can be restored by an admin. If no rewriter is configured, the reply can't be parsed or the call raises, the entry keeps the original GitHub text and the error is shown in the admin, where you can edit the text or regenerate.
186
+
187
+ With [RubyLLM](https://rubyllm.com):
188
+
189
+ ```ruby
190
+ config.rewriter = ->(prompt) { RubyLLM.chat.ask(prompt).content }
191
+ ```
192
+
193
+ With the Anthropic SDK:
194
+
195
+ ```ruby
196
+ client = Anthropic::Client.new
197
+ config.rewriter = lambda do |prompt|
198
+ message = client.messages.create(model: "claude-sonnet-5-5", max_tokens: 1024, messages: [ { role: "user", content: prompt } ])
199
+ message.content.first.text
200
+ end
201
+ ```
202
+
203
+ ## How items reach the roadmap
204
+
205
+ - An issue is *public* when it has `config.public_label`.
206
+ - A public, open issue whose parent is not itself public is a roadmap item. Public children of a public parent are folded into the parent.
207
+ - A roadmap item is drafted, rewritten and visible on `/roadmap` only after an admin approves it. It leaves the roadmap when it is closed.
208
+ - The column is the first match of:
209
+ 1. the column an admin pinned it to;
210
+ 2. **In progress**: the issue or one of its sub-issues has an open linked pull request, or the issue has assignees;
211
+ 3. **Coming soon**: it has a milestone or a label from `soon_labels`;
212
+ 4. **Later**.
213
+ - Within a column cards are ordered by their board position, then by last GitHub update. Dragging a card on the admin board pins it to the target column and stores the order. "Unpin" returns it to the automatic column. Without JavaScript, each card has a column select and a Move button.
214
+
215
+ ## How changelog entries are produced
216
+
217
+ - **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.
219
+ - **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
+
221
+ ## Approval workflow
222
+
223
+ Every entry starts as a **draft**. In the admin inbox you can edit the text, regenerate it, approve it or discard it. Only approved entries are public. Discarded entries are never proposed again but can be restored. If the GitHub text changes after a draft was written, the entry is flagged "GitHub text changed since this draft" and can be regenerated, and approved text is never replaced silently.
224
+
225
+ Approved changelog entries do not react to later GitHub changes (a reverted pull request, a removed label): discard them by hand in the admin. A roadmap issue that is re-labelled or reopened shows its previously approved text again, and the inbox flags it as "GitHub text changed". Issues deleted or transferred on GitHub are closed locally (state reason `deleted`), which removes them from the roadmap; their entries are kept.
226
+
227
+ 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
+
229
+ ## Styling
230
+
231
+ 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.
232
+
233
+ ### 1. CSS variables
234
+
235
+ The bundled stylesheet is driven by variables you can set on `:root`, `body` or `.shiplog-page`. Inside your own layout it inherits your text colour and draws cards and borders as translucent mixes of that colour (`color-mix` against `currentColor`), so it works on light and dark hosts without any setting. Only Shiplog's own layout (`.shiplog-layout`), which paints the page background, switches to dark colours with `prefers-color-scheme`.
236
+
237
+ | Variable | Meaning |
238
+ |---|---|
239
+ | `--shiplog-fg` | Text colour |
240
+ | `--shiplog-muted` | Secondary text |
241
+ | `--shiplog-bg` | Page background (only painted by the engine's own layout) |
242
+ | `--shiplog-card` | Card and entry background |
243
+ | `--shiplog-border` | Borders and dividers |
244
+ | `--shiplog-accent` | Links, feature marker, pill, hover |
245
+ | `--shiplog-radius` | Corner radius (default `0.5rem`) |
246
+ | `--shiplog-font` | Font family |
247
+ | `--shiplog-space` | Base spacing unit (default `1rem`) |
248
+
249
+ ```css
250
+ :root { --shiplog-accent: #7c3aed; --shiplog-radius: 0.75rem; }
251
+ ```
252
+
253
+ ### 2. Override the BEM classes
254
+
255
+ All rules live in `@layer shiplog`, so any unlayered CSS in your app wins without extra specificity:
256
+
257
+ ```css
258
+ .shiplog-card { border-radius: 0; }
259
+ .shiplog-entry--feature { border-left-width: 8px; }
260
+ ```
261
+
262
+ If your own CSS is layered too, declare the order up front, for example `@layer shiplog, app;`.
263
+
264
+ ### 3. `config.classes` (Tailwind and other utility frameworks)
265
+
266
+ `config.classes` maps a key to the classes added to that element, next to its BEM class. Set `config.stylesheet = false` to drop the bundled CSS:
267
+
268
+ ```ruby
269
+ Shiplog.configure do |config|
270
+ config.stylesheet = false
271
+ config.classes = {
272
+ columns: "grid gap-6 md:grid-cols-3",
273
+ card: "rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-800 dark:bg-gray-900"
274
+ }
275
+ end
276
+ ```
277
+
278
+ With Tailwind v4, the class names live in a Ruby file, so tell Tailwind to scan it. In `app/assets/tailwind/application.css`:
279
+
280
+ ```css
281
+ @import "tailwindcss";
282
+ @source "../../../config/initializers/shiplog.rb";
283
+ ```
284
+
285
+ (The path is relative to the stylesheet: `app/assets/tailwind/` up three levels is the app root.) `bin/rails generate shiplog:install --tailwind` writes both for you, with neutral gray/indigo classes covering every key.
286
+
287
+ If you keep the bundled stylesheet (`stylesheet: true`) next to Tailwind v4 utilities, declare the layer order before the import so utilities win over Shiplog's rules:
288
+
289
+ ```css
290
+ @layer theme, base, shiplog, components, utilities;
291
+ @import "tailwindcss";
292
+ @source "../../../config/initializers/shiplog.rb";
293
+ ```
294
+
295
+ Without it, `shiplog` is first seen when `shiplog.css` loads, which can put it after `utilities` in the cascade order (a layer's position is fixed by its first mention), and then `.shiplog-card { border-radius: ... }` would beat a `rounded-lg` utility. Declaring the order first pins `shiplog` below `components` and `utilities`.
296
+
297
+ | Key | BEM class | Element |
298
+ |---|---|---|
299
+ | `page` | `shiplog-page` (+ `--roadmap` / `--changelog`) | Page wrapper |
300
+ | `page_header` | `shiplog-page__header` | Header with title and lede |
301
+ | `page_title` | `shiplog-page__title` | `h1` |
302
+ | `page_lede` | `shiplog-page__lede` | Intro paragraph |
303
+ | `columns` | `shiplog-columns` | Roadmap column grid |
304
+ | `column` | `shiplog-column` (+ `--in-progress` / `--coming-soon` / `--later`) | One roadmap column |
305
+ | `column_header` | `shiplog-column__header` | Column heading block |
306
+ | `column_title` | `shiplog-column__title` | Column `h2` |
307
+ | `column_count` | `shiplog-column__count` | Number of items |
308
+ | `column_description` | `shiplog-column__description` | One-line description |
309
+ | `column_empty` | `shiplog-column__empty` | Empty column notice |
310
+ | `cards` | `shiplog-cards` | List of roadmap cards |
311
+ | `card` | `shiplog-card` | Roadmap card |
312
+ | `card_title` | `shiplog-card__title` | Card `h3` |
313
+ | `card_body` | `shiplog-card__body` | Card text |
314
+ | `months` | `shiplog-months` | Changelog months wrapper |
315
+ | `month` | `shiplog-month` | One month section |
316
+ | `month_title` | `shiplog-month__title` | Month `h2` |
317
+ | `entries` | `shiplog-entries` | List of entries |
318
+ | `entry` | `shiplog-entry` (+ `--feature` / `--change`) | Changelog entry |
319
+ | `entry_feature` | `shiplog-entry--feature` | Also applied to feature entries |
320
+ | `entry_category` | `shiplog-entry__category` | "New" / "Improved" pill |
321
+ | `entry_title` | `shiplog-entry__title` | Entry `h3` |
322
+ | `entry_body` | `shiplog-entry__body` | Entry text |
323
+ | `entry_date` | `shiplog-entry__date` | `time` element |
324
+ | `pagination` | `shiplog-pagination` | Pagination `nav` |
325
+ | `pagination_link` | `shiplog-pagination__link` (+ `--newer` / `--older`) | Newer/older links |
326
+ | `empty` | `shiplog-empty` | "Nothing here yet" notice |
327
+
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.
329
+
330
+ ### 4. Override the partials
331
+
332
+ 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
+
334
+ - `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`
337
+
338
+ `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
+
340
+ The admin has its own stylesheet, scoped under `.shiplog-admin` and also in `@layer shiplog`. Its variables (`--shiplog-admin-fg`, `-muted`, `-bg`, `-surface`, `-border`, `-accent`) can be set on `:root` or `.shiplog-admin`. The admin follows the same rule: dark colours only in the standalone chrome, inherited colours when `admin_chrome` is `:hosted`.
341
+
342
+ ## Rendering inside your layouts
343
+
344
+ - **Public pages**: set `config.public_parent_controller` (default `ActionController::Base`; read once when the controller loads, so use the initializer) and `config.public_layout` (default `"shiplog/application"`). The public controllers run under the parent's `before_action`s, so pick one that lets visitors through.
345
+ - **Admin**: set `config.admin_parent_controller` (same rule), `config.admin_layout` and `config.admin_chrome = :hosted`. Each admin page links its own `admin.css` (and `board.js` on the board) and wraps itself in `<div class="shiplog-admin">`, so nothing depends on your layout's `<head>`. In `:hosted` mode Shiplog adds a small Drafts / Roadmap board / Sync sub-nav inside the content and shows its flash messages there too (set `config.admin_flash = false` if your layout renders the flash itself). The board reads the CSRF token from `meta[name=csrf-token]` and falls back to a `data-csrf-token` attribute, so your layout does not need `csrf_meta_tags` for drag and drop.
346
+ - `config.authorize_admin` and the route constraint still apply.
347
+
348
+ ### Host route helpers
349
+
350
+ A layout rendered by a Shiplog controller runs in the engine's isolated routing context. Shiplog's controllers include `Shiplog::HostRoutesHelper`, which forwards any `*_path` / `*_url` helper the engine does not define to `main_app`, so `root_path`, `about_path` or `new_user_session_path` work in your layout as they do elsewhere. Helpers the engine defines win: in the public engine `roadmap_path` is Shiplog's, and in the admin engine `root_path` is the admin root, so use `main_app.root_path` there when you mean your own.
351
+
352
+ ### Example: Trackberry-style setup
353
+
354
+ ```ruby
355
+ # config/routes.rb
356
+ mount Shiplog::Engine => "/" # /roadmap and /changelog next to the app's own pages
357
+ authenticate :user, ->(user) { user.admin? } do
358
+ mount Shiplog::Admin::Engine => "/admin/shiplog"
359
+ end
360
+
361
+ # config/initializers/shiplog.rb
362
+ Shiplog.configure do |config|
363
+ config.public_parent_controller = "ApplicationController"
364
+ config.public_layout = "application"
365
+ config.admin_parent_controller = "Admin::BaseController"
366
+ config.admin_layout = "admin"
367
+ config.admin_chrome = :hosted
368
+ config.stylesheet = false
369
+ config.classes = { columns: "grid gap-6 md:grid-cols-3", card: "rounded-lg border p-3" } # see the generator for all keys
370
+ end
371
+ ```
372
+
373
+ The host layouts keep using their own helpers (`root_path`, `current_user`, ...) and Tailwind compiles the classes through the `@source` line.
374
+
375
+ ## Hosting notes
376
+
377
+ - Set `public_parent_controller` and `admin_parent_controller` in an initializer (`config/initializers/shiplog.rb`). They are read once, when the engine's controller classes load, so changing them later has no effect.
378
+ - The public pages send an ETag covering the data, locale, gem version, `config.classes` and `config.stylesheet`. If your host layout shows per-user content, declare `etag { current_user&.id }` in the parent controller (`config.public_parent_controller`); Rails ETags are inherited, so cached responses are not shared between users.
379
+
380
+ ## Running the tests
381
+
382
+ ```sh
383
+ bin/rails db:create db:schema:load # once, creates shiplog_test on local PostgreSQL
384
+ bin/rails test
385
+ bin/rails test test/system # the roadmap board in headless Chrome
386
+ bin/rubocop
387
+ ```
388
+
389
+ The system tests need Chrome; Selenium fetches a matching chromedriver. The database is configured in `test/dummy/config/database.yml`; set `PGHOST`, `PGUSER` and `PGPASSWORD` if your PostgreSQL needs them.
390
+
391
+ Tests that call the real GitHub API are skipped by default. To run them:
392
+
393
+ ```sh
394
+ SHIPLOG_LIVE_GITHUB=1 GITHUB_TOKEN=... SHIPLOG_LIVE_REPO=owner/name bin/rails test test/live
395
+ ```
396
+
397
+ `SHIPLOG_LIVE_REPO` defaults to `Trackberry/shiplog`.
398
+
399
+ ## License
400
+
401
+ Released under the [MIT License](MIT-LICENSE).
402
+
403
+ ## Releasing
404
+
405
+ Releases are published by `.github/workflows/release.yml` through RubyGems trusted publishing; there is no API key and no manual `gem push`.
406
+
407
+ 1. Open a pull request that bumps `lib/shiplog/version.rb` and adds a `## x.y.z (date)` entry to `CHANGELOG.md`.
408
+ 2. Merge it. The workflow reruns the tests, refuses a version that is already on RubyGems, publishes the gem, tags `vx.y.z` and creates the GitHub release with that changelog entry as its notes.
409
+
410
+ The workflow can also be started by hand (Actions → Release → Run workflow), for the first release or to retry one that failed for an unrelated reason.
@@ -0,0 +1,37 @@
1
+ module Shiplog
2
+ module Admin
3
+ # Parent controller of the admin. The parent class is resolved when this file loads, so
4
+ # `config.admin_parent_controller` must be set in an initializer (config/initializers/shiplog.rb).
5
+ class ApplicationController < Shiplog.config.admin_parent_controller.constantize
6
+ protect_from_forgery with: :exception
7
+
8
+ helper Shiplog::FrontendHelper
9
+ helper Shiplog::HostRoutesHelper
10
+ helper Shiplog::ApplicationHelper
11
+ helper Shiplog::Admin::EntriesHelper
12
+ helper_method :current_admin_name, :shiplog_admin_hosted?
13
+ layout -> { Shiplog.config.admin_layout }
14
+
15
+ before_action :authorize_admin
16
+
17
+ rescue_from Shiplog::Entry::InvalidTransition do
18
+ redirect_to edit_entry_path(params[:entry_id]), alert: t("shiplog.admin.entries.invalid_transition")
19
+ end
20
+
21
+ private
22
+
23
+ def authorize_admin
24
+ head :forbidden unless Shiplog.config.authorize_admin.call(self)
25
+ end
26
+
27
+ # True when the admin renders inside a host layout (config.admin_chrome = :hosted).
28
+ def shiplog_admin_hosted?
29
+ Shiplog.config.admin_chrome == :hosted
30
+ end
31
+
32
+ def current_admin_name
33
+ Shiplog.config.current_admin.call(self)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,60 @@
1
+ module Shiplog
2
+ module Admin
3
+ class BoardsController < ApplicationController
4
+ def show
5
+ @columns = Roadmap.new(published_only: false).columns
6
+ ActiveRecord::Associations::Preloader.new(records: @columns.values.flatten.map(&:issue), associations: :repository).call
7
+ end
8
+
9
+ def update
10
+ if request.format.json?
11
+ update_json
12
+ else
13
+ update_form
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def update_json
20
+ ids = params[:issue_ids]
21
+ return render_json_error("issue_ids required") unless ids.is_a?(Array) && ids.any?
22
+
23
+ BoardArrangement.new(column: params[:column], moved_id: params[:moved_id], issue_ids: ids).save!
24
+ render json: { card: render_card(params[:moved_id].to_i) }
25
+ rescue ArgumentError, ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound => error
26
+ render_json_error(error.message)
27
+ end
28
+
29
+ def update_form
30
+ issue = Issue.find(params[:issue_id])
31
+ if params[:unpin].present?
32
+ BoardArrangement.unpin!(issue)
33
+ else
34
+ move_to_end(issue, params[:column].to_s)
35
+ end
36
+ redirect_to board_path, notice: t("shiplog.admin.board.updated")
37
+ rescue ArgumentError, ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound => error
38
+ redirect_to board_path, alert: error.message
39
+ end
40
+
41
+ def move_to_end(issue, column)
42
+ raise ArgumentError, "Unknown column: #{column.inspect}" unless Issue::COLUMNS.include?(column)
43
+
44
+ ids = Roadmap.new(published_only: false).columns.fetch(column).map { |item| item.issue.id } - [ issue.id ]
45
+ BoardArrangement.new(column:, moved_id: issue.id, issue_ids: ids + [ issue.id ]).save!
46
+ end
47
+
48
+ # The same partial as a full page load, so the badges, select and Unpin
49
+ # button the script swaps in can't drift from what a reload would show.
50
+ def render_card(issue_id)
51
+ item = Roadmap.new(published_only: false).items.find { |candidate| candidate.issue.id == issue_id }
52
+ render_to_string(partial: "shiplog/admin/boards/card", formats: :html, locals: { item: })
53
+ end
54
+
55
+ def render_json_error(message)
56
+ render json: { error: message }, status: :unprocessable_content
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,15 @@
1
+ module Shiplog
2
+ module Admin
3
+ module Entries
4
+ class ApprovalsController < ApplicationController
5
+ def create
6
+ entry = Entry.find(params[:entry_id])
7
+ entry.approve!(by: current_admin_name)
8
+ redirect_to root_path, notice: t("shiplog.admin.entries.approved")
9
+ rescue ActiveRecord::RecordInvalid => error
10
+ redirect_to edit_entry_path(entry), alert: error.record.errors.full_messages.to_sentence
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,25 @@
1
+ module Shiplog
2
+ module Admin
3
+ module Entries
4
+ class DiscardsController < ApplicationController
5
+ before_action :set_entry
6
+
7
+ def create
8
+ @entry.discard!(reason: params[:reason].to_s.strip.presence)
9
+ redirect_to root_path, notice: t("shiplog.admin.entries.discarded")
10
+ end
11
+
12
+ def destroy
13
+ @entry.restore!
14
+ redirect_to root_path, notice: t("shiplog.admin.entries.restored")
15
+ end
16
+
17
+ private
18
+
19
+ def set_entry
20
+ @entry = Entry.find(params[:entry_id])
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,13 @@
1
+ module Shiplog
2
+ module Admin
3
+ module Entries
4
+ class RewritesController < ApplicationController
5
+ def create
6
+ entry = Entry.find(params[:entry_id])
7
+ RewriteEntryJob.perform_later(entry.id, force: true)
8
+ redirect_to edit_entry_path(entry), notice: t("shiplog.admin.entries.rewrite_queued")
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end