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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f0f17beb628a8bf038407c3a1b0e5caa404db7f51eba9472dbf507df7da84431
4
- data.tar.gz: da38c5e1bc625bcae2ed31ef037db2440bc94a612760c11cc842cb91b02cdf28
3
+ metadata.gz: 58c17b51ed2b752958414ce03688b5c055681502c6ac9304c2238aab2144e521
4
+ data.tar.gz: 50ceed3676ed0cb48cb4d8aa3da190456aced0347d62404d3cfa4d095c4cd69e
5
5
  SHA512:
6
- metadata.gz: 588b5d5e87c63b828d0a75ef4aa01591efec6c94a954622d3ad2e36a6cf88deeb46ae72b19b0f865828606f147dfcdb2b6d30f34ce75741acc23543f9a853d15
7
- data.tar.gz: d6a7f52d6df8768e5586a47bbded5e5b867dfaac2ec07e85fd0ac86b37b0a8e4a275db62f7893a1ea8b0f607027924cc50bb4925ff2efb41c9b10e4158bd1a95
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 carries a type outside `commit_types`; untyped titles are left for the rewriter to judge. Bot authors are recorded as `name[bot]`, so the default `excluded_authors` pattern matches them.
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
- redirect_to edit_entry_path(params[:entry_id]), alert: t("shiplog.admin.entries.invalid_transition")
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
- redirect_to root_path, notice: t("shiplog.admin.entries.approved")
8
+ respond_inline(entry, :approved)
9
9
  rescue ActiveRecord::RecordInvalid => error
10
- redirect_to edit_entry_path(entry), alert: error.record.errors.full_messages.to_sentence
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
- redirect_to root_path, notice: t("shiplog.admin.entries.discarded")
9
+ respond_inline(@entry, :discarded)
10
10
  end
11
11
 
12
12
  def destroy
13
13
  @entry.restore!
14
- redirect_to root_path, notice: t("shiplog.admin.entries.restored")
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 = Entry.statuses.key(params[:status].to_s) || "draft"
11
- @kind = Entry.kinds.key(params[:kind].to_s)
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
- const board = document.querySelector("[data-board-url]")
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
- type.present? && !Shiplog.config.commit_types.include?(type)
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.feature.where(subject_type: Issue.name).select(:subject_id)
55
+ drafted = Entry.where(kind: Entry::CHANGELOG_KINDS, subject_type: Issue.name).select(:subject_id)
56
56
  Issue.publicly_labeled.where(state: "closed", state_reason: "completed", closed_at: FEATURE_LOOKBACK.ago..)
57
57
  .where.not(id: drafted)
58
58
  .includes(:parent).reject { |issue| issue.parent&.public? }
@@ -7,7 +7,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"), root_path %></p>
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 %>
@@ -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
@@ -1,3 +1,3 @@
1
1
  module Shiplog
2
- VERSION = "0.1.0"
2
+ VERSION = "0.1.1"
3
3
  end
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.0
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