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.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -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 +56 -3
  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 +501 -322
  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,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.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)
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] = MetadataParser.serialize(metadata.to_h, '')
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] = MetadataParser.serialize(existing_metadata, '')
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
- 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
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.any?
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
- def resolve_project_number(add_to_project)
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
- # 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
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
- # +save!+ rewrites the backing PMS body comment. Unsaved
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 [Array<PlanMyStuff::Approval>] all required approvers (pending + approved)
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 approved
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.any?
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? && pending_approvals.empty?
513
+ approvals_required? && approvers.all?(&:approved?)
319
514
  end
320
515
 
321
- # Adds user IDs to this issue's visibility allowlist (non-support
322
- # 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).
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
- # 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.
356
551
  #
357
- # Fires +plan_my_stuff.issue.approval_requested+ when any user is
358
- # newly added. Also fires +plan_my_stuff.issue.approvals_invalidated+
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
- # support users may call this. Removing a pending approver may flip
386
- # the issue into +fully_approved?+ (fires +all_approved+). Removing
387
- # an approved approver fires no events (state does not flip).
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+ to +approved+. Only the
412
- # approver themselves may call this. Fires
413
- # +plan_my_stuff.issue.approval_granted+ and, when this flip
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") unless approval.pending?
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 an approved record back to +pending+. Approvers may revoke
440
- # their own approval; support users may revoke any approver's
441
- # approval by passing +target_user_id:+. Non-support callers passing
442
- # a +target_user_id:+ that is not their own raise
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
- # Fires +plan_my_stuff.issue.approval_revoked+ and, when this flip
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] the caller
450
- # @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.
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 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
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 approval")
685
+ raise(PlanMyStuff::AuthorizationError, "Only support users may revoke another user's response")
464
686
  end
465
687
 
466
- just_revoked, was_fully_approved = modify_approvals do |current|
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
- 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
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
- :approval_revoked,
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.waiting_on_user_at+ to now, (re)computes
488
- # +metadata.next_reminder_at+, and adds the configured
489
- # +waiting_on_user_label+ to the issue. Called from
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.iso8601,
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.waiting_on_user_at+, and clears
518
- # +metadata.next_reminder_at+ unless a waiting-on-approval timer
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
- # clears +metadata.closed_by_inactivity+, and emits
543
- # +plan_my_stuff.issue.reopened_by_reply+ carrying the reopening
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
- # from every Projects V2 board it belongs to, locks its
576
- # conversation on GitHub, and stamps +metadata.archived_at+.
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 (either +metadata.archived_at+ is set or the 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.utc.iso8601 },
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
- # write: serializes +@metadata+ into the GitHub issue body and
619
- # PATCHes title/state/labels. When +#body=+ has been called since
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
- # Supports +title:+, +body:+, +state:+, +labels:+, +assignees:+,
651
- # and +metadata:+. The +metadata:+ kwarg is a hash whose keys are
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!(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
- # from the body comment (stripped of its header). Falls back to the
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
- # Silently drops targets that 404 so a dangling pointer doesn't
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
- # already a reciprocal, mirrors the link back on +target+ so
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
- # already a reciprocal, mirrors the removal on +target+. No-op
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
- # it is detached first. Returns a +Link+ describing the new
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
- # +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.
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
- # each returned issue) via GitHub's native issue-dependency REST
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
- # GitHub's native close-as-duplicate. Returns nil for issues that
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
- # close-as-duplicate, carrying over viewers, assignees, and a
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
- # 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
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
- # 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).
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
- # persists the updated allowlist via the class-level +update!+,
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
- # (deep-copied) for mutation, persists the new list to GitHub, and
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
- # based on the change in pending-approval count. The timer resets
1064
- # only when pending count goes UP (add approver, revoke-to-pending)
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.iso8601,
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
- # pending-approval count crosses the zero boundary. Mutations that
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
- # Raises +AuthorizationError+ unless +user+ resolves to a support
1111
- # 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
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. Raises +ArgumentError+
1125
- # when +user+ is +nil+.
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
- # if the aggregate state flipped out of fully-approved, the
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
- # 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.
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
- # +fully_approved?+ flipped. Suppresses +approvals_invalidated+
1183
- # when the issue no longer has any approvers required (dropping
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
- # per-issue +metadata.reminder_days+ when set or
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).utc.iso8601
1468
+ PlanMyStuff.format_time(from + days.first.days)
1274
1469
  end
1275
1470
 
1276
- # Formats a +Time+ as an ISO 8601 UTC string, or +nil+ when the
1277
- # input is nil.
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
- # waiting labels from the outgoing labels array and clears the
1311
- # waiting-related timestamps on +metadata+ so a single save writes
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!(attrs)
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
- # +user_inactive_label+ from the outgoing labels and clears
1339
- # +metadata.closed_by_inactivity+ so the save writes both. No-op
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!(attrs)
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
- # Delegates to +Issue.update!+ passing the full in-memory state
1359
- # (title/state/labels plus the current +metadata+ object so the
1360
- # class method serializes it authoritatively). Only passes
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!(attrs)
1383
- clear_inactivity_state_on_reopen!(attrs)
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
- # Top-level scalars go through their setters so +@body_dirty+
1394
- # and friends stay in sync; +metadata:+ is merged into
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!(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!(attrs[:metadata]) if attrs.key?(:metadata)
1579
+ apply_metadata_attrs(attrs[:metadata]) if attrs.key?(:metadata)
1407
1580
  end
1408
1581
 
1409
1582
  # @return [void]
1410
- def apply_metadata_attrs!(md_hash)
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(StaleObjectError.new(
1441
- "Issue ##{number} has been modified remotely",
1442
- local_updated_at: local_time,
1443
- remote_updated_at: remote_time,
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
- # corresponding item. Paginates via +LIST_ISSUE_PROJECT_ITEMS+ with
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
- # fires +plan_my_stuff.issue.link_reciprocal_failed+ so the
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
- # we only have a +Link+ or hash). Used by +set_parent!+ /
1626
- # +remove_parent!+ to invert the call back through
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
- # 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.
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
- # (+blocked_by+ or +blocking+) via REST. Response is an array of
1678
- # Issue objects; we map through +Issue.find+ to get fully hydrated
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+ and raises +ValidationError+
1726
- # when the target cannot be found.
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
- # +closeIssue+ GraphQL mutation with +stateReason: DUPLICATE+ and
1781
- # +duplicateIssueId+. The REST +duplicate_of+ body param is not
1782
- # recognized; only this GraphQL path actually wires up
1783
- # +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
1784
1963
  #
1785
1964
  # @return [void]
1786
1965
  #