plan_my_stuff 0.7.0 → 0.9.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 +46 -1
- data/CONFIGURATION.md +351 -0
- 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 +3 -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 +52 -14
- 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 +62 -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 +59 -57
- data/lib/plan_my_stuff/comment_metadata.rb +1 -1
- data/lib/plan_my_stuff/configuration.rb +93 -93
- 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 +471 -333
- data/lib/plan_my_stuff/issue_metadata.rb +10 -10
- data/lib/plan_my_stuff/label.rb +34 -18
- 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 +4 -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,109 @@ 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
|
+
begin
|
|
367
|
+
resolve_issue_type!(value.to_sym)
|
|
368
|
+
rescue ArgumentError
|
|
369
|
+
value
|
|
370
|
+
end
|
|
371
|
+
when Symbol
|
|
372
|
+
ISSUE_TYPE_NICKNAMES[value] || raise(
|
|
373
|
+
ArgumentError,
|
|
374
|
+
"Unknown issue_type nickname #{value.inspect}; known: #{ISSUE_TYPE_NICKNAMES.keys.inspect}",
|
|
375
|
+
)
|
|
376
|
+
else
|
|
377
|
+
raise(ArgumentError, "issue_type must be a String, Symbol, or nil; got #{value.class}")
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
PlanMyStuff.configuration.issue_types[canonical] || canonical
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# @raise [PlanMyStuff::APIError] when the GitHub API call fails
|
|
384
|
+
#
|
|
385
|
+
# @return [Hash]
|
|
386
|
+
#
|
|
387
|
+
def submit_import_request!(client, resolved_repo, payload)
|
|
388
|
+
client.octokit.post(
|
|
389
|
+
"/repos/#{resolved_repo}/import/issues",
|
|
390
|
+
payload.merge(accept: 'application/vnd.github.golden-comet-preview+json'),
|
|
391
|
+
)
|
|
392
|
+
rescue Octokit::ClientError, Octokit::ServerError => e
|
|
393
|
+
raise(PlanMyStuff::APIError.new(e.message, status: e.respond_to?(:response_status) ? e.response_status : nil))
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Builds the visible body string written to GitHub for an issue: a markdown link to the consuming-app
|
|
397
|
+
# per-issue URL (carrying the repo as a +?repo=+ query param so the consuming app knows which repo this issue
|
|
398
|
+
# lives in), labelled with the GitHub +Org/Repo#number+. Returns +""+ when either +config.issues_url_prefix+
|
|
399
|
+
# or +number+ is missing.
|
|
255
400
|
#
|
|
256
401
|
# @param number [Integer]
|
|
257
402
|
# @param repo [String] full repo path, e.g. +"BrandsInsurance/Element"+
|
|
@@ -279,16 +424,21 @@ module PlanMyStuff
|
|
|
279
424
|
issue
|
|
280
425
|
end
|
|
281
426
|
|
|
427
|
+
# @raise [ArgumentError] when add_to_project is true but no default_project_number is configured
|
|
428
|
+
#
|
|
282
429
|
# @return [Integer]
|
|
283
|
-
|
|
430
|
+
#
|
|
431
|
+
def resolve_project_number!(add_to_project)
|
|
284
432
|
return add_to_project unless add_to_project == true
|
|
285
433
|
|
|
286
434
|
PlanMyStuff.configuration.default_project_number ||
|
|
287
435
|
raise(ArgumentError, 'add_to_project: true requires config.default_project_number to be set')
|
|
288
436
|
end
|
|
289
437
|
|
|
290
|
-
# Finds the first PMS comment on an issue and updates its body content,
|
|
291
|
-
#
|
|
438
|
+
# Finds the first PMS comment on an issue and updates its body content, preserving the comment header and
|
|
439
|
+
# metadata.
|
|
440
|
+
#
|
|
441
|
+
# @raise [PlanMyStuff::Error] when the issue has no body comment
|
|
292
442
|
#
|
|
293
443
|
# @param number [Integer] issue number
|
|
294
444
|
# @param resolved_repo [String] resolved repo path
|
|
@@ -296,7 +446,7 @@ module PlanMyStuff
|
|
|
296
446
|
#
|
|
297
447
|
# @return [void]
|
|
298
448
|
#
|
|
299
|
-
def update_body_comment(number, resolved_repo, new_body)
|
|
449
|
+
def update_body_comment!(number, resolved_repo, new_body)
|
|
300
450
|
issue = find(number, repo: resolved_repo)
|
|
301
451
|
body_comment = issue.body_comment
|
|
302
452
|
raise(PlanMyStuff::Error, "No body comment found on issue ##{number}") if body_comment.nil?
|
|
@@ -312,13 +462,11 @@ module PlanMyStuff
|
|
|
312
462
|
|
|
313
463
|
# @param value [PlanMyStuff::Repo, Symbol, String, nil]
|
|
314
464
|
def repo=(value)
|
|
315
|
-
super(value.present? ? PlanMyStuff::Repo.resolve(value) : nil)
|
|
465
|
+
super(value.present? ? PlanMyStuff::Repo.resolve!(value) : nil)
|
|
316
466
|
end
|
|
317
467
|
|
|
318
|
-
# Assigning a new body marks the instance dirty so the next
|
|
319
|
-
#
|
|
320
|
-
# assignments are reflected by +#body+ until persisted or
|
|
321
|
-
# reloaded.
|
|
468
|
+
# Assigning a new body marks the instance dirty so the next +save!+ rewrites the backing PMS body comment.
|
|
469
|
+
# Unsaved assignments are reflected by +#body+ until persisted or reloaded.
|
|
322
470
|
#
|
|
323
471
|
# @param value [String]
|
|
324
472
|
#
|
|
@@ -329,10 +477,9 @@ module PlanMyStuff
|
|
|
329
477
|
@body_dirty = true
|
|
330
478
|
end
|
|
331
479
|
|
|
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.
|
|
480
|
+
# @return [String, nil] per-issue URL in the consuming app (+config.issues_url_prefix+ + +"/"+ + +number+ +
|
|
481
|
+
# +"?repo=Org/Repo"+, or +nil+ when either prefix or number is missing). Also rendered as the destination of
|
|
482
|
+
# the markdown link in the GitHub issue body.
|
|
336
483
|
def user_link
|
|
337
484
|
prefix = PlanMyStuff.configuration.issues_url_prefix
|
|
338
485
|
return if prefix.blank? || number.blank?
|
|
@@ -343,28 +490,35 @@ module PlanMyStuff
|
|
|
343
490
|
"#{base}?repo=#{URI.encode_www_form_component(repo.full_name)}"
|
|
344
491
|
end
|
|
345
492
|
|
|
346
|
-
# @return [Array<PlanMyStuff::Approval>] all required approvers (pending + approved)
|
|
493
|
+
# @return [Array<PlanMyStuff::Approval>] all required approvers (pending + approved + rejected)
|
|
347
494
|
def approvers
|
|
348
495
|
metadata.approvals
|
|
349
496
|
end
|
|
350
497
|
|
|
351
|
-
# @return [Array<PlanMyStuff::Approval>] approvers who have not yet
|
|
498
|
+
# @return [Array<PlanMyStuff::Approval>] approvers who have not yet acted (pending only; rejections are NOT
|
|
499
|
+
# pending -- the approver has responded)
|
|
352
500
|
def pending_approvals
|
|
353
501
|
approvers.select(&:pending?)
|
|
354
502
|
end
|
|
355
503
|
|
|
504
|
+
# @return [Array<PlanMyStuff::Approval>] approvers who have rejected
|
|
505
|
+
def rejected_approvals
|
|
506
|
+
approvers.select(&:rejected?)
|
|
507
|
+
end
|
|
508
|
+
|
|
356
509
|
# @return [Boolean] true when at least one approver is required on this issue
|
|
357
510
|
def approvals_required?
|
|
358
|
-
approvers.
|
|
511
|
+
approvers.present?
|
|
359
512
|
end
|
|
360
513
|
|
|
361
|
-
# @return [Boolean] true when approvers are required AND every approver has approved
|
|
514
|
+
# @return [Boolean] true when approvers are required AND every approver has approved. A single rejection blocks
|
|
515
|
+
# this gate until the approver revokes.
|
|
362
516
|
def fully_approved?
|
|
363
|
-
approvals_required? &&
|
|
517
|
+
approvals_required? && approvers.all?(&:approved?)
|
|
364
518
|
end
|
|
365
519
|
|
|
366
|
-
# Adds user IDs to this issue's visibility allowlist (non-support
|
|
367
|
-
#
|
|
520
|
+
# Adds user IDs to this issue's visibility allowlist (non-support users whose ID is in the allowlist can see
|
|
521
|
+
# internal comments).
|
|
368
522
|
#
|
|
369
523
|
# Fires +plan_my_stuff.issue.viewers_added+.
|
|
370
524
|
#
|
|
@@ -373,9 +527,9 @@ module PlanMyStuff
|
|
|
373
527
|
#
|
|
374
528
|
# @return [Array<Integer>] the new allowlist
|
|
375
529
|
#
|
|
376
|
-
def add_viewers(user_ids:, user: nil)
|
|
530
|
+
def add_viewers!(user_ids:, user: nil)
|
|
377
531
|
ids = Array.wrap(user_ids)
|
|
378
|
-
modify_allowlist { |allowlist| allowlist | ids }
|
|
532
|
+
modify_allowlist! { |allowlist| allowlist | ids }
|
|
379
533
|
PlanMyStuff::Notifications.instrument('issue.viewers_added', self, user: user, user_ids: ids)
|
|
380
534
|
metadata.visibility_allowlist
|
|
381
535
|
end
|
|
@@ -389,33 +543,30 @@ module PlanMyStuff
|
|
|
389
543
|
#
|
|
390
544
|
# @return [Array<Integer>] the new allowlist
|
|
391
545
|
#
|
|
392
|
-
def remove_viewers(user_ids:, user: nil)
|
|
546
|
+
def remove_viewers!(user_ids:, user: nil)
|
|
393
547
|
ids = Array.wrap(user_ids)
|
|
394
|
-
modify_allowlist { |allowlist| allowlist - ids }
|
|
548
|
+
modify_allowlist! { |allowlist| allowlist - ids }
|
|
395
549
|
PlanMyStuff::Notifications.instrument('issue.viewers_removed', self, user: user, user_ids: ids)
|
|
396
550
|
metadata.visibility_allowlist
|
|
397
551
|
end
|
|
398
552
|
|
|
399
|
-
# Adds approvers to this issue's required-approvals list. Idempotent:
|
|
400
|
-
#
|
|
553
|
+
# Adds approvers to this issue's required-approvals list. Idempotent: users already present are no-ops. Only
|
|
554
|
+
# support users may call this.
|
|
401
555
|
#
|
|
402
|
-
# Fires +plan_my_stuff.issue.approval_requested+ when any user is
|
|
403
|
-
#
|
|
404
|
-
# (+trigger: :approver_added+) when the new approvers flip the issue
|
|
556
|
+
# Fires +plan_my_stuff.issue.approval_requested+ when any user is newly added. Also fires
|
|
557
|
+
# +plan_my_stuff.issue.approvals_invalidated+ (+trigger: :approver_added+) when the new approvers flip the issue
|
|
405
558
|
# out of a fully-approved state.
|
|
406
559
|
#
|
|
407
560
|
# @param user_ids [Array<Integer>, Integer]
|
|
408
561
|
# @param user [Object, nil] actor; must be a support user
|
|
409
562
|
#
|
|
410
|
-
# @raise [PlanMyStuff::AuthorizationError] if +user+ is not support
|
|
411
|
-
#
|
|
412
563
|
# @return [Array<PlanMyStuff::Approval>] newly-added approvals (empty when all were duplicates)
|
|
413
564
|
#
|
|
414
565
|
def request_approvals!(user_ids:, user: nil)
|
|
415
566
|
guard_support!(user)
|
|
416
567
|
ids = Array.wrap(user_ids).map(&:to_i)
|
|
417
568
|
|
|
418
|
-
just_added, was_fully_approved = modify_approvals do |current|
|
|
569
|
+
just_added, was_fully_approved = modify_approvals! do |current|
|
|
419
570
|
existing_ids = current.map(&:user_id)
|
|
420
571
|
new_ids = ids - existing_ids
|
|
421
572
|
added = new_ids.map { |id| PlanMyStuff::Approval.new(user_id: id, status: 'pending') }
|
|
@@ -426,25 +577,21 @@ module PlanMyStuff
|
|
|
426
577
|
just_added
|
|
427
578
|
end
|
|
428
579
|
|
|
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?+).
|
|
580
|
+
# Removes approvers from this issue's required-approvals list. Only support users may call this. Removing a
|
|
581
|
+
# pending approver may flip the issue into +fully_approved?+ (fires +all_approved+). Removing an approved
|
|
582
|
+
# approver fires no events (state does not flip). Removing the last approver never fires aggregate events (issue
|
|
583
|
+
# no longer has +approvals_required?+).
|
|
435
584
|
#
|
|
436
585
|
# @param user_ids [Array<Integer>, Integer]
|
|
437
586
|
# @param user [Object, nil] actor; must be a support user
|
|
438
587
|
#
|
|
439
|
-
# @raise [PlanMyStuff::AuthorizationError] if +user+ is not support
|
|
440
|
-
#
|
|
441
588
|
# @return [Array<PlanMyStuff::Approval>] removed approval records
|
|
442
589
|
#
|
|
443
590
|
def remove_approvers!(user_ids:, user: nil)
|
|
444
591
|
guard_support!(user)
|
|
445
592
|
ids = Array.wrap(user_ids).map(&:to_i)
|
|
446
593
|
|
|
447
|
-
just_removed, was_fully_approved = modify_approvals do |current|
|
|
594
|
+
just_removed, was_fully_approved = modify_approvals! do |current|
|
|
448
595
|
removed = current.select { |a| ids.include?(a.user_id) }
|
|
449
596
|
[current - removed, removed]
|
|
450
597
|
end
|
|
@@ -453,27 +600,27 @@ module PlanMyStuff
|
|
|
453
600
|
just_removed
|
|
454
601
|
end
|
|
455
602
|
|
|
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+
|
|
603
|
+
# Flips the caller's approval to +approved+ from any other state (+pending+ or +rejected+). Only the approver
|
|
604
|
+
# themselves may call this. Fires +plan_my_stuff.issue.approval_granted+ and, when this flip completes the
|
|
605
|
+
# approval set, +plan_my_stuff.issue.all_approved+.
|
|
462
606
|
#
|
|
463
607
|
# @raise [PlanMyStuff::ValidationError] when the caller is not in the approvers list or is already approved
|
|
464
608
|
#
|
|
609
|
+
# @param user [Object, Integer] actor; must resolve to an approver
|
|
610
|
+
#
|
|
465
611
|
# @return [PlanMyStuff::Approval] the updated approval
|
|
466
612
|
#
|
|
467
613
|
def approve!(user:)
|
|
468
614
|
actor_id = resolve_actor_id!(user)
|
|
469
615
|
|
|
470
|
-
just_approved, was_fully_approved = modify_approvals do |current|
|
|
616
|
+
just_approved, was_fully_approved = modify_approvals! do |current|
|
|
471
617
|
approval = current.find { |a| a.user_id == actor_id }
|
|
472
618
|
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")
|
|
619
|
+
raise(PlanMyStuff::ValidationError, "User #{actor_id} has already approved") if approval.approved?
|
|
474
620
|
|
|
475
621
|
approval.status = 'approved'
|
|
476
622
|
approval.approved_at = Time.current
|
|
623
|
+
approval.rejected_at = nil
|
|
477
624
|
[current, approval]
|
|
478
625
|
end
|
|
479
626
|
|
|
@@ -481,21 +628,55 @@ module PlanMyStuff
|
|
|
481
628
|
just_approved
|
|
482
629
|
end
|
|
483
630
|
|
|
484
|
-
# Flips
|
|
485
|
-
#
|
|
486
|
-
#
|
|
487
|
-
#
|
|
488
|
-
# +AuthorizationError+.
|
|
631
|
+
# Flips the caller's approval to +rejected+ from any other state (+pending+ or +approved+). Only the approver
|
|
632
|
+
# themselves may call this. Fires +plan_my_stuff.issue.approval_rejected+ and, when this flip drops the issue
|
|
633
|
+
# out of +fully_approved?+ (i.e. the caller was the last +approved+ approver),
|
|
634
|
+
# +plan_my_stuff.issue.approvals_invalidated+ (+trigger: :rejected+).
|
|
489
635
|
#
|
|
490
|
-
#
|
|
491
|
-
# drops the issue out of +fully_approved?+,
|
|
492
|
-
# +plan_my_stuff.issue.approvals_invalidated+ (+trigger: :revoked+).
|
|
636
|
+
# @raise [PlanMyStuff::ValidationError] when the caller is not in the approvers list or is already rejected
|
|
493
637
|
#
|
|
494
|
-
# @param user [Object, Integer]
|
|
495
|
-
#
|
|
638
|
+
# @param user [Object, Integer] actor; must resolve to an approver
|
|
639
|
+
#
|
|
640
|
+
# @return [PlanMyStuff::Approval] the updated approval
|
|
641
|
+
#
|
|
642
|
+
def reject!(user:)
|
|
643
|
+
actor_id = resolve_actor_id!(user)
|
|
644
|
+
|
|
645
|
+
just_rejected, was_fully_approved = modify_approvals! do |current|
|
|
646
|
+
approval = current.find { |a| a.user_id == actor_id }
|
|
647
|
+
raise(PlanMyStuff::ValidationError, "User #{actor_id} is not in the approvers list") if approval.nil?
|
|
648
|
+
raise(PlanMyStuff::ValidationError, "User #{actor_id} has already rejected") if approval.rejected?
|
|
649
|
+
|
|
650
|
+
approval.status = 'rejected'
|
|
651
|
+
approval.rejected_at = Time.current
|
|
652
|
+
approval.approved_at = nil
|
|
653
|
+
[current, approval]
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
finish_state_change(
|
|
657
|
+
:approval_rejected,
|
|
658
|
+
just_rejected,
|
|
659
|
+
user: user,
|
|
660
|
+
was_fully_approved: was_fully_approved,
|
|
661
|
+
trigger: :rejected,
|
|
662
|
+
)
|
|
663
|
+
just_rejected
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
# Flips an approved or rejected record back to +pending+. Approvers may revoke their own response; support users
|
|
667
|
+
# may revoke any approver's response by passing +target_user_id:+. Non-support callers passing a
|
|
668
|
+
# +target_user_id:+ that is not their own raise +AuthorizationError+.
|
|
669
|
+
#
|
|
670
|
+
# Emits the granular event keyed off the source state: +plan_my_stuff.issue.approval_revoked+ from approved, or
|
|
671
|
+
# +plan_my_stuff.issue.rejection_revoked+ from rejected. When revoking an approval drops the issue out of
|
|
672
|
+
# +fully_approved?+, also fires +plan_my_stuff.issue.approvals_invalidated+ (+trigger: :revoked+). Revoking a
|
|
673
|
+
# rejection cannot change +fully_approved?+ (the issue was already gated), so no aggregate event fires.
|
|
496
674
|
#
|
|
497
675
|
# @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
|
|
676
|
+
# @raise [PlanMyStuff::ValidationError] when the target is not in the approvers list or is currently pending
|
|
677
|
+
#
|
|
678
|
+
# @param user [Object, Integer] the caller
|
|
679
|
+
# @param target_user_id [Integer, nil] approver whose response should be revoked; defaults to the caller
|
|
499
680
|
#
|
|
500
681
|
# @return [PlanMyStuff::Approval] the updated approval
|
|
501
682
|
#
|
|
@@ -505,36 +686,39 @@ module PlanMyStuff
|
|
|
505
686
|
target_id = target_user_id&.to_i || actor_id
|
|
506
687
|
|
|
507
688
|
if !caller_is_support && target_id != actor_id
|
|
508
|
-
raise(PlanMyStuff::AuthorizationError, "Only support users may revoke another user's
|
|
689
|
+
raise(PlanMyStuff::AuthorizationError, "Only support users may revoke another user's response")
|
|
509
690
|
end
|
|
510
691
|
|
|
511
|
-
|
|
692
|
+
revoked_from = nil
|
|
693
|
+
just_revoked, was_fully_approved = modify_approvals! do |current|
|
|
512
694
|
approval = current.find { |a| a.user_id == target_id }
|
|
513
695
|
raise(PlanMyStuff::ValidationError, "User #{target_id} is not in the approvers list") if approval.nil?
|
|
514
|
-
|
|
696
|
+
if approval.pending?
|
|
697
|
+
raise(PlanMyStuff::ValidationError, "User #{target_id} has not responded — nothing to revoke")
|
|
698
|
+
end
|
|
515
699
|
|
|
700
|
+
revoked_from = approval.status
|
|
516
701
|
approval.status = 'pending'
|
|
517
702
|
approval.approved_at = nil
|
|
703
|
+
approval.rejected_at = nil
|
|
518
704
|
[current, approval]
|
|
519
705
|
end
|
|
520
706
|
|
|
707
|
+
event = (revoked_from == 'approved') ? :approval_revoked : :rejection_revoked
|
|
521
708
|
finish_state_change(
|
|
522
|
-
|
|
709
|
+
event,
|
|
523
710
|
just_revoked,
|
|
524
711
|
user: user,
|
|
525
712
|
was_fully_approved: was_fully_approved,
|
|
526
|
-
trigger: :revoked,
|
|
713
|
+
trigger: (event == :approval_revoked) ? :revoked : nil,
|
|
527
714
|
)
|
|
528
715
|
just_revoked
|
|
529
716
|
end
|
|
530
717
|
|
|
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.
|
|
718
|
+
# Marks the issue as waiting on an end-user reply. Sets +metadata.waiting_on_user_at+ to now, (re)computes
|
|
719
|
+
# +metadata.next_reminder_at+, and adds the configured +waiting_on_user_label+ to the issue. Called from
|
|
720
|
+
# +Comment.create!+ when a support user posts a comment with +waiting_on_reply: true+, and from the
|
|
721
|
+
# +Issues::WaitingsController+ toggle.
|
|
538
722
|
#
|
|
539
723
|
# @param user [Object, nil] actor for the label notification event
|
|
540
724
|
#
|
|
@@ -545,24 +729,22 @@ module PlanMyStuff
|
|
|
545
729
|
label = PlanMyStuff.configuration.waiting_on_user_label
|
|
546
730
|
|
|
547
731
|
PlanMyStuff::Label.ensure!(repo: repo, name: label)
|
|
548
|
-
PlanMyStuff::Label.add(issue: self, labels: [label], user: user) if labels.exclude?(label)
|
|
732
|
+
PlanMyStuff::Label.add!(issue: self, labels: [label], user: user) if labels.exclude?(label)
|
|
549
733
|
|
|
550
734
|
self.class.update!(
|
|
551
735
|
number: number,
|
|
552
736
|
repo: repo,
|
|
553
737
|
metadata: {
|
|
554
|
-
waiting_on_user_at: now
|
|
738
|
+
waiting_on_user_at: PlanMyStuff.format_time(now),
|
|
555
739
|
next_reminder_at: format_next_reminder_at(from: now),
|
|
556
740
|
},
|
|
557
741
|
)
|
|
558
742
|
reload
|
|
559
743
|
end
|
|
560
744
|
|
|
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.
|
|
745
|
+
# Clears the waiting-on-user state: removes the label, clears +metadata.waiting_on_user_at+, and clears
|
|
746
|
+
# +metadata.next_reminder_at+ unless a waiting-on-approval timer is still active. No-ops if the issue is not
|
|
747
|
+
# currently waiting on a user reply.
|
|
566
748
|
#
|
|
567
749
|
# @return [self]
|
|
568
750
|
#
|
|
@@ -570,25 +752,22 @@ module PlanMyStuff
|
|
|
570
752
|
label = PlanMyStuff.configuration.waiting_on_user_label
|
|
571
753
|
return self if metadata.waiting_on_user_at.nil? && labels.exclude?(label)
|
|
572
754
|
|
|
573
|
-
PlanMyStuff::Label.remove(issue: self, labels: [label]) if labels.include?(label)
|
|
755
|
+
PlanMyStuff::Label.remove!(issue: self, labels: [label]) if labels.include?(label)
|
|
574
756
|
|
|
575
757
|
self.class.update!(
|
|
576
758
|
number: number,
|
|
577
759
|
repo: repo,
|
|
578
760
|
metadata: {
|
|
579
761
|
waiting_on_user_at: nil,
|
|
580
|
-
next_reminder_at: metadata.waiting_on_approval_at ? format_time(metadata.next_reminder_at) : nil,
|
|
762
|
+
next_reminder_at: metadata.waiting_on_approval_at ? PlanMyStuff.format_time(metadata.next_reminder_at) : nil,
|
|
581
763
|
},
|
|
582
764
|
)
|
|
583
765
|
reload
|
|
584
766
|
end
|
|
585
767
|
|
|
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.
|
|
768
|
+
# Reopens an issue that was auto-closed by the inactivity sweep, clears +metadata.closed_by_inactivity+, and
|
|
769
|
+
# emits +plan_my_stuff.issue.reopened_by_reply+ carrying the reopening comment. Does not emit the regular
|
|
770
|
+
# +issue.reopened+ event \- subscribers that specifically care about this flow subscribe to the dedicated event.
|
|
592
771
|
#
|
|
593
772
|
# @param comment [PlanMyStuff::Comment] the reopening comment
|
|
594
773
|
# @param user [Object, nil] actor for the notification event
|
|
@@ -597,7 +776,7 @@ module PlanMyStuff
|
|
|
597
776
|
#
|
|
598
777
|
def reopen_by_reply!(comment:, user: nil)
|
|
599
778
|
inactive_label = PlanMyStuff.configuration.user_inactive_label
|
|
600
|
-
PlanMyStuff::Label.remove(issue: self, labels: [inactive_label]) if labels.include?(inactive_label)
|
|
779
|
+
PlanMyStuff::Label.remove!(issue: self, labels: [inactive_label]) if labels.include?(inactive_label)
|
|
601
780
|
|
|
602
781
|
self.class.update!(
|
|
603
782
|
number: number,
|
|
@@ -616,14 +795,12 @@ module PlanMyStuff
|
|
|
616
795
|
self
|
|
617
796
|
end
|
|
618
797
|
|
|
619
|
-
# Tags the issue with the configured +archived_label+, removes it
|
|
620
|
-
#
|
|
621
|
-
#
|
|
622
|
-
# Emits +plan_my_stuff.issue.archived+ on success.
|
|
798
|
+
# Tags the issue with the configured +archived_label+, removes it from every Projects V2 board it belongs to,
|
|
799
|
+
# locks its conversation on GitHub, and stamps +metadata.archived_at+. Emits +plan_my_stuff.issue.archived+ on
|
|
800
|
+
# success.
|
|
623
801
|
#
|
|
624
|
-
# No-op (no network calls, no event) when the issue is already
|
|
625
|
-
# archived
|
|
626
|
-
# label is already on the issue).
|
|
802
|
+
# No-op (no network calls, no event) when the issue is already archived (either +metadata.archived_at+ is set or
|
|
803
|
+
# the archived label is already on the issue).
|
|
627
804
|
#
|
|
628
805
|
# @param now [Time] clock reference for +metadata.archived_at+
|
|
629
806
|
#
|
|
@@ -639,11 +816,11 @@ module PlanMyStuff
|
|
|
639
816
|
self.class.update!(
|
|
640
817
|
number: number,
|
|
641
818
|
repo: repo,
|
|
642
|
-
metadata: { archived_at: now
|
|
819
|
+
metadata: { archived_at: PlanMyStuff.format_time(now) },
|
|
643
820
|
)
|
|
644
821
|
|
|
645
822
|
PlanMyStuff::Label.ensure!(repo: repo, name: label)
|
|
646
|
-
PlanMyStuff::Label.add(issue: self, labels: [label])
|
|
823
|
+
PlanMyStuff::Label.add!(issue: self, labels: [label])
|
|
647
824
|
|
|
648
825
|
remove_from_all_projects!
|
|
649
826
|
|
|
@@ -659,13 +836,9 @@ module PlanMyStuff
|
|
|
659
836
|
self
|
|
660
837
|
end
|
|
661
838
|
|
|
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
|
|
839
|
+
# Persists the issue. Creates if new, otherwise performs a full write: serializes +@metadata+ into the GitHub
|
|
840
|
+
# issue body and PATCHes title/state/labels. When +#body=+ has been called since the last load, also rewrites
|
|
841
|
+
# the PMS body comment. Always reloads afterwards.
|
|
669
842
|
#
|
|
670
843
|
# @return [self]
|
|
671
844
|
#
|
|
@@ -680,6 +853,7 @@ module PlanMyStuff
|
|
|
680
853
|
metadata: metadata.custom_fields.to_h,
|
|
681
854
|
visibility: metadata.visibility,
|
|
682
855
|
visibility_allowlist: Array.wrap(metadata.visibility_allowlist),
|
|
856
|
+
issue_type: issue_type,
|
|
683
857
|
)
|
|
684
858
|
hydrate_from_issue(created)
|
|
685
859
|
else
|
|
@@ -691,20 +865,16 @@ module PlanMyStuff
|
|
|
691
865
|
self
|
|
692
866
|
end
|
|
693
867
|
|
|
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).
|
|
868
|
+
# Applies +attrs+ to this instance in-memory then calls +save!+. Supports +title:+, +body:+, +state:+,
|
|
869
|
+
# +labels:+, +assignees:+, and +metadata:+. The +metadata:+ kwarg is a hash whose keys are merged into the
|
|
870
|
+
# existing +metadata+ (top-level attributes assigned directly; +:custom_fields+ merged key-by-key).
|
|
699
871
|
#
|
|
700
872
|
# @param user [Object, nil] actor for notification events
|
|
701
873
|
#
|
|
702
|
-
# @raise [PlanMyStuff::StaleObjectError] if remote updated_at differs from local
|
|
703
|
-
#
|
|
704
874
|
# @return [self]
|
|
705
875
|
#
|
|
706
876
|
def update!(user: nil, skip_notification: false, **attrs)
|
|
707
|
-
apply_update_attrs
|
|
877
|
+
apply_update_attrs(attrs)
|
|
708
878
|
save!(user: user, skip_notification: skip_notification)
|
|
709
879
|
end
|
|
710
880
|
|
|
@@ -760,9 +930,8 @@ module PlanMyStuff
|
|
|
760
930
|
pms_comments.find { |c| c.metadata.issue_body? }
|
|
761
931
|
end
|
|
762
932
|
|
|
763
|
-
# Returns the issue body content. For PMS issues, this is the body
|
|
764
|
-
#
|
|
765
|
-
# parsed issue body for non-PMS issues.
|
|
933
|
+
# Returns the issue body content. For PMS issues, this is the body from the body comment (stripped of its
|
|
934
|
+
# header). Falls back to the parsed issue body for non-PMS issues.
|
|
766
935
|
#
|
|
767
936
|
# @return [String, nil]
|
|
768
937
|
#
|
|
@@ -788,13 +957,12 @@ module PlanMyStuff
|
|
|
788
957
|
if pms_issue?
|
|
789
958
|
metadata.visible_to?(user)
|
|
790
959
|
else
|
|
791
|
-
UserResolver.support?(UserResolver.resolve(user))
|
|
960
|
+
PlanMyStuff::UserResolver.support?(PlanMyStuff::UserResolver.resolve(user))
|
|
792
961
|
end
|
|
793
962
|
end
|
|
794
963
|
|
|
795
|
-
# Lazy-memoized array of +Issue+ objects for +:related+ links.
|
|
796
|
-
#
|
|
797
|
-
# break the rest of the list.
|
|
964
|
+
# Lazy-memoized array of +Issue+ objects for +:related+ links. Silently drops targets that 404 so a dangling
|
|
965
|
+
# pointer doesn't break the rest of the list.
|
|
798
966
|
#
|
|
799
967
|
# @return [Array<PlanMyStuff::Issue>]
|
|
800
968
|
#
|
|
@@ -802,10 +970,8 @@ module PlanMyStuff
|
|
|
802
970
|
links_cache[:related] ||= fetch_related
|
|
803
971
|
end
|
|
804
972
|
|
|
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.
|
|
973
|
+
# Adds a +:related+ link to +target+ and, unless this call is already a reciprocal, mirrors the link back on
|
|
974
|
+
# +target+ so the pairing is symmetric. Dedups on +(type, issue_number, repo)+ - re-adding is a no-op.
|
|
809
975
|
#
|
|
810
976
|
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
811
977
|
# @param user [Object, nil] actor for notification events
|
|
@@ -814,7 +980,7 @@ module PlanMyStuff
|
|
|
814
980
|
# @return [PlanMyStuff::Link]
|
|
815
981
|
#
|
|
816
982
|
def add_related!(target, user: nil, reciprocal: false)
|
|
817
|
-
link = build_link(target, type: :related)
|
|
983
|
+
link = build_link!(target, type: :related)
|
|
818
984
|
validate_not_self!(link)
|
|
819
985
|
|
|
820
986
|
existing = current_links
|
|
@@ -828,9 +994,8 @@ module PlanMyStuff
|
|
|
828
994
|
link
|
|
829
995
|
end
|
|
830
996
|
|
|
831
|
-
# Removes a +:related+ link to +target+ and, unless this call is
|
|
832
|
-
#
|
|
833
|
-
# when the link isn't present locally.
|
|
997
|
+
# Removes a +:related+ link to +target+ and, unless this call is already a reciprocal, mirrors the removal on
|
|
998
|
+
# +target+. No-op when the link isn't present locally.
|
|
834
999
|
#
|
|
835
1000
|
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
836
1001
|
# @param user [Object, nil]
|
|
@@ -839,7 +1004,7 @@ module PlanMyStuff
|
|
|
839
1004
|
# @return [PlanMyStuff::Link]
|
|
840
1005
|
#
|
|
841
1006
|
def remove_related!(target, user: nil, reciprocal: false)
|
|
842
|
-
link = build_link(target, type: :related)
|
|
1007
|
+
link = build_link!(target, type: :related)
|
|
843
1008
|
validate_not_self!(link)
|
|
844
1009
|
|
|
845
1010
|
existing = current_links
|
|
@@ -853,8 +1018,7 @@ module PlanMyStuff
|
|
|
853
1018
|
link
|
|
854
1019
|
end
|
|
855
1020
|
|
|
856
|
-
# Lazy-memoized parent issue via GitHub's native sub-issues API.
|
|
857
|
-
# GitHub enforces at most one parent per issue.
|
|
1021
|
+
# Lazy-memoized parent issue via GitHub's native sub-issues API. GitHub enforces at most one parent per issue.
|
|
858
1022
|
#
|
|
859
1023
|
# @return [PlanMyStuff::Issue, nil]
|
|
860
1024
|
#
|
|
@@ -872,8 +1036,7 @@ module PlanMyStuff
|
|
|
872
1036
|
links_cache[:sub_tickets] ||= fetch_sub_tickets
|
|
873
1037
|
end
|
|
874
1038
|
|
|
875
|
-
# Adds +target+ as a sub-issue of self via
|
|
876
|
-
# +POST /issues/{number}/sub_issues+. Native GitHub action;
|
|
1039
|
+
# Adds +target+ as a sub-issue of self via +POST /issues/{number}/sub_issues+. Native GitHub action;
|
|
877
1040
|
# notifications are handled by GitHub itself.
|
|
878
1041
|
#
|
|
879
1042
|
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
@@ -884,8 +1047,7 @@ module PlanMyStuff
|
|
|
884
1047
|
mutate_sub_issue!(target, method: :post, path: sub_issues_path)
|
|
885
1048
|
end
|
|
886
1049
|
|
|
887
|
-
# Removes +target+ as a sub-issue of self via
|
|
888
|
-
# +DELETE /issues/{number}/sub_issue+ (singular).
|
|
1050
|
+
# Removes +target+ as a sub-issue of self via +DELETE /issues/{number}/sub_issue+ (singular).
|
|
889
1051
|
#
|
|
890
1052
|
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
891
1053
|
#
|
|
@@ -895,9 +1057,8 @@ module PlanMyStuff
|
|
|
895
1057
|
mutate_sub_issue!(target, method: :delete, path: remove_sub_issue_path)
|
|
896
1058
|
end
|
|
897
1059
|
|
|
898
|
-
# Makes +target+ the parent of self. If self already has a parent,
|
|
899
|
-
#
|
|
900
|
-
# +:parent+ relationship.
|
|
1060
|
+
# Makes +target+ the parent of self. If self already has a parent, it is detached first. Returns a +Link+
|
|
1061
|
+
# describing the new +:parent+ relationship.
|
|
901
1062
|
#
|
|
902
1063
|
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
903
1064
|
#
|
|
@@ -910,11 +1071,11 @@ module PlanMyStuff
|
|
|
910
1071
|
target_issue.add_sub_issue!(self)
|
|
911
1072
|
invalidate_links_cache!
|
|
912
1073
|
|
|
913
|
-
build_link(target_issue, type: :parent)
|
|
1074
|
+
build_link!(target_issue, type: :parent)
|
|
914
1075
|
end
|
|
915
1076
|
|
|
916
|
-
# Detaches self from its current parent, if any. Returns the
|
|
917
|
-
#
|
|
1077
|
+
# Detaches self from its current parent, if any. Returns the +Link+ that was removed, or nil when there was no
|
|
1078
|
+
# parent.
|
|
918
1079
|
#
|
|
919
1080
|
# @return [PlanMyStuff::Link, nil]
|
|
920
1081
|
#
|
|
@@ -925,12 +1086,11 @@ module PlanMyStuff
|
|
|
925
1086
|
current.remove_sub_issue!(self)
|
|
926
1087
|
invalidate_links_cache!
|
|
927
1088
|
|
|
928
|
-
build_link(current, type: :parent)
|
|
1089
|
+
build_link!(current, type: :parent)
|
|
929
1090
|
end
|
|
930
1091
|
|
|
931
|
-
# Lazy-memoized issues that block self (i.e. self is blocked by
|
|
932
|
-
#
|
|
933
|
-
# API.
|
|
1092
|
+
# Lazy-memoized issues that block self (i.e. self is blocked by each returned issue) via GitHub's native
|
|
1093
|
+
# issue-dependency REST API.
|
|
934
1094
|
#
|
|
935
1095
|
# @return [Array<PlanMyStuff::Issue>]
|
|
936
1096
|
#
|
|
@@ -946,15 +1106,14 @@ module PlanMyStuff
|
|
|
946
1106
|
links_cache[:blocking] ||= fetch_dependencies('blocking')
|
|
947
1107
|
end
|
|
948
1108
|
|
|
949
|
-
# Records that +target+ blocks self. Native GitHub action;
|
|
950
|
-
# notifications are handled by GitHub itself.
|
|
1109
|
+
# Records that +target+ blocks self. Native GitHub action; notifications are handled by GitHub itself.
|
|
951
1110
|
#
|
|
952
1111
|
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
953
1112
|
#
|
|
954
1113
|
# @return [PlanMyStuff::Link]
|
|
955
1114
|
#
|
|
956
1115
|
def add_blocker!(target)
|
|
957
|
-
link = build_link(target, type: :blocked_by)
|
|
1116
|
+
link = build_link!(target, type: :blocked_by)
|
|
958
1117
|
validate_not_self!(link)
|
|
959
1118
|
|
|
960
1119
|
target_issue = resolve_target_issue(target, type: :blocked_by)
|
|
@@ -974,7 +1133,7 @@ module PlanMyStuff
|
|
|
974
1133
|
# @return [PlanMyStuff::Link]
|
|
975
1134
|
#
|
|
976
1135
|
def remove_blocker!(target)
|
|
977
|
-
link = build_link(target, type: :blocked_by)
|
|
1136
|
+
link = build_link!(target, type: :blocked_by)
|
|
978
1137
|
validate_not_self!(link)
|
|
979
1138
|
|
|
980
1139
|
target_issue = resolve_target_issue(target, type: :blocked_by)
|
|
@@ -986,9 +1145,8 @@ module PlanMyStuff
|
|
|
986
1145
|
link
|
|
987
1146
|
end
|
|
988
1147
|
|
|
989
|
-
# Lazy-memoized issue that self was marked as duplicate of, via
|
|
990
|
-
#
|
|
991
|
-
# are open or closed for other reasons.
|
|
1148
|
+
# Lazy-memoized issue that self was marked as duplicate of, via GitHub's native close-as-duplicate. Returns nil
|
|
1149
|
+
# for issues that are open or closed for other reasons.
|
|
992
1150
|
#
|
|
993
1151
|
# @return [PlanMyStuff::Issue, nil]
|
|
994
1152
|
#
|
|
@@ -998,9 +1156,8 @@ module PlanMyStuff
|
|
|
998
1156
|
links_cache[:duplicate_of] = fetch_duplicate_of
|
|
999
1157
|
end
|
|
1000
1158
|
|
|
1001
|
-
# Closes self as a duplicate of +target+ via GitHub's native
|
|
1002
|
-
#
|
|
1003
|
-
# back-pointer comment on the target.
|
|
1159
|
+
# Closes self as a duplicate of +target+ via GitHub's native close-as-duplicate, carrying over viewers,
|
|
1160
|
+
# assignees, and a back-pointer comment on the target.
|
|
1004
1161
|
#
|
|
1005
1162
|
# Side effects, in order:
|
|
1006
1163
|
# 1. Resolves +target+; raises +ValidationError+ if missing.
|
|
@@ -1013,8 +1170,9 @@ module PlanMyStuff
|
|
|
1013
1170
|
# 7. Reloads self; invalidates link caches.
|
|
1014
1171
|
# 8. Fires +plan_my_stuff.issue.marked_duplicate+.
|
|
1015
1172
|
#
|
|
1016
|
-
# Partial failures are not rolled back - GitHub retains whatever
|
|
1017
|
-
#
|
|
1173
|
+
# Partial failures are not rolled back - GitHub retains whatever side effects succeeded before the failing step.
|
|
1174
|
+
#
|
|
1175
|
+
# @raise [PlanMyStuff::ValidationError] when the issue is already closed
|
|
1018
1176
|
#
|
|
1019
1177
|
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
1020
1178
|
# @param user [Object, nil] actor for notification + comment
|
|
@@ -1025,20 +1183,19 @@ module PlanMyStuff
|
|
|
1025
1183
|
raise(PlanMyStuff::ValidationError, 'Cannot mark a closed issue as duplicate') if state == 'closed'
|
|
1026
1184
|
|
|
1027
1185
|
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)
|
|
1186
|
+
merge_visibility_allowlist_onto!(target_issue)
|
|
1187
|
+
merge_assignees_onto!(target_issue)
|
|
1188
|
+
post_duplicate_back_pointer!(target_issue, user: user)
|
|
1031
1189
|
close_as_duplicate!(target_issue)
|
|
1032
1190
|
|
|
1033
1191
|
reload
|
|
1034
1192
|
invalidate_links_cache!
|
|
1035
1193
|
PlanMyStuff::Notifications.instrument('issue.marked_duplicate', self, target: target_issue, user: user)
|
|
1036
1194
|
|
|
1037
|
-
build_link(target_issue, type: :duplicate_of)
|
|
1195
|
+
build_link!(target_issue, type: :duplicate_of)
|
|
1038
1196
|
end
|
|
1039
1197
|
|
|
1040
|
-
# GitHub GraphQL node ID (required for native sub-issue mutations).
|
|
1041
|
-
# Read from the hydrated REST response.
|
|
1198
|
+
# GitHub GraphQL node ID (required for native sub-issue mutations). Read from the hydrated REST response.
|
|
1042
1199
|
#
|
|
1043
1200
|
# @return [String, nil]
|
|
1044
1201
|
#
|
|
@@ -1046,8 +1203,8 @@ module PlanMyStuff
|
|
|
1046
1203
|
safe_read_field(github_response, :node_id)
|
|
1047
1204
|
end
|
|
1048
1205
|
|
|
1049
|
-
# GitHub database ID (required for the REST issue-dependency API,
|
|
1050
|
-
#
|
|
1206
|
+
# GitHub database ID (required for the REST issue-dependency API, which takes integer issue_id rather than issue
|
|
1207
|
+
# number).
|
|
1051
1208
|
#
|
|
1052
1209
|
# @return [Integer, nil]
|
|
1053
1210
|
#
|
|
@@ -1057,16 +1214,15 @@ module PlanMyStuff
|
|
|
1057
1214
|
|
|
1058
1215
|
private
|
|
1059
1216
|
|
|
1060
|
-
# Yields +self.metadata.visibility_allowlist+ for modification,
|
|
1061
|
-
#
|
|
1062
|
-
# and reloads +self+ so subsequent reads see the fresh state.
|
|
1217
|
+
# Yields +self.metadata.visibility_allowlist+ for modification, persists the updated allowlist via the
|
|
1218
|
+
# class-level +update!+, and reloads +self+ so subsequent reads see the fresh state.
|
|
1063
1219
|
#
|
|
1064
1220
|
# @yieldparam allowlist [Array<Integer>]
|
|
1065
1221
|
# @yieldreturn [Array<Integer>] the new allowlist
|
|
1066
1222
|
#
|
|
1067
1223
|
# @return [void]
|
|
1068
1224
|
#
|
|
1069
|
-
def modify_allowlist
|
|
1225
|
+
def modify_allowlist!
|
|
1070
1226
|
new_allowlist = yield(Array.wrap(metadata.visibility_allowlist))
|
|
1071
1227
|
self.class.update!(
|
|
1072
1228
|
number: number,
|
|
@@ -1076,16 +1232,15 @@ module PlanMyStuff
|
|
|
1076
1232
|
reload
|
|
1077
1233
|
end
|
|
1078
1234
|
|
|
1079
|
-
# Captures +fully_approved?+ state, yields the current approvals
|
|
1080
|
-
#
|
|
1081
|
-
# reloads +self+. Returns +[extra, was_fully_approved]+.
|
|
1235
|
+
# Captures +fully_approved?+ state, yields the current approvals (deep-copied) for mutation, persists the new
|
|
1236
|
+
# list to GitHub, and reloads +self+. Returns +[extra, was_fully_approved]+.
|
|
1082
1237
|
#
|
|
1083
1238
|
# @yieldparam current [Array<PlanMyStuff::Approval>] deep-copied approvals
|
|
1084
1239
|
# @yieldreturn [Array(Array<PlanMyStuff::Approval>, Object)] +[new_list, extra]+
|
|
1085
1240
|
#
|
|
1086
1241
|
# @return [Array(Object, Boolean)]
|
|
1087
1242
|
#
|
|
1088
|
-
def modify_approvals
|
|
1243
|
+
def modify_approvals!
|
|
1089
1244
|
was_fully_approved = fully_approved?
|
|
1090
1245
|
was_pending_count = metadata.approvals.count(&:pending?)
|
|
1091
1246
|
current = metadata.approvals.map { |a| PlanMyStuff::Approval.new(a.attributes) }
|
|
@@ -1099,16 +1254,14 @@ module PlanMyStuff
|
|
|
1099
1254
|
self.class.update!(number: number, repo: repo, metadata: metadata_updates)
|
|
1100
1255
|
reload
|
|
1101
1256
|
|
|
1102
|
-
sync_waiting_on_approval_label(was_pending_count, new_pending_count)
|
|
1257
|
+
sync_waiting_on_approval_label!(was_pending_count, new_pending_count)
|
|
1103
1258
|
|
|
1104
1259
|
[extra, was_fully_approved]
|
|
1105
1260
|
end
|
|
1106
1261
|
|
|
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.
|
|
1262
|
+
# Computes the metadata delta for the waiting-on-approval timer based on the change in pending-approval count.
|
|
1263
|
+
# The timer resets only when pending count goes UP (add approver, revoke-to-pending) so that remaining pending
|
|
1264
|
+
# approvers keep their original schedule when a peer approves. Drop-to-zero clears the timer entirely.
|
|
1112
1265
|
#
|
|
1113
1266
|
# @param was [Integer] pending count before the mutation
|
|
1114
1267
|
# @param now [Integer] pending count after the mutation
|
|
@@ -1119,41 +1272,41 @@ module PlanMyStuff
|
|
|
1119
1272
|
if now > was
|
|
1120
1273
|
ts = Time.now.utc
|
|
1121
1274
|
{
|
|
1122
|
-
waiting_on_approval_at: ts
|
|
1275
|
+
waiting_on_approval_at: PlanMyStuff.format_time(ts),
|
|
1123
1276
|
next_reminder_at: format_next_reminder_at(from: ts),
|
|
1124
1277
|
}
|
|
1125
1278
|
elsif now.zero? && was.positive?
|
|
1126
1279
|
{
|
|
1127
1280
|
waiting_on_approval_at: nil,
|
|
1128
|
-
next_reminder_at: metadata.waiting_on_user_at ? format_time(metadata.next_reminder_at) : nil,
|
|
1281
|
+
next_reminder_at: metadata.waiting_on_user_at ? PlanMyStuff.format_time(metadata.next_reminder_at) : nil,
|
|
1129
1282
|
}
|
|
1130
1283
|
else
|
|
1131
1284
|
{}
|
|
1132
1285
|
end
|
|
1133
1286
|
end
|
|
1134
1287
|
|
|
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.
|
|
1288
|
+
# Adds or removes the configured waiting-on-approval label when the pending-approval count crosses the zero
|
|
1289
|
+
# boundary. Mutations that stay on the same side of zero leave the label untouched.
|
|
1138
1290
|
#
|
|
1139
1291
|
# @param was [Integer] pending count before the mutation
|
|
1140
1292
|
# @param now [Integer] pending count after the mutation
|
|
1141
1293
|
#
|
|
1142
1294
|
# @return [void]
|
|
1143
1295
|
#
|
|
1144
|
-
def sync_waiting_on_approval_label(was, now)
|
|
1296
|
+
def sync_waiting_on_approval_label!(was, now)
|
|
1145
1297
|
label = PlanMyStuff.configuration.waiting_on_approval_label
|
|
1146
1298
|
|
|
1147
1299
|
if now.positive? && was.zero?
|
|
1148
1300
|
PlanMyStuff::Label.ensure!(repo: repo, name: label)
|
|
1149
|
-
PlanMyStuff::Label.add(issue: self, labels: [label]) if labels.exclude?(label)
|
|
1301
|
+
PlanMyStuff::Label.add!(issue: self, labels: [label]) if labels.exclude?(label)
|
|
1150
1302
|
elsif now.zero? && was.positive?
|
|
1151
|
-
PlanMyStuff::Label.remove(issue: self, labels: [label]) if labels.include?(label)
|
|
1303
|
+
PlanMyStuff::Label.remove!(issue: self, labels: [label]) if labels.include?(label)
|
|
1152
1304
|
end
|
|
1153
1305
|
end
|
|
1154
1306
|
|
|
1155
|
-
#
|
|
1156
|
-
#
|
|
1307
|
+
# Ensures +user+ resolves to a support user. +nil+ user is treated as unauthorized.
|
|
1308
|
+
#
|
|
1309
|
+
# @raise [PlanMyStuff::AuthorizationError] when the actor is not a support user
|
|
1157
1310
|
#
|
|
1158
1311
|
# @param user [Object, Integer, nil]
|
|
1159
1312
|
#
|
|
@@ -1166,8 +1319,9 @@ module PlanMyStuff
|
|
|
1166
1319
|
raise(PlanMyStuff::AuthorizationError, 'Only support users may manage approvers')
|
|
1167
1320
|
end
|
|
1168
1321
|
|
|
1169
|
-
# Resolves +user+ to an integer user_id.
|
|
1170
|
-
#
|
|
1322
|
+
# Resolves +user+ to an integer user_id.
|
|
1323
|
+
#
|
|
1324
|
+
# @raise [ArgumentError] when user is nil
|
|
1171
1325
|
#
|
|
1172
1326
|
# @param user [Object, Integer]
|
|
1173
1327
|
#
|
|
@@ -1180,9 +1334,8 @@ module PlanMyStuff
|
|
|
1180
1334
|
PlanMyStuff::UserResolver.user_id(resolved)
|
|
1181
1335
|
end
|
|
1182
1336
|
|
|
1183
|
-
# Fires +approval_requested+ (when any users were newly added) and,
|
|
1184
|
-
#
|
|
1185
|
-
# +approvals_invalidated+ follow-up.
|
|
1337
|
+
# Fires +approval_requested+ (when any users were newly added) and, if the aggregate state flipped out of
|
|
1338
|
+
# fully-approved, the +approvals_invalidated+ follow-up.
|
|
1186
1339
|
#
|
|
1187
1340
|
# @param added [Array<PlanMyStuff::Approval>]
|
|
1188
1341
|
# @param user [Object, nil]
|
|
@@ -1202,8 +1355,8 @@ module PlanMyStuff
|
|
|
1202
1355
|
emit_aggregate_events(was_fully_approved: was_fully_approved, trigger: :approver_added, user: user)
|
|
1203
1356
|
end
|
|
1204
1357
|
|
|
1205
|
-
# Fires the granular event (+approval_granted+ / +approval_revoked+)
|
|
1206
|
-
#
|
|
1358
|
+
# Fires the granular event (+approval_granted+ / +approval_revoked+) then any aggregate follow-up triggered
|
|
1359
|
+
# by the state flip.
|
|
1207
1360
|
#
|
|
1208
1361
|
# @param event [Symbol] +:approval_granted+ or +:approval_revoked+
|
|
1209
1362
|
# @param approval [PlanMyStuff::Approval]
|
|
@@ -1223,10 +1376,9 @@ module PlanMyStuff
|
|
|
1223
1376
|
emit_aggregate_events(was_fully_approved: was_fully_approved, trigger: trigger, user: user)
|
|
1224
1377
|
end
|
|
1225
1378
|
|
|
1226
|
-
# Fires +all_approved+ or +approvals_invalidated+ based on whether
|
|
1227
|
-
# +
|
|
1228
|
-
#
|
|
1229
|
-
# the list to empty is not an invalidation).
|
|
1379
|
+
# Fires +all_approved+ or +approvals_invalidated+ based on whether +fully_approved?+ flipped. Suppresses
|
|
1380
|
+
# +approvals_invalidated+ when the issue no longer has any approvers required (dropping the list to empty is
|
|
1381
|
+
# not an invalidation).
|
|
1230
1382
|
#
|
|
1231
1383
|
# @param was_fully_approved [Boolean]
|
|
1232
1384
|
# @param trigger [Symbol, nil]
|
|
@@ -1263,13 +1415,15 @@ module PlanMyStuff
|
|
|
1263
1415
|
self.state = read_field(github_issue, :state)
|
|
1264
1416
|
self.raw_body = read_field(github_issue, :body) || ''
|
|
1265
1417
|
self.updated_at = parse_github_time(safe_read_field(github_issue, :updated_at))
|
|
1418
|
+
self.created_at = parse_github_time(safe_read_field(github_issue, :created_at))
|
|
1266
1419
|
self.closed_at = parse_github_time(safe_read_field(github_issue, :closed_at))
|
|
1267
1420
|
self.locked = safe_read_field(github_issue, :locked) || false
|
|
1268
1421
|
self.labels = extract_labels(github_issue)
|
|
1422
|
+
self.issue_type = extract_issue_type(github_issue)
|
|
1269
1423
|
self.repo = repo
|
|
1270
1424
|
|
|
1271
|
-
parsed = MetadataParser.parse(raw_body)
|
|
1272
|
-
self.metadata = IssueMetadata.from_hash(parsed[:metadata])
|
|
1425
|
+
parsed = PlanMyStuff::MetadataParser.parse(raw_body)
|
|
1426
|
+
self.metadata = PlanMyStuff::IssueMetadata.from_hash(parsed[:metadata])
|
|
1273
1427
|
self.body = parsed[:body]
|
|
1274
1428
|
@body_dirty = false
|
|
1275
1429
|
persisted!
|
|
@@ -1291,10 +1445,12 @@ module PlanMyStuff
|
|
|
1291
1445
|
self.body = other.attributes['body']
|
|
1292
1446
|
@body_dirty = false
|
|
1293
1447
|
self.raw_body = other.raw_body
|
|
1448
|
+
self.created_at = other.created_at
|
|
1294
1449
|
self.updated_at = other.updated_at
|
|
1295
1450
|
self.closed_at = other.closed_at
|
|
1296
1451
|
self.locked = other.locked
|
|
1297
1452
|
self.labels = other.labels
|
|
1453
|
+
self.issue_type = other.issue_type
|
|
1298
1454
|
self.repo = other.repo
|
|
1299
1455
|
self.metadata = other.metadata
|
|
1300
1456
|
persisted!
|
|
@@ -1302,10 +1458,8 @@ module PlanMyStuff
|
|
|
1302
1458
|
invalidate_links_cache!
|
|
1303
1459
|
end
|
|
1304
1460
|
|
|
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.
|
|
1461
|
+
# Formats the next reminder time as an ISO 8601 UTC string, using per-issue +metadata.reminder_days+ when set
|
|
1462
|
+
# or +config.reminder_days+ otherwise. Returns +nil+ when the effective schedule is empty.
|
|
1309
1463
|
#
|
|
1310
1464
|
# @param from [Time] baseline timestamp
|
|
1311
1465
|
#
|
|
@@ -1315,25 +1469,11 @@ module PlanMyStuff
|
|
|
1315
1469
|
days = metadata.reminder_days.presence || PlanMyStuff.configuration.reminder_days
|
|
1316
1470
|
return if days.empty?
|
|
1317
1471
|
|
|
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
|
|
1472
|
+
PlanMyStuff.format_time(from + days.first.days)
|
|
1332
1473
|
end
|
|
1333
1474
|
|
|
1334
|
-
# Fires the appropriate notification event for an update: +issue.closed+
|
|
1335
|
-
#
|
|
1336
|
-
# with the captured dirty-tracking diff.
|
|
1475
|
+
# Fires the appropriate notification event for an update: +issue.closed+ or +issue.reopened+ on a state
|
|
1476
|
+
# transition, otherwise +issue.updated+ with the captured dirty-tracking diff.
|
|
1337
1477
|
#
|
|
1338
1478
|
# @param captured [Hash] +ActiveModel::Dirty+ changes captured before +persist_update!+
|
|
1339
1479
|
# @param user [Object, nil]
|
|
@@ -1351,17 +1491,15 @@ module PlanMyStuff
|
|
|
1351
1491
|
end
|
|
1352
1492
|
end
|
|
1353
1493
|
|
|
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.
|
|
1494
|
+
# When an issue is transitioning from open to closed, strips both waiting labels from the outgoing labels
|
|
1495
|
+
# array and clears the waiting-related timestamps on +metadata+ so a single save writes both state change and
|
|
1496
|
+
# cleanup. No-op for any other transition.
|
|
1358
1497
|
#
|
|
1359
|
-
# @param attrs [Hash] the kwargs hash being assembled for
|
|
1360
|
-
# +Issue.update!+; mutated in place
|
|
1498
|
+
# @param attrs [Hash] the kwargs hash being assembled for +Issue.update!+; mutated in place
|
|
1361
1499
|
#
|
|
1362
1500
|
# @return [void]
|
|
1363
1501
|
#
|
|
1364
|
-
def clear_waiting_state_on_close
|
|
1502
|
+
def clear_waiting_state_on_close(attrs)
|
|
1365
1503
|
return unless state_changed?
|
|
1366
1504
|
return unless state_was == 'open'
|
|
1367
1505
|
return unless state == 'closed'
|
|
@@ -1379,17 +1517,15 @@ module PlanMyStuff
|
|
|
1379
1517
|
metadata.next_reminder_at = nil
|
|
1380
1518
|
end
|
|
1381
1519
|
|
|
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.
|
|
1520
|
+
# When an inactivity-closed issue is being reopened, strips the +user_inactive_label+ from the outgoing labels
|
|
1521
|
+
# and clears +metadata.closed_by_inactivity+ so the save writes both. No-op for any other transition or for
|
|
1522
|
+
# reopens of non-inactive closes.
|
|
1386
1523
|
#
|
|
1387
|
-
# @param attrs [Hash] the kwargs hash being assembled for
|
|
1388
|
-
# +Issue.update!+; mutated in place
|
|
1524
|
+
# @param attrs [Hash] the kwargs hash being assembled for +Issue.update!+; mutated in place
|
|
1389
1525
|
#
|
|
1390
1526
|
# @return [void]
|
|
1391
1527
|
#
|
|
1392
|
-
def clear_inactivity_state_on_reopen
|
|
1528
|
+
def clear_inactivity_state_on_reopen(attrs)
|
|
1393
1529
|
return unless state_changed?
|
|
1394
1530
|
return unless state_was == 'closed'
|
|
1395
1531
|
return unless state == 'open'
|
|
@@ -1399,14 +1535,10 @@ module PlanMyStuff
|
|
|
1399
1535
|
metadata.closed_by_inactivity = false
|
|
1400
1536
|
end
|
|
1401
1537
|
|
|
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]
|
|
1538
|
+
# Full-write persistence path for an already-persisted issue. Delegates to +Issue.update!+ passing the full
|
|
1539
|
+
# in-memory state (title/state/labels plus the current +metadata+ object so the class method serializes it
|
|
1540
|
+
# authoritatively). Only passes +body:+ when +@body_dirty+, so the PMS body comment is rewritten exactly when
|
|
1541
|
+
# +#body=+ has been called since load.
|
|
1410
1542
|
#
|
|
1411
1543
|
# @return [void]
|
|
1412
1544
|
#
|
|
@@ -1423,9 +1555,10 @@ module PlanMyStuff
|
|
|
1423
1555
|
}
|
|
1424
1556
|
attrs[:body] = body if @body_dirty
|
|
1425
1557
|
attrs[:assignees] = @pending_assignees unless @pending_assignees.nil?
|
|
1558
|
+
attrs[:issue_type] = issue_type if issue_type_changed?
|
|
1426
1559
|
|
|
1427
|
-
clear_waiting_state_on_close
|
|
1428
|
-
clear_inactivity_state_on_reopen
|
|
1560
|
+
clear_waiting_state_on_close(attrs)
|
|
1561
|
+
clear_inactivity_state_on_reopen(attrs)
|
|
1429
1562
|
|
|
1430
1563
|
self.class.update!(**attrs)
|
|
1431
1564
|
|
|
@@ -1434,25 +1567,24 @@ module PlanMyStuff
|
|
|
1434
1567
|
reload
|
|
1435
1568
|
end
|
|
1436
1569
|
|
|
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).
|
|
1570
|
+
# Applies in-memory updates from an +update!+ kwargs hash. Top-level scalars go through their setters so
|
|
1571
|
+
# +@body_dirty+ and friends stay in sync; +metadata:+ is merged into +@metadata+ (top-level attrs assigned
|
|
1572
|
+
# directly, custom_fields merged key-by-key).
|
|
1442
1573
|
#
|
|
1443
1574
|
# @return [void]
|
|
1444
1575
|
#
|
|
1445
|
-
def apply_update_attrs
|
|
1576
|
+
def apply_update_attrs(attrs)
|
|
1446
1577
|
self.title = attrs[:title] if attrs.key?(:title)
|
|
1447
1578
|
self.state = attrs[:state].to_s if attrs.key?(:state)
|
|
1448
1579
|
self.labels = attrs[:labels] if attrs.key?(:labels)
|
|
1449
1580
|
self.body = attrs[:body] if attrs.key?(:body)
|
|
1581
|
+
self.issue_type = attrs[:issue_type] if attrs.key?(:issue_type)
|
|
1450
1582
|
@pending_assignees = attrs[:assignees] if attrs.key?(:assignees)
|
|
1451
|
-
apply_metadata_attrs
|
|
1583
|
+
apply_metadata_attrs(attrs[:metadata]) if attrs.key?(:metadata)
|
|
1452
1584
|
end
|
|
1453
1585
|
|
|
1454
1586
|
# @return [void]
|
|
1455
|
-
def apply_metadata_attrs
|
|
1587
|
+
def apply_metadata_attrs(md_hash)
|
|
1456
1588
|
return if md_hash.nil?
|
|
1457
1589
|
|
|
1458
1590
|
md_hash.each do |key, value|
|
|
@@ -1464,8 +1596,7 @@ module PlanMyStuff
|
|
|
1464
1596
|
end
|
|
1465
1597
|
end
|
|
1466
1598
|
|
|
1467
|
-
# Raises StaleObjectError if the remote issue has been modified
|
|
1468
|
-
# since this instance was loaded.
|
|
1599
|
+
# Raises StaleObjectError if the remote issue has been modified since this instance was loaded.
|
|
1469
1600
|
#
|
|
1470
1601
|
# @raise [PlanMyStuff::StaleObjectError]
|
|
1471
1602
|
#
|
|
@@ -1482,11 +1613,13 @@ module PlanMyStuff
|
|
|
1482
1613
|
return if remote_time.nil?
|
|
1483
1614
|
return if local_time && remote_time.to_i == local_time.to_i
|
|
1484
1615
|
|
|
1485
|
-
raise(
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1616
|
+
raise(
|
|
1617
|
+
PlanMyStuff::StaleObjectError.new(
|
|
1618
|
+
"Issue ##{number} has been modified remotely",
|
|
1619
|
+
local_updated_at: local_time,
|
|
1620
|
+
remote_updated_at: remote_time,
|
|
1621
|
+
),
|
|
1622
|
+
)
|
|
1490
1623
|
end
|
|
1491
1624
|
|
|
1492
1625
|
# @return [Array<String>]
|
|
@@ -1495,6 +1628,18 @@ module PlanMyStuff
|
|
|
1495
1628
|
raw.map { |label| label_name(label) }
|
|
1496
1629
|
end
|
|
1497
1630
|
|
|
1631
|
+
# Reads the +type.name+ from a REST issue payload. GitHub returns +"type"+ as a nested object (or +nil+) on
|
|
1632
|
+
# every issue response, so we descend into it for the human-readable name.
|
|
1633
|
+
#
|
|
1634
|
+
# @return [String, nil]
|
|
1635
|
+
#
|
|
1636
|
+
def extract_issue_type(github_issue)
|
|
1637
|
+
raw = safe_read_field(github_issue, :type)
|
|
1638
|
+
return if raw.nil?
|
|
1639
|
+
|
|
1640
|
+
safe_read_field(raw, :name)
|
|
1641
|
+
end
|
|
1642
|
+
|
|
1498
1643
|
# @return [String]
|
|
1499
1644
|
def label_name(label)
|
|
1500
1645
|
return label.name if label.respond_to?(:name)
|
|
@@ -1505,7 +1650,7 @@ module PlanMyStuff
|
|
|
1505
1650
|
|
|
1506
1651
|
# @return [Array<PlanMyStuff::Comment>]
|
|
1507
1652
|
def load_comments
|
|
1508
|
-
Comment.list(issue: self)
|
|
1653
|
+
PlanMyStuff::Comment.list(issue: self)
|
|
1509
1654
|
end
|
|
1510
1655
|
|
|
1511
1656
|
# @return [Hash{Symbol => Array}]
|
|
@@ -1513,8 +1658,7 @@ module PlanMyStuff
|
|
|
1513
1658
|
@links_cache ||= {}
|
|
1514
1659
|
end
|
|
1515
1660
|
|
|
1516
|
-
# Clears all memoized link readers. Called from +#hydrate_from_github+
|
|
1517
|
-
# and after any successful write.
|
|
1661
|
+
# Clears all memoized link readers. Called from +#hydrate_from_github+ and after any successful write.
|
|
1518
1662
|
#
|
|
1519
1663
|
# @return [void]
|
|
1520
1664
|
#
|
|
@@ -1522,16 +1666,16 @@ module PlanMyStuff
|
|
|
1522
1666
|
@links_cache = {}
|
|
1523
1667
|
end
|
|
1524
1668
|
|
|
1525
|
-
# Normalizes +target+ to a +PlanMyStuff::Link+ with the source
|
|
1526
|
-
# repo defaulting to self's repo.
|
|
1669
|
+
# Normalizes +target+ to a +PlanMyStuff::Link+ with the source repo defaulting to self's repo.
|
|
1527
1670
|
#
|
|
1528
1671
|
# @return [PlanMyStuff::Link]
|
|
1529
1672
|
#
|
|
1530
|
-
def build_link(target, type:)
|
|
1531
|
-
PlanMyStuff::Link.build(target, type: type, source_repo: repo&.full_name)
|
|
1673
|
+
def build_link!(target, type:)
|
|
1674
|
+
PlanMyStuff::Link.build!(target, type: type, source_repo: repo&.full_name)
|
|
1532
1675
|
end
|
|
1533
1676
|
|
|
1534
|
-
# @raise [PlanMyStuff::ValidationError]
|
|
1677
|
+
# @raise [PlanMyStuff::ValidationError] when target is the same issue (self-link)
|
|
1678
|
+
#
|
|
1535
1679
|
# @return [void]
|
|
1536
1680
|
#
|
|
1537
1681
|
def validate_not_self!(link)
|
|
@@ -1541,8 +1685,7 @@ module PlanMyStuff
|
|
|
1541
1685
|
raise(PlanMyStuff::ValidationError, 'Cannot link an issue to itself')
|
|
1542
1686
|
end
|
|
1543
1687
|
|
|
1544
|
-
# Reads +metadata.links+ and coerces any legacy hash entries to
|
|
1545
|
-
# +Link+ instances. Invalid entries are dropped.
|
|
1688
|
+
# Reads +metadata.links+ and coerces any legacy hash entries to +Link+ instances. Invalid entries are dropped.
|
|
1546
1689
|
#
|
|
1547
1690
|
# @return [Array<PlanMyStuff::Link>]
|
|
1548
1691
|
#
|
|
@@ -1550,14 +1693,13 @@ module PlanMyStuff
|
|
|
1550
1693
|
metadata.links.filter_map do |entry|
|
|
1551
1694
|
next entry if entry.is_a?(PlanMyStuff::Link)
|
|
1552
1695
|
|
|
1553
|
-
PlanMyStuff::Link.build(entry)
|
|
1696
|
+
PlanMyStuff::Link.build!(entry)
|
|
1554
1697
|
rescue ActiveModel::ValidationError, ArgumentError
|
|
1555
1698
|
next
|
|
1556
1699
|
end
|
|
1557
1700
|
end
|
|
1558
1701
|
|
|
1559
|
-
# Writes the given link array back to GitHub via
|
|
1560
|
-
# +Issue.update!+ and updates local metadata so subsequent
|
|
1702
|
+
# Writes the given link array back to GitHub via +Issue.update!+ and updates local metadata so subsequent
|
|
1561
1703
|
# in-memory reads see the change without a +reload+.
|
|
1562
1704
|
#
|
|
1563
1705
|
# @param new_links [Array<PlanMyStuff::Link>]
|
|
@@ -1574,9 +1716,8 @@ module PlanMyStuff
|
|
|
1574
1716
|
invalidate_links_cache!
|
|
1575
1717
|
end
|
|
1576
1718
|
|
|
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.
|
|
1719
|
+
# Walks every Projects V2 board this issue sits on and deletes the corresponding item. Paginates via
|
|
1720
|
+
# +LIST_ISSUE_PROJECT_ITEMS+ with a safety cap to avoid runaway loops. Delete failures propagate.
|
|
1580
1721
|
#
|
|
1581
1722
|
# @return [void]
|
|
1582
1723
|
#
|
|
@@ -1596,7 +1737,7 @@ module PlanMyStuff
|
|
|
1596
1737
|
nodes = Array.wrap(connection[:nodes])
|
|
1597
1738
|
|
|
1598
1739
|
nodes.each do |node|
|
|
1599
|
-
PlanMyStuff::ProjectItem.delete_item(
|
|
1740
|
+
PlanMyStuff::ProjectItem.delete_item!(
|
|
1600
1741
|
item_id: node[:id],
|
|
1601
1742
|
project_number: node.dig(:project, :number),
|
|
1602
1743
|
)
|
|
@@ -1609,9 +1750,8 @@ module PlanMyStuff
|
|
|
1609
1750
|
end
|
|
1610
1751
|
end
|
|
1611
1752
|
|
|
1612
|
-
# Attempts the reciprocal write on +link+'s target. On failure,
|
|
1613
|
-
#
|
|
1614
|
-
# consuming app can surface the half-written pairing.
|
|
1753
|
+
# Attempts the reciprocal write on +link+'s target. On failure, fires
|
|
1754
|
+
# +plan_my_stuff.issue.link_reciprocal_failed+ so the consuming app can surface the half-written pairing.
|
|
1615
1755
|
#
|
|
1616
1756
|
# @param link [PlanMyStuff::Link]
|
|
1617
1757
|
# @param user [Object, nil]
|
|
@@ -1666,27 +1806,26 @@ module PlanMyStuff
|
|
|
1666
1806
|
end
|
|
1667
1807
|
end
|
|
1668
1808
|
|
|
1669
|
-
# Normalizes +target+ to a fully-hydrated +Issue+ (fetching when
|
|
1670
|
-
#
|
|
1671
|
-
#
|
|
1672
|
-
# +#add_sub_issue!+ / +#remove_sub_issue!+ on the parent side.
|
|
1809
|
+
# Normalizes +target+ to a fully-hydrated +Issue+ (fetching when we only have a +Link+ or hash). Used by
|
|
1810
|
+
# +set_parent!+ / +remove_parent!+ to invert the call back through +#add_sub_issue!+ / +#remove_sub_issue!+ on
|
|
1811
|
+
# the parent side.
|
|
1673
1812
|
#
|
|
1674
1813
|
# @return [PlanMyStuff::Issue]
|
|
1675
1814
|
#
|
|
1676
1815
|
def resolve_target_issue(target, type:)
|
|
1677
1816
|
return target if target.is_a?(PlanMyStuff::Issue)
|
|
1678
1817
|
|
|
1679
|
-
link = build_link(target, type: type)
|
|
1818
|
+
link = build_link!(target, type: type)
|
|
1680
1819
|
PlanMyStuff::Issue.find(link.issue_number, repo: link.repo)
|
|
1681
1820
|
end
|
|
1682
1821
|
|
|
1683
|
-
# Shared path for add_sub_issue! / remove_sub_issue!. Builds the
|
|
1684
|
-
#
|
|
1822
|
+
# Shared path for add_sub_issue! / remove_sub_issue!. Builds the link, resolves the target, runs the
|
|
1823
|
+
# mutation, busts caches.
|
|
1685
1824
|
#
|
|
1686
1825
|
# @return [PlanMyStuff::Link]
|
|
1687
1826
|
#
|
|
1688
1827
|
def mutate_sub_issue!(target, method:, path:)
|
|
1689
|
-
link = build_link(target, type: :sub_ticket)
|
|
1828
|
+
link = build_link!(target, type: :sub_ticket)
|
|
1690
1829
|
validate_not_self!(link)
|
|
1691
1830
|
|
|
1692
1831
|
target_issue = resolve_target_issue(target, type: :sub_ticket)
|
|
@@ -1709,8 +1848,7 @@ module PlanMyStuff
|
|
|
1709
1848
|
"/repos/#{repo.organization}/#{repo.name}/issues/#{number}/sub_issues"
|
|
1710
1849
|
end
|
|
1711
1850
|
|
|
1712
|
-
# GitHub's REMOVE endpoint is +/sub_issue+ (singular), distinct
|
|
1713
|
-
# from the list/add path +/sub_issues+ (plural).
|
|
1851
|
+
# GitHub's REMOVE endpoint is +/sub_issue+ (singular), distinct from the list/add path +/sub_issues+ (plural).
|
|
1714
1852
|
#
|
|
1715
1853
|
# @return [String]
|
|
1716
1854
|
#
|
|
@@ -1718,10 +1856,9 @@ module PlanMyStuff
|
|
|
1718
1856
|
"/repos/#{repo.organization}/#{repo.name}/issues/#{number}/sub_issue"
|
|
1719
1857
|
end
|
|
1720
1858
|
|
|
1721
|
-
# Fetches one side of the native issue-dependency graph for self
|
|
1722
|
-
#
|
|
1723
|
-
#
|
|
1724
|
-
# instances (the dependency endpoint returns a slim projection).
|
|
1859
|
+
# Fetches one side of the native issue-dependency graph for self (+blocked_by+ or +blocking+) via REST.
|
|
1860
|
+
# Response is an array of Issue objects; we map through +Issue.find+ to get fully hydrated instances (the
|
|
1861
|
+
# dependency endpoint returns a slim projection).
|
|
1725
1862
|
#
|
|
1726
1863
|
# @param side [String] "blocked_by" or "blocking"
|
|
1727
1864
|
#
|
|
@@ -1767,8 +1904,9 @@ module PlanMyStuff
|
|
|
1767
1904
|
PlanMyStuff::Issue.find(the_dupe[:number], repo: the_dupe.dig(:repository, :nameWithOwner))
|
|
1768
1905
|
end
|
|
1769
1906
|
|
|
1770
|
-
# Resolves +target+ to an +Issue
|
|
1771
|
-
#
|
|
1907
|
+
# Resolves +target+ to an +Issue+.
|
|
1908
|
+
#
|
|
1909
|
+
# @raise [PlanMyStuff::ValidationError] when the duplicate target cannot be found
|
|
1772
1910
|
#
|
|
1773
1911
|
# @return [PlanMyStuff::Issue]
|
|
1774
1912
|
#
|
|
@@ -1782,7 +1920,7 @@ module PlanMyStuff
|
|
|
1782
1920
|
#
|
|
1783
1921
|
# @return [void]
|
|
1784
1922
|
#
|
|
1785
|
-
def merge_visibility_allowlist_onto(target)
|
|
1923
|
+
def merge_visibility_allowlist_onto!(target)
|
|
1786
1924
|
return if metadata.visibility_allowlist.blank?
|
|
1787
1925
|
|
|
1788
1926
|
merged = Array.wrap(target.metadata.visibility_allowlist) | Array.wrap(metadata.visibility_allowlist)
|
|
@@ -1793,7 +1931,7 @@ module PlanMyStuff
|
|
|
1793
1931
|
#
|
|
1794
1932
|
# @return [void]
|
|
1795
1933
|
#
|
|
1796
|
-
def merge_assignees_onto(target)
|
|
1934
|
+
def merge_assignees_onto!(target)
|
|
1797
1935
|
source_logins = extract_assignee_logins(github_response)
|
|
1798
1936
|
return if source_logins.empty?
|
|
1799
1937
|
|
|
@@ -1811,7 +1949,7 @@ module PlanMyStuff
|
|
|
1811
1949
|
end
|
|
1812
1950
|
|
|
1813
1951
|
# @return [void]
|
|
1814
|
-
def post_duplicate_back_pointer(target, user:)
|
|
1952
|
+
def post_duplicate_back_pointer!(target, user:)
|
|
1815
1953
|
visibility = target.metadata.visibility.presence || 'public'
|
|
1816
1954
|
PlanMyStuff::Comment.create!(
|
|
1817
1955
|
issue: target,
|
|
@@ -1821,11 +1959,11 @@ module PlanMyStuff
|
|
|
1821
1959
|
)
|
|
1822
1960
|
end
|
|
1823
1961
|
|
|
1824
|
-
# Closes self as a duplicate of +target+ via GitHub's native
|
|
1825
|
-
# +
|
|
1826
|
-
#
|
|
1827
|
-
#
|
|
1828
|
-
#
|
|
1962
|
+
# Closes self as a duplicate of +target+ via GitHub's native +closeIssue+ GraphQL mutation with
|
|
1963
|
+
# +stateReason: DUPLICATE+ and +duplicateIssueId+. The REST +duplicate_of+ body param is not recognized; only
|
|
1964
|
+
# this GraphQL path actually wires up +Issue#duplicateOf+ on the closed issue.
|
|
1965
|
+
#
|
|
1966
|
+
# @raise [PlanMyStuff::Error] when source or target issue has no node_id
|
|
1829
1967
|
#
|
|
1830
1968
|
# @return [void]
|
|
1831
1969
|
#
|