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
@@ -0,0 +1,78 @@
1
+ // Idempotent: runs now and again on turbo:load (inert without Turbo), binding each container once.
2
+ const init = () => {
3
+ const inbox = document.querySelector("[data-shiplog-inbox]")
4
+ if (!inbox || inbox.dataset.shiplogBound) return
5
+ inbox.dataset.shiplogBound = "true"
6
+
7
+ // Host layouts may not render csrf_meta_tags, so the container carries the token as a fallback.
8
+ const csrfToken = document.querySelector("meta[name=csrf-token]")?.content ?? inbox.dataset.csrfToken
9
+ const status = inbox.querySelector("[data-shiplog-status]")
10
+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)")
11
+
12
+ const announce = (text, alert = false) => {
13
+ status.textContent = text
14
+ status.classList.toggle("shiplog-admin__live--alert", alert)
15
+ status.setAttribute("role", alert ? "alert" : "status")
16
+ status.hidden = false
17
+ }
18
+
19
+ const showEmptyState = () => {
20
+ const empty = document.createElement("p")
21
+ empty.className = "shiplog-admin__empty"
22
+ empty.textContent = inbox.dataset.emptyText
23
+ inbox.querySelector("table").replaceWith(empty)
24
+ }
25
+
26
+ const removeRow = (row) => {
27
+ const done = () => {
28
+ row.remove()
29
+ if (!inbox.querySelector(".shiplog-entry-row")) showEmptyState()
30
+ }
31
+ if (reducedMotion.matches) return done()
32
+ row.classList.add("shiplog-entry-row--leaving")
33
+ row.addEventListener("transitionend", done, { once: true })
34
+ setTimeout(() => row.isConnected && done(), 400)
35
+ }
36
+
37
+ const replaceRow = (row, html) => {
38
+ const template = document.createElement("template")
39
+ template.innerHTML = html.trim()
40
+ row.replaceWith(template.content.firstElementChild)
41
+ }
42
+
43
+ // Bubbling submit on the container runs before Turbo's document-level listener.
44
+ inbox.addEventListener("submit", async (event) => {
45
+ const form = event.target.closest("form[data-shiplog-inline]")
46
+ if (!form) return
47
+ event.preventDefault()
48
+
49
+ const row = form.closest(".shiplog-entry-row")
50
+ const title = row.querySelector("a").textContent.trim()
51
+ const button = event.submitter ?? form.querySelector("button")
52
+ button.disabled = true
53
+ try {
54
+ // Rails reads a hidden _method field (button_to), so the request itself is always a POST.
55
+ const response = await fetch(form.action, {
56
+ method: "POST",
57
+ headers: { "Accept": "application/json", "X-CSRF-Token": csrfToken },
58
+ body: new FormData(form)
59
+ })
60
+ if (response.status !== 200 && response.status !== 422) throw new Error(response.status)
61
+
62
+ const data = await response.json()
63
+ if (response.status === 422) {
64
+ button.disabled = false
65
+ return announce(`${title}: ${data.error}`, true)
66
+ }
67
+ announce(`${title}: ${data.message}`)
68
+ if (data.matches_filter) replaceRow(row, data.row)
69
+ else removeRow(row)
70
+ } catch (error) {
71
+ // Network or server failure: fall back to a normal form submission.
72
+ form.submit()
73
+ }
74
+ })
75
+ }
76
+
77
+ init()
78
+ document.addEventListener("turbo:load", init)
@@ -67,6 +67,37 @@
67
67
  .shiplog-page__title { font-size: 1.875rem; line-height: 1.2; margin: 0; }
68
68
  .shiplog-page__lede { color: var(--sl-muted); margin: 0.5rem 0 0; }
69
69
 
