plan_my_stuff 0.10.5 → 0.11.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ce0e2f02e6063ab2fe3238671d06182fc5447f428e6dc01d336ba6e00379e86a
4
- data.tar.gz: f80f58ce32e19ea6e7b8aca34a62110379e6ea65f10c943fd108c409ec50a669
3
+ metadata.gz: a34c14adba5975eb4e685f1685c6f4e4cf860cb8013a30b529839de03f87cd19
4
+ data.tar.gz: ed239a56d7e52e6def2abd9e28929b49be7763945b84c3e3a4384ca2112f187c
5
5
  SHA512:
6
- metadata.gz: 3aaf1478091d45cbf8caa72a8a0f14b6cc445c5ec7c17382b92d50bb593d36b04ab00ade2973b2e8c67e19b3dfe1b7841022a9f336da2fe7ba3d27ea6c6b7eb5
7
- data.tar.gz: 680bbb80160eaa20b20c05d4585e167e143c5be6c4735869bf8d138605cd11a52bacc1f3564cc6b8ea57e772b13aa22bdc7cd720c8254f5cf576c3207b15826e
6
+ metadata.gz: e779fe0ceff48e299512f96905e0e5b7e9e75b6047a4324b7657b77d4c2a2b21ba6e213d52d72feccb4477930327a3d3d3d9e89f5cb24f2c0502c6a256fd6ef4
7
+ data.tar.gz: e6242bd7a54f8861aca8d341ab17b569120c6acad8886e10b325f037b211b500939bbc941458a3756b54458bd43961588806129de9aa5718f8a99c6daa393f4d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Added
6
+
7
+ - `PlanMyStuff::Attachment` value object (`filename`, `url`) and
8
+ `CommentMetadata#attachments`. PMS now owns attachment storage:
9
+ `Comment.create!` accepts an `attachments:` kwarg of uploaded-file
10
+ objects responding to `#path` and `#original_filename` (e.g.
11
+ Rails `ActionDispatch::Http::UploadedFile`), `String`/`Pathname`
12
+ paths to local files, or pre-built `PlanMyStuff::Attachment`
13
+ instances. Each file is committed to the shared attachment repo
14
+ (`config.attachment_repo`, default `'pms-attachments'`, under
15
+ `config.organization`) on `config.main_branch` at
16
+ `<repo_key>/issue-<N>/<uuid>.<ext>`; the resulting SHA-pinned
17
+ `raw.githubusercontent.com` permalink is stored in metadata as a
18
+ `PlanMyStuff::Attachment(filename:, url:)`. The attachment repo
19
+ must exist; the uploader does not create it. Malformed metadata
20
+ entries are silently dropped on parse, matching existing `links`
21
+ / `approvals` behavior on `IssueMetadata`.
22
+ - `config.attachment_repo` (default `'pms-attachments'`) names the
23
+ bare repo under `config.organization` that stores uploaded
24
+ attachment binaries.
25
+ - `PlanMyStuff::AttachmentUploader.upload_all!(repo:,
26
+ issue_number:, files:)` is the underlying uploader; called
27
+ internally by `Comment.create!` but also usable directly by apps
28
+ that want to upload before constructing a comment. Pre-built
29
+ `Attachment` instances pass through without re-uploading.
30
+ - `Issue.create!` accepts an `attachments:` kwarg, forwarded to the
31
+ body-comment `Comment.create!` call so attachments posted with a
32
+ new issue are recorded on (and uploaded for) the body comment.
33
+ - `PlanMyStuff::Attachment#download_to(path = nil)` fetches the file
34
+ via the GitHub Contents API (so it works on private repos) and
35
+ writes the bytes to disk. Defaults `path` to
36
+ `File.join(Dir.tmpdir, filename)` and returns the path written to.
37
+
38
+ ### Documented
39
+
40
+ - `designs/g9/` documents the design pivot from inline-base64 (body
41
+ size capped) through sibling-repo (rejected) to the chosen
42
+ side-branch approach.
43
+
3
44
  ## 0.10.5
4
45
 
