plan_my_stuff 0.10.5 → 0.12.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: d23d00fb8f2addae60d416beb76a19f81fdf0bb0ce5235453262317c6e351c94
4
+ data.tar.gz: a83cf20f6347ae27df34133d20b0b10646f5a96346616d0b5594ea4bd965c10f
5
5
  SHA512:
6
- metadata.gz: 3aaf1478091d45cbf8caa72a8a0f14b6cc445c5ec7c17382b92d50bb593d36b04ab00ade2973b2e8c67e19b3dfe1b7841022a9f336da2fe7ba3d27ea6c6b7eb5
7
- data.tar.gz: 680bbb80160eaa20b20c05d4585e167e143c5be6c4735869bf8d138605cd11a52bacc1f3564cc6b8ea57e772b13aa22bdc7cd720c8254f5cf576c3207b15826e
6
+ metadata.gz: b4cb5444db19f7a551f7296d96145db4e4be90d7897235abd877771dbbc15ffcd3361a4faade94fa521f7e491828c794854d2b0ac97b76e38e7c122f4fc63d07
7
+ data.tar.gz: a46060793225471181698152cf2681d8be3f64252a845aa2f9e0a838ffc1977f88c81f2b60b28507c8e8a7e7eb64e67016d163d7353d6c9dc9f5faa9b5f8e140
data/CHANGELOG.md CHANGED
@@ -1,5 +1,66 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Added
6
+
7
+ - `PlanMyStuff::IssueField` for org-level GitHub Issue Field definitions (public preview).
8
+ Exposes `.list`, `.find` (case-insensitive), and `#option_id_for!` for resolving
9
+ single-select option names.
10
+ - `PlanMyStuff::Issue#issue_fields` returns a hash-like `IssueFieldValueSet` view of the
11
+ field values on an issue. Values are coerced to native types (`Date` for date fields,
12
+ `Float` for numbers, the option name `String` for single-selects).
13
+ - `PlanMyStuff::Issue#set_issue_fields!(updates)` writes one or more field values in a
14
+ single GraphQL mutation; passing `nil` as a value clears the field.
15
+ - `issue_fields:` kwarg on `Issue.create!`, `Issue.update!`, `Issue#save!`, and
16
+ `Issue#update!` so callers can set field values inline with create/update instead of
17
+ following up with an explicit `set_issue_fields!` call.
18
+ - `Configuration#issue_fields_enabled` (default `true`, opt-out). Set to `false` if your
19
+ org has not been admitted to the Issue Fields preview - with the flag off,
20
+ `Issue#issue_fields` returns an empty set and the write paths raise
21
+ `IssueFieldsNotEnabledError` instead of letting a raw GraphQL error surface.
22
+
23
+ ## 0.11.0
24
+
25
+ ### Added
26
+
27
+ - `PlanMyStuff::Attachment` value object (`filename`, `url`) and
28
+ `CommentMetadata#attachments`. PMS now owns attachment storage:
29
+ `Comment.create!` accepts an `attachments:` kwarg of uploaded-file
30
+ objects responding to `#path` and `#original_filename` (e.g.
31
+ Rails `ActionDispatch::Http::UploadedFile`), `String`/`Pathname`
32
+ paths to local files, or pre-built `PlanMyStuff::Attachment`
33
+ instances. Each file is committed to the shared attachment repo
34
+ (`config.attachment_repo`, default `'pms-attachments'`, under
35
+ `config.organization`) on `config.main_branch` at
36
+ `<repo_key>/issue-<N>/<uuid>.<ext>`; the resulting SHA-pinned
37
+ `raw.githubusercontent.com` permalink is stored in metadata as a
38
+ `PlanMyStuff::Attachment(filename:, url:)`. The attachment repo
39
+ must exist; the uploader does not create it. Malformed metadata
40
+ entries are silently dropped on parse, matching existing `links`
41
+ / `approvals` behavior on `IssueMetadata`.
42
+ - `config.attachment_repo` (default `'pms-attachments'`) names the
43
+ bare repo under `config.organization` that stores uploaded
44
+ attachment binaries.
45
+ - `PlanMyStuff::AttachmentUploader.upload_all!(repo:,
46
+ issue_number:, files:)` is the underlying uploader; called
47
+ internally by `Comment.create!` but also usable directly by apps
48
+ that want to upload before constructing a comment. Pre-built
49
+ `Attachment` instances pass through without re-uploading.
50
+ - `Issue.create!` accepts an `attachments:` kwarg, forwarded to the
51
+ body-comment `Comment.create!` call so attachments posted with a
52
+ new issue are recorded on (and uploaded for) the body comment.
53
+ - `PlanMyStuff::Attachment#download_to(path = nil)` fetches the file
54
+ via the GitHub Contents API (so it works on private repos) and
55
+ writes the bytes to disk. Defaults `path` to
56
+ `File.join(Dir.tmpdir, filename)` and returns the path written to.
57
+
58
+ ### Documented
59
+
60
+ - `designs/g9/` documents the design pivot from inline-base64 (body
61
+ size capped) through sibling-repo (rejected) to the chosen
62
+ side-branch approach.
63
+
3
64
  ## 0.10.5
