shiplog 0.1.1 → 0.3.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -0
  3. data/README.md +266 -9
  4. data/app/controllers/shiplog/admin/area_detections_controller.rb +15 -0
  5. data/app/controllers/shiplog/admin/area_translations_controller.rb +19 -0
  6. data/app/controllers/shiplog/admin/areas/merges_controller.rb +19 -0
  7. data/app/controllers/shiplog/admin/areas/moves_controller.rb +13 -0
  8. data/app/controllers/shiplog/admin/areas_controller.rb +48 -0
  9. data/app/controllers/shiplog/admin/entries/images/moves_controller.rb +20 -0
  10. data/app/controllers/shiplog/admin/entries/images_controller.rb +76 -0
  11. data/app/controllers/shiplog/admin/entries/translations/retranslations_controller.rb +23 -0
  12. data/app/controllers/shiplog/admin/entries/translations_controller.rb +35 -0
  13. data/app/controllers/shiplog/admin/entries_controller.rb +9 -3
  14. data/app/controllers/shiplog/admin/syncs_controller.rb +3 -2
  15. data/app/controllers/shiplog/changelog_entries_controller.rb +26 -0
  16. data/app/controllers/shiplog/changelog_feeds_controller.rb +19 -0
  17. data/app/controllers/shiplog/changelogs_controller.rb +30 -4
  18. data/app/controllers/shiplog/public_controller.rb +58 -1
  19. data/app/controllers/shiplog/roadmaps_controller.rb +27 -2
  20. data/app/frontend/shiplog/admin.css +9 -0
  21. data/app/frontend/shiplog/shiplog.css +168 -8
  22. data/app/helpers/shiplog/admin/entries_helper.rb +15 -0
  23. data/app/helpers/shiplog/callable_helper.rb +12 -0
  24. data/app/helpers/shiplog/frontend_helper.rb +1 -0
  25. data/app/helpers/shiplog/permalink_helper.rb +113 -0
  26. data/app/helpers/shiplog/roadmap_helper.rb +24 -0
  27. data/app/helpers/shiplog/switcher_helper.rb +101 -0
  28. data/app/helpers/shiplog/unseen_helper.rb +9 -0
  29. data/app/jobs/shiplog/detect_areas_job.rb +18 -0
  30. data/app/jobs/shiplog/refresh_job.rb +1 -0
  31. data/app/jobs/shiplog/rewrite_entry_job.rb +4 -1
  32. data/app/jobs/shiplog/scan_changes_job.rb +19 -5
  33. data/app/jobs/shiplog/sync_job.rb +1 -1
  34. data/app/jobs/shiplog/translate_area_job.rb +25 -0
  35. data/app/jobs/shiplog/translate_entry_job.rb +25 -0
  36. data/app/models/shiplog/area.rb +156 -0
  37. data/app/models/shiplog/area_detector.rb +146 -0
  38. data/app/models/shiplog/area_translator.rb +77 -0
  39. data/app/models/shiplog/change_candidates.rb +39 -4
  40. data/app/models/shiplog/entry/images.rb +127 -0
  41. data/app/models/shiplog/entry.rb +218 -2
  42. data/app/models/shiplog/entry_translation.rb +15 -0
  43. data/app/models/shiplog/image_fetcher/downloader.rb +98 -0
  44. data/app/models/shiplog/image_fetcher/sources.rb +41 -0
  45. data/app/models/shiplog/image_fetcher.rb +72 -0
  46. data/app/models/shiplog/image_type.rb +111 -0
  47. data/app/models/shiplog/pull_request.rb +4 -0
  48. data/app/models/shiplog/pull_request_upgrade.rb +5 -0
  49. data/app/models/shiplog/rewriter/prompt.rb +57 -9
  50. data/app/models/shiplog/rewriter.rb +50 -13
  51. data/app/models/shiplog/setting.rb +19 -0
  52. data/app/models/shiplog/sync/pull_request_importer.rb +23 -1
  53. data/app/models/shiplog/sync/repository_sync.rb +2 -0
  54. data/app/models/shiplog/sync/upgrade_detector.rb +203 -0
  55. data/app/models/shiplog/translator/prompt.rb +40 -0
  56. data/app/models/shiplog/translator.rb +99 -0
  57. data/app/views/layouts/shiplog/application.html.erb +4 -0
  58. data/app/views/shiplog/_empty.html.erb +1 -1
  59. data/app/views/shiplog/_page_header.html.erb +2 -1
  60. data/app/views/shiplog/_pagination.html.erb +3 -3
  61. data/app/views/shiplog/_switcher.html.erb +13 -0
  62. data/app/views/shiplog/admin/_nav.html.erb +2 -1
  63. data/app/views/shiplog/admin/areas/index.html.erb +78 -0
  64. data/app/views/shiplog/admin/entries/_entry_row.html.erb +4 -0
  65. data/app/views/shiplog/admin/entries/_images.html.erb +41 -0
  66. data/app/views/shiplog/admin/entries/_translations.html.erb +42 -0
  67. data/app/views/shiplog/admin/entries/edit.html.erb +12 -0
  68. data/app/views/shiplog/changelog_entries/show.html.erb +25 -0
  69. data/app/views/shiplog/changelog_feeds/show.atom.builder +24 -0
  70. data/app/views/shiplog/changelogs/_areas.html.erb +8 -0
  71. data/app/views/shiplog/changelogs/_entry.html.erb +2 -5
  72. data/app/views/shiplog/changelogs/_entry_content.html.erb +10 -0
  73. data/app/views/shiplog/changelogs/_search.html.erb +18 -0
  74. data/app/views/shiplog/changelogs/show.html.erb +20 -5
  75. data/app/views/shiplog/roadmaps/_card.html.erb +14 -3
  76. data/app/views/shiplog/roadmaps/_column.html.erb +4 -4
  77. data/app/views/shiplog/roadmaps/_shipped.html.erb +15 -0
  78. data/app/views/shiplog/roadmaps/show.html.erb +11 -2
  79. data/config/admin_routes.rb +16 -0
  80. data/config/locales/en.yml +105 -0
  81. data/config/locales/es.yml +223 -0
  82. data/config/routes.rb +5 -0
  83. data/db/migrate/20261027000001_create_shiplog_pull_request_upgrades.rb +30 -0
  84. data/db/migrate/20261027000002_shiplog_0_3_0.rb +105 -0
  85. data/lib/generators/shiplog/install/install_generator.rb +29 -1
  86. data/lib/generators/shiplog/install/templates/initializer.rb.tt +82 -1
  87. data/lib/shiplog/classes.rb +29 -1
  88. data/lib/shiplog/configuration.rb +245 -3
  89. data/lib/shiplog/engine.rb +3 -0
  90. data/lib/shiplog/github/client.rb +42 -9
  91. data/lib/shiplog/github/errors.rb +3 -0
  92. data/lib/shiplog/github/queries.rb +44 -1
  93. data/lib/shiplog/version.rb +1 -1
  94. data/lib/shiplog.rb +73 -0
  95. data/lib/tasks/shiplog.rake +29 -0
  96. metadata +48 -1