5
46
  ### Added
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_model'
4
+ require 'tmpdir'
5
+
6
+ module PlanMyStuff
7
+ # Value object representing a single attachment record on a +Comment+.
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.
12
+ #
13
+ # Mirrors +PlanMyStuff::Link+ / +PlanMyStuff::Approval+:
14
+ # +ActiveModel::Attributes+-backed, with +Serializers::JSON+ for
15
+ # round-trip through the metadata blob.
16
+ #
17
+ class Attachment
18
+ RAW_URL_REGEX =
19
+ %r{\Ahttps://raw\.githubusercontent\.com/(?<owner>[^/\s]+)/(?<repo>[^/\s]+)/(?<sha>[^/\s]+)/(?<path>.+)\z}
20
+
21
+ include ActiveModel::Model
22
+ include ActiveModel::Attributes
23
+ include ActiveModel::Serializers::JSON
24
+
25
+ # @return [String] display filename (user-provided original name)
26
+ attribute :filename, :string
27
+ # @return [String] SHA-pinned raw.githubusercontent.com permalink to the uploaded file
28
+ attribute :url, :string
29
+
30
+ validates :filename, presence: true
31
+ validates :url, presence: true, format: { with: RAW_URL_REGEX }
32
+
33
+ # @return [Hash]
34
+ def to_h
35
+ { filename: filename, url: url }
36
+ end
37
+
38
+ # @param other [Object]
39
+ #
40
+ # @return [Boolean]
41
+ #
42
+ def ==(other)
43
+ return false unless other.is_a?(PlanMyStuff::Attachment)
44
+
45
+ filename == other.filename && url == other.url
46
+ end
47
+
48
+ alias eql? ==
49
+
50
+ # @return [Integer]
51
+ def hash
52
+ [filename, url].hash
53
+ end
54
+
55
+ # Downloads the file via the GitHub Contents API (so it works on private repos) and writes the bytes to disk.
56
+ # Defaults the destination to +Dir.tmpdir+ joined with +File.basename(filename)+ (directory components
57
+ # stripped to prevent path traversal).
58
+ #
59
+ # @param path [String, nil] destination path; defaults to +File.join(Dir.tmpdir, File.basename(filename))+
60
+ #
61
+ # @return [String] the path the bytes were written to
62
+ #
63
+ def download_to(path = nil)
64
+ path ||= File.join(Dir.tmpdir, File.basename(filename.to_s))
65
+ match = RAW_URL_REGEX.match(url)
66
+ body = PlanMyStuff.client.rest(
67
+ :contents,
68
+ "#{match[:owner]}/#{match[:repo]}",
69
+ path: match[:path],
70
+ ref: match[:sha],
71
+ accept: 'application/vnd.github.raw',
72
+ )
73
+ File.binwrite(path, body)
74
+ path
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'pathname'
5
+ require 'securerandom'
6
+
7
+ module PlanMyStuff
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.
11
+ #
12
+ # One commit per batch (atomic): all files in a single +upload_all!+ call land in the same tree/commit so partial
13
+ # failures cannot leave +config.main_branch+ in an inconsistent state.
14
+ #
15
+ # The attachment repo is assumed to exist; the uploader does not create it. Per-source-repo attachments are namespaced
16
+ # under +<repo_key>/issue-<number>/<uuid>.<ext>+, where +repo_key+ falls back to +repo.name+ for repos resolved
17
+ # without a +config.repos+ entry.
18
+ #
19
+ # @see PlanMyStuff::Attachment
20
+ #
21
+ class AttachmentUploader
22
+ class << self
23
+ # Uploads each entry in +files+ to the attachment repo and returns the resulting +Attachment+ instances. Order is
24
+ # not preserved: passthrough +Attachment+ entries are returned before newly-uploaded ones. +Attachment+ entries
25
+ # are passed through untouched (no upload), supporting round-trip through +Comment#save!+ without
26
+ # double-uploading.
27
+ #
28
+ # Empty / nil +files+ short-circuits with no API calls.
29
+ #
30
+ # @param repo [PlanMyStuff::Repo] source issues repo (used to namespace the path)
31
+ # @param issue_number [Integer]
32
+ # @param files [Array] mix of already-uploaded +PlanMyStuff::Attachment+ instances, uploaded-file objects
33
+ # responding to +#path+ and +#original_filename+ (e.g. Rails +ActionDispatch::Http::UploadedFile+), and
34
+ # String/Pathname paths to local files
35
+ #
36
+ # @return [Array<PlanMyStuff::Attachment>]
37
+ #
38
+ def upload_all!(repo:, issue_number:, files:)
39
+ entries = Array.wrap(files)
40
+ return [] if entries.empty?
41
+
42
+ slots = entries.map { |entry| classify(entry) }
43
+ attachments = slots.grep(PlanMyStuff::Attachment)
44
+ pending = slots.grep(Hash)
45
+
46
+ return attachments if pending.blank?
47
+
48
+ commit_sha = create_commit!(repo: repo, issue_number: issue_number, pending: pending)
49
+
50
+ pending.each do |slot|
51
+ slot[:attachment] = build_attachment(commit_sha: commit_sha, slot: slot)
52
+ end
53
+
54
+ publish_commit!(commit_sha)
55
+
56
+ attachments + pending.pluck(:attachment)
57
+ end
58
+
59
+ private
60
+
61
+ # Normalizes a single input entry into a slot Hash or itself (an existing Attachment);
62
+ # pending slots carry +{filename:, content:}+ awaiting upload.
63
+ #
64
+ # @param entry [Object]
65
+ #
66
+ # @return [PlanMyStuff::Attachment, Hash]
67
+ #
68
+ def classify(entry)
69
+ if entry.is_a?(PlanMyStuff::Attachment)
70
+ entry
71
+ else
72
+ filename, content = extract_filename_and_content(entry)
73
+ { filename: filename, content: content }
74
+ end
75
+ end
76
+
77
+ # @param entry [Object]
78
+ #
79
+ # @return [Array(String, String)] filename, raw bytes
80
+ #
81
+ def extract_filename_and_content(entry)
82
+ if entry.respond_to?(:path) && entry.respond_to?(:original_filename)
83
+ [leaf_filename(entry.original_filename), File.binread(entry.path)]
84
+ elsif entry.is_a?(String) || entry.is_a?(Pathname)
85
+ [File.basename(entry), File.binread(entry)]
86
+ else
87
+ raise(ArgumentError, "Unsupported attachment entry: #{entry.inspect}")
88
+ end
89
+ end
90
+
91
+ # Strips directory components (both POSIX and Windows separators) from a browser-supplied filename so
92
+ # values like +"../../secret.txt"+ or +"C:\\fakepath\\photo.jpg"+ cannot leak into stored metadata or
93
+ # later download paths.
94
+ #
95
+ # @param raw [Object] +original_filename+ from an uploaded-file-like object
96
+ #
97
+ # @return [String]
98
+ #
99
+ def leaf_filename(raw)
100
+ name = raw.to_s.tr('\\', '/').split('/').last
101
+ raise(ArgumentError, 'Attachment filename cannot be blank') if name.blank?
102
+
103
+ name
104
+ end
105
+
106
+ # Returns the path-segment used to namespace this source repo's attachments inside the shared attachment repo.
107
+ # Prefers +repo.key+ (registered repos); falls back to +repo.name+ for repos resolved by full name only.
108
+ #
109
+ # @param repo [PlanMyStuff::Repo]
110
+ #
111
+ # @return [String]
112
+ #
113
+ def namespace_for(repo)
114
+ (repo.key || repo.name).to_s
115
+ end
116
+
117
+ # Performs the Git Data API dance against the attachment repo and returns the new commit SHA without
118
+ # publishing it (the ref still points at the old head). Callers must invoke +publish_commit!+ after the
119
+ # batch has been converted into +Attachment+ instances so a failure during conversion leaves
120
+ # +config.main_branch+ untouched. Assumes the attachment repo and +config.main_branch+ exist.
121
+ #
122
+ # @param repo [PlanMyStuff::Repo] source issues repo
123
+ # @param issue_number [Integer]
124
+ # @param pending [Array<Hash>] slots awaiting upload
125
+ #
126
+ # @return [String] new commit SHA
127
+ #
128
+ def create_commit!(repo:, issue_number:, pending:)
129
+ client = PlanMyStuff.client
130
+ head_sha, base_tree_sha = fetch_head_and_tree(client)
131
+
132
+ assign_paths!(pending, namespace_for(repo), issue_number)
133
+ tree_items = pending.map { |slot| blob_tree_item(client, slot) }
134
+
135
+ new_tree = client.rest(:create_tree, attachment_repo_full_name, tree_items, base_tree: base_tree_sha)
136
+ new_tree_sha = dig_sha(new_tree, :sha)
137
+
138
+ new_commit = client.rest(
139
+ :create_commit,
140
+ attachment_repo_full_name,
141
+ "task: Add attachments for #{repo.full_name}##{issue_number}",
142
+ new_tree_sha,
143
+ [head_sha],
144
+ )
145
+ dig_sha(new_commit, :sha)
146
+ end
147
+
148
+ # Updates +config.main_branch+ to point at +commit_sha+. Called as the last step of +upload_all!+ so any
149
+ # earlier failure leaves the attachment repo unchanged.
150
+ #
151
+ # @param commit_sha [String]
152
+ #
153
+ # @return [void]
154
+ #
155
+ def publish_commit!(commit_sha)
156
+ PlanMyStuff.client.rest(:update_ref, attachment_repo_full_name, ref, commit_sha)
157
+ end
158
+
159
+ # @param client [PlanMyStuff::Client]
160
+ #
161
+ # @return [Array(String, String)] [head_sha, tree_sha]
162
+ #
163
+ def fetch_head_and_tree(client)
164
+ ref_response = client.rest(:ref, attachment_repo_full_name, ref)
165
+ head_sha = dig_sha(ref_response, :object, :sha)
166
+ commit_response = client.rest(:commit, attachment_repo_full_name, head_sha)
167
+ tree_sha = dig_sha(commit_response, :commit, :tree, :sha)
168
+ [head_sha, tree_sha]
169
+ end
170
+
171
+ # @param pending [Array<Hash>]
172
+ # @param namespace [String]
173
+ # @param issue_number [Integer]
174
+ #
175
+ # @return [void]
176
+ #
177
+ def assign_paths!(pending, namespace, issue_number)
178
+ pending.each do |slot|
179
+ uuid = SecureRandom.uuid
180
+ ext = File.extname(slot[:filename]).delete_prefix('.').downcase
181
+ basename = ext.empty? ? uuid : "#{uuid}.#{ext}"
182
+ slot[:basename] = basename
183
+ slot[:path] = "#{namespace}/issue-#{issue_number}/#{basename}"
184
+ end
185
+ end
186
+
187
+ # @param client [PlanMyStuff::Client]
188
+ # @param slot [Hash]
189
+ #
190
+ # @return [Hash] tree item ready for create_tree
191
+ #
192
+ def blob_tree_item(client, slot)
193
+ blob_sha = client.rest(
194
+ :create_blob, attachment_repo_full_name, Base64.strict_encode64(slot[:content]), 'base64',
195
+ )
196
+ { path: slot[:path], mode: '100644', type: 'blob', sha: blob_sha }
197
+ end
198
+
199
+ # @param commit_sha [String]
200
+ # @param slot [Hash]
201
+ #
202
+ # @return [PlanMyStuff::Attachment]
203
+ #
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)
207
+ end
208
+
209
+ # @return [String] +"<organization>/<attachment_repo>"+
210
+ def attachment_repo_full_name
211
+ "#{PlanMyStuff.configuration.organization}/#{PlanMyStuff.configuration.attachment_repo}"
212
+ end
213
+
214
+ # @return [String] Git ref for the configured main branch (e.g. +"heads/main"+)
215
+ def ref
216
+ "heads/#{PlanMyStuff.configuration.main_branch}"
217
+ end
218
+
219
+ # Walks +keys+ through +obj+, tolerating Sawyer resources (method accessors), Hash with Symbol keys, and
220
+ # Hash with String keys.
221
+ #
222
+ # @param obj [Object]
223
+ # @param keys [Array<Symbol>]
224
+ #
225
+ # @return [Object, nil]
226
+ #
227
+ def dig_sha(obj, *keys)
228
+ keys.reduce(obj) do |acc, key|
229
+ break if acc.nil?
230
+
231
+ if acc.respond_to?(key)
232
+ acc.public_send(key)
233
+ elsif acc.respond_to?(:[])
234
+ acc[key] || acc[key.to_s]
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end
240
+ end
@@ -42,6 +42,10 @@ module PlanMyStuff
42
42
  # @param issue_body [Boolean] whether this comment holds the issue body
