plan_my_stuff 0.7.0 → 0.8.0

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