shiplog 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -4
  3. data/README.md +178 -7
  4. data/app/controllers/shiplog/admin/announcements_controller.rb +19 -0
  5. data/app/controllers/shiplog/admin/entries/announcement_notifications_controller.rb +18 -0
  6. data/app/controllers/shiplog/admin/entries/exports_controller.rb +45 -0
  7. data/app/controllers/shiplog/admin/entries/newsletter_retries_controller.rb +17 -0
  8. data/app/controllers/shiplog/admin/entries/newsletter_tests_controller.rb +39 -0
  9. data/app/controllers/shiplog/admin/entries/newsletters_controller.rb +40 -0
  10. data/app/controllers/shiplog/admin/entries/translations_controller.rb +1 -0
  11. data/app/controllers/shiplog/admin/entries_controller.rb +7 -3
  12. data/app/controllers/shiplog/admin/newsletter_subscribers_controller.rb +47 -0
  13. data/app/controllers/shiplog/changelog_entries_controller.rb +14 -2
  14. data/app/controllers/shiplog/changelog_feeds_controller.rb +6 -2
  15. data/app/controllers/shiplog/changelogs_controller.rb +11 -1
  16. data/app/controllers/shiplog/frontends_controller.rb +2 -1
  17. data/app/controllers/shiplog/newsletter/confirmations_controller.rb +27 -0
  18. data/app/controllers/shiplog/newsletter/preferences_controller.rb +50 -0
  19. data/app/controllers/shiplog/newsletter/subscriptions_controller.rb +70 -0
  20. data/app/controllers/shiplog/newsletter/unsubscriptions_controller.rb +36 -0
  21. data/app/controllers/shiplog/newsletter_controller.rb +34 -0
  22. data/app/controllers/shiplog/public_controller.rb +10 -0
  23. data/app/frontend/shiplog/admin.css +5 -0
  24. data/app/frontend/shiplog/once.js +31 -0
  25. data/app/frontend/shiplog/shiplog.css +72 -0
  26. data/app/helpers/shiplog/admin/entries_helper.rb +2 -0
  27. data/app/helpers/shiplog/head_helper.rb +1 -1
  28. data/app/helpers/shiplog/newsletter_helper.rb +28 -0
  29. data/app/helpers/shiplog/newsletter_mailer_helper.rb +37 -0
  30. data/app/helpers/shiplog/permalink_helper.rb +24 -5
  31. data/app/helpers/shiplog/unseen_helper.rb +4 -0
  32. data/app/jobs/shiplog/announce_job.rb +30 -0
  33. data/app/jobs/shiplog/draft_announcement_job.rb +9 -0
  34. data/app/jobs/shiplog/purge_newsletter_job.rb +15 -0
  35. data/app/jobs/shiplog/send_newsletter_job.rb +64 -0
  36. data/app/jobs/shiplog/translate_entry_job.rb +2 -0
  37. data/app/mailers/shiplog/newsletter_mailer.rb +51 -0
  38. data/app/models/shiplog/announcements.rb +34 -0
  39. data/app/models/shiplog/area_detector.rb +2 -1
  40. data/app/models/shiplog/change_candidates.rb +4 -0
  41. data/app/models/shiplog/drafter.rb +4 -2
  42. data/app/models/shiplog/entry.rb +76 -6
  43. data/app/models/shiplog/markdown.rb +233 -0
  44. data/app/models/shiplog/newsletter/content.rb +172 -0
  45. data/app/models/shiplog/newsletter/csv.rb +32 -0
  46. data/app/models/shiplog/newsletter/recipients.rb +92 -0
  47. data/app/models/shiplog/newsletter/tokens.rb +77 -0
  48. data/app/models/shiplog/newsletter.rb +180 -0
  49. data/app/models/shiplog/newsletter_delivery.rb +83 -0
  50. data/app/models/shiplog/newsletter_subscriber.rb +158 -0
  51. data/app/models/shiplog/period.rb +129 -0
  52. data/app/models/shiplog/public_links.rb +47 -0
  53. data/app/models/shiplog/rewriter/announcement_prompt.rb +70 -0
  54. data/app/models/shiplog/rewriter.rb +23 -3
  55. data/app/models/shiplog/translator/prompt.rb +2 -2
  56. data/app/models/shiplog/translator.rb +1 -1
  57. data/app/views/shiplog/admin/_nav.html.erb +3 -0
  58. data/app/views/shiplog/admin/entries/_announcement.html.erb +52 -0
  59. data/app/views/shiplog/admin/entries/_badges.html.erb +1 -1
  60. data/app/views/shiplog/admin/entries/_newsletter.html.erb +42 -0
  61. data/app/views/shiplog/admin/entries/edit.html.erb +8 -2
  62. data/app/views/shiplog/admin/entries/index.html.erb +9 -0
  63. data/app/views/shiplog/admin/entries/newsletters/show.html.erb +46 -0
  64. data/app/views/shiplog/admin/newsletter_subscribers/index.html.erb +83 -0
  65. data/app/views/shiplog/changelog_entries/announcement.html.erb +16 -0
  66. data/app/views/shiplog/changelogs/_month.html.erb +10 -3
  67. data/app/views/shiplog/changelogs/show.html.erb +4 -1
  68. data/app/views/shiplog/newsletter/_form.html.erb +25 -0
  69. data/app/views/shiplog/newsletter/_page.html.erb +14 -0
  70. data/app/views/shiplog/newsletter/confirmations/create.html.erb +5 -0
  71. data/app/views/shiplog/newsletter/confirmations/show.html.erb +5 -0
  72. data/app/views/shiplog/newsletter/invalid.html.erb +4 -0
  73. data/app/views/shiplog/newsletter/preferences/destroy.html.erb +4 -0
  74. data/app/views/shiplog/newsletter/preferences/show.html.erb +23 -0
  75. data/app/views/shiplog/newsletter/rate_limited.html.erb +4 -0
  76. data/app/views/shiplog/newsletter/subscriptions/show.html.erb +16 -0
  77. data/app/views/shiplog/newsletter/unsubscriptions/create.html.erb +4 -0
  78. data/app/views/shiplog/newsletter/unsubscriptions/show.html.erb +5 -0
  79. data/app/views/shiplog/newsletter_mailer/announcement.html.erb +113 -0
  80. data/app/views/shiplog/newsletter_mailer/announcement.text.erb +38 -0
  81. data/app/views/shiplog/newsletter_mailer/confirmation.html.erb +43 -0
  82. data/app/views/shiplog/newsletter_mailer/confirmation.text.erb +12 -0
  83. data/config/admin_routes.rb +12 -0
  84. data/config/locales/en.yml +147 -0
  85. data/config/locales/es.yml +147 -0
  86. data/config/routes.rb +13 -0
  87. data/db/migrate/20261028000001_shiplog_0_4_0.rb +19 -0
  88. data/db/migrate/20261028000002_shiplog_0_4_0_newsletter.rb +44 -0
  89. data/lib/generators/shiplog/install/install_generator.rb +14 -1
  90. data/lib/generators/shiplog/install/templates/initializer.rb.tt +50 -0
  91. data/lib/shiplog/classes.rb +14 -1
  92. data/lib/shiplog/configuration.rb +135 -3
  93. data/lib/shiplog/engine.rb +2 -0
  94. data/{app/helpers → lib}/shiplog/head_tags_helper.rb +15 -5
  95. data/lib/shiplog/previews/shiplog/newsletter_mailer_preview.rb +77 -0
  96. data/lib/shiplog/version.rb +1 -1
  97. data/lib/shiplog.rb +33 -0
  98. data/lib/tasks/shiplog.rake +22 -0
  99. metadata +57 -2