4
65
 
5
66
  ### Added
data/CONFIGURATION.md CHANGED
@@ -192,6 +192,26 @@ config.issue_types = {
192
192
  }
193
193
  ```
194
194
 
195
+ ## Issue Fields (public preview)
196
+
197
+ | Option | Type | Default | Description |
198
+ |---|---|---|---|
199
+ | `issue_fields_enabled` | `Boolean` | `true` | Whether the Issue Fields public preview is wired up for the org. |
200
+
201
+ GitHub Issue Fields are structured per-issue metadata (text, number, date, or single-select)
202
+ configured once at the org level. The preview is rolling out org-by-org. Leave this flag at its
203
+ default (`true`) once your org has been admitted; flip to `false` to keep the gem from issuing
204
+ calls that would otherwise return raw GraphQL errors.
205
+
206
+ With the flag off:
207
+
208
+ - `Issue#issue_fields` returns an empty `IssueFieldValueSet` without making a request.
209
+ - `Issue#set_issue_fields!(...)` and `IssueField.list` raise `IssueFieldsNotEnabledError`.
210
+
211
+ ```ruby
212
+ config.issue_fields_enabled = false # org not admitted to the preview yet
213
+ ```
214
+
195
215
  ## Release pipeline
196
216
 
197
217
  | Option | Type | Default | Description |
@@ -168,6 +168,16 @@ PlanMyStuff.configure do |config|
168
168
  # 'Feature' => 'Enhancement',
169
169
  # }
170
170
 
171
+ # --------------------------------------------------------------------------
172
+ # Issue Fields (public preview)
173
+ # --------------------------------------------------------------------------
174
+ # GitHub Issue Fields is a per-org public preview. Default is true (opt-out).
175
+ # Flip to false if your org has not been admitted to the preview - read paths
176
+ # return an empty set and write paths raise IssueFieldsNotEnabledError instead
177
+ # of letting a raw GraphQL error bubble.
178
+ #
179
+ # config.issue_fields_enabled = false
180
+
171
181
  # --------------------------------------------------------------------------
172
182
  # Release pipeline
173
183
  # --------------------------------------------------------------------------
@@ -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,27 @@ 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
+
359
+ # Whether GitHub's Issue Fields (public preview) are wired up for the configured org. Defaults to +true+ (opt-out):
360
+ # when +false+, +Issue#issue_fields+ returns an empty +IssueFieldValueSet+ without making a request and
361
+ # +Issue#set_issue_fields!+ / +IssueField.list+ raise +IssueFieldsNotEnabledError+. Set to +false+ if your org has
362
+ # not been admitted to the Issue Fields preview, to avoid raw GraphQL errors from GitHub.
363
+ #
364
+ # @return [Boolean]
365
+ #
366
+ attr_accessor :issue_fields_enabled
367
+
351
368
  # @return [Configuration]
