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.
Files changed (92) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +15 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +410 -0
  5. data/app/controllers/shiplog/admin/application_controller.rb +37 -0
  6. data/app/controllers/shiplog/admin/boards_controller.rb +60 -0
  7. data/app/controllers/shiplog/admin/entries/approvals_controller.rb +15 -0
  8. data/app/controllers/shiplog/admin/entries/discards_controller.rb +25 -0
  9. data/app/controllers/shiplog/admin/entries/rewrites_controller.rb +13 -0
  10. data/app/controllers/shiplog/admin/entries_controller.rb +55 -0
  11. data/app/controllers/shiplog/admin/syncs_controller.rb +21 -0
  12. data/app/controllers/shiplog/application_controller.rb +5 -0
  13. data/app/controllers/shiplog/changelogs_controller.rb +24 -0
  14. data/app/controllers/shiplog/frontends_controller.rb +21 -0
  15. data/app/controllers/shiplog/public_controller.rb +19 -0
  16. data/app/controllers/shiplog/roadmaps_controller.rb +14 -0
  17. data/app/controllers/shiplog/webhooks_controller.rb +121 -0
  18. data/app/frontend/shiplog/admin.css +137 -0
  19. data/app/frontend/shiplog/board.js +77 -0
  20. data/app/frontend/shiplog/shiplog.css +184 -0
  21. data/app/helpers/shiplog/admin/entries_helper.rb +27 -0
  22. data/app/helpers/shiplog/application_helper.rb +14 -0
  23. data/app/helpers/shiplog/frontend_helper.rb +17 -0
  24. data/app/helpers/shiplog/host_routes_helper.rb +25 -0
  25. data/app/helpers/shiplog/styling_helper.rb +24 -0
  26. data/app/jobs/shiplog/application_job.rb +14 -0
  27. data/app/jobs/shiplog/refresh_job.rb +38 -0
  28. data/app/jobs/shiplog/rewrite_entry_job.rb +9 -0
  29. data/app/jobs/shiplog/scan_changes_job.rb +36 -0
  30. data/app/jobs/shiplog/sync_job.rb +20 -0
  31. data/app/models/shiplog/application_record.rb +5 -0
  32. data/app/models/shiplog/board_arrangement.rb +38 -0
  33. data/app/models/shiplog/change_candidates.rb +77 -0
  34. data/app/models/shiplog/commit.rb +9 -0
  35. data/app/models/shiplog/drafter.rb +69 -0
  36. data/app/models/shiplog/entry.rb +42 -0
  37. data/app/models/shiplog/issue.rb +68 -0
  38. data/app/models/shiplog/issue_form.rb +43 -0
  39. data/app/models/shiplog/issue_link.rb +6 -0
  40. data/app/models/shiplog/pull_request.rb +11 -0
  41. data/app/models/shiplog/repository.rb +9 -0
  42. data/app/models/shiplog/rewriter/prompt.rb +71 -0
  43. data/app/models/shiplog/rewriter.rb +89 -0
  44. data/app/models/shiplog/roadmap/column_resolver.rb +46 -0
  45. data/app/models/shiplog/roadmap.rb +34 -0
  46. data/app/models/shiplog/sync/commit_scanner.rb +39 -0
  47. data/app/models/shiplog/sync/issue_importer.rb +50 -0
  48. data/app/models/shiplog/sync/node_attributes.rb +50 -0
  49. data/app/models/shiplog/sync/pull_request_importer.rb +54 -0
  50. data/app/models/shiplog/sync/repository_sync.rb +80 -0
  51. data/app/models/shiplog/webhook_delivery.rb +5 -0
  52. data/app/models/shiplog/webhook_signature.rb +16 -0
  53. data/app/views/layouts/shiplog/admin.html.erb +16 -0
  54. data/app/views/layouts/shiplog/application.html.erb +15 -0
  55. data/app/views/shiplog/_empty.html.erb +3 -0
  56. data/app/views/shiplog/_page.html.erb +6 -0
  57. data/app/views/shiplog/_page_header.html.erb +6 -0
  58. data/app/views/shiplog/_pagination.html.erb +12 -0
  59. data/app/views/shiplog/admin/_assets.html.erb +3 -0
  60. data/app/views/shiplog/admin/_custom_head.html.erb +0 -0
  61. data/app/views/shiplog/admin/_nav.html.erb +9 -0
  62. data/app/views/shiplog/admin/_page.html.erb +14 -0
  63. data/app/views/shiplog/admin/boards/_card.html.erb +22 -0
  64. data/app/views/shiplog/admin/boards/show.html.erb +17 -0
  65. data/app/views/shiplog/admin/entries/_badges.html.erb +10 -0
  66. data/app/views/shiplog/admin/entries/_entry_row.html.erb +21 -0
  67. data/app/views/shiplog/admin/entries/edit.html.erb +61 -0
  68. data/app/views/shiplog/admin/entries/index.html.erb +47 -0
  69. data/app/views/shiplog/admin/syncs/show.html.erb +71 -0
  70. data/app/views/shiplog/changelogs/_entry.html.erb +8 -0
  71. data/app/views/shiplog/changelogs/_month.html.erb +9 -0
  72. data/app/views/shiplog/changelogs/show.html.erb +16 -0
  73. data/app/views/shiplog/roadmaps/_card.html.erb +6 -0
  74. data/app/views/shiplog/roadmaps/_column.html.erb +17 -0
  75. data/app/views/shiplog/roadmaps/show.html.erb +9 -0
  76. data/config/admin_routes.rb +13 -0
  77. data/config/locales/en.yml +106 -0
  78. data/config/routes.rb +6 -0
  79. data/db/migrate/20261026000001_create_shiplog_tables.rb +102 -0
  80. data/lib/generators/shiplog/install/install_generator.rb +94 -0
  81. data/lib/generators/shiplog/install/templates/initializer.rb.tt +86 -0
  82. data/lib/generators/shiplog/install/templates/shiplog-feature.yml +23 -0
  83. data/lib/shiplog/admin/engine.rb +9 -0
  84. data/lib/shiplog/classes.rb +38 -0
  85. data/lib/shiplog/configuration.rb +92 -0
  86. data/lib/shiplog/engine.rb +5 -0
  87. data/lib/shiplog/github/client.rb +96 -0
  88. data/lib/shiplog/github/errors.rb +17 -0
  89. data/lib/shiplog/github/queries.rb +108 -0
  90. data/lib/shiplog/version.rb +3 -0
  91. data/lib/shiplog.rb +16 -0
  92. 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,9 @@
1
+ module Shiplog
2
+ module Admin
3
+ class Engine < ::Rails::Engine
4
+ isolate_namespace Shiplog::Admin
5
+ engine_name "shiplog_admin"
6
+ config.paths["config/routes.rb"] = "config/admin_routes.rb"
7
+ end
8
+ end
9
+ end
@@ -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,5 @@
1
+ module Shiplog
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Shiplog
4
+ end
5
+ 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
@@ -0,0 +1,3 @@
1
+ module Shiplog
2
+ VERSION = "0.1.0"
3
+ end