@@ -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);
@@ -116,7 +152,8 @@
116
152
  .shiplog-entries { display: grid; gap: 0.75rem; list-style: none; margin: 0; padding: 0; }
117
153
 
118
154
  .shiplog-card,
119
- .shiplog-entry {
155
+ .shiplog-entry,
156
+ .shiplog-permalink {
120
157
  background: var(--sl-card);
121
158
  border: 1px solid var(--sl-border);
122
159
  border-radius: var(--sl-radius);
@@ -132,6 +169,58 @@
132
169
  .shiplog-entry__body p { margin: 0.25rem 0; }
133
170
  .shiplog-card__body { color: var(--sl-muted); font-size: 0.9375rem; }
134
171
 
172
+ .shiplog-card__meta {
173
+ align-items: center;
174
+ color: var(--sl-muted);
175
+ display: flex;
176
+ flex-wrap: wrap;
177
+ font-size: 0.75rem;
178
+ font-weight: 500;
179
+ gap: 0.5rem;
180
+ margin: 0.75rem 0 0;
181
+ }
182
+ .shiplog-card__tag {
183
+ border: 1px solid var(--sl-border);
184
+ border-radius: 0.375rem;
185
+ padding: 0 0.5rem;
186
+ }
187
+
188
+ /* Recently shipped: a title, a link to the full changelog, then a grid of the latest entries */
189
+ .shiplog-shipped {
190
+ align-items: baseline;
191
+ border-top: 1px solid var(--sl-border);
192
+ display: grid;
193
+ gap: 0.5rem 1rem;
194
+ grid-template-columns: 1fr auto;
195
+ margin-top: calc(var(--sl-space) * 2.5);
196
+ padding-top: calc(var(--sl-space) * 1.5);
197
+ }
198
+ .shiplog-shipped__title { font-size: 1.125rem; margin: 0; }
199
+ .shiplog-shipped__list {
200
+ display: grid;
201
+ gap: var(--sl-space) calc(var(--sl-space) * 1.5);
202
+ grid-column: 1 / -1;
203
+ grid-template-columns: repeat(auto-fit, minmax(min(12rem, 100%), 1fr));
204
+ list-style: none;
205
+ margin: 0;
206
+ padding: 0;
207
+ }
208
+ .shiplog-shipped__item { display: grid; gap: 0.25rem; align-content: start; }
209
+ .shiplog-shipped__date { color: var(--sl-muted); font-size: 0.75rem; }
210
+ .shiplog-shipped__link { color: inherit; font-weight: 600; text-decoration: none; }
211
+ .shiplog-shipped__link:hover { color: var(--sl-accent); }
212
+ .shiplog-shipped__link--all { color: var(--sl-accent); font-size: 0.875rem; font-weight: 500; }
213
+
214
+ .shiplog-footnote {
215
+ border-top: 1px solid var(--sl-border);
216
+ color: var(--sl-muted);
217
+ font-size: 0.875rem;
218
+ margin: calc(var(--sl-space) * 2) 0 0;
219
+ padding-top: var(--sl-space);
220
+ text-align: center;
221
+ }
222
+ .shiplog-footnote__link { color: var(--sl-accent); font-weight: 500; }
223
+
135
224
  /* Changelog: months hang off a vertical line */
136
225
  .shiplog-months { display: grid; gap: calc(var(--sl-space) * 2); }
137
226
 
@@ -153,10 +242,14 @@
153
242
  width: 0.75rem;
154
243
  }
155
244
 
156
- .shiplog-entry { align-items: center; display: flex; flex-wrap: wrap; gap: 0.25rem 0.625rem; }
245
+ .shiplog-entry,
246
+ .shiplog-permalink { align-items: center; display: flex; flex-wrap: wrap; gap: 0.25rem 0.625rem; }
157
247
  .shiplog-entry__title,
158
248
  .shiplog-entry__body { flex-basis: 100%; }
159
249
  .shiplog-entry__title { font-size: 1rem; margin: 0.25rem 0 0; }
250
+ .shiplog-entry__title a { color: inherit; text-decoration: none; }
251
+ .shiplog-entry__title a:hover { color: var(--sl-accent); text-decoration: underline; }
252
+ .shiplog-entry__title a:focus-visible { outline: 2px solid var(--sl-accent); outline-offset: 2px; }
160
253
  .shiplog-entry__body { color: var(--sl-muted); }
161
254
  .shiplog-entry__date { color: var(--sl-muted); font-size: 0.8125rem; }
162
255
 
@@ -168,6 +261,9 @@
168
261
  font-weight: 600;
169
262
  padding: 0 0.5rem;
170
263
  }