352
369
  def initialize
353
370
  @repos = {}
371
+ @attachment_repo = 'pms-attachments'
354
372
  @user_class = 'User'
355
373
  @display_name_method = :to_s
356
374
  @user_id_method = :id
@@ -385,6 +403,7 @@ module PlanMyStuff
385
403
  @archived_label = 'archived'
386
404
  @pipeline_completion_purge_enabled = true
387
405
  @pipeline_completion_ttl_hours = 24
406
+ @issue_fields_enabled = true
388
407
  @process_aws_webhooks = Rails.env.production?
389
408
  @sns_verifier_class = ::Aws::SNS::MessageVerifier if defined?(::Aws::SNS::MessageVerifier)
390
409
  @sns_verifier_error =
@@ -106,6 +106,17 @@ module PlanMyStuff
106
106
  class LockedIssueError < PlanMyStuff::Error
107
107
  end
108
108
 
109
+ # Raised when an Issue Fields API call is attempted while
110
+ # +config.issue_fields_enabled+ is +false+. Consumers whose org has not been
111
+ # admitted to the Issue Fields public preview flip the flag off; this error
112
+ # surfaces faster (and with a clearer message) than the underlying
113
+ # +GraphQLError+ that GitHub would otherwise return.
114
+ class IssueFieldsNotEnabledError < PlanMyStuff::Error
115
+ def initialize(message = nil)
116
+ super(message || 'Issue Fields are disabled; set config.issue_fields_enabled = true to enable')
117
+ end
118
+ end
119
+
109
120
  # Raised by +PlanMyStuff::Pipeline+ forward transitions when the linked
110
121
  # +Issue+ has any pending manager approvals.
111
122
  class PendingApprovalsError < PlanMyStuff::ValidationError
@@ -399,6 +399,56 @@ module PlanMyStuff
399
399
  }
400
400
  }
401
401
  GRAPHQL
402
+
403
+ # --- Issue Fields (org-level public preview) -----------------------
404
+
405
+ LIST_ORG_ISSUE_FIELDS = <<~GRAPHQL
406
+ query($org: String!) {
407
+ organization(login: $org) {
408
+ issueFields(first: 50) {
409
+ nodes {
410
+ __typename
411
+ ... on IssueFieldText { id name description }
412
+ ... on IssueFieldNumber { id name description }
413
+ ... on IssueFieldDate { id name description }
414
+ ... on IssueFieldSingleSelect {
415
+ id name description
416
+ options { id name description color }
417
+ }
418
+ }
419
+ }
420
+ }
421
+ }
422
+ GRAPHQL
423
+
424
+ READ_ISSUE_FIELD_VALUES = <<~GRAPHQL
425
+ query($owner: String!, $name: String!, $number: Int!) {
426
+ repository(owner: $owner, name: $name) {
427
+ issue(number: $number) {
428
+ issueFieldValues(first: 50) {
429
+ nodes {
430
+ __typename
431
+ ... on IssueFieldTextValue { value field { ... on IssueFieldText { id name } } }
432
+ ... on IssueFieldNumberValue { value field { ... on IssueFieldNumber { id name } } }
433
+ ... on IssueFieldDateValue { value field { ... on IssueFieldDate { id name } } }
434
+ ... on IssueFieldSingleSelectValue {
435
+ name optionId
436
+ field { ... on IssueFieldSingleSelect { id name } }
437
+ }
438
+ }
439
+ }
440
+ }
441
+ }
442
+ }
443
+ GRAPHQL
444
+
445
+ SET_ISSUE_FIELD_VALUES = <<~GRAPHQL
446
+ mutation($issueId: ID!, $issueFields: [IssueFieldCreateOrUpdateInput!]!) {
447
+ setIssueFieldValue(input: { issueId: $issueId, issueFields: $issueFields }) {
448
+ issue { number }
449
+ }
450
+ }
451
+ GRAPHQL
402
452
  end
403
453
  end
404
454
  end
@@ -84,6 +84,14 @@ 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 issue_fields [Hash{String,Symbol => Object,nil}, nil] GitHub Issue Field values to apply after the
88
+ # issue is created. Delegates to +#set_issue_fields!+, so the same coercion rules and
89
+ # +IssueFieldsNotEnabledError+ behavior apply. +nil+ or an empty hash is a no-op.
90
+ # @param attachments [Array] files to upload to +config.attachment_repo+ and record on the body comment. Each
91
+ # entry may be an uploaded-file object responding to +#path+ and +#original_filename+ (e.g. Rails
92
+ # +ActionDispatch::Http::UploadedFile+), a String/Pathname path to a local file, or a pre-built
93
+ # +PlanMyStuff::Attachment+ instance (passthrough, no re-upload). Forwarded to the body comment's
94
+ # +attachments:+ kwarg; see +Comment.create!+ for full detail.
87
95
  #
88
96
  # @return [PlanMyStuff::Issue]
89
97
  #
@@ -97,8 +105,14 @@ module PlanMyStuff
97
105
  add_to_project: nil,
98
106
  visibility: 'public',
99
107
  visibility_allowlist: [],
100
- issue_type: nil
108
+ issue_type: nil,
109
+ issue_fields: nil,
110
+ attachments: []
101
111
  )
112
+ if issue_fields.present? && !PlanMyStuff.configuration.issue_fields_enabled
113
+ raise(PlanMyStuff::IssueFieldsNotEnabledError)
114
+ end
115
+
102
116
  if body.blank?
103
117
  raise(PlanMyStuff::ValidationError.new('body must be present', field: :body, expected_type: :string))
104
118
  end
@@ -151,8 +165,11 @@ module PlanMyStuff
151
165
  visibility: issue_metadata.visibility.to_sym,
152
166
  skip_responded: true,
153
167
  issue_body: true,
168
+ attachments: attachments,
154
169
  )
155
170
 
171
+ issue.set_issue_fields!(issue_fields) if issue_fields.present?
172
+
156
173
  issue.reload
157
174
  PlanMyStuff::Notifications.instrument('issue.created', issue, user: user)
158
175
  issue
@@ -180,6 +197,9 @@ module PlanMyStuff
180
197
  # @param issue_type [String, nil] GitHub issue type name. Pass a String to set, +nil+ to clear, or omit the
181
198
  # kwarg to leave the current type untouched. (+nil+-vs-omitted is differentiated by the private
182
199
  # +ISSUE_TYPE_UNCHANGED+ sentinel.)
200
+ # @param issue_fields [Hash{String,Symbol => Object,nil}, nil] GitHub Issue Field values to apply after the
201
+ # PATCH (or instead of it, when no other attrs are provided). Delegates to +#set_issue_fields!+, so the same
202
+ # coercion rules and +IssueFieldsNotEnabledError+ behavior apply. +nil+ or an empty hash is a no-op.
183
203
  #
184
204
  # @return [Object]
185
205
  #
@@ -192,7 +212,8 @@ module PlanMyStuff
192
212
  labels: nil,
193
213
  state: nil,
194
214
  assignees: nil,
195
- issue_type: ISSUE_TYPE_UNCHANGED
215
+ issue_type: ISSUE_TYPE_UNCHANGED,
216
+ issue_fields: nil
196
217
  )
197
218
  client = PlanMyStuff.client
198
219
  resolved_repo = client.resolve_repo!(repo)
@@ -229,7 +250,8 @@ module PlanMyStuff
229
250
 
230
251
  update_body_comment!(number, resolved_repo, body) if body
231
252
 
232
- return if options.none?
253
+ updated_issue = find(number, repo: resolved_repo).set_issue_fields!(issue_fields) if issue_fields.present?
254
+ return updated_issue if options.none?
233
255
 
234
256
  result = client.rest(:update_issue, resolved_repo, number, **options)
235
257
  store_etag_to_cache(client, resolved_repo, number, result, cache_writer: :write_issue)
@@ -550,6 +572,7 @@ module PlanMyStuff
550
572
  visibility: metadata.visibility,
551
573
  visibility_allowlist: Array.wrap(metadata.visibility_allowlist),
552
574
  issue_type: issue_type,
575
+ issue_fields: @pending_issue_fields,
553
576
  )
554
577
  hydrate_from_issue(created)
555
578
  else
@@ -558,6 +581,7 @@ module PlanMyStuff
558
581
  instrument_update(captured_changes, user) unless skip_notification
559
582
  end
560
583
 
584
+ @pending_issue_fields = nil
561
585
  self
562
586
  end
563
587
 
@@ -659,6 +683,42 @@ module PlanMyStuff
659
683
  safe_read_field(github_response, :id)
660
684
  end
661
685
 
686
+ # Returns a hash-like view of GitHub Issue Field values currently set on this issue. Reads on first access and
687
+ # memoizes; +set_issue_fields!+ invalidates the cache. Returns an empty set without making a request when
688
+ # +config.issue_fields_enabled+ is +false+.
689
+ #
690
+ # @return [PlanMyStuff::IssueFieldValueSet]
691
+ #
692
+ def issue_fields
693
+ @issue_fields ||= load_issue_fields!
694
+ end
695
+
696
+ # Bulk-updates GitHub Issue Field values in a single +setIssueFieldValue+ mutation. Each key is the field display
697
+ # name; values are coerced to the right input fragment based on the field's type. Passing +nil+ as a value clears
698
+ # that field.
699
+ #
700
+ # @raise [PlanMyStuff::IssueFieldsNotEnabledError] when +config.issue_fields_enabled+ is +false+
701
+ # @raise [PlanMyStuff::Error] when a referenced field name does not exist on the org
702
+ #
703
+ # @param updates [Hash{String,Symbol => Object,nil}]
704
+ #
705
+ # @return [self]
706
+ #
707
+ def set_issue_fields!(updates)
708
+ raise(PlanMyStuff::IssueFieldsNotEnabledError) unless PlanMyStuff.configuration.issue_fields_enabled
709
+
710
+ fields_by_name = PlanMyStuff::IssueField.list(org: repo.organization).index_by { |field| field.name.downcase }
711
+ inputs = updates.map { |name, value| build_issue_field_input(fields_by_name, name, value) }
712
+
713
+ PlanMyStuff.client.graphql(
714
+ PlanMyStuff::GraphQL::Queries::SET_ISSUE_FIELD_VALUES,
715
+ variables: { issueId: github_node_id, issueFields: inputs },
716
+ )
717
+
718
+ @issue_fields = nil
719
+ self
720
+ end
721
+
662
722
  private
663
723
 
664
724
  # Populates this instance from a GitHub API response.
@@ -688,6 +748,7 @@ module PlanMyStuff
688
748
  @body_dirty = false
689
749
  persisted!
690
750
  @comments = nil
751
+ @issue_fields = nil
691
752
  invalidate_links_cache!
692
753
  end
693
754
 
@@ -715,6 +776,7 @@ module PlanMyStuff
715
776
  self.metadata = other.metadata
716
777
  persisted!
717
778
  @comments = nil
779
+ @issue_fields = nil
718
780
  invalidate_links_cache!
719
781
  end
720
782
 
@@ -757,6 +819,7 @@ module PlanMyStuff
757
819
  }
758
820
  attrs[:body] = body if @body_dirty
759
821
  attrs[:assignees] = @pending_assignees unless @pending_assignees.nil?
822
+ attrs[:issue_fields] = @pending_issue_fields if @pending_issue_fields.present?
760
823
  attrs[:issue_type] = issue_type if issue_type_changed?