43
43
  # @param waiting_on_reply [Boolean] when true and the author is a support user, marks the issue as waiting on
44
44
  # an end-user reply. Ignored for non-support authors.
45
+ # @param attachments [Array] files to upload to +config.attachment_repo+ and record on the comment. Each entry
46
+ # may be an uploaded-file object responding to +#path+ and +#original_filename+ (e.g. Rails
47
+ # +ActionDispatch::Http::UploadedFile+), a String/Pathname path to a local file, or a pre-built
48
+ # +PlanMyStuff::Attachment+ instance (passthrough, no re-upload).
45
49
  #
46
50
  # @return [PlanMyStuff::Comment]
47
51
  #
@@ -53,7 +57,8 @@ module PlanMyStuff
53
57
  custom_fields: {},
54
58
  skip_responded: false,
55
59
  issue_body: false,
56
- waiting_on_reply: false
60
+ waiting_on_reply: false,
61
+ attachments: []
57
62
  )
58
63
  raise(PlanMyStuff::LockedIssueError, "Issue ##{issue.number} is locked") if issue.locked?
59
64
 
@@ -66,6 +71,11 @@ module PlanMyStuff
66
71
  issue_body: issue_body,
67
72
  )
68
73
  comment_metadata.validate_custom_fields!
74
+ comment_metadata.attachments = PlanMyStuff::AttachmentUploader.upload_all!(
75
+ repo: issue.repo,
76
+ issue_number: issue.number,
77
+ files: attachments,
78
+ )
69
79
 
