plan_my_stuff 0.6.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 +41 -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 +56 -3
- 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 +501 -322
- 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,23 +104,37 @@ 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)
|
|
83
117
|
store_etag_to_cache(client, resolved_repo, number, result, cache_writer: :write_issue)
|
|
84
118
|
|
|
119
|
+
link_body = visible_body_for(number, resolved_repo)
|
|
120
|
+
if link_body.present?
|
|
121
|
+
result = client.rest(
|
|
122
|
+
:update_issue,
|
|
123
|
+
resolved_repo,
|
|
124
|
+
number,
|
|
125
|
+
body: PlanMyStuff::MetadataParser.serialize!(issue_metadata.to_h, link_body),
|
|
126
|
+
)
|
|
127
|
+
store_etag_to_cache(client, resolved_repo, number, result, cache_writer: :write_issue)
|
|
128
|
+
end
|
|
129
|
+
|
|
85
130
|
issue = find(number, repo: resolved_repo)
|
|
86
131
|
|
|
87
132
|
if add_to_project.present?
|
|
88
|
-
project_number = resolve_project_number(add_to_project)
|
|
89
|
-
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)
|
|
90
135
|
end
|
|
91
136
|
|
|
92
|
-
Comment.create!(
|
|
137
|
+
PlanMyStuff::Comment.create!(
|
|
93
138
|
issue: issue,
|
|
94
139
|
body: body,
|
|
95
140
|
user: user,
|
|
@@ -122,6 +167,9 @@ module PlanMyStuff
|
|
|
122
167
|
# @param labels [Array<String>, nil]
|
|
123
168
|
# @param state [Symbol, nil] :open or :closed
|
|
124
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.)
|
|
125
173
|
#
|
|
126
174
|
# @return [Object]
|
|
127
175
|
#
|
|
@@ -133,25 +181,28 @@ module PlanMyStuff
|
|
|
133
181
|
metadata: nil,
|
|
134
182
|
labels: nil,
|
|
135
183
|
state: nil,
|
|
136
|
-
assignees: nil
|
|
184
|
+
assignees: nil,
|
|
185
|
+
issue_type: ISSUE_TYPE_UNCHANGED
|
|
137
186
|
)
|
|
138
187
|
client = PlanMyStuff.client
|
|
139
|
-
resolved_repo = client.resolve_repo(repo)
|
|
188
|
+
resolved_repo = client.resolve_repo!(repo)
|
|
140
189
|
|
|
141
190
|
options = {}
|
|
142
191
|
options[:title] = title unless title.nil?
|
|
143
192
|
options[:labels] = labels unless labels.nil?
|
|
144
193
|
options[:state] = state.to_s unless state.nil?
|
|
145
194
|
options[:assignees] = Array.wrap(assignees) unless assignees.nil?
|
|
195
|
+
options[:type] = resolve_issue_type!(issue_type) unless issue_type.equal?(ISSUE_TYPE_UNCHANGED)
|
|
146
196
|
|
|
147
197
|
case metadata
|
|
148
198
|
when PlanMyStuff::IssueMetadata
|
|
149
199
|
metadata.validate_custom_fields!
|
|
150
|
-
options[:body] =
|
|
200
|
+
options[:body] =
|
|
201
|
+
PlanMyStuff::MetadataParser.serialize!(metadata.to_h, visible_body_for(number, resolved_repo))
|
|
151
202
|
when Hash
|
|
152
203
|
current = client.rest(:issue, resolved_repo, number)
|
|
153
204
|
current_body = current.respond_to?(:body) ? current.body : current[:body]
|
|
154
|
-
parsed = MetadataParser.parse(current_body)
|
|
205
|
+
parsed = PlanMyStuff::MetadataParser.parse(current_body)
|
|
155
206
|
existing_metadata = parsed[:metadata]
|
|
156
207
|
|
|
157
208
|
merged_custom_fields = (existing_metadata[:custom_fields] || {}).merge(metadata[:custom_fields] || {})
|
|
@@ -162,10 +213,11 @@ module PlanMyStuff
|
|
|
162
213
|
merged_custom_fields,
|
|
163
214
|
).validate!
|
|
164
215
|
|
|
165
|
-
options[:body] =
|
|
216
|
+
options[:body] =
|
|
217
|
+
PlanMyStuff::MetadataParser.serialize!(existing_metadata, visible_body_for(number, resolved_repo))
|
|
166
218
|
end
|
|
167
219
|
|
|
168
|
-
update_body_comment(number, resolved_repo, body) if body
|
|
220
|
+
update_body_comment!(number, resolved_repo, body) if body
|
|
169
221
|
|
|
170
222
|
return if options.none?
|
|
171
223
|
|
|
@@ -176,6 +228,8 @@ module PlanMyStuff
|
|
|
176
228
|
|
|
177
229
|
# Finds a single GitHub issue by number and parses its PMS metadata.
|
|
178
230
|
#
|
|
231
|
+
# @raise [Octokit::NotFound] when the issue number resolves to a pull request
|
|
232
|
+
#
|
|
179
233
|
# @param number [Integer]
|
|
180
234
|
# @param repo [Symbol, String, nil] defaults to config.default_repo
|
|
181
235
|
#
|
|
@@ -183,7 +237,7 @@ module PlanMyStuff
|
|
|
183
237
|
#
|
|
184
238
|
def find(number, repo: nil)
|
|
185
239
|
client = PlanMyStuff.client
|
|
186
|
-
resolved_repo = client.resolve_repo(repo)
|
|
240
|
+
resolved_repo = client.resolve_repo!(repo)
|
|
187
241
|
|
|
188
242
|
github_issue =
|
|
189
243
|
fetch_with_etag_cache(
|
|
@@ -195,7 +249,14 @@ module PlanMyStuff
|
|
|
195
249
|
cache_writer: :write_issue,
|
|
196
250
|
)
|
|
197
251
|
|
|
198
|
-
|
|
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
|
|
199
260
|
raise(Octokit::NotFound, { method: 'GET', url: "repos/#{resolved_repo}/issues/#{number}" })
|
|
200
261
|
end
|
|
201
262
|
|
|
@@ -214,10 +275,10 @@ module PlanMyStuff
|
|
|
214
275
|
#
|
|
215
276
|
def list(repo: nil, state: :open, labels: [], page: 1, per_page: 25)
|
|
216
277
|
client = PlanMyStuff.client
|
|
217
|
-
resolved_repo = client.resolve_repo(repo)
|
|
278
|
+
resolved_repo = client.resolve_repo!(repo)
|
|
218
279
|
|
|
219
280
|
params = { state: state.to_s, page: page, per_page: per_page }
|
|
220
|
-
params[:labels] = labels.sort.join(',') if labels.
|
|
281
|
+
params[:labels] = labels.sort.join(',') if labels.present?
|
|
221
282
|
|
|
222
283
|
cached = PlanMyStuff::Cache.read_list(:issue, resolved_repo, params)
|
|
223
284
|
request_options = cached ? params.merge(headers: { 'If-None-Match' => cached[:etag] }) : params
|
|
@@ -233,8 +294,119 @@ module PlanMyStuff
|
|
|
233
294
|
filtered.map { |gi| build(gi, repo: resolved_repo) }
|
|
234
295
|
end
|
|
235
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
|
+
|
|
236
347
|
private
|
|
237
348
|
|
|
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.
|
|
396
|
+
#
|
|
397
|
+
# @param number [Integer]
|
|
398
|
+
# @param repo [String] full repo path, e.g. +"BrandsInsurance/Element"+
|
|
399
|
+
#
|
|
400
|
+
# @return [String]
|
|
401
|
+
#
|
|
402
|
+
def visible_body_for(number, repo)
|
|
403
|
+
prefix = PlanMyStuff.configuration.issues_url_prefix
|
|
404
|
+
return '' if prefix.blank? || number.blank?
|
|
405
|
+
|
|
406
|
+
url = "#{prefix.to_s.chomp('/')}/#{number}?repo=#{URI.encode_www_form_component(repo)}"
|
|
407
|
+
"[#{repo}##{number}](#{url})"
|
|
408
|
+
end
|
|
409
|
+
|
|
238
410
|
# Hydrates an Issue from a GitHub API response.
|
|
239
411
|
#
|
|
240
412
|
# @param github_issue [Object] Octokit issue response
|
|
@@ -248,16 +420,21 @@ module PlanMyStuff
|
|
|
248
420
|
issue
|
|
249
421
|
end
|
|
250
422
|
|
|
423
|
+
# @raise [ArgumentError] when add_to_project is true but no default_project_number is configured
|
|
424
|
+
#
|
|
251
425
|
# @return [Integer]
|
|
252
|
-
|
|
426
|
+
#
|
|
427
|
+
def resolve_project_number!(add_to_project)
|
|
253
428
|
return add_to_project unless add_to_project == true
|
|
254
429
|
|
|
255
430
|
PlanMyStuff.configuration.default_project_number ||
|
|
256
431
|
raise(ArgumentError, 'add_to_project: true requires config.default_project_number to be set')
|
|
257
432
|
end
|
|
258
433
|
|
|
259
|
-
# Finds the first PMS comment on an issue and updates its body content,
|
|
260
|
-
#
|
|
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
|
|
261
438
|
#
|
|
262
439
|
# @param number [Integer] issue number
|
|
263
440
|
# @param resolved_repo [String] resolved repo path
|
|
@@ -265,7 +442,7 @@ module PlanMyStuff
|
|
|
265
442
|
#
|
|
266
443
|
# @return [void]
|
|
267
444
|
#
|
|
268
|
-
def update_body_comment(number, resolved_repo, new_body)
|
|
445
|
+
def update_body_comment!(number, resolved_repo, new_body)
|
|
269
446
|
issue = find(number, repo: resolved_repo)
|
|
270
447
|
body_comment = issue.body_comment
|
|
271
448
|
raise(PlanMyStuff::Error, "No body comment found on issue ##{number}") if body_comment.nil?
|
|
@@ -281,13 +458,11 @@ module PlanMyStuff
|
|
|
281
458
|
|
|
282
459
|
# @param value [PlanMyStuff::Repo, Symbol, String, nil]
|
|
283
460
|
def repo=(value)
|
|
284
|
-
super(value.present? ? PlanMyStuff::Repo.resolve(value) : nil)
|
|
461
|
+
super(value.present? ? PlanMyStuff::Repo.resolve!(value) : nil)
|
|
285
462
|
end
|
|
286
463
|
|
|
287
|
-
# Assigning a new body marks the instance dirty so the next
|
|
288
|
-
#
|
|
289
|
-
# assignments are reflected by +#body+ until persisted or
|
|
290
|
-
# 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.
|
|
291
466
|
#
|
|
292
467
|
# @param value [String]
|
|
293
468
|
#
|
|
@@ -298,28 +473,48 @@ module PlanMyStuff
|
|
|
298
473
|
@body_dirty = true
|
|
299
474
|
end
|
|
300
475
|
|
|
301
|
-
# @return [
|
|
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.
|
|
479
|
+
def user_link
|
|
480
|
+
prefix = PlanMyStuff.configuration.issues_url_prefix
|
|
481
|
+
return if prefix.blank? || number.blank?
|
|
482
|
+
|
|
483
|
+
base = "#{prefix.to_s.chomp('/')}/#{number}"
|
|
484
|
+
return base if repo.blank?
|
|
485
|
+
|
|
486
|
+
"#{base}?repo=#{URI.encode_www_form_component(repo.full_name)}"
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# @return [Array<PlanMyStuff::Approval>] all required approvers (pending + approved + rejected)
|
|
302
490
|
def approvers
|
|
303
491
|
metadata.approvals
|
|
304
492
|
end
|
|
305
493
|
|
|
306
|
-
# @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)
|
|
307
496
|
def pending_approvals
|
|
308
497
|
approvers.select(&:pending?)
|
|
309
498
|
end
|
|
310
499
|
|
|
500
|
+
# @return [Array<PlanMyStuff::Approval>] approvers who have rejected
|
|
501
|
+
def rejected_approvals
|
|
502
|
+
approvers.select(&:rejected?)
|
|
503
|
+
end
|
|
504
|
+
|
|
311
505
|
# @return [Boolean] true when at least one approver is required on this issue
|
|
312
506
|
def approvals_required?
|
|
313
|
-
approvers.
|
|
507
|
+
approvers.present?
|
|
314
508
|
end
|
|
315
509
|
|
|
316
|
-
# @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.
|
|
317
512
|
def fully_approved?
|
|
318
|
-
approvals_required? &&
|
|
513
|
+
approvals_required? && approvers.all?(&:approved?)
|
|
319
514
|
end
|
|
320
515
|
|
|
321
|
-
# Adds user IDs to this issue's visibility allowlist (non-support
|
|
322
|
-
#
|
|
516
|
+
# Adds user IDs to this issue's visibility allowlist (non-support users whose ID is in the allowlist can see
|
|
517
|
+
# internal comments).
|
|
323
518
|
#
|
|
324
519
|
# Fires +plan_my_stuff.issue.viewers_added+.
|
|
325
520
|
#
|
|
@@ -328,9 +523,9 @@ module PlanMyStuff
|
|
|
328
523
|
#
|
|
329
524
|
# @return [Array<Integer>] the new allowlist
|
|
330
525
|
#
|
|
331
|
-
def add_viewers(user_ids:, user: nil)
|
|
526
|
+
def add_viewers!(user_ids:, user: nil)
|
|
332
527
|
ids = Array.wrap(user_ids)
|
|
333
|
-
modify_allowlist { |allowlist| allowlist | ids }
|
|
528
|
+
modify_allowlist! { |allowlist| allowlist | ids }
|
|
334
529
|
PlanMyStuff::Notifications.instrument('issue.viewers_added', self, user: user, user_ids: ids)
|
|
335
530
|
metadata.visibility_allowlist
|
|
336
531
|
end
|
|
@@ -344,33 +539,30 @@ module PlanMyStuff
|
|
|
344
539
|
#
|
|
345
540
|
# @return [Array<Integer>] the new allowlist
|
|
346
541
|
#
|
|
347
|
-
def remove_viewers(user_ids:, user: nil)
|
|
542
|
+
def remove_viewers!(user_ids:, user: nil)
|
|
348
543
|
ids = Array.wrap(user_ids)
|
|
349
|
-
modify_allowlist { |allowlist| allowlist - ids }
|
|
544
|
+
modify_allowlist! { |allowlist| allowlist - ids }
|
|
350
545
|
PlanMyStuff::Notifications.instrument('issue.viewers_removed', self, user: user, user_ids: ids)
|
|
351
546
|
metadata.visibility_allowlist
|
|
352
547
|
end
|
|
353
548
|
|
|
354
|
-
# Adds approvers to this issue's required-approvals list. Idempotent:
|
|
355
|
-
#
|
|
549
|
+
# Adds approvers to this issue's required-approvals list. Idempotent: users already present are no-ops. Only
|
|
550
|
+
# support users may call this.
|
|
356
551
|
#
|
|
357
|
-
# Fires +plan_my_stuff.issue.approval_requested+ when any user is
|
|
358
|
-
#
|
|
359
|
-
# (+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
|
|
360
554
|
# out of a fully-approved state.
|
|
361
555
|
#
|
|
362
556
|
# @param user_ids [Array<Integer>, Integer]
|
|
363
557
|
# @param user [Object, nil] actor; must be a support user
|
|
364
558
|
#
|
|
365
|
-
# @raise [PlanMyStuff::AuthorizationError] if +user+ is not support
|
|
366
|
-
#
|
|
367
559
|
# @return [Array<PlanMyStuff::Approval>] newly-added approvals (empty when all were duplicates)
|
|
368
560
|
#
|
|
369
561
|
def request_approvals!(user_ids:, user: nil)
|
|
370
562
|
guard_support!(user)
|
|
371
563
|
ids = Array.wrap(user_ids).map(&:to_i)
|
|
372
564
|
|
|
373
|
-
just_added, was_fully_approved = modify_approvals do |current|
|
|
565
|
+
just_added, was_fully_approved = modify_approvals! do |current|
|
|
374
566
|
existing_ids = current.map(&:user_id)
|
|
375
567
|
new_ids = ids - existing_ids
|
|
376
568
|
added = new_ids.map { |id| PlanMyStuff::Approval.new(user_id: id, status: 'pending') }
|
|
@@ -381,25 +573,21 @@ module PlanMyStuff
|
|
|
381
573
|
just_added
|
|
382
574
|
end
|
|
383
575
|
|
|
384
|
-
# Removes approvers from this issue's required-approvals list. Only
|
|
385
|
-
#
|
|
386
|
-
#
|
|
387
|
-
#
|
|
388
|
-
# Removing the last approver never fires aggregate events (issue no
|
|
389
|
-
# 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?+).
|
|
390
580
|
#
|
|
391
581
|
# @param user_ids [Array<Integer>, Integer]
|
|
392
582
|
# @param user [Object, nil] actor; must be a support user
|
|
393
583
|
#
|
|
394
|
-
# @raise [PlanMyStuff::AuthorizationError] if +user+ is not support
|
|
395
|
-
#
|
|
396
584
|
# @return [Array<PlanMyStuff::Approval>] removed approval records
|
|
397
585
|
#
|
|
398
586
|
def remove_approvers!(user_ids:, user: nil)
|
|
399
587
|
guard_support!(user)
|
|
400
588
|
ids = Array.wrap(user_ids).map(&:to_i)
|
|
401
589
|
|
|
402
|
-
just_removed, was_fully_approved = modify_approvals do |current|
|
|
590
|
+
just_removed, was_fully_approved = modify_approvals! do |current|
|
|
403
591
|
removed = current.select { |a| ids.include?(a.user_id) }
|
|
404
592
|
[current - removed, removed]
|
|
405
593
|
end
|
|
@@ -408,27 +596,27 @@ module PlanMyStuff
|
|
|
408
596
|
just_removed
|
|
409
597
|
end
|
|
410
598
|
|
|
411
|
-
# Flips the caller's approval from +pending+
|
|
412
|
-
#
|
|
413
|
-
# +plan_my_stuff.issue.
|
|
414
|
-
# completes the approval set, +plan_my_stuff.issue.all_approved+.
|
|
415
|
-
#
|
|
416
|
-
# @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+.
|
|
417
602
|
#
|
|
418
603
|
# @raise [PlanMyStuff::ValidationError] when the caller is not in the approvers list or is already approved
|
|
419
604
|
#
|
|
605
|
+
# @param user [Object, Integer] actor; must resolve to an approver
|
|
606
|
+
#
|
|
420
607
|
# @return [PlanMyStuff::Approval] the updated approval
|
|
421
608
|
#
|
|
422
609
|
def approve!(user:)
|
|
423
610
|
actor_id = resolve_actor_id!(user)
|
|
424
611
|
|
|
425
|
-
just_approved, was_fully_approved = modify_approvals do |current|
|
|
612
|
+
just_approved, was_fully_approved = modify_approvals! do |current|
|
|
426
613
|
approval = current.find { |a| a.user_id == actor_id }
|
|
427
614
|
raise(PlanMyStuff::ValidationError, "User #{actor_id} is not in the approvers list") if approval.nil?
|
|
428
|
-
raise(PlanMyStuff::ValidationError, "User #{actor_id} has already approved")
|
|
615
|
+
raise(PlanMyStuff::ValidationError, "User #{actor_id} has already approved") if approval.approved?
|
|
429
616
|
|
|
430
617
|
approval.status = 'approved'
|
|
431
618
|
approval.approved_at = Time.current
|
|
619
|
+
approval.rejected_at = nil
|
|
432
620
|
[current, approval]
|
|
433
621
|
end
|
|
434
622
|
|
|
@@ -436,21 +624,55 @@ module PlanMyStuff
|
|
|
436
624
|
just_approved
|
|
437
625
|
end
|
|
438
626
|
|
|
439
|
-
# Flips
|
|
440
|
-
#
|
|
441
|
-
#
|
|
442
|
-
#
|
|
443
|
-
# +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+).
|
|
444
631
|
#
|
|
445
|
-
#
|
|
446
|
-
# drops the issue out of +fully_approved?+,
|
|
447
|
-
# +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
|
|
448
633
|
#
|
|
449
|
-
# @param user [Object, Integer]
|
|
450
|
-
#
|
|
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.
|
|
451
670
|
#
|
|
452
671
|
# @raise [PlanMyStuff::AuthorizationError] when a non-support caller targets another user
|
|
453
|
-
# @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
|
|
454
676
|
#
|
|
455
677
|
# @return [PlanMyStuff::Approval] the updated approval
|
|
456
678
|
#
|
|
@@ -460,36 +682,39 @@ module PlanMyStuff
|
|
|
460
682
|
target_id = target_user_id&.to_i || actor_id
|
|
461
683
|
|
|
462
684
|
if !caller_is_support && target_id != actor_id
|
|
463
|
-
raise(PlanMyStuff::AuthorizationError, "Only support users may revoke another user's
|
|
685
|
+
raise(PlanMyStuff::AuthorizationError, "Only support users may revoke another user's response")
|
|
464
686
|
end
|
|
465
687
|
|
|
466
|
-
|
|
688
|
+
revoked_from = nil
|
|
689
|
+
just_revoked, was_fully_approved = modify_approvals! do |current|
|
|
467
690
|
approval = current.find { |a| a.user_id == target_id }
|
|
468
691
|
raise(PlanMyStuff::ValidationError, "User #{target_id} is not in the approvers list") if approval.nil?
|
|
469
|
-
|
|
692
|
+
if approval.pending?
|
|
693
|
+
raise(PlanMyStuff::ValidationError, "User #{target_id} has not responded — nothing to revoke")
|
|
694
|
+
end
|
|
470
695
|
|
|
696
|
+
revoked_from = approval.status
|
|
471
697
|
approval.status = 'pending'
|
|
472
698
|
approval.approved_at = nil
|
|
699
|
+
approval.rejected_at = nil
|
|
473
700
|
[current, approval]
|
|
474
701
|
end
|
|
475
702
|
|
|
703
|
+
event = (revoked_from == 'approved') ? :approval_revoked : :rejection_revoked
|
|
476
704
|
finish_state_change(
|
|
477
|
-
|
|
705
|
+
event,
|
|
478
706
|
just_revoked,
|
|
479
707
|
user: user,
|
|
480
708
|
was_fully_approved: was_fully_approved,
|
|
481
|
-
trigger: :revoked,
|
|
709
|
+
trigger: (event == :approval_revoked) ? :revoked : nil,
|
|
482
710
|
)
|
|
483
711
|
just_revoked
|
|
484
712
|
end
|
|
485
713
|
|
|
486
|
-
# Marks the issue as waiting on an end-user reply. Sets
|
|
487
|
-
# +metadata.
|
|
488
|
-
# +
|
|
489
|
-
# +
|
|
490
|
-
# +Comment.create!+ when a support user posts a comment with
|
|
491
|
-
# +waiting_on_reply: true+, and from the +Issues::WaitingsController+
|
|
492
|
-
# 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.
|
|
493
718
|
#
|
|
494
719
|
# @param user [Object, nil] actor for the label notification event
|
|
495
720
|
#
|
|
@@ -500,24 +725,22 @@ module PlanMyStuff
|
|
|
500
725
|
label = PlanMyStuff.configuration.waiting_on_user_label
|
|
501
726
|
|
|
502
727
|
PlanMyStuff::Label.ensure!(repo: repo, name: label)
|
|
503
|
-
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)
|
|
504
729
|
|
|
505
730
|
self.class.update!(
|
|
506
731
|
number: number,
|
|
507
732
|
repo: repo,
|
|
508
733
|
metadata: {
|
|
509
|
-
waiting_on_user_at: now
|
|
734
|
+
waiting_on_user_at: PlanMyStuff.format_time(now),
|
|
510
735
|
next_reminder_at: format_next_reminder_at(from: now),
|
|
511
736
|
},
|
|
512
737
|
)
|
|
513
738
|
reload
|
|
514
739
|
end
|
|
515
740
|
|
|
516
|
-
# Clears the waiting-on-user state: removes the label, clears
|
|
517
|
-
# +metadata.
|
|
518
|
-
#
|
|
519
|
-
# is still active. No-ops if the issue is not currently waiting
|
|
520
|
-
# 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.
|
|
521
744
|
#
|
|
522
745
|
# @return [self]
|
|
523
746
|
#
|
|
@@ -525,25 +748,22 @@ module PlanMyStuff
|
|
|
525
748
|
label = PlanMyStuff.configuration.waiting_on_user_label
|
|
526
749
|
return self if metadata.waiting_on_user_at.nil? && labels.exclude?(label)
|
|
527
750
|
|
|
528
|
-
PlanMyStuff::Label.remove(issue: self, labels: [label]) if labels.include?(label)
|
|
751
|
+
PlanMyStuff::Label.remove!(issue: self, labels: [label]) if labels.include?(label)
|
|
529
752
|
|
|
530
753
|
self.class.update!(
|
|
531
754
|
number: number,
|
|
532
755
|
repo: repo,
|
|
533
756
|
metadata: {
|
|
534
757
|
waiting_on_user_at: nil,
|
|
535
|
-
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,
|
|
536
759
|
},
|
|
537
760
|
)
|
|
538
761
|
reload
|
|
539
762
|
end
|
|
540
763
|
|
|
541
|
-
# Reopens an issue that was auto-closed by the inactivity sweep,
|
|
542
|
-
#
|
|
543
|
-
# +
|
|
544
|
-
# comment. Does not emit the regular +issue.reopened+ event \-
|
|
545
|
-
# subscribers that specifically care about this flow subscribe
|
|
546
|
-
# 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.
|
|
547
767
|
#
|
|
548
768
|
# @param comment [PlanMyStuff::Comment] the reopening comment
|
|
549
769
|
# @param user [Object, nil] actor for the notification event
|
|
@@ -552,7 +772,7 @@ module PlanMyStuff
|
|
|
552
772
|
#
|
|
553
773
|
def reopen_by_reply!(comment:, user: nil)
|
|
554
774
|
inactive_label = PlanMyStuff.configuration.user_inactive_label
|
|
555
|
-
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)
|
|
556
776
|
|
|
557
777
|
self.class.update!(
|
|
558
778
|
number: number,
|
|
@@ -571,14 +791,12 @@ module PlanMyStuff
|
|
|
571
791
|
self
|
|
572
792
|
end
|
|
573
793
|
|
|
574
|
-
# Tags the issue with the configured +archived_label+, removes it
|
|
575
|
-
#
|
|
576
|
-
#
|
|
577
|
-
# 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.
|
|
578
797
|
#
|
|
579
|
-
# No-op (no network calls, no event) when the issue is already
|
|
580
|
-
# archived
|
|
581
|
-
# 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).
|
|
582
800
|
#
|
|
583
801
|
# @param now [Time] clock reference for +metadata.archived_at+
|
|
584
802
|
#
|
|
@@ -594,11 +812,11 @@ module PlanMyStuff
|
|
|
594
812
|
self.class.update!(
|
|
595
813
|
number: number,
|
|
596
814
|
repo: repo,
|
|
597
|
-
metadata: { archived_at: now
|
|
815
|
+
metadata: { archived_at: PlanMyStuff.format_time(now) },
|
|
598
816
|
)
|
|
599
817
|
|
|
600
818
|
PlanMyStuff::Label.ensure!(repo: repo, name: label)
|
|
601
|
-
PlanMyStuff::Label.add(issue: self, labels: [label])
|
|
819
|
+
PlanMyStuff::Label.add!(issue: self, labels: [label])
|
|
602
820
|
|
|
603
821
|
remove_from_all_projects!
|
|
604
822
|
|
|
@@ -614,13 +832,9 @@ module PlanMyStuff
|
|
|
614
832
|
self
|
|
615
833
|
end
|
|
616
834
|
|
|
617
|
-
# Persists the issue. Creates if new, otherwise performs a full
|
|
618
|
-
#
|
|
619
|
-
#
|
|
620
|
-
# the last load, also rewrites the PMS body comment. Always
|
|
621
|
-
# reloads afterwards.
|
|
622
|
-
#
|
|
623
|
-
# @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.
|
|
624
838
|
#
|
|
625
839
|
# @return [self]
|
|
626
840
|
#
|
|
@@ -635,6 +849,7 @@ module PlanMyStuff
|
|
|
635
849
|
metadata: metadata.custom_fields.to_h,
|
|
636
850
|
visibility: metadata.visibility,
|
|
637
851
|
visibility_allowlist: Array.wrap(metadata.visibility_allowlist),
|
|
852
|
+
issue_type: issue_type,
|
|
638
853
|
)
|
|
639
854
|
hydrate_from_issue(created)
|
|
640
855
|
else
|
|
@@ -646,20 +861,16 @@ module PlanMyStuff
|
|
|
646
861
|
self
|
|
647
862
|
end
|
|
648
863
|
|
|
649
|
-
# Applies +attrs+ to this instance in-memory then calls +save!+.
|
|
650
|
-
#
|
|
651
|
-
#
|
|
652
|
-
# merged into the existing +metadata+ (top-level attributes
|
|
653
|
-
# 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).
|
|
654
867
|
#
|
|
655
868
|
# @param user [Object, nil] actor for notification events
|
|
656
869
|
#
|
|
657
|
-
# @raise [PlanMyStuff::StaleObjectError] if remote updated_at differs from local
|
|
658
|
-
#
|
|
659
870
|
# @return [self]
|
|
660
871
|
#
|
|
661
872
|
def update!(user: nil, skip_notification: false, **attrs)
|
|
662
|
-
apply_update_attrs
|
|
873
|
+
apply_update_attrs(attrs)
|
|
663
874
|
save!(user: user, skip_notification: skip_notification)
|
|
664
875
|
end
|
|
665
876
|
|
|
@@ -715,9 +926,8 @@ module PlanMyStuff
|
|
|
715
926
|
pms_comments.find { |c| c.metadata.issue_body? }
|
|
716
927
|
end
|
|
717
928
|
|
|
718
|
-
# Returns the issue body content. For PMS issues, this is the body
|
|
719
|
-
#
|
|
720
|
-
# 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.
|
|
721
931
|
#
|
|
722
932
|
# @return [String, nil]
|
|
723
933
|
#
|
|
@@ -743,13 +953,12 @@ module PlanMyStuff
|
|
|
743
953
|
if pms_issue?
|
|
744
954
|
metadata.visible_to?(user)
|
|
745
955
|
else
|
|
746
|
-
UserResolver.support?(UserResolver.resolve(user))
|
|
956
|
+
PlanMyStuff::UserResolver.support?(PlanMyStuff::UserResolver.resolve(user))
|
|
747
957
|
end
|
|
748
958
|
end
|
|
749
959
|
|
|
750
|
-
# Lazy-memoized array of +Issue+ objects for +:related+ links.
|
|
751
|
-
#
|
|
752
|
-
# 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.
|
|
753
962
|
#
|
|
754
963
|
# @return [Array<PlanMyStuff::Issue>]
|
|
755
964
|
#
|
|
@@ -757,10 +966,8 @@ module PlanMyStuff
|
|
|
757
966
|
links_cache[:related] ||= fetch_related
|
|
758
967
|
end
|
|
759
968
|
|
|
760
|
-
# Adds a +:related+ link to +target+ and, unless this call is
|
|
761
|
-
#
|
|
762
|
-
# the pairing is symmetric. Dedups on
|
|
763
|
-
# +(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.
|
|
764
971
|
#
|
|
765
972
|
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
766
973
|
# @param user [Object, nil] actor for notification events
|
|
@@ -769,7 +976,7 @@ module PlanMyStuff
|
|
|
769
976
|
# @return [PlanMyStuff::Link]
|
|
770
977
|
#
|
|
771
978
|
def add_related!(target, user: nil, reciprocal: false)
|
|
772
|
-
link = build_link(target, type: :related)
|
|
979
|
+
link = build_link!(target, type: :related)
|
|
773
980
|
validate_not_self!(link)
|
|
774
981
|
|
|
775
982
|
existing = current_links
|
|
@@ -783,9 +990,8 @@ module PlanMyStuff
|
|
|
783
990
|
link
|
|
784
991
|
end
|
|
785
992
|
|
|
786
|
-
# Removes a +:related+ link to +target+ and, unless this call is
|
|
787
|
-
#
|
|
788
|
-
# 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.
|
|
789
995
|
#
|
|
790
996
|
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
791
997
|
# @param user [Object, nil]
|
|
@@ -794,7 +1000,7 @@ module PlanMyStuff
|
|
|
794
1000
|
# @return [PlanMyStuff::Link]
|
|
795
1001
|
#
|
|
796
1002
|
def remove_related!(target, user: nil, reciprocal: false)
|
|
797
|
-
link = build_link(target, type: :related)
|
|
1003
|
+
link = build_link!(target, type: :related)
|
|
798
1004
|
validate_not_self!(link)
|
|
799
1005
|
|
|
800
1006
|
existing = current_links
|
|
@@ -808,8 +1014,7 @@ module PlanMyStuff
|
|
|
808
1014
|
link
|
|
809
1015
|
end
|
|
810
1016
|
|
|
811
|
-
# Lazy-memoized parent issue via GitHub's native sub-issues API.
|
|
812
|
-
# 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.
|
|
813
1018
|
#
|
|
814
1019
|
# @return [PlanMyStuff::Issue, nil]
|
|
815
1020
|
#
|
|
@@ -827,8 +1032,7 @@ module PlanMyStuff
|
|
|
827
1032
|
links_cache[:sub_tickets] ||= fetch_sub_tickets
|
|
828
1033
|
end
|
|
829
1034
|
|
|
830
|
-
# Adds +target+ as a sub-issue of self via
|
|
831
|
-
# +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;
|
|
832
1036
|
# notifications are handled by GitHub itself.
|
|
833
1037
|
#
|
|
834
1038
|
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
@@ -839,8 +1043,7 @@ module PlanMyStuff
|
|
|
839
1043
|
mutate_sub_issue!(target, method: :post, path: sub_issues_path)
|
|
840
1044
|
end
|
|
841
1045
|
|
|
842
|
-
# Removes +target+ as a sub-issue of self via
|
|
843
|
-
# +DELETE /issues/{number}/sub_issue+ (singular).
|
|
1046
|
+
# Removes +target+ as a sub-issue of self via +DELETE /issues/{number}/sub_issue+ (singular).
|
|
844
1047
|
#
|
|
845
1048
|
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
846
1049
|
#
|
|
@@ -850,9 +1053,8 @@ module PlanMyStuff
|
|
|
850
1053
|
mutate_sub_issue!(target, method: :delete, path: remove_sub_issue_path)
|
|
851
1054
|
end
|
|
852
1055
|
|
|
853
|
-
# Makes +target+ the parent of self. If self already has a parent,
|
|
854
|
-
#
|
|
855
|
-
# +: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.
|
|
856
1058
|
#
|
|
857
1059
|
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
858
1060
|
#
|
|
@@ -865,11 +1067,11 @@ module PlanMyStuff
|
|
|
865
1067
|
target_issue.add_sub_issue!(self)
|
|
866
1068
|
invalidate_links_cache!
|
|
867
1069
|
|
|
868
|
-
build_link(target_issue, type: :parent)
|
|
1070
|
+
build_link!(target_issue, type: :parent)
|
|
869
1071
|
end
|
|
870
1072
|
|
|
871
|
-
# Detaches self from its current parent, if any. Returns the
|
|
872
|
-
#
|
|
1073
|
+
# Detaches self from its current parent, if any. Returns the +Link+ that was removed, or nil when there was no
|
|
1074
|
+
# parent.
|
|
873
1075
|
#
|
|
874
1076
|
# @return [PlanMyStuff::Link, nil]
|
|
875
1077
|
#
|
|
@@ -880,12 +1082,11 @@ module PlanMyStuff
|
|
|
880
1082
|
current.remove_sub_issue!(self)
|
|
881
1083
|
invalidate_links_cache!
|
|
882
1084
|
|
|
883
|
-
build_link(current, type: :parent)
|
|
1085
|
+
build_link!(current, type: :parent)
|
|
884
1086
|
end
|
|
885
1087
|
|
|
886
|
-
# Lazy-memoized issues that block self (i.e. self is blocked by
|
|
887
|
-
#
|
|
888
|
-
# 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.
|
|
889
1090
|
#
|
|
890
1091
|
# @return [Array<PlanMyStuff::Issue>]
|
|
891
1092
|
#
|
|
@@ -901,15 +1102,14 @@ module PlanMyStuff
|
|
|
901
1102
|
links_cache[:blocking] ||= fetch_dependencies('blocking')
|
|
902
1103
|
end
|
|
903
1104
|
|
|
904
|
-
# Records that +target+ blocks self. Native GitHub action;
|
|
905
|
-
# notifications are handled by GitHub itself.
|
|
1105
|
+
# Records that +target+ blocks self. Native GitHub action; notifications are handled by GitHub itself.
|
|
906
1106
|
#
|
|
907
1107
|
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
908
1108
|
#
|
|
909
1109
|
# @return [PlanMyStuff::Link]
|
|
910
1110
|
#
|
|
911
1111
|
def add_blocker!(target)
|
|
912
|
-
link = build_link(target, type: :blocked_by)
|
|
1112
|
+
link = build_link!(target, type: :blocked_by)
|
|
913
1113
|
validate_not_self!(link)
|
|
914
1114
|
|
|
915
1115
|
target_issue = resolve_target_issue(target, type: :blocked_by)
|
|
@@ -929,7 +1129,7 @@ module PlanMyStuff
|
|
|
929
1129
|
# @return [PlanMyStuff::Link]
|
|
930
1130
|
#
|
|
931
1131
|
def remove_blocker!(target)
|
|
932
|
-
link = build_link(target, type: :blocked_by)
|
|
1132
|
+
link = build_link!(target, type: :blocked_by)
|
|
933
1133
|
validate_not_self!(link)
|
|
934
1134
|
|
|
935
1135
|
target_issue = resolve_target_issue(target, type: :blocked_by)
|
|
@@ -941,9 +1141,8 @@ module PlanMyStuff
|
|
|
941
1141
|
link
|
|
942
1142
|
end
|
|
943
1143
|
|
|
944
|
-
# Lazy-memoized issue that self was marked as duplicate of, via
|
|
945
|
-
#
|
|
946
|
-
# 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.
|
|
947
1146
|
#
|
|
948
1147
|
# @return [PlanMyStuff::Issue, nil]
|
|
949
1148
|
#
|
|
@@ -953,9 +1152,8 @@ module PlanMyStuff
|
|
|
953
1152
|
links_cache[:duplicate_of] = fetch_duplicate_of
|
|
954
1153
|
end
|
|
955
1154
|
|
|
956
|
-
# Closes self as a duplicate of +target+ via GitHub's native
|
|
957
|
-
#
|
|
958
|
-
# 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.
|
|
959
1157
|
#
|
|
960
1158
|
# Side effects, in order:
|
|
961
1159
|
# 1. Resolves +target+; raises +ValidationError+ if missing.
|
|
@@ -968,8 +1166,9 @@ module PlanMyStuff
|
|
|
968
1166
|
# 7. Reloads self; invalidates link caches.
|
|
969
1167
|
# 8. Fires +plan_my_stuff.issue.marked_duplicate+.
|
|
970
1168
|
#
|
|
971
|
-
# Partial failures are not rolled back - GitHub retains whatever
|
|
972
|
-
#
|
|
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
|
|
973
1172
|
#
|
|
974
1173
|
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
975
1174
|
# @param user [Object, nil] actor for notification + comment
|
|
@@ -980,20 +1179,19 @@ module PlanMyStuff
|
|
|
980
1179
|
raise(PlanMyStuff::ValidationError, 'Cannot mark a closed issue as duplicate') if state == 'closed'
|
|
981
1180
|
|
|
982
1181
|
target_issue = resolve_duplicate_target!(target)
|
|
983
|
-
merge_visibility_allowlist_onto(target_issue)
|
|
984
|
-
merge_assignees_onto(target_issue)
|
|
985
|
-
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)
|
|
986
1185
|
close_as_duplicate!(target_issue)
|
|
987
1186
|
|
|
988
1187
|
reload
|
|
989
1188
|
invalidate_links_cache!
|
|
990
1189
|
PlanMyStuff::Notifications.instrument('issue.marked_duplicate', self, target: target_issue, user: user)
|
|
991
1190
|
|
|
992
|
-
build_link(target_issue, type: :duplicate_of)
|
|
1191
|
+
build_link!(target_issue, type: :duplicate_of)
|
|
993
1192
|
end
|
|
994
1193
|
|
|
995
|
-
# GitHub GraphQL node ID (required for native sub-issue mutations).
|
|
996
|
-
# Read from the hydrated REST response.
|
|
1194
|
+
# GitHub GraphQL node ID (required for native sub-issue mutations). Read from the hydrated REST response.
|
|
997
1195
|
#
|
|
998
1196
|
# @return [String, nil]
|
|
999
1197
|
#
|
|
@@ -1001,8 +1199,8 @@ module PlanMyStuff
|
|
|
1001
1199
|
safe_read_field(github_response, :node_id)
|
|
1002
1200
|
end
|
|
1003
1201
|
|
|
1004
|
-
# GitHub database ID (required for the REST issue-dependency API,
|
|
1005
|
-
#
|
|
1202
|
+
# GitHub database ID (required for the REST issue-dependency API, which takes integer issue_id rather than issue
|
|
1203
|
+
# number).
|
|
1006
1204
|
#
|
|
1007
1205
|
# @return [Integer, nil]
|
|
1008
1206
|
#
|
|
@@ -1012,16 +1210,15 @@ module PlanMyStuff
|
|
|
1012
1210
|
|
|
1013
1211
|
private
|
|
1014
1212
|
|
|
1015
|
-
# Yields +self.metadata.visibility_allowlist+ for modification,
|
|
1016
|
-
#
|
|
1017
|
-
# 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.
|
|
1018
1215
|
#
|
|
1019
1216
|
# @yieldparam allowlist [Array<Integer>]
|
|
1020
1217
|
# @yieldreturn [Array<Integer>] the new allowlist
|
|
1021
1218
|
#
|
|
1022
1219
|
# @return [void]
|
|
1023
1220
|
#
|
|
1024
|
-
def modify_allowlist
|
|
1221
|
+
def modify_allowlist!
|
|
1025
1222
|
new_allowlist = yield(Array.wrap(metadata.visibility_allowlist))
|
|
1026
1223
|
self.class.update!(
|
|
1027
1224
|
number: number,
|
|
@@ -1031,16 +1228,15 @@ module PlanMyStuff
|
|
|
1031
1228
|
reload
|
|
1032
1229
|
end
|
|
1033
1230
|
|
|
1034
|
-
# Captures +fully_approved?+ state, yields the current approvals
|
|
1035
|
-
#
|
|
1036
|
-
# 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]+.
|
|
1037
1233
|
#
|
|
1038
1234
|
# @yieldparam current [Array<PlanMyStuff::Approval>] deep-copied approvals
|
|
1039
1235
|
# @yieldreturn [Array(Array<PlanMyStuff::Approval>, Object)] +[new_list, extra]+
|
|
1040
1236
|
#
|
|
1041
1237
|
# @return [Array(Object, Boolean)]
|
|
1042
1238
|
#
|
|
1043
|
-
def modify_approvals
|
|
1239
|
+
def modify_approvals!
|
|
1044
1240
|
was_fully_approved = fully_approved?
|
|
1045
1241
|
was_pending_count = metadata.approvals.count(&:pending?)
|
|
1046
1242
|
current = metadata.approvals.map { |a| PlanMyStuff::Approval.new(a.attributes) }
|
|
@@ -1054,16 +1250,14 @@ module PlanMyStuff
|
|
|
1054
1250
|
self.class.update!(number: number, repo: repo, metadata: metadata_updates)
|
|
1055
1251
|
reload
|
|
1056
1252
|
|
|
1057
|
-
sync_waiting_on_approval_label(was_pending_count, new_pending_count)
|
|
1253
|
+
sync_waiting_on_approval_label!(was_pending_count, new_pending_count)
|
|
1058
1254
|
|
|
1059
1255
|
[extra, was_fully_approved]
|
|
1060
1256
|
end
|
|
1061
1257
|
|
|
1062
|
-
# Computes the metadata delta for the waiting-on-approval timer
|
|
1063
|
-
#
|
|
1064
|
-
#
|
|
1065
|
-
# so that remaining pending approvers keep their original schedule
|
|
1066
|
-
# 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.
|
|
1067
1261
|
#
|
|
1068
1262
|
# @param was [Integer] pending count before the mutation
|
|
1069
1263
|
# @param now [Integer] pending count after the mutation
|
|
@@ -1074,41 +1268,41 @@ module PlanMyStuff
|
|
|
1074
1268
|
if now > was
|
|
1075
1269
|
ts = Time.now.utc
|
|
1076
1270
|
{
|
|
1077
|
-
waiting_on_approval_at: ts
|
|
1271
|
+
waiting_on_approval_at: PlanMyStuff.format_time(ts),
|
|
1078
1272
|
next_reminder_at: format_next_reminder_at(from: ts),
|
|
1079
1273
|
}
|
|
1080
1274
|
elsif now.zero? && was.positive?
|
|
1081
1275
|
{
|
|
1082
1276
|
waiting_on_approval_at: nil,
|
|
1083
|
-
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,
|
|
1084
1278
|
}
|
|
1085
1279
|
else
|
|
1086
1280
|
{}
|
|
1087
1281
|
end
|
|
1088
1282
|
end
|
|
1089
1283
|
|
|
1090
|
-
# Adds or removes the configured waiting-on-approval label when the
|
|
1091
|
-
#
|
|
1092
|
-
# 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.
|
|
1093
1286
|
#
|
|
1094
1287
|
# @param was [Integer] pending count before the mutation
|
|
1095
1288
|
# @param now [Integer] pending count after the mutation
|
|
1096
1289
|
#
|
|
1097
1290
|
# @return [void]
|
|
1098
1291
|
#
|
|
1099
|
-
def sync_waiting_on_approval_label(was, now)
|
|
1292
|
+
def sync_waiting_on_approval_label!(was, now)
|
|
1100
1293
|
label = PlanMyStuff.configuration.waiting_on_approval_label
|
|
1101
1294
|
|
|
1102
1295
|
if now.positive? && was.zero?
|
|
1103
1296
|
PlanMyStuff::Label.ensure!(repo: repo, name: label)
|
|
1104
|
-
PlanMyStuff::Label.add(issue: self, labels: [label]) if labels.exclude?(label)
|
|
1297
|
+
PlanMyStuff::Label.add!(issue: self, labels: [label]) if labels.exclude?(label)
|
|
1105
1298
|
elsif now.zero? && was.positive?
|
|
1106
|
-
PlanMyStuff::Label.remove(issue: self, labels: [label]) if labels.include?(label)
|
|
1299
|
+
PlanMyStuff::Label.remove!(issue: self, labels: [label]) if labels.include?(label)
|
|
1107
1300
|
end
|
|
1108
1301
|
end
|
|
1109
1302
|
|
|
1110
|
-
#
|
|
1111
|
-
#
|
|
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
|
|
1112
1306
|
#
|
|
1113
1307
|
# @param user [Object, Integer, nil]
|
|
1114
1308
|
#
|
|
@@ -1121,8 +1315,9 @@ module PlanMyStuff
|
|
|
1121
1315
|
raise(PlanMyStuff::AuthorizationError, 'Only support users may manage approvers')
|
|
1122
1316
|
end
|
|
1123
1317
|
|
|
1124
|
-
# Resolves +user+ to an integer user_id.
|
|
1125
|
-
#
|
|
1318
|
+
# Resolves +user+ to an integer user_id.
|
|
1319
|
+
#
|
|
1320
|
+
# @raise [ArgumentError] when user is nil
|
|
1126
1321
|
#
|
|
1127
1322
|
# @param user [Object, Integer]
|
|
1128
1323
|
#
|
|
@@ -1135,9 +1330,8 @@ module PlanMyStuff
|
|
|
1135
1330
|
PlanMyStuff::UserResolver.user_id(resolved)
|
|
1136
1331
|
end
|
|
1137
1332
|
|
|
1138
|
-
# Fires +approval_requested+ (when any users were newly added) and,
|
|
1139
|
-
#
|
|
1140
|
-
# +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.
|
|
1141
1335
|
#
|
|
1142
1336
|
# @param added [Array<PlanMyStuff::Approval>]
|
|
1143
1337
|
# @param user [Object, nil]
|
|
@@ -1157,8 +1351,8 @@ module PlanMyStuff
|
|
|
1157
1351
|
emit_aggregate_events(was_fully_approved: was_fully_approved, trigger: :approver_added, user: user)
|
|
1158
1352
|
end
|
|
1159
1353
|
|
|
1160
|
-
# Fires the granular event (+approval_granted+ / +approval_revoked+)
|
|
1161
|
-
#
|
|
1354
|
+
# Fires the granular event (+approval_granted+ / +approval_revoked+) then any aggregate follow-up triggered
|
|
1355
|
+
# by the state flip.
|
|
1162
1356
|
#
|
|
1163
1357
|
# @param event [Symbol] +:approval_granted+ or +:approval_revoked+
|
|
1164
1358
|
# @param approval [PlanMyStuff::Approval]
|
|
@@ -1178,10 +1372,9 @@ module PlanMyStuff
|
|
|
1178
1372
|
emit_aggregate_events(was_fully_approved: was_fully_approved, trigger: trigger, user: user)
|
|
1179
1373
|
end
|
|
1180
1374
|
|
|
1181
|
-
# Fires +all_approved+ or +approvals_invalidated+ based on whether
|
|
1182
|
-
# +
|
|
1183
|
-
#
|
|
1184
|
-
# 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).
|
|
1185
1378
|
#
|
|
1186
1379
|
# @param was_fully_approved [Boolean]
|
|
1187
1380
|
# @param trigger [Symbol, nil]
|
|
@@ -1218,13 +1411,15 @@ module PlanMyStuff
|
|
|
1218
1411
|
self.state = read_field(github_issue, :state)
|
|
1219
1412
|
self.raw_body = read_field(github_issue, :body) || ''
|
|
1220
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))
|
|
1221
1415
|
self.closed_at = parse_github_time(safe_read_field(github_issue, :closed_at))
|
|
1222
1416
|
self.locked = safe_read_field(github_issue, :locked) || false
|
|
1223
1417
|
self.labels = extract_labels(github_issue)
|
|
1418
|
+
self.issue_type = extract_issue_type(github_issue)
|
|
1224
1419
|
self.repo = repo
|
|
1225
1420
|
|
|
1226
|
-
parsed = MetadataParser.parse(raw_body)
|
|
1227
|
-
self.metadata = IssueMetadata.from_hash(parsed[:metadata])
|
|
1421
|
+
parsed = PlanMyStuff::MetadataParser.parse(raw_body)
|
|
1422
|
+
self.metadata = PlanMyStuff::IssueMetadata.from_hash(parsed[:metadata])
|
|
1228
1423
|
self.body = parsed[:body]
|
|
1229
1424
|
@body_dirty = false
|
|
1230
1425
|
persisted!
|
|
@@ -1246,10 +1441,12 @@ module PlanMyStuff
|
|
|
1246
1441
|
self.body = other.attributes['body']
|
|
1247
1442
|
@body_dirty = false
|
|
1248
1443
|
self.raw_body = other.raw_body
|
|
1444
|
+
self.created_at = other.created_at
|
|
1249
1445
|
self.updated_at = other.updated_at
|
|
1250
1446
|
self.closed_at = other.closed_at
|
|
1251
1447
|
self.locked = other.locked
|
|
1252
1448
|
self.labels = other.labels
|
|
1449
|
+
self.issue_type = other.issue_type
|
|
1253
1450
|
self.repo = other.repo
|
|
1254
1451
|
self.metadata = other.metadata
|
|
1255
1452
|
persisted!
|
|
@@ -1257,10 +1454,8 @@ module PlanMyStuff
|
|
|
1257
1454
|
invalidate_links_cache!
|
|
1258
1455
|
end
|
|
1259
1456
|
|
|
1260
|
-
# Formats the next reminder time as an ISO 8601 UTC string, using
|
|
1261
|
-
#
|
|
1262
|
-
# +config.reminder_days+ otherwise. Returns +nil+ when the
|
|
1263
|
-
# 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.
|
|
1264
1459
|
#
|
|
1265
1460
|
# @param from [Time] baseline timestamp
|
|
1266
1461
|
#
|
|
@@ -1270,25 +1465,11 @@ module PlanMyStuff
|
|
|
1270
1465
|
days = metadata.reminder_days.presence || PlanMyStuff.configuration.reminder_days
|
|
1271
1466
|
return if days.empty?
|
|
1272
1467
|
|
|
1273
|
-
(from + days.first.days)
|
|
1468
|
+
PlanMyStuff.format_time(from + days.first.days)
|
|
1274
1469
|
end
|
|
1275
1470
|
|
|
1276
|
-
#
|
|
1277
|
-
#
|
|
1278
|
-
#
|
|
1279
|
-
# @param time [Time, nil]
|
|
1280
|
-
#
|
|
1281
|
-
# @return [String, nil]
|
|
1282
|
-
#
|
|
1283
|
-
def format_time(time)
|
|
1284
|
-
return if time.nil?
|
|
1285
|
-
|
|
1286
|
-
time.utc.iso8601
|
|
1287
|
-
end
|
|
1288
|
-
|
|
1289
|
-
# Fires the appropriate notification event for an update: +issue.closed+
|
|
1290
|
-
# or +issue.reopened+ on a state transition, otherwise +issue.updated+
|
|
1291
|
-
# 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.
|
|
1292
1473
|
#
|
|
1293
1474
|
# @param captured [Hash] +ActiveModel::Dirty+ changes captured before +persist_update!+
|
|
1294
1475
|
# @param user [Object, nil]
|
|
@@ -1306,17 +1487,15 @@ module PlanMyStuff
|
|
|
1306
1487
|
end
|
|
1307
1488
|
end
|
|
1308
1489
|
|
|
1309
|
-
# When an issue is transitioning from open to closed, strips both
|
|
1310
|
-
#
|
|
1311
|
-
#
|
|
1312
|
-
# 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.
|
|
1313
1493
|
#
|
|
1314
|
-
# @param attrs [Hash] the kwargs hash being assembled for
|
|
1315
|
-
# +Issue.update!+; mutated in place
|
|
1494
|
+
# @param attrs [Hash] the kwargs hash being assembled for +Issue.update!+; mutated in place
|
|
1316
1495
|
#
|
|
1317
1496
|
# @return [void]
|
|
1318
1497
|
#
|
|
1319
|
-
def clear_waiting_state_on_close
|
|
1498
|
+
def clear_waiting_state_on_close(attrs)
|
|
1320
1499
|
return unless state_changed?
|
|
1321
1500
|
return unless state_was == 'open'
|
|
1322
1501
|
return unless state == 'closed'
|
|
@@ -1334,17 +1513,15 @@ module PlanMyStuff
|
|
|
1334
1513
|
metadata.next_reminder_at = nil
|
|
1335
1514
|
end
|
|
1336
1515
|
|
|
1337
|
-
# When an inactivity-closed issue is being reopened, strips the
|
|
1338
|
-
# +
|
|
1339
|
-
#
|
|
1340
|
-
# 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.
|
|
1341
1519
|
#
|
|
1342
|
-
# @param attrs [Hash] the kwargs hash being assembled for
|
|
1343
|
-
# +Issue.update!+; mutated in place
|
|
1520
|
+
# @param attrs [Hash] the kwargs hash being assembled for +Issue.update!+; mutated in place
|
|
1344
1521
|
#
|
|
1345
1522
|
# @return [void]
|
|
1346
1523
|
#
|
|
1347
|
-
def clear_inactivity_state_on_reopen
|
|
1524
|
+
def clear_inactivity_state_on_reopen(attrs)
|
|
1348
1525
|
return unless state_changed?
|
|
1349
1526
|
return unless state_was == 'closed'
|
|
1350
1527
|
return unless state == 'open'
|
|
@@ -1354,14 +1531,10 @@ module PlanMyStuff
|
|
|
1354
1531
|
metadata.closed_by_inactivity = false
|
|
1355
1532
|
end
|
|
1356
1533
|
|
|
1357
|
-
# Full-write persistence path for an already-persisted issue.
|
|
1358
|
-
#
|
|
1359
|
-
#
|
|
1360
|
-
#
|
|
1361
|
-
# +body:+ when +@body_dirty+, so the PMS body comment is
|
|
1362
|
-
# rewritten exactly when +#body=+ has been called since load.
|
|
1363
|
-
#
|
|
1364
|
-
# @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.
|
|
1365
1538
|
#
|
|
1366
1539
|
# @return [void]
|
|
1367
1540
|
#
|
|
@@ -1378,9 +1551,10 @@ module PlanMyStuff
|
|
|
1378
1551
|
}
|
|
1379
1552
|
attrs[:body] = body if @body_dirty
|
|
1380
1553
|
attrs[:assignees] = @pending_assignees unless @pending_assignees.nil?
|
|
1554
|
+
attrs[:issue_type] = issue_type if issue_type_changed?
|
|
1381
1555
|
|
|
1382
|
-
clear_waiting_state_on_close
|
|
1383
|
-
clear_inactivity_state_on_reopen
|
|
1556
|
+
clear_waiting_state_on_close(attrs)
|
|
1557
|
+
clear_inactivity_state_on_reopen(attrs)
|
|
1384
1558
|
|
|
1385
1559
|
self.class.update!(**attrs)
|
|
1386
1560
|
|
|
@@ -1389,25 +1563,24 @@ module PlanMyStuff
|
|
|
1389
1563
|
reload
|
|
1390
1564
|
end
|
|
1391
1565
|
|
|
1392
|
-
# Applies in-memory updates from an +update!+ kwargs hash.
|
|
1393
|
-
#
|
|
1394
|
-
#
|
|
1395
|
-
# +@metadata+ (top-level attrs assigned directly, custom_fields
|
|
1396
|
-
# 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).
|
|
1397
1569
|
#
|
|
1398
1570
|
# @return [void]
|
|
1399
1571
|
#
|
|
1400
|
-
def apply_update_attrs
|
|
1572
|
+
def apply_update_attrs(attrs)
|
|
1401
1573
|
self.title = attrs[:title] if attrs.key?(:title)
|
|
1402
1574
|
self.state = attrs[:state].to_s if attrs.key?(:state)
|
|
1403
1575
|
self.labels = attrs[:labels] if attrs.key?(:labels)
|
|
1404
1576
|
self.body = attrs[:body] if attrs.key?(:body)
|
|
1577
|
+
self.issue_type = attrs[:issue_type] if attrs.key?(:issue_type)
|
|
1405
1578
|
@pending_assignees = attrs[:assignees] if attrs.key?(:assignees)
|
|
1406
|
-
apply_metadata_attrs
|
|
1579
|
+
apply_metadata_attrs(attrs[:metadata]) if attrs.key?(:metadata)
|
|
1407
1580
|
end
|
|
1408
1581
|
|
|
1409
1582
|
# @return [void]
|
|
1410
|
-
def apply_metadata_attrs
|
|
1583
|
+
def apply_metadata_attrs(md_hash)
|
|
1411
1584
|
return if md_hash.nil?
|
|
1412
1585
|
|
|
1413
1586
|
md_hash.each do |key, value|
|
|
@@ -1419,8 +1592,7 @@ module PlanMyStuff
|
|
|
1419
1592
|
end
|
|
1420
1593
|
end
|
|
1421
1594
|
|
|
1422
|
-
# Raises StaleObjectError if the remote issue has been modified
|
|
1423
|
-
# since this instance was loaded.
|
|
1595
|
+
# Raises StaleObjectError if the remote issue has been modified since this instance was loaded.
|
|
1424
1596
|
#
|
|
1425
1597
|
# @raise [PlanMyStuff::StaleObjectError]
|
|
1426
1598
|
#
|
|
@@ -1437,11 +1609,13 @@ module PlanMyStuff
|
|
|
1437
1609
|
return if remote_time.nil?
|
|
1438
1610
|
return if local_time && remote_time.to_i == local_time.to_i
|
|
1439
1611
|
|
|
1440
|
-
raise(
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
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
|
+
)
|
|
1445
1619
|
end
|
|
1446
1620
|
|
|
1447
1621
|
# @return [Array<String>]
|
|
@@ -1450,6 +1624,18 @@ module PlanMyStuff
|
|
|
1450
1624
|
raw.map { |label| label_name(label) }
|
|
1451
1625
|
end
|
|
1452
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
|
+
|
|
1453
1639
|
# @return [String]
|
|
1454
1640
|
def label_name(label)
|
|
1455
1641
|
return label.name if label.respond_to?(:name)
|
|
@@ -1460,7 +1646,7 @@ module PlanMyStuff
|
|
|
1460
1646
|
|
|
1461
1647
|
# @return [Array<PlanMyStuff::Comment>]
|
|
1462
1648
|
def load_comments
|
|
1463
|
-
Comment.list(issue: self)
|
|
1649
|
+
PlanMyStuff::Comment.list(issue: self)
|
|
1464
1650
|
end
|
|
1465
1651
|
|
|
1466
1652
|
# @return [Hash{Symbol => Array}]
|
|
@@ -1468,8 +1654,7 @@ module PlanMyStuff
|
|
|
1468
1654
|
@links_cache ||= {}
|
|
1469
1655
|
end
|
|
1470
1656
|
|
|
1471
|
-
# Clears all memoized link readers. Called from +#hydrate_from_github+
|
|
1472
|
-
# and after any successful write.
|
|
1657
|
+
# Clears all memoized link readers. Called from +#hydrate_from_github+ and after any successful write.
|
|
1473
1658
|
#
|
|
1474
1659
|
# @return [void]
|
|
1475
1660
|
#
|
|
@@ -1477,16 +1662,16 @@ module PlanMyStuff
|
|
|
1477
1662
|
@links_cache = {}
|
|
1478
1663
|
end
|
|
1479
1664
|
|
|
1480
|
-
# Normalizes +target+ to a +PlanMyStuff::Link+ with the source
|
|
1481
|
-
# repo defaulting to self's repo.
|
|
1665
|
+
# Normalizes +target+ to a +PlanMyStuff::Link+ with the source repo defaulting to self's repo.
|
|
1482
1666
|
#
|
|
1483
1667
|
# @return [PlanMyStuff::Link]
|
|
1484
1668
|
#
|
|
1485
|
-
def build_link(target, type:)
|
|
1486
|
-
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)
|
|
1487
1671
|
end
|
|
1488
1672
|
|
|
1489
|
-
# @raise [PlanMyStuff::ValidationError]
|
|
1673
|
+
# @raise [PlanMyStuff::ValidationError] when target is the same issue (self-link)
|
|
1674
|
+
#
|
|
1490
1675
|
# @return [void]
|
|
1491
1676
|
#
|
|
1492
1677
|
def validate_not_self!(link)
|
|
@@ -1496,8 +1681,7 @@ module PlanMyStuff
|
|
|
1496
1681
|
raise(PlanMyStuff::ValidationError, 'Cannot link an issue to itself')
|
|
1497
1682
|
end
|
|
1498
1683
|
|
|
1499
|
-
# Reads +metadata.links+ and coerces any legacy hash entries to
|
|
1500
|
-
# +Link+ instances. Invalid entries are dropped.
|
|
1684
|
+
# Reads +metadata.links+ and coerces any legacy hash entries to +Link+ instances. Invalid entries are dropped.
|
|
1501
1685
|
#
|
|
1502
1686
|
# @return [Array<PlanMyStuff::Link>]
|
|
1503
1687
|
#
|
|
@@ -1505,14 +1689,13 @@ module PlanMyStuff
|
|
|
1505
1689
|
metadata.links.filter_map do |entry|
|
|
1506
1690
|
next entry if entry.is_a?(PlanMyStuff::Link)
|
|
1507
1691
|
|
|
1508
|
-
PlanMyStuff::Link.build(entry)
|
|
1692
|
+
PlanMyStuff::Link.build!(entry)
|
|
1509
1693
|
rescue ActiveModel::ValidationError, ArgumentError
|
|
1510
1694
|
next
|
|
1511
1695
|
end
|
|
1512
1696
|
end
|
|
1513
1697
|
|
|
1514
|
-
# Writes the given link array back to GitHub via
|
|
1515
|
-
# +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
|
|
1516
1699
|
# in-memory reads see the change without a +reload+.
|
|
1517
1700
|
#
|
|
1518
1701
|
# @param new_links [Array<PlanMyStuff::Link>]
|
|
@@ -1529,9 +1712,8 @@ module PlanMyStuff
|
|
|
1529
1712
|
invalidate_links_cache!
|
|
1530
1713
|
end
|
|
1531
1714
|
|
|
1532
|
-
# Walks every Projects V2 board this issue sits on and deletes the
|
|
1533
|
-
#
|
|
1534
|
-
# 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.
|
|
1535
1717
|
#
|
|
1536
1718
|
# @return [void]
|
|
1537
1719
|
#
|
|
@@ -1551,7 +1733,7 @@ module PlanMyStuff
|
|
|
1551
1733
|
nodes = Array.wrap(connection[:nodes])
|
|
1552
1734
|
|
|
1553
1735
|
nodes.each do |node|
|
|
1554
|
-
PlanMyStuff::ProjectItem.delete_item(
|
|
1736
|
+
PlanMyStuff::ProjectItem.delete_item!(
|
|
1555
1737
|
item_id: node[:id],
|
|
1556
1738
|
project_number: node.dig(:project, :number),
|
|
1557
1739
|
)
|
|
@@ -1564,9 +1746,8 @@ module PlanMyStuff
|
|
|
1564
1746
|
end
|
|
1565
1747
|
end
|
|
1566
1748
|
|
|
1567
|
-
# Attempts the reciprocal write on +link+'s target. On failure,
|
|
1568
|
-
#
|
|
1569
|
-
# 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.
|
|
1570
1751
|
#
|
|
1571
1752
|
# @param link [PlanMyStuff::Link]
|
|
1572
1753
|
# @param user [Object, nil]
|
|
@@ -1621,27 +1802,26 @@ module PlanMyStuff
|
|
|
1621
1802
|
end
|
|
1622
1803
|
end
|
|
1623
1804
|
|
|
1624
|
-
# Normalizes +target+ to a fully-hydrated +Issue+ (fetching when
|
|
1625
|
-
#
|
|
1626
|
-
#
|
|
1627
|
-
# +#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.
|
|
1628
1808
|
#
|
|
1629
1809
|
# @return [PlanMyStuff::Issue]
|
|
1630
1810
|
#
|
|
1631
1811
|
def resolve_target_issue(target, type:)
|
|
1632
1812
|
return target if target.is_a?(PlanMyStuff::Issue)
|
|
1633
1813
|
|
|
1634
|
-
link = build_link(target, type: type)
|
|
1814
|
+
link = build_link!(target, type: type)
|
|
1635
1815
|
PlanMyStuff::Issue.find(link.issue_number, repo: link.repo)
|
|
1636
1816
|
end
|
|
1637
1817
|
|
|
1638
|
-
# Shared path for add_sub_issue! / remove_sub_issue!. Builds the
|
|
1639
|
-
#
|
|
1818
|
+
# Shared path for add_sub_issue! / remove_sub_issue!. Builds the link, resolves the target, runs the
|
|
1819
|
+
# mutation, busts caches.
|
|
1640
1820
|
#
|
|
1641
1821
|
# @return [PlanMyStuff::Link]
|
|
1642
1822
|
#
|
|
1643
1823
|
def mutate_sub_issue!(target, method:, path:)
|
|
1644
|
-
link = build_link(target, type: :sub_ticket)
|
|
1824
|
+
link = build_link!(target, type: :sub_ticket)
|
|
1645
1825
|
validate_not_self!(link)
|
|
1646
1826
|
|
|
1647
1827
|
target_issue = resolve_target_issue(target, type: :sub_ticket)
|
|
@@ -1664,8 +1844,7 @@ module PlanMyStuff
|
|
|
1664
1844
|
"/repos/#{repo.organization}/#{repo.name}/issues/#{number}/sub_issues"
|
|
1665
1845
|
end
|
|
1666
1846
|
|
|
1667
|
-
# GitHub's REMOVE endpoint is +/sub_issue+ (singular), distinct
|
|
1668
|
-
# 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).
|
|
1669
1848
|
#
|
|
1670
1849
|
# @return [String]
|
|
1671
1850
|
#
|
|
@@ -1673,10 +1852,9 @@ module PlanMyStuff
|
|
|
1673
1852
|
"/repos/#{repo.organization}/#{repo.name}/issues/#{number}/sub_issue"
|
|
1674
1853
|
end
|
|
1675
1854
|
|
|
1676
|
-
# Fetches one side of the native issue-dependency graph for self
|
|
1677
|
-
#
|
|
1678
|
-
#
|
|
1679
|
-
# 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).
|
|
1680
1858
|
#
|
|
1681
1859
|
# @param side [String] "blocked_by" or "blocking"
|
|
1682
1860
|
#
|
|
@@ -1722,8 +1900,9 @@ module PlanMyStuff
|
|
|
1722
1900
|
PlanMyStuff::Issue.find(the_dupe[:number], repo: the_dupe.dig(:repository, :nameWithOwner))
|
|
1723
1901
|
end
|
|
1724
1902
|
|
|
1725
|
-
# Resolves +target+ to an +Issue
|
|
1726
|
-
#
|
|
1903
|
+
# Resolves +target+ to an +Issue+.
|
|
1904
|
+
#
|
|
1905
|
+
# @raise [PlanMyStuff::ValidationError] when the duplicate target cannot be found
|
|
1727
1906
|
#
|
|
1728
1907
|
# @return [PlanMyStuff::Issue]
|
|
1729
1908
|
#
|
|
@@ -1737,7 +1916,7 @@ module PlanMyStuff
|
|
|
1737
1916
|
#
|
|
1738
1917
|
# @return [void]
|
|
1739
1918
|
#
|
|
1740
|
-
def merge_visibility_allowlist_onto(target)
|
|
1919
|
+
def merge_visibility_allowlist_onto!(target)
|
|
1741
1920
|
return if metadata.visibility_allowlist.blank?
|
|
1742
1921
|
|
|
1743
1922
|
merged = Array.wrap(target.metadata.visibility_allowlist) | Array.wrap(metadata.visibility_allowlist)
|
|
@@ -1748,7 +1927,7 @@ module PlanMyStuff
|
|
|
1748
1927
|
#
|
|
1749
1928
|
# @return [void]
|
|
1750
1929
|
#
|
|
1751
|
-
def merge_assignees_onto(target)
|
|
1930
|
+
def merge_assignees_onto!(target)
|
|
1752
1931
|
source_logins = extract_assignee_logins(github_response)
|
|
1753
1932
|
return if source_logins.empty?
|
|
1754
1933
|
|
|
@@ -1766,7 +1945,7 @@ module PlanMyStuff
|
|
|
1766
1945
|
end
|
|
1767
1946
|
|
|
1768
1947
|
# @return [void]
|
|
1769
|
-
def post_duplicate_back_pointer(target, user:)
|
|
1948
|
+
def post_duplicate_back_pointer!(target, user:)
|
|
1770
1949
|
visibility = target.metadata.visibility.presence || 'public'
|
|
1771
1950
|
PlanMyStuff::Comment.create!(
|
|
1772
1951
|
issue: target,
|
|
@@ -1776,11 +1955,11 @@ module PlanMyStuff
|
|
|
1776
1955
|
)
|
|
1777
1956
|
end
|
|
1778
1957
|
|
|
1779
|
-
# Closes self as a duplicate of +target+ via GitHub's native
|
|
1780
|
-
# +
|
|
1781
|
-
#
|
|
1782
|
-
#
|
|
1783
|
-
#
|
|
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
|
|
1784
1963
|
#
|
|
1785
1964
|
# @return [void]
|
|
1786
1965
|
#
|