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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -0
- data/README.md +127 -5
- data/app/controllers/shiplog/admin/application_controller.rb +3 -1
- data/app/controllers/shiplog/admin/entries/approvals_controller.rb +2 -2
- data/app/controllers/shiplog/admin/entries/discards_controller.rb +2 -2
- data/app/controllers/shiplog/admin/entries/rewrites_controller.rb +1 -1
- data/app/controllers/shiplog/admin/entries_controller.rb +8 -4
- data/app/controllers/shiplog/admin/inbox_filters.rb +55 -0
- data/app/controllers/shiplog/admin/syncs_controller.rb +3 -2
- data/app/controllers/shiplog/changelogs_controller.rb +11 -1
- data/app/controllers/shiplog/frontends_controller.rb +2 -1
- data/app/controllers/shiplog/public_controller.rb +3 -1
- data/app/controllers/shiplog/roadmaps_controller.rb +25 -2
- data/app/frontend/shiplog/admin.css +7 -0
- data/app/frontend/shiplog/board.js +8 -2
- data/app/frontend/shiplog/inbox.js +78 -0
- data/app/frontend/shiplog/shiplog.css +121 -6
- data/app/helpers/shiplog/callable_helper.rb +12 -0
- data/app/helpers/shiplog/frontend_helper.rb +1 -0
- data/app/helpers/shiplog/host_routes_helper.rb +37 -0
- data/app/helpers/shiplog/roadmap_helper.rb +15 -0
- data/app/helpers/shiplog/switcher_helper.rb +101 -0
- data/app/jobs/shiplog/refresh_job.rb +1 -0
- data/app/jobs/shiplog/scan_changes_job.rb +19 -5
- data/app/jobs/shiplog/sync_job.rb +1 -1
- data/app/models/shiplog/change_candidates.rb +43 -6
- data/app/models/shiplog/drafter.rb +1 -1
- data/app/models/shiplog/entry.rb +41 -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 +37 -3
- data/app/models/shiplog/rewriter.rb +20 -1
- 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/views/layouts/shiplog/application.html.erb +1 -0
- data/app/views/shiplog/_page_header.html.erb +2 -1
- data/app/views/shiplog/_switcher.html.erb +13 -0
- data/app/views/shiplog/admin/entries/_entry_row.html.erb +6 -5
- data/app/views/shiplog/admin/entries/edit.html.erb +12 -6
- data/app/views/shiplog/admin/entries/index.html.erb +4 -0
- data/app/views/shiplog/changelogs/_search.html.erb +16 -0
- data/app/views/shiplog/changelogs/show.html.erb +11 -3
- data/app/views/shiplog/roadmaps/_card.html.erb +12 -1
- data/app/views/shiplog/roadmaps/_column.html.erb +2 -2
- data/app/views/shiplog/roadmaps/_shipped.html.erb +15 -0
- data/app/views/shiplog/roadmaps/show.html.erb +9 -0
- data/config/locales/en.yml +28 -0
- data/db/migrate/20261027000001_create_shiplog_pull_request_upgrades.rb +30 -0
- data/lib/generators/shiplog/install/install_generator.rb +20 -1
- data/lib/generators/shiplog/install/templates/initializer.rb.tt +37 -0
- data/lib/shiplog/classes.rb +20 -1
- data/lib/shiplog/configuration.rb +155 -2
- data/lib/shiplog/github/client.rb +42 -9
- data/lib/shiplog/github/errors.rb +3 -0
- data/lib/shiplog/github/queries.rb +32 -1
- data/lib/shiplog/version.rb +1 -1
- 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-
|
|
133
|
+
.shiplog-column__marker {
|
|
99
134
|
background: var(--sl-dot);
|
|
100
135
|
border-radius: 50%;
|
|
101
|
-
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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.
|
|
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 =
|
|
17
|
-
|
|
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
|
-
|
|
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.
|
|
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? }
|
data/app/models/shiplog/entry.rb
CHANGED
|
@@ -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"
|