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
@@ -7,9 +7,12 @@ module Shiplog
7
7
  page_header: "shiplog-page__header",
8
8
  page_title: "shiplog-page__title",
9
9
  page_lede: "shiplog-page__lede",
10
+ switcher: "shiplog-switcher",
11
+ switcher_link: "shiplog-switcher__link",
10
12
  columns: "shiplog-columns",
11
13
  column: "shiplog-column",
12
14
  column_header: "shiplog-column__header",
15
+ column_marker: "shiplog-column__marker",
13
16
  column_title: "shiplog-column__title",
14
17
  column_count: "shiplog-column__count",
15
18
  column_description: "shiplog-column__description",
@@ -18,6 +21,16 @@ module Shiplog
18
21
  card: "shiplog-card",
19
22
  card_title: "shiplog-card__title",
20
23
  card_body: "shiplog-card__body",
24
+ card_meta: "shiplog-card__meta",
25
+ card_tag: "shiplog-card__tag",
26
+ shipped: "shiplog-shipped",
27
+ shipped_title: "shiplog-shipped__title",
28
+ shipped_list: "shiplog-shipped__list",
29
+ shipped_item: "shiplog-shipped__item",
30
+ shipped_date: "shiplog-shipped__date",
31
+ shipped_link: "shiplog-shipped__link",
32
+ footnote: "shiplog-footnote",
33
+ footnote_link: "shiplog-footnote__link",
21
34
  months: "shiplog-months",
22
35
  month: "shiplog-month",
23
36
  month_title: "shiplog-month__title",
@@ -30,7 +43,22 @@ module Shiplog
30
43
  entry_date: "shiplog-entry__date",
31
44
  pagination: "shiplog-pagination",
32
45
  pagination_link: "shiplog-pagination__link",
33
- empty: "shiplog-empty"
46
+ empty: "shiplog-empty",
47
+ search: "shiplog-search",
48
+ search_input: "shiplog-search__input",
49
+ search_button: "shiplog-search__button",
50
+ search_summary: "shiplog-search__summary",
51
+ search_clear: "shiplog-search__clear",
52
+ search_empty: "shiplog-search__empty",
53
+ permalink: "shiplog-permalink",
54
+ permalink_back: "shiplog-permalink__back",
55
+ permalink_nav: "shiplog-permalink__nav",
56
+ permalink_nav_link: "shiplog-permalink__nav-link",
57
+ areas: "shiplog-areas",
58
+ area_link: "shiplog-areas__link",
59
+ entry_area: "shiplog-entry__area",
60
+ entry_images: "shiplog-entry__images",
61
+ entry_image: "shiplog-entry__image"
34
62
  }.freeze
35
63
 
36
64
  KEYS = BEM.keys.freeze
@@ -1,16 +1,166 @@
1
1
  module Shiplog
2
2
  class Configuration
3
- attr_accessor :repositories, :github_token, :webhook_secret,
3
+ attr_accessor :github_token, :webhook_secret,
4
4
  :public_label, :soon_labels, :excluded_labels, :excluded_authors, :commit_types,
5
5
  :rewriter, :product_name, :audience, :tone, :prompt_builder,
6
6
  :public_layout, :admin_layout,
7
7
  :stylesheet, :admin_flash, :authorize_admin, :current_admin,
8
- :roadmap_path, :changelog_path
8
+ :roadmap_path
9
9
 
10
10
  ADMIN_CHROMES = %i[standalone hosted].freeze
