plan_my_stuff 0.7.0 → 0.8.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 +31 -1
- data/README.md +100 -103
- data/app/controllers/plan_my_stuff/application_controller.rb +22 -3
- data/app/controllers/plan_my_stuff/comments_controller.rb +14 -16
- data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +23 -13
- data/app/controllers/plan_my_stuff/issues/closures_controller.rb +7 -5
- data/app/controllers/plan_my_stuff/issues/links_controller.rb +14 -18
- data/app/controllers/plan_my_stuff/issues/takes_controller.rb +99 -28
- data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +13 -5
- data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +7 -5
- data/app/controllers/plan_my_stuff/issues_controller.rb +24 -28
- data/app/controllers/plan_my_stuff/labels_controller.rb +21 -5
- data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +13 -6
- data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +5 -4
- data/app/controllers/plan_my_stuff/project_items_controller.rb +30 -5
- data/app/controllers/plan_my_stuff/projects_controller.rb +16 -16
- data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +21 -11
- data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +9 -4
- data/app/controllers/plan_my_stuff/testing_projects_controller.rb +30 -14
- data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +50 -17
- data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +32 -49
- data/app/jobs/plan_my_stuff/application_job.rb +2 -3
- data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +15 -22
- data/app/views/plan_my_stuff/comments/edit.html.erb +1 -3
- data/app/views/plan_my_stuff/comments/partials/_form.html.erb +1 -0
- data/app/views/plan_my_stuff/issues/edit.html.erb +2 -4
- data/app/views/plan_my_stuff/issues/index.html.erb +2 -2
- data/app/views/plan_my_stuff/issues/new.html.erb +2 -4
- data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +23 -2
- data/app/views/plan_my_stuff/issues/partials/_form.html.erb +1 -0
- data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +2 -1
- data/app/views/plan_my_stuff/issues/partials/_links.html.erb +50 -7
- data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +2 -1
- data/app/views/plan_my_stuff/issues/show.html.erb +5 -2
- data/app/views/plan_my_stuff/partials/_flash.html.erb +4 -0
- data/app/views/plan_my_stuff/projects/edit.html.erb +1 -3
- data/app/views/plan_my_stuff/projects/index.html.erb +1 -1
- data/app/views/plan_my_stuff/projects/new.html.erb +1 -3
- data/app/views/plan_my_stuff/projects/partials/_form.html.erb +1 -0
- data/app/views/plan_my_stuff/projects/show.html.erb +13 -3
- data/app/views/plan_my_stuff/testing_project_items/new.html.erb +1 -3
- data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +1 -3
- data/app/views/plan_my_stuff/testing_projects/edit.html.erb +1 -3
- data/app/views/plan_my_stuff/testing_projects/new.html.erb +1 -3
- data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +4 -3
- data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +1 -0
- data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +1 -0
- data/app/views/plan_my_stuff/testing_projects/show.html.erb +2 -2
- data/config/routes.rb +2 -2
- data/lib/generators/plan_my_stuff/install/templates/initializer.rb +51 -2
- data/lib/plan_my_stuff/approval.rb +12 -4
- data/lib/plan_my_stuff/aws_sns_simulator.rb +12 -6
- data/lib/plan_my_stuff/base_metadata.rb +4 -15
- data/lib/plan_my_stuff/base_project.rb +68 -55
- data/lib/plan_my_stuff/base_project_item.rb +61 -57
- data/lib/plan_my_stuff/base_project_metadata.rb +1 -1
- data/lib/plan_my_stuff/client.rb +136 -48
- data/lib/plan_my_stuff/comment.rb +57 -57
- data/lib/plan_my_stuff/comment_metadata.rb +1 -1
- data/lib/plan_my_stuff/configuration.rb +95 -82
- data/lib/plan_my_stuff/errors.rb +10 -10
- data/lib/plan_my_stuff/graphql/queries.rb +1 -1
- data/lib/plan_my_stuff/issue.rb +467 -333
- data/lib/plan_my_stuff/issue_metadata.rb +10 -10
- data/lib/plan_my_stuff/label.rb +32 -16
- data/lib/plan_my_stuff/link.rb +15 -15
- data/lib/plan_my_stuff/markdown.rb +12 -6
- data/lib/plan_my_stuff/metadata_parser.rb +3 -1
- data/lib/plan_my_stuff/notifications.rb +1 -1
- data/lib/plan_my_stuff/pipeline/completed_sweep.rb +2 -2
- data/lib/plan_my_stuff/pipeline/issue_linker.rb +1 -1
- data/lib/plan_my_stuff/pipeline.rb +61 -83
- data/lib/plan_my_stuff/project.rb +4 -4
- data/lib/plan_my_stuff/project_item_metadata.rb +1 -1
- data/lib/plan_my_stuff/project_metadata.rb +1 -1
- data/lib/plan_my_stuff/reminders/closer.rb +1 -1
- data/lib/plan_my_stuff/reminders/fire.rb +3 -3
- data/lib/plan_my_stuff/reminders/sweep.rb +4 -4
- data/lib/plan_my_stuff/repo.rb +12 -6
- data/lib/plan_my_stuff/test_helpers.rb +11 -11
- data/lib/plan_my_stuff/testing_project.rb +12 -11
- data/lib/plan_my_stuff/testing_project_item.rb +11 -9
- data/lib/plan_my_stuff/testing_project_metadata.rb +2 -2
- data/lib/plan_my_stuff/version.rb +1 -1
- data/lib/plan_my_stuff/webhook_replayer.rb +14 -2
- data/lib/plan_my_stuff.rb +26 -2
- data/lib/tasks/plan_my_stuff.rake +33 -20
- metadata +3 -2
data/lib/plan_my_stuff/issue.rb
CHANGED
|
@@ -23,20 +23,46 @@ module PlanMyStuff
|
|
|
23
23
|
attribute :labels, default: -> { [] }
|
|
24
24
|
# @return [Time, nil] GitHub's updated_at timestamp
|
|
25
25
|
attribute :updated_at
|
|
26
|
+
# @return [Time, nil] GitHub's created_at timestamp; settable on unpersisted issues for use with +Issue.import+
|
|
27
|
+
attribute :created_at
|
|
26
28
|
# @return [Time, nil] GitHub's closed_at timestamp (nil while open)
|
|
27
29
|
attribute :closed_at
|
|
28
|
-
# @return [Boolean] GitHub's +locked+ flag; +true+ for archived or
|
|
29
|
-
# manually-locked issues (no new comments)
|
|
30
|
+
# @return [Boolean] GitHub's +locked+ flag; +true+ for archived or manually-locked issues (no new comments)
|
|
30
31
|
attribute :locked, :boolean, default: false
|
|
31
32
|
alias locked? locked
|
|
32
33
|
# @return [PlanMyStuff::Repo, nil]
|
|
33
34
|
attribute :repo
|
|
34
35
|
# @return [String, nil] issue body (user-visible content, separate from metadata)
|
|
35
36
|
attribute :body, :string
|
|
37
|
+
# @return [String, nil] GitHub issue type name (e.g. +"Bug"+, +"Feature"+) or +nil+ when no type is assigned. Read
|
|
38
|
+
# from the nested +type.name+ field on the REST response. Settable via the +issue_type:+ kwarg on
|
|
39
|
+
# +Issue.create!+ / +Issue.update!+.
|
|
40
|
+
attribute :issue_type, :string
|
|
41
|
+
|
|
42
|
+
# Sentinel default for the +issue_type:+ kwarg on +Issue.update!+. Lets the class method differentiate "kwarg not
|
|
43
|
+
# provided" (don't touch the type) from "kwarg explicitly set to +nil+" (clear the type). +nil+ alone can't carry
|
|
44
|
+
# that distinction so we need an object identity check.
|
|
45
|
+
ISSUE_TYPE_UNCHANGED = Object.new.freeze
|
|
46
|
+
private_constant :ISSUE_TYPE_UNCHANGED
|
|
47
|
+
|
|
48
|
+
# Symbol nicknames for the seven GitHub native issue types the gem knows about. Resolved to canonical names which
|
|
49
|
+
# then pass through +config.issue_types+ for org-specific renames.
|
|
50
|
+
ISSUE_TYPE_NICKNAMES = {
|
|
51
|
+
bug: 'Bug',
|
|
52
|
+
feature: 'Feature',
|
|
53
|
+
it_issue: 'IT Issue / Hardware',
|
|
54
|
+
other: 'Other',
|
|
55
|
+
performance: 'Performance',
|
|
56
|
+
question: 'Question',
|
|
57
|
+
task: 'Task',
|
|
58
|
+
}.freeze
|
|
59
|
+
private_constant :ISSUE_TYPE_NICKNAMES
|
|
36
60
|
|
|
37
61
|
class << self
|
|
38
62
|
# Creates a GitHub issue with PMS metadata embedded in the body.
|
|
39
63
|
#
|
|
64
|
+
# @raise [PlanMyStuff::ValidationError] when body is blank
|
|
65
|
+
#
|
|
40
66
|
# @param title [String]
|
|
41
67
|
# @param body [String]
|
|
42
68
|
# @param repo [Symbol, String, nil] defaults to config.default_repo
|
|
@@ -46,6 +72,8 @@ module PlanMyStuff
|
|
|
46
72
|
# @param add_to_project [Boolean, Integer, nil]
|
|
47
73
|
# @param visibility [String] "public" or "internal"
|
|
48
74
|
# @param visibility_allowlist [Array<Integer>] user IDs for internal comment access
|
|
75
|
+
# @param issue_type [String, nil] GitHub issue type name (e.g. +"Bug"+, +"Feature"+). Must match a type
|
|
76
|
+
# configured on the org. +nil+ creates the issue with no type.
|
|
49
77
|
#
|
|
50
78
|
# @return [PlanMyStuff::Issue]
|
|
51
79
|
#
|
|
@@ -58,14 +86,17 @@ module PlanMyStuff
|
|
|
58
86
|
metadata: {},
|
|
59
87
|
add_to_project: nil,
|
|
60
88
|
visibility: 'public',
|
|
61
|
-
visibility_allowlist: []
|
|
89
|
+
visibility_allowlist: [],
|
|
90
|
+
issue_type: nil
|
|
62
91
|
)
|
|
63
|
-
|
|
92
|
+
if body.blank?
|
|
93
|
+
raise(PlanMyStuff::ValidationError.new('body must be present', field: :body, expected_type: :string))
|
|
94
|
+
end
|
|
64
95
|
|
|
65
96
|
client = PlanMyStuff.client
|
|
66
|
-
resolved_repo = client.resolve_repo(repo)
|
|
97
|
+
resolved_repo = client.resolve_repo!(repo)
|
|
67
98
|
|
|
68
|
-
issue_metadata = IssueMetadata.build(
|
|
99
|
+
issue_metadata = PlanMyStuff::IssueMetadata.build(
|
|
69
100
|
user: user,
|
|
70
101
|
visibility: visibility,
|
|
71
102
|
custom_fields: metadata,
|
|
@@ -73,10 +104,13 @@ module PlanMyStuff
|
|
|
73
104
|
issue_metadata.visibility_allowlist = Array.wrap(visibility_allowlist)
|
|
74
105
|
issue_metadata.validate_custom_fields!
|
|
75
106
|
|
|
76
|
-
serialized_body = MetadataParser.serialize(issue_metadata.to_h, '')
|
|
107
|
+
serialized_body = PlanMyStuff::MetadataParser.serialize!(issue_metadata.to_h, '')
|
|
108
|
+
|
|
109
|
+
resolved_type = resolve_issue_type!(issue_type)
|
|
77
110
|
|
|
78
111
|
options = {}
|
|
79
|
-
options[:labels] = labels if labels.
|
|
112
|
+
options[:labels] = labels if labels.present?
|
|
113
|
+
options[:type] = resolved_type if resolved_type.present?
|
|
80
114
|
|
|
81
115
|
result = client.rest(:create_issue, resolved_repo, title, serialized_body, **options)
|
|
82
116
|
number = read_field(result, :number)
|
|
@@ -88,7 +122,7 @@ module PlanMyStuff
|
|
|
88
122
|
:update_issue,
|
|
89
123
|
resolved_repo,
|
|
90
124
|
number,
|
|
91
|
-
body: MetadataParser.serialize(issue_metadata.to_h, link_body),
|
|
125
|
+
body: PlanMyStuff::MetadataParser.serialize!(issue_metadata.to_h, link_body),
|
|
92
126
|
)
|
|
93
127
|
store_etag_to_cache(client, resolved_repo, number, result, cache_writer: :write_issue)
|
|
94
128
|
end
|
|
@@ -96,11 +130,11 @@ module PlanMyStuff
|
|
|
96
130
|
issue = find(number, repo: resolved_repo)
|
|
97
131
|
|
|
98
132
|
if add_to_project.present?
|
|
99
|
-
project_number = resolve_project_number(add_to_project)
|
|
100
|
-
ProjectItem.create!(issue, project_number: project_number)
|
|
133
|
+
project_number = resolve_project_number!(add_to_project)
|
|
134
|
+
PlanMyStuff::ProjectItem.create!(issue, project_number: project_number)
|
|
101
135
|
end
|
|
102
136
|
|
|
103
|
-
Comment.create!(
|
|
137
|
+
PlanMyStuff::Comment.create!(
|
|
104
138
|
issue: issue,
|
|
105
139
|
body: body,
|
|
106
140
|
user: user,
|
|
@@ -133,6 +167,9 @@ module PlanMyStuff
|
|
|
133
167
|
# @param labels [Array<String>, nil]
|
|
134
168
|
# @param state [Symbol, nil] :open or :closed
|
|
135
169
|
# @param assignees [Array<String>, String, nil] GitHub logins
|
|
170
|
+
# @param issue_type [String, nil] GitHub issue type name. Pass a String to set, +nil+ to clear, or omit the
|
|
171
|
+
# kwarg to leave the current type untouched. (+nil+-vs-omitted is differentiated by the private
|
|
172
|
+
# +ISSUE_TYPE_UNCHANGED+ sentinel.)
|
|
136
173
|
#
|
|
137
174
|
# @return [Object]
|
|
138
175
|
#
|
|
@@ -144,25 +181,28 @@ module PlanMyStuff
|
|
|
144
181
|
metadata: nil,
|
|
145
182
|
labels: nil,
|
|
146
183
|
state: nil,
|
|
147
|
-
assignees: nil
|
|
184
|
+
assignees: nil,
|
|
185
|
+
issue_type: ISSUE_TYPE_UNCHANGED
|
|
148
186
|
)
|
|
149
187
|
client = PlanMyStuff.client
|
|
150
|
-
resolved_repo = client.resolve_repo(repo)
|
|
188
|
+
resolved_repo = client.resolve_repo!(repo)
|
|
151
189
|
|
|
152
190
|
options = {}
|
|
153
191
|
options[:title] = title unless title.nil?
|
|
154
192
|
options[:labels] = labels unless labels.nil?
|
|
155
193
|
options[:state] = state.to_s unless state.nil?
|
|
156
194
|
options[:assignees] = Array.wrap(assignees) unless assignees.nil?
|
|
195
|
+
options[:type] = resolve_issue_type!(issue_type) unless issue_type.equal?(ISSUE_TYPE_UNCHANGED)
|
|
157
196
|
|
|
158
197
|
case metadata
|
|
159
198
|
when PlanMyStuff::IssueMetadata
|
|
160
199
|
metadata.validate_custom_fields!
|
|
161
|
-
options[:body] =
|
|
200
|
+
options[:body] =
|
|
201
|
+
PlanMyStuff::MetadataParser.serialize!(metadata.to_h, visible_body_for(number, resolved_repo))
|
|
162
202
|
when Hash
|
|
163
203
|
current = client.rest(:issue, resolved_repo, number)
|
|
164
204
|
current_body = current.respond_to?(:body) ? current.body : current[:body]
|
|
165
|
-
parsed = MetadataParser.parse(current_body)
|
|
205
|
+
parsed = PlanMyStuff::MetadataParser.parse(current_body)
|
|
166
206
|
existing_metadata = parsed[:metadata]
|
|
167
207
|
|
|
168
208
|
merged_custom_fields = (existing_metadata[:custom_fields] || {}).merge(metadata[:custom_fields] || {})
|
|
@@ -173,10 +213,11 @@ module PlanMyStuff
|
|
|
173
213
|
merged_custom_fields,
|
|
174
214
|
).validate!
|
|
175
215
|
|
|
176
|
-
options[:body] =
|
|
216
|
+
options[:body] =
|
|
217
|
+
PlanMyStuff::MetadataParser.serialize!(existing_metadata, visible_body_for(number, resolved_repo))
|
|
177
218
|
end
|
|
178
219
|
|
|
179
|
-
update_body_comment(number, resolved_repo, body) if body
|
|
220
|
+
update_body_comment!(number, resolved_repo, body) if body
|
|
180
221
|
|
|
181
222
|
return if options.none?
|
|
182
223
|
|
|
@@ -187,6 +228,8 @@ module PlanMyStuff
|
|
|
187
228
|
|
|
188
229
|
# Finds a single GitHub issue by number and parses its PMS metadata.
|
|
189
230
|
#
|
|
231
|
+
# @raise [Octokit::NotFound] when the issue number resolves to a pull request
|
|
232
|
+
#
|
|
190
233
|
# @param number [Integer]
|
|
191
234
|
# @param repo [Symbol, String, nil] defaults to config.default_repo
|
|
192
235
|
#
|
|
@@ -194,7 +237,7 @@ module PlanMyStuff
|
|
|
194
237
|
#
|
|
195
238
|
def find(number, repo: nil)
|
|
196
239
|
client = PlanMyStuff.client
|
|
197
|
-
resolved_repo = client.resolve_repo(repo)
|
|
240
|
+
resolved_repo = client.resolve_repo!(repo)
|
|
198
241
|
|
|
199
242
|
github_issue =
|
|
200
243
|
fetch_with_etag_cache(
|
|
@@ -206,7 +249,14 @@ module PlanMyStuff
|
|
|
206
249
|
cache_writer: :write_issue,
|
|
207
250
|
)
|
|
208
251
|
|
|
209
|
-
|
|
252
|
+
pull_request =
|
|
253
|
+
if github_issue.respond_to?(:pull_request)
|
|
254
|
+
github_issue.pull_request
|
|
255
|
+
elsif github_issue.is_a?(Hash)
|
|
256
|
+
github_issue[:pull_request] || github_issue['pull_request']
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
if pull_request
|
|
210
260
|
raise(Octokit::NotFound, { method: 'GET', url: "repos/#{resolved_repo}/issues/#{number}" })
|
|
211
261
|
end
|
|
212
262
|
|
|
@@ -225,10 +275,10 @@ module PlanMyStuff
|
|
|
225
275
|
#
|
|
226
276
|
def list(repo: nil, state: :open, labels: [], page: 1, per_page: 25)
|
|
227
277
|
client = PlanMyStuff.client
|
|
228
|
-
resolved_repo = client.resolve_repo(repo)
|
|
278
|
+
resolved_repo = client.resolve_repo!(repo)
|
|
229
279
|
|
|
230
280
|
params = { state: state.to_s, page: page, per_page: per_page }
|
|
231
|
-
params[:labels] = labels.sort.join(',') if labels.
|
|
281
|
+
params[:labels] = labels.sort.join(',') if labels.present?
|
|
232
282
|
|
|
233
283
|
cached = PlanMyStuff::Cache.read_list(:issue, resolved_repo, params)
|
|
234
284
|
request_options = cached ? params.merge(headers: { 'If-None-Match' => cached[:etag] }) : params
|
|
@@ -244,14 +294,105 @@ module PlanMyStuff
|
|
|
244
294
|
filtered.map { |gi| build(gi, repo: resolved_repo) }
|
|
245
295
|
end
|
|
246
296
|
|
|
297
|
+
# Submits one or more pre-built payloads to GitHub's "Import Issues" preview endpoint
|
|
298
|
+
# (+POST /repos/:repo/import/issues+). One request per payload: the endpoint only accepts a single
|
|
299
|
+
# +{issue:, comments:}+ payload at a time.
|
|
300
|
+
#
|
|
301
|
+
# Each payload hash MUST include a +:repo+ key (symbol, string, or +PlanMyStuff::Repo+) and the GitHub-shaped
|
|
302
|
+
# +:issue+ /+ :comments+ keys; +:repo+ is extracted before the POST. Payloads are passed through to GitHub
|
|
303
|
+
# unchanged otherwise - callers are responsible for shape, encoding, and any PlanMyStuff metadata they want to
|
|
304
|
+
# embed.
|
|
305
|
+
#
|
|
306
|
+
# The endpoint is async: each response carries an +id+ and +url+ for polling via +Issue.check_import+.
|
|
307
|
+
#
|
|
308
|
+
# @raise [ArgumentError] when the import payload is missing :repo
|
|
309
|
+
#
|
|
310
|
+
# @param payloads [Array<Hash>, Hash]
|
|
311
|
+
#
|
|
312
|
+
# @return [Array<Hash>] one parsed status hash per input payload, in input order
|
|
313
|
+
#
|
|
314
|
+
def import!(payloads)
|
|
315
|
+
client = PlanMyStuff.import_client
|
|
316
|
+
|
|
317
|
+
Array.wrap(payloads).map do |payload|
|
|
318
|
+
repo = payload[:repo] || payload['repo'] || PlanMyStuff.configuration.default_repo
|
|
319
|
+
raise(ArgumentError, 'import payload must include :repo') if repo.blank?
|
|
320
|
+
|
|
321
|
+
body = payload.except(:repo, 'repo')
|
|
322
|
+
submit_import_request!(client, client.resolve_repo!(repo), body)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Polls a previously-submitted import for its current status.
|
|
327
|
+
#
|
|
328
|
+
# @raise [PlanMyStuff::APIError] when the GitHub API call fails
|
|
329
|
+
#
|
|
330
|
+
# @param import_id [Integer] +id+ from the +Issue.import+ response
|
|
331
|
+
# @param repo [Symbol, String, nil] defaults to config.default_repo
|
|
332
|
+
#
|
|
333
|
+
# @return [Hash] parsed status response
|
|
334
|
+
#
|
|
335
|
+
def check_import!(import_id, repo: nil)
|
|
336
|
+
client = PlanMyStuff.import_client
|
|
337
|
+
resolved_repo = client.resolve_repo!(repo)
|
|
338
|
+
|
|
339
|
+
client.octokit.get(
|
|
340
|
+
"/repos/#{resolved_repo}/import/issues/#{import_id}",
|
|
341
|
+
accept: 'application/vnd.github.golden-comet-preview+json',
|
|
342
|
+
)
|
|
343
|
+
rescue Octokit::ClientError, Octokit::ServerError => e
|
|
344
|
+
raise(PlanMyStuff::APIError.new(e.message, status: e.respond_to?(:response_status) ? e.response_status : nil))
|
|
345
|
+
end
|
|
346
|
+
|
|
247
347
|
private
|
|
248
348
|
|
|
249
|
-
#
|
|
250
|
-
#
|
|
251
|
-
#
|
|
252
|
-
#
|
|
253
|
-
#
|
|
254
|
-
#
|
|
349
|
+
# Resolves an +issue_type:+ kwarg to the literal display name GitHub expects. Two stages: a Symbol is first
|
|
350
|
+
# looked up in the gem-side +ISSUE_TYPE_NICKNAMES+ to get a canonical name; then the canonical (or
|
|
351
|
+
# directly-provided String) name is passed through +config.issue_types+ for org-specific renames. Missing
|
|
352
|
+
# entries in +config.issue_types+ fall through unchanged.
|
|
353
|
+
#
|
|
354
|
+
# @raise [ArgumentError] if a Symbol isn't a known nickname, or +value+ is not a Symbol/String/nil
|
|
355
|
+
#
|
|
356
|
+
# @param value [Symbol, String, nil]
|
|
357
|
+
#
|
|
358
|
+
# @return [String, nil]
|
|
359
|
+
#
|
|
360
|
+
def resolve_issue_type!(value)
|
|
361
|
+
return if value.nil?
|
|
362
|
+
|
|
363
|
+
canonical =
|
|
364
|
+
case value
|
|
365
|
+
when String
|
|
366
|
+
value
|
|
367
|
+
when Symbol
|
|
368
|
+
ISSUE_TYPE_NICKNAMES[value] || raise(
|
|
369
|
+
ArgumentError,
|
|
370
|
+
"Unknown issue_type nickname #{value.inspect}; known: #{ISSUE_TYPE_NICKNAMES.keys.inspect}",
|
|
371
|
+
)
|
|
372
|
+
else
|
|
373
|
+
raise(ArgumentError, "issue_type must be a String, Symbol, or nil; got #{value.class}")
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
PlanMyStuff.configuration.issue_types[canonical] || canonical
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# @raise [PlanMyStuff::APIError] when the GitHub API call fails
|
|
380
|
+
#
|
|
381
|
+
# @return [Hash]
|
|
382
|
+
#
|
|
383
|
+
def submit_import_request!(client, resolved_repo, payload)
|
|
384
|
+
client.octokit.post(
|
|
385
|
+
"/repos/#{resolved_repo}/import/issues",
|
|
386
|
+
payload.merge(accept: 'application/vnd.github.golden-comet-preview+json'),
|
|
387
|
+
)
|
|
388
|
+
rescue Octokit::ClientError, Octokit::ServerError => e
|
|
389
|
+
raise(PlanMyStuff::APIError.new(e.message, status: e.respond_to?(:response_status) ? e.response_status : nil))
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Builds the visible body string written to GitHub for an issue: a markdown link to the consuming-app
|
|
393
|
+
# per-issue URL (carrying the repo as a +?repo=+ query param so the consuming app knows which repo this issue
|
|
394
|
+
# lives in), labelled with the GitHub +Org/Repo#number+. Returns +""+ when either +config.issues_url_prefix+
|
|
395
|
+
# or +number+ is missing.
|
|
255
396
|
#
|
|
256
397
|
# @param number [Integer]
|
|
257
398
|
# @param repo [String] full repo path, e.g. +"BrandsInsurance/Element"+
|
|
@@ -279,16 +420,21 @@ module PlanMyStuff
|
|
|
279
420
|
issue
|
|
280
421
|
end
|
|
281
422
|
|
|
423
|
+
# @raise [ArgumentError] when add_to_project is true but no default_project_number is configured
|
|
424
|
+
#
|
|
282
425
|
# @return [Integer]
|
|
283
|
-
|
|
426
|
+
#
|
|
427
|
+
def resolve_project_number!(add_to_project)
|
|
284
428
|
return add_to_project unless add_to_project == true
|
|
285
429
|
|
|
286
430
|
PlanMyStuff.configuration.default_project_number ||
|
|
287
431
|
raise(ArgumentError, 'add_to_project: true requires config.default_project_number to be set')
|
|
288
432
|
end
|
|
289
433
|
|
|
290
|
-
# Finds the first PMS comment on an issue and updates its body content,
|
|
291
|
-
#
|
|
434
|
+
# Finds the first PMS comment on an issue and updates its body content, preserving the comment header and
|
|
435
|
+
# metadata.
|
|
436
|
+
#
|
|
437
|
+
# @raise [PlanMyStuff::Error] when the issue has no body comment
|
|
292
438
|
#
|
|
293
439
|
# @param number [Integer] issue number
|
|
294
440
|
# @param resolved_repo [String] resolved repo path
|
|
@@ -296,7 +442,7 @@ module PlanMyStuff
|
|
|
296
442
|
#
|
|
297
443
|
# @return [void]
|
|
298
444
|
#
|
|
299
|
-
def update_body_comment(number, resolved_repo, new_body)
|
|
445
|
+
def update_body_comment!(number, resolved_repo, new_body)
|
|
300
446
|
issue = find(number, repo: resolved_repo)
|
|
301
447
|
body_comment = issue.body_comment
|
|
302
448
|
raise(PlanMyStuff::Error, "No body comment found on issue ##{number}") if body_comment.nil?
|
|
@@ -312,13 +458,11 @@ module PlanMyStuff
|
|
|
312
458
|
|
|
313
459
|
# @param value [PlanMyStuff::Repo, Symbol, String, nil]
|
|
314
460
|
def repo=(value)
|
|
315
|
-
super(value.present? ? PlanMyStuff::Repo.resolve(value) : nil)
|
|
461
|
+
super(value.present? ? PlanMyStuff::Repo.resolve!(value) : nil)
|
|
316
462
|
end
|
|
317
463
|
|
|
318
|
-
# Assigning a new body marks the instance dirty so the next
|
|
319
|
-
#
|
|
320
|
-
# assignments are reflected by +#body+ until persisted or
|
|
321
|
-
# reloaded.
|
|
464
|
+
# Assigning a new body marks the instance dirty so the next +save!+ rewrites the backing PMS body comment.
|
|
465
|
+
# Unsaved assignments are reflected by +#body+ until persisted or reloaded.
|
|
322
466
|
#
|
|
323
467
|
# @param value [String]
|
|
324
468
|
#
|
|
@@ -329,10 +473,9 @@ module PlanMyStuff
|
|
|
329
473
|
@body_dirty = true
|
|
330
474
|
end
|
|
331
475
|
|
|
332
|
-
# @return [String, nil] per-issue URL in the consuming app
|
|
333
|
-
#
|
|
334
|
-
#
|
|
335
|
-
# as the destination of the markdown link in the GitHub issue body.
|
|
476
|
+
# @return [String, nil] per-issue URL in the consuming app (+config.issues_url_prefix+ + +"/"+ + +number+ +
|
|
477
|
+
# +"?repo=Org/Repo"+, or +nil+ when either prefix or number is missing). Also rendered as the destination of
|
|
478
|
+
# the markdown link in the GitHub issue body.
|
|
336
479
|
def user_link
|
|
337
480
|
prefix = PlanMyStuff.configuration.issues_url_prefix
|
|
338
481
|
return if prefix.blank? || number.blank?
|
|
@@ -343,28 +486,35 @@ module PlanMyStuff
|
|
|
343
486
|
"#{base}?repo=#{URI.encode_www_form_component(repo.full_name)}"
|
|
344
487
|
end
|
|
345
488
|
|
|
346
|
-
# @return [Array<PlanMyStuff::Approval>] all required approvers (pending + approved)
|
|
489
|
+
# @return [Array<PlanMyStuff::Approval>] all required approvers (pending + approved + rejected)
|
|
347
490
|
def approvers
|
|
348
491
|
metadata.approvals
|
|
349
492
|
end
|
|
350
493
|
|
|
351
|
-
# @return [Array<PlanMyStuff::Approval>] approvers who have not yet
|
|
494
|
+
# @return [Array<PlanMyStuff::Approval>] approvers who have not yet acted (pending only; rejections are NOT
|
|
495
|
+
# pending -- the approver has responded)
|
|
352
496
|
def pending_approvals
|
|
353
497
|
approvers.select(&:pending?)
|
|
354
498
|
end
|
|
355
499
|
|
|
500
|
+
# @return [Array<PlanMyStuff::Approval>] approvers who have rejected
|
|
501
|
+
def rejected_approvals
|
|
502
|
+
approvers.select(&:rejected?)
|
|
503
|
+
end
|
|
504
|
+
|
|
356
505
|
# @return [Boolean] true when at least one approver is required on this issue
|
|
357
506
|
def approvals_required?
|
|
358
|
-
approvers.
|
|
507
|
+
approvers.present?
|
|
359
508
|
end
|
|
360
509
|
|
|
361
|
-
# @return [Boolean] true when approvers are required AND every approver has approved
|
|
510
|
+
# @return [Boolean] true when approvers are required AND every approver has approved. A single rejection blocks
|
|
511
|
+
# this gate until the approver revokes.
|
|
362
512
|
def fully_approved?
|
|
363
|
-
approvals_required? &&
|
|
513
|
+
approvals_required? && approvers.all?(&:approved?)
|
|
364
514
|
end
|
|
365
515
|
|
|
366
|
-
# Adds user IDs to this issue's visibility allowlist (non-support
|
|
367
|
-
#
|
|
516
|
+
# Adds user IDs to this issue's visibility allowlist (non-support users whose ID is in the allowlist can see
|
|
517
|
+
# internal comments).
|
|
368
518
|
#
|
|
369
519
|
# Fires +plan_my_stuff.issue.viewers_added+.
|
|
370
520
|
#
|
|
@@ -373,9 +523,9 @@ module PlanMyStuff
|
|
|
373
523
|
#
|
|
374
524
|
# @return [Array<Integer>] the new allowlist
|
|
375
525
|
#
|
|
376
|
-
def add_viewers(user_ids:, user: nil)
|
|
526
|
+
def add_viewers!(user_ids:, user: nil)
|
|
377
527
|
ids = Array.wrap(user_ids)
|
|
378
|
-
modify_allowlist { |allowlist| allowlist | ids }
|
|
528
|
+
modify_allowlist! { |allowlist| allowlist | ids }
|
|
379
529
|
PlanMyStuff::Notifications.instrument('issue.viewers_added', self, user: user, user_ids: ids)
|
|
380
530
|
metadata.visibility_allowlist
|
|
381
531
|
end
|
|
@@ -389,33 +539,30 @@ module PlanMyStuff
|
|
|
389
539
|
#
|
|
390
540
|
# @return [Array<Integer>] the new allowlist
|
|
391
541
|
#
|
|
392
|
-
def remove_viewers(user_ids:, user: nil)
|
|
542
|
+
def remove_viewers!(user_ids:, user: nil)
|
|
393
543
|
ids = Array.wrap(user_ids)
|
|
394
|
-
modify_allowlist { |allowlist| allowlist - ids }
|
|
544
|
+
modify_allowlist! { |allowlist| allowlist - ids }
|
|
395
545
|
PlanMyStuff::Notifications.instrument('issue.viewers_removed', self, user: user, user_ids: ids)
|
|
396
546
|
metadata.visibility_allowlist
|
|
397
547
|
end
|
|
398
548
|
|
|
399
|
-
# Adds approvers to this issue's required-approvals list. Idempotent:
|
|
400
|
-
#
|
|
549
|
+
# Adds approvers to this issue's required-approvals list. Idempotent: users already present are no-ops. Only
|
|
550
|
+
# support users may call this.
|
|
401
551
|
#
|
|
402
|
-
# Fires +plan_my_stuff.issue.approval_requested+ when any user is
|
|
403
|
-
#
|
|
404
|
-
# (+trigger: :approver_added+) when the new approvers flip the issue
|
|
552
|
+
# Fires +plan_my_stuff.issue.approval_requested+ when any user is newly added. Also fires
|
|
553
|
+
# +plan_my_stuff.issue.approvals_invalidated+ (+trigger: :approver_added+) when the new approvers flip the issue
|
|
405
554
|
# out of a fully-approved state.
|
|
406
555
|
#
|
|
407
556
|
# @param user_ids [Array<Integer>, Integer]
|
|
408
557
|
# @param user [Object, nil] actor; must be a support user
|
|
409
558
|
#
|
|
410
|
-
# @raise [PlanMyStuff::AuthorizationError] if +user+ is not support
|
|
411
|
-
#
|
|
412
559
|
# @return [Array<PlanMyStuff::Approval>] newly-added approvals (empty when all were duplicates)
|
|
413
560
|
#
|
|
414
561
|
def request_approvals!(user_ids:, user: nil)
|
|
415
562
|
guard_support!(user)
|
|
416
563
|
ids = Array.wrap(user_ids).map(&:to_i)
|
|
417
564
|
|
|
418
|
-
just_added, was_fully_approved = modify_approvals do |current|
|
|
565
|
+
just_added, was_fully_approved = modify_approvals! do |current|
|
|
419
566
|
existing_ids = current.map(&:user_id)
|
|
420
567
|
new_ids = ids - existing_ids
|
|
421
568
|
added = new_ids.map { |id| PlanMyStuff::Approval.new(user_id: id, status: 'pending') }
|
|
@@ -426,25 +573,21 @@ module PlanMyStuff
|
|
|
426
573
|
just_added
|
|
427
574
|
end
|
|
428
575
|
|
|
429
|
-
# Removes approvers from this issue's required-approvals list. Only
|
|
430
|
-
#
|
|
431
|
-
#
|
|
432
|
-
#
|
|
433
|
-
# Removing the last approver never fires aggregate events (issue no
|
|
434
|
-
# longer has +approvals_required?+).
|
|
576
|
+
# Removes approvers from this issue's required-approvals list. Only support users may call this. Removing a
|
|
577
|
+
# pending approver may flip the issue into +fully_approved?+ (fires +all_approved+). Removing an approved
|
|
578
|
+
# approver fires no events (state does not flip). Removing the last approver never fires aggregate events (issue
|
|
579
|
+
# no longer has +approvals_required?+).
|
|
435
580
|
#
|
|
436
581
|
# @param user_ids [Array<Integer>, Integer]
|
|
437
582
|
# @param user [Object, nil] actor; must be a support user
|
|
438
583
|
#
|
|
439
|
-
# @raise [PlanMyStuff::AuthorizationError] if +user+ is not support
|
|
440
|
-
#
|
|
441
584
|
# @return [Array<PlanMyStuff::Approval>] removed approval records
|
|
442
585
|
#
|
|
443
586
|
def remove_approvers!(user_ids:, user: nil)
|
|
444
587
|
guard_support!(user)
|
|
445
588
|
ids = Array.wrap(user_ids).map(&:to_i)
|
|
446
589
|
|
|
447
|
-
just_removed, was_fully_approved = modify_approvals do |current|
|
|
590
|
+
just_removed, was_fully_approved = modify_approvals! do |current|
|
|
448
591
|
removed = current.select { |a| ids.include?(a.user_id) }
|
|
449
592
|
[current - removed, removed]
|
|
450
593
|
end
|
|
@@ -453,27 +596,27 @@ module PlanMyStuff
|
|
|
453
596
|
just_removed
|
|
454
597
|
end
|
|
455
598
|
|
|
456
|
-
# Flips the caller's approval from +pending+
|
|
457
|
-
#
|
|
458
|
-
# +plan_my_stuff.issue.
|
|
459
|
-
# completes the approval set, +plan_my_stuff.issue.all_approved+.
|
|
460
|
-
#
|
|
461
|
-
# @param user [Object, Integer] actor; must resolve to an approver currently +pending+
|
|
599
|
+
# Flips the caller's approval to +approved+ from any other state (+pending+ or +rejected+). Only the approver
|
|
600
|
+
# themselves may call this. Fires +plan_my_stuff.issue.approval_granted+ and, when this flip completes the
|
|
601
|
+
# approval set, +plan_my_stuff.issue.all_approved+.
|
|
462
602
|
#
|
|
463
603
|
# @raise [PlanMyStuff::ValidationError] when the caller is not in the approvers list or is already approved
|
|
464
604
|
#
|
|
605
|
+
# @param user [Object, Integer] actor; must resolve to an approver
|
|
606
|
+
#
|
|
465
607
|
# @return [PlanMyStuff::Approval] the updated approval
|
|
466
608
|
#
|
|
467
609
|
def approve!(user:)
|
|
468
610
|
actor_id = resolve_actor_id!(user)
|
|
469
611
|
|
|
470
|
-
just_approved, was_fully_approved = modify_approvals do |current|
|
|
612
|
+
just_approved, was_fully_approved = modify_approvals! do |current|
|
|
471
613
|
approval = current.find { |a| a.user_id == actor_id }
|
|
472
614
|
raise(PlanMyStuff::ValidationError, "User #{actor_id} is not in the approvers list") if approval.nil?
|
|
473
|
-
raise(PlanMyStuff::ValidationError, "User #{actor_id} has already approved")
|
|
615
|
+
raise(PlanMyStuff::ValidationError, "User #{actor_id} has already approved") if approval.approved?
|
|
474
616
|
|
|
475
617
|
approval.status = 'approved'
|
|
476
618
|
approval.approved_at = Time.current
|
|
619
|
+
approval.rejected_at = nil
|
|
477
620
|
[current, approval]
|
|
478
621
|
end
|
|
479
622
|
|
|
@@ -481,21 +624,55 @@ module PlanMyStuff
|
|
|
481
624
|
just_approved
|
|
482
625
|
end
|
|
483
626
|
|
|
484
|
-
# Flips
|
|
485
|
-
#
|
|
486
|
-
#
|
|
487
|
-
#
|
|
488
|
-
# +AuthorizationError+.
|
|
627
|
+
# Flips the caller's approval to +rejected+ from any other state (+pending+ or +approved+). Only the approver
|
|
628
|
+
# themselves may call this. Fires +plan_my_stuff.issue.approval_rejected+ and, when this flip drops the issue
|
|
629
|
+
# out of +fully_approved?+ (i.e. the caller was the last +approved+ approver),
|
|
630
|
+
# +plan_my_stuff.issue.approvals_invalidated+ (+trigger: :rejected+).
|
|
489
631
|
#
|
|
490
|
-
#
|
|
491
|
-
# drops the issue out of +fully_approved?+,
|
|
492
|
-
# +plan_my_stuff.issue.approvals_invalidated+ (+trigger: :revoked+).
|
|
632
|
+
# @raise [PlanMyStuff::ValidationError] when the caller is not in the approvers list or is already rejected
|
|
493
633
|
#
|
|
494
|
-
# @param user [Object, Integer]
|
|
495
|
-
#
|
|
634
|
+
# @param user [Object, Integer] actor; must resolve to an approver
|
|
635
|
+
#
|
|
636
|
+
# @return [PlanMyStuff::Approval] the updated approval
|
|
637
|
+
#
|
|
638
|
+
def reject!(user:)
|
|
639
|
+
actor_id = resolve_actor_id!(user)
|
|
640
|
+
|
|
641
|
+
just_rejected, was_fully_approved = modify_approvals! do |current|
|
|
642
|
+
approval = current.find { |a| a.user_id == actor_id }
|
|
643
|
+
raise(PlanMyStuff::ValidationError, "User #{actor_id} is not in the approvers list") if approval.nil?
|
|
644
|
+
raise(PlanMyStuff::ValidationError, "User #{actor_id} has already rejected") if approval.rejected?
|
|
645
|
+
|
|
646
|
+
approval.status = 'rejected'
|
|
647
|
+
approval.rejected_at = Time.current
|
|
648
|
+
approval.approved_at = nil
|
|
649
|
+
[current, approval]
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
finish_state_change(
|
|
653
|
+
:approval_rejected,
|
|
654
|
+
just_rejected,
|
|
655
|
+
user: user,
|
|
656
|
+
was_fully_approved: was_fully_approved,
|
|
657
|
+
trigger: :rejected,
|
|
658
|
+
)
|
|
659
|
+
just_rejected
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# Flips an approved or rejected record back to +pending+. Approvers may revoke their own response; support users
|
|
663
|
+
# may revoke any approver's response by passing +target_user_id:+. Non-support callers passing a
|
|
664
|
+
# +target_user_id:+ that is not their own raise +AuthorizationError+.
|
|
665
|
+
#
|
|
666
|
+
# Emits the granular event keyed off the source state: +plan_my_stuff.issue.approval_revoked+ from approved, or
|
|
667
|
+
# +plan_my_stuff.issue.rejection_revoked+ from rejected. When revoking an approval drops the issue out of
|
|
668
|
+
# +fully_approved?+, also fires +plan_my_stuff.issue.approvals_invalidated+ (+trigger: :revoked+). Revoking a
|
|
669
|
+
# rejection cannot change +fully_approved?+ (the issue was already gated), so no aggregate event fires.
|
|
496
670
|
#
|
|
497
671
|
# @raise [PlanMyStuff::AuthorizationError] when a non-support caller targets another user
|
|
498
|
-
# @raise [PlanMyStuff::ValidationError] when the target is not in the approvers list or is
|
|
672
|
+
# @raise [PlanMyStuff::ValidationError] when the target is not in the approvers list or is currently pending
|
|
673
|
+
#
|
|
674
|
+
# @param user [Object, Integer] the caller
|
|
675
|
+
# @param target_user_id [Integer, nil] approver whose response should be revoked; defaults to the caller
|
|
499
676
|
#
|
|
500
677
|
# @return [PlanMyStuff::Approval] the updated approval
|
|
501
678
|
#
|
|
@@ -505,36 +682,39 @@ module PlanMyStuff
|
|
|
505
682
|
target_id = target_user_id&.to_i || actor_id
|
|
506
683
|
|
|
507
684
|
if !caller_is_support && target_id != actor_id
|
|
508
|
-
raise(PlanMyStuff::AuthorizationError, "Only support users may revoke another user's
|
|
685
|
+
raise(PlanMyStuff::AuthorizationError, "Only support users may revoke another user's response")
|
|
509
686
|
end
|
|
510
687
|
|
|
511
|
-
|
|
688
|
+
revoked_from = nil
|
|
689
|
+
just_revoked, was_fully_approved = modify_approvals! do |current|
|
|
512
690
|
approval = current.find { |a| a.user_id == target_id }
|
|
513
691
|
raise(PlanMyStuff::ValidationError, "User #{target_id} is not in the approvers list") if approval.nil?
|
|
514
|
-
|
|
692
|
+
if approval.pending?
|
|
693
|
+
raise(PlanMyStuff::ValidationError, "User #{target_id} has not responded — nothing to revoke")
|
|
694
|
+
end
|
|
515
695
|
|
|
696
|
+
revoked_from = approval.status
|
|
516
697
|
approval.status = 'pending'
|
|
517
698
|
approval.approved_at = nil
|
|
699
|
+
approval.rejected_at = nil
|
|
518
700
|
[current, approval]
|
|
519
701
|
end
|
|
520
702
|
|
|
703
|
+
event = (revoked_from == 'approved') ? :approval_revoked : :rejection_revoked
|
|
521
704
|
finish_state_change(
|
|
522
|
-
|
|
705
|
+
event,
|
|
523
706
|
just_revoked,
|
|
524
707
|
user: user,
|
|
525
708
|
was_fully_approved: was_fully_approved,
|
|
526
|
-
trigger: :revoked,
|
|
709
|
+
trigger: (event == :approval_revoked) ? :revoked : nil,
|
|
527
710
|
)
|
|
528
711
|
just_revoked
|
|
529
712
|
end
|
|
530
713
|
|
|
531
|
-
# Marks the issue as waiting on an end-user reply. Sets
|
|
532
|
-
# +metadata.
|
|
533
|
-
# +
|
|
534
|
-
# +
|
|
535
|
-
# +Comment.create!+ when a support user posts a comment with
|
|
536
|
-
# +waiting_on_reply: true+, and from the +Issues::WaitingsController+
|
|
537
|
-
# toggle.
|
|
714
|
+
# Marks the issue as waiting on an end-user reply. Sets +metadata.waiting_on_user_at+ to now, (re)computes
|
|
715
|
+
# +metadata.next_reminder_at+, and adds the configured +waiting_on_user_label+ to the issue. Called from
|
|
716
|
+
# +Comment.create!+ when a support user posts a comment with +waiting_on_reply: true+, and from the
|
|
717
|
+
# +Issues::WaitingsController+ toggle.
|
|
538
718
|
#
|
|
539
719
|
# @param user [Object, nil] actor for the label notification event
|
|
540
720
|
#
|
|
@@ -545,24 +725,22 @@ module PlanMyStuff
|
|
|
545
725
|
label = PlanMyStuff.configuration.waiting_on_user_label
|
|
546
726
|
|
|
547
727
|
PlanMyStuff::Label.ensure!(repo: repo, name: label)
|
|
548
|
-
PlanMyStuff::Label.add(issue: self, labels: [label], user: user) if labels.exclude?(label)
|
|
728
|
+
PlanMyStuff::Label.add!(issue: self, labels: [label], user: user) if labels.exclude?(label)
|
|
549
729
|
|
|
550
730
|
self.class.update!(
|
|
551
731
|
number: number,
|
|
552
732
|
repo: repo,
|
|
553
733
|
metadata: {
|
|
554
|
-
waiting_on_user_at: now
|
|
734
|
+
waiting_on_user_at: PlanMyStuff.format_time(now),
|
|
555
735
|
next_reminder_at: format_next_reminder_at(from: now),
|
|
556
736
|
},
|
|
557
737
|
)
|
|
558
738
|
reload
|
|
559
739
|
end
|
|
560
740
|
|
|
561
|
-
# Clears the waiting-on-user state: removes the label, clears
|
|
562
|
-
# +metadata.
|
|
563
|
-
#
|
|
564
|
-
# is still active. No-ops if the issue is not currently waiting
|
|
565
|
-
# on a user reply.
|
|
741
|
+
# Clears the waiting-on-user state: removes the label, clears +metadata.waiting_on_user_at+, and clears
|
|
742
|
+
# +metadata.next_reminder_at+ unless a waiting-on-approval timer is still active. No-ops if the issue is not
|
|
743
|
+
# currently waiting on a user reply.
|
|
566
744
|
#
|
|
567
745
|
# @return [self]
|
|
568
746
|
#
|
|
@@ -570,25 +748,22 @@ module PlanMyStuff
|
|
|
570
748
|
label = PlanMyStuff.configuration.waiting_on_user_label
|
|
571
749
|
return self if metadata.waiting_on_user_at.nil? && labels.exclude?(label)
|
|
572
750
|
|
|
573
|
-
PlanMyStuff::Label.remove(issue: self, labels: [label]) if labels.include?(label)
|
|
751
|
+
PlanMyStuff::Label.remove!(issue: self, labels: [label]) if labels.include?(label)
|
|
574
752
|
|
|
575
753
|
self.class.update!(
|
|
576
754
|
number: number,
|
|
577
755
|
repo: repo,
|
|
578
756
|
metadata: {
|
|
579
757
|
waiting_on_user_at: nil,
|
|
580
|
-
next_reminder_at: metadata.waiting_on_approval_at ? format_time(metadata.next_reminder_at) : nil,
|
|
758
|
+
next_reminder_at: metadata.waiting_on_approval_at ? PlanMyStuff.format_time(metadata.next_reminder_at) : nil,
|
|
581
759
|
},
|
|
582
760
|
)
|
|
583
761
|
reload
|
|
584
762
|
end
|
|
585
763
|
|
|
586
|
-
# Reopens an issue that was auto-closed by the inactivity sweep,
|
|
587
|
-
#
|
|
588
|
-
# +
|
|
589
|
-
# comment. Does not emit the regular +issue.reopened+ event \-
|
|
590
|
-
# subscribers that specifically care about this flow subscribe
|
|
591
|
-
# to the dedicated event.
|
|
764
|
+
# Reopens an issue that was auto-closed by the inactivity sweep, clears +metadata.closed_by_inactivity+, and
|
|
765
|
+
# emits +plan_my_stuff.issue.reopened_by_reply+ carrying the reopening comment. Does not emit the regular
|
|
766
|
+
# +issue.reopened+ event \- subscribers that specifically care about this flow subscribe to the dedicated event.
|
|
592
767
|
#
|
|
593
768
|
# @param comment [PlanMyStuff::Comment] the reopening comment
|
|
594
769
|
# @param user [Object, nil] actor for the notification event
|
|
@@ -597,7 +772,7 @@ module PlanMyStuff
|
|
|
597
772
|
#
|
|
598
773
|
def reopen_by_reply!(comment:, user: nil)
|
|
599
774
|
inactive_label = PlanMyStuff.configuration.user_inactive_label
|
|
600
|
-
PlanMyStuff::Label.remove(issue: self, labels: [inactive_label]) if labels.include?(inactive_label)
|
|
775
|
+
PlanMyStuff::Label.remove!(issue: self, labels: [inactive_label]) if labels.include?(inactive_label)
|
|
601
776
|
|
|
602
777
|
self.class.update!(
|
|
603
778
|
number: number,
|
|
@@ -616,14 +791,12 @@ module PlanMyStuff
|
|
|
616
791
|
self
|
|
617
792
|
end
|
|
618
793
|
|
|
619
|
-
# Tags the issue with the configured +archived_label+, removes it
|
|
620
|
-
#
|
|
621
|
-
#
|
|
622
|
-
# Emits +plan_my_stuff.issue.archived+ on success.
|
|
794
|
+
# Tags the issue with the configured +archived_label+, removes it from every Projects V2 board it belongs to,
|
|
795
|
+
# locks its conversation on GitHub, and stamps +metadata.archived_at+. Emits +plan_my_stuff.issue.archived+ on
|
|
796
|
+
# success.
|
|
623
797
|
#
|
|
624
|
-
# No-op (no network calls, no event) when the issue is already
|
|
625
|
-
# archived
|
|
626
|
-
# label is already on the issue).
|
|
798
|
+
# No-op (no network calls, no event) when the issue is already archived (either +metadata.archived_at+ is set or
|
|
799
|
+
# the archived label is already on the issue).
|
|
627
800
|
#
|
|
628
801
|
# @param now [Time] clock reference for +metadata.archived_at+
|
|
629
802
|
#
|
|
@@ -639,11 +812,11 @@ module PlanMyStuff
|
|
|
639
812
|
self.class.update!(
|
|
640
813
|
number: number,
|
|
641
814
|
repo: repo,
|
|
642
|
-
metadata: { archived_at: now
|
|
815
|
+
metadata: { archived_at: PlanMyStuff.format_time(now) },
|
|
643
816
|
)
|
|
644
817
|
|
|
645
818
|
PlanMyStuff::Label.ensure!(repo: repo, name: label)
|
|
646
|
-
PlanMyStuff::Label.add(issue: self, labels: [label])
|
|
819
|
+
PlanMyStuff::Label.add!(issue: self, labels: [label])
|
|
647
820
|
|
|
648
821
|
remove_from_all_projects!
|
|
649
822
|
|
|
@@ -659,13 +832,9 @@ module PlanMyStuff
|
|
|
659
832
|
self
|
|
660
833
|
end
|
|
661
834
|
|
|
662
|
-
# Persists the issue. Creates if new, otherwise performs a full
|
|
663
|
-
#
|
|
664
|
-
#
|
|
665
|
-
# the last load, also rewrites the PMS body comment. Always
|
|
666
|
-
# reloads afterwards.
|
|
667
|
-
#
|
|
668
|
-
# @raise [PlanMyStuff::StaleObjectError] on update if stale
|
|
835
|
+
# Persists the issue. Creates if new, otherwise performs a full write: serializes +@metadata+ into the GitHub
|
|
836
|
+
# issue body and PATCHes title/state/labels. When +#body=+ has been called since the last load, also rewrites
|
|
837
|
+
# the PMS body comment. Always reloads afterwards.
|
|
669
838
|
#
|
|
670
839
|
# @return [self]
|
|
671
840
|
#
|
|
@@ -680,6 +849,7 @@ module PlanMyStuff
|
|
|
680
849
|
metadata: metadata.custom_fields.to_h,
|
|
681
850
|
visibility: metadata.visibility,
|
|
682
851
|
visibility_allowlist: Array.wrap(metadata.visibility_allowlist),
|
|
852
|
+
issue_type: issue_type,
|
|
683
853
|
)
|
|
684
854
|
hydrate_from_issue(created)
|
|
685
855
|
else
|
|
@@ -691,20 +861,16 @@ module PlanMyStuff
|
|
|
691
861
|
self
|
|
692
862
|
end
|
|
693
863
|
|
|
694
|
-
# Applies +attrs+ to this instance in-memory then calls +save!+.
|
|
695
|
-
#
|
|
696
|
-
#
|
|
697
|
-
# merged into the existing +metadata+ (top-level attributes
|
|
698
|
-
# assigned directly; +:custom_fields+ merged key-by-key).
|
|
864
|
+
# Applies +attrs+ to this instance in-memory then calls +save!+. Supports +title:+, +body:+, +state:+,
|
|
865
|
+
# +labels:+, +assignees:+, and +metadata:+. The +metadata:+ kwarg is a hash whose keys are merged into the
|
|
866
|
+
# existing +metadata+ (top-level attributes assigned directly; +:custom_fields+ merged key-by-key).
|
|
699
867
|
#
|
|
700
868
|
# @param user [Object, nil] actor for notification events
|
|
701
869
|
#
|
|
702
|
-
# @raise [PlanMyStuff::StaleObjectError] if remote updated_at differs from local
|
|
703
|
-
#
|
|
704
870
|
# @return [self]
|
|
705
871
|
#
|
|
706
872
|
def update!(user: nil, skip_notification: false, **attrs)
|
|
707
|
-
apply_update_attrs
|
|
873
|
+
apply_update_attrs(attrs)
|
|
708
874
|
save!(user: user, skip_notification: skip_notification)
|
|
709
875
|
end
|
|
710
876
|
|
|
@@ -760,9 +926,8 @@ module PlanMyStuff
|
|
|
760
926
|
pms_comments.find { |c| c.metadata.issue_body? }
|
|
761
927
|
end
|
|
762
928
|
|
|
763
|
-
# Returns the issue body content. For PMS issues, this is the body
|
|
764
|
-
#
|
|
765
|
-
# parsed issue body for non-PMS issues.
|
|
929
|
+
# Returns the issue body content. For PMS issues, this is the body from the body comment (stripped of its
|
|
930
|
+
# header). Falls back to the parsed issue body for non-PMS issues.
|
|
766
931
|
#
|
|
767
932
|
# @return [String, nil]
|
|
768
933
|
#
|
|
@@ -788,13 +953,12 @@ module PlanMyStuff
|
|
|
788
953
|
if pms_issue?
|
|
789
954
|
metadata.visible_to?(user)
|
|
790
955
|
else
|
|
791
|
-
UserResolver.support?(UserResolver.resolve(user))
|
|
956
|
+
PlanMyStuff::UserResolver.support?(PlanMyStuff::UserResolver.resolve(user))
|
|
792
957
|
end
|
|
793
958
|
end
|
|
794
959
|
|
|
795
|
-
# Lazy-memoized array of +Issue+ objects for +:related+ links.
|
|
796
|
-
#
|
|
797
|
-
# break the rest of the list.
|
|
960
|
+
# Lazy-memoized array of +Issue+ objects for +:related+ links. Silently drops targets that 404 so a dangling
|
|
961
|
+
# pointer doesn't break the rest of the list.
|
|
798
962
|
#
|
|
799
963
|
# @return [Array<PlanMyStuff::Issue>]
|
|
800
964
|
#
|
|
@@ -802,10 +966,8 @@ module PlanMyStuff
|
|
|
802
966
|
links_cache[:related] ||= fetch_related
|
|
803
967
|
end
|
|
804
968
|
|
|
805
|
-
# Adds a +:related+ link to +target+ and, unless this call is
|
|
806
|
-
#
|
|
807
|
-
# the pairing is symmetric. Dedups on
|
|
808
|
-
# +(type, issue_number, repo)+ - re-adding is a no-op.
|
|
969
|
+
# Adds a +:related+ link to +target+ and, unless this call is already a reciprocal, mirrors the link back on
|
|
970
|
+
# +target+ so the pairing is symmetric. Dedups on +(type, issue_number, repo)+ - re-adding is a no-op.
|
|
809
971
|
#
|
|
810
972
|
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
811
973
|
# @param user [Object, nil] actor for notification events
|
|
@@ -814,7 +976,7 @@ module PlanMyStuff
|
|
|
814
976
|
# @return [PlanMyStuff::Link]
|
|
815
977
|
#
|
|
816
978
|
def add_related!(target, user: nil, reciprocal: false)
|
|
817
|
-
link = build_link(target, type: :related)
|
|
979
|
+
link = build_link!(target, type: :related)
|
|
818
980
|
validate_not_self!(link)
|
|
819
981
|
|
|
820
982
|
existing = current_links
|
|
@@ -828,9 +990,8 @@ module PlanMyStuff
|
|
|
828
990
|
link
|
|
829
991
|
end
|
|
830
992
|
|
|
831
|
-
# Removes a +:related+ link to +target+ and, unless this call is
|
|
832
|
-
#
|
|
833
|
-
# when the link isn't present locally.
|
|
993
|
+
# Removes a +:related+ link to +target+ and, unless this call is already a reciprocal, mirrors the removal on
|
|
994
|
+
# +target+. No-op when the link isn't present locally.
|
|
834
995
|
#
|
|
835
996
|
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
836
997
|
# @param user [Object, nil]
|
|
@@ -839,7 +1000,7 @@ module PlanMyStuff
|
|
|
839
1000
|
# @return [PlanMyStuff::Link]
|
|
840
1001
|
#
|
|
841
1002
|
def remove_related!(target, user: nil, reciprocal: false)
|
|
842
|
-
link = build_link(target, type: :related)
|
|
1003
|
+
link = build_link!(target, type: :related)
|
|
843
1004
|
validate_not_self!(link)
|
|
844
1005
|
|
|
845
1006
|
existing = current_links
|
|
@@ -853,8 +1014,7 @@ module PlanMyStuff
|
|
|
853
1014
|
link
|
|
854
1015
|
end
|
|
855
1016
|
|
|
856
|
-
# Lazy-memoized parent issue via GitHub's native sub-issues API.
|
|
857
|
-
# GitHub enforces at most one parent per issue.
|
|
1017
|
+
# Lazy-memoized parent issue via GitHub's native sub-issues API. GitHub enforces at most one parent per issue.
|
|
858
1018
|
#
|
|
859
1019
|
# @return [PlanMyStuff::Issue, nil]
|
|
860
1020
|
#
|
|
@@ -872,8 +1032,7 @@ module PlanMyStuff
|
|
|
872
1032
|
links_cache[:sub_tickets] ||= fetch_sub_tickets
|
|
873
1033
|
end
|
|
874
1034
|
|
|
875
|
-
# Adds +target+ as a sub-issue of self via
|
|
876
|
-
# +POST /issues/{number}/sub_issues+. Native GitHub action;
|
|
1035
|
+
# Adds +target+ as a sub-issue of self via +POST /issues/{number}/sub_issues+. Native GitHub action;
|
|
877
1036
|
# notifications are handled by GitHub itself.
|
|
878
1037
|
#
|
|
879
1038
|
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
@@ -884,8 +1043,7 @@ module PlanMyStuff
|
|
|
884
1043
|
mutate_sub_issue!(target, method: :post, path: sub_issues_path)
|
|
885
1044
|
end
|
|
886
1045
|
|
|
887
|
-
# Removes +target+ as a sub-issue of self via
|
|
888
|
-
# +DELETE /issues/{number}/sub_issue+ (singular).
|
|
1046
|
+
# Removes +target+ as a sub-issue of self via +DELETE /issues/{number}/sub_issue+ (singular).
|
|
889
1047
|
#
|
|
890
1048
|
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
891
1049
|
#
|
|
@@ -895,9 +1053,8 @@ module PlanMyStuff
|
|
|
895
1053
|
mutate_sub_issue!(target, method: :delete, path: remove_sub_issue_path)
|
|
896
1054
|
end
|
|
897
1055
|
|
|
898
|
-
# Makes +target+ the parent of self. If self already has a parent,
|
|
899
|
-
#
|
|
900
|
-
# +:parent+ relationship.
|
|
1056
|
+
# Makes +target+ the parent of self. If self already has a parent, it is detached first. Returns a +Link+
|
|
1057
|
+
# describing the new +:parent+ relationship.
|
|
901
1058
|
#
|
|
902
1059
|
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
903
1060
|
#
|
|
@@ -910,11 +1067,11 @@ module PlanMyStuff
|
|
|
910
1067
|
target_issue.add_sub_issue!(self)
|
|
911
1068
|
invalidate_links_cache!
|
|
912
1069
|
|
|
913
|
-
build_link(target_issue, type: :parent)
|
|
1070
|
+
build_link!(target_issue, type: :parent)
|
|
914
1071
|
end
|
|
915
1072
|
|
|
916
|
-
# Detaches self from its current parent, if any. Returns the
|
|
917
|
-
#
|
|
1073
|
+
# Detaches self from its current parent, if any. Returns the +Link+ that was removed, or nil when there was no
|
|
1074
|
+
# parent.
|
|
918
1075
|
#
|
|
919
1076
|
# @return [PlanMyStuff::Link, nil]
|
|
920
1077
|
#
|
|
@@ -925,12 +1082,11 @@ module PlanMyStuff
|
|
|
925
1082
|
current.remove_sub_issue!(self)
|
|
926
1083
|
invalidate_links_cache!
|
|
927
1084
|
|
|
928
|
-
build_link(current, type: :parent)
|
|
1085
|
+
build_link!(current, type: :parent)
|
|
929
1086
|
end
|
|
930
1087
|
|
|
931
|
-
# Lazy-memoized issues that block self (i.e. self is blocked by
|
|
932
|
-
#
|
|
933
|
-
# API.
|
|
1088
|
+
# Lazy-memoized issues that block self (i.e. self is blocked by each returned issue) via GitHub's native
|
|
1089
|
+
# issue-dependency REST API.
|
|
934
1090
|
#
|
|
935
1091
|
# @return [Array<PlanMyStuff::Issue>]
|
|
936
1092
|
#
|
|
@@ -946,15 +1102,14 @@ module PlanMyStuff
|
|
|
946
1102
|
links_cache[:blocking] ||= fetch_dependencies('blocking')
|
|
947
1103
|
end
|
|
948
1104
|
|
|
949
|
-
# Records that +target+ blocks self. Native GitHub action;
|
|
950
|
-
# notifications are handled by GitHub itself.
|
|
1105
|
+
# Records that +target+ blocks self. Native GitHub action; notifications are handled by GitHub itself.
|
|
951
1106
|
#
|
|
952
1107
|
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
953
1108
|
#
|
|
954
1109
|
# @return [PlanMyStuff::Link]
|
|
955
1110
|
#
|
|
956
1111
|
def add_blocker!(target)
|
|
957
|
-
link = build_link(target, type: :blocked_by)
|
|
1112
|
+
link = build_link!(target, type: :blocked_by)
|
|
958
1113
|
validate_not_self!(link)
|
|
959
1114
|
|
|
960
1115
|
target_issue = resolve_target_issue(target, type: :blocked_by)
|
|
@@ -974,7 +1129,7 @@ module PlanMyStuff
|
|
|
974
1129
|
# @return [PlanMyStuff::Link]
|
|
975
1130
|
#
|
|
976
1131
|
def remove_blocker!(target)
|
|
977
|
-
link = build_link(target, type: :blocked_by)
|
|
1132
|
+
link = build_link!(target, type: :blocked_by)
|
|
978
1133
|
validate_not_self!(link)
|
|
979
1134
|
|
|
980
1135
|
target_issue = resolve_target_issue(target, type: :blocked_by)
|
|
@@ -986,9 +1141,8 @@ module PlanMyStuff
|
|
|
986
1141
|
link
|
|
987
1142
|
end
|
|
988
1143
|
|
|
989
|
-
# Lazy-memoized issue that self was marked as duplicate of, via
|
|
990
|
-
#
|
|
991
|
-
# are open or closed for other reasons.
|
|
1144
|
+
# Lazy-memoized issue that self was marked as duplicate of, via GitHub's native close-as-duplicate. Returns nil
|
|
1145
|
+
# for issues that are open or closed for other reasons.
|
|
992
1146
|
#
|
|
993
1147
|
# @return [PlanMyStuff::Issue, nil]
|
|
994
1148
|
#
|
|
@@ -998,9 +1152,8 @@ module PlanMyStuff
|
|
|
998
1152
|
links_cache[:duplicate_of] = fetch_duplicate_of
|
|
999
1153
|
end
|
|
1000
1154
|
|
|
1001
|
-
# Closes self as a duplicate of +target+ via GitHub's native
|
|
1002
|
-
#
|
|
1003
|
-
# back-pointer comment on the target.
|
|
1155
|
+
# Closes self as a duplicate of +target+ via GitHub's native close-as-duplicate, carrying over viewers,
|
|
1156
|
+
# assignees, and a back-pointer comment on the target.
|
|
1004
1157
|
#
|
|
1005
1158
|
# Side effects, in order:
|
|
1006
1159
|
# 1. Resolves +target+; raises +ValidationError+ if missing.
|
|
@@ -1013,8 +1166,9 @@ module PlanMyStuff
|
|
|
1013
1166
|
# 7. Reloads self; invalidates link caches.
|
|
1014
1167
|
# 8. Fires +plan_my_stuff.issue.marked_duplicate+.
|
|
1015
1168
|
#
|
|
1016
|
-
# Partial failures are not rolled back - GitHub retains whatever
|
|
1017
|
-
#
|
|
1169
|
+
# Partial failures are not rolled back - GitHub retains whatever side effects succeeded before the failing step.
|
|
1170
|
+
#
|
|
1171
|
+
# @raise [PlanMyStuff::ValidationError] when the issue is already closed
|
|
1018
1172
|
#
|
|
1019
1173
|
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
1020
1174
|
# @param user [Object, nil] actor for notification + comment
|
|
@@ -1025,20 +1179,19 @@ module PlanMyStuff
|
|
|
1025
1179
|
raise(PlanMyStuff::ValidationError, 'Cannot mark a closed issue as duplicate') if state == 'closed'
|
|
1026
1180
|
|
|
1027
1181
|
target_issue = resolve_duplicate_target!(target)
|
|
1028
|
-
merge_visibility_allowlist_onto(target_issue)
|
|
1029
|
-
merge_assignees_onto(target_issue)
|
|
1030
|
-
post_duplicate_back_pointer(target_issue, user: user)
|
|
1182
|
+
merge_visibility_allowlist_onto!(target_issue)
|
|
1183
|
+
merge_assignees_onto!(target_issue)
|
|
1184
|
+
post_duplicate_back_pointer!(target_issue, user: user)
|
|
1031
1185
|
close_as_duplicate!(target_issue)
|
|
1032
1186
|
|
|
1033
1187
|
reload
|
|
1034
1188
|
invalidate_links_cache!
|
|
1035
1189
|
PlanMyStuff::Notifications.instrument('issue.marked_duplicate', self, target: target_issue, user: user)
|
|
1036
1190
|
|
|
1037
|
-
build_link(target_issue, type: :duplicate_of)
|
|
1191
|
+
build_link!(target_issue, type: :duplicate_of)
|
|
1038
1192
|
end
|
|
1039
1193
|
|
|
1040
|
-
# GitHub GraphQL node ID (required for native sub-issue mutations).
|
|
1041
|
-
# Read from the hydrated REST response.
|
|
1194
|
+
# GitHub GraphQL node ID (required for native sub-issue mutations). Read from the hydrated REST response.
|
|
1042
1195
|
#
|
|
1043
1196
|
# @return [String, nil]
|
|
1044
1197
|
#
|
|
@@ -1046,8 +1199,8 @@ module PlanMyStuff
|
|
|
1046
1199
|
safe_read_field(github_response, :node_id)
|
|
1047
1200
|
end
|
|
1048
1201
|
|
|
1049
|
-
# GitHub database ID (required for the REST issue-dependency API,
|
|
1050
|
-
#
|
|
1202
|
+
# GitHub database ID (required for the REST issue-dependency API, which takes integer issue_id rather than issue
|
|
1203
|
+
# number).
|
|
1051
1204
|
#
|
|
1052
1205
|
# @return [Integer, nil]
|
|
1053
1206
|
#
|
|
@@ -1057,16 +1210,15 @@ module PlanMyStuff
|
|
|
1057
1210
|
|
|
1058
1211
|
private
|
|
1059
1212
|
|
|
1060
|
-
# Yields +self.metadata.visibility_allowlist+ for modification,
|
|
1061
|
-
#
|
|
1062
|
-
# and reloads +self+ so subsequent reads see the fresh state.
|
|
1213
|
+
# Yields +self.metadata.visibility_allowlist+ for modification, persists the updated allowlist via the
|
|
1214
|
+
# class-level +update!+, and reloads +self+ so subsequent reads see the fresh state.
|
|
1063
1215
|
#
|
|
1064
1216
|
# @yieldparam allowlist [Array<Integer>]
|
|
1065
1217
|
# @yieldreturn [Array<Integer>] the new allowlist
|
|
1066
1218
|
#
|
|
1067
1219
|
# @return [void]
|
|
1068
1220
|
#
|
|
1069
|
-
def modify_allowlist
|
|
1221
|
+
def modify_allowlist!
|
|
1070
1222
|
new_allowlist = yield(Array.wrap(metadata.visibility_allowlist))
|
|
1071
1223
|
self.class.update!(
|
|
1072
1224
|
number: number,
|
|
@@ -1076,16 +1228,15 @@ module PlanMyStuff
|
|
|
1076
1228
|
reload
|
|
1077
1229
|
end
|
|
1078
1230
|
|
|
1079
|
-
# Captures +fully_approved?+ state, yields the current approvals
|
|
1080
|
-
#
|
|
1081
|
-
# reloads +self+. Returns +[extra, was_fully_approved]+.
|
|
1231
|
+
# Captures +fully_approved?+ state, yields the current approvals (deep-copied) for mutation, persists the new
|
|
1232
|
+
# list to GitHub, and reloads +self+. Returns +[extra, was_fully_approved]+.
|
|
1082
1233
|
#
|
|
1083
1234
|
# @yieldparam current [Array<PlanMyStuff::Approval>] deep-copied approvals
|
|
1084
1235
|
# @yieldreturn [Array(Array<PlanMyStuff::Approval>, Object)] +[new_list, extra]+
|
|
1085
1236
|
#
|
|
1086
1237
|
# @return [Array(Object, Boolean)]
|
|
1087
1238
|
#
|
|
1088
|
-
def modify_approvals
|
|
1239
|
+
def modify_approvals!
|
|
1089
1240
|
was_fully_approved = fully_approved?
|
|
1090
1241
|
was_pending_count = metadata.approvals.count(&:pending?)
|
|
1091
1242
|
current = metadata.approvals.map { |a| PlanMyStuff::Approval.new(a.attributes) }
|
|
@@ -1099,16 +1250,14 @@ module PlanMyStuff
|
|
|
1099
1250
|
self.class.update!(number: number, repo: repo, metadata: metadata_updates)
|
|
1100
1251
|
reload
|
|
1101
1252
|
|
|
1102
|
-
sync_waiting_on_approval_label(was_pending_count, new_pending_count)
|
|
1253
|
+
sync_waiting_on_approval_label!(was_pending_count, new_pending_count)
|
|
1103
1254
|
|
|
1104
1255
|
[extra, was_fully_approved]
|
|
1105
1256
|
end
|
|
1106
1257
|
|
|
1107
|
-
# Computes the metadata delta for the waiting-on-approval timer
|
|
1108
|
-
#
|
|
1109
|
-
#
|
|
1110
|
-
# so that remaining pending approvers keep their original schedule
|
|
1111
|
-
# when a peer approves. Drop-to-zero clears the timer entirely.
|
|
1258
|
+
# Computes the metadata delta for the waiting-on-approval timer based on the change in pending-approval count.
|
|
1259
|
+
# The timer resets only when pending count goes UP (add approver, revoke-to-pending) so that remaining pending
|
|
1260
|
+
# approvers keep their original schedule when a peer approves. Drop-to-zero clears the timer entirely.
|
|
1112
1261
|
#
|
|
1113
1262
|
# @param was [Integer] pending count before the mutation
|
|
1114
1263
|
# @param now [Integer] pending count after the mutation
|
|
@@ -1119,41 +1268,41 @@ module PlanMyStuff
|
|
|
1119
1268
|
if now > was
|
|
1120
1269
|
ts = Time.now.utc
|
|
1121
1270
|
{
|
|
1122
|
-
waiting_on_approval_at: ts
|
|
1271
|
+
waiting_on_approval_at: PlanMyStuff.format_time(ts),
|
|
1123
1272
|
next_reminder_at: format_next_reminder_at(from: ts),
|
|
1124
1273
|
}
|
|
1125
1274
|
elsif now.zero? && was.positive?
|
|
1126
1275
|
{
|
|
1127
1276
|
waiting_on_approval_at: nil,
|
|
1128
|
-
next_reminder_at: metadata.waiting_on_user_at ? format_time(metadata.next_reminder_at) : nil,
|
|
1277
|
+
next_reminder_at: metadata.waiting_on_user_at ? PlanMyStuff.format_time(metadata.next_reminder_at) : nil,
|
|
1129
1278
|
}
|
|
1130
1279
|
else
|
|
1131
1280
|
{}
|
|
1132
1281
|
end
|
|
1133
1282
|
end
|
|
1134
1283
|
|
|
1135
|
-
# Adds or removes the configured waiting-on-approval label when the
|
|
1136
|
-
#
|
|
1137
|
-
# stay on the same side of zero leave the label untouched.
|
|
1284
|
+
# Adds or removes the configured waiting-on-approval label when the pending-approval count crosses the zero
|
|
1285
|
+
# boundary. Mutations that stay on the same side of zero leave the label untouched.
|
|
1138
1286
|
#
|
|
1139
1287
|
# @param was [Integer] pending count before the mutation
|
|
1140
1288
|
# @param now [Integer] pending count after the mutation
|
|
1141
1289
|
#
|
|
1142
1290
|
# @return [void]
|
|
1143
1291
|
#
|
|
1144
|
-
def sync_waiting_on_approval_label(was, now)
|
|
1292
|
+
def sync_waiting_on_approval_label!(was, now)
|
|
1145
1293
|
label = PlanMyStuff.configuration.waiting_on_approval_label
|
|
1146
1294
|
|
|
1147
1295
|
if now.positive? && was.zero?
|
|
1148
1296
|
PlanMyStuff::Label.ensure!(repo: repo, name: label)
|
|
1149
|
-
PlanMyStuff::Label.add(issue: self, labels: [label]) if labels.exclude?(label)
|
|
1297
|
+
PlanMyStuff::Label.add!(issue: self, labels: [label]) if labels.exclude?(label)
|
|
1150
1298
|
elsif now.zero? && was.positive?
|
|
1151
|
-
PlanMyStuff::Label.remove(issue: self, labels: [label]) if labels.include?(label)
|
|
1299
|
+
PlanMyStuff::Label.remove!(issue: self, labels: [label]) if labels.include?(label)
|
|
1152
1300
|
end
|
|
1153
1301
|
end
|
|
1154
1302
|
|
|
1155
|
-
#
|
|
1156
|
-
#
|
|
1303
|
+
# Ensures +user+ resolves to a support user. +nil+ user is treated as unauthorized.
|
|
1304
|
+
#
|
|
1305
|
+
# @raise [PlanMyStuff::AuthorizationError] when the actor is not a support user
|
|
1157
1306
|
#
|
|
1158
1307
|
# @param user [Object, Integer, nil]
|
|
1159
1308
|
#
|
|
@@ -1166,8 +1315,9 @@ module PlanMyStuff
|
|
|
1166
1315
|
raise(PlanMyStuff::AuthorizationError, 'Only support users may manage approvers')
|
|
1167
1316
|
end
|
|
1168
1317
|
|
|
1169
|
-
# Resolves +user+ to an integer user_id.
|
|
1170
|
-
#
|
|
1318
|
+
# Resolves +user+ to an integer user_id.
|
|
1319
|
+
#
|
|
1320
|
+
# @raise [ArgumentError] when user is nil
|
|
1171
1321
|
#
|
|
1172
1322
|
# @param user [Object, Integer]
|
|
1173
1323
|
#
|
|
@@ -1180,9 +1330,8 @@ module PlanMyStuff
|
|
|
1180
1330
|
PlanMyStuff::UserResolver.user_id(resolved)
|
|
1181
1331
|
end
|
|
1182
1332
|
|
|
1183
|
-
# Fires +approval_requested+ (when any users were newly added) and,
|
|
1184
|
-
#
|
|
1185
|
-
# +approvals_invalidated+ follow-up.
|
|
1333
|
+
# Fires +approval_requested+ (when any users were newly added) and, if the aggregate state flipped out of
|
|
1334
|
+
# fully-approved, the +approvals_invalidated+ follow-up.
|
|
1186
1335
|
#
|
|
1187
1336
|
# @param added [Array<PlanMyStuff::Approval>]
|
|
1188
1337
|
# @param user [Object, nil]
|
|
@@ -1202,8 +1351,8 @@ module PlanMyStuff
|
|
|
1202
1351
|
emit_aggregate_events(was_fully_approved: was_fully_approved, trigger: :approver_added, user: user)
|
|
1203
1352
|
end
|
|
1204
1353
|
|
|
1205
|
-
# Fires the granular event (+approval_granted+ / +approval_revoked+)
|
|
1206
|
-
#
|
|
1354
|
+
# Fires the granular event (+approval_granted+ / +approval_revoked+) then any aggregate follow-up triggered
|
|
1355
|
+
# by the state flip.
|
|
1207
1356
|
#
|
|
1208
1357
|
# @param event [Symbol] +:approval_granted+ or +:approval_revoked+
|
|
1209
1358
|
# @param approval [PlanMyStuff::Approval]
|
|
@@ -1223,10 +1372,9 @@ module PlanMyStuff
|
|
|
1223
1372
|
emit_aggregate_events(was_fully_approved: was_fully_approved, trigger: trigger, user: user)
|
|
1224
1373
|
end
|
|
1225
1374
|
|
|
1226
|
-
# Fires +all_approved+ or +approvals_invalidated+ based on whether
|
|
1227
|
-
# +
|
|
1228
|
-
#
|
|
1229
|
-
# the list to empty is not an invalidation).
|
|
1375
|
+
# Fires +all_approved+ or +approvals_invalidated+ based on whether +fully_approved?+ flipped. Suppresses
|
|
1376
|
+
# +approvals_invalidated+ when the issue no longer has any approvers required (dropping the list to empty is
|
|
1377
|
+
# not an invalidation).
|
|
1230
1378
|
#
|
|
1231
1379
|
# @param was_fully_approved [Boolean]
|
|
1232
1380
|
# @param trigger [Symbol, nil]
|
|
@@ -1263,13 +1411,15 @@ module PlanMyStuff
|
|
|
1263
1411
|
self.state = read_field(github_issue, :state)
|
|
1264
1412
|
self.raw_body = read_field(github_issue, :body) || ''
|
|
1265
1413
|
self.updated_at = parse_github_time(safe_read_field(github_issue, :updated_at))
|
|
1414
|
+
self.created_at = parse_github_time(safe_read_field(github_issue, :created_at))
|
|
1266
1415
|
self.closed_at = parse_github_time(safe_read_field(github_issue, :closed_at))
|
|
1267
1416
|
self.locked = safe_read_field(github_issue, :locked) || false
|
|
1268
1417
|
self.labels = extract_labels(github_issue)
|
|
1418
|
+
self.issue_type = extract_issue_type(github_issue)
|
|
1269
1419
|
self.repo = repo
|
|
1270
1420
|
|
|
1271
|
-
parsed = MetadataParser.parse(raw_body)
|
|
1272
|
-
self.metadata = IssueMetadata.from_hash(parsed[:metadata])
|
|
1421
|
+
parsed = PlanMyStuff::MetadataParser.parse(raw_body)
|
|
1422
|
+
self.metadata = PlanMyStuff::IssueMetadata.from_hash(parsed[:metadata])
|
|
1273
1423
|
self.body = parsed[:body]
|
|
1274
1424
|
@body_dirty = false
|
|
1275
1425
|
persisted!
|
|
@@ -1291,10 +1441,12 @@ module PlanMyStuff
|
|
|
1291
1441
|
self.body = other.attributes['body']
|
|
1292
1442
|
@body_dirty = false
|
|
1293
1443
|
self.raw_body = other.raw_body
|
|
1444
|
+
self.created_at = other.created_at
|
|
1294
1445
|
self.updated_at = other.updated_at
|
|
1295
1446
|
self.closed_at = other.closed_at
|
|
1296
1447
|
self.locked = other.locked
|
|
1297
1448
|
self.labels = other.labels
|
|
1449
|
+
self.issue_type = other.issue_type
|
|
1298
1450
|
self.repo = other.repo
|
|
1299
1451
|
self.metadata = other.metadata
|
|
1300
1452
|
persisted!
|
|
@@ -1302,10 +1454,8 @@ module PlanMyStuff
|
|
|
1302
1454
|
invalidate_links_cache!
|
|
1303
1455
|
end
|
|
1304
1456
|
|
|
1305
|
-
# Formats the next reminder time as an ISO 8601 UTC string, using
|
|
1306
|
-
#
|
|
1307
|
-
# +config.reminder_days+ otherwise. Returns +nil+ when the
|
|
1308
|
-
# effective schedule is empty.
|
|
1457
|
+
# Formats the next reminder time as an ISO 8601 UTC string, using per-issue +metadata.reminder_days+ when set
|
|
1458
|
+
# or +config.reminder_days+ otherwise. Returns +nil+ when the effective schedule is empty.
|
|
1309
1459
|
#
|
|
1310
1460
|
# @param from [Time] baseline timestamp
|
|
1311
1461
|
#
|
|
@@ -1315,25 +1465,11 @@ module PlanMyStuff
|
|
|
1315
1465
|
days = metadata.reminder_days.presence || PlanMyStuff.configuration.reminder_days
|
|
1316
1466
|
return if days.empty?
|
|
1317
1467
|
|
|
1318
|
-
(from + days.first.days)
|
|
1319
|
-
end
|
|
1320
|
-
|
|
1321
|
-
# Formats a +Time+ as an ISO 8601 UTC string, or +nil+ when the
|
|
1322
|
-
# input is nil.
|
|
1323
|
-
#
|
|
1324
|
-
# @param time [Time, nil]
|
|
1325
|
-
#
|
|
1326
|
-
# @return [String, nil]
|
|
1327
|
-
#
|
|
1328
|
-
def format_time(time)
|
|
1329
|
-
return if time.nil?
|
|
1330
|
-
|
|
1331
|
-
time.utc.iso8601
|
|
1468
|
+
PlanMyStuff.format_time(from + days.first.days)
|
|
1332
1469
|
end
|
|
1333
1470
|
|
|
1334
|
-
# Fires the appropriate notification event for an update: +issue.closed+
|
|
1335
|
-
#
|
|
1336
|
-
# with the captured dirty-tracking diff.
|
|
1471
|
+
# Fires the appropriate notification event for an update: +issue.closed+ or +issue.reopened+ on a state
|
|
1472
|
+
# transition, otherwise +issue.updated+ with the captured dirty-tracking diff.
|
|
1337
1473
|
#
|
|
1338
1474
|
# @param captured [Hash] +ActiveModel::Dirty+ changes captured before +persist_update!+
|
|
1339
1475
|
# @param user [Object, nil]
|
|
@@ -1351,17 +1487,15 @@ module PlanMyStuff
|
|
|
1351
1487
|
end
|
|
1352
1488
|
end
|
|
1353
1489
|
|
|
1354
|
-
# When an issue is transitioning from open to closed, strips both
|
|
1355
|
-
#
|
|
1356
|
-
#
|
|
1357
|
-
# both state change and cleanup. No-op for any other transition.
|
|
1490
|
+
# When an issue is transitioning from open to closed, strips both waiting labels from the outgoing labels
|
|
1491
|
+
# array and clears the waiting-related timestamps on +metadata+ so a single save writes both state change and
|
|
1492
|
+
# cleanup. No-op for any other transition.
|
|
1358
1493
|
#
|
|
1359
|
-
# @param attrs [Hash] the kwargs hash being assembled for
|
|
1360
|
-
# +Issue.update!+; mutated in place
|
|
1494
|
+
# @param attrs [Hash] the kwargs hash being assembled for +Issue.update!+; mutated in place
|
|
1361
1495
|
#
|
|
1362
1496
|
# @return [void]
|
|
1363
1497
|
#
|
|
1364
|
-
def clear_waiting_state_on_close
|
|
1498
|
+
def clear_waiting_state_on_close(attrs)
|
|
1365
1499
|
return unless state_changed?
|
|
1366
1500
|
return unless state_was == 'open'
|
|
1367
1501
|
return unless state == 'closed'
|
|
@@ -1379,17 +1513,15 @@ module PlanMyStuff
|
|
|
1379
1513
|
metadata.next_reminder_at = nil
|
|
1380
1514
|
end
|
|
1381
1515
|
|
|
1382
|
-
# When an inactivity-closed issue is being reopened, strips the
|
|
1383
|
-
# +
|
|
1384
|
-
#
|
|
1385
|
-
# for any other transition or for reopens of non-inactive closes.
|
|
1516
|
+
# When an inactivity-closed issue is being reopened, strips the +user_inactive_label+ from the outgoing labels
|
|
1517
|
+
# and clears +metadata.closed_by_inactivity+ so the save writes both. No-op for any other transition or for
|
|
1518
|
+
# reopens of non-inactive closes.
|
|
1386
1519
|
#
|
|
1387
|
-
# @param attrs [Hash] the kwargs hash being assembled for
|
|
1388
|
-
# +Issue.update!+; mutated in place
|
|
1520
|
+
# @param attrs [Hash] the kwargs hash being assembled for +Issue.update!+; mutated in place
|
|
1389
1521
|
#
|
|
1390
1522
|
# @return [void]
|
|
1391
1523
|
#
|
|
1392
|
-
def clear_inactivity_state_on_reopen
|
|
1524
|
+
def clear_inactivity_state_on_reopen(attrs)
|
|
1393
1525
|
return unless state_changed?
|
|
1394
1526
|
return unless state_was == 'closed'
|
|
1395
1527
|
return unless state == 'open'
|
|
@@ -1399,14 +1531,10 @@ module PlanMyStuff
|
|
|
1399
1531
|
metadata.closed_by_inactivity = false
|
|
1400
1532
|
end
|
|
1401
1533
|
|
|
1402
|
-
# Full-write persistence path for an already-persisted issue.
|
|
1403
|
-
#
|
|
1404
|
-
#
|
|
1405
|
-
#
|
|
1406
|
-
# +body:+ when +@body_dirty+, so the PMS body comment is
|
|
1407
|
-
# rewritten exactly when +#body=+ has been called since load.
|
|
1408
|
-
#
|
|
1409
|
-
# @raise [PlanMyStuff::StaleObjectError]
|
|
1534
|
+
# Full-write persistence path for an already-persisted issue. Delegates to +Issue.update!+ passing the full
|
|
1535
|
+
# in-memory state (title/state/labels plus the current +metadata+ object so the class method serializes it
|
|
1536
|
+
# authoritatively). Only passes +body:+ when +@body_dirty+, so the PMS body comment is rewritten exactly when
|
|
1537
|
+
# +#body=+ has been called since load.
|
|
1410
1538
|
#
|
|
1411
1539
|
# @return [void]
|
|
1412
1540
|
#
|
|
@@ -1423,9 +1551,10 @@ module PlanMyStuff
|
|
|
1423
1551
|
}
|
|
1424
1552
|
attrs[:body] = body if @body_dirty
|
|
1425
1553
|
attrs[:assignees] = @pending_assignees unless @pending_assignees.nil?
|
|
1554
|
+
attrs[:issue_type] = issue_type if issue_type_changed?
|
|
1426
1555
|
|
|
1427
|
-
clear_waiting_state_on_close
|
|
1428
|
-
clear_inactivity_state_on_reopen
|
|
1556
|
+
clear_waiting_state_on_close(attrs)
|
|
1557
|
+
clear_inactivity_state_on_reopen(attrs)
|
|
1429
1558
|
|
|
1430
1559
|
self.class.update!(**attrs)
|
|
1431
1560
|
|
|
@@ -1434,25 +1563,24 @@ module PlanMyStuff
|
|
|
1434
1563
|
reload
|
|
1435
1564
|
end
|
|
1436
1565
|
|
|
1437
|
-
# Applies in-memory updates from an +update!+ kwargs hash.
|
|
1438
|
-
#
|
|
1439
|
-
#
|
|
1440
|
-
# +@metadata+ (top-level attrs assigned directly, custom_fields
|
|
1441
|
-
# merged key-by-key).
|
|
1566
|
+
# Applies in-memory updates from an +update!+ kwargs hash. Top-level scalars go through their setters so
|
|
1567
|
+
# +@body_dirty+ and friends stay in sync; +metadata:+ is merged into +@metadata+ (top-level attrs assigned
|
|
1568
|
+
# directly, custom_fields merged key-by-key).
|
|
1442
1569
|
#
|
|
1443
1570
|
# @return [void]
|
|
1444
1571
|
#
|
|
1445
|
-
def apply_update_attrs
|
|
1572
|
+
def apply_update_attrs(attrs)
|
|
1446
1573
|
self.title = attrs[:title] if attrs.key?(:title)
|
|
1447
1574
|
self.state = attrs[:state].to_s if attrs.key?(:state)
|
|
1448
1575
|
self.labels = attrs[:labels] if attrs.key?(:labels)
|
|
1449
1576
|
self.body = attrs[:body] if attrs.key?(:body)
|
|
1577
|
+
self.issue_type = attrs[:issue_type] if attrs.key?(:issue_type)
|
|
1450
1578
|
@pending_assignees = attrs[:assignees] if attrs.key?(:assignees)
|
|
1451
|
-
apply_metadata_attrs
|
|
1579
|
+
apply_metadata_attrs(attrs[:metadata]) if attrs.key?(:metadata)
|
|
1452
1580
|
end
|
|
1453
1581
|
|
|
1454
1582
|
# @return [void]
|
|
1455
|
-
def apply_metadata_attrs
|
|
1583
|
+
def apply_metadata_attrs(md_hash)
|
|
1456
1584
|
return if md_hash.nil?
|
|
1457
1585
|
|
|
1458
1586
|
md_hash.each do |key, value|
|
|
@@ -1464,8 +1592,7 @@ module PlanMyStuff
|
|
|
1464
1592
|
end
|
|
1465
1593
|
end
|
|
1466
1594
|
|
|
1467
|
-
# Raises StaleObjectError if the remote issue has been modified
|
|
1468
|
-
# since this instance was loaded.
|
|
1595
|
+
# Raises StaleObjectError if the remote issue has been modified since this instance was loaded.
|
|
1469
1596
|
#
|
|
1470
1597
|
# @raise [PlanMyStuff::StaleObjectError]
|
|
1471
1598
|
#
|
|
@@ -1482,11 +1609,13 @@ module PlanMyStuff
|
|
|
1482
1609
|
return if remote_time.nil?
|
|
1483
1610
|
return if local_time && remote_time.to_i == local_time.to_i
|
|
1484
1611
|
|
|
1485
|
-
raise(
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1612
|
+
raise(
|
|
1613
|
+
PlanMyStuff::StaleObjectError.new(
|
|
1614
|
+
"Issue ##{number} has been modified remotely",
|
|
1615
|
+
local_updated_at: local_time,
|
|
1616
|
+
remote_updated_at: remote_time,
|
|
1617
|
+
),
|
|
1618
|
+
)
|
|
1490
1619
|
end
|
|
1491
1620
|
|
|
1492
1621
|
# @return [Array<String>]
|
|
@@ -1495,6 +1624,18 @@ module PlanMyStuff
|
|
|
1495
1624
|
raw.map { |label| label_name(label) }
|
|
1496
1625
|
end
|
|
1497
1626
|
|
|
1627
|
+
# Reads the +type.name+ from a REST issue payload. GitHub returns +"type"+ as a nested object (or +nil+) on
|
|
1628
|
+
# every issue response, so we descend into it for the human-readable name.
|
|
1629
|
+
#
|
|
1630
|
+
# @return [String, nil]
|
|
1631
|
+
#
|
|
1632
|
+
def extract_issue_type(github_issue)
|
|
1633
|
+
raw = safe_read_field(github_issue, :type)
|
|
1634
|
+
return if raw.nil?
|
|
1635
|
+
|
|
1636
|
+
safe_read_field(raw, :name)
|
|
1637
|
+
end
|
|
1638
|
+
|
|
1498
1639
|
# @return [String]
|
|
1499
1640
|
def label_name(label)
|
|
1500
1641
|
return label.name if label.respond_to?(:name)
|
|
@@ -1505,7 +1646,7 @@ module PlanMyStuff
|
|
|
1505
1646
|
|
|
1506
1647
|
# @return [Array<PlanMyStuff::Comment>]
|
|
1507
1648
|
def load_comments
|
|
1508
|
-
Comment.list(issue: self)
|
|
1649
|
+
PlanMyStuff::Comment.list(issue: self)
|
|
1509
1650
|
end
|
|
1510
1651
|
|
|
1511
1652
|
# @return [Hash{Symbol => Array}]
|
|
@@ -1513,8 +1654,7 @@ module PlanMyStuff
|
|
|
1513
1654
|
@links_cache ||= {}
|
|
1514
1655
|
end
|
|
1515
1656
|
|
|
1516
|
-
# Clears all memoized link readers. Called from +#hydrate_from_github+
|
|
1517
|
-
# and after any successful write.
|
|
1657
|
+
# Clears all memoized link readers. Called from +#hydrate_from_github+ and after any successful write.
|
|
1518
1658
|
#
|
|
1519
1659
|
# @return [void]
|
|
1520
1660
|
#
|
|
@@ -1522,16 +1662,16 @@ module PlanMyStuff
|
|
|
1522
1662
|
@links_cache = {}
|
|
1523
1663
|
end
|
|
1524
1664
|
|
|
1525
|
-
# Normalizes +target+ to a +PlanMyStuff::Link+ with the source
|
|
1526
|
-
# repo defaulting to self's repo.
|
|
1665
|
+
# Normalizes +target+ to a +PlanMyStuff::Link+ with the source repo defaulting to self's repo.
|
|
1527
1666
|
#
|
|
1528
1667
|
# @return [PlanMyStuff::Link]
|
|
1529
1668
|
#
|
|
1530
|
-
def build_link(target, type:)
|
|
1531
|
-
PlanMyStuff::Link.build(target, type: type, source_repo: repo&.full_name)
|
|
1669
|
+
def build_link!(target, type:)
|
|
1670
|
+
PlanMyStuff::Link.build!(target, type: type, source_repo: repo&.full_name)
|
|
1532
1671
|
end
|
|
1533
1672
|
|
|
1534
|
-
# @raise [PlanMyStuff::ValidationError]
|
|
1673
|
+
# @raise [PlanMyStuff::ValidationError] when target is the same issue (self-link)
|
|
1674
|
+
#
|
|
1535
1675
|
# @return [void]
|
|
1536
1676
|
#
|
|
1537
1677
|
def validate_not_self!(link)
|
|
@@ -1541,8 +1681,7 @@ module PlanMyStuff
|
|
|
1541
1681
|
raise(PlanMyStuff::ValidationError, 'Cannot link an issue to itself')
|
|
1542
1682
|
end
|
|
1543
1683
|
|
|
1544
|
-
# Reads +metadata.links+ and coerces any legacy hash entries to
|
|
1545
|
-
# +Link+ instances. Invalid entries are dropped.
|
|
1684
|
+
# Reads +metadata.links+ and coerces any legacy hash entries to +Link+ instances. Invalid entries are dropped.
|
|
1546
1685
|
#
|
|
1547
1686
|
# @return [Array<PlanMyStuff::Link>]
|
|
1548
1687
|
#
|
|
@@ -1550,14 +1689,13 @@ module PlanMyStuff
|
|
|
1550
1689
|
metadata.links.filter_map do |entry|
|
|
1551
1690
|
next entry if entry.is_a?(PlanMyStuff::Link)
|
|
1552
1691
|
|
|
1553
|
-
PlanMyStuff::Link.build(entry)
|
|
1692
|
+
PlanMyStuff::Link.build!(entry)
|
|
1554
1693
|
rescue ActiveModel::ValidationError, ArgumentError
|
|
1555
1694
|
next
|
|
1556
1695
|
end
|
|
1557
1696
|
end
|
|
1558
1697
|
|
|
1559
|
-
# Writes the given link array back to GitHub via
|
|
1560
|
-
# +Issue.update!+ and updates local metadata so subsequent
|
|
1698
|
+
# Writes the given link array back to GitHub via +Issue.update!+ and updates local metadata so subsequent
|
|
1561
1699
|
# in-memory reads see the change without a +reload+.
|
|
1562
1700
|
#
|
|
1563
1701
|
# @param new_links [Array<PlanMyStuff::Link>]
|
|
@@ -1574,9 +1712,8 @@ module PlanMyStuff
|
|
|
1574
1712
|
invalidate_links_cache!
|
|
1575
1713
|
end
|
|
1576
1714
|
|
|
1577
|
-
# Walks every Projects V2 board this issue sits on and deletes the
|
|
1578
|
-
#
|
|
1579
|
-
# a safety cap to avoid runaway loops. Delete failures propagate.
|
|
1715
|
+
# Walks every Projects V2 board this issue sits on and deletes the corresponding item. Paginates via
|
|
1716
|
+
# +LIST_ISSUE_PROJECT_ITEMS+ with a safety cap to avoid runaway loops. Delete failures propagate.
|
|
1580
1717
|
#
|
|
1581
1718
|
# @return [void]
|
|
1582
1719
|
#
|
|
@@ -1596,7 +1733,7 @@ module PlanMyStuff
|
|
|
1596
1733
|
nodes = Array.wrap(connection[:nodes])
|
|
1597
1734
|
|
|
1598
1735
|
nodes.each do |node|
|
|
1599
|
-
PlanMyStuff::ProjectItem.delete_item(
|
|
1736
|
+
PlanMyStuff::ProjectItem.delete_item!(
|
|
1600
1737
|
item_id: node[:id],
|
|
1601
1738
|
project_number: node.dig(:project, :number),
|
|
1602
1739
|
)
|
|
@@ -1609,9 +1746,8 @@ module PlanMyStuff
|
|
|
1609
1746
|
end
|
|
1610
1747
|
end
|
|
1611
1748
|
|
|
1612
|
-
# Attempts the reciprocal write on +link+'s target. On failure,
|
|
1613
|
-
#
|
|
1614
|
-
# consuming app can surface the half-written pairing.
|
|
1749
|
+
# Attempts the reciprocal write on +link+'s target. On failure, fires
|
|
1750
|
+
# +plan_my_stuff.issue.link_reciprocal_failed+ so the consuming app can surface the half-written pairing.
|
|
1615
1751
|
#
|
|
1616
1752
|
# @param link [PlanMyStuff::Link]
|
|
1617
1753
|
# @param user [Object, nil]
|
|
@@ -1666,27 +1802,26 @@ module PlanMyStuff
|
|
|
1666
1802
|
end
|
|
1667
1803
|
end
|
|
1668
1804
|
|
|
1669
|
-
# Normalizes +target+ to a fully-hydrated +Issue+ (fetching when
|
|
1670
|
-
#
|
|
1671
|
-
#
|
|
1672
|
-
# +#add_sub_issue!+ / +#remove_sub_issue!+ on the parent side.
|
|
1805
|
+
# Normalizes +target+ to a fully-hydrated +Issue+ (fetching when we only have a +Link+ or hash). Used by
|
|
1806
|
+
# +set_parent!+ / +remove_parent!+ to invert the call back through +#add_sub_issue!+ / +#remove_sub_issue!+ on
|
|
1807
|
+
# the parent side.
|
|
1673
1808
|
#
|
|
1674
1809
|
# @return [PlanMyStuff::Issue]
|
|
1675
1810
|
#
|
|
1676
1811
|
def resolve_target_issue(target, type:)
|
|
1677
1812
|
return target if target.is_a?(PlanMyStuff::Issue)
|
|
1678
1813
|
|
|
1679
|
-
link = build_link(target, type: type)
|
|
1814
|
+
link = build_link!(target, type: type)
|
|
1680
1815
|
PlanMyStuff::Issue.find(link.issue_number, repo: link.repo)
|
|
1681
1816
|
end
|
|
1682
1817
|
|
|
1683
|
-
# Shared path for add_sub_issue! / remove_sub_issue!. Builds the
|
|
1684
|
-
#
|
|
1818
|
+
# Shared path for add_sub_issue! / remove_sub_issue!. Builds the link, resolves the target, runs the
|
|
1819
|
+
# mutation, busts caches.
|
|
1685
1820
|
#
|
|
1686
1821
|
# @return [PlanMyStuff::Link]
|
|
1687
1822
|
#
|
|
1688
1823
|
def mutate_sub_issue!(target, method:, path:)
|
|
1689
|
-
link = build_link(target, type: :sub_ticket)
|
|
1824
|
+
link = build_link!(target, type: :sub_ticket)
|
|
1690
1825
|
validate_not_self!(link)
|
|
1691
1826
|
|
|
1692
1827
|
target_issue = resolve_target_issue(target, type: :sub_ticket)
|
|
@@ -1709,8 +1844,7 @@ module PlanMyStuff
|
|
|
1709
1844
|
"/repos/#{repo.organization}/#{repo.name}/issues/#{number}/sub_issues"
|
|
1710
1845
|
end
|
|
1711
1846
|
|
|
1712
|
-
# GitHub's REMOVE endpoint is +/sub_issue+ (singular), distinct
|
|
1713
|
-
# from the list/add path +/sub_issues+ (plural).
|
|
1847
|
+
# GitHub's REMOVE endpoint is +/sub_issue+ (singular), distinct from the list/add path +/sub_issues+ (plural).
|
|
1714
1848
|
#
|
|
1715
1849
|
# @return [String]
|
|
1716
1850
|
#
|
|
@@ -1718,10 +1852,9 @@ module PlanMyStuff
|
|
|
1718
1852
|
"/repos/#{repo.organization}/#{repo.name}/issues/#{number}/sub_issue"
|
|
1719
1853
|
end
|
|
1720
1854
|
|
|
1721
|
-
# Fetches one side of the native issue-dependency graph for self
|
|
1722
|
-
#
|
|
1723
|
-
#
|
|
1724
|
-
# instances (the dependency endpoint returns a slim projection).
|
|
1855
|
+
# Fetches one side of the native issue-dependency graph for self (+blocked_by+ or +blocking+) via REST.
|
|
1856
|
+
# Response is an array of Issue objects; we map through +Issue.find+ to get fully hydrated instances (the
|
|
1857
|
+
# dependency endpoint returns a slim projection).
|
|
1725
1858
|
#
|
|
1726
1859
|
# @param side [String] "blocked_by" or "blocking"
|
|
1727
1860
|
#
|
|
@@ -1767,8 +1900,9 @@ module PlanMyStuff
|
|
|
1767
1900
|
PlanMyStuff::Issue.find(the_dupe[:number], repo: the_dupe.dig(:repository, :nameWithOwner))
|
|
1768
1901
|
end
|
|
1769
1902
|
|
|
1770
|
-
# Resolves +target+ to an +Issue
|
|
1771
|
-
#
|
|
1903
|
+
# Resolves +target+ to an +Issue+.
|
|
1904
|
+
#
|
|
1905
|
+
# @raise [PlanMyStuff::ValidationError] when the duplicate target cannot be found
|
|
1772
1906
|
#
|
|
1773
1907
|
# @return [PlanMyStuff::Issue]
|
|
1774
1908
|
#
|
|
@@ -1782,7 +1916,7 @@ module PlanMyStuff
|
|
|
1782
1916
|
#
|
|
1783
1917
|
# @return [void]
|
|
1784
1918
|
#
|
|
1785
|
-
def merge_visibility_allowlist_onto(target)
|
|
1919
|
+
def merge_visibility_allowlist_onto!(target)
|
|
1786
1920
|
return if metadata.visibility_allowlist.blank?
|
|
1787
1921
|
|
|
1788
1922
|
merged = Array.wrap(target.metadata.visibility_allowlist) | Array.wrap(metadata.visibility_allowlist)
|
|
@@ -1793,7 +1927,7 @@ module PlanMyStuff
|
|
|
1793
1927
|
#
|
|
1794
1928
|
# @return [void]
|
|
1795
1929
|
#
|
|
1796
|
-
def merge_assignees_onto(target)
|
|
1930
|
+
def merge_assignees_onto!(target)
|
|
1797
1931
|
source_logins = extract_assignee_logins(github_response)
|
|
1798
1932
|
return if source_logins.empty?
|
|
1799
1933
|
|
|
@@ -1811,7 +1945,7 @@ module PlanMyStuff
|
|
|
1811
1945
|
end
|
|
1812
1946
|
|
|
1813
1947
|
# @return [void]
|
|
1814
|
-
def post_duplicate_back_pointer(target, user:)
|
|
1948
|
+
def post_duplicate_back_pointer!(target, user:)
|
|
1815
1949
|
visibility = target.metadata.visibility.presence || 'public'
|
|
1816
1950
|
PlanMyStuff::Comment.create!(
|
|
1817
1951
|
issue: target,
|
|
@@ -1821,11 +1955,11 @@ module PlanMyStuff
|
|
|
1821
1955
|
)
|
|
1822
1956
|
end
|
|
1823
1957
|
|
|
1824
|
-
# Closes self as a duplicate of +target+ via GitHub's native
|
|
1825
|
-
# +
|
|
1826
|
-
#
|
|
1827
|
-
#
|
|
1828
|
-
#
|
|
1958
|
+
# Closes self as a duplicate of +target+ via GitHub's native +closeIssue+ GraphQL mutation with
|
|
1959
|
+
# +stateReason: DUPLICATE+ and +duplicateIssueId+. The REST +duplicate_of+ body param is not recognized; only
|
|
1960
|
+
# this GraphQL path actually wires up +Issue#duplicateOf+ on the closed issue.
|
|
1961
|
+
#
|
|
1962
|
+
# @raise [PlanMyStuff::Error] when source or target issue has no node_id
|
|
1829
1963
|
#
|
|
1830
1964
|
# @return [void]
|
|
1831
1965
|
#
|