plan_my_stuff 0.20.0 → 0.21.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 06744d7c7bc63206522ce6d7c9176eeb8e93fabb4de903d7df32b17cfae6948d
4
- data.tar.gz: 2338f2f85989b2ee3b29a48c114a82d36c8105c13e4caf19b2601ff02cbf063c
3
+ metadata.gz: 4eda8634e75b318cd847d512405f71d4ffe2b0d7a6beccd6404912a6c64f1c2b
4
+ data.tar.gz: 80c7edb2a0e861c3dc0f76b06ae23232bde0ed52d04f4cdada69bd29aa8107dd
5
5
  SHA512:
6
- metadata.gz: dd143db048f77a46fe7875b1592b9ff1dc26e6ddac13af60c177ae977f799606d172a23ca861222a1a133d53fb91d16be19dd40d8f7027b680316276d99088f0
7
- data.tar.gz: 607957cf66e887c31bb6ab3f9a93726da0cf853cb3d43e8d34857b0dd789981788de4f0c8c6e076ad5236141732867ed9641971dcc683df5e4683e6b726542f3
6
+ metadata.gz: 3336630d6d5ee6b8bad51fdfa08a647aa58933e0f30a36241fd137a4d3f5b67656386d36fd0990d289499a7cf2643469e32c1642d4f2d5811be5fc671ea9d420
7
+ data.tar.gz: 15db293bc2203d60ca7e2b5d102035b1a8d064a74b84666e13d0fbcad1ab15dbe576f02af2f7582c56c9f65246dbb23e41043f6036e304801722c40890c463fc
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.21.1
4
+
5
+ ### Added
6
+
7
+ - `config.eager_load_controllers_on_boot` (default `false`). When `true`, the engine eager-loads
8
+ `app/controllers/` in `after_initialize` so consuming apps with `eager_load = false` (dev mode) see
9
+ `PlanMyStuff::*Controller` constants without having to reference them first. Opt in if the host app
10
+ probes engine controllers via `defined?`, e.g. `defined?(PlanMyStuff::Issues::TakesController)` would
11
+ otherwise return `nil` until something triggered Zeitwerk autoload. Falls back to `Kernel#require` on
12
+ Zeitwerk < 2.6.2 (which lacks `eager_load_dir`).
13
+
14
+ ## 0.21.0
15
+
16
+ ### Breaking
17
+
18
+ - `PMS::Attachment` no longer accepts/stores a single `url:` attribute. Construct with explicit
19
+ `filename:`, `owner:`, `repo:`, `sha:`, `path:` instead; `#url` is now a derived method that returns the
20
+ GitHub blob viewer URL (`https://github.com/{owner}/{repo}/blob/{sha}/{path}`) rather than the
21
+ `raw.githubusercontent.com` URL. `#to_h` emits the structured fields and no longer includes a `url` key.
22
+ Persisted comment metadata in the legacy `{filename, url}` shape is migrated on read and rewritten in the
23
+ new shape on next save (closes #70).
24
+
25
+ ### Added
26
+
27
+ - Comment bodies now render a visible `<details><summary>attachments (N)</summary>` block listing each
28
+ attachment as a markdown link to its blob URL, between the `pms-metadata` block and the user body. The
29
+ parser strips this block on read so round-trips stay clean.
30
+
3
31
  ## 0.20.0
4
32
 
5
33
  ### Breaking
data/CONFIGURATION.md CHANGED
@@ -49,6 +49,19 @@ config.repo_nicknames = { safety: 'Compliance' } # :element -> "Element", :under
49
49
  `Issue#to_param` then returns `"Element-1234"` / `"Compliance-567"`, encoding both repo and number in a single
50
50
  URL segment so `youtrack_issue_path(@issue)` works without a `repo:` query param.
51
51
 
52
+ ## Attachments
53
+
54
+ | Option | Type | Default | Description |
55
+ |---|---|---|---|
56
+ | `attachment_repo` | `String` | `'pms-attachments'` | Bare repo (under `organization`) for uploaded attachments. |
57
+
58
+ The repo must already exist; the uploader does not create it. Attachments commit onto
59
+ `config.main_branch` under `<repo_key_or_name>/issue-<number>/<uuid>.<ext>`.
60
+
61
+ ```ruby
62
+ config.attachment_repo = 'pms-attachments'
63
+ ```
64
+
52
65
  ## Projects
53
66
 
54
67
  | Option | Type | Default | Description |
@@ -337,6 +350,20 @@ config.cache_version = Rails.configuration.x.image_tag
337
350
  config.mount_groups = { webhooks: true, issues: true, projects: true }
338
351
  ```
339
352
 
353
+ ## Boot behavior
354
+
355
+ | Option | Type | Default | Description |
356
+ |---|---|---|---|
357
+ | `eager_load_controllers_on_boot` | `Boolean` | `false` | Eager-load engine controllers in `after_initialize`. |
358
+
359
+ Opt in if the host app probes engine controllers via `defined?` in dev mode. When `true`, the
360
+ engine walks `app/controllers` once on boot so `defined?(PlanMyStuff::SomeController)` resolves
361
+ without first referencing the constant.
362
+
363
+ ```ruby
364
+ config.eager_load_controllers_on_boot = true
365
+ ```
366
+
340
367
  ## Controller overrides
341
368
 
342
369
  | Option | Type | Default | Description |
@@ -30,6 +30,15 @@ PlanMyStuff.configure do |config|
30
30
  # need an entry.
31
31
  # config.repo_nicknames = { safety: 'Compliance' }
32
32
 
33
+ # --------------------------------------------------------------------------
34
+ # Attachments
35
+ # --------------------------------------------------------------------------
36
+ # Bare repo name (under `organization`) that stores uploaded attachment
37
+ # binaries. The repo must already exist; the uploader does not create it.
38
+ # Attachments commit onto `config.main_branch` under
39
+ # `<repo_key_or_name>/issue-<number>/<uuid>.<ext>`.
40
+ # config.attachment_repo = 'pms-attachments'
41
+
33
42
  # --------------------------------------------------------------------------
34
43
  # Projects
35
44
  # --------------------------------------------------------------------------
@@ -184,6 +193,16 @@ PlanMyStuff.configure do |config|
184
193
  #
185
194
  # config.issue_fields_enabled = false
186
195
 
196
+ # --------------------------------------------------------------------------
197
+ # Boot behavior
198
+ # --------------------------------------------------------------------------
199
+ # Eager-load the engine's controllers in after_initialize so that
200
+ # `defined?(PlanMyStuff::SomeController)` resolves without first referencing
201
+ # the constant. Opt in if the host app probes engine controllers via
202
+ # `defined?` in dev mode.
203
+ #
204
+ # config.eager_load_controllers_on_boot = true
205
+
187
206
  # --------------------------------------------------------------------------
188
207
  # Release pipeline
189
208
  # --------------------------------------------------------------------------
@@ -6,33 +6,40 @@ require 'tmpdir'
6
6
  module PlanMyStuff
7
7
  # Value object representing a single attachment record on a +Comment+.
8
8
  # Persisted in +CommentMetadata#attachments+; the gem owns the upload
9
- # (see +PlanMyStuff::AttachmentUploader+) and stores a SHA-pinned
10
- # +raw.githubusercontent.com+ permalink so the file remains reachable
11
- # across later branch rewrites.
9
+ # (see +PlanMyStuff::AttachmentUploader+) and stores the structured
10
+ # location (+owner+/+repo+/+sha+/+path+) of the uploaded file so the
11
+ # blob remains addressable across later branch rewrites.
12
12
  #
13
13
  # Mirrors +PlanMyStuff::Link+ / +PlanMyStuff::Approval+:
14
14
  # +ActiveModel::Attributes+-backed, with +Serializers::JSON+ for
15
15
  # round-trip through the metadata blob.
16
16
  #
17
17
  class Attachment
18
- RAW_URL_REGEX =
19
- %r{\Ahttps://raw\.githubusercontent\.com/(?<owner>[^/\s]+)/(?<repo>[^/\s]+)/(?<sha>[^/\s]+)/(?<path>.+)\z}
20
-
21
18
  include ActiveModel::Model
22
19
  include ActiveModel::Attributes
23
20
  include ActiveModel::Serializers::JSON
24
21
 
25
22
  # @return [String] display filename (user-provided original name)
26
23
  attribute :filename, :string
27
- # @return [String] SHA-pinned raw.githubusercontent.com permalink to the uploaded file
28
- attribute :url, :string
24
+ # @return [String] owner of the attachment repo (e.g. +"BrandsInsurance"+)
25
+ attribute :owner, :string
26
+ # @return [String] attachment repo name
27
+ attribute :repo, :string
28
+ # @return [String] commit SHA pinning the file
29
+ attribute :sha, :string
30
+ # @return [String] path within the attachment repo (no leading slash)
31
+ attribute :path, :string
32
+
33
+ validates :filename, :owner, :repo, :sha, :path, presence: true
29
34
 
30
- validates :filename, presence: true
31
- validates :url, presence: true, format: { with: RAW_URL_REGEX }
35
+ # @return [String] blob-viewer URL for the pinned file
36
+ def url
37
+ "https://github.com/#{owner}/#{repo}/blob/#{sha}/#{path}"
38
+ end
32
39
 
33
40
  # @return [Hash]
34
41
  def to_h
35
- { filename: filename, url: url }
42
+ { filename: filename, owner: owner, repo: repo, sha: sha, path: path }
36
43
  end
37
44
 
38
45
  # @param other [Object]
@@ -56,22 +63,21 @@ module PlanMyStuff
56
63
  # Defaults the destination to +Dir.tmpdir+ joined with +File.basename(filename)+ (directory components
57
64
  # stripped to prevent path traversal).
58
65
  #
59
- # @param path [String, nil] destination path; defaults to +File.join(Dir.tmpdir, File.basename(filename))+
66
+ # @param dest [String, nil] destination path; defaults to +File.join(Dir.tmpdir, File.basename(filename))+
60
67
  #
61
68
  # @return [String] the path the bytes were written to
62
69
  #
63
- def download_to(path = nil)
64
- path ||= File.join(Dir.tmpdir, File.basename(filename.to_s))
65
- match = RAW_URL_REGEX.match(url)
70
+ def download_to(dest = nil)
71
+ dest ||= File.join(Dir.tmpdir, File.basename(filename.to_s))
66
72
  body = PlanMyStuff.client.rest(
67
73
  :contents,
68
- "#{match[:owner]}/#{match[:repo]}",
69
- path: match[:path],
70
- ref: match[:sha],
74
+ "#{owner}/#{repo}",
75
+ path: path,
76
+ ref: sha,
71
77
  accept: 'application/vnd.github.raw',
72
78
  )
73
- File.binwrite(path, body)
74
- path
79
+ File.binwrite(dest, body)
80
+ dest
75
81
  end
76
82
  end
77
83
  end
@@ -6,8 +6,8 @@ require 'securerandom'
6
6
 
7
7
  module PlanMyStuff
8
8
  # Uploads attachment files to a shared attachment repo (+config.attachment_repo+ under +config.organization+) using
9
- # GitHub's Git Data API, returning SHA-pinned +raw.githubusercontent.com+ permalinks wrapped in
10
- # +PlanMyStuff::Attachment+ instances.
9
+ # GitHub's Git Data API, returning +PlanMyStuff::Attachment+ instances populated with the structured
10
+ # +owner+/+repo+/+sha+/+path+ location of each uploaded file.
11
11
  #
12
12
  # One commit per batch (atomic): all files in a single +upload_all!+ call land in the same tree/commit so partial
13
13
  # failures cannot leave +config.main_branch+ in an inconsistent state.
@@ -202,8 +202,13 @@ module PlanMyStuff
202
202
  # @return [PlanMyStuff::Attachment]
203
203
  #
204
204
  def build_attachment(commit_sha:, slot:)
205
- url = "https://raw.githubusercontent.com/#{attachment_repo_full_name}/#{commit_sha}/#{slot[:path]}"
206
- PlanMyStuff::Attachment.new(filename: slot[:filename], url: url)
205
+ PlanMyStuff::Attachment.new(
206
+ filename: slot[:filename],
207
+ owner: PlanMyStuff.configuration.organization,
208
+ repo: PlanMyStuff.configuration.attachment_repo,
209
+ sha: commit_sha,
210
+ path: slot[:path],
211
+ )
207
212
  end
208
213
 
209
214
  # @return [String] +"<organization>/<attachment_repo>"+
@@ -2,6 +2,17 @@
2
2
 
3
3
  module PlanMyStuff
4
4
  class CommentMetadata < PlanMyStuff::BaseMetadata
5
+ LEGACY_URL_REGEXES = [
6
+ %r{\Ahttps://raw\.githubusercontent\.com/(?<owner>[^/\s]+)/(?<repo>[^/\s]+)/(?<sha>[^/\s]+)/(?<path>.+)\z},
7
+ %r{\Ahttps://github\.com/(?<owner>[^/\s]+)/(?<repo>[^/\s]+)/blob/(?<sha>[^/\s]+)/(?<path>.+)\z},
8
+ ].freeze
9
+
10
+ LEGACY_ATTACHMENT_DEPRECATION_MESSAGE =
11
+ 'PlanMyStuff: legacy attachment metadata shape {filename, url} detected. It will continue to parse ' \
12
+ 'until 1.0.0, at which point legacy detection will be removed. New writes use the structured ' \
13
+ '{filename, owner, repo, sha, path} shape introduced in #70; existing entries migrate on their ' \
14
+ 'next write.'
15
+
5
16
  # @return [Boolean] true if this comment holds the issue's body content
6
17
  attr_accessor :issue_body
7
18
  # @return [Array<PlanMyStuff::Attachment>] consuming-app attachment records associated with this comment
@@ -82,9 +93,14 @@ module PlanMyStuff
82
93
  private
83
94
 
84
95
  # Builds a +PlanMyStuff::Attachment+ from each parsed entry.
85
- # Each entry is a +{filename:, url:}+ hash (symbol keys when
86
- # built locally, string keys when read back from GitHub
87
- # metadata) or an existing +PlanMyStuff::Attachment+.
96
+ # Accepts:
97
+ # - an existing +PlanMyStuff::Attachment+
98
+ # - the new shape +{filename:, owner:, repo:, sha:, path:}+
99
+ # - the legacy shape +{filename:, url:}+ where +url+ is a raw
100
+ # +raw.githubusercontent.com+ permalink or a +github.com/.../blob/...+
101
+ # blob-viewer URL; parsed once into structured fields so the
102
+ # next write drops the +url+ key (migrate-on-write).
103
+ #
88
104
  # Malformed hash entries are silently dropped so a single bad
89
105
  # historical entry doesn't crash +Comment.find+. An already-
90
106
  # constructed +Attachment+ that fails validation raises so the
@@ -102,7 +118,7 @@ module PlanMyStuff
102
118
  if entry.is_a?(PlanMyStuff::Attachment)
103
119
  entry
104
120
  elsif entry.respond_to?(:transform_keys)
105
- PlanMyStuff::Attachment.new(entry.transform_keys(&:to_sym))
121
+ PlanMyStuff::Attachment.new(structured_attrs(entry.transform_keys(&:to_sym)))
106
122
  end
107
123
  next if attachment.nil?
108
124
 
@@ -114,5 +130,33 @@ module PlanMyStuff
114
130
  next
115
131
  end
116
132
  end
133
+
134
+ # Returns +attrs+ untouched when it already carries structured
135
+ # fields, otherwise parses a legacy +url+ into
136
+ # +owner+/+repo+/+sha+/+path+. Unparseable URLs yield a hash
137
+ # with no structured fields, leaving +Attachment+ validation to
138
+ # drop the entry.
139
+ #
140
+ # @param attrs [Hash{Symbol=>Object}]
141
+ #
142
+ # @return [Hash{Symbol=>Object}]
143
+ #
144
+ def structured_attrs(attrs)
145
+ return attrs if attrs.key?(:owner)
146
+
147
+ PlanMyStuff.deprecator.warn(LEGACY_ATTACHMENT_DEPRECATION_MESSAGE)
148
+
149
+ url = attrs[:url].to_s
150
+ match = LEGACY_URL_REGEXES.lazy.filter_map { |re| re.match(url) }.first
151
+
152
+ return attrs.except(:url) if match.nil?
153
+
154
+ attrs.except(:url).merge(
155
+ owner: match[:owner],
156
+ repo: match[:repo],
157
+ sha: match[:sha],
158
+ path: match[:path],
159
+ )
160
+ end
117
161
  end
118
162
  end
@@ -373,6 +373,15 @@ module PlanMyStuff
373
373
  #
374
374
  attr_accessor :issue_fields_enabled
375
375
 
376
+ # Whether to eager-load the engine's controllers on host boot. Defaults to +false+ (opt-in). When +true+, the engine
377
+ # walks +app/controllers+ during +after_initialize+ so +defined?(PlanMyStuff::SomeController)+ resolves without
378
+ # first referencing the constant. Enable in host apps that rely on +defined?+ probes against engine controllers in
379
+ # dev mode.
380
+ #
381
+ # @return [Boolean]
382
+ #
383
+ attr_accessor :eager_load_controllers_on_boot
384
+
376
385
  # @return [Configuration]
377
386
  def initialize
378
387
  @repos = {}
@@ -413,6 +422,7 @@ module PlanMyStuff
413
422
  @pipeline_completion_purge_enabled = true
414
423
  @pipeline_completion_ttl_hours = 24
415
424
  @issue_fields_enabled = true
425
+ @eager_load_controllers_on_boot = false
416
426
  @process_aws_webhooks = Rails.env.production?
417
427
  @sns_verifier_class = ::Aws::SNS::MessageVerifier if defined?(::Aws::SNS::MessageVerifier)
418
428
  @sns_verifier_error =
@@ -3,5 +3,21 @@
3
3
  module PlanMyStuff
4
4
  class Engine < ::Rails::Engine
5
5
  isolate_namespace PlanMyStuff
6
+
7
+ # Opt-in (via +config.eager_load_controllers_on_boot+): eager-load the engine's controllers regardless of the host
8
+ # app's eager_load setting. Without this, `defined?(PlanMyStuff::SomeController)` returns nil in host-app dev mode
9
+ # until something explicitly references the constant, since `defined?` does not trigger Zeitwerk autoload.
10
+ config.after_initialize do
11
+ next unless PlanMyStuff.configuration.eager_load_controllers_on_boot
12
+
13
+ controllers_dir = File.expand_path('../../app/controllers', __dir__)
14
+ loader = Rails.autoloaders.main
15
+
16
+ if loader.respond_to?(:eager_load_dir)
17
+ loader.eager_load_dir(controllers_dir)
18
+ else
19
+ Dir.glob(File.join(controllers_dir, '**/*.rb')).sort.each { |path| require path }
20
+ end
21
+ end
6
22
  end
7
23
  end
@@ -18,6 +18,15 @@ module PlanMyStuff
18
18
  </details>\n*
19
19
  }mx
20
20
 
21
+ # Visible attachments block emitted after the metadata block when the
22
+ # metadata carries non-empty +attachments+ (issue #70). Parse strips it
23
+ # so round-trips stay clean.
24
+ ATTACHMENTS_PATTERN = %r{
25
+ \A<details><summary>attachments\ \(\d+\)</summary>\n\n
26
+ .*?\n\n
27
+ </details>\n*
28
+ }mx
29
+
21
30
  # Legacy format kept for parsing only - existing issues serialized before 0.17.0
22
31
  # used a hidden HTML comment. They migrate to the new format on the next write.
23
32
  LEGACY_METADATA_PATTERN = /\A<!-- pms-metadata:(.*?) -->\n*/m
@@ -40,7 +49,7 @@ module PlanMyStuff
40
49
 
41
50
  match = raw_body.match(pattern)
42
51
  metadata = JSON.parse(match[1], symbolize_names: true)
43
- body = raw_body.sub(pattern, '')
52
+ body = raw_body.sub(pattern, '').sub(ATTACHMENTS_PATTERN, '')
44
53
 
45
54
  { metadata: metadata, body: body }
46
55
  rescue JSON::ParserError
@@ -61,14 +70,37 @@ module PlanMyStuff
61
70
  raise(ArgumentError, "metadata must be a Hash or PlanMyStuff::CustomFields, got #{metadata.class}")
62
71
  end
63
72
 
64
- json =
65
- if metadata.is_a?(PlanMyStuff::CustomFields)
66
- JSON.pretty_generate(metadata.to_h)
67
- else
68
- JSON.pretty_generate(metadata)
69
- end
73
+ hash = metadata.is_a?(PlanMyStuff::CustomFields) ? metadata.to_h : metadata
74
+ json = JSON.pretty_generate(hash)
75
+
76
+ "<details><summary>pms-metadata</summary>\n\n```json\n#{json}\n```\n\n</details>\n\n" \
77
+ "#{attachments_block(hash)}#{body}"
78
+ end
70
79
 
71
- "<details><summary>pms-metadata</summary>\n\n```json\n#{json}\n```\n\n</details>\n\n#{body}"
80
+ # Renders the visible attachments block when +metadata+ carries
81
+ # non-empty +:attachments+, otherwise returns an empty string.
82
+ #
83
+ # @param metadata [Hash]
84
+ #
85
+ # @return [String]
86
+ #
87
+ def attachments_block(metadata)
88
+ attachments = metadata[:attachments]
89
+ return '' if attachments.blank?
90
+
91
+ lines = attachments.map { |a| attachment_line(a) }.join("\n")
92
+ "<details><summary>attachments (#{attachments.size})</summary>\n\n#{lines}\n\n</details>\n\n"
93
+ end
94
+
95
+ # @param attachment [Hash{Symbol=>String}]
96
+ #
97
+ # @return [String]
98
+ #
99
+ def attachment_line(attachment)
100
+ url = "https://github.com/#{attachment[:owner]}/#{attachment[:repo]}" \
101
+ "/blob/#{attachment[:sha]}/#{attachment[:path]}"
102
+ safe_filename = attachment[:filename].to_s.gsub(']', '\]')
103
+ "- [#{safe_filename}](#{url})"
72
104
  end
73
105
 
74
106
  # @param raw_body [String]
@@ -3,8 +3,8 @@
3
3
  module PlanMyStuff
4
4
  module VERSION
5
5
  MAJOR = 0
6
- MINOR = 20
7
- TINY = 0
6
+ MINOR = 21
7
+ TINY = 1
8
8
 
9
9
  # Set PRE to nil unless it's a pre-release (beta, rc, etc.)
10
10
  PRE = nil
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plan_my_stuff
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.20.0
4
+ version: 0.21.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brands Insurance
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-20 00:00:00.000000000 Z
11
+ date: 2026-05-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails