shiplog 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/README.md +127 -5
  4. data/app/controllers/shiplog/admin/application_controller.rb +3 -1
  5. data/app/controllers/shiplog/admin/entries/approvals_controller.rb +2 -2
  6. data/app/controllers/shiplog/admin/entries/discards_controller.rb +2 -2
  7. data/app/controllers/shiplog/admin/entries/rewrites_controller.rb +1 -1
  8. data/app/controllers/shiplog/admin/entries_controller.rb +8 -4
  9. data/app/controllers/shiplog/admin/inbox_filters.rb +55 -0
  10. data/app/controllers/shiplog/admin/syncs_controller.rb +3 -2
  11. data/app/controllers/shiplog/changelogs_controller.rb +11 -1
  12. data/app/controllers/shiplog/frontends_controller.rb +2 -1
  13. data/app/controllers/shiplog/public_controller.rb +3 -1
  14. data/app/controllers/shiplog/roadmaps_controller.rb +25 -2
  15. data/app/frontend/shiplog/admin.css +7 -0
  16. data/app/frontend/shiplog/board.js +8 -2
  17. data/app/frontend/shiplog/inbox.js +78 -0
  18. data/app/frontend/shiplog/shiplog.css +121 -6
  19. data/app/helpers/shiplog/callable_helper.rb +12 -0
  20. data/app/helpers/shiplog/frontend_helper.rb +1 -0
  21. data/app/helpers/shiplog/host_routes_helper.rb +37 -0
  22. data/app/helpers/shiplog/roadmap_helper.rb +15 -0
  23. data/app/helpers/shiplog/switcher_helper.rb +101 -0
  24. data/app/jobs/shiplog/refresh_job.rb +1 -0
  25. data/app/jobs/shiplog/scan_changes_job.rb +19 -5
  26. data/app/jobs/shiplog/sync_job.rb +1 -1
  27. data/app/models/shiplog/change_candidates.rb +43 -6
  28. data/app/models/shiplog/drafter.rb +1 -1
  29. data/app/models/shiplog/entry.rb +41 -0
  30. data/app/models/shiplog/pull_request.rb +4 -0
  31. data/app/models/shiplog/pull_request_upgrade.rb +5 -0
  32. data/app/models/shiplog/rewriter/prompt.rb +37 -3
  33. data/app/models/shiplog/rewriter.rb +20 -1
  34. data/app/models/shiplog/sync/pull_request_importer.rb +23 -1
  35. data/app/models/shiplog/sync/repository_sync.rb +2 -0
  36. data/app/models/shiplog/sync/upgrade_detector.rb +203 -0
  37. data/app/views/layouts/shiplog/application.html.erb +1 -0
  38. data/app/views/shiplog/_page_header.html.erb +2 -1
  39. data/app/views/shiplog/_switcher.html.erb +13 -0
  40. data/app/views/shiplog/admin/entries/_entry_row.html.erb +6 -5
  41. data/app/views/shiplog/admin/entries/edit.html.erb +12 -6
  42. data/app/views/shiplog/admin/entries/index.html.erb +4 -0
  43. data/app/views/shiplog/changelogs/_search.html.erb +16 -0
  44. data/app/views/shiplog/changelogs/show.html.erb +11 -3
  45. data/app/views/shiplog/roadmaps/_card.html.erb +12 -1
  46. data/app/views/shiplog/roadmaps/_column.html.erb +2 -2
  47. data/app/views/shiplog/roadmaps/_shipped.html.erb +15 -0
  48. data/app/views/shiplog/roadmaps/show.html.erb +9 -0
  49. data/config/locales/en.yml +28 -0
  50. data/db/migrate/20261027000001_create_shiplog_pull_request_upgrades.rb +30 -0
  51. data/lib/generators/shiplog/install/install_generator.rb +20 -1
  52. data/lib/generators/shiplog/install/templates/initializer.rb.tt +37 -0
  53. data/lib/shiplog/classes.rb +20 -1
  54. data/lib/shiplog/configuration.rb +155 -2
  55. data/lib/shiplog/github/client.rb +42 -9
  56. data/lib/shiplog/github/errors.rb +3 -0
  57. data/lib/shiplog/github/queries.rb +32 -1
  58. data/lib/shiplog/version.rb +1 -1
  59. metadata +12 -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
@@ -15,6 +19,18 @@ en:
15
19
  roadmap:
16
20
  title: Roadmap
17
21
  lede: What we're working on and what comes next.
22
+ parts:
23
+ one: Includes %{count} part
24
+ other: Includes %{count} parts
25
+ shipped_heading: Recently shipped
26
+ shipped_date_format: "%-d %B"
27
+ full_changelog: See the full changelog
28
+ footnote: ""
29
+ suggest: Suggest a feature
30
+ switcher:
31
+ label: Product updates
32
+ roadmap: Roadmap
33
+ changelog: Changelog
18
34
  changelog:
19
35
  title: Changelog
20
36
  lede: New features and improvements, as they ship.
@@ -22,6 +38,14 @@ en:
22
38
  label: Pagination
23
39
  older: Older
24
40
  newer: Newer
41
+ search:
42
+ label: Search the changelog
43
+ submit: Search
44
+ summary:
45
+ one: "%{count} result for “%{query}”"
46
+ other: "%{count} results for “%{query}”"
47
+ clear: Clear search
48
+ empty: "No entries match “%{query}”."
25
49
  admin:
26
50
  title: Shiplog admin
27
51
  nav:
@@ -56,6 +80,9 @@ en:
56
80
  public_text: Public text
57
81
  source: GitHub source
58
82
  back: Back to drafts
83
+ category_options:
84
+ feature: New feature
85
+ change: Improvement
59
86
  statuses:
60
87
  draft: Drafts
61
88
  approved: Approved
@@ -73,6 +100,7 @@ en:
73
100
  title: Title
74
101
  body: Text
75
102
  released_on: Released on
103
+ category: Category
76
104
  reason: Reason
77
105
  board:
78
106
  title: Roadmap board
@@ -0,0 +1,30 @@
1
+ class CreateShiplogPullRequestUpgrades < ActiveRecord::Migration[8.1]
2
+ def change
3
+ # What upgrade detection needs from the pull request, and its "checked" marker: a pull request with
4
+ # no tracked upgrade has no upgrade row, so only a column can say "inspected, nothing found".
5
+ change_table :shiplog_pull_requests do |t|
6
+ t.string :base_oid
7
+ t.string :merge_oid
8
+ t.boolean :lockfile_changed, null: false, default: false
9
+ t.datetime :upgrades_checked_at
10
+ # Transient failures while inspecting it; the detector gives up (marks it checked) after a few.
11
+ t.integer :upgrade_check_attempts, null: false, default: 0
12
+ end
13
+
14
+ create_table :shiplog_pull_request_upgrades do |t|
15
+ t.references :pull_request, null: false, foreign_key: { to_table: :shiplog_pull_requests }, index: false
16
+ t.string :gem_name, null: false
17
+ t.string :from_ref
18
+ t.string :to_ref
19
+ t.string :gem_repository
20
+ # Not `changes`: that name belongs to ActiveModel::Dirty and Active Record refuses it as an attribute.
21
+ t.jsonb :gem_changes, null: false, default: []
22
+ t.timestamps
23
+ t.index [ :pull_request_id, :gem_name ], unique: true
24
+ end
25
+
26
+ # The changelog and the roadmap's recently shipped list read approved entries newest release first:
27
+ # `status = 'approved' ... ORDER BY released_on DESC`. Equality column first, then the sort column.
28
+ add_index :shiplog_entries, [ :status, :released_on ]
29
+ end
30
+ end
@@ -17,9 +17,12 @@ module Shiplog
17
17
  page_header: "mb-8",
18
18
  page_title: "text-3xl font-semibold tracking-tight",
19
19
  page_lede: "mt-2 text-gray-600 dark:text-gray-400",
20
+ switcher: "mt-6 inline-flex gap-1 rounded-full bg-gray-100 p-1 text-sm font-medium dark:bg-gray-900",
21
+ switcher_link: "rounded-full px-4 py-1.5 text-gray-600 transition-colors hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 aria-[current=page]:bg-white aria-[current=page]:text-gray-900 aria-[current=page]:shadow-sm dark:text-gray-400 dark:hover:text-white dark:aria-[current=page]:bg-gray-700 dark:aria-[current=page]:text-white",
20
22
  columns: "grid gap-6 md:grid-cols-3",
21
23
  column: "rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-900/50",
22
24
  column_header: "mb-4 border-b border-gray-200 pb-3 dark:border-gray-800",
25
+ column_marker: "mr-2 inline-block h-2 w-2 rounded-full bg-gray-400 in-[.shiplog-column--in-progress]:bg-indigo-600 in-[.shiplog-column--coming-soon]:bg-indigo-300",
23
26
  column_title: "inline text-base font-semibold",
24
27
  column_count: "ml-2 rounded-full bg-gray-200 px-2 py-0.5 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300",
25
28
  column_description: "mt-1 text-sm text-gray-500 dark:text-gray-400",
@@ -28,6 +31,16 @@ module Shiplog
28
31
  card: "rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition hover:border-indigo-400 hover:shadow dark:border-gray-800 dark:bg-gray-900 dark:hover:border-indigo-500",
29
32
  card_title: "font-medium",
30
33
  card_body: "mt-1 text-sm text-gray-600 dark:text-gray-400",