70
+ /* Switcher: Roadmap / Changelog / host pages. It can sit outside .shiplog-page (host pages), so it
71
+ falls back to the host-safe palette when the page variables are not inherited. */
72
+ .shiplog-switcher {
73
+ --sw-border: var(--sl-border, var(--shiplog-border, color-mix(in srgb, currentColor 22%, transparent)));
74
+ --sw-card: var(--sl-card, var(--shiplog-card, color-mix(in srgb, currentColor 5%, transparent)));
75
+ --sw-muted: var(--sl-muted, var(--shiplog-muted, color-mix(in srgb, currentColor 65%, transparent)));
76
+ --sw-accent: var(--sl-accent, var(--shiplog-accent, #2f81f7));
77
+ background: var(--sw-card);
78
+ border: 1px solid var(--sw-border);
79
+ border-radius: 999px;
80
+ display: inline-flex;
81
+ flex-wrap: wrap;
82
+ font-size: 0.875rem;
83
+ font-weight: 500;
84
+ gap: 0.25rem;
85
+ margin-top: var(--sl-space, 1rem);
86
+ padding: 0.25rem;
87
+ }
88
+ .shiplog-switcher__link {
89
+ border-radius: 999px;
90
+ color: var(--sw-muted);
91
+ padding: 0.25rem 1rem;
92
+ text-decoration: none;
93
+ transition: background-color 0.15s, color 0.15s;
94
+ }
95
+ .shiplog-switcher__link:hover { color: inherit; }
96
+ .shiplog-switcher__link:focus-visible { outline: 2px solid var(--sw-accent); outline-offset: 2px; }
97
+ .shiplog-switcher__link--current,
98
+ .shiplog-switcher__link--current:hover { background: var(--sw-accent); color: #fff; }
99
+ @media (prefers-reduced-motion: reduce) { .shiplog-switcher__link { transition: none; } }
100
+
70
101
  .shiplog-empty,
71
102
  .shiplog-column__empty { color: var(--sl-muted); margin: 0; }
72
103
 
@@ -77,17 +108,21 @@
77
108
  grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
78
109
  }
79
110
 
111
+ /* A rule on top and a marker carry the column's place in time: in progress is solid, coming soon
112
+ is the accent, later is neutral. */
80
113
  .shiplog-column {
81
114
  --sl-dot: var(--sl-muted);
115
+ --sl-rule: var(--sl-border);
116
+ border-top: 2px solid var(--sl-rule);
82
117
  min-width: 0;
118
+ padding-top: 1rem;
83
119
  }
84
- .shiplog-column--in-progress { --sl-dot: #1a7f37; }
85
- .shiplog-column--coming-soon { --sl-dot: var(--sl-accent); }
86
- .shiplog-column--later { --sl-dot: var(--sl-muted); }
120
+ .shiplog-column--in-progress { --sl-dot: #1a7f37; --sl-rule: var(--sl-dot); }
121
+ .shiplog-column--coming-soon { --sl-dot: var(--sl-accent); --sl-rule: var(--sl-dot); }
122
+ .shiplog-column--later { --sl-dot: var(--sl-muted); --sl-rule: var(--sl-border); }
87
123
 
88
124
  .shiplog-column__header {
89
125
  align-items: baseline;
90
- border-bottom: 1px solid var(--sl-border);
91
126
  display: flex;
92
127
  flex-wrap: wrap;
93
128
  gap: 0 0.5rem;
@@ -95,13 +130,14 @@
95
130
  padding-bottom: 0.75rem;
96
131
  }
97
132
  .shiplog-column__title { align-items: center; display: flex; font-size: 1.125rem; gap: 0.5rem; margin: 0; }
98
- .shiplog-column__title::before {
133
+ .shiplog-column__marker {
99
134
  background: var(--sl-dot);
100
135
  border-radius: 50%;
101
- content: "";
136
+ flex-shrink: 0;
102
137
  height: 0.625rem;
103
138
  width: 0.625rem;
104
139
  }
140
+ .shiplog-column__marker--in-progress { box-shadow: 0 0 0 4px color-mix(in srgb, var(--sl-dot) 20%, transparent); }
105
141
  .shiplog-column__count {
106
142
  background: var(--sl-card);
107
143
  border: 1px solid var(--sl-border);
@@ -132,6 +168,58 @@
132
168
  .shiplog-entry__body p { margin: 0.25rem 0; }
133
169
  .shiplog-card__body { color: var(--sl-muted); font-size: 0.9375rem; }
134
170
 
171
+ .shiplog-card__meta {
172
+ align-items: center;
173
+ color: var(--sl-muted);
174
+ display: flex;
175
+ flex-wrap: wrap;
176
+ font-size: 0.75rem;
177
+ font-weight: 500;
178
+ gap: 0.5rem;
179
+ margin: 0.75rem 0 0;
180
+ }
181
+ .shiplog-card__tag {
182
+ border: 1px solid var(--sl-border);
183
+ border-radius: 0.375rem;
184
+ padding: 0 0.5rem;
185
+ }
186
+
187
+ /* Recently shipped: a title, a link to the full changelog, then a grid of the latest entries */
188
+ .shiplog-shipped {
189
+ align-items: baseline;
190
+ border-top: 1px solid var(--sl-border);
191
+ display: grid;
192
+ gap: 0.5rem 1rem;
193
+ grid-template-columns: 1fr auto;
194
+ margin-top: calc(var(--sl-space) * 2.5);
195
+ padding-top: calc(var(--sl-space) * 1.5);
196
+ }
197
+ .shiplog-shipped__title { font-size: 1.125rem; margin: 0; }
198
+ .shiplog-shipped__list {
199
+ display: grid;
200
+ gap: var(--sl-space) calc(var(--sl-space) * 1.5);
201
+ grid-column: 1 / -1;
202
+ grid-template-columns: repeat(auto-fit, minmax(min(12rem, 100%), 1fr));
203
+ list-style: none;
204
+ margin: 0;
205
+ padding: 0;
206
+ }
207
+ .shiplog-shipped__item { display: grid; gap: 0.25rem; align-content: start; }
208
+ .shiplog-shipped__date { color: var(--sl-muted); font-size: 0.75rem; }
209
+ .shiplog-shipped__link { color: inherit; font-weight: 600; text-decoration: none; }
210
+ .shiplog-shipped__link:hover { color: var(--sl-accent); }
211
+ .shiplog-shipped__link--all { color: var(--sl-accent); font-size: 0.875rem; font-weight: 500; }
212
+
213
+ .shiplog-footnote {
214
+ border-top: 1px solid var(--sl-border);
215
+ color: var(--sl-muted);
216
+ font-size: 0.875rem;
217
+ margin: calc(var(--sl-space) * 2) 0 0;
218
+ padding-top: var(--sl-space);
219
+ text-align: center;
220
+ }
221
+ .shiplog-footnote__link { color: var(--sl-accent); font-weight: 500; }
222
+
135
223
  /* Changelog: months hang off a vertical line */
136
224
  .shiplog-months { display: grid; gap: calc(var(--sl-space) * 2); }
137
225
 
@@ -176,6 +264,33 @@
176
264
  .shiplog-pagination__link { color: var(--sl-accent); }
177
265
  .shiplog-pagination__link--older { margin-left: auto; }
178
266
 
267
+ .shiplog-search { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: var(--sl-space) 0; }
268
+ .shiplog-search__input {
269
+ flex: 1 1 14rem;
270
+ min-width: 0;
271
+ font: inherit;
272
+ color: inherit;
273
+ background: var(--sl-card);
274
+ border: 1px solid var(--sl-border);
275
+ border-radius: var(--sl-radius);
276
+ padding: 0.4rem 0.75rem;
277
+ }
278
+ .shiplog-search__input:focus-visible,
279
+ .shiplog-search__button:focus-visible,
280
+ .shiplog-search__clear:focus-visible { outline: 2px solid var(--sl-accent); outline-offset: 2px; }
281
+ .shiplog-search__button {
282
+ font: inherit;
283
+ color: #fff;
284
+ background: var(--sl-accent);
285
+ border: 1px solid var(--sl-accent);
286
+ border-radius: var(--sl-radius);
287
+ padding: 0.4rem 1rem;
288
+ cursor: pointer;
289
+ }
290
+ .shiplog-search__summary { color: var(--sl-muted); margin: 0 0 var(--sl-space); }
291
+ .shiplog-search__clear { color: var(--sl-accent); margin-left: 0.5rem; }
292
+ .shiplog-search__empty { color: var(--sl-muted); margin: 0; }
293
+
179
294
  @media (max-width: 40rem) {
180
295
  .shiplog-page { padding: var(--sl-space) 0.75rem; }
181
296
  .shiplog-page__title { font-size: 1.5rem; }
@@ -0,0 +1,12 @@
1
+ module Shiplog
2
+ module CallableHelper
3
+ # A config value that is a String, or a callable evaluated in the view so it can use route helpers
4
+ # and I18n (`-> { main_app.news_path }`, `-> { t("...") }`).
5
+ def shiplog_resolve(value)
6
+ case value
7
+ when Proc then instance_exec(&value)
8
+ else value.respond_to?(:call) ? value.call : value
9
+ end
10
+ end
11
+ end
12
+ end
@@ -1,6 +1,7 @@
1
1
  module Shiplog
2
2
  module FrontendHelper
3
3
  def shiplog_stylesheet_tag(name)
4
+ @_shiplog_stylesheet_emitted = true if name == "shiplog" # shiplog_switcher then skips its own link
4
5
  tag.link rel: "stylesheet", href: shiplog_asset_path("#{name}.css"), nonce: content_security_policy_nonce
5
6
  end
6
7
 
@@ -3,6 +3,12 @@ module Shiplog
3
3
  # the host's own helpers (root_path, new_user_session_path, ...) are undefined there. This forwards
4
4
  # any unknown *_path / *_url call to main_app when the host app defines it. Helpers the engine
5
5
  # defines itself (roadmap_path, board_path, ...) are real methods and always win.
6
+ #
7
+ # Polymorphic URLs get the same treatment. Rails registers `resolve` mappings (Active Storage's
8
+ # VariantWithRecord, ...) on the host's routes only, so image_tag(record), url_for(record),
9
+ # link_to(label, record) and polymorphic_path/url(record) would otherwise fall through to
10
+ # record.to_model. When the engine has no mapping for the record's class but main_app does, the
11
+ # call is delegated to main_app. Engine mappings keep precedence.
6
12
  module HostRoutesHelper
7
13
  def method_missing(name, ...)
8
14
  if shiplog_host_route?(name)
@@ -16,8 +22,39 @@ module Shiplog
16
22
  shiplog_host_route?(name) || super
17
23
  end
18
24
 
25
+ def polymorphic_url(record_or_hash_or_array, options = {})
26
+ return super unless shiplog_host_mapping?(record_or_hash_or_array)
27
+
28
+ main_app.polymorphic_url(record_or_hash_or_array, options)
29
+ end
30
+
31
+ def polymorphic_path(record_or_hash_or_array, options = {})
32
+ return super unless shiplog_host_mapping?(record_or_hash_or_array)
33
+
34
+ main_app.polymorphic_path(record_or_hash_or_array, options)
35
+ end
36
+
37
+ def url_for(options = nil)
38
+ return super unless shiplog_host_mapping?(options)
39
+
40
+ _generate_paths_by_default ? polymorphic_path(options) : polymorphic_url(options)
41
+ end
42
+
19
43
  private
20
44
 
45
+ def shiplog_host_mapping?(record)
46
+ return false if record.nil? || record.is_a?(Hash) || record.is_a?(Array) || record.is_a?(String) ||
47
+ record.is_a?(Symbol) || record.is_a?(Class) || record.is_a?(ActionController::Parameters)
48
+ return false unless respond_to?(:main_app, true)
49
+
50
+ model = record.respond_to?(:to_model) ? record.to_model : record
51
+ # Odd objects (to_model returning something without a model_name) are left to Rails' own error.
52
+ return false if record.respond_to?(:to_model) && !model.respond_to?(:model_name)
53
+
54
+ name = record.respond_to?(:to_model) ? model.model_name.name : record.class.name
55
+ !_routes.polymorphic_mappings.key?(name) && main_app._routes.polymorphic_mappings.key?(name)
56
+ end
57
+
21
58
  def shiplog_host_route?(name)
22
59
  name.end_with?("_path", "_url") && respond_to?(:main_app, true) && main_app.respond_to?(name)
23
60
  end
@@ -0,0 +1,15 @@
1
+ module Shiplog
2
+ module RoadmapHelper
3
+ include Shiplog::SwitcherHelper
4
+
5
+ # The configured label of a repository (config.repository_labels, matched case-insensitively), or nil.
6
+ def shiplog_repository_label(repository)
7
+ labels = Shiplog.config.repository_labels
8
+ label = labels[repository.full_name] || labels.find { |name, _| name.casecmp?(repository.full_name) }&.last
9
+ shiplog_resolve(label).presence
10
+ end
11
+
12
+ # The "suggest a feature" target (String or callable), or nil.
13
+ def shiplog_feedback_path = shiplog_safe_url(shiplog_resolve(Shiplog.config.feedback_path))
14
+ end
15
+ end
@@ -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
@@ -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,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
@@ -1,6 +1,21 @@
1
1
  module Shiplog
2
2
  class ChangeCandidates
3
3
  COMMIT_TYPE = /\A(\w+)(\([^)]*\))?!?:/
4
+ # The Conventional Commits types; "Importers: ..." is a topic prefix, not a type.
5
+ CONVENTIONAL_TYPES = %w[feat fix perf refactor chore docs test ci build style revert deps].freeze
6
+
7
+ # Dropped from the upgrade bypass: the label Dependabot adds. Any other excluded label still excludes.
8
+ UPGRADE_LABEL = "dependencies".freeze
9
+
10
+ # The merge time of the oldest merged pull request merged at or after `since` whose upgrade detection has not
11
+ # run yet, or nil. Such pull requests are held back from candidates, so a scan window must not move past it.
12
+ # Older ones are outside the window: they are never drafted, and must not drag it backwards. Nil when no gem
13
+ # is tracked, since detection would never run.
14
+ def self.oldest_pending(repository, since:)
15
+ return unless Shiplog.config.tracked_gems.any?
16
+
17
+ repository.pull_requests.awaiting_upgrade_check.where(merged_at: since..).reorder(:merged_at).pick(:merged_at)
18
+ end
4
19
 
5
20
  def initialize(repository:, since:, before:)
6
21
  @repository = repository
@@ -13,25 +28,47 @@ module Shiplog
13
28
 
14
29
  private
15
30
  def pull_requests
16
- candidates = @repository.pull_requests.where(state: "merged", merged_at: @window).where.missing(:entries).to_a
17
- .reject { |pull_request| excluded_label?(pull_request) || excluded_type?(pull_request) || Shiplog.config.excluded_author?(pull_request.author_login) }
31
+ candidates = merged_in_window.where.missing(:entries).to_a
32
+ upgrade_ids = ids_with_upgrades(candidates)
33
+ candidates = candidates.reject do |pull_request|
34
+ if upgrade_ids.include?(pull_request.id)
35
+ excluded_label?(pull_request, ignoring: UPGRADE_LABEL)
36
+ else
37
+ excluded_label?(pull_request) || excluded_type?(pull_request) || Shiplog.config.excluded_author?(pull_request.author_login)
38
+ end
39
+ end
18
40
  public_ids = pull_request_ids_in_public_tree(candidates)
19
41
  candidates.reject { |pull_request| public_ids.include?(pull_request.node_id) }
20
42
  end
21
43
 
44
+ # Pull requests still awaiting upgrade detection are held back (neither candidates nor excluded): excluding
45
+ # them now would skip an upgrade for good once the window moves on.
46
+ def merged_in_window
47
+ scope = @repository.pull_requests.where(state: "merged", merged_at: @window)
48
+ Shiplog.config.tracked_gems.any? ? scope.where.not(id: @repository.pull_requests.awaiting_upgrade_check.reorder(nil).select(:id)) : scope
49
+ end
50
+
51
+ # A pull request that upgrades a tracked gem with customer-visible changes is how customers receive them,
52
+ # so a Dependabot author, a chore(deps) title or a dependencies label does not exclude it. Other excluded
53
+ # labels, such as an `internal` a human added, still do.
54
+ def ids_with_upgrades(pull_requests)
55
+ PullRequestUpgrade.where(pull_request_id: pull_requests.map(&:id)).where("jsonb_array_length(gem_changes) > 0")
56
+ .distinct.pluck(:pull_request_id).to_set
57
+ end
58
+
22
59
  def commits
23
60
  @repository.commits.where(committed_at: @window).where.missing(:entries).order(:committed_at).to_a
24
61
  .select { |commit| eligible_commit?(commit) }
25
62
  end
26
63
 
27
- def excluded_label?(pull_request)
28
- pull_request.labels.intersect?(Shiplog.config.excluded_labels)
64
+ def excluded_label?(pull_request, ignoring: nil)
65
+ pull_request.labels.intersect?(Shiplog.config.excluded_labels - Array(ignoring))
29
66
  end
30
67
 
31
68
  # Untyped PR titles stay in; the rewriter decides whether customers would notice.
32
69
  def excluded_type?(pull_request)
33
- type = pull_request.title[COMMIT_TYPE, 1]
34
- type.present? && !Shiplog.config.commit_types.include?(type)
70
+ type = pull_request.title[COMMIT_TYPE, 1]&.downcase
71
+ CONVENTIONAL_TYPES.include?(type) && !Shiplog.config.commit_types.include?(type)
35
72
  end
36
73
 
37
74
  def eligible_commit?(commit)
@@ -52,7 +52,7 @@ module Shiplog
52
52
  end
53
53
 
54
54
  def self.shipped_candidates
55
- drafted = Entry.feature.where(subject_type: Issue.name).select(:subject_id)
55
+ drafted = Entry.where(kind: Entry::CHANGELOG_KINDS, subject_type: Issue.name).select(:subject_id)
56
56
  Issue.publicly_labeled.where(state: "closed", state_reason: "completed", closed_at: FEATURE_LOOKBACK.ago..)
57
57
  .where.not(id: drafted)
58
58
  .includes(:parent).reject { |issue| issue.parent&.public? }
@@ -7,7 +7,36 @@ module Shiplog
7
7
  enum :kind, %w[roadmap feature change].index_by(&:itself), validate: true
8
8
  enum :status, %w[draft approved discarded].index_by(&:itself), default: "draft", validate: true
9
9
 
10
+ CHANGELOG_KINDS = %w[feature change].freeze
11
+
10
12
  validates :released_on, presence: true, if: -> { approved? && !roadmap? }
13
+ validate :kind_change_allowed, on: :update
14
+ validate :kind_free_for_subject, on: :update
15
+
16
+ SEARCH_MAX_LENGTH = 100
17
+ SEARCH_MAX_WORDS = 5
18
+
19
+ # The words of a search query: whitespace squished, cut at 100 characters, at most 5 words.
20
+ def self.search_terms(query)
21
+ query.to_s.squish.first(SEARCH_MAX_LENGTH).split.first(SEARCH_MAX_WORDS)
22
+ end
23
+
24
+ # Entries whose title or body contains every word of the query (case-insensitive, LIKE wildcards escaped).
25
+ # A blank query leaves the scope unchanged.
26
+ def self.search(query)
27
+ search_terms(query).reduce(all) do |scope, term|
28
+ pattern = "%#{sanitize_sql_like(term)}%"
29
+ scope.where("#{table_name}.title ILIKE :pattern OR #{table_name}.body ILIKE :pattern", pattern:)
30
+ end
31
+ end
32
+
33
+ def changelog_kind = kind
34
+
35
+ # Switches a changelog entry between "feature" and "change". Roadmap entries never convert, and
36
+ # anything other than those two values is ignored.
37
+ def changelog_kind=(value)
38
+ self.kind = value if CHANGELOG_KINDS.include?(kind) && CHANGELOG_KINDS.include?(value)
39
+ end
11
40
 
12
41
  def self.digest_for(subject)
13
42
  Digest::SHA256.hexdigest([ subject.title, subject.body ].join("\n"))
@@ -35,6 +64,18 @@ module Shiplog
35
64
 
36
65
  private
37
66
 
67
+ def kind_free_for_subject
68
+ return unless will_save_change_to_kind? && self.class.where(subject_type:, subject_id:, kind:).where.not(id:).exists?
69
+
70
+ errors.add(:changelog_kind, :taken)
71
+ end
72
+
73
+ def kind_change_allowed
74
+ return unless will_save_change_to_kind?
75
+
76
+ errors.add(:kind, :invalid) unless CHANGELOG_KINDS.include?(kind_in_database) && CHANGELOG_KINDS.include?(kind)
77
+ end
78
+
38
79
  def require_status!(action, from:)
39
80
  raise InvalidTransition, "Cannot #{action} an entry that is #{status}" unless status == from
40
81
  end
@@ -4,6 +4,10 @@ module Shiplog
4
4
  has_many :issue_links, primary_key: :node_id, foreign_key: :pull_request_node_id, dependent: :delete_all
5
5
  has_many :issues, through: :issue_links
6
6
  has_many :entries, as: :subject, dependent: :destroy
7
+ has_many :upgrades, class_name: "Shiplog::PullRequestUpgrade", dependent: :destroy
8
+
9
+ # Merged pull requests that touched the lockfile and were not inspected yet, newest first.
10
+ scope :awaiting_upgrade_check, -> { where(state: "merged", lockfile_changed: true, upgrades_checked_at: nil).order(merged_at: :desc, id: :desc) }
7
11
 
8
12
  def open? = state == "open"
9
13
  def merged? = state == "merged"
@@ -0,0 +1,5 @@
1
+ module Shiplog
2
+ class PullRequestUpgrade < ApplicationRecord
3
+ belongs_to :pull_request
4
+ end
5
+ end