shiplog 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -0
- data/README.md +127 -5
- data/app/controllers/shiplog/admin/application_controller.rb +3 -1
- data/app/controllers/shiplog/admin/entries/approvals_controller.rb +2 -2
- data/app/controllers/shiplog/admin/entries/discards_controller.rb +2 -2
- data/app/controllers/shiplog/admin/entries/rewrites_controller.rb +1 -1
- data/app/controllers/shiplog/admin/entries_controller.rb +8 -4
- data/app/controllers/shiplog/admin/inbox_filters.rb +55 -0
- data/app/controllers/shiplog/admin/syncs_controller.rb +3 -2
- data/app/controllers/shiplog/changelogs_controller.rb +11 -1
- data/app/controllers/shiplog/frontends_controller.rb +2 -1
- data/app/controllers/shiplog/public_controller.rb +3 -1
- data/app/controllers/shiplog/roadmaps_controller.rb +25 -2
- data/app/frontend/shiplog/admin.css +7 -0
- data/app/frontend/shiplog/board.js +8 -2
- data/app/frontend/shiplog/inbox.js +78 -0
- data/app/frontend/shiplog/shiplog.css +121 -6
- data/app/helpers/shiplog/callable_helper.rb +12 -0
- data/app/helpers/shiplog/frontend_helper.rb +1 -0
- data/app/helpers/shiplog/host_routes_helper.rb +37 -0
- data/app/helpers/shiplog/roadmap_helper.rb +15 -0
- data/app/helpers/shiplog/switcher_helper.rb +101 -0
- data/app/jobs/shiplog/refresh_job.rb +1 -0
- data/app/jobs/shiplog/scan_changes_job.rb +19 -5
- data/app/jobs/shiplog/sync_job.rb +1 -1
- data/app/models/shiplog/change_candidates.rb +43 -6
- data/app/models/shiplog/drafter.rb +1 -1
- data/app/models/shiplog/entry.rb +41 -0
- data/app/models/shiplog/pull_request.rb +4 -0
- data/app/models/shiplog/pull_request_upgrade.rb +5 -0
- data/app/models/shiplog/rewriter/prompt.rb +37 -3
- data/app/models/shiplog/rewriter.rb +20 -1
- data/app/models/shiplog/sync/pull_request_importer.rb +23 -1
- data/app/models/shiplog/sync/repository_sync.rb +2 -0
- data/app/models/shiplog/sync/upgrade_detector.rb +203 -0
- data/app/views/layouts/shiplog/application.html.erb +1 -0
- data/app/views/shiplog/_page_header.html.erb +2 -1
- data/app/views/shiplog/_switcher.html.erb +13 -0
- data/app/views/shiplog/admin/entries/_entry_row.html.erb +6 -5
- data/app/views/shiplog/admin/entries/edit.html.erb +12 -6
- data/app/views/shiplog/admin/entries/index.html.erb +4 -0
- data/app/views/shiplog/changelogs/_search.html.erb +16 -0
- data/app/views/shiplog/changelogs/show.html.erb +11 -3
- data/app/views/shiplog/roadmaps/_card.html.erb +12 -1
- data/app/views/shiplog/roadmaps/_column.html.erb +2 -2
- data/app/views/shiplog/roadmaps/_shipped.html.erb +15 -0
- data/app/views/shiplog/roadmaps/show.html.erb +9 -0
- data/config/locales/en.yml +28 -0
- data/db/migrate/20261027000001_create_shiplog_pull_request_upgrades.rb +30 -0
- data/lib/generators/shiplog/install/install_generator.rb +20 -1
- data/lib/generators/shiplog/install/templates/initializer.rb.tt +37 -0
- data/lib/shiplog/classes.rb +20 -1
- data/lib/shiplog/configuration.rb +155 -2
- data/lib/shiplog/github/client.rb +42 -9
- data/lib/shiplog/github/errors.rb +3 -0
- data/lib/shiplog/github/queries.rb +32 -1
- data/lib/shiplog/version.rb +1 -1
- metadata +12 -1
data/config/locales/en.yml
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
en:
|
|
2
|
+
activerecord:
|
|
3
|
+
attributes:
|
|
4
|
+
shiplog/entry:
|
|
5
|
+
changelog_kind: Category
|
|
2
6
|
shiplog:
|
|
3
7
|
columns:
|
|
4
8
|
in_progress: In progress
|
|
@@ -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
|
data/lib/shiplog/classes.rb
CHANGED
|
@@ -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 :
|
|
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
|
-
|
|
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
|
|
31
|
-
|
|
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
|
-
|
|
51
|
+
perform(retried: true, &build)
|
|
36
52
|
end
|
|
37
53
|
|
|
38
|
-
def request(
|
|
39
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/shiplog/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: shiplog
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
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
|