11
+ # Segments of only dots ("." and "..") are path traversal, not names.
12
+ REPOSITORY_NAME = %r{\A(?!\.+/)[\w.-]+/(?!\.+\z)[\w.-]+\z}
13
+ REPOSITORY_OPTION_KEYS = %i[name changelog gem].freeze
14
+
15
+ attr_reader :repositories
16
+ # Path of the lockfile read in merged pull requests to detect gem upgrades (see config.repositories `gem:`).
17
+ attr_accessor :lockfile_path
18
+
19
+ # The public changelog's path: segments of a-z, 0-9, dashes and underscores, joined by slashes ("changelog",
20
+ # "docs/whats_new"); surrounding slashes are stripped. Permalinks live below it and the Atom feed is
21
+ # "<path>.atom". Read when routes are drawn.
22
+ attr_reader :changelog_path
23
+ CHANGELOG_PATH = %r{\A[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*\z}
24
+
25
+ # The site's canonical origin, e.g. "https://trackberry.com" (nil by default). When set, the Atom feed's ids
26
+ # and links, entry links, og:url and the other absolute URLs use it instead of the request's host, which
27
+ # varies (apex and www, a forged X-Forwarded-Host). An origin only: scheme, host and optional port. Any path
28
+ # prefix of the engine's mount comes from the routes, so it needs no place here.
29
+ attr_reader :canonical_url
11
30
 
12
31
  attr_reader :admin_chrome, :classes, :public_parent_controller, :admin_parent_controller
13
32
 
33
+ # Product updates navigation and roadmap options.
34
+ attr_accessor :feedback_path
35
+ # Whether the public changelog has a search box and honours ?q= (default true).
36
+ attr_accessor :changelog_search
37
+ # Called with the controller on each changelog index or permalink view (200 or 304 answers), e.g. to stamp
38
+ # the visitor's "last seen" time. It runs BEFORE the page renders (so a badge in the same response is
39
+ # current), on every visit, and may write: keep it cheap, e.g. write only when the stored time is older than
40
+ # the newest entry. Nil by default. Not part of the ETag: it doesn't change the markup.
41
+ attr_reader :on_changelog_seen
42
+ # Bump it when the output of a callable you configured changes (extra_pages, repository_labels, feedback_path):
43
+ # callables cannot be part of the ETag, so this is how cached public pages learn about it.
44
+ attr_accessor :cache_version
45
+ attr_reader :extra_pages, :repository_labels, :roadmap_shipped_count
46
+ # How many product areas the AI may create (default 8). Past it, entries can only join existing areas.
47
+ # Not part of render_digest: the markup depends on the areas themselves, which the ETag carries.
48
+ attr_reader :max_areas
49
+ # Screenshots in entries. `images` (default true) is effective only when Active Storage is loaded in the host
50
+ # (see Shiplog.images?). `max_images_per_entry` (default 4) caps the images fetched from GitHub per entry.
51
+ attr_reader :images, :max_images_per_entry
52
+ LOCALE_FORMAT = /\A[A-Za-z][A-Za-z0-9_-]*\z/
53
+
54
+ EXTRA_PAGE_KEYS = %i[label path].freeze
55
+
56
+ # A path (not "//host", "/\host" or "/ x", which browsers read as other targets), an http(s) URL or mailto:.
57
+ SAFE_URL = %r{\A(?:/(?![/\\\s])|https?://|mailto:)}i
58
+
59
+ # Whether a link target is safe to render: a path, an http(s) URL or a mailto: address.
60
+ def self.safe_url?(value)
61
+ value.is_a?(String) && SAFE_URL.match?(value.strip) && !value.match?(/[[:cntrl:]]/)
62
+ end
63
+
64
+ # A digest of everything configured that changes the public markup, stable across processes and servers
65
+ # (no Object#hash). Callables are represented by a marker; see cache_version.
66
+ def render_digest
67
+ stable = ->(value) { value.respond_to?(:call) ? "callable" : value }
68
+ data = [
69
+ classes.sort_by { |key, _| key.to_s }.map { |key, value| [ key.to_s, Array(value).map(&:to_s) ] },
70
+ stylesheet, roadmap_shipped_count, cache_version, product_name, canonical_url,
71
+ extra_pages.map { |page| [ stable.(page[:label]), stable.(page[:path]) ] },
72
+ repository_labels.sort_by { |name, _| name.to_s }.map { |name, label| [ name, stable.(label) ] },
73
+ stable.(feedback_path), changelog_search, images, max_images_per_entry, locales
74
+ ]
75
+ Digest::SHA256.hexdigest(JSON.generate(data))
76
+ end
77
+
78
+ # Pages added to the switcher after Roadmap and Changelog: [{ label:, path: }]. Each is a String, or a callable
79
+ # evaluated in the view (instance_exec), e.g. `path: -> { main_app.news_path }`.
80
+ def extra_pages=(value)
81
+ raise ArgumentError, "extra_pages must be an Array of { label:, path: } Hashes, got #{value.inspect}" unless value.nil? || value.is_a?(Array)
82
+ pages = (value || []).map do |page|
83
+ raise ArgumentError, "Each extra page must be a Hash with :label and :path, got #{page.inspect}" unless page.is_a?(Hash)
84
+ page = page.to_h.transform_keys { |key| key.to_s.to_sym }
85
+ unless page.keys.sort == EXTRA_PAGE_KEYS
86
+ raise ArgumentError, "An extra page needs exactly the keys :label and :path, got #{page.keys.inspect}"
87
+ end
88
+ page.each do |key, item|
89
+ valid = item.respond_to?(:call) || (item.is_a?(String) && item.present?)
90
+ raise ArgumentError, "Extra page #{key} must be a non-empty String or a callable, got #{item.inspect}" unless valid
91
+ if key == :path && item.is_a?(String) && !self.class.safe_url?(item)
92
+ raise ArgumentError, "Extra page path must be relative (/...), http(s):// or mailto:, got #{item.inspect}"
93
+ end
94
+ end
95
+ { label: page[:label], path: page[:path] }.freeze
96
+ end
97
+ @extra_pages = pages.freeze
98
+ end
99
+
100
+ # Display names for repositories on roadmap cards: { "owner/name" => "iPhone & iPad" }. Values are Strings or
101
+ # callables evaluated in the view.
102
+ def repository_labels=(value)
103
+ labels = value.nil? ? {} : value
104
+ raise ArgumentError, "repository_labels must be a Hash, got #{value.inspect}" unless labels.is_a?(Hash)
105
+ labels.each do |name, label|
106
+ unless label.respond_to?(:call) || label.is_a?(String)
107
+ raise ArgumentError, "repository_labels[#{name.inspect}] must be a String or a callable, got #{label.inspect}"
108
+ end
109
+ end
110
+ @repository_labels = labels.transform_keys(&:to_s).freeze
111
+ end
112
+
113
+ # How many recently shipped changelog entries the roadmap lists; 0 hides the section.
114
+ def roadmap_shipped_count=(value)
115
+ raise ArgumentError, "roadmap_shipped_count must be a non-negative Integer, got #{value.inspect}" unless value.is_a?(Integer) && value >= 0
116
+ @roadmap_shipped_count = value
117
+ end
118
+
119
+ def max_areas=(value)
120
+ raise ArgumentError, "max_areas must be a non-negative Integer, got #{value.inspect}" unless value.is_a?(Integer) && value >= 0
121
+ @max_areas = value
122
+ end
123
+
124
+ def images=(value)
125
+ raise ArgumentError, "images must be true or false, got #{value.inspect}" unless [ true, false ].include?(value)
126
+ @images = value
127
+ end
128
+
129
+ def max_images_per_entry=(value)
130
+ raise ArgumentError, "max_images_per_entry must be a non-negative Integer, got #{value.inspect}" unless value.is_a?(Integer) && value >= 0
131
+ @max_images_per_entry = value
132
+ end
133
+
134
+ # Locales entries are translated into when approved, e.g. [:es] (default none), as symbols and without the
135
+ # default locale (I18n.default_locale, read each time so an initializer that sets it later still counts): it
136
+ # is the language entries are written in. See Shiplog::Translator.
137
+ def locales = @locales - [ I18n.default_locale.to_sym ]
138
+
139
+ # Sets the locales to translate into: an Array of locale Symbols or Strings (see #locales).
140
+ def locales=(value)
141
+ unless value.is_a?(Array) && value.all? { |item| (item.is_a?(Symbol) || item.is_a?(String)) && LOCALE_FORMAT.match?(item.to_s) }
142
+ raise ArgumentError, "locales must be an Array of locale Symbols or Strings like [:es, \"pt-BR\"], got #{value.inspect}"
143
+ end
144
+ @locales = value.map(&:to_sym).uniq.freeze
145
+ end
146
+
147
+ def changelog_path=(value)
148
+ path = value.gsub(%r{\A/+|/+\z}, "") if value.is_a?(String)
149
+ unless path && CHANGELOG_PATH.match?(path)
150
+ raise ArgumentError, "changelog_path must be a path of letters, digits, dashes and underscores, with / between segments (like \"changelog\"), got #{value.inspect}"
151
+ end
152
+ @changelog_path = path
153
+ end
154
+
155
+ def canonical_url=(value)
156
+ @canonical_url = normalize_canonical_url(value)
157
+ end
158
+
159
+ def on_changelog_seen=(value)
160
+ raise ArgumentError, "on_changelog_seen must be nil or a callable, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
161
+ @on_changelog_seen = value
162
+ end
163
+
14
164
  # :standalone (default) wraps the admin in Shiplog's own page chrome; :hosted renders it inside the
15
165
  # layout of config.admin_layout (compact sub-nav, no page chrome of its own).
16
166
  def admin_chrome=(value)
@@ -44,8 +194,17 @@ module Shiplog
44
194
  @admin_parent_controller = value
45
195
  end
46
196
 
197
+ # Strings ("owner/name") or Hashes: { name:, changelog: true, gem: nil }. `changelog: false` keeps a repository
198
+ # on the roadmap but never drafts changelog entries from its pull requests or commits. `gem:` is the gem's
199
+ # name in the host's lockfile.
200
+ def repositories=(value)
201
+ warn_double_drafting(normalize_repositories(value))
202
+ @repositories = value
203
+ end
204
+
47
205
  def initialize
48
206
  @repositories = []
207
+ @lockfile_path = "Gemfile.lock"
49
208
  @public_label = "public"
50
209
  @soon_labels = %w[soon]
51
210
  @excluded_labels = %w[internal dependencies refactor chore]
@@ -64,6 +223,14 @@ module Shiplog
64
223
  @current_admin = ->(controller) { controller.try(:current_user)&.try(:email) }
65
224
  @roadmap_path = "roadmap"
66
225
  @changelog_path = "changelog"
226
+ @extra_pages = [].freeze
227
+ @repository_labels = {}.freeze
228
+ @roadmap_shipped_count = 4
229
+ @changelog_search = true
230
+ @max_areas = 8
231
+ @images = true
232
+ @max_images_per_entry = 4
233
+ @locales = [].freeze
67
234
  end
68
235
 
69
236
  def resolved_github_token
@@ -73,7 +240,27 @@ module Shiplog
73
240
  # The configured spelling of a repository name (matched case-insensitively), or nil.
74
241
  def repository_configured?(name)
75
242
  return if name.blank?
76
- repositories.find { |configured| configured.casecmp?(name.to_s) }
243
+ repository_names.find { |configured| configured.casecmp?(name.to_s) }
244
+ end
245
+
246
+ # Normalised options of every configured repository: [{ name:, changelog:, gem: }].
247
+ def repository_entries = normalize_repositories(repositories)
248
+
249
+ def repository_names = repository_entries.map { |entry| entry[:name] }
250
+
251
+ def repository_options(name)
252
+ return if name.blank?
253
+ repository_entries.find { |entry| entry[:name].casecmp?(name.to_s) }
254
+ end
255
+
256
+ # False for roadmap-only repositories. Unknown repositories count as changelog sources.
257
+ def changelog_repository?(name)
258
+ repository_options(name)&.fetch(:changelog) != false
259
+ end
260
+
261
+ # { "gem name in the lockfile" => "owner/name" } for repositories with `gem:`.
262
+ def tracked_gems
263
+ repository_entries.select { |entry| entry[:gem] }.to_h { |entry| [ entry[:gem], entry[:name] ] }
77
264
  end
78
265
 
79
266
  def excluded_author?(login)
@@ -82,6 +269,61 @@ module Shiplog
82
269
 
83
270
  private
84
271
 
272
+ def normalize_canonical_url(value)
273
+ return if value.nil? || (value.is_a?(String) && value.strip.empty?)
274
+ raise ArgumentError, "canonical_url must be a String like \"https://example.com\", got #{value.inspect}" unless value.is_a?(String)
275
+
276
+ uri = begin
277
+ URI.parse(value.strip)
278
+ rescue URI::InvalidURIError
279
+ nil
280
+ end
281
+ valid = uri.is_a?(URI::HTTP) && uri.host.present? && [ "", "/" ].include?(uri.path) && uri.query.nil? && uri.fragment.nil? && uri.userinfo.nil?
282
+ raise ArgumentError, "canonical_url must be an http(s) origin without path, query or credentials (\"https://example.com\"), got #{value.inspect}" unless valid
283
+
284
+ port = ":#{uri.port}" unless uri.port == uri.default_port
285
+ "#{uri.scheme}://#{uri.host.downcase}#{port}"
286
+ end
287
+
288
+ def normalize_repositories(value)
289
+ raise ArgumentError, "config.repositories must be an Array, got #{value.inspect}" unless value.is_a?(Array)
290
+
291
+ value.map { |item| normalize_repository(item) }
292
+ end
293
+
294
+ # A gem repository that also drafts its own changelog entries announces each change twice: once from its own
295
+ # pull requests and once from the host's upgrade pull request.
296
+ def warn_double_drafting(entries)
297
+ entries.select { |entry| entry[:gem] && entry[:changelog] }.each do |entry|
298
+ Rails.logger&.warn("[shiplog] Repository #{entry[:name]} has gem: #{entry[:gem].inspect} and changelog: true, so its changes " \
299
+ "are drafted twice (from its own pull requests and from the host's upgrade pull request). Set changelog: false.")
300
+ end
301
+ end
302
+
303
+ def normalize_repository(item)
304
+ return { name: valid_repository_name!(item), changelog: true, gem: nil } if item.is_a?(String) && item.present?
305
+ raise ArgumentError, "Invalid repository #{item.inspect}: expected \"owner/name\" or a Hash" unless item.is_a?(Hash)
306
+
307
+ options = item.transform_keys { |key| key.to_s.to_sym }
308
+ unknown = options.keys - REPOSITORY_OPTION_KEYS
309
+ raise ArgumentError, "Unknown repository option(s) #{unknown.map(&:inspect).join(", ")}. Valid: #{REPOSITORY_OPTION_KEYS.map(&:inspect).join(", ")}" if unknown.any?
310
+
311
+ name, changelog, gem = options.values_at(:name, :changelog, :gem)
312
+ changelog = true if changelog.nil?
313
+ raise ArgumentError, "Repository #{item.inspect} needs a :name (\"owner/name\")" unless name.is_a?(String) && name.present?
314
+ valid_repository_name!(name)
315
+ raise ArgumentError, "Repository #{name}: changelog must be true or false" unless [ true, false ].include?(changelog)
316
+ raise ArgumentError, "Repository #{name}: gem must be a non-empty String" unless gem.nil? || (gem.is_a?(String) && gem.present?)
317
+
318
+ { name:, changelog:, gem: }
319
+ end
320
+
321
+ def valid_repository_name!(name)
322
+ raise ArgumentError, "Invalid repository name #{name.inspect}: expected \"owner/name\"" unless REPOSITORY_NAME.match?(name)
323
+
324
+ name
325
+ end
326
+
85
327
  def ensure_controller_not_loaded!(setting, namespace, name)
86
328
  return unless namespace.const_defined?(name, false) && !namespace.autoload?(name)
87
329
 
@@ -1,5 +1,8 @@
1
1
  module Shiplog
2
2
  class Engine < ::Rails::Engine
3
3
  isolate_namespace Shiplog
4
+
5
+ # Once per boot, after the host's initializers have set config.images: say why screenshots are off.
6
+ config.after_initialize { Shiplog.log_images_status }
4
7
  end
5
8
  end
@@ -6,6 +6,7 @@ module Shiplog
6
6
  module Github
7
7
  class Client
8
8
  ENDPOINT = URI("https://api.github.com/graphql")
9
+ REST_ROOT = "https://api.github.com".freeze
9
10
  RETRYABLE_STATUSES = [ 502, 503, 504 ].freeze
10
11
  NETWORK_ERRORS = [
11
12
  Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, Errno::ECONNREFUSED,
@@ -25,40 +26,72 @@ module Shiplog
25
26
  handle(response)
26
27
  end
27
28
 
29
+ # REST GET (e.g. "/repos/owner/name/compare/a...b") with the same auth, errors and rate-limit
30
+ # handling as #query. Returns the parsed JSON body; a 404 raises NotFound.
31
+ def get(path)
32
+ raise Error, "No GitHub token configured (Shiplog.config.github_token)" if @token.blank?
33
+
34
+ uri = URI("#{REST_ROOT}#{path}")
35
+ response = perform(retried: false) { build_get(uri) }
36
+ check_status(response)
37
+ JSON.parse(response.body)
38
+ rescue JSON::ParserError
39
+ raise Error, "GitHub returned an unparseable response"
40
+ end
41
+
28
42
  private
29
43
 
30
- def post(payload, retried: false)
31
- response = request(payload)
44
+ def post(payload) = perform(retried: false) { build_request(payload) }
45
+
46
+ def perform(retried:, &build)
47
+ response = request(build.call)
32
48
  return response unless RETRYABLE_STATUSES.include?(response.code.to_i) && !retried
33
49
 
34
50
  sleep @retry_delay
35
- post(payload, retried: true)
51
+ perform(retried: true, &build)
36
52
  end
37
53
 
38
- def request(payload)
39
- http = Net::HTTP.new(ENDPOINT.host, ENDPOINT.port)
54
+ def request(http_request)
55
+ uri = http_request.uri
56
+ http = Net::HTTP.new(uri.host, uri.port)
40
57
  http.use_ssl = true
41
58
  http.open_timeout = 10
42
59
  http.read_timeout = 30
43
- http.request(build_request(payload))
60
+ http.request(http_request)
44
61
  rescue *NETWORK_ERRORS => error
45
62
  raise Transient, "#{error.class}: #{error.message}"
46
63
  end
47
64
 
48
65
  def build_request(payload)
49
66
  Net::HTTP::Post.new(ENDPOINT).tap do |req|
50
- req["Authorization"] = "Bearer #{@token}"
67
+ authorize(req)
51
68
  req["Content-Type"] = "application/json"
52
- req["User-Agent"] = "shiplog/#{Shiplog::VERSION}"
53
69
  req.body = payload
54
70
  end
55
71
  end
56
72
 
57
- def handle(response)
73
+ def build_get(uri)
74
+ Net::HTTP::Get.new(uri).tap do |req|
75
+ authorize(req)
76
+ req["Accept"] = "application/vnd.github+json"
77
+ end
78
+ end
79
+
80
+ def authorize(req)
81
+ req["Authorization"] = "Bearer #{@token}"
82
+ req["User-Agent"] = "shiplog/#{Shiplog::VERSION}"
83
+ end
84
+
85
+ def check_status(response)
58
86
  status = response.code.to_i
59
87
  raise_rate_limited(response) if rate_limited?(response, status)
60
88
  raise Transient, "GitHub responded with HTTP #{status}" if status >= 500
89
+ raise NotFound, "GitHub responded with HTTP 404" if status == 404
61
90
  raise Error, "GitHub responded with HTTP #{status}" unless status.between?(200, 299)
91
+ end
92
+
93
+ def handle(response)
94
+ check_status(response)
62
95
 
63
96
  body = JSON.parse(response.body)
64
97
  errors = body["errors"].to_a
@@ -5,6 +5,9 @@ module Shiplog
5
5
  # Network failures and 5xx responses; safe to retry later.
6
6
  class Transient < Error; end
7
7
 
8
+ # HTTP 404: the resource does not exist, or the token cannot see it.
9
+ class NotFound < Error; end
10
+
8
11
  class RateLimited < Error
9
12
  attr_reader :reset_at
10
13
 
@@ -27,6 +27,9 @@ module Shiplog
27
27
  PULL_REQUEST_WITH_ISSUES = <<~GRAPHQL.freeze
28
28
  fragment PullRequestWithIssues on PullRequest {
29
29
  ...PullRequestFields
30
+ baseRefOid
31
+ mergeCommit { oid parents(first: 1) { nodes { oid } } }
32
+ files(first: 100) { pageInfo { hasNextPage } nodes { path } }
30
33
  closingIssuesReferences(first: 20) {
31
34
  pageInfo { hasNextPage }
32
35
  nodes { id }
@@ -102,7 +105,47 @@ module Shiplog
102
105
  }
103
106
  GRAPHQL
104
107
 
105
- ALL = { ISSUES:, PULL_REQUESTS:, ISSUE:, PULL_REQUEST:, COMMITS: }.freeze
108
+ # A file's text at a commit (`$expression` is "<oid>:<path>"); `object` is null when the file is absent.
109
+ BLOB_TEXT = <<~GRAPHQL.freeze
110
+ query($owner: String!, $name: String!, $expression: String!) {
111
+ repository(owner: $owner, name: $name) {
112
+ object(expression: $expression) {
113
+ ... on Blob { text isTruncated }
114
+ }
115
+ }
116
+ }
117
+ GRAPHQL
118
+
119
+ # The pull requests that introduced each commit (`$ids` are commit node ids, at most 100).
120
+ COMMIT_PULL_REQUESTS = <<~GRAPHQL.freeze
121
+ query($ids: [ID!]!) {
122
+ nodes(ids: $ids) {
123
+ ... on Commit {
124
+ oid
125
+ associatedPullRequests(first: 5) {
126
+ nodes {
127
+ number title body url state
128
+ repository { nameWithOwner }
129
+ }
130
+ }
131
+ }
132
+ }
133
+ }
134
+ GRAPHQL
135
+
136
+ # The rendered HTML of one issue or pull request body (`$id` is its node id). Kept out of the sync documents so
137
+ # their payloads don't grow: it is only asked for when a draft is written, to find screenshots. On private
138
+ # repositories its <img src> URLs are short-lived signed URLs.
139
+ BODY_HTML = <<~GRAPHQL.freeze
140
+ query($id: ID!) {
141
+ node(id: $id) {
142
+ ... on Issue { bodyHTML }
143
+ ... on PullRequest { bodyHTML }
144
+ }
145
+ }
146
+ GRAPHQL
147
+
148
+ ALL = { ISSUES:, PULL_REQUESTS:, ISSUE:, PULL_REQUEST:, COMMITS:, BLOB_TEXT:, COMMIT_PULL_REQUESTS:, BODY_HTML: }.freeze
106
149
  end
107
150
  end
108
151
  end
@@ -1,3 +1,3 @@
1
1
  module Shiplog
2
- VERSION = "0.1.1"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/shiplog.rb CHANGED
@@ -12,5 +12,78 @@ module Shiplog
12
12
  def config = (@config ||= Configuration.new)
13
13
  def configure = yield(config)
14
14
  def reset_config! = (@config = nil)
15
+
16
+ # The number of approved features and changes approved after `since`, or all of them while it is nil, capped
17
+ # at 99. Counted on approved_at (when the entry was approved), not released_on (the list's order). The host supplies the timestamp (Shiplog doesn't know its users): see config.on_changelog_seen.
18
+ def unseen_count(since: nil) = Entry.unseen_count(since:)
19
+
20
+ ACTIVE_STORAGE_TABLES = %w[active_storage_attachments active_storage_blobs].freeze
21
+
22
+ # Whether the host loaded Active Storage (its engine and the model macros). The declaration on Entry needs
23
+ # only this, so it never touches the database.
24
+ def active_storage_loaded? = !!(defined?(ActiveStorage::Engine) && ActiveRecord::Base.respond_to?(:has_many_attached))
25
+
26
+ # Whether screenshots can work: Active Storage is loaded and its tables exist (a host that loads
27
+ # `rails/all` but never ran active_storage:install would otherwise fail on every query). Without either,
28
+ # every image feature stays off instead of failing.
29
+ def active_storage? = active_storage_loaded? && active_storage_tables?
30
+
31
+ # Whether the Active Storage tables exist. Checked lazily, on first use, then remembered for the life of the
32
+ # process, so public pages never look at the schema again. When the database can't be reached (at boot, say)
33
+ # the answer is "not yet known": false for now, and asked again next time. The reason is logged once.
34
+ def active_storage_tables?
35
+ return @active_storage_tables unless @active_storage_tables.nil?
36
+
37
+ present = ActiveRecord::Base.with_connection { |connection| ACTIVE_STORAGE_TABLES.all? { |table| connection.data_source_exists?(table) } }
38
+ log_images_off("Active Storage's tables are missing (#{ACTIVE_STORAGE_TABLES.join(", ")}). Run bin/rails active_storage:install and db:migrate to enable them.") unless present || !config.images
39
+ @active_storage_tables = present
40
+ rescue ActiveRecord::ActiveRecordError
41
+ false
42
+ end
43
+
44
+ # Forgets what was learned about the schema and what was logged (tests, and hosts that create the tables at runtime).
45
+ def reset_images_checks!
46
+ @active_storage_tables = nil
47
+ @images_off_logged = nil
48
+ @image_variants = nil
49
+ end
50
+
51
+ # Logged once at boot (see Shiplog::Engine): screenshots wanted but Active Storage missing. A missing
52
+ # table is logged by the check itself, the first time it runs.
53
+ def log_images_status
54
+ return unless config.images
55
+
56
+ if !active_storage_loaded? then log_images_off("Active Storage is not loaded. Require active_storage/engine to enable them.")
57
+ else active_storage_tables?
58
+ end
59
+ end
60
+
61
+ # Whether the admin can show thumbnails as variants: the image_processing gem is loaded and so is a
62
+ # processor for it (ruby-vips, which must load, or mini_magick). Otherwise the originals are shown.
63
+ def image_variants?
64
+ return @image_variants unless @image_variants.nil?
65
+
66
+ @image_variants = Gem.loaded_specs.key?("image_processing") && (vips_loads? || Gem.loaded_specs.key?("mini_magick"))
67
+ end
68
+
69
+ # Whether screenshots are in use: config.images and Active Storage.
70
+ def images? = config.images && active_storage?
71
+
72
+ private
73
+
74
+ def vips_loads?
75
+ return true if defined?(::Vips)
76
+
77
+ Gem.loaded_specs.key?("ruby-vips") && require("vips")
78
+ rescue LoadError
79
+ false
80
+ end
81
+
82
+ def log_images_off(reason)
83
+ @images_off_logged ||= Set.new
84
+ return unless @images_off_logged.add?(reason)
85
+
86
+ Rails.logger&.info("[shiplog] Screenshots are off: #{reason}")
87
+ end
15
88
  end
16
89
  end
@@ -0,0 +1,29 @@
1
+ # Shiplog::Engine and Shiplog::Admin::Engine share the gem's root, so Rails loads this file once for each: the
2
+ # guard keeps every task from running twice.
3
+ unless Rake::Task.task_defined?("shiplog:backfill_slugs")
4
+ namespace :shiplog do
5
+ desc "Give approved changelog entries that have no permalink slug one (entries approved by code older than 0.3.0 after the migration ran)"
6
+ task backfill_slugs: :environment do
7
+ count = Shiplog::Entry.backfill_slugs
8
+ puts "Shiplog: gave #{count} #{count == 1 ? "entry" : "entries"} a slug."
9
+ end
10
+
11
+ desc "Translate approved entries and area names that have no current translation into config.locales (entries approved before 0.3.0 get none). BATCH_SIZE=100"
12
+ task translate_approved: :environment do
13
+ if Shiplog.config.locales.empty?
14
+ puts "Shiplog: config.locales is empty, nothing to translate."
15
+ elsif Shiplog.config.rewriter.nil?
16
+ puts "Shiplog: config.rewriter is not set, so nothing can be translated."
17
+ else
18
+ result = Shiplog::Entry.enqueue_missing_translations(batch_size: ENV.fetch("BATCH_SIZE", 100).to_i.clamp(1, 10_000))
19
+ approved = result[:approved]
20
+ puts "Shiplog: #{approved} approved #{approved == 1 ? "entry" : "entries"}."
21
+ result[:enqueued].each do |locale, count|
22
+ puts "Shiplog: #{locale}: #{count} enqueued, #{approved - count} current."
23
+ end
24
+ areas = Shiplog::Area.enqueue_missing_translations
25
+ puts "Shiplog: #{areas} #{areas == 1 ? "area" : "areas"} enqueued for name translation."
26
+ end
27
+ end
28
+ end
29
+ end