34
+ card_meta: "mt-3 flex flex-wrap items-center gap-2 text-xs font-medium text-gray-500 dark:text-gray-400",
35
+ card_tag: "rounded-md bg-gray-100 px-2 py-0.5 text-gray-700 dark:bg-white/10 dark:text-gray-300",
36
+ shipped: "mt-12 grid grid-cols-[1fr_auto] items-baseline gap-x-6 gap-y-4 border-t border-gray-200 pt-8 dark:border-gray-800",
37
+ shipped_title: "text-lg font-semibold",
38
+ shipped_list: "col-span-2 grid gap-6 sm:grid-cols-2 lg:grid-cols-4",
39
+ shipped_item: "flex flex-col gap-1",
40
+ shipped_date: "text-xs tabular-nums text-gray-500 dark:text-gray-400",
41
+ shipped_link: "text-sm font-semibold text-gray-900 hover:text-indigo-600 dark:text-white dark:hover:text-indigo-400",
42
+ footnote: "mt-10 border-t border-gray-200 pt-6 text-center text-sm text-gray-500 dark:border-gray-800 dark:text-gray-400",
43
+ footnote_link: "font-medium text-indigo-600 hover:underline dark:text-indigo-400",
31
44
  months: "space-y-10",
32
45
  month: "border-l-2 border-gray-200 pl-6 dark:border-gray-800",
33
46
  month_title: "mb-4 text-lg font-semibold",
@@ -40,7 +53,13 @@ module Shiplog
40
53
  entry_date: "text-xs text-gray-500 dark:text-gray-400",
41
54
  pagination: "mt-8 flex justify-between",
42
55
  pagination_link: "text-sm font-medium text-indigo-600 hover:underline dark:text-indigo-400",
43
- empty: "text-gray-500 dark:text-gray-400"
56
+ empty: "text-gray-500 dark:text-gray-400",
57
+ search: "my-4 flex gap-2",
58
+ search_input: "min-w-0 flex-1 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-gray-700 dark:bg-gray-900",
59
+ search_button: "rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500",
60
+ search_summary: "mb-4 text-sm text-gray-500 dark:text-gray-400",
61
+ search_clear: "ml-2 font-medium text-indigo-600 hover:underline dark:text-indigo-400",
62
+ search_empty: "text-gray-500 dark:text-gray-400"
44
63
  }.freeze
45
64
 
46
65
  def copy_initializer
@@ -2,6 +2,21 @@ Shiplog.configure do |config|
2
2
  # GitHub repositories to sync ("owner/name"). Sub-issues may cross repositories of the same owner.
3
3
  config.repositories = %w[acme/app acme/app-ios]
4
4
 
5
+ # A repository can also be a Hash with options:
6
+ # changelog: false keep the repository on the roadmap, but never draft changelog entries from its PRs or commits
7
+ # gem: "name" the gem's name in your Gemfile.lock. When a merged PR upgrades it, the changelog gets one
8
+ # entry for the upgrade, built from the gem's changes. Needed because names differ
9
+ # (a "trackberry-produce" gem can live in acme/produce) and a public gem of the same name
10
+ # is not your repository.
11
+ # config.repositories = [
12
+ # "acme/app",
13
+ # { name: "acme/app-ios" },
14
+ # { name: "acme/pricing", changelog: false, gem: "acme-pricing" }
15
+ # ]
16
+
17
+ # Lockfile read in merged pull requests to detect gem upgrades (change it for a monorepo).
18
+ # config.lockfile_path = "Gemfile.lock"
19
+
5
20
  # A String, or a callable evaluated on every GitHub request (e.g. to mint a GitHub App installation token).
6
21
  config.github_token = -> { Rails.application.credentials.dig(:github, :shiplog_token) }
7
22
 
@@ -83,4 +98,26 @@ Shiplog.configure do |config|
83
98
  # Public paths, read when routes are drawn.
84
99
  # config.roadmap_path = "roadmap"
85
100
  # config.changelog_path = "changelog"
101
+
102
+ # Pages added to the Roadmap / Changelog switcher. label and path are Strings, or callables evaluated in the
103
+ # view (route helpers, I18n). Render the same switcher on your own pages with `helper Shiplog::SwitcherHelper`
104
+ # and `<%%= shiplog_switcher %>`.
105
+ # config.extra_pages = [ { label: "News", path: -> { main_app.news_path } } ]
106
+
107
+ # Names shown on roadmap cards for the repository an item lives in (Strings or callables).
108
+ # config.repository_labels = { "acme/app-ios" => "iPhone & iPad" }
109
+
110
+ # How many recently shipped changelog entries the roadmap lists; 0 hides the section.
111
+ # config.roadmap_shipped_count = 4
112
+
113
+ # Where the roadmap's "Suggest a feature" link goes (String or callable); no link while nil. Add a footnote
114
+ # next to it with the shiplog.roadmap.footnote translation.
115
+ # config.feedback_path = -> { main_app.new_feedback_path }
116
+
117
+ # The public changelog's search box and ?q= filtering. Set to false to turn search off.
118
+ # config.changelog_search = true
119
+
120
+ # Callables above cannot be part of the public pages' ETag. Bump this when the output of a callable you
121
+ # configured changes (a new label, another path) so cached pages are refreshed.
122
+ # config.cache_version = nil
86
123
  end