264
+ .shiplog-permalink .shiplog-entry__title { font-size: 1.5rem; }
265
+ .shiplog-permalink--feature { border-left: 4px solid var(--sl-accent); }
266
+ .shiplog-permalink--feature .shiplog-entry__category { background: var(--sl-accent); border-color: var(--sl-accent); color: #fff; }
171
267
  .shiplog-entry--feature { border-left: 4px solid var(--sl-accent); padding: var(--sl-space) calc(var(--sl-space) * 1.25); }
172
268
  .shiplog-entry--feature .shiplog-entry__title { font-size: 1.25rem; }
173
269
  .shiplog-entry--feature .shiplog-entry__category { background: var(--sl-accent); border-color: var(--sl-accent); color: #fff; }
@@ -176,6 +272,70 @@
176
272
  .shiplog-pagination__link { color: var(--sl-accent); }
177
273
  .shiplog-pagination__link--older { margin-left: auto; }
178
274
 
275
+ .shiplog-permalink__back { color: var(--sl-accent); display: inline-block; margin-top: var(--sl-space); }
276
+ .shiplog-permalink__nav {
277
+ display: flex;
278
+ flex-wrap: wrap;
279
+ gap: var(--sl-space);
280
+ justify-content: space-between;
281
+ margin-top: calc(var(--sl-space) * 2);
282
+ }
283
+ .shiplog-permalink__nav-link { color: var(--sl-accent); }
284
+ .shiplog-permalink__nav-link--older { margin-left: auto; text-align: right; }
285
+ .shiplog-permalink__back:focus-visible,
286
+ .shiplog-permalink__nav-link:focus-visible { outline: 2px solid var(--sl-accent); outline-offset: 2px; }
287
+
288
+ .shiplog-entry__area {
289
+ color: var(--sl-muted);
290
+ font-size: 0.75rem;
291
+ font-weight: 600;
292
+ letter-spacing: 0.02em;
293
+ text-transform: uppercase;
294
+ }
295
+
296
+ .shiplog-entry__images { display: grid; flex-basis: 100%; gap: 0.75rem; margin-top: 0.5rem; }
297
+ .shiplog-entry__image { border: 1px solid var(--sl-border); border-radius: 0.5rem; display: block; height: auto; max-width: 100%; }
298
+
299
+ .shiplog-areas { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: var(--sl-space) 0; }
300
+ .shiplog-areas__link {
301
+ border: 1px solid var(--sl-border);
302
+ border-radius: 1rem;
303
+ color: var(--sl-fg);
304
+ font-size: 0.875rem;
305
+ padding: 0.125rem 0.75rem;
306
+ text-decoration: none;
307
+ }
308
+ .shiplog-areas__link:hover { border-color: var(--sl-accent); }
309
+ .shiplog-areas__link:focus-visible { outline: 2px solid var(--sl-accent); outline-offset: 2px; }
310
+ .shiplog-areas__link--current { background: var(--sl-accent); border-color: var(--sl-accent); color: #fff; }
311
+
312
+ .shiplog-search { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: var(--sl-space) 0; }
313
+ .shiplog-search__input {
314
+ flex: 1 1 14rem;
315
+ min-width: 0;
316
+ font: inherit;
317
+ color: inherit;
318
+ background: var(--sl-card);
319
+ border: 1px solid var(--sl-border);
320
+ border-radius: var(--sl-radius);
321
+ padding: 0.4rem 0.75rem;
322
+ }
323
+ .shiplog-search__input:focus-visible,
324
+ .shiplog-search__button:focus-visible,
325
+ .shiplog-search__clear:focus-visible { outline: 2px solid var(--sl-accent); outline-offset: 2px; }
326
+ .shiplog-search__button {
327
+ font: inherit;
328
+ color: #fff;
329
+ background: var(--sl-accent);
330
+ border: 1px solid var(--sl-accent);
331
+ border-radius: var(--sl-radius);
332
+ padding: 0.4rem 1rem;
333
+ cursor: pointer;
334
+ }
335
+ .shiplog-search__summary { color: var(--sl-muted); margin: 0 0 var(--sl-space); }
336
+ .shiplog-search__clear { color: var(--sl-accent); margin-left: 0.5rem; }
337
+ .shiplog-search__empty { color: var(--sl-muted); margin: 0; }
338
+
179
339
  @media (max-width: 40rem) {
180
340
  .shiplog-page { padding: var(--sl-space) 0.75rem; }
181
341
  .shiplog-page__title { font-size: 1.5rem; }
@@ -19,6 +19,21 @@ module Shiplog
19
19
  entry.title.presence || source_title(entry.subject)
20
20
  end
21
21
 
22
+ # The URL of an image's thumbnail: a 200x200 variant when the host can make them (image_processing and a
23
+ # processor, see Shiplog.image_variants?), else the original (the stylesheet sizes it).
24
+ def image_thumbnail_path(image)
25
+ source = Shiplog.image_variants? && image.variable? ? image.variant(resize_to_limit: [ 200, 200 ]) : image
26
+ polymorphic_path(source)
27
+ rescue ActionController::UrlGenerationError, NoMethodError, ArgumentError
28
+ nil
29
+ end
30
+
31
+ # "Spanish (es)" for a locale of config.locales; the code alone when Shiplog has no name for the language.
32
+ def translation_language_label(locale)
33
+ name = Shiplog::Translator.language_name(locale)
34
+ name == locale.to_s ? name : "#{name} (#{locale})"
35
+ end
36
+
22
37
  def entries_filter_path(status: nil, kind: nil, page: nil)
23
38
  root_path({ status:, kind:, page: }.compact_blank)
24
39
  end
@@ -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,113 @@
1
+ module Shiplog
2
+ # Markup helpers of the permalink pages, the Atom feed and the entries' screenshots.
3
+ module PermalinkHelper
4
+ DESCRIPTION_LENGTH = 160
5
+
6
+ # The path of an entry's permalink page, or nil when the entry has no slug the route accepts (a link is
7
+ # an extra, never a reason to fail the page).
8
+ def shiplog_entry_path(entry)
9
+ changelog_entry_path(entry.slug) if entry.permalink?
10
+ rescue ActionController::UrlGenerationError
11
+ nil
12
+ end
13
+
14
+ # The absolute URL of a named engine route: config.canonical_url plus the route's path (which carries the
15
+ # engine's mount prefix) when it is set, the request's own origin otherwise.
16
+ # shiplog_absolute_url(:changelog_feed) # => "https://trackberry.com/changelog.atom"
17
+ def shiplog_absolute_url(route, *args)
18
+ canonical = Shiplog.config.canonical_url
19
+ canonical ? "#{canonical}#{public_send("#{route}_path", *args)}" : public_send("#{route}_url", *args)
20
+ end
21
+
22
+ # The absolute URL of an entry's permalink page, or nil when it has no valid slug.
23
+ def shiplog_entry_url(entry)
24
+ shiplog_absolute_url(:changelog_entry, entry.slug) if shiplog_entry_path(entry)
25
+ end
26
+
27
+ # The authority of the feed's tag URIs: the canonical host, else the request's.
28
+ def shiplog_tag_host
29
+ canonical = Shiplog.config.canonical_url
30
+ canonical ? URI.parse(canonical).host : request.host
31
+ end
32
+
33
+ # A link to an entry's permalink page, or the plain content in a span when it has none.
34
+ def shiplog_entry_link(entry, name = nil, html_options = {}, &block)
35
+ name, html_options = capture(&block), name || {} if block
36
+ path = shiplog_entry_path(entry)
37
+ path ? link_to(name, path, html_options) : content_tag(:span, name, html_options.slice(:class))
38
+ end
39
+
40
+ # The id of a month's section on the changelog list.
41
+ def shiplog_month_anchor(date) = "shiplog-month-#{date.strftime("%Y-%m")}"
42
+
43
+ # The body, on one line and cut for a meta description. Plain text: tag helpers escape it.
44
+ def shiplog_entry_description(entry)
45
+ truncate(entry.display_body.to_s.squish, length: DESCRIPTION_LENGTH, separator: " ")
46
+ end
47
+
48
+ # The Open Graph meta tags of a permalink page, for `content_for :shiplog_og`.
49
+ def shiplog_og_tags(entry, url:)
50
+ tags = {
51
+ "og:title" => entry.display_title, "og:description" => shiplog_entry_description(entry),
52
+ "og:type" => "article", "og:url" => url, "og:image" => shiplog_entry_og_image(entry)
53
+ }
54
+ safe_join(tags.compact_blank.map { |property, content| tag.meta(property:, content:) }, "\n")
55
+ end
56
+
57
+ # The absolute URL of the image to show when the page is shared (the first image), or nil.
58
+ def shiplog_entry_og_image(entry)
59
+ image = shiplog_images(entry).first
60
+ shiplog_image_url(image) if image
61
+ end
62
+
63
+ # The HTML of an entry in the Atom feed: its text, then its images with absolute URLs.
64
+ def shiplog_feed_content(entry)
65
+ images = shiplog_images(entry).filter_map do |image|
66
+ url = shiplog_image_url(image)
67
+ tag.p(tag.img(src: url, alt: entry.image_alt(image), **shiplog_image_size(image))) if url
68
+ end
69
+ safe_join([ simple_format(h(entry.display_body)), *images ], "\n")
70
+ end
71
+
72
+ # An entry's images in order, or none while screenshots are off.
73
+ def shiplog_images(entry)
74
+ Shiplog.images? ? entry.ordered_images : []
75
+ end
76
+
77
+ # The images of an entry under its body: `limit` of them (all by default). Nil when it has none to show.
78
+ def shiplog_entry_images(entry, limit: nil)
79
+ images = shiplog_images(entry)
80
+ images = images.first(limit) if limit
81
+ tags = images.filter_map do |image|
82
+ path = shiplog_image_path(image)
83
+ tag.img(src: path, alt: entry.image_alt(image), loading: "lazy", class: shiplog_class(:entry_image), **shiplog_image_size(image)) if path
84
+ end
85
+ tag.div(safe_join(tags), class: shiplog_class(:entry_images)) if tags.any?
86
+ end
87
+
88
+ # Active Storage's routes belong to the host: url_for reaches them through main_app (see HostRoutesHelper).
89
+ # Nil when the host can't build the route.
90
+ def shiplog_image_path(image)
91
+ polymorphic_path(image)
92
+ rescue ActionController::UrlGenerationError, NoMethodError, ArgumentError
93
+ nil
94
+ end
95
+
96
+ # The image's absolute URL: config.canonical_url plus its path when set, the request's origin otherwise.
97
+ def shiplog_image_url(image)
98
+ canonical = Shiplog.config.canonical_url
99
+ return polymorphic_url(image) unless canonical
100
+
101
+ path = shiplog_image_path(image)
102
+ "#{canonical}#{path}" if path
103
+ rescue ActionController::UrlGenerationError, NoMethodError, ArgumentError
104
+ nil
105
+ end
106
+
107
+ # width and height from the blob's analysis, when it has run and found them.
108
+ def shiplog_image_size(image)
109
+ metadata = image.blob.metadata
110
+ { width: metadata[:width], height: metadata[:height] }.select { |_, value| value.to_i.positive? }
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,24 @@
1
+ module Shiplog
2
+ module RoadmapHelper
3
+ include Shiplog::SwitcherHelper
4
+
5
+ # English texts of the roadmap columns, used when the host's locale has no shiplog.columns translations.
6
+ COLUMN_DEFAULTS = {
7
+ in_progress: { title: "In progress", description: "What we're building now" },
8
+ coming_soon: { title: "Coming soon", description: "Planned next" },
9
+ later: { title: "Later", description: "On our radar" }
10
+ }.freeze
11
+
12
+ def shiplog_column_defaults = COLUMN_DEFAULTS
13
+
14
+ # The configured label of a repository (config.repository_labels, matched case-insensitively), or nil.
15
+ def shiplog_repository_label(repository)
16
+ labels = Shiplog.config.repository_labels
17
+ label = labels[repository.full_name] || labels.find { |name, _| name.casecmp?(repository.full_name) }&.last
18
+ shiplog_resolve(label).presence
19
+ end
20
+
21
+ # The "suggest a feature" target (String or callable), or nil.
22
+ def shiplog_feedback_path = shiplog_safe_url(shiplog_resolve(Shiplog.config.feedback_path))
23
+ end
24
+ end
@@ -0,0 +1,101 @@
1
+ module Shiplog
2
+ # The Roadmap / Changelog / extra pages switcher. The default page header renders it, and a host adds
3
+ # `helper Shiplog::SwitcherHelper` to render it on its own pages (e.g. a news page): `<%= shiplog_switcher %>`.
4
+ # It uses the engine's own route helpers, or the proxy of the host's mount (whatever its `as:`), so it works in
5
+ # main-app views too.
6
+ module SwitcherHelper
7
+ include Shiplog::StylingHelper
8
+ include Shiplog::CallableHelper
9
+
10
+ # URLs already warned about. Capped, since a callable path could return a new bad URL on every request.
11
+ WARNED = Set.new
12
+ MAX_WARNED = 100
13
+ private_constant :WARNED, :MAX_WARNED
14
+
15
+ def self.reset_warnings! = WARNED.clear
16
+
17
+ NotMounted = Class.new(StandardError)
18
+
19
+ # The name the host gave the engine's mount in its routes (`mount Shiplog::Engine => "/", as: :updates`; the
20
+ # default is "shiplog"), which is the name of its route proxy in host views. Found by looking for the route
21
+ # of config/routes.rb whose Rack app is the engine (the Constraints wrapper's `app`; Rails::Engine is what
22
+ # `mount` stores) and remembered while that route exists.
23
+ def self.mount_name
24
+ cached = @mount_name
25
+ return cached if cached && Rails.application.routes.named_routes.key?(cached)
26
+
27
+ route = Rails.application.routes.routes.find { |candidate| candidate.app.try(:app) == Shiplog::Engine }
28
+ raise NotMounted, "Shiplog::Engine is not mounted in the host's routes: add `mount Shiplog::Engine => \"/\"` to config/routes.rb" unless route&.name
29
+
30
+ @mount_name = route.name
31
+ end
32
+
33
+ def self.reset_mount_name! = (@mount_name = nil)
34
+
35
+ # The switcher for host pages: the bundled stylesheet (once per page, and not when the engine page already
36
+ # linked it; unless config.stylesheet is false) and the nav.
37
+ def shiplog_switcher
38
+ stylesheet = if Shiplog.config.stylesheet && !@_shiplog_stylesheet_emitted
39
+ @_shiplog_stylesheet_emitted = true
40
+ tag.link(rel: "stylesheet", nonce: content_security_policy_nonce,
41
+ href: shiplog_routes.frontend_path(version: Shiplog::VERSION, name: "shiplog.css"))
42
+ end
43
+ safe_join([ stylesheet, render("shiplog/switcher") ])
44
+ end
45
+
46
+ # The nav's aria-label: a plain String (never html_safe markup, never a translation-missing span).
47
+ def shiplog_switcher_label = String.new(I18n.t("shiplog.switcher.label", default: "Product updates"))
48
+
49
+ # [{ label:, path:, current: }] in display order. A path with an unsafe scheme is nil (rendered as text).
50
+ # Only the page with the longest matching path is current.
51
+ def shiplog_switcher_pages
52
+ pages = [
53
+ { label: t("shiplog.switcher.roadmap", default: "Roadmap"), path: shiplog_routes.roadmap_path },
54
+ { label: t("shiplog.switcher.changelog", default: "Changelog"), path: shiplog_routes.changelog_path },
55
+ *Shiplog.config.extra_pages.map { |page| { label: shiplog_resolve(page[:label]), path: shiplog_safe_url(shiplog_resolve(page[:path])) } }
56
+ ]
57
+ lengths = pages.map { |page| shiplog_match_length(page[:path]) }
58
+ best = lengths.compact.max
59
+ current = best && lengths.index(best)
60
+ pages.each_with_index.map { |page, index| page.merge(current: index == current) }
61
+ end
62
+
63
+ # The URL when it is safe to link (see Configuration.safe_url?); otherwise nil, with a warning logged once.
64
+ def shiplog_safe_url(url)
65
+ return if url.blank?
66
+ return url.to_s if Shiplog::Configuration.safe_url?(url)
67
+
68
+ if WARNED.size < MAX_WARNED && WARNED.add?(url.to_s)
69
+ Rails.logger.warn("[Shiplog] Not linking #{url.inspect}: only paths, http(s) and mailto: URLs are allowed")
70
+ end
71
+ nil
72
+ end
73
+
74
+ private
75
+
76
+ # Where the engine's route helpers live: the view itself inside the engine's own pages, otherwise (host pages)
77
+ # the proxy the host's mount defined, whatever its name.
78
+ def shiplog_routes
79
+ return self if controller.try(:_routes) == Shiplog::Engine.routes
80
+
81
+ public_send(SwitcherHelper.mount_name)
82
+ end
83
+
84
+ # Length of the link's path when it contains the request path (equal, or a parent segment), else nil.
85
+ # An absolute URL counts only for http(s) and the request's own host.
86
+ def shiplog_match_length(url)
87
+ return if url.nil?
88
+
89
+ uri = URI.parse(url)
90
+ return if uri.scheme && !(uri.scheme.match?(/\Ahttps?\z/i) && uri.host.to_s.casecmp?(request.host))
91
+
92
+ prefix = (uri.path || "").chomp("/")
93
+ current = request.path.to_s.chomp("/")
94
+ return (prefix.length if current == prefix) if prefix.empty?
95
+
96
+ prefix.length if current == prefix || current.start_with?("#{prefix}/")
97
+ rescue URI::InvalidURIError
98
+ nil
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,9 @@
1
+ module Shiplog
2
+ # The "what's new" count for host views: `helper Shiplog::UnseenHelper`, then
3
+ # `shiplog_unseen_count(current_user.changelog_seen_at)`. Shiplog doesn't know about users, so the host
4
+ # supplies the "last seen" time (and stamps it from config.on_changelog_seen).
5
+ module UnseenHelper
6
+ # The number of approved features and changes approved after `since` (all of them while it is nil), at most 99.
7
+ def shiplog_unseen_count(since) = Shiplog.unseen_count(since:)
8
+ end
9
+ end
@@ -0,0 +1,18 @@
1
+ module Shiplog
2
+ # Backfills product areas for entries that have none (see AreaDetector). Enqueued by the admin's Areas page.
3
+ # A failure is logged and recorded for the Areas page (Setting "area_detection"), never raised: the job is not
4
+ # retried, the admin clicks again once the cause (usually the rewriter) is fixed.
5
+ class DetectAreasJob < ApplicationJob
6
+ SETTING = "area_detection".freeze
7
+ MAX_ERROR_CHARS = 500
8
+
9
+ # recheck: true first makes the entries the AI left unplaced eligible again.
10
+ def perform(recheck: false)
11
+ assigned = AreaDetector.new.call(recheck:)
12
+ Setting.write(SETTING, at: Time.current.iso8601, assigned:) if assigned # nil: another run was in progress
13
+ rescue StandardError => error
14
+ Rails.logger.error("[shiplog] Detect areas failed: #{error.class}: #{error.message}\n#{error.backtrace&.first(5)&.join("\n")}")
15
+ Setting.write(SETTING, at: Time.current.iso8601, error: "#{error.class}: #{error.message}".truncate(MAX_ERROR_CHARS))
16
+ end
17
+ end
18
+ end
@@ -15,6 +15,7 @@ module Shiplog
15
15
  when "pull_request" then import(repository, number, Github::Queries::PULL_REQUEST, "pullRequest", Sync::PullRequestImporter)
16
16
  else raise ArgumentError, "Unknown kind: #{kind.inspect}"
17
17
  end
18
+ Sync::UpgradeDetector.call_isolated(repository) if kind.to_s == "pull_request"
18
19
  Drafter.call
19
20
  end
20
21
 
@@ -2,8 +2,11 @@ module Shiplog
2
2
  class RewriteEntryJob < ApplicationJob
3
3
  discard_on ActiveRecord::RecordNotFound
4
4
 
5
+ # After writing the draft, screenshots from the GitHub body are attached to a draft that has none (the
6
+ # fetcher checks, never raises, and leaves approved entries alone).
5
7
  def perform(entry_id, force: false)
6
- Rewriter.new(Entry.find(entry_id)).call(force:)
8
+ entry = Rewriter.new(Entry.find(entry_id)).call(force:)
9
+ ImageFetcher.call(entry)
7
10
  end
8
11
  end
9
12
  end
@@ -2,9 +2,13 @@ module Shiplog
2
2
  class ScanChangesJob < ApplicationJob
3
3
  DEFAULT_WINDOW = 7.days
4
4
 
5
+ # Where a repository's next scan starts, and so the oldest merge nothing will draft before: the end of the
6
+ # last scan, or DEFAULT_WINDOW ago for a repository never scanned.
7
+ def self.window_start(repository) = repository.changes_scanned_at || DEFAULT_WINDOW.ago
8
+
5
9
  def perform
6
10
  client = Github::Client.new
7
- Shiplog.config.repositories.each do |full_name|
11
+ Shiplog.config.repository_names.each do |full_name|
8
12
  scan(Repository.find_or_create_by!(full_name:), client)
9
13
  end
10
14
  Drafter.call
@@ -12,19 +16,29 @@ module Shiplog
12
16
 
13
17
  private
14
18
  def scan(repository, client)
15
- since = repository.changes_scanned_at || DEFAULT_WINDOW.ago
19
+ since = self.class.window_start(repository)
16
20
  started_at = Time.current
17
21
  # Import late-synced PRs first so anything merged before `started_at` is visible to the scan.
18
22
  Sync::RepositorySync.new(repository, client:).call
19
- Sync::CommitScanner.new(repository, client:).call(since:)
20
- draft_changes(ChangeCandidates.new(repository:, since:, before: started_at).to_a)
21
- repository.update!(changes_scanned_at: started_at)
23
+ if Shiplog.config.changelog_repository?(repository.full_name)
24
+ Sync::CommitScanner.new(repository, client:).call(since:)
25
+ draft_changes(ChangeCandidates.new(repository:, since:, before: started_at).to_a)
26
+ end
27
+ repository.update!(changes_scanned_at: window_end(repository, started_at, since))
22
28
  rescue Github::RateLimited
23
29
  raise
24
30
  rescue StandardError => error
25
31
  Rails.error.report(error, handled: true, context: { repository: repository.full_name })
26
32
  end
27
33
 
34
+ # Pull requests of this window that await upgrade detection are held back by ChangeCandidates, so the window
35
+ # stays at the oldest one and the next scan reconsiders it once it is checked. It never ends before `since`:
36
+ # older pending pull requests are outside the window, and the window must not move backwards.
37
+ def window_end(repository, started_at, since)
38
+ oldest_pending = Shiplog.config.changelog_repository?(repository.full_name) ? ChangeCandidates.oldest_pending(repository, since:) : nil
39
+ [ [ started_at, oldest_pending ].compact.min, since ].max
40
+ end
41
+
28
42
  def draft_changes(candidates)
29
43
  candidates.each { |subject| Drafter.draft(subject, "change", released_on: released_on(subject)) }
30
44
  end
@@ -2,7 +2,7 @@ module Shiplog
2
2
  class SyncJob < ApplicationJob
3
3
  def perform
4
4
  client = Github::Client.new
5
- repositories = Shiplog.config.repositories.map { |full_name| Repository.find_or_create_by!(full_name:) }
5
+ repositories = Shiplog.config.repository_names.map { |full_name| Repository.find_or_create_by!(full_name:) }
6
6
  repositories.each { |repository| sync(repository, client) }
7
7
  Drafter.call
8
8
  end
@@ -0,0 +1,25 @@
1
+ module Shiplog
2
+ # Translates an area's name into every locale of config.locales with one LLM call (see AreaTranslator).
3
+ # Enqueued when an area is created or renamed, by "Translate names" (with `force: true`) and by
4
+ # `shiplog:translate_approved`. Without force a locale that has a name is left alone, an admin's edit included;
5
+ # a repeat when every locale has one makes no LLM call.
6
+ #
7
+ # A failure (the rewriter raised, or its reply was unusable) is logged and retried twice, with a growing wait,
8
+ # then logged again and dropped: never an endless retry. The English name stays shown meanwhile.
9
+ class TranslateAreaJob < ApplicationJob
10
+ ATTEMPTS = 3
11
+
12
+ retry_on StandardError, attempts: ATTEMPTS, wait: :polynomially_longer do |job, error|
13
+ Rails.logger.error("[shiplog] Giving up translating area #{job.arguments.first.try(:id)} after #{ATTEMPTS} attempts: #{error.class}: #{error.message}")
14
+ end
15
+ # Declared after retry_on, which makes it win: an area deleted since the job was enqueued is not an error.
16
+ discard_on ActiveJob::DeserializationError, ActiveRecord::RecordNotFound
17
+
18
+ def perform(area, force: false)
19
+ AreaTranslator.new(area).call(force:)
20
+ rescue StandardError => error
21
+ Rails.logger.error("[shiplog] Translating area #{area.id} failed (attempt #{executions} of #{ATTEMPTS}): #{error.class}: #{error.message}")
22
+ raise
23
+ end
24
+ end
25
+ end