70
80
  header = build_header(resolved_user)
71
81
  full_body = "#{header}\n\n#{body}"
@@ -276,6 +286,7 @@ module PlanMyStuff
276
286
  custom_fields: metadata.custom_fields.to_h,
277
287
  issue_body: metadata.issue_body,
278
288
  waiting_on_reply: waiting_on_reply,
289
+ attachments: metadata.attachments,
279
290
  )
280
291
  hydrate_from_comment(created)
281
292
  else
@@ -4,6 +4,8 @@ module PlanMyStuff
4
4
  class CommentMetadata < PlanMyStuff::BaseMetadata
5
5
  # @return [Boolean] true if this comment holds the issue's body content
6
6
  attr_accessor :issue_body
7
+ # @return [Array<PlanMyStuff::Attachment>] consuming-app attachment records associated with this comment
8
+ attr_reader :attachments
7
9
 
8
10
  class << self
9
11
  # Builds a CommentMetadata from a parsed hash (e.g. from MetadataParser)
@@ -16,6 +18,7 @@ module PlanMyStuff
16
18
  metadata = new
17
19
  apply_common_from_hash(metadata, hash, PlanMyStuff.configuration.custom_fields_for(:comment))
18
20
  metadata.issue_body = hash[:issue_body] || false
21
+ metadata.attachments = hash[:attachments]
19
22
 