@@ -37,13 +37,30 @@ module Shiplog
37
37
  path ? link_to(name, path, html_options) : content_tag(:span, name, html_options.slice(:class))
38
38
  end
39
39
 
40
+ # Where a month heading's "Read the summary" link goes: the announcement's page (config.announcements = :page),
41
+ # or what config.announcement_url returns for it (:external; evaluated in the view with the announcement and
42
+ # the locale). Nil, so no link, when there is none, it raises or it is not a safe link.
43
+ def shiplog_announcement_href(announcement)
44
+ return shiplog_entry_path(announcement) if Shiplog.announcement_pages?
45
+
46
+ callable = Shiplog.config.announcement_url
47
+ return unless Shiplog.config.announcements == :external && callable
48
+
49
+ value = callable.is_a?(Proc) ? instance_exec(announcement, I18n.locale, &callable) : callable.call(announcement, I18n.locale)
50
+ value if Shiplog::Configuration.safe_url?(value)
51
+ rescue StandardError => error
52
+ Rails.logger.error("[shiplog] config.announcement_url failed for entry #{announcement.id}: #{error.class}: #{error.message}")
53
+ nil
54
+ end
55
+
40
56
  # The id of a month's section on the changelog list.
41
57
  def shiplog_month_anchor(date) = "shiplog-month-#{date.strftime("%Y-%m")}"
