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,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,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,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,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
|