plan_my_stuff 0.7.0 → 0.9.0

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