42
58
 
43
59
  # The body, on one line and cut for a meta description. Plain text: tag helpers escape it. `escape: false`
44
60
  # keeps the cut text raw (not HTML-escaped) for JSON, which escapes it its own way.
45
61
  def shiplog_entry_description(entry, escape: true)
46
- truncate(entry.display_body.to_s.squish, length: DESCRIPTION_LENGTH, separator: " ", escape:)
62
+ text = entry.announcement? ? Shiplog::Markdown.to_text(entry.display_body) : entry.display_body.to_s
63
+ truncate(text.squish, length: DESCRIPTION_LENGTH, separator: " ", escape:)
47
64
  end
48
65
 
49
66
  # The Open Graph meta tags of a permalink page, for `content_for :shiplog_og` (superseded by shiplog_head_tags).
@@ -55,15 +72,16 @@ module Shiplog
55
72
  safe_join(tags.compact_blank.map { |property, content| tag.meta(property:, content:) }, "\n")
56
73
  end
57
74
 
58
- # The absolute URL of the image to show when the page is shared, or nil: the entry's first screenshot, else
75
+ # The absolute URL of the image to show when the page is shared, or nil: the entry's first screenshot with a URL, else
59
76
  # what config.og_image returns for it. Computed once per entry per render, so a failing callable is logged
60
77
  # once for a page.
61
78
  def shiplog_entry_og_image(entry)
62
79
  @shiplog_og_images ||= {}.compare_by_identity
63
80
  return @shiplog_og_images[entry] if @shiplog_og_images.key?(entry)
64
81
 
65
- image = shiplog_images(entry).first
66
- @shiplog_og_images[entry] = (shiplog_image_url(image) if image) || shiplog_configured_og_image(entry)
82
+ # Same rule as the JSON-LD Article image: screenshots whose URL can't be built are skipped.
83
+ screenshot = shiplog_images(entry).lazy.filter_map { |image| shiplog_image_url(image) }.first
84
+ @shiplog_og_images[entry] = screenshot || shiplog_configured_og_image(entry)
67
85
  end
68
86
 
69
87
  # What config.og_image gives for an entry (called in the view with the entry and the locale), as an absolute
@@ -102,7 +120,8 @@ module Shiplog
102
120
  url = shiplog_image_url(image)
103
121
  tag.p(tag.img(src: url, alt: entry.image_alt(image), **shiplog_image_size(image))) if url
104
122
  end
105
- safe_join([ simple_format(h(entry.display_body)), *images ], "\n")
123
+ text = entry.announcement? ? Shiplog::Markdown.to_html(entry.display_body) : simple_format(h(entry.display_body))
124
+ safe_join([ text, *images ], "\n")
106
125
  end
107
126
 
108
127
  # An entry's images in order, or none while screenshots are off.
@@ -5,5 +5,9 @@ module Shiplog
5
5
  module UnseenHelper
6
6
  # The number of approved features and changes approved after `since` (all of them while it is nil), at most 99.
7
7
  def shiplog_unseen_count(since) = Shiplog.unseen_count(since:)
8
+
9
+ # The number of approved monthly announcements approved after `since` (all of them while it is nil), at most 99:
10
+ # the rare signal for a badge, where shiplog_unseen_count counts every daily entry.
11
+ def shiplog_unseen_announcements_count(since) = Shiplog.unseen_announcements_count(since:)
8
12
  end
9
13
  end