761
824
 
762
825
  clear_waiting_state_on_close(attrs)
@@ -782,6 +845,7 @@ module PlanMyStuff
782
845
  self.body = attrs[:body] if attrs.key?(:body)
783
846
  self.issue_type = attrs[:issue_type] if attrs.key?(:issue_type)
784
847
  @pending_assignees = attrs[:assignees] if attrs.key?(:assignees)
848
+ @pending_issue_fields = attrs[:issue_fields] if attrs.key?(:issue_fields)
785
849
  apply_metadata_attrs(attrs[:metadata]) if attrs.key?(:metadata)
786
850
  end
787
851
 
@@ -898,5 +962,47 @@ module PlanMyStuff
898
962
 
899
963
  id
900
964
  end
965
+
966
+ # @return [PlanMyStuff::IssueFieldValueSet]
967
+ def load_issue_fields!
968
+ return PlanMyStuff::IssueFieldValueSet.new({}) unless PlanMyStuff.configuration.issue_fields_enabled
969
+
970
+ data = PlanMyStuff.client.graphql(
971
+ PlanMyStuff::GraphQL::Queries::READ_ISSUE_FIELD_VALUES,
972
+ variables: { owner: repo.organization, name: repo.name, number: number },
973
+ )
974
+ nodes = data.dig(:repository, :issue, :issueFieldValues, :nodes)
975
+ PlanMyStuff::IssueFieldValueSet.from_graphql(nodes)
976
+ end
977
+
978
+ # Builds one element of the +issueFields+ argument to the +setIssueFieldValue+ mutation. Looks up the field
979
+ # definition in the provided hash to pick the right input fragment and (for single-select) resolve the option ID.
980
+ #
981
+ # @raise [PlanMyStuff::Error] if the field name is unknown on the org
982
+ #
983
+ # @param fields_by_name [Hash{String => PlanMyStuff::IssueField}] fields keyed by downcased display name
984
+ # @param name [String, Symbol]
985
+ # @param value [Object, nil]
986
+ #
987
+ # @return [Hash]
988
+ #
989
+ def build_issue_field_input(fields_by_name, name, value)
990
+ field = fields_by_name[name.to_s.downcase]
991
+ raise(PlanMyStuff::Error, "Unknown Issue Field #{name.inspect}") if field.nil?
992
+
993
+ return { fieldId: field.id, delete: true } if value.nil?
994
+
995
+ case field.type
996
+ when :single_select then { fieldId: field.id, singleSelectOptionId: field.option_id_for!(value) }
997
+ when :date then { fieldId: field.id, dateValue: value.to_date.iso8601 }
998
+ when :number
999
+ unless value.is_a?(Numeric)
1000
+ raise(PlanMyStuff::Error, "Issue Field #{name.inspect} expects Numeric, got #{value.inspect}")
1001
+ end
1002
+
1003
+ { fieldId: field.id, numberValue: value.to_f }
1004
+ when :text then { fieldId: field.id, textValue: value.to_s }
1005
+ end
1006
+ end
901
1007
  end
902
1008
  end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ # Value object wrapping an organization-level GitHub Issue Field definition (public preview).
