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 +4 -4
- data/CHANGELOG.md +41 -0
- data/lib/plan_my_stuff/attachment.rb +77 -0
- data/lib/plan_my_stuff/attachment_uploader.rb +240 -0
- data/lib/plan_my_stuff/comment.rb +12 -1
- data/lib/plan_my_stuff/comment_metadata.rb +58 -2
- data/lib/plan_my_stuff/configuration.rb +9 -0
- data/lib/plan_my_stuff/issue.rb +8 -1
- data/lib/plan_my_stuff/version.rb +2 -2
- data/lib/plan_my_stuff.rb +2 -0
- data/lib/tasks/plan_my_stuff.rake +113 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a34c14adba5975eb4e685f1685c6f4e4cf860cb8013a30b529839de03f87cd19
|
|
4
|
+
data.tar.gz: ed239a56d7e52e6def2abd9e28929b49be7763945b84c3e3a4384ca2112f187c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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(
|
|
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
|
data/lib/plan_my_stuff/issue.rb
CHANGED
|
@@ -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
|
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.
|
|
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-
|
|
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
|