20
23
  metadata
21
24
  end
@@ -26,10 +29,11 @@ module PlanMyStuff
26
29
  # @param visibility [String] "public" or "internal"
27
30
  # @param custom_fields [Hash] app-defined field values
28
31
  # @param issue_body [Boolean] whether this comment holds the issue body
32
+ # @param attachments [Array<Hash, PlanMyStuff::Attachment>] consuming-app attachment records
29
33
  #
30
34
  # @return [CommentMetadata]
31
35
  #
32
- def build(user:, visibility: 'internal', custom_fields: {}, issue_body: false)
36
+ def build(user:, visibility: 'internal', custom_fields: {}, issue_body: false, attachments: [])
33
37
  metadata = new
34
38
  apply_common_build(
35
39
  metadata,
@@ -39,6 +43,7 @@ module PlanMyStuff
39
43
  custom_fields_schema: PlanMyStuff.configuration.custom_fields_for(:comment),
40
44
  )
41
45
  metadata.issue_body = issue_body
46
+ metadata.attachments = attachments
42
47
 
43
48
  metadata
44
49
  end
@@ -47,6 +52,18 @@ module PlanMyStuff
47
52
  def initialize
48
53
  super
49
54
  @issue_body = false
55
+ @attachments = []
56
+ end
57
+
58
+ # Assigns +attachments+, normalizing each entry through
59
+ # +PlanMyStuff::Attachment+ and dropping any malformed ones.
60
+ #
61
+ # @param raw [Array, nil]
62
+ #
63
+ # @return [Array<PlanMyStuff::Attachment>]
64
+ #
65
+ def attachments=(raw)
66
+ @attachments = normalize_attachments(raw)
50
67
  end