5
+ # Issue Fields are structured per-issue metadata (text, number, date, or single-select)
6
+ # configured once at the org level and applied across all of the org's repositories.
7
+ #
8
+ # Read-only on the gem side: callers manage field *definitions* through the GitHub UI, while
9
+ # the gem only handles field *values* on individual issues (see +Issue#issue_fields+).
10
+ class IssueField
11
+ # GraphQL +__typename+ -> normalized type symbol used internally.
12
+ TYPES = {
13
+ IssueFieldText: :text,
14
+ IssueFieldNumber: :number,
15
+ IssueFieldDate: :date,
16
+ IssueFieldSingleSelect: :single_select,
17
+ }.freeze
18
+
19
+ # @return [String] GraphQL node ID, e.g. +"IFSS_kgDOAAGskA"+
20
+ attr_reader :id
21
+
22
+ # @return [String] display name (e.g. +"Priority"+)
23
+ attr_reader :name
24
+
25
+ # @return [Symbol] one of +:text+, +:number+, +:date+, +:single_select+
26
+ attr_reader :type
27
+
28
+ # @return [String, nil]
29
+ attr_reader :description
30
+
31
+ # @return [Array<Hash>] for +:single_select+, the option list as returned by GraphQL with symbol keys
32
+ # (+id+, +name+, +description+, +color+). Empty for other field types.
33
+ attr_reader :options
34
+
35
+ class << self
36
+ # Lists Issue Field definitions configured on the org.
37
+ #
38
+ # @raise [PlanMyStuff::IssueFieldsNotEnabledError] if +config.issue_fields_enabled+ is +false+
39
+ #
40
+ # @param org [String, nil] org login; defaults to +config.organization+
41
+ #
42
+ # @return [Array<PlanMyStuff::IssueField>]
43
+ #
44
+ def list(org: nil)
45
+ ensure_enabled!
46
+
47
+ org_login = org || PlanMyStuff.configuration.organization
48
+ data = PlanMyStuff.client.graphql(
49
+ PlanMyStuff::GraphQL::Queries::LIST_ORG_ISSUE_FIELDS,
50
+ variables: { org: org_login },
51
+ )
52
+ Array.wrap(data.dig(:organization, :issueFields, :nodes)).map { |node| from_graphql(node) }
53
+ end
54
+
55
+ # @param name [String, Symbol]
56
+ # @param org [String, nil]
57
+ #
58
+ # @return [PlanMyStuff::IssueField, nil]
59
+ #
60
+ def find(name, org: nil)
61
+ list(org: org).find { |field| field.name.casecmp?(name.to_s) }
62
+ end
63
+
64
+ # @param node [Hash] one node from +LIST_ORG_ISSUE_FIELDS+
65
+ #
66
+ # @return [PlanMyStuff::IssueField]
67
+ #
68
+ def from_graphql(node)
69
+ typename = node[:__typename]
70
+ type = TYPES[typename.to_sym] if typename
71
+ raise(PlanMyStuff::Error, "Unknown Issue Field typename: #{typename.inspect}") if type.nil?
72
+
73
+ new(
74
+ id: node.fetch(:id),
75
+ name: node.fetch(:name),
76
+ type: type,
77
+ description: node[:description],
78
+ options: Array.wrap(node[:options]),
79
+ )
80
+ end
81
+
82
+ private
83
+
84
+ # @raise [PlanMyStuff::IssueFieldsNotEnabledError]
85
+ #
86
+ # @return [void]
87
+ #
88
+ def ensure_enabled!
89
+ return if PlanMyStuff.configuration.issue_fields_enabled
90
+
91
+ raise(PlanMyStuff::IssueFieldsNotEnabledError)
92
+ end
93
+ end
94
+
95
+ # @param id [String]
96
+ # @param name [String]
97
+ # @param type [Symbol]
98
+ # @param description [String, nil]
99
+ # @param options [Array<Hash>]
100
+ #
101
+ def initialize(id:, name:, type:, description: nil, options: [])
102
+ @id = id
103
+ @name = name
104
+ @type = type
105
+ @description = description
106
+ @options = options
107
+ end
108
+
109
+ # Resolves a single-select option name to its GraphQL node ID.
110
+ #
111
+ # @raise [PlanMyStuff::Error] if this field is not a single-select, or the option name is unknown
112
+ #
113
+ # @param option_name [String, Symbol]
114
+ #
115
+ # @return [String]
116
+ #
117
+ def option_id_for!(option_name)
118
+ raise(PlanMyStuff::Error, "Field #{name.inspect} is not a single-select") unless type == :single_select
119
+
120
+ match = options.find { |option| option.fetch(:name).casecmp?(option_name.to_s) }
121
+ raise(PlanMyStuff::Error, "Unknown option #{option_name.inspect} for field #{name.inspect}") if match.nil?
122
+
123
+ match.fetch(:id)
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ # Hash-like read-side view of GitHub Issue Field values on a single +Issue+. Returned by
5
+ # +Issue#issue_fields+. Values are coerced into Ruby types on construction: date fields come back
6
+ # as +Date+, number fields as +Float+, single-select fields as the option name +String+, and
7
+ # text fields as the raw +String+.
8
+ #
9
+ # Access is by field display name; string and symbol keys both work. Iteration yields
10
+ # +[name, value]+ pairs in the order GitHub returned them.
11
+ class IssueFieldValueSet
12
+ include Enumerable
13
+
14
+ delegate :empty?, to: :@hash
15
+
16
+ class << self
17
+ # @param nodes [Array<Hash>, nil] +issueFieldValues.nodes+ from the GraphQL read query
18
+ #
19
+ # @return [PlanMyStuff::IssueFieldValueSet]
20
+ #
21
+ def from_graphql(nodes)
22
+ pairs = Array.wrap(nodes).map { |node| [node.dig(:field, :name), coerce(node)] }
23
+ new(pairs.to_h)
24
+ end
25
+
26
+ # @param node [Hash]
27
+ #
28
+ # @return [Object]
29
+ #
30
+ def coerce(node)
31
+ case node[:__typename].to_s
32
+ when 'IssueFieldDateValue' then Date.parse(node.fetch(:value))
33
+ when 'IssueFieldNumberValue' then node.fetch(:value).to_f
34
+ when 'IssueFieldSingleSelectValue' then node.fetch(:name)
35
+ else node.fetch(:value)
36
+ end
37
+ end
38
+ end
39
+
40
+ # @param hash [Hash{String => Object}]
41
+ #
42
+ def initialize(hash)
43
+ @hash = hash
44
+ end
45
+
46
+ # @param name [String, Symbol] field display name
47
+ #
48
+ # @return [Object, nil]
49
+ #
50
+ def [](name)
51
+ @hash[name.to_s]
52
+ end
53
+
54
+ # @return [Hash{String => Object}] copy of the underlying hash
55
+ def to_h
56
+ @hash.dup
57
+ end
58
+
59
+ # @return [Enumerator, void]
60
+ def each(&)
61
+ @hash.each(&)
62
+ end
63
+ end
64
+ end
@@ -3,8 +3,8 @@
3
3
  module PlanMyStuff
