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 +4 -4
- data/CHANGELOG.md +61 -0
- data/CONFIGURATION.md +20 -0
- data/lib/generators/plan_my_stuff/install/templates/initializer.rb +10 -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 +19 -0
- data/lib/plan_my_stuff/errors.rb +11 -0
- data/lib/plan_my_stuff/graphql/queries.rb +50 -0
- data/lib/plan_my_stuff/issue.rb +109 -3
- data/lib/plan_my_stuff/issue_field.rb +126 -0
- data/lib/plan_my_stuff/issue_field_value_set.rb +64 -0
- data/lib/plan_my_stuff/version.rb +2 -2
- data/lib/plan_my_stuff.rb +4 -0
- data/lib/tasks/plan_my_stuff.rake +113 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d23d00fb8f2addae60d416beb76a19f81fdf0bb0ce5235453262317c6e351c94
|
|
4
|
+
data.tar.gz: a83cf20f6347ae27df34133d20b0b10646f5a96346616d0b5594ea4bd965c10f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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(
|
|
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 =
|
data/lib/plan_my_stuff/errors.rb
CHANGED
|
@@ -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
|
data/lib/plan_my_stuff/issue.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
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.
|
|
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-
|
|
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
|