@@ -7,9 +7,12 @@ module Shiplog
7
7
  page_header: "shiplog-page__header",
8
8
  page_title: "shiplog-page__title",
9
9
  page_lede: "shiplog-page__lede",
10
+ switcher: "shiplog-switcher",
11
+ switcher_link: "shiplog-switcher__link",
10
12
  columns: "shiplog-columns",
11
13
  column: "shiplog-column",
12
14
  column_header: "shiplog-column__header",
15
+ column_marker: "shiplog-column__marker",
13
16
  column_title: "shiplog-column__title",
14
17
  column_count: "shiplog-column__count",
15
18
  column_description: "shiplog-column__description",
@@ -18,6 +21,16 @@ module Shiplog
18
21
  card: "shiplog-card",
19
22
  card_title: "shiplog-card__title",
20
23
  card_body: "shiplog-card__body",
24
+ card_meta: "shiplog-card__meta",
25
+ card_tag: "shiplog-card__tag",
26
+ shipped: "shiplog-shipped",
27
+ shipped_title: "shiplog-shipped__title",
28
+ shipped_list: "shiplog-shipped__list",
29
+ shipped_item: "shiplog-shipped__item",
30
+ shipped_date: "shiplog-shipped__date",
31
+ shipped_link: "shiplog-shipped__link",
32
+ footnote: "shiplog-footnote",
33
+ footnote_link: "shiplog-footnote__link",
21
34
  months: "shiplog-months",
22
35
  month: "shiplog-month",
23
36
  month_title: "shiplog-month__title",
@@ -30,7 +43,13 @@ module Shiplog
30
43
  entry_date: "shiplog-entry__date",
31
44
  pagination: "shiplog-pagination",
32
45
  pagination_link: "shiplog-pagination__link",
33
- empty: "shiplog-empty"
46
+ empty: "shiplog-empty",
47
+ search: "shiplog-search",
48
+ search_input: "shiplog-search__input",
49
+ search_button: "shiplog-search__button",
50
+ search_summary: "shiplog-search__summary",
51
+ search_clear: "shiplog-search__clear",
52
+ search_empty: "shiplog-search__empty"
34
53
  }.freeze
35
54
 
36
55
  KEYS = BEM.keys.freeze
@@ -1,6 +1,6 @@
1
1
  module Shiplog
2
2
  class Configuration
3
- attr_accessor :repositories, :github_token, :webhook_secret,
3
+ attr_accessor :github_token, :webhook_secret,
4
4
  :public_label, :soon_labels, :excluded_labels, :excluded_authors, :commit_types,
5
5
  :rewriter, :product_name, :audience, :tone, :prompt_builder,
6
6
  :public_layout, :admin_layout,
@@ -8,9 +8,90 @@ module Shiplog
8
8
  :roadmap_path, :changelog_path
9
9
 
10
10
  ADMIN_CHROMES = %i[standalone hosted].freeze
11
+ # Segments of only dots ("." and "..") are path traversal, not names.
12
+ REPOSITORY_NAME = %r{\A(?!\.+/)[\w.-]+/(?!\.+\z)[\w.-]+\z}
13
+ REPOSITORY_OPTION_KEYS = %i[name changelog gem].freeze
14
+
15
+ attr_reader :repositories
16
+ # Path of the lockfile read in merged pull requests to detect gem upgrades (see config.repositories `gem:`).
17
+ attr_accessor :lockfile_path
11
18
 
12
19
  attr_reader :admin_chrome, :classes, :public_parent_controller, :admin_parent_controller
13
20
 