4
4
  module VERSION
5
5
  MAJOR = 0
6
- MINOR = 10
7
- TINY = 5
6
+ MINOR = 12
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'
@@ -22,6 +24,8 @@ require_relative 'plan_my_stuff/engine' if defined?(Rails)
22
24
  require_relative 'plan_my_stuff/errors'
23
25
  require_relative 'plan_my_stuff/graphql/queries'
24
26
  require_relative 'plan_my_stuff/issue'
27
+ require_relative 'plan_my_stuff/issue_field'
28
+ require_relative 'plan_my_stuff/issue_field_value_set'
25
29
  require_relative 'plan_my_stuff/issue_metadata'
26
30
  require_relative 'plan_my_stuff/label'
27
31
  require_relative 'plan_my_stuff/link'
@@ -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.12.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
@@ -130,6 +132,8 @@ files:
130
132
  - lib/plan_my_stuff/issue_extractions/links.rb
131
133
  - lib/plan_my_stuff/issue_extractions/viewers.rb
132
134
  - lib/plan_my_stuff/issue_extractions/waiting.rb
135
+ - lib/plan_my_stuff/issue_field.rb
136
+ - lib/plan_my_stuff/issue_field_value_set.rb
133
137
  - lib/plan_my_stuff/issue_metadata.rb
134
138
  - lib/plan_my_stuff/label.rb
135
139
  - lib/plan_my_stuff/link.rb