shiplog 0.1.0 → 0.1.1
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 +15 -0
- data/README.md +1 -1
- 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/frontends_controller.rb +2 -1
- 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/helpers/shiplog/host_routes_helper.rb +37 -0
- data/app/models/shiplog/change_candidates.rb +4 -2
- data/app/models/shiplog/drafter.rb +1 -1
- data/app/models/shiplog/entry.rb +24 -0
- data/app/models/shiplog/rewriter/prompt.rb +12 -2
- data/app/views/shiplog/admin/entries/_entry_row.html.erb +5 -5
- data/app/views/shiplog/admin/entries/edit.html.erb +12 -6
- data/app/views/shiplog/admin/entries/index.html.erb +4 -0
- data/config/locales/en.yml +8 -0
- data/lib/shiplog/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 58c17b51ed2b752958414ce03688b5c055681502c6ac9304c2238aab2144e521
|
|
4
|
+
data.tar.gz: 50ceed3676ed0cb48cb4d8aa3da190456aced0347d62404d3cfa4d095c4cd69e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 875076c4feb976490743235f4f56355548518640b15c1fbdb0183afaec7161617a93acb5f199741a6b94c5206d20623aa95b1f9cb63eed5b3823a8190373ab5f
|
|
7
|
+
data.tar.gz: a16a92cb22802351b8074211400ca12e3ae28f572efffc5fd07fc63fb081a8a27b9c5aa8f530595ebf2f8e53bf5b3a88e62ebcd1f5a24898fb3e2751db602238
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.1 (2026-10-26)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Approve, discard and restore update the inbox in place, without a reload. Without JavaScript they still redirect back to the same filtered list.
|
|
8
|
+
- Admins can switch a changelog entry between New feature and Improvement.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Pull requests whose title starts with a topic (`Importers: …`) are no longer mistaken for a non-changelog Conventional Commits type and skipped; only known types such as `chore:` or `ci:` are.
|
|
13
|
+
- Roadmap drafts are written in the future tense ("You'll be able to…") instead of as if the feature had already shipped, and no longer turn implementation details from the issue into promises such as dates, prices, refunds or limits.
|
|
14
|
+
- Host layouts can render records that the host app routes, such as Active Storage avatars passed to `image_tag`, `link_to` or `url_for`, inside Shiplog's public and admin pages instead of raising `Can't resolve image into URL`.
|
|
15
|
+
- Admin actions return to the filtered list they started from: approving, discarding, restoring or saving an entry keeps the status, kind and page of the inbox.
|
|
16
|
+
- The roadmap board's drag and drop keeps working after Turbo Drive navigation in host apps that use it.
|
|
17
|
+
|
|
3
18
|
## 0.1.0 (2026-10-26)
|
|
4
19
|
|
|
5
20
|
Initial release.
|
data/README.md
CHANGED
|
@@ -215,7 +215,7 @@ end
|
|
|
215
215
|
## How changelog entries are produced
|
|
216
216
|
|
|
217
217
|
- **Features**: a public top-level issue closed as completed that has at least one merged linked pull request on itself or a sub-issue. The issue form sections (`### Problem`, `### Before`, `### After`), sub-issue titles and linked pull request titles are given to the rewriter. Only issues closed in the last 90 days are drafted, so a first sync doesn't flood the inbox. The release date is the newest merge date.
|
|
218
|
-
- **Small changes**: the weekly scan looks at pull requests merged, and stored commits made, since the last scan (seven days on the first run). Commits are only considered when they are on the default branch and have no pull request, and their conventional-commit type (`feat:`, `fix(scope):`, ...) must be in `commit_types`. Pull requests are skipped when their title
|
|
218
|
+
- **Small changes**: the weekly scan looks at pull requests merged, and stored commits made, since the last scan (seven days on the first run). Commits are only considered when they are on the default branch and have no pull request, and their conventional-commit type (`feat:`, `fix(scope):`, ...) must be in `commit_types`. Pull requests are skipped when their title starts with a Conventional Commits type (`chore:`, `ci:`, `docs:`, ...) outside `commit_types`; a topic prefix such as `Importers:` is not a type; untyped titles are left for the rewriter to judge. Bot authors are recorded as `name[bot]`, so the default `excluded_authors` pattern matches them.
|
|
219
219
|
- **Exclusions**: pull requests with an excluded label or an excluded author, commits from excluded authors, pull requests that belong to a feature's issue tree (the feature entry covers them), and anything that already has an entry.
|
|
220
220
|
|
|
221
221
|
## Approval workflow
|
|
@@ -3,6 +3,8 @@ module Shiplog
|
|
|
3
3
|
# Parent controller of the admin. The parent class is resolved when this file loads, so
|
|
4
4
|
# `config.admin_parent_controller` must be set in an initializer (config/initializers/shiplog.rb).
|
|
5
5
|
class ApplicationController < Shiplog.config.admin_parent_controller.constantize
|
|
6
|
+
include InboxFilters
|
|
7
|
+
|
|
6
8
|
protect_from_forgery with: :exception
|
|
7
9
|
|
|
8
10
|
helper Shiplog::FrontendHelper
|
|
@@ -15,7 +17,7 @@ module Shiplog
|
|
|
15
17
|
before_action :authorize_admin
|
|
16
18
|
|
|
17
19
|
rescue_from Shiplog::Entry::InvalidTransition do
|
|
18
|
-
|
|
20
|
+
respond_inline_error(t("shiplog.admin.entries.invalid_transition"), edit_entry_path(params[:entry_id], inbox_filter_params))
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
private
|
|
@@ -5,9 +5,9 @@ module Shiplog
|
|
|
5
5
|
def create
|
|
6
6
|
entry = Entry.find(params[:entry_id])
|
|
7
7
|
entry.approve!(by: current_admin_name)
|
|
8
|
-
|
|
8
|
+
respond_inline(entry, :approved)
|
|
9
9
|
rescue ActiveRecord::RecordInvalid => error
|
|
10
|
-
|
|
10
|
+
respond_inline_error(error.record.errors.full_messages.to_sentence, edit_entry_path(entry, inbox_filter_params))
|
|
11
11
|
end
|
|
12
12
|
end
|
|
13
13
|
end
|
|
@@ -6,12 +6,12 @@ module Shiplog
|
|
|
6
6
|
|
|
7
7
|
def create
|
|
8
8
|
@entry.discard!(reason: params[:reason].to_s.strip.presence)
|
|
9
|
-
|
|
9
|
+
respond_inline(@entry, :discarded)
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def destroy
|
|
13
13
|
@entry.restore!
|
|
14
|
-
|
|
14
|
+
respond_inline(@entry, :restored)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
private
|
|
@@ -5,7 +5,7 @@ module Shiplog
|
|
|
5
5
|
def create
|
|
6
6
|
entry = Entry.find(params[:entry_id])
|
|
7
7
|
RewriteEntryJob.perform_later(entry.id, force: true)
|
|
8
|
-
redirect_to edit_entry_path(entry), notice: t("shiplog.admin.entries.rewrite_queued")
|
|
8
|
+
redirect_to edit_entry_path(entry, inbox_filter_params), notice: t("shiplog.admin.entries.rewrite_queued")
|
|
9
9
|
end
|
|
10
10
|
end
|
|
11
11
|
end
|
|
@@ -7,8 +7,8 @@ module Shiplog
|
|
|
7
7
|
before_action :set_entry, only: %i[edit update]
|
|
8
8
|
|
|
9
9
|
def index
|
|
10
|
-
@status =
|
|
11
|
-
@kind =
|
|
10
|
+
@status = inbox_filter_params[:status] || "draft"
|
|
11
|
+
@kind = inbox_filter_params[:kind]
|
|
12
12
|
@page = page_number
|
|
13
13
|
scope = Entry.where(status: @status)
|
|
14
14
|
scope = scope.where(kind: @kind) if @kind
|
|
@@ -24,10 +24,14 @@ module Shiplog
|
|
|
24
24
|
|
|
25
25
|
def update
|
|
26
26
|
if @entry.update(entry_params)
|
|
27
|
-
redirect_to root_path, notice: t("shiplog.admin.entries.saved")
|
|
27
|
+
redirect_to root_path(inbox_filter_params), notice: t("shiplog.admin.entries.saved")
|
|
28
28
|
else
|
|
29
29
|
render :edit, status: :unprocessable_content
|
|
30
30
|
end
|
|
31
|
+
rescue ActiveRecord::RecordNotUnique
|
|
32
|
+
# Two concurrent kind switches can both pass the validation; the unique index has the last word.
|
|
33
|
+
@entry.errors.add(:changelog_kind, :taken)
|
|
34
|
+
render :edit, status: :unprocessable_content
|
|
31
35
|
end
|
|
32
36
|
|
|
33
37
|
private
|
|
@@ -44,7 +48,7 @@ module Shiplog
|
|
|
44
48
|
end
|
|
45
49
|
|
|
46
50
|
def entry_params
|
|
47
|
-
params.expect(entry: %i[title body released_on])
|
|
51
|
+
params.expect(entry: %i[title body released_on changelog_kind])
|
|
48
52
|
end
|
|
49
53
|
|
|
50
54
|
def page_number
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module Admin
|
|
3
|
+
# The drafts inbox state (status, kind, page) carried through every action that leaves the list, so
|
|
4
|
+
# the admin lands back on the same filtered list. Values are rebuilt from a whitelist and never taken
|
|
5
|
+
# from a URL or the Referer, so they cannot be used for an open redirect.
|
|
6
|
+
module InboxFilters
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
MAX_PAGE = 100_000
|
|
10
|
+
|
|
11
|
+
included do
|
|
12
|
+
helper_method :inbox_filter_params
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Only present, valid values: { status:, kind:, page: } with any unknown or invalid key dropped.
|
|
16
|
+
def inbox_filter_params
|
|
17
|
+
@inbox_filter_params ||= { status: inbox_status, kind: inbox_kind, page: inbox_page }.compact
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
# Inline (script) requests get the re-rendered row; plain requests redirect back to the filtered list.
|
|
22
|
+
def respond_inline(entry, message_key)
|
|
23
|
+
message = t("shiplog.admin.entries.#{message_key}")
|
|
24
|
+
return redirect_to(root_path(inbox_filter_params), notice: message) unless request.format.json?
|
|
25
|
+
|
|
26
|
+
render json: { row: render_inbox_row(entry), matches_filter: inbox_match?(entry), message: message }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def respond_inline_error(message, fallback)
|
|
30
|
+
return render(json: { error: message }, status: :unprocessable_content) if request.format.json?
|
|
31
|
+
|
|
32
|
+
redirect_to fallback, alert: message
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def render_inbox_row(entry)
|
|
36
|
+
render_to_string(partial: "shiplog/admin/entries/entry_row", formats: :html, locals: { entry: })
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Mirrors EntriesController#index: drafts unless a status was given, any kind unless one was.
|
|
40
|
+
def inbox_match?(entry)
|
|
41
|
+
entry.status == (inbox_filter_params[:status] || "draft") &&
|
|
42
|
+
(inbox_filter_params[:kind].nil? || entry.kind == inbox_filter_params[:kind])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def inbox_status = Entry.statuses.key(params[:status].to_s) && params[:status].to_s
|
|
46
|
+
|
|
47
|
+
def inbox_kind = Entry.kinds.key(params[:kind].to_s) && params[:kind].to_s
|
|
48
|
+
|
|
49
|
+
def inbox_page
|
|
50
|
+
page = Integer(params[:page].to_s, 10, exception: false)
|
|
51
|
+
page.clamp(1, MAX_PAGE) if page&.positive?
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -4,7 +4,8 @@ module Shiplog
|
|
|
4
4
|
ASSETS = {
|
|
5
5
|
"shiplog.css" => { path: ASSET_DIRECTORY.join("shiplog.css"), type: "text/css" },
|
|
6
6
|
"admin.css" => { path: ASSET_DIRECTORY.join("admin.css"), type: "text/css" },
|
|
7
|
-
"board.js" => { path: ASSET_DIRECTORY.join("board.js"), type: "text/javascript" }
|
|
7
|
+
"board.js" => { path: ASSET_DIRECTORY.join("board.js"), type: "text/javascript" },
|
|
8
|
+
"inbox.js" => { path: ASSET_DIRECTORY.join("inbox.js"), type: "text/javascript" }
|
|
8
9
|
}.freeze
|
|
9
10
|
|
|
10
11
|
# The version segment busts caches; stale versions are 404 so they are never cached as the current file.
|
|
@@ -70,8 +70,15 @@
|
|
|
70
70
|
.shiplog-admin .shiplog-admin__flash { border: 1px solid var(--sa-border); border-radius: 0.375rem; padding: 0.5rem 0.75rem; }
|
|
71
71
|
.shiplog-admin .shiplog-admin__flash--alert { border-color: #cf222e; }
|
|
72
72
|
|
|
73
|
+
.shiplog-admin .shiplog-admin__live { border: 1px solid var(--sa-border); border-radius: 0.375rem; padding: 0.5rem 0.75rem; }
|
|
74
|
+
.shiplog-admin .shiplog-admin__live[hidden] { display: none; }
|
|
75
|
+
.shiplog-admin .shiplog-admin__live--alert { border-color: #cf222e; }
|
|
76
|
+
|
|
73
77
|
.shiplog-admin .shiplog-admin__title { font-size: 1.5rem; margin: 0 0 1rem; }
|
|
74
78
|
.shiplog-admin .shiplog-admin__muted { color: var(--sa-muted); font-size: 0.875rem; }
|
|
79
|
+
.shiplog-admin .shiplog-entry-row { transition: opacity 0.2s; }
|
|
80
|
+
.shiplog-admin .shiplog-entry-row--leaving { opacity: 0; }
|
|
81
|
+
@media (prefers-reduced-motion: reduce) { .shiplog-admin .shiplog-entry-row { transition: none; } }
|
|
75
82
|
.shiplog-admin .shiplog-admin__empty { color: var(--sa-muted); }
|
|
76
83
|
|
|
77
84
|
.shiplog-admin .shiplog-admin-filters { display: flex; flex-wrap: wrap; gap: 0.5rem 2rem; margin-bottom: 1rem; }
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
// Idempotent: runs now and again on turbo:load (inert without Turbo), binding each board once.
|
|
2
|
+
const init = () => {
|
|
3
|
+
const board = document.querySelector("[data-board-url]")
|
|
4
|
+
if (!board || board.dataset.shiplogBound) return
|
|
5
|
+
board.dataset.shiplogBound = "true"
|
|
2
6
|
|
|
3
|
-
if (board) {
|
|
4
7
|
// Host layouts may not render csrf_meta_tags, so the board element carries the token as a fallback.
|
|
5
8
|
const csrfToken = document.querySelector("meta[name=csrf-token]")?.content ?? board.dataset.csrfToken
|
|
6
9
|
let dragged = null
|
|
@@ -75,3 +78,6 @@ if (board) {
|
|
|
75
78
|
save(list, dragged).catch(() => window.location.reload())
|
|
76
79
|
})
|
|
77
80
|
}
|
|
81
|
+
|
|
82
|
+
init()
|
|
83
|
+
document.addEventListener("turbo:load", init)
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -1,6 +1,8 @@
|
|
|
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
|
|
4
6
|
|
|
5
7
|
def initialize(repository:, since:, before:)
|
|
6
8
|
@repository = repository
|
|
@@ -30,8 +32,8 @@ module Shiplog
|
|
|
30
32
|
|
|
31
33
|
# Untyped PR titles stay in; the rewriter decides whether customers would notice.
|
|
32
34
|
def excluded_type?(pull_request)
|
|
33
|
-
type = pull_request.title[COMMIT_TYPE, 1]
|
|
34
|
-
|
|
35
|
+
type = pull_request.title[COMMIT_TYPE, 1]&.downcase
|
|
36
|
+
CONVENTIONAL_TYPES.include?(type) && !Shiplog.config.commit_types.include?(type)
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
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,19 @@ 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
|
+
def changelog_kind = kind
|
|
17
|
+
|
|
18
|
+
# Switches a changelog entry between "feature" and "change". Roadmap entries never convert, and
|
|
19
|
+
# anything other than those two values is ignored.
|
|
20
|
+
def changelog_kind=(value)
|
|
21
|
+
self.kind = value if CHANGELOG_KINDS.include?(kind) && CHANGELOG_KINDS.include?(value)
|
|
22
|
+
end
|
|
11
23
|
|
|
12
24
|
def self.digest_for(subject)
|
|
13
25
|
Digest::SHA256.hexdigest([ subject.title, subject.body ].join("\n"))
|
|
@@ -35,6 +47,18 @@ module Shiplog
|
|
|
35
47
|
|
|
36
48
|
private
|
|
37
49
|
|
|
50
|
+
def kind_free_for_subject
|
|
51
|
+
return unless will_save_change_to_kind? && self.class.where(subject_type:, subject_id:, kind:).where.not(id:).exists?
|
|
52
|
+
|
|
53
|
+
errors.add(:changelog_kind, :taken)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def kind_change_allowed
|
|
57
|
+
return unless will_save_change_to_kind?
|
|
58
|
+
|
|
59
|
+
errors.add(:kind, :invalid) unless CHANGELOG_KINDS.include?(kind_in_database) && CHANGELOG_KINDS.include?(kind)
|
|
60
|
+
end
|
|
61
|
+
|
|
38
62
|
def require_status!(action, from:)
|
|
39
63
|
raise InvalidTransition, "Cannot #{action} an entry that is #{status}" unless status == from
|
|
40
64
|
end
|
|
@@ -7,7 +7,7 @@ module Shiplog
|
|
|
7
7
|
You write release notes for %<product_name>s, used by %<audience>s.
|
|
8
8
|
Tone: %<tone>s
|
|
9
9
|
Rewrite the following %<kind_label>s for customers who are not developers.
|
|
10
|
-
Explain what changes for them, not how it was built. Never mention code, tables, refactors, gems, or internal names
|
|
10
|
+
Explain what changes for them, not how it was built. Never mention code, tables, refactors, gems, or internal names.%<kind_rules>s
|
|
11
11
|
Title: at most 8 words. Body: 1–3 short sentences (features may use up to 2 short paragraphs).
|
|
12
12
|
Reply with JSON only: {"title": "...", "body": "...", "customer_facing": true|false}
|
|
13
13
|
Set customer_facing to false if a customer would not notice this change.
|
|
@@ -17,6 +17,16 @@ module Shiplog
|
|
|
17
17
|
</source>
|
|
18
18
|
PROMPT
|
|
19
19
|
|
|
20
|
+
KIND_RULES = {
|
|
21
|
+
"roadmap" => <<~RULES.squish.freeze,
|
|
22
|
+
This is NOT available yet: it is planned work that has not been built. Write in the future tense ("You'll be able to…", "We're working on…").
|
|
23
|
+
Never use "now", "is available", "has been added" or "you can now".
|
|
24
|
+
Describe the outcome for the customer, not specifics that could change: no dates, prices, refunds, guarantees or exact limits, and do not turn implementation details from the source into promises.
|
|
25
|
+
RULES
|
|
26
|
+
"feature" => 'This feature has already shipped: present or past tense is right ("You can now…").',
|
|
27
|
+
"change" => ""
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
20
30
|
MAX_BODY_CHARS = 4_000
|
|
21
31
|
MAX_ITEM_CHARS = 200
|
|
22
32
|
MAX_LIST_ITEMS = 20
|
|
@@ -33,7 +43,7 @@ module Shiplog
|
|
|
33
43
|
|
|
34
44
|
config = Shiplog.config
|
|
35
45
|
format(TEMPLATE, product_name: config.product_name, audience: config.audience, tone: config.tone,
|
|
36
|
-
kind_label: KIND_LABELS.fetch(@entry.kind), context: render_context)
|
|
46
|
+
kind_label: KIND_LABELS.fetch(@entry.kind), kind_rules: KIND_RULES.fetch(@entry.kind).then { |rules| rules.empty? ? rules : "\n#{rules}" }, context: render_context)
|
|
37
47
|
end
|
|
38
48
|
|
|
39
49
|
private
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
<tr class="shiplog-entry-row">
|
|
1
|
+
<tr class="shiplog-entry-row" id="shiplog-entry-<%= entry.id %>">
|
|
2
2
|
<td>
|
|
3
|
-
<%= link_to entry_heading(entry), edit_entry_path(entry) %>
|
|
3
|
+
<%= link_to entry_heading(entry), edit_entry_path(entry, inbox_filter_params) %>
|
|
4
4
|
<%= render "shiplog/admin/entries/badges", entry: %>
|
|
5
5
|
<% if entry.discarded? && entry.discard_reason.present? %>
|
|
6
6
|
<div class="shiplog-admin__muted"><%= entry.discard_reason %></div>
|
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
<%= shiplog_github_link(source_reference(entry.subject), entry.subject.url) %>
|
|
12
12
|
</td>
|
|
13
13
|
<td class="shiplog-admin-table__actions">
|
|
14
|
-
<%= link_to t("shiplog.admin.entries.edit"), edit_entry_path(entry), class: "shiplog-button" %>
|
|
14
|
+
<%= link_to t("shiplog.admin.entries.edit"), edit_entry_path(entry, inbox_filter_params), class: "shiplog-button" %>
|
|
15
15
|
<% if entry.draft? %>
|
|
16
|
-
<%= button_to t("shiplog.admin.entries.approve"), entry_approval_path(entry), class: "shiplog-button shiplog-button--primary" %>
|
|
16
|
+
<%= button_to t("shiplog.admin.entries.approve"), entry_approval_path(entry, inbox_filter_params), class: "shiplog-button shiplog-button--primary", form: { data: { shiplog_inline: true } } %>
|
|
17
17
|
<% elsif entry.discarded? %>
|
|
18
|
-
<%= button_to t("shiplog.admin.entries.restore"), entry_discard_path(entry), method: :delete, class: "shiplog-button" %>
|
|
18
|
+
<%= button_to t("shiplog.admin.entries.restore"), entry_discard_path(entry, inbox_filter_params), method: :delete, class: "shiplog-button", form: { data: { shiplog_inline: true } } %>
|
|
19
19
|
<% end %>
|
|
20
20
|
</td>
|
|
21
21
|
</tr>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<div class="shiplog-admin-split">
|
|
9
9
|
<section class="shiplog-admin-panel">
|
|
10
10
|
<h2><%= t("shiplog.admin.entries.public_text") %></h2>
|
|
11
|
-
<%= form_with model: @entry, url: entry_path(@entry), method: :patch, class: "shiplog-admin-form" do |form| %>
|
|
11
|
+
<%= form_with model: @entry, url: entry_path(@entry, inbox_filter_params), method: :patch, class: "shiplog-admin-form" do |form| %>
|
|
12
12
|
<% if @entry.errors.any? %>
|
|
13
13
|
<div class="shiplog-admin__flash shiplog-admin__flash--alert" role="alert">
|
|
14
14
|
<%= @entry.errors.full_messages.to_sentence %>
|
|
@@ -22,6 +22,12 @@
|
|
|
22
22
|
<%= form.label :body, t("shiplog.admin.entries.fields.body") %>
|
|
23
23
|
<%= form.text_area :body, rows: 8 %>
|
|
24
24
|
</p>
|
|
25
|
+
<% if Shiplog::Entry::CHANGELOG_KINDS.include?(@entry.kind) %>
|
|
26
|
+
<p>
|
|
27
|
+
<%= form.label :changelog_kind, t("shiplog.admin.entries.fields.category") %>
|
|
28
|
+
<%= form.select :changelog_kind, Shiplog::Entry::CHANGELOG_KINDS.map { |kind| [ t("shiplog.admin.entries.category_options.#{kind}"), kind ] } %>
|
|
29
|
+
</p>
|
|
30
|
+
<% end %>
|
|
25
31
|
<p>
|
|
26
32
|
<%= form.label :released_on, t("shiplog.admin.entries.fields.released_on") %>
|
|
27
33
|
<%= form.date_field :released_on %>
|
|
@@ -31,16 +37,16 @@
|
|
|
31
37
|
|
|
32
38
|
<div class="shiplog-admin-actions">
|
|
33
39
|
<% if @entry.draft? %>
|
|
34
|
-
<%= button_to t("shiplog.admin.entries.approve"), entry_approval_path(@entry), class: "shiplog-button shiplog-button--primary" %>
|
|
35
|
-
<%= form_with url: entry_discard_path(@entry), class: "shiplog-admin-inline-form" do |form| %>
|
|
40
|
+
<%= button_to t("shiplog.admin.entries.approve"), entry_approval_path(@entry, inbox_filter_params), class: "shiplog-button shiplog-button--primary" %>
|
|
41
|
+
<%= form_with url: entry_discard_path(@entry, inbox_filter_params), class: "shiplog-admin-inline-form" do |form| %>
|
|
36
42
|
<%= form.label :reason, t("shiplog.admin.entries.fields.reason") %>
|
|
37
43
|
<%= form.text_field :reason %>
|
|
38
44
|
<%= form.submit t("shiplog.admin.entries.discard"), class: "shiplog-button" %>
|
|
39
45
|
<% end %>
|
|
40
46
|
<% elsif @entry.discarded? %>
|
|
41
|
-
<%= button_to t("shiplog.admin.entries.restore"), entry_discard_path(@entry), method: :delete, class: "shiplog-button" %>
|
|
47
|
+
<%= button_to t("shiplog.admin.entries.restore"), entry_discard_path(@entry, inbox_filter_params), method: :delete, class: "shiplog-button" %>
|
|
42
48
|
<% end %>
|
|
43
|
-
<%= button_to t("shiplog.admin.entries.regenerate"), entry_rewrite_path(@entry), class: "shiplog-button" %>
|
|
49
|
+
<%= button_to t("shiplog.admin.entries.regenerate"), entry_rewrite_path(@entry, inbox_filter_params), class: "shiplog-button" %>
|
|
44
50
|
<% if @entry.approved? %>
|
|
45
51
|
<p class="shiplog-admin__muted"><%= t("shiplog.admin.entries.regenerate_warning") %></p>
|
|
46
52
|
<% end %>
|
|
@@ -57,5 +63,5 @@
|
|
|
57
63
|
</aside>
|
|
58
64
|
</div>
|
|
59
65
|
|
|
60
|
-
<p><%= link_to t("shiplog.admin.entries.back"),
|
|
66
|
+
<p><%= link_to t("shiplog.admin.entries.back"), entries_filter_path(**inbox_filter_params) %></p>
|
|
61
67
|
<% end %>
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
</ul>
|
|
19
19
|
</nav>
|
|
20
20
|
|
|
21
|
+
<div data-shiplog-inbox data-empty-text="<%= t("shiplog.admin.entries.empty") %>" data-csrf-token="<%= form_authenticity_token %>">
|
|
22
|
+
<p class="shiplog-admin__live" role="status" aria-live="polite" data-shiplog-status hidden></p>
|
|
21
23
|
<% if @entries.empty? %>
|
|
22
24
|
<p class="shiplog-admin__empty"><%= t("shiplog.admin.entries.empty") %></p>
|
|
23
25
|
<% else %>
|
|
@@ -35,6 +37,8 @@
|
|
|
35
37
|
</tbody>
|
|
36
38
|
</table>
|
|
37
39
|
<% end %>
|
|
40
|
+
</div>
|
|
41
|
+
<%= shiplog_javascript_tag "inbox" %>
|
|
38
42
|
|
|
39
43
|
<nav class="shiplog-admin-pagination" aria-label="<%= t("shiplog.admin.entries.pagination") %>">
|
|
40
44
|
<% if @page > 1 %>
|
data/config/locales/en.yml
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
en:
|
|
2
|
+
activerecord:
|
|
3
|
+
attributes:
|
|
4
|
+
shiplog/entry:
|
|
5
|
+
changelog_kind: Category
|
|
2
6
|
shiplog:
|
|
3
7
|
columns:
|
|
4
8
|
in_progress: In progress
|
|
@@ -56,6 +60,9 @@ en:
|
|
|
56
60
|
public_text: Public text
|
|
57
61
|
source: GitHub source
|
|
58
62
|
back: Back to drafts
|
|
63
|
+
category_options:
|
|
64
|
+
feature: New feature
|
|
65
|
+
change: Improvement
|
|
59
66
|
statuses:
|
|
60
67
|
draft: Drafts
|
|
61
68
|
approved: Approved
|
|
@@ -73,6 +80,7 @@ en:
|
|
|
73
80
|
title: Title
|
|
74
81
|
body: Text
|
|
75
82
|
released_on: Released on
|
|
83
|
+
category: Category
|
|
76
84
|
reason: Reason
|
|
77
85
|
board:
|
|
78
86
|
title: Roadmap board
|
data/lib/shiplog/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: shiplog
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Trackberry
|
|
@@ -45,6 +45,7 @@ files:
|
|
|
45
45
|
- app/controllers/shiplog/admin/entries/discards_controller.rb
|
|
46
46
|
- app/controllers/shiplog/admin/entries/rewrites_controller.rb
|
|
47
47
|
- app/controllers/shiplog/admin/entries_controller.rb
|
|
48
|
+
- app/controllers/shiplog/admin/inbox_filters.rb
|
|
48
49
|
- app/controllers/shiplog/admin/syncs_controller.rb
|
|
49
50
|
- app/controllers/shiplog/application_controller.rb
|
|
50
51
|
- app/controllers/shiplog/changelogs_controller.rb
|
|
@@ -54,6 +55,7 @@ files:
|
|
|
54
55
|
- app/controllers/shiplog/webhooks_controller.rb
|
|
55
56
|
- app/frontend/shiplog/admin.css
|
|
56
57
|
- app/frontend/shiplog/board.js
|
|
58
|
+
- app/frontend/shiplog/inbox.js
|
|
57
59
|
- app/frontend/shiplog/shiplog.css
|
|
58
60
|
- app/helpers/shiplog/admin/entries_helper.rb
|
|
59
61
|
- app/helpers/shiplog/application_helper.rb
|