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,184 @@
1
+ /*
2
+ * Shiplog public pages. Everything lives in the `shiplog` cascade layer, so any unlayered CSS in
3
+ * your app wins over it without needing higher specificity. Only BEM classes are styled.
4
+ *
5
+ * Inside a host layout the page inherits the host's text colour and uses transparent surfaces and
6
+ * borders mixed from currentColor, so it suits light and dark hosts alike. Only the engine's own
7
+ * layout (`.shiplog-layout`), which paints the page background, follows prefers-color-scheme.
8
+ *
9
+ * Variables (set them on :root, body or .shiplog-page):
10
+ * --shiplog-fg text colour --shiplog-border borders and dividers
11
+ * --shiplog-muted secondary text --shiplog-accent links, feature marker, focus
12
+ * --shiplog-bg page background --shiplog-radius corner radius of cards and pills
13
+ * --shiplog-card card background --shiplog-font font family
14
+ * --shiplog-space base spacing unit (default 1rem)
15
+ */
16
+ @layer shiplog {
17
+ /* Host-safe defaults: inherit the text colour, derive surfaces and borders from it. */
18
+ .shiplog-page,
19
+ .shiplog-layout {
20
+ --sl-fg: var(--shiplog-fg, currentColor);
21
+ --sl-muted: var(--shiplog-muted, color-mix(in srgb, currentColor 65%, transparent));
22
+ --sl-bg: var(--shiplog-bg, transparent);
23
+ --sl-card: var(--shiplog-card, color-mix(in srgb, currentColor 5%, transparent));
24
+ --sl-border: var(--shiplog-border, color-mix(in srgb, currentColor 22%, transparent));
25
+ --sl-accent: var(--shiplog-accent, #2f81f7);
26
+ --sl-radius: var(--shiplog-radius, 0.5rem);
27
+ --sl-font: var(--shiplog-font, system-ui, -apple-system, "Segoe UI", sans-serif);
28
+ --sl-space: var(--shiplog-space, 1rem);
29
+ }
30
+
31
+ /* The engine's own layout owns the page background, so it picks its own light and dark palette. */
32
+ .shiplog-layout,
33
+ .shiplog-layout .shiplog-page {
34
+ --sl-fg: var(--shiplog-fg, #1f2328);
35
+ --sl-muted: var(--shiplog-muted, #59636e);
36
+ --sl-bg: var(--shiplog-bg, #ffffff);
37
+ --sl-card: var(--shiplog-card, #f6f8fa);
38
+ --sl-border: var(--shiplog-border, #d1d9e0);
39
+ --sl-accent: var(--shiplog-accent, #0969da);
40
+ }
41
+
42
+ @media (prefers-color-scheme: dark) {
43
+ .shiplog-layout,
44
+ .shiplog-layout .shiplog-page {
45
+ --sl-fg: var(--shiplog-fg, #e6edf3);
46
+ --sl-muted: var(--shiplog-muted, #9198a1);
47
+ --sl-bg: var(--shiplog-bg, #0d1117);
48
+ --sl-card: var(--shiplog-card, #151b23);
49
+ --sl-border: var(--shiplog-border, #3d444d);
50
+ --sl-accent: var(--shiplog-accent, #4493f8);
51
+ }
52
+ }
53
+
54
+ /* Only the engine's own layout paints the body; a host layout keeps its background. */
55
+ .shiplog-layout { background: var(--sl-bg); color: var(--sl-fg); margin: 0; }
56
+
57
+ .shiplog-page {
58
+ color: var(--sl-fg);
59
+ font-family: var(--sl-font);
60
+ line-height: 1.5;
61
+ margin: 0 auto;
62
+ max-width: 72rem;
63
+ padding: calc(var(--sl-space) * 1.5) var(--sl-space);
64
+ }
65
+
66
+ .shiplog-page__header { margin-bottom: calc(var(--sl-space) * 2); }
67
+ .shiplog-page__title { font-size: 1.875rem; line-height: 1.2; margin: 0; }
68
+ .shiplog-page__lede { color: var(--sl-muted); margin: 0.5rem 0 0; }
69
+
70
+ .shiplog-empty,
71
+ .shiplog-column__empty { color: var(--sl-muted); margin: 0; }
72
+
73
+ /* Roadmap */
74
+ .shiplog-columns {
75
+ display: grid;
76
+ gap: calc(var(--sl-space) * 1.5);
77
+ grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
78
+ }
79
+
80
+ .shiplog-column {
81
+ --sl-dot: var(--sl-muted);
82
+ min-width: 0;
83
+ }
84
+ .shiplog-column--in-progress { --sl-dot: #1a7f37; }
85
+ .shiplog-column--coming-soon { --sl-dot: var(--sl-accent); }
86
+ .shiplog-column--later { --sl-dot: var(--sl-muted); }
87
+
88
+ .shiplog-column__header {
89
+ align-items: baseline;
90
+ border-bottom: 1px solid var(--sl-border);
91
+ display: flex;
92
+ flex-wrap: wrap;
93
+ gap: 0 0.5rem;
94
+ margin-bottom: var(--sl-space);
95
+ padding-bottom: 0.75rem;
96
+ }
97
+ .shiplog-column__title { align-items: center; display: flex; font-size: 1.125rem; gap: 0.5rem; margin: 0; }
98
+ .shiplog-column__title::before {
99
+ background: var(--sl-dot);
100
+ border-radius: 50%;
101
+ content: "";
102
+ height: 0.625rem;
103
+ width: 0.625rem;
104
+ }
105
+ .shiplog-column__count {
106
+ background: var(--sl-card);
107
+ border: 1px solid var(--sl-border);
108
+ border-radius: 1rem;
109
+ color: var(--sl-muted);
110
+ font-size: 0.75rem;
111
+ padding: 0 0.5rem;
112
+ }
113
+ .shiplog-column__description { color: var(--sl-muted); flex-basis: 100%; font-size: 0.875rem; margin: 0.25rem 0 0; }
114
+
115
+ .shiplog-cards,
116
+ .shiplog-entries { display: grid; gap: 0.75rem; list-style: none; margin: 0; padding: 0; }
117
+
118
+ .shiplog-card,
119
+ .shiplog-entry {
120
+ background: var(--sl-card);
121
+ border: 1px solid var(--sl-border);
122
+ border-radius: var(--sl-radius);
123
+ padding: 0.75rem var(--sl-space);
124
+ }
125
+
126
+ .shiplog-card { transition: border-color 0.15s, box-shadow 0.15s; }
127
+ .shiplog-card:hover { border-color: var(--sl-accent); box-shadow: 0 1px 6px rgb(0 0 0 / 0.1); }
128
+ @media (prefers-reduced-motion: reduce) { .shiplog-card { transition: none; } }
129
+
130
+ .shiplog-card__title { font-size: 1rem; margin: 0 0 0.25rem; }
131
+ .shiplog-card__body p,
132
+ .shiplog-entry__body p { margin: 0.25rem 0; }
133
+ .shiplog-card__body { color: var(--sl-muted); font-size: 0.9375rem; }
134
+
135
+ /* Changelog: months hang off a vertical line */
136
+ .shiplog-months { display: grid; gap: calc(var(--sl-space) * 2); }
137
+
138
+ .shiplog-month {
139
+ border-left: 2px solid var(--sl-border);
140
+ margin-left: 0.375rem;
141
+ padding-left: calc(var(--sl-space) * 1.5);
142
+ position: relative;
143
+ }
144
+ .shiplog-month__title { font-size: 1.125rem; margin: 0 0 0.75rem; }
145
+ .shiplog-month__title::before {
146
+ background: var(--sl-accent);
147
+ border-radius: 50%;
148
+ content: "";
149
+ height: 0.75rem;
150
+ left: calc(-1px - 0.375rem);
151
+ position: absolute;
152
+ top: 0.45rem;
153
+ width: 0.75rem;
154
+ }
155
+
156
+ .shiplog-entry { align-items: center; display: flex; flex-wrap: wrap; gap: 0.25rem 0.625rem; }
157
+ .shiplog-entry__title,
158
+ .shiplog-entry__body { flex-basis: 100%; }
159
+ .shiplog-entry__title { font-size: 1rem; margin: 0.25rem 0 0; }
160
+ .shiplog-entry__body { color: var(--sl-muted); }
161
+ .shiplog-entry__date { color: var(--sl-muted); font-size: 0.8125rem; }
162
+
163
+ .shiplog-entry__category {
164
+ border: 1px solid var(--sl-border);
165
+ border-radius: 1rem;
166
+ color: var(--sl-muted);
167
+ font-size: 0.75rem;
168
+ font-weight: 600;
169
+ padding: 0 0.5rem;
170
+ }
171
+ .shiplog-entry--feature { border-left: 4px solid var(--sl-accent); padding: var(--sl-space) calc(var(--sl-space) * 1.25); }
172
+ .shiplog-entry--feature .shiplog-entry__title { font-size: 1.25rem; }
173
+ .shiplog-entry--feature .shiplog-entry__category { background: var(--sl-accent); border-color: var(--sl-accent); color: #fff; }
174
+
175
+ .shiplog-pagination { display: flex; flex-wrap: wrap; gap: var(--sl-space); justify-content: space-between; margin-top: calc(var(--sl-space) * 2); }
176
+ .shiplog-pagination__link { color: var(--sl-accent); }
177
+ .shiplog-pagination__link--older { margin-left: auto; }
178
+
179
+ @media (max-width: 40rem) {
180
+ .shiplog-page { padding: var(--sl-space) 0.75rem; }
181
+ .shiplog-page__title { font-size: 1.5rem; }
182
+ .shiplog-month { padding-left: var(--sl-space); }
183
+ }
184
+ }
@@ -0,0 +1,27 @@
1
+ module Shiplog
2
+ module Admin
3
+ module EntriesHelper
4
+ def source_title(subject)
5
+ subject.is_a?(Commit) ? subject.message.lines.first.to_s.strip : subject.title
6
+ end
7
+
8
+ def source_body(subject)
9
+ subject.is_a?(Commit) ? subject.message.lines.drop(1).join.strip : subject.body.to_s
10
+ end
11
+
12
+ def source_reference(subject)
13
+ return subject.sha.first(7) if subject.is_a?(Commit)
14
+
15
+ "#{subject.repository.full_name}##{subject.number}"
16
+ end
17
+
18
+ def entry_heading(entry)
19
+ entry.title.presence || source_title(entry.subject)
20
+ end
21
+
22
+ def entries_filter_path(status: nil, kind: nil, page: nil)
23
+ root_path({ status:, kind:, page: }.compact_blank)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,14 @@
1
+ module Shiplog
2
+ module ApplicationHelper
3
+ GITHUB_URL_PREFIX = "https://github.com/".freeze
4
+
5
+ # Links only to github.com over https; anything else (javascript:, data:, other hosts) renders as plain text.
6
+ def shiplog_github_link(text, url)
7
+ if url.to_s.start_with?(GITHUB_URL_PREFIX)
8
+ link_to text, url, rel: "noopener noreferrer", target: "_blank"
9
+ else
10
+ text
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,17 @@
1
+ module Shiplog
2
+ module FrontendHelper
3
+ def shiplog_stylesheet_tag(name)
4
+ tag.link rel: "stylesheet", href: shiplog_asset_path("#{name}.css"), nonce: content_security_policy_nonce
5
+ end
6
+
7
+ def shiplog_javascript_tag(name)
8
+ tag.script "", src: shiplog_asset_path("#{name}.js"), type: "module", nonce: content_security_policy_nonce
9
+ end
10
+
11
+ private
12
+
13
+ def shiplog_asset_path(file)
14
+ frontend_path(version: Shiplog::VERSION, name: file)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,25 @@
1
+ module Shiplog
2
+ # A host layout rendered by a Shiplog controller runs in the engine's isolated routing context, so
3
+ # the host's own helpers (root_path, new_user_session_path, ...) are undefined there. This forwards
4
+ # any unknown *_path / *_url call to main_app when the host app defines it. Helpers the engine
5
+ # defines itself (roadmap_path, board_path, ...) are real methods and always win.
6
+ module HostRoutesHelper
7
+ def method_missing(name, ...)
8
+ if shiplog_host_route?(name)
9
+ main_app.public_send(name, ...)
10
+ else
11
+ super
12
+ end
13
+ end
14
+
15
+ def respond_to_missing?(name, include_private = false)
16
+ shiplog_host_route?(name) || super
17
+ end
18
+
19
+ private
20
+
21
+ def shiplog_host_route?(name)
22
+ name.end_with?("_path", "_url") && respond_to?(:main_app, true) && main_app.respond_to?(name)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,24 @@
1
+ module Shiplog
2
+ module StylingHelper
3
+ # The stable BEM class of an element, BEM modifiers (class--modifier) and the host's classes
4
+ # from config.classes[key]. Unknown keys raise outside production so typos surface early.
5
+ def shiplog_class(key, *modifiers)
6
+ key = key.to_sym
7
+ base = Shiplog::Classes::BEM[key]
8
+ if base.nil?
9
+ raise ArgumentError, "Unknown Shiplog class key #{key.inspect}. Valid keys: #{Shiplog::Classes::KEYS.join(", ")}" unless Rails.env.production?
10
+ return shiplog_host_classes(key)
11
+ end
12
+
13
+ bem = [ base, *modifiers.compact.map { |modifier| "#{base}--#{modifier.to_s.dasherize}" } ]
14
+ [ *bem, shiplog_host_classes(key) ].compact_blank.join(" ")
15
+ end
16
+
17
+ private
18
+
19
+ def shiplog_host_classes(key)
20
+ configured = Shiplog.config.classes
21
+ Array(configured[key] || configured[key.to_s]).join(" ").squish.presence
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,14 @@
1
+ module Shiplog
2
+ class ApplicationJob < ActiveJob::Base
3
+ MAX_RATE_LIMIT_RETRIES = 5
4
+
5
+ rescue_from Github::RateLimited do |error|
6
+ if executions >= MAX_RATE_LIMIT_RETRIES
7
+ Rails.error.report(error, handled: false, context: { job: self.class.name, executions: })
8
+ raise error
9
+ end
10
+
11
+ retry_job wait_until: error.reset_at + 1.second
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,38 @@
1
+ module Shiplog
2
+ class RefreshJob < ApplicationJob
3
+ retry_on Github::Transient, attempts: 5, wait: :polynomially_longer
4
+
5
+ def perform(repository_full_name, kind, number)
6
+ configured_name = Shiplog.config.repository_configured?(repository_full_name)
7
+ unless configured_name
8
+ Rails.logger.info("[shiplog] Ignoring refresh for unconfigured repository #{repository_full_name}")
9
+ return
10
+ end
11
+
12
+ repository = Repository.find_or_create_by!(full_name: configured_name)
13
+ case kind.to_s
14
+ when "issue" then import(repository, number, Github::Queries::ISSUE, "issue", Sync::IssueImporter)
15
+ when "pull_request" then import(repository, number, Github::Queries::PULL_REQUEST, "pullRequest", Sync::PullRequestImporter)
16
+ else raise ArgumentError, "Unknown kind: #{kind.inspect}"
17
+ end
18
+ Drafter.call
19
+ end
20
+
21
+ private
22
+
23
+ def import(repository, number, query, field, importer)
24
+ owner, name = repository.full_name.split("/", 2)
25
+ node = Github::Client.new.query(query, { owner:, name:, number: }).dig("repository", field)
26
+ importer.new(repository).import(node) if node
27
+ close_deleted(repository, number, node) if field == "issue"
28
+ end
29
+
30
+ # Deleted issues come back as null; transferred ones may come back as a different node. Either way the local
31
+ # row at this repository and number is gone from GitHub: close it, keeping the row and its entries for audit.
32
+ def close_deleted(repository, number, node)
33
+ Issue.where(repository:, number:).where.not(node_id: node&.fetch("id")).find_each do |issue|
34
+ issue.update!(state: "closed", state_reason: "deleted", closed_at: issue.closed_at || Time.current)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,9 @@
1
+ module Shiplog
2
+ class RewriteEntryJob < ApplicationJob
3
+ discard_on ActiveRecord::RecordNotFound
4
+
5
+ def perform(entry_id, force: false)
6
+ Rewriter.new(Entry.find(entry_id)).call(force:)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,36 @@
1
+ module Shiplog
2
+ class ScanChangesJob < ApplicationJob
3
+ DEFAULT_WINDOW = 7.days
4
+
5
+ def perform
6
+ client = Github::Client.new
7
+ Shiplog.config.repositories.each do |full_name|
8
+ scan(Repository.find_or_create_by!(full_name:), client)
9
+ end
10
+ Drafter.call
11
+ end
12
+
13
+ private
14
+ def scan(repository, client)
15
+ since = repository.changes_scanned_at || DEFAULT_WINDOW.ago
16
+ started_at = Time.current
17
+ # Import late-synced PRs first so anything merged before `started_at` is visible to the scan.
18
+ Sync::RepositorySync.new(repository, client:).call
19
+ Sync::CommitScanner.new(repository, client:).call(since:)
20
+ draft_changes(ChangeCandidates.new(repository:, since:, before: started_at).to_a)
21
+ repository.update!(changes_scanned_at: started_at)
22
+ rescue Github::RateLimited
23
+ raise
24
+ rescue StandardError => error
25
+ Rails.error.report(error, handled: true, context: { repository: repository.full_name })
26
+ end
27
+
28
+ def draft_changes(candidates)
29
+ candidates.each { |subject| Drafter.draft(subject, "change", released_on: released_on(subject)) }
30
+ end
31
+
32
+ def released_on(subject)
33
+ (subject.is_a?(PullRequest) ? subject.merged_at : subject.committed_at).to_date
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,20 @@
1
+ module Shiplog
2
+ class SyncJob < ApplicationJob
3
+ def perform
4
+ client = Github::Client.new
5
+ repositories = Shiplog.config.repositories.map { |full_name| Repository.find_or_create_by!(full_name:) }
6
+ repositories.each { |repository| sync(repository, client) }
7
+ Drafter.call
8
+ end
9
+
10
+ private
11
+
12
+ def sync(repository, client)
13
+ Sync::RepositorySync.new(repository, client:).call
14
+ rescue Github::RateLimited
15
+ raise
16
+ rescue StandardError => error
17
+ Rails.error.report(error, handled: true, context: { repository: repository.full_name })
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,5 @@
1
+ module Shiplog
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,38 @@
1
+ module Shiplog
2
+ class BoardArrangement
3
+ def self.unpin!(issue)
4
+ issue.update!(pinned_column: nil, position: nil)
5
+ end
6
+
7
+ # Pins only the moved card to the column; every id in issue_ids (the full visible order of the column) gets a position.
8
+ def initialize(column:, moved_id:, issue_ids:)
9
+ @column = column.to_s
10
+ @moved_id = Integer(moved_id.to_s, 10)
11
+ @issue_ids = Array(issue_ids).map { |id| Integer(id.to_s, 10) }.uniq
12
+ end
13
+
14
+ def save!
15
+ raise ArgumentError, "Unknown column: #{@column.inspect}" unless Issue::COLUMNS.include?(@column)
16
+ raise ArgumentError, "moved_id must be one of issue_ids" unless @issue_ids.include?(@moved_id)
17
+
18
+ Issue.transaction do
19
+ issues = Issue.where(id: roadmap_issue_ids & @issue_ids).index_by(&:id)
20
+ missing = @issue_ids - issues.keys
21
+ raise ActiveRecord::RecordNotFound, "Not roadmap items: #{missing.join(", ")}" if missing.any?
22
+
23
+ @issue_ids.each_with_index do |id, position|
24
+ issue = issues.fetch(id)
25
+ issue.pinned_column = @column if id == @moved_id
26
+ issue.position = position
27
+ issue.save!
28
+ end
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def roadmap_issue_ids
35
+ Roadmap.new(published_only: false).items.map { |item| item.issue.id }
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,77 @@
1
+ module Shiplog
2
+ class ChangeCandidates
3
+ COMMIT_TYPE = /\A(\w+)(\([^)]*\))?!?:/
4
+
5
+ def initialize(repository:, since:, before:)
6
+ @repository = repository
7
+ @window = since...before
8
+ end
9
+
10
+ def to_a
11
+ pull_requests + commits
12
+ end
13
+
14
+ private
15
+ def pull_requests
16
+ candidates = @repository.pull_requests.where(state: "merged", merged_at: @window).where.missing(:entries).to_a
17
+ .reject { |pull_request| excluded_label?(pull_request) || excluded_type?(pull_request) || Shiplog.config.excluded_author?(pull_request.author_login) }
18
+ public_ids = pull_request_ids_in_public_tree(candidates)
19
+ candidates.reject { |pull_request| public_ids.include?(pull_request.node_id) }
20
+ end
21
+
22
+ def commits
23
+ @repository.commits.where(committed_at: @window).where.missing(:entries).order(:committed_at).to_a
24
+ .select { |commit| eligible_commit?(commit) }
25
+ end
26
+
27
+ def excluded_label?(pull_request)
28
+ pull_request.labels.intersect?(Shiplog.config.excluded_labels)
29
+ end
30
+
31
+ # Untyped PR titles stay in; the rewriter decides whether customers would notice.
32
+ def excluded_type?(pull_request)
33
+ type = pull_request.title[COMMIT_TYPE, 1]
34
+ type.present? && !Shiplog.config.commit_types.include?(type)
35
+ end
36
+
37
+ def eligible_commit?(commit)
38
+ type = commit.title[COMMIT_TYPE, 1]
39
+ type && Shiplog.config.commit_types.include?(type) && !Shiplog.config.excluded_author?(commit.author_login)
40
+ end
41
+
42
+ # Node ids of the pull requests linked to an issue that is public or has a public ancestor.
43
+ # One query for the links, one for the linked issues, then one per ancestor level.
44
+ def pull_request_ids_in_public_tree(pull_requests)
45
+ return Set.new if pull_requests.empty?
46
+
47
+ links = IssueLink.where(pull_request_node_id: pull_requests.map(&:node_id)).pluck(:issue_node_id, :pull_request_node_id)
48
+ public_issue_ids = public_tree_issue_ids(links.map(&:first).uniq)
49
+ links.filter_map { |issue_id, pull_request_id| pull_request_id if public_issue_ids.include?(issue_id) }.to_set
50
+ end
51
+
52
+ def public_tree_issue_ids(issue_ids)
53
+ public_ids = Set.new
54
+ current = Issue.where(node_id: issue_ids).index_by(&:node_id) # origin node_id => issue at the current level
55
+ seen = current.transform_values { |issue| Set[issue.node_id] }
56
+
57
+ (Issue::MAX_DEPTH + 1).times do
58
+ break if current.empty?
59
+
60
+ current.each { |origin, issue| public_ids << origin if issue.public? }
61
+ current = climb(current.reject { |origin, _| public_ids.include?(origin) }, seen)
62
+ end
63
+ public_ids
64
+ end
65
+
66
+ def climb(current, seen)
67
+ parent_ids = current.values.filter_map(&:parent_node_id).uniq
68
+ return {} if parent_ids.empty?
69
+
70
+ parents = Issue.where(node_id: parent_ids).index_by(&:node_id)
71
+ current.each_with_object({}) do |(origin, issue), next_level|
72
+ parent = parents[issue.parent_node_id]
73
+ next_level[origin] = parent if parent && seen[origin].add?(parent.node_id)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,9 @@
1
+ module Shiplog
2
+ class Commit < ApplicationRecord
3
+ belongs_to :repository
4
+ has_many :entries, as: :subject, dependent: :destroy
5
+
6
+ def title = message.lines.first.to_s.strip
7
+ def body = message.lines.drop(1).join.strip
8
+ end
9
+ end
@@ -0,0 +1,69 @@
1
+ module Shiplog
2
+ module Drafter
3
+ # Only issues closed within this window get a feature draft, so a first-run backfill
4
+ # cannot flood the inbox with years-old work. Recently closed issues are still backfilled.
5
+ FEATURE_LOOKBACK = 90.days
6
+
7
+ def self.call
8
+ draft_roadmap_items
9
+ draft_shipped_features
10
+ requeue_lost_rewrites
11
+ end
12
+
13
+ # Rewrite jobs can be lost (queue flushed, process killed). Drafts that never got a result nor an error are retried.
14
+ REQUEUE_AFTER = 1.hour
15
+ REQUEUE_LIMIT = 100
16
+
17
+ # Returns the new entry, or nil when one already exists for the subject and kind.
18
+ def self.draft(subject, kind, released_on: nil)
19
+ entry = Entry.create_or_find_by!(subject:, kind:) do |new_entry|
20
+ new_entry.title = subject.title
21
+ new_entry.body = subject.body
22
+ new_entry.released_on = released_on
23
+ end
24
+ return unless entry.previously_new_record?
25
+
26
+ RewriteEntryJob.perform_later(entry.id)
27
+ entry
28
+ end
29
+
30
+ def self.draft_roadmap_items
31
+ Roadmap.new(published_only: false).items.each do |item|
32
+ draft(item.issue, "roadmap") unless item.entry
33
+ end
34
+ end
35
+
36
+ def self.draft_shipped_features
37
+ issues = shipped_candidates
38
+ return if issues.empty?
39
+
40
+ index = Issue.children_index(issues)
41
+ merged_at_by_issue = merged_at_by_issue_node_id(issues, index)
42
+ issues.each do |issue|
43
+ tree = [ issue, *Issue.descendants_in(index, issue) ]
44
+ merged_at = tree.filter_map { |member| merged_at_by_issue[member.node_id] }.max
45
+ draft(issue, "feature", released_on: merged_at.to_date) if merged_at
46
+ end
47
+ end
48
+
49
+ def self.requeue_lost_rewrites
50
+ Entry.draft.where(rewritten_at: nil, rewrite_error: nil, created_at: ..REQUEUE_AFTER.ago)
51
+ .order(:created_at).limit(REQUEUE_LIMIT).ids.each { |id| RewriteEntryJob.perform_later(id) }
52
+ end
53
+
54
+ def self.shipped_candidates
55
+ drafted = Entry.feature.where(subject_type: Issue.name).select(:subject_id)
56
+ Issue.publicly_labeled.where(state: "closed", state_reason: "completed", closed_at: FEATURE_LOOKBACK.ago..)
57
+ .where.not(id: drafted)
58
+ .includes(:parent).reject { |issue| issue.parent&.public? }
59
+ end
60
+
61
+ def self.merged_at_by_issue_node_id(issues, index)
62
+ node_ids = issues.map(&:node_id) + index.values.flatten.map(&:node_id)
63
+ IssueLink.where(issue_node_id: node_ids.uniq).joins(:pull_request).merge(PullRequest.where(state: "merged"))
64
+ .group(:issue_node_id).maximum(PullRequest.arel_table[:merged_at])
65
+ end
66
+
67
+ private_class_method :draft_roadmap_items, :draft_shipped_features, :requeue_lost_rewrites, :shipped_candidates, :merged_at_by_issue_node_id
68
+ end
69
+ end
@@ -0,0 +1,42 @@
1
+ module Shiplog
2
+ class Entry < ApplicationRecord
3
+ class InvalidTransition < StandardError; end
4
+
5
+ belongs_to :subject, polymorphic: true
6
+
7
+ enum :kind, %w[roadmap feature change].index_by(&:itself), validate: true
8
+ enum :status, %w[draft approved discarded].index_by(&:itself), default: "draft", validate: true
9
+
10
+ validates :released_on, presence: true, if: -> { approved? && !roadmap? }
11
+
12
+ def self.digest_for(subject)
13
+ Digest::SHA256.hexdigest([ subject.title, subject.body ].join("\n"))
14
+ end
15
+
16
+ def approve!(by:)
17
+ require_status!("approve", from: "draft")
18
+ update!(status: "approved", approved_at: Time.current, approved_by: by, discard_reason: nil,
19
+ released_on: released_on || Date.current)
20
+ end
21
+
22
+ def discard!(reason:)
23
+ require_status!("discard", from: "draft")
24
+ update!(status: "discarded", discard_reason: reason)
25
+ end
26
+
27
+ def restore!
28
+ require_status!("restore", from: "discarded")
29
+ update!(status: "draft", discard_reason: nil)
30
+ end
31
+
32
+ def source_changed?
33
+ source_digest.present? && source_digest != self.class.digest_for(subject)
34
+ end
35
+
36
+ private
37
+
38
+ def require_status!(action, from:)
39
+ raise InvalidTransition, "Cannot #{action} an entry that is #{status}" unless status == from
40
+ end
41
+ end
42
+ end