51
68
 
52
69
  # @return [Boolean]
@@ -56,7 +73,46 @@ module PlanMyStuff
56
73
 
57
74
  # @return [Hash]
58
75
  def to_h
59
- super.merge(issue_body: issue_body)
76
+ super.merge(
77
+ issue_body: issue_body,
78
+ attachments: attachments.map(&:to_h),
79
+ )
60
80
  end
81
+
82
+ private
83
+
84
+ # 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+.
88
+ # Malformed hash entries are silently dropped so a single bad
89
+ # historical entry doesn't crash +Comment.find+. An already-
90
+ # constructed +Attachment+ that fails validation raises so the
91
+ # caller is not silently missing an uploaded file.
92
+ #
93
+ # @param raw [Array, nil]
94
+ #
95
+ # @return [Array<PlanMyStuff::Attachment>]
96
+ #
97
+ def normalize_attachments(raw)
98
+ Array.wrap(raw).filter_map do |entry|
99
+ next if entry.nil?
100
+
101
+ attachment =
102
+ if entry.is_a?(PlanMyStuff::Attachment)
103
+ entry
104
+ elsif entry.respond_to?(:transform_keys)
105
+ PlanMyStuff::Attachment.new(entry.transform_keys(&:to_sym))
106
+ end
107
+ next if attachment.nil?
108
+
109
+ attachment.validate!
110
+ attachment
111
+ rescue ActiveModel::ValidationError, ArgumentError
112
+ raise if entry.is_a?(PlanMyStuff::Attachment)
113
+
114
+ next
115
+ end
116
+ end
61
117
  end
62
118
  end
@@ -348,9 +348,18 @@ module PlanMyStuff
348
348
  #
349
349
  attr_accessor :repos
350
350
 
351
+ # Bare repo name (under +config.organization+) that stores uploaded attachment binaries. Defaults to
352
+ # +'pms-attachments'+. The repo must exist; the uploader does not create it. Attachments commit onto
353
+ # +config.main_branch+ and live under +<repo_key_or_name>/issue-<number>/<uuid>.<ext>+.
354
+ #
355
+ # @return [String]
356
+ #
357
+ attr_accessor :attachment_repo
358
+
351
359
  # @return [Configuration]
352
360
  def initialize
353
361
  @repos = {}
362
+ @attachment_repo = 'pms-attachments'
354
363
  @user_class = 'User'
355
364
  @display_name_method = :to_s
356
365
  @user_id_method = :id
@@ -84,6 +84,11 @@ module PlanMyStuff
84
84
  # @param visibility_allowlist [Array<Integer>] user IDs for internal comment access
85
85
  # @param issue_type [String, nil] GitHub issue type name (e.g. +"Bug"+, +"Feature"+). Must match a type
86
86
  # configured on the org. +nil+ creates the issue with no type.
87
+ # @param attachments [Array] files to upload to +config.attachment_repo+ and record on the body comment. Each
88
+ # entry may be an uploaded-file object responding to +#path+ and +#original_filename+ (e.g. Rails
89
+ # +ActionDispatch::Http::UploadedFile+), a String/Pathname path to a local file, or a pre-built
90
+ # +PlanMyStuff::Attachment+ instance (passthrough, no re-upload). Forwarded to the body comment's
91
+ # +attachments:+ kwarg; see +Comment.create!+ for full detail.
87
92
  #
88
93
  # @return [PlanMyStuff::Issue]
89
94
  #
@@ -97,7 +102,8 @@ module PlanMyStuff
97
102
  add_to_project: nil,
98
103
  visibility: 'public',
99
104
  visibility_allowlist: [],
100
- issue_type: nil
105
+ issue_type: nil,
106
+ attachments: []
101
107
  )
102
108
  if body.blank?
103
109
  raise(PlanMyStuff::ValidationError.new('body must be present', field: :body, expected_type: :string))
@@ -151,6 +157,7 @@ module PlanMyStuff
151
157
  visibility: issue_metadata.visibility.to_sym,
152
158
  skip_responded: true,
153
159
  issue_body: true,
160
+ attachments: attachments,
154
161
  )
155
162
 
156
163
  issue.reload
@@ -3,8 +3,8 @@
3
3
  module PlanMyStuff
4
4
  module VERSION
5
5
  MAJOR = 0
6
- MINOR = 10
7
- TINY = 5
6
+ MINOR = 11
7
+ TINY = 0
8
8
 
9
9
  # Set PRE to nil unless it's a pre-release (beta, rc, etc.)
10
10
  PRE = nil
data/lib/plan_my_stuff.rb CHANGED
@@ -8,6 +8,8 @@ require 'active_support/core_ext/object/blank'
8
8
  require_relative 'plan_my_stuff/application_record'
9
9
  require_relative 'plan_my_stuff/approval'
10
10
  require_relative 'plan_my_stuff/archive'
11
+ require_relative 'plan_my_stuff/attachment'
12
+ require_relative 'plan_my_stuff/attachment_uploader'
11
13
  require_relative 'plan_my_stuff/base_metadata'
12
14
  require_relative 'plan_my_stuff/base_project'
13
15
  require_relative 'plan_my_stuff/base_project_item'
@@ -219,6 +219,119 @@ namespace :plan_my_stuff do
219
219
  end
220
220
  end
221
221
 
