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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +97 -0
- data/README.md +266 -9
- data/app/controllers/shiplog/admin/area_detections_controller.rb +15 -0
- data/app/controllers/shiplog/admin/area_translations_controller.rb +19 -0
- data/app/controllers/shiplog/admin/areas/merges_controller.rb +19 -0
- data/app/controllers/shiplog/admin/areas/moves_controller.rb +13 -0
- data/app/controllers/shiplog/admin/areas_controller.rb +48 -0
- data/app/controllers/shiplog/admin/entries/images/moves_controller.rb +20 -0
- data/app/controllers/shiplog/admin/entries/images_controller.rb +76 -0
- data/app/controllers/shiplog/admin/entries/translations/retranslations_controller.rb +23 -0
- data/app/controllers/shiplog/admin/entries/translations_controller.rb +35 -0
- data/app/controllers/shiplog/admin/entries_controller.rb +9 -3
- data/app/controllers/shiplog/admin/syncs_controller.rb +3 -2
- data/app/controllers/shiplog/changelog_entries_controller.rb +26 -0
- data/app/controllers/shiplog/changelog_feeds_controller.rb +19 -0
- data/app/controllers/shiplog/changelogs_controller.rb +30 -4
- data/app/controllers/shiplog/public_controller.rb +58 -1
- data/app/controllers/shiplog/roadmaps_controller.rb +27 -2
- data/app/frontend/shiplog/admin.css +9 -0
- data/app/frontend/shiplog/shiplog.css +168 -8
- data/app/helpers/shiplog/admin/entries_helper.rb +15 -0
- data/app/helpers/shiplog/callable_helper.rb +12 -0
- data/app/helpers/shiplog/frontend_helper.rb +1 -0
- data/app/helpers/shiplog/permalink_helper.rb +113 -0
- data/app/helpers/shiplog/roadmap_helper.rb +24 -0
- data/app/helpers/shiplog/switcher_helper.rb +101 -0
- data/app/helpers/shiplog/unseen_helper.rb +9 -0
- data/app/jobs/shiplog/detect_areas_job.rb +18 -0
- data/app/jobs/shiplog/refresh_job.rb +1 -0
- data/app/jobs/shiplog/rewrite_entry_job.rb +4 -1
- data/app/jobs/shiplog/scan_changes_job.rb +19 -5
- data/app/jobs/shiplog/sync_job.rb +1 -1
- data/app/jobs/shiplog/translate_area_job.rb +25 -0
- data/app/jobs/shiplog/translate_entry_job.rb +25 -0
- data/app/models/shiplog/area.rb +156 -0
- data/app/models/shiplog/area_detector.rb +146 -0
- data/app/models/shiplog/area_translator.rb +77 -0
- data/app/models/shiplog/change_candidates.rb +39 -4
- data/app/models/shiplog/entry/images.rb +127 -0
- data/app/models/shiplog/entry.rb +218 -2
- data/app/models/shiplog/entry_translation.rb +15 -0
- data/app/models/shiplog/image_fetcher/downloader.rb +98 -0
- data/app/models/shiplog/image_fetcher/sources.rb +41 -0
- data/app/models/shiplog/image_fetcher.rb +72 -0
- data/app/models/shiplog/image_type.rb +111 -0
- data/app/models/shiplog/pull_request.rb +4 -0
- data/app/models/shiplog/pull_request_upgrade.rb +5 -0
- data/app/models/shiplog/rewriter/prompt.rb +57 -9
- data/app/models/shiplog/rewriter.rb +50 -13
- data/app/models/shiplog/setting.rb +19 -0
- data/app/models/shiplog/sync/pull_request_importer.rb +23 -1
- data/app/models/shiplog/sync/repository_sync.rb +2 -0
- data/app/models/shiplog/sync/upgrade_detector.rb +203 -0
- data/app/models/shiplog/translator/prompt.rb +40 -0
- data/app/models/shiplog/translator.rb +99 -0
- data/app/views/layouts/shiplog/application.html.erb +4 -0
- data/app/views/shiplog/_empty.html.erb +1 -1
- data/app/views/shiplog/_page_header.html.erb +2 -1
- data/app/views/shiplog/_pagination.html.erb +3 -3
- data/app/views/shiplog/_switcher.html.erb +13 -0
- data/app/views/shiplog/admin/_nav.html.erb +2 -1
- data/app/views/shiplog/admin/areas/index.html.erb +78 -0
- data/app/views/shiplog/admin/entries/_entry_row.html.erb +4 -0
- data/app/views/shiplog/admin/entries/_images.html.erb +41 -0
- data/app/views/shiplog/admin/entries/_translations.html.erb +42 -0
- data/app/views/shiplog/admin/entries/edit.html.erb +12 -0
- data/app/views/shiplog/changelog_entries/show.html.erb +25 -0
- data/app/views/shiplog/changelog_feeds/show.atom.builder +24 -0
- data/app/views/shiplog/changelogs/_areas.html.erb +8 -0
- data/app/views/shiplog/changelogs/_entry.html.erb +2 -5
- data/app/views/shiplog/changelogs/_entry_content.html.erb +10 -0
- data/app/views/shiplog/changelogs/_search.html.erb +18 -0
- data/app/views/shiplog/changelogs/show.html.erb +20 -5
- data/app/views/shiplog/roadmaps/_card.html.erb +14 -3
- data/app/views/shiplog/roadmaps/_column.html.erb +4 -4
- data/app/views/shiplog/roadmaps/_shipped.html.erb +15 -0
- data/app/views/shiplog/roadmaps/show.html.erb +11 -2
- data/config/admin_routes.rb +16 -0
- data/config/locales/en.yml +105 -0
- data/config/locales/es.yml +223 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20261027000001_create_shiplog_pull_request_upgrades.rb +30 -0
- data/db/migrate/20261027000002_shiplog_0_3_0.rb +105 -0
- data/lib/generators/shiplog/install/install_generator.rb +29 -1
- data/lib/generators/shiplog/install/templates/initializer.rb.tt +82 -1
- data/lib/shiplog/classes.rb +29 -1
- data/lib/shiplog/configuration.rb +245 -3
- data/lib/shiplog/engine.rb +3 -0
- data/lib/shiplog/github/client.rb +42 -9
- data/lib/shiplog/github/errors.rb +3 -0
- data/lib/shiplog/github/queries.rb +44 -1
- data/lib/shiplog/version.rb +1 -1
- data/lib/shiplog.rb +73 -0
- data/lib/tasks/shiplog.rake +29 -0
- metadata +48 -1
data/lib/shiplog/classes.rb
CHANGED
|
@@ -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 :
|
|
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
|
|
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
|
-
|
|
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
|
|
data/lib/shiplog/engine.rb
CHANGED
|
@@ -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
|
|
31
|
-
|
|
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
|
-
|
|
51
|
+
perform(retried: true, &build)
|
|
36
52
|
end
|
|
37
53
|
|
|
38
|
-
def request(
|
|
39
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/shiplog/version.rb
CHANGED
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
|