@@ -0,0 +1,30 @@
1
+ module Shiplog
2
+ # Calls config.on_announcement_approved with an approved announcement, and sends the
3
+ # `announcement_approved.shiplog` notification, once its translations into every configured locale are current
4
+ # (or cannot be made: no rewriter). Enqueued when an announcement is approved and after each translation of one;
5
+ # whichever run finds it ready announces it, once per approval (Entry#announce!).
6
+ #
7
+ # The hook runs inside the announcement's row lock and transaction: keep it fast (enqueue your own job). A hook
8
+ # that raises, or a worker that dies mid-hook, leaves the announcement unannounced, and the retry calls it again;
9
+ # after the last attempt the error is logged and the admin can "Notify now". `force: true` (that button) does not
10
+ # wait for translations.
11
+ class AnnounceJob < ApplicationJob
12
+ ATTEMPTS = 3
13
+ EVENT = "announcement_approved.shiplog".freeze
14
+
15
+ retry_on StandardError, attempts: ATTEMPTS, wait: :polynomially_longer do |job, error|
16
+ Rails.logger.error("[shiplog] Giving up announcing entry #{job.arguments.first.try(:id)} after #{ATTEMPTS} attempts: #{error.class}: #{error.message}")
17
+ end
18
+ discard_on ActiveJob::DeserializationError, ActiveRecord::RecordNotFound
19
+
20
+ def perform(entry, force: false)
21
+ return unless Shiplog.announcements?
22
+
23
+ entry.announce!(force:) do
24
+ ActiveSupport::Notifications.instrument(EVENT, announcement: entry) do
25
+ Shiplog.config.on_announcement_approved&.call(entry)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,9 @@
1
+ module Shiplog
2
+ # Drafts the announcement of a month (the previous one by default) and enqueues its rewrite. Schedule it once a
3
+ # month, early in the month (see the README's Scheduling section); running it again is harmless.
4
+ class DraftAnnouncementJob < ApplicationJob
5
+ def perform(month = nil)
6
+ Announcements.draft(month: month || Date.current.prev_month)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,15 @@
1
+ module Shiplog
2
+ # Housekeeping of the newsletter's tables, to schedule weekly like ScanChangesJob (see the README's Scheduling
3
+ # section): deletes the subscribers who never confirmed within 30 days (NewsletterSubscriber::PENDING_FOR: an
4
+ # address typed by someone else, or a confirmation email that never arrived) and the delivery rows older than
5
+ # 180 days (NewsletterDelivery::RETENTION: the idempotency record of a send long done). Confirmed and
6
+ # unsubscribed rows are never touched. Returns { subscribers:, deliveries: } counts.
7
+ class PurgeNewsletterJob < ApplicationJob
8
+ def perform
9
+ subscribers = NewsletterSubscriber.purge_pending
10
+ deliveries = NewsletterDelivery.purge
11
+ Rails.logger.info("[shiplog] Purged #{subscribers} unconfirmed newsletter #{subscribers == 1 ? "subscriber" : "subscribers"} and #{deliveries} old delivery #{deliveries == 1 ? "row" : "rows"}")
12
+ { subscribers:, deliveries: }
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,64 @@
1
+ module Shiplog
2
+ # Emails an approved announcement to every recipient (Newsletter::Recipients: the host's users, then the
3
+ # confirmed subscribers, minus the unsubscribed and the duplicates), once per recipient. For each chunk of
4
+ # NewsletterDelivery::CLAIM_SIZE it inserts the delivery rows first (one statement, skipping the rows that
5
+ # exist) and emails only the recipients it inserted, touching each row right before its send, so a retried
6
+ # job, a second click on Send or two workers never send twice: at most once per recipient. A failed delivery
7
+ # is recorded on its row (never raised: the chunk goes on) for the admin's "Retry failed", which runs the job
8
+ # with `retry_failed: true`: the failed and stalled rows are reclaimed in one statement, the ones reclaimed by
9
+ # this run are sent again, and the ones whose recipient is gone end as failed with the reason.
10
+ #
11
+ # The announcement's Markdown is rendered once per locale (Newsletter::Content); each message is then built with
12
+ # the recipient's own links. The job stops before anything is sent when the newsletter is off or the
13
+ # announcement cannot be sent (Newsletter.blockers: not approved, missing translations, no sender...).
14
+ class SendNewsletterJob < ApplicationJob
15
+ discard_on ActiveJob::DeserializationError, ActiveRecord::RecordNotFound
16
+
17
+ def perform(entry, retry_failed: false)
18
+ return log_skip(entry, "the newsletter is off") unless Newsletter.enabled?
19
+
20
+ blockers = Newsletter.blockers(entry)
21
+ return log_skip(entry, "it cannot be sent: #{blockers.join(", ")}") if blockers.any?
22
+
23
+ reclaimed = retry_failed ? NewsletterDelivery.reclaim(entry).to_set : Set.new
24
+ contents = Hash.new { |hash, locale| hash[locale] = Newsletter::Content.new(entry, locale) }
25
+ sent = 0
26
+ Newsletter::Recipients.new.each_batch do |batch|
27
+ batch.each_slice(NewsletterDelivery::CLAIM_SIZE) do |chunk|
28
+ claimed = NewsletterDelivery.claim(entry, chunk).to_set
29
+ chunk.each do |recipient|
30
+ next unless claimed.include?(recipient.key) || reclaimed.delete?(recipient.key)
31
+
32
+ sent += 1 if deliver(entry, recipient, contents[recipient.locale])
33
+ end
34
+ end
35
+ end
36
+ NewsletterDelivery.mark_missing(entry, reclaimed) # reclaimed, but no longer a recipient
37
+ Rails.logger.info("[shiplog] Newsletter of entry #{entry.id}: #{sent} emails sent in this run")
38
+ end
39
+
40
+ private
41
+
42
+ # Sends one email and records the outcome on the delivery row. True when it was sent.
43
+ def deliver(entry, recipient, content)
44
+ NewsletterDelivery.mark_sending(entry, recipient.key)
45
+ NewsletterMailer.announcement(entry, recipient.email, locale: recipient.locale, content:,
46
+ unsubscribe_url: Newsletter.unsubscribe_url(recipient.email), preferences_url: preferences_url(recipient)).deliver_now
47
+ NewsletterDelivery.mark_sent(entry, recipient.key)
48
+ true
49
+ rescue StandardError => error
50
+ NewsletterDelivery.mark_failed(entry, recipient.key, error)
51
+ Rails.logger.error("[shiplog] Newsletter of entry #{entry.id} to #{recipient.key} failed: #{error.class}: #{NewsletterDelivery.scrub(error.message)}")
52
+ false
53
+ end
54
+
55
+ # A subscriber's preferences page; a host user's own page when config.newsletter_unsubscribe_url gives one.
56
+ def preferences_url(recipient)
57
+ recipient.subscriber? ? Newsletter.preferences_url(recipient.record) : Newsletter.host_unsubscribe_url(recipient.record)
58
+ end
59
+
60
+ def log_skip(entry, reason)
61
+ Rails.logger.info("[shiplog] Newsletter of entry #{entry.id} not sent: #{reason}")
62
+ end
63
+ end
64
+ end
@@ -17,6 +17,8 @@ module Shiplog
17
17
 
18
18
  def perform(entry, locale, force: false)
19
19
  Translator.new(entry, locale).call(force:)
20
+ # An approved announcement is announced once all its translations are current: this may have been the last.
21
+ AnnounceJob.perform_later(entry) if entry.announcement?
20
22
  rescue StandardError => error
21
23
  Rails.logger.error("[shiplog] Translating entry #{entry.id} into #{locale} failed (attempt #{executions} of #{ATTEMPTS}): #{error.class}: #{error.message}")
22
24
  raise
@@ -0,0 +1,51 @@
1
+ module Shiplog
2
+ # The newsletter's emails: an announcement to one recipient, and the confirmation of a visitor's subscription.
3
+ # Both have an HTML part (one 600px table column, styles inline, see app/views/shiplog/newsletter_mailer) and a
4
+ # text part, in the recipient's language (the chrome from Shiplog's I18n keys, the announcement's text from its
5
+ # translation). Hosts override the templates like any engine view. The sender is config.newsletter_from.
6
+ class NewsletterMailer < ActionMailer::Base
7
+ helper Shiplog::NewsletterMailerHelper
8
+
9
+ default from: -> { sender! }
10
+
11
+ # The announcement for `email` in `locale`. `unsubscribe_url` is the one-click target (the List-Unsubscribe
12
+ # headers Gmail and Yahoo require; Newsletter.unsubscribe_url for the address when not given, so a queued test
13
+ # send carries no token), `preferences_url` the page the footer's "Preferences" opens (a subscriber's page, or
14
+ # the host's own for its users; nil shows the unsubscribe page instead). `content` is the locale's rendered
15
+ # Content when the caller has it (SendNewsletterJob renders once per locale). config.newsletter_mailer_options
16
+ # are merged into the message (e.g. Postmark's message_stream); they cannot change the recipient or the subject.
17
+ def announcement(entry, email, locale:, unsubscribe_url: nil, preferences_url: nil, content: nil)
18
+ @content = content || Newsletter::Content.new(entry, locale)
19
+ @unsubscribe_url = unsubscribe_url || Newsletter.unsubscribe_url(email)
20
+ @preferences_url = preferences_url || @unsubscribe_url
21
+ headers["List-Unsubscribe"] = "<#{@unsubscribe_url}>"
22
+ headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
23
+ headers["Precedence"] = "bulk"
24
+ I18n.with_locale(Newsletter.i18n_locale(@content.locale)) do
25
+ mail(**Shiplog.config.newsletter_mailer_options, to: email, subject: @content.subject)
26
+ end
27
+ end
28
+
29
+ # The double opt-in email: a single-use link (valid 7 days) the subscriber must open, in the locale of the
30
+ # page they subscribed on. `subscriber` is a NewsletterSubscriber, or its id when queued (deliver_later): a
31
+ # row deleted or confirmed in the meantime sends nothing, quietly. Transactional: no mailer options, no
32
+ # List-Unsubscribe.
33
+ def confirmation(subscriber)
34
+ @subscriber = subscriber.is_a?(NewsletterSubscriber) ? subscriber : NewsletterSubscriber.find_by(id: subscriber)
35
+ @confirmation_url = @subscriber && Newsletter.confirmation_url(@subscriber)
36
+ return unless @confirmation_url
37
+
38
+ @product_name = Newsletter.product_name
39
+ I18n.with_locale(Newsletter.i18n_locale(@subscriber.locale)) do
40
+ mail(to: @subscriber.email, subject: t("shiplog.newsletter.mail.confirm_subject", product: Newsletter.product_name))
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ # The From address, or a clear error when none is configured (the job records it on the delivery row).
47
+ def sender!
48
+ Newsletter.from or raise ArgumentError, "[shiplog] config.newsletter_from is not set, so no newsletter email can be sent"
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,34 @@
1
+ module Shiplog
2
+ # Monthly announcements: a short, AI-drafted summary of what shipped in a month, approved by an admin like every
3
+ # other entry (kind "announcement", subject a Shiplog::Period). Where it is published is config.announcements.
4
+ module Announcements
5
+ # The slug used when a feature or change approved before 0.4.0 already took the month's own ("2026-10").
6
+ FALLBACK_SUFFIX = "-update".freeze
7
+
8
+ # Drafts the announcement of the month containing `month` and enqueues its rewrite. Returns the new draft, or
9
+ # nil when announcements are off, the month has no approved feature or change (nothing to announce), or it
10
+ # has an announcement already (whatever its status).
11
+ def self.draft(month:)
12
+ return unless Shiplog.announcements?
13
+
14
+ starts_on = month.to_date.beginning_of_month
15
+ return unless Period.changelog_entries_between(starts_on..starts_on.end_of_month).exists?
16
+
17
+ period = Period.month(starts_on)
18
+ Drafter.draft(period, "announcement", released_on: period.ends_on, slug: slug_for(period))
19
+ end
20
+
21
+ # The months an admin can draft from the admin: the current one (so far) and the previous one.
22
+ def self.draftable_months(today = Date.current) = [ today.beginning_of_month, today.prev_month.beginning_of_month ]
23
+
24
+ # "2026-10", or when an older entry already has that slug "2026-10-update", then "2026-10-update-2", ...
25
+ def self.slug_for(period)
26
+ month = period.starts_on.strftime("%Y-%m")
27
+ candidates = Enumerator.new do |yielder|
28
+ yielder << month << "#{month}#{FALLBACK_SUFFIX}"
29
+ (2..).each { |suffix| yielder << "#{month}#{FALLBACK_SUFFIX}-#{suffix}" }
30
+ end
31
+ candidates.find { |slug| !Entry.where(slug:).where.not(subject: period).exists? }
32
+ end
33
+ end
34
+ end
@@ -99,9 +99,10 @@ module Shiplog
99
99
  end
