shiplog 0.1.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 +7 -0
- data/CHANGELOG.md +15 -0
- data/MIT-LICENSE +20 -0
- data/README.md +410 -0
- data/app/controllers/shiplog/admin/application_controller.rb +37 -0
- data/app/controllers/shiplog/admin/boards_controller.rb +60 -0
- data/app/controllers/shiplog/admin/entries/approvals_controller.rb +15 -0
- data/app/controllers/shiplog/admin/entries/discards_controller.rb +25 -0
- data/app/controllers/shiplog/admin/entries/rewrites_controller.rb +13 -0
- data/app/controllers/shiplog/admin/entries_controller.rb +55 -0
- data/app/controllers/shiplog/admin/syncs_controller.rb +21 -0
- data/app/controllers/shiplog/application_controller.rb +5 -0
- data/app/controllers/shiplog/changelogs_controller.rb +24 -0
- data/app/controllers/shiplog/frontends_controller.rb +21 -0
- data/app/controllers/shiplog/public_controller.rb +19 -0
- data/app/controllers/shiplog/roadmaps_controller.rb +14 -0
- data/app/controllers/shiplog/webhooks_controller.rb +121 -0
- data/app/frontend/shiplog/admin.css +137 -0
- data/app/frontend/shiplog/board.js +77 -0
- data/app/frontend/shiplog/shiplog.css +184 -0
- data/app/helpers/shiplog/admin/entries_helper.rb +27 -0
- data/app/helpers/shiplog/application_helper.rb +14 -0
- data/app/helpers/shiplog/frontend_helper.rb +17 -0
- data/app/helpers/shiplog/host_routes_helper.rb +25 -0
- data/app/helpers/shiplog/styling_helper.rb +24 -0
- data/app/jobs/shiplog/application_job.rb +14 -0
- data/app/jobs/shiplog/refresh_job.rb +38 -0
- data/app/jobs/shiplog/rewrite_entry_job.rb +9 -0
- data/app/jobs/shiplog/scan_changes_job.rb +36 -0
- data/app/jobs/shiplog/sync_job.rb +20 -0
- data/app/models/shiplog/application_record.rb +5 -0
- data/app/models/shiplog/board_arrangement.rb +38 -0
- data/app/models/shiplog/change_candidates.rb +77 -0
- data/app/models/shiplog/commit.rb +9 -0
- data/app/models/shiplog/drafter.rb +69 -0
- data/app/models/shiplog/entry.rb +42 -0
- data/app/models/shiplog/issue.rb +68 -0
- data/app/models/shiplog/issue_form.rb +43 -0
- data/app/models/shiplog/issue_link.rb +6 -0
- data/app/models/shiplog/pull_request.rb +11 -0
- data/app/models/shiplog/repository.rb +9 -0
- data/app/models/shiplog/rewriter/prompt.rb +71 -0
- data/app/models/shiplog/rewriter.rb +89 -0
- data/app/models/shiplog/roadmap/column_resolver.rb +46 -0
- data/app/models/shiplog/roadmap.rb +34 -0
- data/app/models/shiplog/sync/commit_scanner.rb +39 -0
- data/app/models/shiplog/sync/issue_importer.rb +50 -0
- data/app/models/shiplog/sync/node_attributes.rb +50 -0
- data/app/models/shiplog/sync/pull_request_importer.rb +54 -0
- data/app/models/shiplog/sync/repository_sync.rb +80 -0
- data/app/models/shiplog/webhook_delivery.rb +5 -0
- data/app/models/shiplog/webhook_signature.rb +16 -0
- data/app/views/layouts/shiplog/admin.html.erb +16 -0
- data/app/views/layouts/shiplog/application.html.erb +15 -0
- data/app/views/shiplog/_empty.html.erb +3 -0
- data/app/views/shiplog/_page.html.erb +6 -0
- data/app/views/shiplog/_page_header.html.erb +6 -0
- data/app/views/shiplog/_pagination.html.erb +12 -0
- data/app/views/shiplog/admin/_assets.html.erb +3 -0
- data/app/views/shiplog/admin/_custom_head.html.erb +0 -0
- data/app/views/shiplog/admin/_nav.html.erb +9 -0
- data/app/views/shiplog/admin/_page.html.erb +14 -0
- data/app/views/shiplog/admin/boards/_card.html.erb +22 -0
- data/app/views/shiplog/admin/boards/show.html.erb +17 -0
- data/app/views/shiplog/admin/entries/_badges.html.erb +10 -0
- data/app/views/shiplog/admin/entries/_entry_row.html.erb +21 -0
- data/app/views/shiplog/admin/entries/edit.html.erb +61 -0
- data/app/views/shiplog/admin/entries/index.html.erb +47 -0
- data/app/views/shiplog/admin/syncs/show.html.erb +71 -0
- data/app/views/shiplog/changelogs/_entry.html.erb +8 -0
- data/app/views/shiplog/changelogs/_month.html.erb +9 -0
- data/app/views/shiplog/changelogs/show.html.erb +16 -0
- data/app/views/shiplog/roadmaps/_card.html.erb +6 -0
- data/app/views/shiplog/roadmaps/_column.html.erb +17 -0
- data/app/views/shiplog/roadmaps/show.html.erb +9 -0
- data/config/admin_routes.rb +13 -0
- data/config/locales/en.yml +106 -0
- data/config/routes.rb +6 -0
- data/db/migrate/20261026000001_create_shiplog_tables.rb +102 -0
- data/lib/generators/shiplog/install/install_generator.rb +94 -0
- data/lib/generators/shiplog/install/templates/initializer.rb.tt +86 -0
- data/lib/generators/shiplog/install/templates/shiplog-feature.yml +23 -0
- data/lib/shiplog/admin/engine.rb +9 -0
- data/lib/shiplog/classes.rb +38 -0
- data/lib/shiplog/configuration.rb +92 -0
- data/lib/shiplog/engine.rb +5 -0
- data/lib/shiplog/github/client.rb +96 -0
- data/lib/shiplog/github/errors.rb +17 -0
- data/lib/shiplog/github/queries.rb +108 -0
- data/lib/shiplog/version.rb +3 -0
- data/lib/shiplog.rb +16 -0
- metadata +154 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
class CreateShiplogTables < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :shiplog_repositories do |t|
|
|
4
|
+
t.string :full_name, null: false
|
|
5
|
+
t.datetime :synced_at
|
|
6
|
+
t.datetime :sync_cursor
|
|
7
|
+
t.datetime :changes_scanned_at
|
|
8
|
+
t.text :last_error
|
|
9
|
+
t.timestamps
|
|
10
|
+
t.index :full_name, unique: true
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
create_table :shiplog_issues do |t|
|
|
14
|
+
t.references :repository, null: false, foreign_key: { to_table: :shiplog_repositories }
|
|
15
|
+
t.string :node_id, null: false
|
|
16
|
+
t.integer :number, null: false
|
|
17
|
+
t.string :title, null: false
|
|
18
|
+
t.text :body
|
|
19
|
+
t.string :state, null: false, default: "open"
|
|
20
|
+
t.string :state_reason
|
|
21
|
+
t.jsonb :labels, null: false, default: []
|
|
22
|
+
t.string :milestone_title
|
|
23
|
+
t.integer :assignees_count, null: false, default: 0
|
|
24
|
+
t.string :parent_node_id
|
|
25
|
+
t.string :url
|
|
26
|
+
t.datetime :github_created_at
|
|
27
|
+
t.datetime :github_updated_at
|
|
28
|
+
t.datetime :closed_at
|
|
29
|
+
t.string :pinned_column
|
|
30
|
+
t.integer :position
|
|
31
|
+
t.timestamps
|
|
32
|
+
t.index :node_id, unique: true
|
|
33
|
+
t.index :parent_node_id
|
|
34
|
+
t.index :labels, using: :gin
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
create_table :shiplog_pull_requests do |t|
|
|
38
|
+
t.references :repository, null: false, foreign_key: { to_table: :shiplog_repositories }
|
|
39
|
+
t.string :node_id, null: false
|
|
40
|
+
t.integer :number, null: false
|
|
41
|
+
t.string :title, null: false
|
|
42
|
+
t.text :body
|
|
43
|
+
t.string :state, null: false, default: "open"
|
|
44
|
+
t.datetime :merged_at
|
|
45
|
+
t.string :author_login
|
|
46
|
+
t.jsonb :labels, null: false, default: []
|
|
47
|
+
t.string :url
|
|
48
|
+
t.datetime :github_updated_at
|
|
49
|
+
t.timestamps
|
|
50
|
+
t.index :node_id, unique: true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
create_table :shiplog_issue_links do |t|
|
|
54
|
+
t.string :issue_node_id, null: false
|
|
55
|
+
t.string :pull_request_node_id, null: false
|
|
56
|
+
t.timestamps
|
|
57
|
+
t.index [ :issue_node_id, :pull_request_node_id ], unique: true
|
|
58
|
+
t.index :pull_request_node_id
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
create_table :shiplog_commits do |t|
|
|
62
|
+
t.references :repository, null: false, foreign_key: { to_table: :shiplog_repositories }, index: false
|
|
63
|
+
t.string :sha, null: false
|
|
64
|
+
t.text :message, null: false
|
|
65
|
+
t.string :author_login
|
|
66
|
+
t.datetime :committed_at
|
|
67
|
+
t.string :url
|
|
68
|
+
t.timestamps
|
|
69
|
+
t.index [ :repository_id, :sha ], unique: true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
create_table :shiplog_entries do |t|
|
|
73
|
+
t.string :kind, null: false
|
|
74
|
+
t.string :status, null: false, default: "draft"
|
|
75
|
+
t.references :subject, polymorphic: true, null: false, index: false
|
|
76
|
+
t.string :title
|
|
77
|
+
t.text :body
|
|
78
|
+
t.string :source_digest
|
|
79
|
+
t.datetime :rewritten_at
|
|
80
|
+
t.text :rewrite_error
|
|
81
|
+
t.date :released_on
|
|
82
|
+
t.datetime :approved_at
|
|
83
|
+
t.string :approved_by
|
|
84
|
+
t.string :discard_reason
|
|
85
|
+
t.timestamps
|
|
86
|
+
t.index [ :subject_type, :subject_id, :kind ], unique: true
|
|
87
|
+
t.index :status
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
create_table :shiplog_webhook_deliveries do |t|
|
|
91
|
+
t.string :delivery_id, null: false
|
|
92
|
+
t.string :event
|
|
93
|
+
t.string :action
|
|
94
|
+
t.string :repository_full_name
|
|
95
|
+
t.datetime :received_at
|
|
96
|
+
t.datetime :processed_at
|
|
97
|
+
t.text :error
|
|
98
|
+
t.timestamps
|
|
99
|
+
t.index :delivery_id, unique: true
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module Generators
|
|
3
|
+
class InstallGenerator < Rails::Generators::Base
|
|
4
|
+
source_root File.expand_path("templates", __dir__)
|
|
5
|
+
desc "Installs Shiplog: initializer, routes, migrations and the GitHub issue form."
|
|
6
|
+
|
|
7
|
+
class_option :tailwind, type: :boolean, default: false,
|
|
8
|
+
desc: "Style the public pages with Tailwind: turn the bundled stylesheet off and generate config.classes"
|
|
9
|
+
|
|
10
|
+
TAILWIND_STYLESHEET = "app/assets/tailwind/application.css".freeze
|
|
11
|
+
# Relative to app/assets/tailwind/application.css: ../ is app/assets, ../../ is app, ../../../ is the app root.
|
|
12
|
+
TAILWIND_SOURCE = %(@source "../../../config/initializers/shiplog.rb";).freeze
|
|
13
|
+
|
|
14
|
+
# Neutral Tailwind v4 classes (gray scale, indigo accent, dark: variants) for every Shiplog::Classes key.
|
|
15
|
+
TAILWIND_CLASSES = {
|
|
16
|
+
page: "mx-auto max-w-6xl px-4 py-8 text-gray-900 dark:text-gray-100",
|
|
17
|
+
page_header: "mb-8",
|
|
18
|
+
page_title: "text-3xl font-semibold tracking-tight",
|
|
19
|
+
page_lede: "mt-2 text-gray-600 dark:text-gray-400",
|
|
20
|
+
columns: "grid gap-6 md:grid-cols-3",
|
|
21
|
+
column: "rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-900/50",
|
|
22
|
+
column_header: "mb-4 border-b border-gray-200 pb-3 dark:border-gray-800",
|
|
23
|
+
column_title: "inline text-base font-semibold",
|
|
24
|
+
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
|
+
column_description: "mt-1 text-sm text-gray-500 dark:text-gray-400",
|
|
26
|
+
column_empty: "text-sm text-gray-500 dark:text-gray-400",
|
|
27
|
+
cards: "space-y-3",
|
|
28
|
+
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
|
+
card_title: "font-medium",
|
|
30
|
+
card_body: "mt-1 text-sm text-gray-600 dark:text-gray-400",
|
|
31
|
+
months: "space-y-10",
|
|
32
|
+
month: "border-l-2 border-gray-200 pl-6 dark:border-gray-800",
|
|
33
|
+
month_title: "mb-4 text-lg font-semibold",
|
|
34
|
+
entries: "space-y-4",
|
|
35
|
+
entry: "rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900",
|
|
36
|
+
entry_feature: "border-l-4 border-l-indigo-500",
|
|
37
|
+
entry_category: "mr-2 inline-block rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300",
|
|
38
|
+
entry_title: "mt-2 text-base font-semibold",
|
|
39
|
+
entry_body: "mt-1 text-sm text-gray-600 dark:text-gray-400",
|
|
40
|
+
entry_date: "text-xs text-gray-500 dark:text-gray-400",
|
|
41
|
+
pagination: "mt-8 flex justify-between",
|
|
42
|
+
pagination_link: "text-sm font-medium text-indigo-600 hover:underline dark:text-indigo-400",
|
|
43
|
+
empty: "text-gray-500 dark:text-gray-400"
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
def copy_initializer
|
|
47
|
+
template "initializer.rb.tt", "config/initializers/shiplog.rb"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def add_tailwind_source
|
|
51
|
+
return unless options[:tailwind]
|
|
52
|
+
|
|
53
|
+
path = File.join(destination_root, TAILWIND_STYLESHEET)
|
|
54
|
+
if File.exist?(path)
|
|
55
|
+
css = File.read(path)
|
|
56
|
+
if css.include?(TAILWIND_SOURCE)
|
|
57
|
+
say_status :identical, TAILWIND_STYLESHEET
|
|
58
|
+
else
|
|
59
|
+
separator = css.empty? || css.end_with?("\n") ? "" : "\n"
|
|
60
|
+
append_to_file TAILWIND_STYLESHEET, "#{separator}#{TAILWIND_SOURCE}\n"
|
|
61
|
+
end
|
|
62
|
+
else
|
|
63
|
+
say "Add this line to your Tailwind stylesheet so Tailwind compiles the Shiplog classes:\n #{TAILWIND_SOURCE}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def mount_engines
|
|
68
|
+
route <<~RUBY
|
|
69
|
+
# Shiplog: public roadmap, changelog and GitHub webhook.
|
|
70
|
+
mount Shiplog::Engine => "/"
|
|
71
|
+
|
|
72
|
+
# Shiplog admin. Wrap it in your own authentication before enabling it, for example with Devise:
|
|
73
|
+
# authenticate :user, ->(user) { user.admin? } do
|
|
74
|
+
# mount Shiplog::Admin::Engine => "/admin/shiplog"
|
|
75
|
+
# end
|
|
76
|
+
RUBY
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def install_migrations
|
|
80
|
+
rake "shiplog:install:migrations"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def copy_issue_form
|
|
84
|
+
copy_file "shiplog-feature.yml", ".github/ISSUE_TEMPLATE/shiplog-feature.yml"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def tailwind_classes_ruby
|
|
90
|
+
TAILWIND_CLASSES.map { |key, value| %( #{key}: "#{value}",\n) }.join.sub(/,\n\z/, "\n")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Shiplog.configure do |config|
|
|
2
|
+
# GitHub repositories to sync ("owner/name"). Sub-issues may cross repositories of the same owner.
|
|
3
|
+
config.repositories = %w[acme/app acme/app-ios]
|
|
4
|
+
|
|
5
|
+
# A String, or a callable evaluated on every GitHub request (e.g. to mint a GitHub App installation token).
|
|
6
|
+
config.github_token = -> { Rails.application.credentials.dig(:github, :shiplog_token) }
|
|
7
|
+
|
|
8
|
+
# Secret configured on the GitHub webhook. Without it every webhook delivery is rejected.
|
|
9
|
+
config.webhook_secret = Rails.application.credentials.dig(:github, :shiplog_webhook_secret)
|
|
10
|
+
|
|
11
|
+
# Only issues with this label reach the roadmap.
|
|
12
|
+
# config.public_label = "public"
|
|
13
|
+
|
|
14
|
+
# Open public issues with one of these labels (or a milestone) are shown as "Coming soon".
|
|
15
|
+
# config.soon_labels = %w[soon]
|
|
16
|
+
|
|
17
|
+
# Pull requests with one of these labels never become changelog entries.
|
|
18
|
+
# config.excluded_labels = %w[internal dependencies refactor chore]
|
|
19
|
+
|
|
20
|
+
# Authors (Regexp, String or anything responding to ===) whose pull requests and commits are skipped.
|
|
21
|
+
# config.excluded_authors = [/\[bot\]\z/]
|
|
22
|
+
|
|
23
|
+
# Conventional-commit types eligible for the changelog.
|
|
24
|
+
# config.commit_types = %w[feat fix perf]
|
|
25
|
+
|
|
26
|
+
# Callable that receives the prompt (String) and returns a String containing JSON:
|
|
27
|
+
# {"title": "...", "body": "...", "customer_facing": true}
|
|
28
|
+
# config.rewriter = ->(prompt) { RubyLLM.chat.ask(prompt).content }
|
|
29
|
+
|
|
30
|
+
# Used in the built-in prompts.
|
|
31
|
+
# config.product_name = "Acme"
|
|
32
|
+
# config.audience = "small business owners who invoice customers"
|
|
33
|
+
# config.tone = "Friendly, concise, no jargon. Address the customer as 'you'."
|
|
34
|
+
|
|
35
|
+
# Optional ->(kind:, context:) { String } that replaces the built-in prompts.
|
|
36
|
+
# config.prompt_builder = nil
|
|
37
|
+
|
|
38
|
+
# Read once, when the public controllers load: changing it later has no effect.
|
|
39
|
+
# config.public_parent_controller = "ActionController::Base"
|
|
40
|
+
# config.public_layout = "shiplog/application"
|
|
41
|
+
|
|
42
|
+
# Parent class and layout of the admin controllers. Set the parent here (read once, when they load) and
|
|
43
|
+
# point the layout at your admin layout to render the admin inside your app's chrome.
|
|
44
|
+
# config.admin_parent_controller = "ActionController::Base"
|
|
45
|
+
# config.admin_layout = "shiplog/admin"
|
|
46
|
+
|
|
47
|
+
# :standalone (default) wraps the admin in Shiplog's own page chrome. Use :hosted when admin_layout is one of
|
|
48
|
+
# your layouts: Shiplog then renders only a compact sub-nav inside your content.
|
|
49
|
+
# config.admin_chrome = :standalone
|
|
50
|
+
|
|
51
|
+
# In :hosted mode, render Shiplog's flash messages inside the admin content. Set it to false when your
|
|
52
|
+
# layout already shows the flash.
|
|
53
|
+
# config.admin_flash = true
|
|
54
|
+
|
|
55
|
+
<% if options[:tailwind] -%>
|
|
56
|
+
# The bundled stylesheet is off: the pages are styled with the Tailwind classes below, added next to
|
|
57
|
+
# the stable shiplog-* BEM classes. Tailwind finds them through the @source line in
|
|
58
|
+
# app/assets/tailwind/application.css (@source "../../../config/initializers/shiplog.rb";).
|
|
59
|
+
config.stylesheet = false
|
|
60
|
+
|
|
61
|
+
config.classes = {
|
|
62
|
+
<%= tailwind_classes_ruby -%>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
<% else -%>
|
|
66
|
+
# Include the bundled stylesheet (CSS variables and @layer shiplog) in the public pages.
|
|
67
|
+
# Set it to false when your app styles the pages itself, for example with Tailwind.
|
|
68
|
+
# config.stylesheet = true
|
|
69
|
+
|
|
70
|
+
# Extra classes per element, added to the stable shiplog-* BEM classes (e.g. Tailwind utilities).
|
|
71
|
+
# Keys: <%= Shiplog::Classes::KEYS.join(", ") %>
|
|
72
|
+
# config.classes = {}
|
|
73
|
+
|
|
74
|
+
<% end -%>
|
|
75
|
+
# Checked by the admin controllers; return false for a 403. It defaults to denying everyone.
|
|
76
|
+
# The line below opens it because the route constraint around the admin mount does the authentication.
|
|
77
|
+
# If the mount is not wrapped in such a constraint, replace it with a real check (e.g. controller.current_user&.admin?).
|
|
78
|
+
config.authorize_admin = ->(controller) { true } # the route constraint around the admin mount does the authentication
|
|
79
|
+
|
|
80
|
+
# Recorded as the approver of an entry.
|
|
81
|
+
# config.current_admin = ->(controller) { controller.try(:current_user)&.try(:email) }
|
|
82
|
+
|
|
83
|
+
# Public paths, read when routes are drawn.
|
|
84
|
+
# config.roadmap_path = "roadmap"
|
|
85
|
+
# config.changelog_path = "changelog"
|
|
86
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: Customer-facing feature
|
|
2
|
+
description: A feature or improvement your customers will see on the public roadmap and changelog.
|
|
3
|
+
labels: ["public"]
|
|
4
|
+
body:
|
|
5
|
+
- type: textarea
|
|
6
|
+
id: problem
|
|
7
|
+
attributes:
|
|
8
|
+
label: Problem
|
|
9
|
+
description: What can customers not do today, or what is painful? Write it in plain language.
|
|
10
|
+
validations:
|
|
11
|
+
required: true
|
|
12
|
+
- type: textarea
|
|
13
|
+
id: before
|
|
14
|
+
attributes:
|
|
15
|
+
label: Before
|
|
16
|
+
description: How do customers work around the problem today?
|
|
17
|
+
- type: textarea
|
|
18
|
+
id: after
|
|
19
|
+
attributes:
|
|
20
|
+
label: After
|
|
21
|
+
description: What will customers be able to do once this ships?
|
|
22
|
+
validations:
|
|
23
|
+
required: true
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
# The public styling hooks. Every element of the public pages has a key here, a stable BEM class
|
|
3
|
+
# (always rendered) and an optional host-supplied class string (config.classes[key]).
|
|
4
|
+
module Classes
|
|
5
|
+
BEM = {
|
|
6
|
+
page: "shiplog-page",
|
|
7
|
+
page_header: "shiplog-page__header",
|
|
8
|
+
page_title: "shiplog-page__title",
|
|
9
|
+
page_lede: "shiplog-page__lede",
|
|
10
|
+
columns: "shiplog-columns",
|
|
11
|
+
column: "shiplog-column",
|
|
12
|
+
column_header: "shiplog-column__header",
|
|
13
|
+
column_title: "shiplog-column__title",
|
|
14
|
+
column_count: "shiplog-column__count",
|
|
15
|
+
column_description: "shiplog-column__description",
|
|
16
|
+
column_empty: "shiplog-column__empty",
|
|
17
|
+
cards: "shiplog-cards",
|
|
18
|
+
card: "shiplog-card",
|
|
19
|
+
card_title: "shiplog-card__title",
|
|
20
|
+
card_body: "shiplog-card__body",
|
|
21
|
+
months: "shiplog-months",
|
|
22
|
+
month: "shiplog-month",
|
|
23
|
+
month_title: "shiplog-month__title",
|
|
24
|
+
entries: "shiplog-entries",
|
|
25
|
+
entry: "shiplog-entry",
|
|
26
|
+
entry_feature: "shiplog-entry--feature",
|
|
27
|
+
entry_category: "shiplog-entry__category",
|
|
28
|
+
entry_title: "shiplog-entry__title",
|
|
29
|
+
entry_body: "shiplog-entry__body",
|
|
30
|
+
entry_date: "shiplog-entry__date",
|
|
31
|
+
pagination: "shiplog-pagination",
|
|
32
|
+
pagination_link: "shiplog-pagination__link",
|
|
33
|
+
empty: "shiplog-empty"
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
KEYS = BEM.keys.freeze
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
class Configuration
|
|
3
|
+
attr_accessor :repositories, :github_token, :webhook_secret,
|
|
4
|
+
:public_label, :soon_labels, :excluded_labels, :excluded_authors, :commit_types,
|
|
5
|
+
:rewriter, :product_name, :audience, :tone, :prompt_builder,
|
|
6
|
+
:public_layout, :admin_layout,
|
|
7
|
+
:stylesheet, :admin_flash, :authorize_admin, :current_admin,
|
|
8
|
+
:roadmap_path, :changelog_path
|
|
9
|
+
|
|
10
|
+
ADMIN_CHROMES = %i[standalone hosted].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :admin_chrome, :classes, :public_parent_controller, :admin_parent_controller
|
|
13
|
+
|
|
14
|
+
# :standalone (default) wraps the admin in Shiplog's own page chrome; :hosted renders it inside the
|
|
15
|
+
# layout of config.admin_layout (compact sub-nav, no page chrome of its own).
|
|
16
|
+
def admin_chrome=(value)
|
|
17
|
+
chrome = value.to_s.to_sym if value.respond_to?(:to_sym)
|
|
18
|
+
unless ADMIN_CHROMES.include?(chrome)
|
|
19
|
+
raise ArgumentError, "Invalid admin_chrome #{value.inspect}. Valid values: #{ADMIN_CHROMES.map(&:inspect).join(", ")}"
|
|
20
|
+
end
|
|
21
|
+
@admin_chrome = chrome
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Extra classes per element (see Shiplog::Classes::KEYS). Unknown keys raise in every environment;
|
|
25
|
+
# string keys become symbols and the hash is stored as a frozen copy.
|
|
26
|
+
def classes=(value)
|
|
27
|
+
classes = (value || {}).to_h.transform_keys { |key| key.to_s.to_sym }
|
|
28
|
+
unknown = classes.keys - Shiplog::Classes::KEYS
|
|
29
|
+
if unknown.any?
|
|
30
|
+
raise ArgumentError, "Unknown Shiplog classes key(s): #{unknown.map(&:inspect).join(", ")}. " \
|
|
31
|
+
"Valid keys: #{Shiplog::Classes::KEYS.map(&:inspect).join(", ")}"
|
|
32
|
+
end
|
|
33
|
+
@classes = classes.freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The parent classes are read once, when the engine's controllers load.
|
|
37
|
+
def public_parent_controller=(value)
|
|
38
|
+
ensure_controller_not_loaded!(:public_parent_controller, Shiplog, :PublicController)
|
|
39
|
+
@public_parent_controller = value
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def admin_parent_controller=(value)
|
|
43
|
+
ensure_controller_not_loaded!(:admin_parent_controller, Shiplog::Admin, :ApplicationController)
|
|
44
|
+
@admin_parent_controller = value
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def initialize
|
|
48
|
+
@repositories = []
|
|
49
|
+
@public_label = "public"
|
|
50
|
+
@soon_labels = %w[soon]
|
|
51
|
+
@excluded_labels = %w[internal dependencies refactor chore]
|
|
52
|
+
@excluded_authors = [ /\[bot\]\z/ ]
|
|
53
|
+
@commit_types = %w[feat fix perf]
|
|
54
|
+
@tone = "Friendly, concise, no jargon. Address the customer as 'you'."
|
|
55
|
+
@public_parent_controller = "ActionController::Base"
|
|
56
|
+
@public_layout = "shiplog/application"
|
|
57
|
+
@admin_parent_controller = "ActionController::Base"
|
|
58
|
+
@admin_layout = "shiplog/admin"
|
|
59
|
+
@classes = {}.freeze
|
|
60
|
+
@stylesheet = true
|
|
61
|
+
@admin_chrome = :standalone
|
|
62
|
+
@admin_flash = true
|
|
63
|
+
@authorize_admin = ->(_controller) { false }
|
|
64
|
+
@current_admin = ->(controller) { controller.try(:current_user)&.try(:email) }
|
|
65
|
+
@roadmap_path = "roadmap"
|
|
66
|
+
@changelog_path = "changelog"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def resolved_github_token
|
|
70
|
+
github_token.respond_to?(:call) ? github_token.call : github_token
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The configured spelling of a repository name (matched case-insensitively), or nil.
|
|
74
|
+
def repository_configured?(name)
|
|
75
|
+
return if name.blank?
|
|
76
|
+
repositories.find { |configured| configured.casecmp?(name.to_s) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def excluded_author?(login)
|
|
80
|
+
excluded_authors.any? { |pattern| pattern === login }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def ensure_controller_not_loaded!(setting, namespace, name)
|
|
86
|
+
return unless namespace.const_defined?(name, false) && !namespace.autoload?(name)
|
|
87
|
+
|
|
88
|
+
raise ArgumentError, "config.#{setting} was set after #{namespace}::#{name} loaded, so it would have no effect. " \
|
|
89
|
+
"Set it in config/initializers (before Shiplog controllers load)."
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Shiplog
|
|
6
|
+
module Github
|
|
7
|
+
class Client
|
|
8
|
+
ENDPOINT = URI("https://api.github.com/graphql")
|
|
9
|
+
RETRYABLE_STATUSES = [ 502, 503, 504 ].freeze
|
|
10
|
+
NETWORK_ERRORS = [
|
|
11
|
+
Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, Errno::ECONNREFUSED,
|
|
12
|
+
OpenSSL::SSL::SSLError, SocketError
|
|
13
|
+
].freeze
|
|
14
|
+
DEFAULT_RESET_WINDOW = 60
|
|
15
|
+
|
|
16
|
+
def initialize(token: Shiplog.config.resolved_github_token, retry_delay: 1)
|
|
17
|
+
@token = token
|
|
18
|
+
@retry_delay = retry_delay
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def query(query, variables = {})
|
|
22
|
+
raise Error, "No GitHub token configured (Shiplog.config.github_token)" if @token.blank?
|
|
23
|
+
|
|
24
|
+
response = post(JSON.generate(query:, variables:))
|
|
25
|
+
handle(response)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def post(payload, retried: false)
|
|
31
|
+
response = request(payload)
|
|
32
|
+
return response unless RETRYABLE_STATUSES.include?(response.code.to_i) && !retried
|
|
33
|
+
|
|
34
|
+
sleep @retry_delay
|
|
35
|
+
post(payload, retried: true)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def request(payload)
|
|
39
|
+
http = Net::HTTP.new(ENDPOINT.host, ENDPOINT.port)
|
|
40
|
+
http.use_ssl = true
|
|
41
|
+
http.open_timeout = 10
|
|
42
|
+
http.read_timeout = 30
|
|
43
|
+
http.request(build_request(payload))
|
|
44
|
+
rescue *NETWORK_ERRORS => error
|
|
45
|
+
raise Transient, "#{error.class}: #{error.message}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def build_request(payload)
|
|
49
|
+
Net::HTTP::Post.new(ENDPOINT).tap do |req|
|
|
50
|
+
req["Authorization"] = "Bearer #{@token}"
|
|
51
|
+
req["Content-Type"] = "application/json"
|
|
52
|
+
req["User-Agent"] = "shiplog/#{Shiplog::VERSION}"
|
|
53
|
+
req.body = payload
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def handle(response)
|
|
58
|
+
status = response.code.to_i
|
|
59
|
+
raise_rate_limited(response) if rate_limited?(response, status)
|
|
60
|
+
raise Transient, "GitHub responded with HTTP #{status}" if status >= 500
|
|
61
|
+
raise Error, "GitHub responded with HTTP #{status}" unless status.between?(200, 299)
|
|
62
|
+
|
|
63
|
+
body = JSON.parse(response.body)
|
|
64
|
+
errors = body["errors"].to_a
|
|
65
|
+
raise_rate_limited(response) if errors.any? { |e| e["type"] == "RATE_LIMITED" }
|
|
66
|
+
return body.fetch("data") if errors.empty? || not_found_only?(body, errors)
|
|
67
|
+
|
|
68
|
+
raise Error, errors.map { |e| e["message"] }.join("; ")
|
|
69
|
+
rescue JSON::ParserError
|
|
70
|
+
raise Error, "GitHub returned an unparseable response"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A missing issue/PR number yields `data.repository.issue: null` plus a NOT_FOUND error.
|
|
74
|
+
def not_found_only?(body, errors)
|
|
75
|
+
body["data"].present? && errors.all? { |e| e["type"] == "NOT_FOUND" }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def rate_limited?(response, status)
|
|
79
|
+
return false unless [ 403, 429 ].include?(status)
|
|
80
|
+
|
|
81
|
+
response["x-ratelimit-remaining"] == "0" || response["retry-after"].present?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def raise_rate_limited(response)
|
|
85
|
+
reset_at = if response["retry-after"].present?
|
|
86
|
+
Time.current + response["retry-after"].to_i
|
|
87
|
+
elsif response["x-ratelimit-reset"].to_i.positive?
|
|
88
|
+
Time.at(response["x-ratelimit-reset"].to_i).utc
|
|
89
|
+
else
|
|
90
|
+
Time.current + DEFAULT_RESET_WINDOW
|
|
91
|
+
end
|
|
92
|
+
raise RateLimited.new(reset_at)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module Github
|
|
3
|
+
class Error < StandardError; end
|
|
4
|
+
|
|
5
|
+
# Network failures and 5xx responses; safe to retry later.
|
|
6
|
+
class Transient < Error; end
|
|
7
|
+
|
|
8
|
+
class RateLimited < Error
|
|
9
|
+
attr_reader :reset_at
|
|
10
|
+
|
|
11
|
+
def initialize(reset_at)
|
|
12
|
+
@reset_at = reset_at
|
|
13
|
+
super("GitHub rate limit exceeded, resets at #{reset_at.utc.iso8601}")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module Github
|
|
3
|
+
module Queries
|
|
4
|
+
PULL_REQUEST_FIELDS = <<~GRAPHQL.freeze
|
|
5
|
+
fragment PullRequestFields on PullRequest {
|
|
6
|
+
id number title body state mergedAt url updatedAt
|
|
7
|
+
author { __typename login }
|
|
8
|
+
repository { nameWithOwner }
|
|
9
|
+
labels(first: 30) { nodes { name } }
|
|
10
|
+
}
|
|
11
|
+
GRAPHQL
|
|
12
|
+
|
|
13
|
+
ISSUE_FIELDS = <<~GRAPHQL.freeze
|
|
14
|
+
fragment IssueFields on Issue {
|
|
15
|
+
__typename id number title body state stateReason url createdAt updatedAt closedAt
|
|
16
|
+
labels(first: 30) { nodes { name } }
|
|
17
|
+
milestone { title }
|
|
18
|
+
assignees { totalCount }
|
|
19
|
+
parent { id }
|
|
20
|
+
closedByPullRequestsReferences(first: 20, includeClosedPrs: true) {
|
|
21
|
+
pageInfo { hasNextPage }
|
|
22
|
+
nodes { ...PullRequestFields }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
GRAPHQL
|
|
26
|
+
|
|
27
|
+
PULL_REQUEST_WITH_ISSUES = <<~GRAPHQL.freeze
|
|
28
|
+
fragment PullRequestWithIssues on PullRequest {
|
|
29
|
+
...PullRequestFields
|
|
30
|
+
closingIssuesReferences(first: 20) {
|
|
31
|
+
pageInfo { hasNextPage }
|
|
32
|
+
nodes { id }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
GRAPHQL
|
|
36
|
+
|
|
37
|
+
# GitHub rejects documents with unused fragments, so each query embeds
|
|
38
|
+
# exactly the fragments it (transitively) uses.
|
|
39
|
+
ISSUE_FRAGMENTS = (ISSUE_FIELDS + PULL_REQUEST_FIELDS).freeze
|
|
40
|
+
PULL_REQUEST_FRAGMENTS = (PULL_REQUEST_WITH_ISSUES + PULL_REQUEST_FIELDS).freeze
|
|
41
|
+
|
|
42
|
+
ISSUES = <<~GRAPHQL.freeze
|
|
43
|
+
query($owner: String!, $name: String!, $since: DateTime, $after: String) {
|
|
44
|
+
repository(owner: $owner, name: $name) {
|
|
45
|
+
issues(first: 50, after: $after, filterBy: { since: $since }, orderBy: { field: UPDATED_AT, direction: ASC }) {
|
|
46
|
+
pageInfo { hasNextPage endCursor }
|
|
47
|
+
nodes { ...IssueFields }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
#{ISSUE_FRAGMENTS}
|
|
52
|
+
GRAPHQL
|
|
53
|
+
|
|
54
|
+
PULL_REQUESTS = <<~GRAPHQL.freeze
|
|
55
|
+
query($owner: String!, $name: String!, $after: String) {
|
|
56
|
+
repository(owner: $owner, name: $name) {
|
|
57
|
+
pullRequests(first: 50, after: $after, orderBy: { field: UPDATED_AT, direction: DESC }) {
|
|
58
|
+
pageInfo { hasNextPage endCursor }
|
|
59
|
+
nodes { ...PullRequestWithIssues }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
#{PULL_REQUEST_FRAGMENTS}
|
|
64
|
+
GRAPHQL
|
|
65
|
+
|
|
66
|
+
ISSUE = <<~GRAPHQL.freeze
|
|
67
|
+
query($owner: String!, $name: String!, $number: Int!) {
|
|
68
|
+
repository(owner: $owner, name: $name) {
|
|
69
|
+
issue(number: $number) { ...IssueFields }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
#{ISSUE_FRAGMENTS}
|
|
73
|
+
GRAPHQL
|
|
74
|
+
|
|
75
|
+
PULL_REQUEST = <<~GRAPHQL.freeze
|
|
76
|
+
query($owner: String!, $name: String!, $number: Int!) {
|
|
77
|
+
repository(owner: $owner, name: $name) {
|
|
78
|
+
pullRequest(number: $number) { ...PullRequestWithIssues }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
#{PULL_REQUEST_FRAGMENTS}
|
|
82
|
+
GRAPHQL
|
|
83
|
+
|
|
84
|
+
COMMITS = <<~GRAPHQL.freeze
|
|
85
|
+
query($owner: String!, $name: String!, $since: GitTimestamp, $after: String) {
|
|
86
|
+
repository(owner: $owner, name: $name) {
|
|
87
|
+
defaultBranchRef {
|
|
88
|
+
target {
|
|
89
|
+
... on Commit {
|
|
90
|
+
history(first: 100, since: $since, after: $after) {
|
|
91
|
+
pageInfo { hasNextPage endCursor }
|
|
92
|
+
nodes {
|
|
93
|
+
oid message committedDate url
|
|
94
|
+
author { user { login } }
|
|
95
|
+
associatedPullRequests(first: 1) { totalCount }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
GRAPHQL
|
|
104
|
+
|
|
105
|
+
ALL = { ISSUES:, PULL_REQUESTS:, ISSUE:, PULL_REQUEST:, COMMITS: }.freeze
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|