21
+ # Product updates navigation and roadmap options.
22
+ attr_accessor :feedback_path
23
+ # Whether the public changelog has a search box and honours ?q= (default true).
24
+ attr_accessor :changelog_search
25
+ # Bump it when the output of a callable you configured changes (extra_pages, repository_labels, feedback_path):
26
+ # callables cannot be part of the ETag, so this is how cached public pages learn about it.
27
+ attr_accessor :cache_version
28
+ attr_reader :extra_pages, :repository_labels, :roadmap_shipped_count
29
+
30
+ EXTRA_PAGE_KEYS = %i[label path].freeze
31
+
32
+ # A path (not "//host", "/\host" or "/ x", which browsers read as other targets), an http(s) URL or mailto:.
33
+ SAFE_URL = %r{\A(?:/(?![/\\\s])|https?://|mailto:)}i
34
+
35
+ # Whether a link target is safe to render: a path, an http(s) URL or a mailto: address.
36
+ def self.safe_url?(value)
37
+ value.is_a?(String) && SAFE_URL.match?(value.strip) && !value.match?(/[[:cntrl:]]/)
38
+ end
39
+
40
+ # A digest of everything configured that changes the public markup, stable across processes and servers
41
+ # (no Object#hash). Callables are represented by a marker; see cache_version.
42
+ def render_digest
43
+ stable = ->(value) { value.respond_to?(:call) ? "callable" : value }
44
+ data = [
45
+ classes.sort_by { |key, _| key.to_s }.map { |key, value| [ key.to_s, Array(value).map(&:to_s) ] },
46
+ stylesheet, roadmap_shipped_count, cache_version,
47
+ extra_pages.map { |page| [ stable.(page[:label]), stable.(page[:path]) ] },
48
+ repository_labels.sort_by { |name, _| name.to_s }.map { |name, label| [ name, stable.(label) ] },
49
+ stable.(feedback_path), changelog_search
50
+ ]
51
+ Digest::SHA256.hexdigest(JSON.generate(data))
52
+ end
53
+
54
+ # Pages added to the switcher after Roadmap and Changelog: [{ label:, path: }]. Each is a String, or a callable
55
+ # evaluated in the view (instance_exec), e.g. `path: -> { main_app.news_path }`.
56
+ def extra_pages=(value)
57
+ raise ArgumentError, "extra_pages must be an Array of { label:, path: } Hashes, got #{value.inspect}" unless value.nil? || value.is_a?(Array)
58
+ pages = (value || []).map do |page|
59
+ raise ArgumentError, "Each extra page must be a Hash with :label and :path, got #{page.inspect}" unless page.is_a?(Hash)
60
+ page = page.to_h.transform_keys { |key| key.to_s.to_sym }
61
+ unless page.keys.sort == EXTRA_PAGE_KEYS
62
+ raise ArgumentError, "An extra page needs exactly the keys :label and :path, got #{page.keys.inspect}"
63
+ end
64
+ page.each do |key, item|
65
+ valid = item.respond_to?(:call) || (item.is_a?(String) && item.present?)
66
+ raise ArgumentError, "Extra page #{key} must be a non-empty String or a callable, got #{item.inspect}" unless valid
67
+ if key == :path && item.is_a?(String) && !self.class.safe_url?(item)
68
+ raise ArgumentError, "Extra page path must be relative (/...), http(s):// or mailto:, got #{item.inspect}"
69
+ end
70
+ end
71
+ { label: page[:label], path: page[:path] }.freeze
72
+ end
73
+ @extra_pages = pages.freeze
74
+ end
75
+
76
+ # Display names for repositories on roadmap cards: { "owner/name" => "iPhone & iPad" }. Values are Strings or
77
+ # callables evaluated in the view.
78
+ def repository_labels=(value)
79
+ labels = value.nil? ? {} : value
80
+ raise ArgumentError, "repository_labels must be a Hash, got #{value.inspect}" unless labels.is_a?(Hash)
81
+ labels.each do |name, label|
82
+ unless label.respond_to?(:call) || label.is_a?(String)
83
+ raise ArgumentError, "repository_labels[#{name.inspect}] must be a String or a callable, got #{label.inspect}"
84
+ end
85
+ end
86
+ @repository_labels = labels.transform_keys(&:to_s).freeze
87
+ end
88
+
89
+ # How many recently shipped changelog entries the roadmap lists; 0 hides the section.
90
+ def roadmap_shipped_count=(value)
91
+ raise ArgumentError, "roadmap_shipped_count must be a non-negative Integer, got #{value.inspect}" unless value.is_a?(Integer) && value >= 0
92
+ @roadmap_shipped_count = value
93
+ end
94
+
14
95
  # :standalone (default) wraps the admin in Shiplog's own page chrome; :hosted renders it inside the
15
96
  # layout of config.admin_layout (compact sub-nav, no page chrome of its own).
16
97
  def admin_chrome=(value)
@@ -44,8 +125,17 @@ module Shiplog
44
125
  @admin_parent_controller = value
45
126
  end
46
127
 
128
+ # Strings ("owner/name") or Hashes: { name:, changelog: true, gem: nil }. `changelog: false` keeps a repository
129
+ # on the roadmap but never drafts changelog entries from its pull requests or commits. `gem:` is the gem's
130
+ # name in the host's lockfile.
131
+ def repositories=(value)
132
+ warn_double_drafting(normalize_repositories(value))
133
+ @repositories = value
134
+ end
135
+
47
136
  def initialize
48
137
  @repositories = []
138
+ @lockfile_path = "Gemfile.lock"
49
139
  @public_label = "public"
50
140
  @soon_labels = %w[soon]
51
141
  @excluded_labels = %w[internal dependencies refactor chore]
@@ -64,6 +154,10 @@ module Shiplog
64
154
  @current_admin = ->(controller) { controller.try(:current_user)&.try(:email) }
65
155
  @roadmap_path = "roadmap"
66
156
  @changelog_path = "changelog"
157
+ @extra_pages = [].freeze
158
+ @repository_labels = {}.freeze
159
+ @roadmap_shipped_count = 4
160
+ @changelog_search = true
67
161
  end
68
162
 
69
163
  def resolved_github_token
@@ -73,7 +167,27 @@ module Shiplog
73
167
  # The configured spelling of a repository name (matched case-insensitively), or nil.
74
168
  def repository_configured?(name)
75
169
  return if name.blank?
76
- repositories.find { |configured| configured.casecmp?(name.to_s) }
170
+ repository_names.find { |configured| configured.casecmp?(name.to_s) }
171
+ end
172
+
173
+ # Normalised options of every configured repository: [{ name:, changelog:, gem: }].
174
+ def repository_entries = normalize_repositories(repositories)
175
+
176
+ def repository_names = repository_entries.map { |entry| entry[:name] }
177
+
178
+ def repository_options(name)
179
+ return if name.blank?
180
+ repository_entries.find { |entry| entry[:name].casecmp?(name.to_s) }
181
+ end
182
+
183
+ # False for roadmap-only repositories. Unknown repositories count as changelog sources.
184
+ def changelog_repository?(name)
185
+ repository_options(name)&.fetch(:changelog) != false
186
+ end
187
+
188
+ # { "gem name in the lockfile" => "owner/name" } for repositories with `gem:`.
189
+ def tracked_gems
190
+ repository_entries.select { |entry| entry[:gem] }.to_h { |entry| [ entry[:gem], entry[:name] ] }
77
191
  end
78
192
 
79
193
  def excluded_author?(login)
@@ -82,6 +196,45 @@ module Shiplog
82
196
 
83
197
  private
84
198
 
199
+ def normalize_repositories(value)
200
+ raise ArgumentError, "config.repositories must be an Array, got #{value.inspect}" unless value.is_a?(Array)
201
+
202
+ value.map { |item| normalize_repository(item) }
203
+ end
204
+
205
+ # A gem repository that also drafts its own changelog entries announces each change twice: once from its own
206
+ # pull requests and once from the host's upgrade pull request.
207
+ def warn_double_drafting(entries)
208
+ entries.select { |entry| entry[:gem] && entry[:changelog] }.each do |entry|
209
+ Rails.logger&.warn("[shiplog] Repository #{entry[:name]} has gem: #{entry[:gem].inspect} and changelog: true, so its changes " \
210
+ "are drafted twice (from its own pull requests and from the host's upgrade pull request). Set changelog: false.")
211
+ end
212
+ end
213
+
214
+ def normalize_repository(item)
215
+ return { name: valid_repository_name!(item), changelog: true, gem: nil } if item.is_a?(String) && item.present?
216
+ raise ArgumentError, "Invalid repository #{item.inspect}: expected \"owner/name\" or a Hash" unless item.is_a?(Hash)
217
+
218
+ options = item.transform_keys { |key| key.to_s.to_sym }
219
+ unknown = options.keys - REPOSITORY_OPTION_KEYS
220
+ raise ArgumentError, "Unknown repository option(s) #{unknown.map(&:inspect).join(", ")}. Valid: #{REPOSITORY_OPTION_KEYS.map(&:inspect).join(", ")}" if unknown.any?
221
+
222
+ name, changelog, gem = options.values_at(:name, :changelog, :gem)
223
+ changelog = true if changelog.nil?
224
+ raise ArgumentError, "Repository #{item.inspect} needs a :name (\"owner/name\")" unless name.is_a?(String) && name.present?
225
+ valid_repository_name!(name)
226
+ raise ArgumentError, "Repository #{name}: changelog must be true or false" unless [ true, false ].include?(changelog)
227
+ raise ArgumentError, "Repository #{name}: gem must be a non-empty String" unless gem.nil? || (gem.is_a?(String) && gem.present?)
228
+
229
+ { name:, changelog:, gem: }
230
+ end
231
+
232
+ def valid_repository_name!(name)
233
+ raise ArgumentError, "Invalid repository name #{name.inspect}: expected \"owner/name\"" unless REPOSITORY_NAME.match?(name)
234
+
235
+ name
236
+ end
237
+
85
238
  def ensure_controller_not_loaded!(setting, namespace, name)
86
239
  return unless namespace.const_defined?(name, false) && !namespace.autoload?(name)
87
240
 
@@ -6,6 +6,7 @@ module Shiplog
6
6
  module Github
7
7
  class Client
8
8
  ENDPOINT = URI("https://api.github.com/graphql")
9
+ REST_ROOT = "https://api.github.com".freeze
9
10
  RETRYABLE_STATUSES = [ 502, 503, 504 ].freeze
10
11
  NETWORK_ERRORS = [
11
12
  Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, Errno::ECONNREFUSED,
@@ -25,40 +26,72 @@ module Shiplog
25
26
  handle(response)
26
27
  end
27
28
 
29
+ # REST GET (e.g. "/repos/owner/name/compare/a...b") with the same auth, errors and rate-limit
30
+ # handling as #query. Returns the parsed JSON body; a 404 raises NotFound.
31
+ def get(path)
32
+ raise Error, "No GitHub token configured (Shiplog.config.github_token)" if @token.blank?
33
+
34
+ uri = URI("#{REST_ROOT}#{path}")
35
+ response = perform(retried: false) { build_get(uri) }
36
+ check_status(response)
37
+ JSON.parse(response.body)
38
+ rescue JSON::ParserError
39
+ raise Error, "GitHub returned an unparseable response"
40
+ end
41
+
28
42
  private
29
43
 
30
- def post(payload, retried: false)
31
- response = request(payload)
44
+ def post(payload) = perform(retried: false) { build_request(payload) }
45
+
46
+ def perform(retried:, &build)
47
+ response = request(build.call)
32
48
  return response unless RETRYABLE_STATUSES.include?(response.code.to_i) && !retried
33
49
 
34
50
  sleep @retry_delay
35
- post(payload, retried: true)
51
+ perform(retried: true, &build)
36
52
  end
37
53
 
38
- def request(payload)
39
- http = Net::HTTP.new(ENDPOINT.host, ENDPOINT.port)
54
+ def request(http_request)
55
+ uri = http_request.uri
56
+ http = Net::HTTP.new(uri.host, uri.port)
40
57
  http.use_ssl = true
41
58
  http.open_timeout = 10
42
59
  http.read_timeout = 30
43
- http.request(build_request(payload))
60
+ http.request(http_request)
44
61
  rescue *NETWORK_ERRORS => error
45
62
  raise Transient, "#{error.class}: #{error.message}"
46
63
  end
47
64
 
48
65
  def build_request(payload)
49
66
  Net::HTTP::Post.new(ENDPOINT).tap do |req|
50
- req["Authorization"] = "Bearer #{@token}"
67
+ authorize(req)
51
68
  req["Content-Type"] = "application/json"
52
- req["User-Agent"] = "shiplog/#{Shiplog::VERSION}"
53
69
  req.body = payload
54
70
  end
55
71
  end
56
72
 
57
- def handle(response)
73
+ def build_get(uri)
74
+ Net::HTTP::Get.new(uri).tap do |req|
75
+ authorize(req)
76
+ req["Accept"] = "application/vnd.github+json"
77
+ end
78
+ end
79
+
80
+ def authorize(req)
81
+ req["Authorization"] = "Bearer #{@token}"
82
+ req["User-Agent"] = "shiplog/#{Shiplog::VERSION}"
83
+ end
84
+
85
+ def check_status(response)
58
86
  status = response.code.to_i
59
87
  raise_rate_limited(response) if rate_limited?(response, status)
60
88
  raise Transient, "GitHub responded with HTTP #{status}" if status >= 500
89
+ raise NotFound, "GitHub responded with HTTP 404" if status == 404
61
90
  raise Error, "GitHub responded with HTTP #{status}" unless status.between?(200, 299)
91
+ end
92
+
93
+ def handle(response)
94
+ check_status(response)
62
95
 
63
96
  body = JSON.parse(response.body)
64
97
  errors = body["errors"].to_a
@@ -5,6 +5,9 @@ module Shiplog
5
5
  # Network failures and 5xx responses; safe to retry later.
6
6
  class Transient < Error; end
7
7
 
8
+ # HTTP 404: the resource does not exist, or the token cannot see it.
9
+ class NotFound < Error; end
10
+
8
11
  class RateLimited < Error
9
12
  attr_reader :reset_at
10
13
 
@@ -27,6 +27,9 @@ module Shiplog
27
27
  PULL_REQUEST_WITH_ISSUES = <<~GRAPHQL.freeze
28
28
  fragment PullRequestWithIssues on PullRequest {
29
29
  ...PullRequestFields
30
+ baseRefOid
31
+ mergeCommit { oid parents(first: 1) { nodes { oid } } }
32
+ files(first: 100) { pageInfo { hasNextPage } nodes { path } }
30
33
  closingIssuesReferences(first: 20) {
31
34
  pageInfo { hasNextPage }
32
35
  nodes { id }
@@ -102,7 +105,35 @@ module Shiplog
102
105
  }
103
106
  GRAPHQL
104
107
 
105
- ALL = { ISSUES:, PULL_REQUESTS:, ISSUE:, PULL_REQUEST:, COMMITS: }.freeze
108
+ # A file's text at a commit (`$expression` is "<oid>:<path>"); `object` is null when the file is absent.
109
+ BLOB_TEXT = <<~GRAPHQL.freeze
110
+ query($owner: String!, $name: String!, $expression: String!) {
111
+ repository(owner: $owner, name: $name) {
112
+ object(expression: $expression) {
113
+ ... on Blob { text isTruncated }
114
+ }
115
+ }
116
+ }
117
+ GRAPHQL
118
+
119
+ # The pull requests that introduced each commit (`$ids` are commit node ids, at most 100).
120
+ COMMIT_PULL_REQUESTS = <<~GRAPHQL.freeze
121
+ query($ids: [ID!]!) {
122
+ nodes(ids: $ids) {
123
+ ... on Commit {
124
+ oid
125
+ associatedPullRequests(first: 5) {
126
+ nodes {
127
+ number title body url state
128
+ repository { nameWithOwner }
129
+ }
130
+ }
131
+ }
132
+ }
133
+ }
134
+ GRAPHQL
135
+
136
+ ALL = { ISSUES:, PULL_REQUESTS:, ISSUE:, PULL_REQUEST:, COMMITS:, BLOB_TEXT:, COMMIT_PULL_REQUESTS: }.freeze
106
137
  end
107
138
  end
108
139
  end
@@ -1,3 +1,3 @@
1
1
  module Shiplog
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
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.2.0
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,12 +55,16 @@ 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
62
+ - app/helpers/shiplog/callable_helper.rb
60
63
  - app/helpers/shiplog/frontend_helper.rb
61
64
  - app/helpers/shiplog/host_routes_helper.rb
65
+ - app/helpers/shiplog/roadmap_helper.rb
62
66
  - app/helpers/shiplog/styling_helper.rb
67
+ - app/helpers/shiplog/switcher_helper.rb
63
68
  - app/jobs/shiplog/application_job.rb
64
69
  - app/jobs/shiplog/refresh_job.rb
65
70
  - app/jobs/shiplog/rewrite_entry_job.rb
@@ -75,6 +80,7 @@ files:
75
80
  - app/models/shiplog/issue_form.rb
76
81
  - app/models/shiplog/issue_link.rb
77
82
  - app/models/shiplog/pull_request.rb
83
+ - app/models/shiplog/pull_request_upgrade.rb
78
84
  - app/models/shiplog/repository.rb
79
85
  - app/models/shiplog/rewriter.rb
80
86
  - app/models/shiplog/rewriter/prompt.rb
@@ -85,6 +91,7 @@ files:
85
91
  - app/models/shiplog/sync/node_attributes.rb
86
92
  - app/models/shiplog/sync/pull_request_importer.rb
87
93
  - app/models/shiplog/sync/repository_sync.rb
94
+ - app/models/shiplog/sync/upgrade_detector.rb
88
95
  - app/models/shiplog/webhook_delivery.rb
89
96
  - app/models/shiplog/webhook_signature.rb
90
97
  - app/views/layouts/shiplog/admin.html.erb
@@ -93,6 +100,7 @@ files:
93
100
  - app/views/shiplog/_page.html.erb
94
101
  - app/views/shiplog/_page_header.html.erb
95
102
  - app/views/shiplog/_pagination.html.erb
103
+ - app/views/shiplog/_switcher.html.erb
96
104
  - app/views/shiplog/admin/_assets.html.erb
97
105
  - app/views/shiplog/admin/_custom_head.html.erb
98
106
  - app/views/shiplog/admin/_nav.html.erb
@@ -106,14 +114,17 @@ files:
106
114
  - app/views/shiplog/admin/syncs/show.html.erb
107
115
  - app/views/shiplog/changelogs/_entry.html.erb
108
116
  - app/views/shiplog/changelogs/_month.html.erb
117
+ - app/views/shiplog/changelogs/_search.html.erb
109
118
  - app/views/shiplog/changelogs/show.html.erb
110
119
  - app/views/shiplog/roadmaps/_card.html.erb
111
120
  - app/views/shiplog/roadmaps/_column.html.erb
121
+ - app/views/shiplog/roadmaps/_shipped.html.erb
112
122
  - app/views/shiplog/roadmaps/show.html.erb
113
123
  - config/admin_routes.rb
114
124
  - config/locales/en.yml
115
125
  - config/routes.rb
116
126
  - db/migrate/20261026000001_create_shiplog_tables.rb
127
+ - db/migrate/20261027000001_create_shiplog_pull_request_upgrades.rb
117
128
  - lib/generators/shiplog/install/install_generator.rb
118
129
  - lib/generators/shiplog/install/templates/initializer.rb.tt
119
130
  - lib/generators/shiplog/install/templates/shiplog-feature.yml