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,55 @@
1
+ module Shiplog
2
+ module Admin
3
+ class EntriesController < ApplicationController
4
+ PER_PAGE = 50
5
+ MAX_PAGE = 100_000
6
+
7
+ before_action :set_entry, only: %i[edit update]
8
+
9
+ def index
10
+ @status = Entry.statuses.key(params[:status].to_s) || "draft"
11
+ @kind = Entry.kinds.key(params[:kind].to_s)
12
+ @page = page_number
13
+ scope = Entry.where(status: @status)
14
+ scope = scope.where(kind: @kind) if @kind
15
+ records = scope.includes(:subject).order(updated_at: :desc, id: :desc)
16
+ .offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
17
+ @more = records.size > PER_PAGE
18
+ @entries = records.first(PER_PAGE)
19
+ preload_repositories(@entries)
20
+ end
21
+
22
+ def edit
23
+ end
24
+
25
+ def update
26
+ if @entry.update(entry_params)
27
+ redirect_to root_path, notice: t("shiplog.admin.entries.saved")
28
+ else
29
+ render :edit, status: :unprocessable_content
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ # Every subject type (issue, pull request, commit) belongs to a repository; load them once per type.
36
+ def preload_repositories(entries)
37
+ entries.filter_map(&:subject).group_by(&:class).each_value do |subjects|
38
+ ActiveRecord::Associations::Preloader.new(records: subjects, associations: :repository).call
39
+ end
40
+ end
41
+
42
+ def set_entry
43
+ @entry = Entry.find(params[:id])
44
+ end
45
+
46
+ def entry_params
47
+ params.expect(entry: %i[title body released_on])
48
+ end
49
+
50
+ def page_number
51
+ Integer(params[:page].to_s, 10, exception: false).to_i.clamp(1, MAX_PAGE)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,21 @@
1
+ module Shiplog
2
+ module Admin
3
+ class SyncsController < ApplicationController
4
+ DELIVERIES_SHOWN = 20
5
+
6
+ def show
7
+ repositories = Repository.where(full_name: Shiplog.config.repositories).index_by(&:full_name)
8
+ @repositories = Shiplog.config.repositories.map { |full_name| [ full_name, repositories[full_name] ] }
9
+ @deliveries = WebhookDelivery.order(received_at: :desc, id: :desc).limit(DELIVERIES_SHOWN)
10
+ @webhook_secret_missing = Shiplog.config.webhook_secret.blank?
11
+ @github_token_missing = Shiplog.config.resolved_github_token.blank?
12
+ end
13
+
14
+ def create
15
+ SyncJob.perform_later
16
+ ScanChangesJob.perform_later if params[:scan] == "1"
17
+ redirect_to sync_path, notice: t("shiplog.admin.sync.queued")
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,5 @@
1
+ module Shiplog
2
+ class ApplicationController < ActionController::Base
3
+ helper Shiplog::FrontendHelper
4
+ end
5
+ end
@@ -0,0 +1,24 @@
1
+ module Shiplog
2
+ class ChangelogsController < PublicController
3
+ PER_PAGE = 30
4
+ MAX_PAGE = 100_000
5
+
6
+ def show
7
+ @page = page_number
8
+ scope = Entry.approved.where(kind: %w[feature change]).where.not(released_on: nil)
9
+ .order(released_on: :desc, approved_at: :desc, id: :desc)
10
+ records = scope.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
11
+ return head(:not_found) if @page > 1 && records.empty?
12
+
13
+ @older = records.size > PER_PAGE
14
+ @months = records.first(PER_PAGE).group_by { |entry| entry.released_on.beginning_of_month }
15
+ fresh_when etag: [ *shiplog_etag_parts, @page, @older, @months.values.flatten.map { |entry| [ entry.id, entry.updated_at ] } ]
16
+ end
17
+
18
+ private
19
+
20
+ def page_number
21
+ Integer(params[:page].to_s, 10, exception: false).to_i.clamp(1, MAX_PAGE)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,21 @@
1
+ module Shiplog
2
+ class FrontendsController < ApplicationController
3
+ ASSET_DIRECTORY = Engine.root.join("app/frontend/shiplog")
4
+ ASSETS = {
5
+ "shiplog.css" => { path: ASSET_DIRECTORY.join("shiplog.css"), type: "text/css" },
6
+ "admin.css" => { path: ASSET_DIRECTORY.join("admin.css"), type: "text/css" },
7
+ "board.js" => { path: ASSET_DIRECTORY.join("board.js"), type: "text/javascript" }
8
+ }.freeze
9
+
10
+ # The version segment busts caches; stale versions are 404 so they are never cached as the current file.
11
+ skip_after_action :verify_same_origin_request, raise: false
12
+
13
+ def show
14
+ return head(:not_found) unless params[:version] == Shiplog::VERSION
15
+
16
+ asset = ASSETS[params[:name].to_s] or return head(:not_found)
17
+ expires_in 1.year, public: true, immutable: true
18
+ send_file asset[:path], type: asset[:type], disposition: :inline
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,19 @@
1
+ module Shiplog
2
+ # Parent controller for the public pages. The parent class is resolved when this
3
+ # file loads, so `config.public_parent_controller` must be set in an initializer
4
+ # (config/initializers/shiplog.rb), not later at runtime.
5
+ class PublicController < Shiplog.config.public_parent_controller.constantize
6
+ helper Shiplog::FrontendHelper
7
+ helper Shiplog::StylingHelper
8
+ helper Shiplog::HostRoutesHelper
9
+ layout -> { Shiplog.config.public_layout }
10
+
11
+ private
12
+
13
+ # What the rendered markup depends on besides the data: the locale, the gem version and the styling
14
+ # config. Host parent controllers rendering per-user layout content should declare `etag { current_user&.id }`.
15
+ def shiplog_etag_parts
16
+ [ I18n.locale, Shiplog::VERSION, Shiplog.config.classes.hash, Shiplog.config.stylesheet ]
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,14 @@
1
+ module Shiplog
2
+ class RoadmapsController < PublicController
3
+ def show
4
+ @columns = Roadmap.new(published_only: true).columns
5
+ fresh_when etag: [ *shiplog_etag_parts, etag_for(@columns) ]
6
+ end
7
+
8
+ private
9
+
10
+ def etag_for(columns)
11
+ columns.transform_values { |items| items.map { |item| [ item.entry.id, item.entry.updated_at ] } }
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,121 @@
1
+ module Shiplog
2
+ class WebhooksController < ActionController::Base
3
+ MAX_BODY_BYTES = 25.megabytes
4
+
5
+ skip_forgery_protection
6
+ layout false
7
+
8
+ before_action :disable_session
9
+ before_action :read_body_within_limit
10
+ before_action :require_secret
11
+ before_action :verify_signature
12
+ before_action :require_delivery_id
13
+
14
+ def create
15
+ payload = parse_payload or return head(:bad_request)
16
+ event = request.headers["X-GitHub-Event"].to_s
17
+ return head(:ok) if event == "ping"
18
+
19
+ delivery = record_delivery(event, payload) or return head(:accepted)
20
+ enqueue_refreshes(delivery, event, payload)
21
+ end
22
+
23
+ private
24
+
25
+ def disable_session
26
+ request.session_options[:skip] = true
27
+ end
28
+
29
+ # Reads the body once, never more than the cap plus one byte; the result is reused for signature and parsing.
30
+ def read_body_within_limit
31
+ return head :content_too_large if request.content_length.to_i > MAX_BODY_BYTES
32
+
33
+ # Content-Length may be absent (chunked) or wrong; check what was actually received.
34
+ @body = request.body.read(MAX_BODY_BYTES + 1).to_s
35
+ request.body.rewind
36
+ head :content_too_large if @body.bytesize > MAX_BODY_BYTES
37
+ end
38
+
39
+ def require_delivery_id
40
+ head :bad_request if delivery_id.blank?
41
+ end
42
+
43
+ def delivery_id
44
+ request.headers["X-GitHub-Delivery"].to_s
45
+ end
46
+
47
+ def require_secret
48
+ head :service_unavailable if Shiplog.config.webhook_secret.blank?
49
+ end
50
+
51
+ def verify_signature
52
+ signature = WebhookSignature.new(
53
+ secret: Shiplog.config.webhook_secret.to_s,
54
+ body: @body,
55
+ header: request.headers["X-Hub-Signature-256"]
56
+ )
57
+ head :unauthorized unless signature.valid?
58
+ end
59
+
60
+ def parse_payload
61
+ payload = JSON.parse(@body)
62
+ payload if payload.is_a?(Hash)
63
+ rescue JSON::ParserError
64
+ nil
65
+ end
66
+
67
+ # Returns the delivery to process: a new one, or an earlier one that failed (error, never processed).
68
+ def record_delivery(event, payload)
69
+ WebhookDelivery.create!(
70
+ delivery_id: delivery_id,
71
+ event: event,
72
+ action: payload["action"],
73
+ repository_full_name: payload.dig("repository", "full_name"),
74
+ received_at: Time.current
75
+ )
76
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
77
+ retry_failed_delivery
78
+ end
79
+
80
+ # Claims atomically (clearing the error) so concurrent redeliveries do not both reprocess.
81
+ def retry_failed_delivery
82
+ failed = WebhookDelivery.where(delivery_id: delivery_id, processed_at: nil).where.not(error: nil)
83
+ delivery = failed.first
84
+ # Known gap: if the process dies after this claim but before processed_at is set, the delivery stays stuck;
85
+ # the scheduled SyncJob repairs the data.
86
+ delivery if delivery && failed.where(id: delivery.id).update_all(error: nil, updated_at: Time.current) == 1
87
+ end
88
+
89
+ def enqueue_refreshes(delivery, event, payload)
90
+ refresh_targets(event, payload).each { |target| RefreshJob.perform_later(*target) }
91
+ delivery.update!(processed_at: Time.current)
92
+ head :accepted
93
+ rescue => error
94
+ delivery.update(error: "#{error.class}: #{error.message}")
95
+ head :internal_server_error
96
+ end
97
+
98
+ def refresh_targets(event, payload)
99
+ targets =
100
+ case event
101
+ when "issues" then [ [ payload.dig("repository", "full_name"), "issue", payload.dig("issue", "number") ] ]
102
+ when "pull_request" then [ [ payload.dig("repository", "full_name"), "pull_request", payload.dig("pull_request", "number") ] ]
103
+ when "sub_issues" then sub_issue_targets(payload)
104
+ else []
105
+ end
106
+ targets.filter_map { |repo, kind, number| [ Shiplog.config.repository_configured?(repo), kind, number ] if number }
107
+ .select(&:first).uniq
108
+ end
109
+
110
+ def sub_issue_targets(payload)
111
+ here = payload.dig("repository", "full_name")
112
+ if payload["action"].to_s.start_with?("parent_issue")
113
+ [ [ here, "issue", payload.dig("sub_issue", "number") ],
114
+ [ payload.dig("parent_issue_repo", "full_name"), "issue", payload.dig("parent_issue", "number") ] ]
115
+ else
116
+ [ [ here, "issue", payload.dig("parent_issue", "number") ],
117
+ [ payload.dig("sub_issue_repo", "full_name"), "issue", payload.dig("sub_issue", "number") ] ]
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,137 @@
1
+ /*
2
+ * Shiplog admin. Everything lives in the `shiplog` cascade layer and is scoped under .shiplog-admin,
3
+ * so it neither leaks into nor is unexpectedly overridden by a host admin. Unlayered host CSS wins.
4
+ *
5
+ * Inside a host layout the admin inherits the host's text colour and uses surfaces and borders mixed
6
+ * from currentColor, so it suits light and dark hosts. Only the standalone chrome
7
+ * (`.shiplog-admin--standalone`), which paints the page background, follows prefers-color-scheme.
8
+ *
9
+ * Variables (set them on :root or .shiplog-admin):
10
+ * --shiplog-admin-fg, -muted, -bg, -surface, -border, -accent
11
+ */
12
+ @layer shiplog {
13
+ /* Host-safe defaults. */
14
+ .shiplog-admin {
15
+ --sa-fg: var(--shiplog-admin-fg, currentColor);
16
+ --sa-muted: var(--shiplog-admin-muted, color-mix(in srgb, currentColor 65%, transparent));
17
+ --sa-bg: var(--shiplog-admin-bg, color-mix(in srgb, currentColor 6%, transparent));
18
+ --sa-surface: var(--shiplog-admin-surface, transparent);
19
+ --sa-border: var(--shiplog-admin-border, color-mix(in srgb, currentColor 22%, transparent));
20
+ --sa-accent: var(--shiplog-admin-accent, #2f81f7);
21
+ }
22
+
23
+ /* The standalone chrome owns the page background, so it picks its own light and dark palette. */
24
+ .shiplog-admin.shiplog-admin--standalone {
25
+ --sa-fg: var(--shiplog-admin-fg, #1f2328);
26
+ --sa-muted: var(--shiplog-admin-muted, #59636e);
27
+ --sa-bg: var(--shiplog-admin-bg, #f6f8fa);
28
+ --sa-surface: var(--shiplog-admin-surface, #ffffff);
29
+ --sa-border: var(--shiplog-admin-border, #d1d9e0);
30
+ --sa-accent: var(--shiplog-admin-accent, #0969da);
31
+ }
32
+
33
+ @media (prefers-color-scheme: dark) {
34
+ .shiplog-admin.shiplog-admin--standalone {
35
+ --sa-fg: var(--shiplog-admin-fg, #e6edf3);
36
+ --sa-muted: var(--shiplog-admin-muted, #9198a1);
37
+ --sa-bg: var(--shiplog-admin-bg, #0d1117);
38
+ --sa-surface: var(--shiplog-admin-surface, #151b23);
39
+ --sa-border: var(--shiplog-admin-border, #3d444d);
40
+ --sa-accent: var(--shiplog-admin-accent, #4493f8);
41
+ }
42
+ }
43
+
44
+ .shiplog-admin {
45
+ color: var(--sa-fg);
46
+ line-height: 1.5;
47
+ }
48
+
49
+ /* Only the engine's own layout paints the page; inside a host layout the host owns background and font. */
50
+ .shiplog-admin--standalone {
51
+ background: var(--sa-bg);
52
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
53
+ min-height: 100vh;
54
+ }
55
+
56
+ .shiplog-admin a { color: var(--sa-accent); }
57
+
58
+ .shiplog-admin .shiplog-admin-nav {
59
+ background: var(--sa-surface);
60
+ border-bottom: 1px solid var(--sa-border);
61
+ padding: 0.75rem 1rem;
62
+ }
63
+
64
+ .shiplog-admin .shiplog-admin-nav--sub { background: none; border-bottom: 0; padding: 0 0 0.75rem; }
65
+
66
+ .shiplog-admin .shiplog-admin-nav__list { display: flex; gap: 1.25rem; list-style: none; margin: 0; padding: 0; }
67
+
68
+ .shiplog-admin .shiplog-admin__main { margin: 0 auto; max-width: 80rem; padding: 1.5rem 1rem; }
69
+
70
+ .shiplog-admin .shiplog-admin__flash { border: 1px solid var(--sa-border); border-radius: 0.375rem; padding: 0.5rem 0.75rem; }
71
+ .shiplog-admin .shiplog-admin__flash--alert { border-color: #cf222e; }
72
+
73
+ .shiplog-admin .shiplog-admin__title { font-size: 1.5rem; margin: 0 0 1rem; }
74
+ .shiplog-admin .shiplog-admin__muted { color: var(--sa-muted); font-size: 0.875rem; }
75
+ .shiplog-admin .shiplog-admin__empty { color: var(--sa-muted); }
76
+
77
+ .shiplog-admin .shiplog-admin-filters { display: flex; flex-wrap: wrap; gap: 0.5rem 2rem; margin-bottom: 1rem; }
78
+ .shiplog-admin .shiplog-admin-filters__list { display: flex; gap: 0.75rem; list-style: none; margin: 0; padding: 0; }
79
+ .shiplog-admin .shiplog-admin-filters__link[aria-current] { color: var(--sa-fg); font-weight: 600; text-decoration: none; }
80
+
81
+ .shiplog-admin .shiplog-admin-table { background: var(--sa-surface); border: 1px solid var(--sa-border); border-collapse: collapse; width: 100%; }
82
+ .shiplog-admin .shiplog-admin-table th,
83
+ .shiplog-admin .shiplog-admin-table td { border-bottom: 1px solid var(--sa-border); padding: 0.5rem 0.75rem; text-align: left; vertical-align: top; }
84
+ .shiplog-admin .shiplog-admin-table__actions { display: flex; gap: 0.5rem; }
85
+ .shiplog-admin .shiplog-admin-table__actions form { margin: 0; }
86
+
87
+ .shiplog-admin .shiplog-admin-pagination { display: flex; justify-content: space-between; margin-top: 1rem; }
88
+ .shiplog-admin .shiplog-admin-pagination__next { margin-left: auto; }
89
+
90
+ .shiplog-admin .shiplog-badge {
91
+ background: var(--sa-bg);
92
+ border: 1px solid var(--sa-border);
93
+ border-radius: 1rem;
94
+ font-size: 0.75rem;
95
+ padding: 0.0625rem 0.5rem;
96
+ }
97
+ .shiplog-admin .shiplog-badge--warning { border-color: #bf8700; color: #9a6700; }
98
+ .shiplog-admin .shiplog-badge--error { border-color: #cf222e; color: #cf222e; }
99
+
100
+ .shiplog-admin .shiplog-button {
101
+ background: var(--sa-surface);
102
+ border: 1px solid var(--sa-border);
103
+ border-radius: 0.375rem;
104
+ color: var(--sa-fg);
105
+ cursor: pointer;
106
+ display: inline-block;
107
+ font: inherit;
108
+ padding: 0.25rem 0.75rem;
109
+ text-decoration: none;
110
+ }
111
+ .shiplog-admin .shiplog-button--primary { background: var(--sa-accent); border-color: var(--sa-accent); color: #fff; }
112
+
113
+ .shiplog-admin .shiplog-admin-split { display: grid; gap: 1.5rem; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); }
114
+ .shiplog-admin .shiplog-admin-panel { background: var(--sa-surface); border: 1px solid var(--sa-border); border-radius: 0.5rem; padding: 1rem; }
115
+ .shiplog-admin .shiplog-admin-panel h2 { font-size: 1.125rem; margin-top: 0; }
116
+ .shiplog-admin .shiplog-admin-form label { display: block; font-weight: 600; margin-bottom: 0.25rem; }
117
+ .shiplog-admin .shiplog-admin-form input[type=text],
118
+ .shiplog-admin .shiplog-admin-form textarea { box-sizing: border-box; font: inherit; width: 100%; }
119
+ .shiplog-admin .shiplog-admin-actions { border-top: 1px solid var(--sa-border); display: grid; gap: 0.75rem; margin-top: 1rem; padding-top: 1rem; }
120
+ .shiplog-admin .shiplog-admin-inline-form { display: flex; flex-wrap: wrap; gap: 0.5rem; }
121
+ .shiplog-admin .shiplog-admin-source { background: var(--sa-bg); overflow-x: auto; padding: 0.75rem; white-space: pre-wrap; word-break: break-word; }
122
+
123
+ .shiplog-admin .shiplog-board { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); }
124
+ .shiplog-admin .shiplog-board__title { font-size: 1.125rem; margin: 0 0 0.5rem; }
125
+ .shiplog-admin .shiplog-board__column { display: flex; flex-direction: column; }
126
+ .shiplog-admin .shiplog-board__column .shiplog-board__cards { flex: 1; }
127
+ .shiplog-admin .shiplog-board__cards { display: grid; align-content: start; gap: 0.5rem; min-height: 6rem; padding: 0.5rem; background: var(--sa-bg); border: 1px dashed var(--sa-border); border-radius: 0.5rem; }
128
+ .shiplog-admin .shiplog-card { background: var(--sa-surface); border: 1px solid var(--sa-border); border-radius: 0.375rem; cursor: grab; padding: 0.5rem 0.75rem; }
129
+ .shiplog-admin .shiplog-card--dragging { opacity: 0.5; }
130
+ .shiplog-admin .shiplog-card__title { font-size: 0.9375rem; margin: 0 0 0.25rem; }
131
+ .shiplog-admin .shiplog-card__meta { color: var(--sa-muted); font-size: 0.8125rem; margin: 0 0 0.5rem; }
132
+ .shiplog-admin .shiplog-card__form { align-items: center; display: flex; gap: 0.375rem; margin-bottom: 0.25rem; }
133
+ .shiplog-admin .shiplog-card__label { font-size: 0.8125rem; }
134
+
135
+ /* The engine layout's own body (not scoped: it is not inside .shiplog-admin). */
136
+ .shiplog-admin-body { margin: 0; }
137
+ }
@@ -0,0 +1,77 @@
1
+ const board = document.querySelector("[data-board-url]")
2
+
3
+ if (board) {
4
+ // Host layouts may not render csrf_meta_tags, so the board element carries the token as a fallback.
5
+ const csrfToken = document.querySelector("meta[name=csrf-token]")?.content ?? board.dataset.csrfToken
6
+ let dragged = null
7
+ let origin = null
8
+ let dropped = false
9
+
10
+ // The whole column section (heading included) is a drop target; cards live in its [data-column] list.
11
+ const listFor = (target) => target.closest?.("section")?.querySelector("[data-column]") ?? null
12
+
13
+ const save = async (list, card) => {
14
+ const issueIds = [...list.querySelectorAll(".shiplog-card")].map((el) => Number(el.dataset.issueId))
15
+ const response = await fetch(board.dataset.boardUrl, {
16
+ method: "PATCH",
17
+ headers: { "Content-Type": "application/json", "Accept": "application/json", "X-CSRF-Token": csrfToken },
18
+ body: JSON.stringify({ column: list.dataset.column, moved_id: Number(card.dataset.issueId), issue_ids: issueIds })
19
+ })
20
+ if (!response.ok) return window.location.reload()
21
+
22
+ // The server renders the moved card as a reload would (Pinned badge, column select, Unpin).
23
+ const { card: html } = await response.json()
24
+ const template = document.createElement("template")
25
+ template.innerHTML = html.trim()
26
+ card.replaceWith(template.content.firstElementChild)
27
+ }
28
+
29
+ const cardAfter = (list, y) => {
30
+ const cards = [...list.querySelectorAll(".shiplog-card:not(.shiplog-card--dragging)")]
31
+ return cards.find((card) => {
32
+ const box = card.getBoundingClientRect()
33
+ return y < box.top + box.height / 2
34
+ })
35
+ }
36
+
37
+ const restore = () => {
38
+ const { parent, nextSibling } = origin
39
+ parent.insertBefore(dragged, nextSibling?.parentNode === parent ? nextSibling : null)
40
+ }
41
+
42
+ board.addEventListener("dragstart", (event) => {
43
+ const card = event.target.closest?.(".shiplog-card")
44
+ if (!card) return
45
+ dragged = card
46
+ origin = { parent: card.parentNode, nextSibling: card.nextSibling }
47
+ dropped = false
48
+ event.dataTransfer.setData("text/plain", card.dataset.issueId)
49
+ event.dataTransfer.effectAllowed = "move"
50
+ card.classList.add("shiplog-card--dragging")
51
+ })
52
+
53
+ board.addEventListener("dragend", () => {
54
+ if (!dragged) return
55
+ dragged.classList.remove("shiplog-card--dragging")
56
+ if (!dropped) restore()
57
+ dragged = null
58
+ origin = null
59
+ dropped = false
60
+ })
61
+
62
+ board.addEventListener("dragover", (event) => {
63
+ const list = listFor(event.target)
64
+ if (!dragged || !list) return
65
+ event.preventDefault()
66
+ event.dataTransfer.dropEffect = "move"
67
+ list.insertBefore(dragged, cardAfter(list, event.clientY) ?? null)
68
+ })
69
+
70
+ board.addEventListener("drop", (event) => {
71
+ const list = listFor(event.target)
72
+ if (!dragged || !list) return
73
+ event.preventDefault()
74
+ dropped = true
75
+ save(list, dragged).catch(() => window.location.reload())
76
+ })
77
+ }