100
100
 
101
101
  # Entries that may still get an area: drafts and approved ones with a title, no area, none chosen by an admin,
102
- # not asked about yet.
102
+ # not asked about yet. Announcements cover a whole month and never get one.
103
103
  def candidates
104
104
  Entry.where(status: %w[draft approved], area_id: nil, area_set_by_admin: false, area_detected_at: nil).where.not(title: [ nil, "" ])
105
+ .where.not(kind: "announcement")
105
106
  end
106
107
 
107
108
  # Ignores ids outside the batch and areas that don't exist. The update re-checks the conditions, so an
@@ -17,6 +17,10 @@ module Shiplog
17
17
  repository.pull_requests.awaiting_upgrade_check.where(merged_at: since..).reorder(:merged_at).pick(:merged_at)
18
18
  end
19
19
 
20
+ # Node ids of the pull requests (of any repository) linked to an issue that is public or has a public ancestor:
21
+ # the feature entry of that issue covers them. The lookup reads no instance state.
22
+ def self.public_tree_pull_request_ids(pull_requests) = allocate.send(:pull_request_ids_in_public_tree, pull_requests)
23
+
20
24
  def initialize(repository:, since:, before:)
21
25
  @repository = repository
22
26
  @window = since...before
@@ -14,12 +14,14 @@ module Shiplog
14
14
  REQUEUE_AFTER = 1.hour
15
15
  REQUEUE_LIMIT = 100
16
16
 