222
+ namespace :debug do
223
+ desc 'Probe GitHub issue body size limit by creating issues of increasing size, then closing them. ' \
224
+ 'Options: REPO=owner/name (default: config.default_repo) START=60000 MAX=70000 STEP=1000 ' \
225
+ 'LABEL=body-size-probe KEEP=0 (set KEEP=1 to leave issues open for inspection)'
226
+ task body_size_limit: :environment do
227
+ client = PlanMyStuff.client
228
+ repo = client.resolve_repo!(ENV.fetch('REPO', nil))
229
+ start = Integer(ENV.fetch('START', '60000'))
230
+ max = Integer(ENV.fetch('MAX', '70000'))
231
+ step = Integer(ENV.fetch('STEP', '1000'))
232
+ keep = ENV.fetch('KEEP', '0') == '1'
233
+ label = 'pms-body-size-probe'
234
+
235
+ puts("Probing #{repo} body limit: sizes #{start}..#{max} step #{step} " \
236
+ "label=#{label.inspect} (KEEP=#{keep ? '1' : '0'})")
237
+ puts
238
+
239
+ PlanMyStuff::Label.ensure!(repo: repo, name: label) if label.present?
240
+
241
+ created_numbers = []
242
+
243
+ start.step(max, step) do |size|
244
+ header = "<details><summary>#{size} chars</summary>\n\n"
245
+ footer = "\n\n</details>"
246
+ inner = 'a' * [size - header.length - footer.length, 0].max
247
+ body = "#{header}#{inner}#{footer}"
248
+ title = "[PMS body-size probe] #{size} chars"
249
+ print(format('size=%-8d ', size))
250
+
251
+ begin
252
+ options = label.present? ? { labels: [label] } : {}
253
+ result = client.rest(:create_issue, repo, title, body, **options)
254
+ number = result.respond_to?(:number) ? result.number : result[:number]
255
+ created_numbers << number
256
+ puts("OK ##{number}")
257
+ rescue Octokit::Error => e
258
+ puts("FAIL #{e.class}: #{e.message.to_s[0, 200]}")
259
+ break
260
+ end
261
+ end
262
+
263
+ puts
264
+ if keep || created_numbers.empty?
265
+ puts("Created #{created_numbers.size} issue(s); leaving open (KEEP=1).") if keep
266
+ else
267
+ puts("Closing #{created_numbers.size} probe issue(s)...")
268
+ created_numbers.each do |n|
269
+ PlanMyStuff::Issue.update!(number: n, repo: repo, state: 'closed')
270
+ rescue Octokit::Error => e
271
+ puts(" ##{n}: close failed (#{e.class})")
272
+ end
273
+ end
274
+ end
275
+
276
+ desc 'Probe GitHub issue comment body size limit by posting comments of increasing size on a single ' \
277
+ 'bootstrap issue, then closing it. ' \
278
+ 'Options: START=60000 MAX=70000 STEP=1000 KEEP=0 (set KEEP=1 to leave the bootstrap issue open)'
279
+ task comment_size_limit: :environment do
280
+ client = PlanMyStuff.client
281
+ repo = client.resolve_repo!
282
+ start = Integer(ENV.fetch('START', '60000'))
283
+ max = Integer(ENV.fetch('MAX', '70000'))
284
+ step = Integer(ENV.fetch('STEP', '1000'))
285
+ keep = ENV.fetch('KEEP', '0') == '1'
286
+ label = 'pms-comment-size-probe'
287
+
288
+ puts("Probing #{repo} comment body limit: sizes #{start}..#{max} step #{step} " \
289
+ "label=#{label.inspect} (KEEP=#{keep ? '1' : '0'})")
290
+ puts
291
+
292
+ PlanMyStuff::Label.ensure!(repo: repo, name: label)
293
+
294
+ issue = client.rest(
295
+ :create_issue,
296
+ repo,
297
+ '[PMS comment-size probe] bootstrap',
298
+ 'Bootstrap issue for comment-size probing.',
299
+ labels: [label],
300
+ )
301
+ issue_number = issue.respond_to?(:number) ? issue.number : issue[:number]
302
+ puts("Bootstrap issue: ##{issue_number}")
303
+ puts
304
+
305
+ start.step(max, step) do |size|
306
+ header = "<details><summary>#{size} chars</summary>\n\n"
307
+ footer = "\n\n</details>"
308
+ inner = 'a' * [size - header.length - footer.length, 0].max
309
+ body = "#{header}#{inner}#{footer}"
310
+ print(format('size=%-8d ', size))
311
+
312
+ begin
313
+ client.rest(:add_comment, repo, issue_number, body)
314
+ puts('OK')
315
+ rescue Octokit::Error => e
316
+ puts("FAIL #{e.class}: #{e.message.to_s[0, 200]}")
317
+ break
318
+ end
319
+ end
320
+
321
+ puts
322
+ if keep
323
+ puts("Leaving bootstrap issue ##{issue_number} open (KEEP=1).")
324
+ else
325
+ puts("Closing bootstrap issue ##{issue_number}...")
326
+ begin
327
+ PlanMyStuff::Issue.update!(number: issue_number, repo: repo, state: 'closed')
328
+ rescue Octokit::Error => e
329
+ puts(" close failed (#{e.class})")
330
+ end
331
+ end
332
+ end
333
+ end
334
+
222
335
  desc 'Verify PlanMyStuff configuration: token, org, repos, and project access'
223
336
  task verify: :environment do
224
337
  require 'plan_my_stuff/verifier'
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.10.5
4
+ version: 0.11.0
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-13 00:00:00.000000000 Z
11
+ date: 2026-05-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -110,6 +110,8 @@ files:
110
110
  - lib/plan_my_stuff/approval.rb
111
111
  - lib/plan_my_stuff/archive.rb
112
112
  - lib/plan_my_stuff/archive/sweep.rb
113
+ - lib/plan_my_stuff/attachment.rb
114
+ - lib/plan_my_stuff/attachment_uploader.rb
113
115
  - lib/plan_my_stuff/aws_sns_simulator.rb
114
116
  - lib/plan_my_stuff/base_metadata.rb
115
117
  - lib/plan_my_stuff/base_project.rb