17
- # Returns the new entry, or nil when one already exists for the subject and kind.
18
- def self.draft(subject, kind, released_on: nil)
17
+ # Returns the new entry, or nil when one already exists for the subject and kind. `attributes` are set on a new
18
+ # entry only (an announcement's slug).
19
+ def self.draft(subject, kind, released_on: nil, **attributes)
19
20
  entry = Entry.create_or_find_by!(subject:, kind:) do |new_entry|
20
21
  new_entry.title = subject.title
21
22
  new_entry.body = subject.body
22
23
  new_entry.released_on = released_on
24
+ new_entry.assign_attributes(attributes)
23
25
  end
24
26
  return unless entry.previously_new_record?
25
27
 
@@ -7,20 +7,31 @@ module Shiplog
7
7
  belongs_to :subject, polymorphic: true
8
8
  belongs_to :area, class_name: "Shiplog::Area", optional: true
9
9
  has_many :translations, class_name: "Shiplog::EntryTranslation", dependent: :destroy, inverse_of: :entry
10
+ # The newsletter's deliveries of an announcement (see NewsletterDelivery); the database cascades the delete.
11
+ has_many :newsletter_deliveries, class_name: "Shiplog::NewsletterDelivery", foreign_key: :announcement_id, dependent: :delete_all, inverse_of: :announcement
10
12
 
11
- enum :kind, %w[roadmap feature change].index_by(&:itself), validate: true
13
+ enum :kind, %w[roadmap feature change announcement].index_by(&:itself), validate: true
12
14
  enum :status, %w[draft approved discarded].index_by(&:itself), default: "draft", validate: true
13
15
 
14
16
  CHANGELOG_KINDS = %w[feature change].freeze
17
+ # The slug of a month's announcement ("2026-10"). Features and changes never get one of this shape, so an
18
+ # announcement's page URL is known before it is drafted.
19
+ ANNOUNCEMENT_SLUG = /\A\d{4}-\d{2}\z/
15
20
 
16
21
  validates :released_on, presence: true, if: -> { approved? && !roadmap? }
17
22
  validate :area_exists
18
23
  validate :kind_change_allowed, on: :update
19
24
  validate :kind_free_for_subject, on: :update
20
25
 
26
+ # An announcement that leaves "approved" may be announced again once it is approved again.
27
+ before_save :clear_announced_at, if: -> { announcement? && will_save_change_to_status? && !approved? }
28
+
21
29
  # Approving an entry, or changing the English text of an approved one, translates it into every configured
22
30
  # locale (see TranslateEntryJob). After the commit, so the job reads what was saved.
23
31
  after_commit :enqueue_translations, on: :update, if: :translation_due?
32
+ # Approving an announcement calls config.on_announcement_approved once its translations are current (see
33
+ # AnnounceJob, which TranslateEntryJob enqueues too).
34
+ after_commit :enqueue_announcement, on: :update, if: -> { announcement? && approved? && saved_change_to_status? }
24
35
 
25
36
  SEARCH_MAX_LENGTH = 100
26
37
  SEARCH_MAX_WORDS = 5
@@ -84,11 +95,20 @@ module Shiplog
84
95
  # at 99. One COUNT over the (status, approved_at) index. It filters on approved_at, not on released_on (the
85
96
  # list's order): "new since you last looked" is about when an entry appeared, so an entry approved today
86
97
  # with an earlier release date still counts as new.
87
- def self.unseen_count(since: nil)
88
- scope = approved.where(kind: CHANGELOG_KINDS)
98
+ def self.unseen_count(since: nil) = count_approved_since(CHANGELOG_KINDS, since)
99
+
100
+ # The number of approved announcements approved after `since` (all of them while it is nil), capped at 99.
101
+ def self.unseen_announcements_count(since: nil) = count_approved_since("announcement", since)
102
+
103
+ def self.count_approved_since(kinds, since)
104
+ scope = approved.where(kind: kinds)
89
105
  scope = scope.where("#{table_name}.approved_at > ?", since) if since
90
106
  scope.limit(UNSEEN_COUNT_CAP).count
91
107
  end
108
+ private_class_method :count_approved_since
109
+
110
+ # Approved announcements: the ones with a public page (config.announcements = :page) or a host link.
111
+ scope :published_announcements, -> { approved.where(kind: "announcement").where.not(released_on: nil) }
92
112
 
93
113
  # Gives every published changelog entry that has no slug one (oldest first, so the oldest keeps the plain
94
114
  # slug on duplicate titles). Returns how many it filled.
@@ -134,7 +154,11 @@ module Shiplog
134
154
  self.kind = value if CHANGELOG_KINDS.include?(kind) && CHANGELOG_KINDS.include?(value)
135
155
  end
136
156
 
157
+ # The digest of what a draft was written from: the GitHub title and body, or for an announcement the month's
158
+ # entries (Period#content_digest). A draft whose stored digest differs is flagged in the admin.
137
159
  def self.digest_for(subject)
160
+ return subject.content_digest if subject.is_a?(Period)
161
+
138
162
  Digest::SHA256.hexdigest([ subject.title, subject.body ].join("\n"))
139
163
  end
140
164
 
@@ -200,6 +224,40 @@ module Shiplog
200
224
  translated.blank? && body.present? ? body : (translated || body)
201
225
  end
202
226
 
227
+ # Whether every configured locale has a current translation (always true without config.locales).
228
+ def translations_current? = Shiplog.config.locales.all? { |locale| translation(locale) }
229
+
230
+ # The title, Markdown body, HTML and plain text of an announcement in a locale (the default one when omitted):
231
+ # the current translation when there is one, else the English text. For config.on_announcement_approved and the
232
+ # admin's export.
233
+ def to_markdown(locale = I18n.default_locale) = display_body(locale).to_s
234
+ def to_html(locale = I18n.default_locale) = Markdown.to_html(display_body(locale))
235
+ def to_text(locale = I18n.default_locale) = Markdown.to_text(display_body(locale))
236
+
237
+ # The absolute URL of the entry's public page (see PublicLinks), or nil without a slug. For an announcement
238
+ # with config.announcements = :external, the host's own page is elsewhere (config.announcement_url).
239
+ def public_url = PublicLinks.entry_url(self)
240
+
241
+ # Whether the translations an announcement waits for are there: all current, or none can be made because there
242
+ # is no rewriter (then hosts get the English text, or an admin's hand-made translations).
243
+ def translations_ready? = Shiplog.config.rewriter.nil? || translations_current?
244
+
245
+ # Runs the block (the host's hook, see AnnounceJob) once for this approval, when this is an approved announcement
246
+ # not announced yet whose translations are ready (`force: true` skips that last check: the admin's "Notify
247
+ # now"). The block runs under the row lock and announced_at is stored in the same transaction, so two jobs
248
+ # never both run it, and a block that raises (or a worker that dies) stores nothing and is run again by the
249
+ # retry. Returns whether the block ran.
250
+ def announce!(force: false)
251
+ with_lock do
252
+ reload
253
+ next false unless announcement? && approved? && announced_at.nil? && (force || translations_ready?)
254
+
255
+ yield
256
+ update_columns(announced_at: Time.current)
257
+ true
258
+ end
259
+ end
260
+
203
261
  def source_changed?
204
262
  source_digest.present? && source_digest != self.class.digest_for(subject)
205
263
  end
@@ -214,16 +272,28 @@ module Shiplog
214
272
  Shiplog.config.locales.each { |locale| TranslateEntryJob.perform_later(self, locale) }
215
273
  end
216
274
 
275
+ # Features and changes get a slug when approved; an announcement gets its month's slug when drafted.
217
276
  def permalinked? = CHANGELOG_KINDS.include?(kind)
218
277
 
278
+ # Written whatever this instance holds: AnnounceJob sets the column with update_columns, so an instance loaded
279
+ # before it may still hold nil and would otherwise see no change to save.
280
+ def clear_announced_at
281
+ self.announced_at = nil
282
+ attribute_will_change!("announced_at")
283
+ end
284
+
285
+ def enqueue_announcement
286
+ AnnounceJob.perform_later(self)
287
+ end
288
+
219
289
  # The title parameterized and cut at 80 characters, made unique with -2, -3, ...; entry-<id> when the title
220
- # has nothing left. The uniqueness check can lose a race, which the unique index catches (see
221
- # retrying_on_slug_conflict).
290
+ # has nothing left. A month's shape ("2026-10") is kept for announcements and gets a suffix too. The
291
+ # uniqueness check can lose a race, which the unique index catches (see retrying_on_slug_conflict).
222
292
  def generate_slug
223
293
  base = dashed_title.first(SLUG_MAX_LENGTH).gsub(/\A-+|-+\z/, "").presence || "entry-#{id}"
224
294
  candidate = base
225
295
  suffix = 2
226
- while self.class.where(slug: candidate).where.not(id:).exists?
296
+ while ANNOUNCEMENT_SLUG.match?(candidate) || self.class.where(slug: candidate).where.not(id:).exists?
227
297
  tail = "-#{suffix}"
228
298
  candidate = "#{base.first(SLUG_MAX_LENGTH - tail.length).delete_suffix("-")}#{tail}"
229
299
  suffix += 1