plan_my_stuff 0.3.0 → 0.4.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +569 -38
  4. data/app/controllers/plan_my_stuff/comments_controller.rb +5 -1
  5. data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +102 -0
  6. data/app/controllers/plan_my_stuff/issues/closures_controller.rb +37 -0
  7. data/app/controllers/plan_my_stuff/issues/links_controller.rb +127 -0
  8. data/app/controllers/plan_my_stuff/issues/takes_controller.rb +88 -0
  9. data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +48 -0
  10. data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +47 -0
  11. data/app/controllers/plan_my_stuff/issues_controller.rb +22 -55
  12. data/app/controllers/plan_my_stuff/labels_controller.rb +4 -4
  13. data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +75 -0
  14. data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +40 -0
  15. data/app/controllers/plan_my_stuff/project_items_controller.rb +0 -75
  16. data/app/controllers/plan_my_stuff/projects_controller.rb +11 -1
  17. data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +54 -0
  18. data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +39 -0
  19. data/app/controllers/plan_my_stuff/testing_projects_controller.rb +93 -0
  20. data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +148 -0
  21. data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +284 -0
  22. data/app/jobs/plan_my_stuff/application_job.rb +9 -0
  23. data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +81 -0
  24. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +7 -0
  25. data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +87 -0
  26. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +2 -2
  27. data/app/views/plan_my_stuff/issues/partials/_links.html.erb +70 -0
  28. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +2 -2
  29. data/app/views/plan_my_stuff/issues/show.html.erb +46 -3
  30. data/app/views/plan_my_stuff/projects/index.html.erb +15 -1
  31. data/app/views/plan_my_stuff/projects/show.html.erb +10 -5
  32. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +12 -0
  33. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +22 -0
  34. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +7 -0
  35. data/app/views/plan_my_stuff/testing_projects/new.html.erb +7 -0
  36. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +39 -0
  37. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +51 -0
  38. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +35 -0
  39. data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
  40. data/config/routes.rb +38 -15
  41. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +138 -5
  42. data/lib/plan_my_stuff/application_record.rb +121 -0
  43. data/lib/plan_my_stuff/approval.rb +80 -0
  44. data/lib/plan_my_stuff/archive/sweep.rb +85 -0
  45. data/lib/plan_my_stuff/archive.rb +14 -0
  46. data/lib/plan_my_stuff/aws_sns_simulator.rb +110 -0
  47. data/lib/plan_my_stuff/base_project.rb +661 -0
  48. data/lib/plan_my_stuff/base_project_item.rb +562 -0
  49. data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
  50. data/lib/plan_my_stuff/cache.rb +197 -0
  51. data/lib/plan_my_stuff/client.rb +7 -0
  52. data/lib/plan_my_stuff/comment.rb +171 -50
  53. data/lib/plan_my_stuff/configuration.rb +210 -10
  54. data/lib/plan_my_stuff/custom_fields.rb +31 -17
  55. data/lib/plan_my_stuff/engine.rb +0 -4
  56. data/lib/plan_my_stuff/errors.rb +49 -0
  57. data/lib/plan_my_stuff/graphql/queries.rb +392 -0
  58. data/lib/plan_my_stuff/issue.rb +1476 -175
  59. data/lib/plan_my_stuff/issue_metadata.rb +122 -0
  60. data/lib/plan_my_stuff/label.rb +82 -11
  61. data/lib/plan_my_stuff/link.rb +144 -0
  62. data/lib/plan_my_stuff/notifications.rb +142 -0
  63. data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
  64. data/lib/plan_my_stuff/pipeline/status.rb +44 -0
  65. data/lib/plan_my_stuff/pipeline.rb +293 -0
  66. data/lib/plan_my_stuff/project.rb +30 -693
  67. data/lib/plan_my_stuff/project_item.rb +3 -417
  68. data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
  69. data/lib/plan_my_stuff/project_metadata.rb +9 -3
  70. data/lib/plan_my_stuff/reminders/closer.rb +70 -0
  71. data/lib/plan_my_stuff/reminders/fire.rb +129 -0
  72. data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
  73. data/lib/plan_my_stuff/reminders.rb +16 -0
  74. data/lib/plan_my_stuff/test_helpers.rb +260 -15
  75. data/lib/plan_my_stuff/testing_project.rb +291 -0
  76. data/lib/plan_my_stuff/testing_project_item.rb +184 -0
  77. data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
  78. data/lib/plan_my_stuff/user_resolver.rb +8 -3
  79. data/lib/plan_my_stuff/version.rb +1 -1
  80. data/lib/plan_my_stuff/webhook_replayer.rb +280 -0
  81. data/lib/plan_my_stuff.rb +15 -0
  82. data/lib/tasks/plan_my_stuff.rake +163 -0
  83. metadata +50 -2
@@ -9,25 +9,30 @@ module PlanMyStuff
9
9
  # - `Issue.create!` / `Issue.find` / `Issue.list` return persisted instances
10
10
  # - `issue.save!` / `issue.update!` / `issue.reload` for persistence
11
11
  class Issue < PlanMyStuff::ApplicationRecord
12
- # @return [Integer] GitHub issue number
13
- attr_reader :number
14
- # @return [String] full body as stored on GitHub
15
- attr_reader :raw_body
12
+ # @return [Integer, nil] GitHub issue number
13
+ attribute :number, :integer
14
+ # @return [String, nil] full body as stored on GitHub
15
+ attribute :raw_body, :string
16
16
  # @return [PlanMyStuff::IssueMetadata] parsed metadata (empty when no PMS metadata present)
17
- attr_reader :metadata
18
-
19
- # @return [String] issue title
20
- attr_accessor :title
21
- # @return [String] issue body without the metadata HTML comment
22
- attr_writer :body
23
- # @return [String] issue state ("open" or "closed")
24
- attr_accessor :state
17
+ attribute :metadata, default: -> { PlanMyStuff::IssueMetadata.new }
18
+ # @return [String, nil] issue title
19
+ attribute :title, :string
20
+ # @return [String, nil] issue state ("open" or "closed")
21
+ attribute :state, :string
25
22
  # @return [Array<String>] label names
26
- attr_accessor :labels
23
+ attribute :labels, default: -> { [] }
27
24
  # @return [Time, nil] GitHub's updated_at timestamp
28
- attr_reader :updated_at
25
+ attribute :updated_at
26
+ # @return [Time, nil] GitHub's closed_at timestamp (nil while open)
27
+ attribute :closed_at
28
+ # @return [Boolean] GitHub's +locked+ flag; +true+ for archived or
29
+ # manually-locked issues (no new comments)
30
+ attribute :locked, :boolean, default: false
31
+ alias locked? locked
29
32
  # @return [PlanMyStuff::Repo, nil]
30
- attr_reader :repo
33
+ attribute :repo
34
+ # @return [String, nil] issue body (user-visible content, separate from metadata)
35
+ attribute :body, :string
31
36
 
32
37
  class << self
33
38
  # Creates a GitHub issue with PMS metadata embedded in the body.
@@ -39,6 +44,7 @@ module PlanMyStuff
39
44
  # @param user [Object, Integer] user object or user_id
40
45
  # @param metadata [Hash] custom fields hash
41
46
  # @param add_to_project [Boolean, Integer, nil]
47
+ # @param visibility [String] "public" or "internal"
42
48
  # @param visibility_allowlist [Array<Integer>] user IDs for internal comment access
43
49
  #
44
50
  # @return [PlanMyStuff::Issue]
@@ -51,6 +57,7 @@ module PlanMyStuff
51
57
  user: nil,
52
58
  metadata: {},
53
59
  add_to_project: nil,
60
+ visibility: 'public',
54
61
  visibility_allowlist: []
55
62
  )
56
63
  raise(ValidationError.new('body must be present', field: :body, expected_type: :string)) if body.blank?
@@ -60,6 +67,7 @@ module PlanMyStuff
60
67
 
61
68
  issue_metadata = IssueMetadata.build(
62
69
  user: user,
70
+ visibility: visibility,
63
71
  custom_fields: metadata,
64
72
  )
65
73
  issue_metadata.visibility_allowlist = Array.wrap(visibility_allowlist)
@@ -72,6 +80,7 @@ module PlanMyStuff
72
80
 
73
81
  result = client.rest(:create_issue, resolved_repo, title, serialized_body, **options)
74
82
  number = read_field(result, :number)
83
+ store_etag_to_cache(client, resolved_repo, number, result, cache_writer: :write_issue)
75
84
 
76
85
  issue = find(number, repo: resolved_repo)
77
86
 
@@ -89,22 +98,43 @@ module PlanMyStuff
89
98
  issue_body: true,
90
99
  )
91
100
 
101
+ issue.reload
102
+ PlanMyStuff::Notifications.instrument('issue.created', issue, user: user)
92
103
  issue
93
104
  end
94
105
 
95
106
  # Updates an existing GitHub issue.
96
107
  #
108
+ # +metadata:+ accepts either:
109
+ # - a +PlanMyStuff::IssueMetadata+ instance - treated as the
110
+ # full authoritative metadata and serialized as-is (used by
111
+ # instance +save!+/+update!+ so local +@metadata+ mutations
112
+ # like +metadata.commit_sha = ...+ actually persist).
113
+ # - a +Hash+ - patch-style merge against the CURRENT remote
114
+ # metadata. Top-level keys are merged in; +:custom_fields+
115
+ # is merged separately so unrelated fields stay intact.
116
+ #
97
117
  # @param number [Integer]
98
118
  # @param repo [Symbol, String, nil] defaults to config.default_repo
99
119
  # @param title [String, nil]
100
120
  # @param body [String, nil]
101
- # @param metadata [Hash, nil] custom fields to merge into existing metadata
121
+ # @param metadata [PlanMyStuff::IssueMetadata, Hash, nil]
102
122
  # @param labels [Array<String>, nil]
103
123
  # @param state [Symbol, nil] :open or :closed
124
+ # @param assignees [Array<String>, String, nil] GitHub logins
104
125
  #
105
126
  # @return [Object]
106
127
  #
107
- def update!(number:, repo: nil, title: nil, body: nil, metadata: nil, labels: nil, state: nil, assignees: nil)
128
+ def update!(
129
+ number:,
130
+ repo: nil,
131
+ title: nil,
132
+ body: nil,
133
+ metadata: nil,
134
+ labels: nil,
135
+ state: nil,
136
+ assignees: nil
137
+ )
108
138
  client = PlanMyStuff.client
109
139
  resolved_repo = client.resolve_repo(repo)
110
140
 
@@ -114,7 +144,11 @@ module PlanMyStuff
114
144
  options[:state] = state.to_s unless state.nil?
115
145
  options[:assignees] = Array.wrap(assignees) unless assignees.nil?
116
146
 
117
- if metadata
147
+ case metadata
148
+ when PlanMyStuff::IssueMetadata
149
+ metadata.validate_custom_fields!
150
+ options[:body] = MetadataParser.serialize(metadata.to_h, '')
151
+ when Hash
118
152
  current = client.rest(:issue, resolved_repo, number)
119
153
  current_body = current.respond_to?(:body) ? current.body : current[:body]
120
154
  parsed = MetadataParser.parse(current_body)
@@ -133,7 +167,11 @@ module PlanMyStuff
133
167
 
134
168
  update_body_comment(number, resolved_repo, body) if body
135
169
 
136
- client.rest(:update_issue, resolved_repo, number, **options) if options.any?
170
+ return if options.none?
171
+
172
+ result = client.rest(:update_issue, resolved_repo, number, **options)
173
+ store_etag_to_cache(client, resolved_repo, number, result, cache_writer: :write_issue)
174
+ result
137
175
  end
138
176
 
139
177
  # Finds a single GitHub issue by number and parses its PMS metadata.
@@ -147,7 +185,15 @@ module PlanMyStuff
147
185
  client = PlanMyStuff.client
148
186
  resolved_repo = client.resolve_repo(repo)
149
187
 
150
- github_issue = client.rest(:issue, resolved_repo, number)
188
+ github_issue =
189
+ fetch_with_etag_cache(
190
+ client,
191
+ resolved_repo,
192
+ number,
193
+ rest_method: :issue,
194
+ cache_reader: :read_issue,
195
+ cache_writer: :write_issue,
196
+ )
151
197
 
152
198
  if github_issue.respond_to?(:pull_request) && github_issue.pull_request
153
199
  raise(Octokit::NotFound, { method: 'GET', url: "repos/#{resolved_repo}/issues/#{number}" })
@@ -170,43 +216,21 @@ module PlanMyStuff
170
216
  client = PlanMyStuff.client
171
217
  resolved_repo = client.resolve_repo(repo)
172
218
 
173
- options = { state: state.to_s, page: page, per_page: per_page }
174
- options[:labels] = labels.join(',') if labels.any?
219
+ params = { state: state.to_s, page: page, per_page: per_page }
220
+ params[:labels] = labels.sort.join(',') if labels.any?
175
221
 
176
- github_issues = client.rest(:list_issues, resolved_repo, **options)
177
- github_issues.filter_map do |gi|
178
- next if gi.respond_to?(:pull_request) && gi.pull_request
222
+ cached = PlanMyStuff::Cache.read_list(:issue, resolved_repo, params)
223
+ request_options = cached ? params.merge(headers: { 'If-None-Match' => cached[:etag] }) : params
179
224
 
180
- build(gi, repo: resolved_repo)
181
- end
182
- end
225
+ github_issues = client.rest(:list_issues, resolved_repo, **request_options)
183
226
 
184
- # Adds user IDs to the visibility allowlist of an issue's metadata.
185
- #
186
- # @param number [Integer]
187
- # @param repo [Symbol, String, nil] defaults to config.default_repo
188
- # @param user_ids [Array<Integer>]
189
- #
190
- # @return [Object] Octokit response
191
- #
192
- def add_viewers(number:, user_ids:, repo: nil)
193
- modify_allowlist(number: number, repo: repo) do |allowlist|
194
- allowlist | Array.wrap(user_ids)
227
+ if cached && not_modified?(client)
228
+ return cached[:body].map { |gi| build(gi, repo: resolved_repo) }
195
229
  end
196
- end
197
230
 
198
- # Removes user IDs from the visibility allowlist of an issue's metadata.
199
- #
200
- # @param number [Integer]
201
- # @param repo [Symbol, String, nil] defaults to config.default_repo
202
- # @param user_ids [Array<Integer>]
203
- #
204
- # @return [Object] Octokit response
205
- #
206
- def remove_viewers(number:, user_ids:, repo: nil)
207
- modify_allowlist(number: number, repo: repo) do |allowlist|
208
- allowlist - Array.wrap(user_ids)
209
- end
231
+ filtered = github_issues.reject { |gi| gi.respond_to?(:pull_request) && gi.pull_request }
232
+ store_list_etag_to_cache(client, :issue, resolved_repo, params, filtered)
233
+ filtered.map { |gi| build(gi, repo: resolved_repo) }
210
234
  end
211
235
 
212
236
  private
@@ -232,30 +256,6 @@ module PlanMyStuff
232
256
  raise(ArgumentError, 'add_to_project: true requires config.default_project_number to be set')
233
257
  end
234
258
 
235
- # Reads an issue's metadata, yields the allowlist for modification,
236
- # and PATCHes the issue body with the updated allowlist.
237
- #
238
- # @param number [Integer]
239
- # @param repo [Symbol, String, nil]
240
- #
241
- # @return [Object] Octokit response
242
- #
243
- def modify_allowlist(number:, repo:)
244
- client = PlanMyStuff.client
245
- resolved_repo = client.resolve_repo(repo)
246
-
247
- current = client.rest(:issue, resolved_repo, number)
248
- current_body = current.respond_to?(:body) ? current.body : current[:body]
249
- parsed = MetadataParser.parse(current_body)
250
-
251
- existing_metadata = parsed[:metadata]
252
- allowlist = Array.wrap(existing_metadata[:visibility_allowlist])
253
- existing_metadata[:visibility_allowlist] = yield(allowlist)
254
-
255
- new_body = MetadataParser.serialize(existing_metadata, parsed[:body])
256
- client.rest(:update_issue, resolved_repo, number, body: new_body)
257
- end
258
-
259
259
  # Finds the first PMS comment on an issue and updates its body content,
260
260
  # preserving the comment header and metadata.
261
261
  #
@@ -275,59 +275,392 @@ module PlanMyStuff
275
275
  end
276
276
 
277
277
  def initialize(**attrs)
278
- @number = attrs.delete(:number)
279
- @raw_body = nil
280
- @metadata = IssueMetadata.new
278
+ @body_dirty = false
281
279
  super
282
- @labels ||= []
283
280
  end
284
281
 
285
282
  # @param value [PlanMyStuff::Repo, Symbol, String, nil]
286
283
  def repo=(value)
287
- @repo = value.present? ? PlanMyStuff::Repo.resolve(value) : nil
284
+ super(value.present? ? PlanMyStuff::Repo.resolve(value) : nil)
285
+ end
286
+
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.
291
+ #
292
+ # @param value [String]
293
+ #
294
+ # @return [String]
295
+ #
296
+ def body=(value)
297
+ super
298
+ @body_dirty = true
299
+ end
300
+
301
+ # @return [Array<PlanMyStuff::Approval>] all required approvers (pending + approved)
302
+ def approvers
303
+ metadata.approvals
304
+ end
305
+
306
+ # @return [Array<PlanMyStuff::Approval>] approvers who have not yet approved
307
+ def pending_approvals
308
+ approvers.select(&:pending?)
309
+ end
310
+
311
+ # @return [Boolean] true when at least one approver is required on this issue
312
+ def approvals_required?
313
+ approvers.any?
314
+ end
315
+
316
+ # @return [Boolean] true when approvers are required AND every approver has approved
317
+ def fully_approved?
318
+ approvals_required? && pending_approvals.empty?
319
+ end
320
+
321
+ # Adds user IDs to this issue's visibility allowlist (non-support
322
+ # users whose ID is in the allowlist can see internal comments).
323
+ #
324
+ # Fires +plan_my_stuff.issue.viewers_added+.
325
+ #
326
+ # @param user_ids [Array<Integer>, Integer]
327
+ # @param user [Object, nil] actor for the notification event
328
+ #
329
+ # @return [Array<Integer>] the new allowlist
330
+ #
331
+ def add_viewers(user_ids:, user: nil)
332
+ ids = Array.wrap(user_ids)
333
+ modify_allowlist { |allowlist| allowlist | ids }
334
+ PlanMyStuff::Notifications.instrument('issue.viewers_added', self, user: user, user_ids: ids)
335
+ metadata.visibility_allowlist
336
+ end
337
+
338
+ # Removes user IDs from this issue's visibility allowlist.
339
+ #
340
+ # Fires +plan_my_stuff.issue.viewers_removed+.
341
+ #
342
+ # @param user_ids [Array<Integer>, Integer]
343
+ # @param user [Object, nil] actor for the notification event
344
+ #
345
+ # @return [Array<Integer>] the new allowlist
346
+ #
347
+ def remove_viewers(user_ids:, user: nil)
348
+ ids = Array.wrap(user_ids)
349
+ modify_allowlist { |allowlist| allowlist - ids }
350
+ PlanMyStuff::Notifications.instrument('issue.viewers_removed', self, user: user, user_ids: ids)
351
+ metadata.visibility_allowlist
352
+ end
353
+
354
+ # Adds approvers to this issue's required-approvals list. Idempotent:
355
+ # users already present are no-ops. Only support users may call this.
356
+ #
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
360
+ # out of a fully-approved state.
361
+ #
362
+ # @param user_ids [Array<Integer>, Integer]
363
+ # @param user [Object, nil] actor; must be a support user
364
+ #
365
+ # @raise [PlanMyStuff::AuthorizationError] if +user+ is not support
366
+ #
367
+ # @return [Array<PlanMyStuff::Approval>] newly-added approvals (empty when all were duplicates)
368
+ #
369
+ def request_approvals!(user_ids:, user: nil)
370
+ guard_support!(user)
371
+ ids = Array.wrap(user_ids).map(&:to_i)
372
+
373
+ just_added, was_fully_approved = modify_approvals do |current|
374
+ existing_ids = current.map(&:user_id)
375
+ new_ids = ids - existing_ids
376
+ added = new_ids.map { |id| PlanMyStuff::Approval.new(user_id: id, status: 'pending') }
377
+ [current + added, added]
378
+ end
379
+
380
+ finish_request_approvals(just_added, user: user, was_fully_approved: was_fully_approved)
381
+ just_added
382
+ end
383
+
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?+).
390
+ #
391
+ # @param user_ids [Array<Integer>, Integer]
392
+ # @param user [Object, nil] actor; must be a support user
393
+ #
394
+ # @raise [PlanMyStuff::AuthorizationError] if +user+ is not support
395
+ #
396
+ # @return [Array<PlanMyStuff::Approval>] removed approval records
397
+ #
398
+ def remove_approvers!(user_ids:, user: nil)
399
+ guard_support!(user)
400
+ ids = Array.wrap(user_ids).map(&:to_i)
401
+
402
+ just_removed, was_fully_approved = modify_approvals do |current|
403
+ removed = current.select { |a| ids.include?(a.user_id) }
404
+ [current - removed, removed]
405
+ end
406
+
407
+ emit_aggregate_events(was_fully_approved: was_fully_approved, trigger: nil, user: user)
408
+ just_removed
409
+ end
410
+
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+
417
+ #
418
+ # @raise [PlanMyStuff::ValidationError] when the caller is not in the approvers list or is already approved
419
+ #
420
+ # @return [PlanMyStuff::Approval] the updated approval
421
+ #
422
+ def approve!(user:)
423
+ actor_id = resolve_actor_id!(user)
424
+
425
+ just_approved, was_fully_approved = modify_approvals do |current|
426
+ approval = current.find { |a| a.user_id == actor_id }
427
+ 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?
429
+
430
+ approval.status = 'approved'
431
+ approval.approved_at = Time.current
432
+ [current, approval]
433
+ end
434
+
435
+ finish_state_change(:approval_granted, just_approved, user: user, was_fully_approved: was_fully_approved)
436
+ just_approved
437
+ end
438
+
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+.
444
+ #
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+).
448
+ #
449
+ # @param user [Object, Integer] the caller
450
+ # @param target_user_id [Integer, nil] approver whose approval should be revoked; defaults to the caller
451
+ #
452
+ # @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
454
+ #
455
+ # @return [PlanMyStuff::Approval] the updated approval
456
+ #
457
+ def revoke_approval!(user:, target_user_id: nil)
458
+ actor_id = resolve_actor_id!(user)
459
+ caller_is_support = PlanMyStuff::UserResolver.support?(PlanMyStuff::UserResolver.resolve(user))
460
+ target_id = target_user_id&.to_i || actor_id
461
+
462
+ if !caller_is_support && target_id != actor_id
463
+ raise(PlanMyStuff::AuthorizationError, "Only support users may revoke another user's approval")
464
+ end
465
+
466
+ just_revoked, was_fully_approved = modify_approvals do |current|
467
+ approval = current.find { |a| a.user_id == target_id }
468
+ 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?
470
+
471
+ approval.status = 'pending'
472
+ approval.approved_at = nil
473
+ [current, approval]
474
+ end
475
+
476
+ finish_state_change(
477
+ :approval_revoked,
478
+ just_revoked,
479
+ user: user,
480
+ was_fully_approved: was_fully_approved,
481
+ trigger: :revoked,
482
+ )
483
+ just_revoked
484
+ end
485
+
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.
493
+ #
494
+ # @param user [Object, nil] actor for the label notification event
495
+ #
496
+ # @return [self]
497
+ #
498
+ def enter_waiting_on_user!(user: nil)
499
+ now = Time.now.utc
500
+ label = PlanMyStuff.configuration.waiting_on_user_label
501
+
502
+ PlanMyStuff::Label.ensure!(repo: repo, name: label)
503
+ PlanMyStuff::Label.add(issue: self, labels: [label], user: user) if labels.exclude?(label)
504
+
505
+ self.class.update!(
506
+ number: number,
507
+ repo: repo,
508
+ metadata: {
509
+ waiting_on_user_at: now.iso8601,
510
+ next_reminder_at: format_next_reminder_at(from: now),
511
+ },
512
+ )
513
+ reload
514
+ end
515
+
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.
521
+ #
522
+ # @return [self]
523
+ #
524
+ def clear_waiting_on_user!
525
+ label = PlanMyStuff.configuration.waiting_on_user_label
526
+ return self if metadata.waiting_on_user_at.nil? && labels.exclude?(label)
527
+
528
+ PlanMyStuff::Label.remove(issue: self, labels: [label]) if labels.include?(label)
529
+
530
+ self.class.update!(
531
+ number: number,
532
+ repo: repo,
533
+ metadata: {
534
+ waiting_on_user_at: nil,
535
+ next_reminder_at: metadata.waiting_on_approval_at ? format_time(metadata.next_reminder_at) : nil,
536
+ },
537
+ )
538
+ reload
539
+ end
540
+
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.
547
+ #
548
+ # @param comment [PlanMyStuff::Comment] the reopening comment
549
+ # @param user [Object, nil] actor for the notification event
550
+ #
551
+ # @return [self]
552
+ #
553
+ def reopen_by_reply!(comment:, user: nil)
554
+ inactive_label = PlanMyStuff.configuration.user_inactive_label
555
+ PlanMyStuff::Label.remove(issue: self, labels: [inactive_label]) if labels.include?(inactive_label)
556
+
557
+ self.class.update!(
558
+ number: number,
559
+ repo: repo,
560
+ state: :open,
561
+ metadata: { closed_by_inactivity: false },
562
+ )
563
+ reload
564
+
565
+ PlanMyStuff::Notifications.instrument(
566
+ 'issue.reopened_by_reply',
567
+ self,
568
+ user: user,
569
+ comment: comment,
570
+ )
571
+ self
572
+ end
573
+
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.
578
+ #
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).
582
+ #
583
+ # @param now [Time] clock reference for +metadata.archived_at+
584
+ #
585
+ # @return [self]
586
+ #
587
+ def archive!(now: Time.now.utc)
588
+ label = PlanMyStuff.configuration.archived_label
589
+ return self unless state == 'closed'
590
+
591
+ return self if metadata.archived_at.present?
592
+ return self if labels.include?(label)
593
+
594
+ self.class.update!(
595
+ number: number,
596
+ repo: repo,
597
+ metadata: { archived_at: now.utc.iso8601 },
598
+ )
599
+
600
+ PlanMyStuff::Label.ensure!(repo: repo, name: label)
601
+ PlanMyStuff::Label.add(issue: self, labels: [label])
602
+
603
+ remove_from_all_projects!
604
+
605
+ PlanMyStuff.client.rest(:lock_issue, repo.full_name, number)
606
+
607
+ reload
608
+
609
+ PlanMyStuff::Notifications.instrument(
610
+ 'issue.archived',
611
+ self,
612
+ reason: :aged_closed,
613
+ )
614
+ self
288
615
  end
289
616
 
290
- # Persists the issue. Creates if new, updates if persisted.
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.
291
622
  #
292
623
  # @raise [PlanMyStuff::StaleObjectError] on update if stale
293
624
  #
294
625
  # @return [self]
295
626
  #
296
- def save!
627
+ def save!(user: nil, skip_notification: false)
297
628
  if new_record?
298
629
  created = self.class.create!(
299
630
  title: title,
300
631
  body: body,
301
632
  repo: repo,
302
633
  labels: labels || [],
634
+ user: user || metadata.created_by,
635
+ metadata: metadata.custom_fields.to_h,
636
+ visibility: metadata.visibility,
637
+ visibility_allowlist: Array.wrap(metadata.visibility_allowlist),
303
638
  )
304
639
  hydrate_from_issue(created)
305
640
  else
306
- update!(body: body, state: state, labels: labels)
641
+ captured_changes = changes.dup
642
+ persist_update!
643
+ instrument_update(captured_changes, user) unless skip_notification
307
644
  end
308
645
 
309
646
  self
310
647
  end
311
648
 
312
- # Updates this issue on GitHub. Raises StaleObjectError if the remote
313
- # has been modified since this instance was loaded.
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).
314
654
  #
315
- # @param attrs [Hash] attributes to update (title:, body:, state:, labels:, metadata:)
655
+ # @param user [Object, nil] actor for notification events
316
656
  #
317
657
  # @raise [PlanMyStuff::StaleObjectError] if remote updated_at differs from local
318
658
  #
319
659
  # @return [self]
320
660
  #
321
- def update!(**attrs)
322
- raise_if_stale!
323
-
324
- self.class.update!(
325
- number: number,
326
- repo: repo,
327
- **attrs,
328
- )
329
-
330
- reload
661
+ def update!(user: nil, skip_notification: false, **attrs)
662
+ apply_update_attrs!(attrs)
663
+ save!(user: user, skip_notification: skip_notification)
331
664
  end
332
665
 
333
666
  # Re-fetches this issue from GitHub and updates all local attributes.
@@ -348,6 +681,14 @@ module PlanMyStuff
348
681
  @comments ||= load_comments
349
682
  end
350
683
 
684
+ # GitHub web URL for this issue, for escape-hatch "View on GitHub" links.
685
+ #
686
+ # @return [String, nil]
687
+ #
688
+ def html_url
689
+ safe_read_field(github_response, :html_url)
690
+ end
691
+
351
692
  # @return [Boolean]
352
693
  def pms_issue?
353
694
  metadata.schema_version.present?
@@ -373,14 +714,14 @@ module PlanMyStuff
373
714
  # @return [String, nil]
374
715
  #
375
716
  def body
376
- return @body if new_record?
377
-
378
- return @body unless pms_issue?
717
+ return super if new_record?
718
+ return super if @body_dirty
719
+ return super unless pms_issue?
379
720
 
380
721
  bc = body_comment
381
722
  return bc.body_without_header if bc.present?
382
723
 
383
- @body
724
+ super
384
725
  end
385
726
 
386
727
  # Delegates visibility check to metadata.
@@ -398,93 +739,1053 @@ module PlanMyStuff
398
739
  end
399
740
  end
400
741
 
401
- private
742
+ # Lazy-memoized array of +Issue+ objects for +:related+ links.
743
+ # Silently drops targets that 404 so a dangling pointer doesn't
744
+ # break the rest of the list.
745
+ #
746
+ # @return [Array<PlanMyStuff::Issue>]
747
+ #
748
+ def related
749
+ links_cache[:related] ||= fetch_related
750
+ end
402
751
 
403
- # Populates this instance from a GitHub API response.
404
- #
405
- # @param github_issue [Object] Octokit issue response
406
- # @param repo [String] resolved repo path
407
- #
408
- # @return [void]
409
- #
410
- def hydrate_from_github(github_issue, repo:)
411
- @number = read_field(github_issue, :number)
412
- @title = read_field(github_issue, :title)
413
- @state = read_field(github_issue, :state)
414
- @raw_body = read_field(github_issue, :body) || ''
415
- @updated_at = parse_github_time(safe_read_field(github_issue, :updated_at))
416
- @labels = extract_labels(github_issue)
417
- self.repo = repo
752
+ # Adds a +:related+ link to +target+ and, unless this call is
753
+ # already a reciprocal, mirrors the link back on +target+ so
754
+ # the pairing is symmetric. Dedups on
755
+ # +(type, issue_number, repo)+ - re-adding is a no-op.
756
+ #
757
+ # @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
758
+ # @param user [Object, nil] actor for notification events
759
+ # @param reciprocal [Boolean] internal flag; set by the mirror call
760
+ #
761
+ # @return [PlanMyStuff::Link]
762
+ #
763
+ def add_related!(target, user: nil, reciprocal: false)
764
+ link = build_link(target, type: :related)
765
+ validate_not_self!(link)
418
766
 
419
- parsed = MetadataParser.parse(@raw_body)
420
- @metadata = IssueMetadata.from_hash(parsed[:metadata])
421
- @body = parsed[:body]
422
- @persisted = true
423
- @comments = nil
424
- end
767
+ existing = current_links
768
+ return link if existing.include?(link)
425
769
 
426
- # Copies attributes from another Issue instance into self.
427
- #
428
- # @param other [PlanMyStuff::Issue]
429
- #
430
- # @return [void]
431
- #
432
- def hydrate_from_issue(other)
433
- @number = other.number
434
- @title = other.title
435
- @state = other.state
436
- @body = other.instance_variable_get(:@body)
437
- @raw_body = other.raw_body
438
- @updated_at = other.updated_at
439
- @labels = other.labels
440
- @repo = other.repo
441
- @metadata = other.metadata
442
- @persisted = true
443
- @comments = nil
770
+ persist_links!(existing + [link])
771
+ unless reciprocal
772
+ mirror_on_target(link, user: user) { |other| other.add_related!(self, user: user, reciprocal: true) }
444
773
  end
445
774
 
446
- # Raises StaleObjectError if the remote issue has been modified
447
- # since this instance was loaded.
448
- #
449
- # @raise [PlanMyStuff::StaleObjectError]
450
- #
451
- # @return [void]
452
- #
453
- def raise_if_stale!
454
- return if new_record?
455
- return if updated_at.nil?
775
+ link
776
+ end
456
777
 
457
- remote = self.class.find(number, repo: repo)
458
- remote_time = remote.updated_at
459
- local_time = updated_at
778
+ # Removes a +:related+ link to +target+ and, unless this call is
779
+ # already a reciprocal, mirrors the removal on +target+. No-op
780
+ # when the link isn't present locally.
781
+ #
782
+ # @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
783
+ # @param user [Object, nil]
784
+ # @param reciprocal [Boolean]
785
+ #
786
+ # @return [PlanMyStuff::Link]
787
+ #
788
+ def remove_related!(target, user: nil, reciprocal: false)
789
+ link = build_link(target, type: :related)
790
+ validate_not_self!(link)
460
791
 
461
- return if remote_time.nil?
462
- return if local_time && remote_time.to_i == local_time.to_i
792
+ existing = current_links
793
+ return link if existing.exclude?(link)
463
794
 
464
- raise(StaleObjectError.new(
465
- "Issue ##{number} has been modified remotely",
466
- local_updated_at: local_time,
467
- remote_updated_at: remote_time,
468
- ))
795
+ persist_links!(existing.reject { |l| l == link })
796
+ unless reciprocal
797
+ mirror_on_target(link, user: user) { |other| other.remove_related!(self, user: user, reciprocal: true) }
469
798
  end
470
799
 
471
- # @return [Array<String>]
472
- def extract_labels(github_issue)
473
- raw = read_field(github_issue, :labels) || []
474
- raw.map { |label| label_name(label) }
475
- end
800
+ link
801
+ end
476
802
 
477
- # @return [String]
478
- def label_name(label)
479
- return label.name if label.respond_to?(:name)
480
- return label[:name] || label['name'] if label.is_a?(Hash)
803
+ # Lazy-memoized parent issue via GitHub's native sub-issues API.
804
+ # GitHub enforces at most one parent per issue.
805
+ #
806
+ # @return [PlanMyStuff::Issue, nil]
807
+ #
808
+ def parent
809
+ return links_cache[:parent] if links_cache.key?(:parent)
481
810
 
482
- label.to_s
483
- end
811
+ links_cache[:parent] = fetch_parent
812
+ end
484
813
 
485
- # @return [Array<PlanMyStuff::Comment>]
486
- def load_comments
487
- Comment.list(issue: self)
814
+ # Lazy-memoized sub-issues via GitHub's native sub-issues API.
815
+ #
816
+ # @return [Array<PlanMyStuff::Issue>]
817
+ #
818
+ def sub_tickets
819
+ links_cache[:sub_tickets] ||= fetch_sub_tickets
820
+ end
821
+
822
+ # Adds +target+ as a sub-issue of self via
823
+ # +POST /issues/{number}/sub_issues+. Native GitHub action;
824
+ # notifications are handled by GitHub itself.
825
+ #
826
+ # @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
827
+ #
828
+ # @return [PlanMyStuff::Link]
829
+ #
830
+ def add_sub_issue!(target)
831
+ mutate_sub_issue!(target, method: :post, path: sub_issues_path)
832
+ end
833
+
834
+ # Removes +target+ as a sub-issue of self via
835
+ # +DELETE /issues/{number}/sub_issue+ (singular).
836
+ #
837
+ # @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
838
+ #
839
+ # @return [PlanMyStuff::Link]
840
+ #
841
+ def remove_sub_issue!(target)
842
+ mutate_sub_issue!(target, method: :delete, path: remove_sub_issue_path)
843
+ end
844
+
845
+ # Makes +target+ the parent of self. If self already has a parent,
846
+ # it is detached first. Returns a +Link+ describing the new
847
+ # +:parent+ relationship.
848
+ #
849
+ # @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
850
+ #
851
+ # @return [PlanMyStuff::Link]
852
+ #
853
+ def set_parent!(target)
854
+ parent.presence&.remove_sub_issue!(self)
855
+
856
+ target_issue = resolve_target_issue(target, type: :parent)
857
+ target_issue.add_sub_issue!(self)
858
+ invalidate_links_cache!
859
+
860
+ build_link(target_issue, type: :parent)
861
+ end
862
+
863
+ # Detaches self from its current parent, if any. Returns the
864
+ # +Link+ that was removed, or nil when there was no parent.
865
+ #
866
+ # @return [PlanMyStuff::Link, nil]
867
+ #
868
+ def remove_parent!
869
+ current = parent
870
+ return if current.nil?
871
+
872
+ current.remove_sub_issue!(self)
873
+ invalidate_links_cache!
874
+
875
+ build_link(current, type: :parent)
876
+ end
877
+
878
+ # Lazy-memoized issues that block self (i.e. self is blocked by
879
+ # each returned issue) via GitHub's native issue-dependency REST
880
+ # API.
881
+ #
882
+ # @return [Array<PlanMyStuff::Issue>]
883
+ #
884
+ def blocked_by
885
+ links_cache[:blocked_by] ||= fetch_dependencies('blocked_by')
886
+ end
887
+
888
+ # Lazy-memoized issues that self blocks.
889
+ #
890
+ # @return [Array<PlanMyStuff::Issue>]
891
+ #
892
+ def blocking
893
+ links_cache[:blocking] ||= fetch_dependencies('blocking')
894
+ end
895
+
896
+ # Records that +target+ blocks self. Native GitHub action;
897
+ # notifications are handled by GitHub itself.
898
+ #
899
+ # @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
900
+ #
901
+ # @return [PlanMyStuff::Link]
902
+ #
903
+ def add_blocker!(target)
904
+ link = build_link(target, type: :blocked_by)
905
+ validate_not_self!(link)
906
+
907
+ target_issue = resolve_target_issue(target, type: :blocked_by)
908
+ PlanMyStuff.client.rest(
909
+ :post,
910
+ dependency_path('blocked_by'),
911
+ { issue_id: target_issue.__send__(:require_github_id!) },
912
+ )
913
+ invalidate_links_cache!
914
+ link
915
+ end
916
+
917
+ # Removes the record that +target+ blocks self.
918
+ #
919
+ # @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
920
+ #
921
+ # @return [PlanMyStuff::Link]
922
+ #
923
+ def remove_blocker!(target)
924
+ link = build_link(target, type: :blocked_by)
925
+ validate_not_self!(link)
926
+
927
+ target_issue = resolve_target_issue(target, type: :blocked_by)
928
+ PlanMyStuff.client.rest(
929
+ :delete,
930
+ "#{dependency_path('blocked_by')}/#{target_issue.__send__(:require_github_id!)}",
931
+ )
932
+ invalidate_links_cache!
933
+ link
934
+ end
935
+
936
+ # Lazy-memoized issue that self was marked as duplicate of, via
937
+ # GitHub's native close-as-duplicate. Returns nil for issues that
938
+ # are open or closed for other reasons.
939
+ #
940
+ # @return [PlanMyStuff::Issue, nil]
941
+ #
942
+ def duplicate_of
943
+ return links_cache[:duplicate_of] if links_cache.key?(:duplicate_of)
944
+
945
+ links_cache[:duplicate_of] = fetch_duplicate_of
946
+ end
947
+
948
+ # Closes self as a duplicate of +target+ via GitHub's native
949
+ # close-as-duplicate, carrying over viewers, assignees, and a
950
+ # back-pointer comment on the target.
951
+ #
952
+ # Side effects, in order:
953
+ # 1. Resolves +target+; raises +ValidationError+ if missing.
954
+ # 2. Raises +ValidationError+ when self is already closed.
955
+ # 3. Merges self's +visibility_allowlist+ onto target.
956
+ # 4. Merges self's assignees onto target.
957
+ # 5. Posts a PMS comment on target with the back-pointer.
958
+ # 6. Closes self with +state_reason: :duplicate+ and
959
+ # +duplicate_of: { owner:, repo:, number: }+.
960
+ # 7. Reloads self; invalidates link caches.
961
+ # 8. Fires +plan_my_stuff.issue.marked_duplicate+.
962
+ #
963
+ # Partial failures are not rolled back - GitHub retains whatever
964
+ # side effects succeeded before the failing step.
965
+ #
966
+ # @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
967
+ # @param user [Object, nil] actor for notification + comment
968
+ #
969
+ # @return [PlanMyStuff::Link]
970
+ #
971
+ def mark_duplicate!(target, user: nil)
972
+ raise(PlanMyStuff::ValidationError, 'Cannot mark a closed issue as duplicate') if state == 'closed'
973
+
974
+ target_issue = resolve_duplicate_target!(target)
975
+ merge_visibility_allowlist_onto(target_issue)
976
+ merge_assignees_onto(target_issue)
977
+ post_duplicate_back_pointer(target_issue, user: user)
978
+ close_as_duplicate!(target_issue)
979
+
980
+ reload
981
+ invalidate_links_cache!
982
+ PlanMyStuff::Notifications.instrument('issue.marked_duplicate', self, target: target_issue, user: user)
983
+
984
+ build_link(target_issue, type: :duplicate_of)
985
+ end
986
+
987
+ # GitHub GraphQL node ID (required for native sub-issue mutations).
988
+ # Read from the hydrated REST response.
989
+ #
990
+ # @return [String, nil]
991
+ #
992
+ def github_node_id
993
+ safe_read_field(github_response, :node_id)
994
+ end
995
+
996
+ # GitHub database ID (required for the REST issue-dependency API,
997
+ # which takes integer issue_id rather than issue number).
998
+ #
999
+ # @return [Integer, nil]
1000
+ #
1001
+ def github_id
1002
+ safe_read_field(github_response, :id)
1003
+ end
1004
+
1005
+ private
1006
+
1007
+ # Yields +self.metadata.visibility_allowlist+ for modification,
1008
+ # persists the updated allowlist via the class-level +update!+,
1009
+ # and reloads +self+ so subsequent reads see the fresh state.
1010
+ #
1011
+ # @yieldparam allowlist [Array<Integer>]
1012
+ # @yieldreturn [Array<Integer>] the new allowlist
1013
+ #
1014
+ # @return [void]
1015
+ #
1016
+ def modify_allowlist
1017
+ new_allowlist = yield(Array.wrap(metadata.visibility_allowlist))
1018
+ self.class.update!(
1019
+ number: number,
1020
+ repo: repo,
1021
+ metadata: { visibility_allowlist: new_allowlist },
1022
+ )
1023
+ reload
1024
+ end
1025
+
1026
+ # Captures +fully_approved?+ state, yields the current approvals
1027
+ # (deep-copied) for mutation, persists the new list to GitHub, and
1028
+ # reloads +self+. Returns +[extra, was_fully_approved]+.
1029
+ #
1030
+ # @yieldparam current [Array<PlanMyStuff::Approval>] deep-copied approvals
1031
+ # @yieldreturn [Array(Array<PlanMyStuff::Approval>, Object)] +[new_list, extra]+
1032
+ #
1033
+ # @return [Array(Object, Boolean)]
1034
+ #
1035
+ def modify_approvals
1036
+ was_fully_approved = fully_approved?
1037
+ was_pending_count = metadata.approvals.count(&:pending?)
1038
+ current = metadata.approvals.map { |a| PlanMyStuff::Approval.new(a.attributes) }
1039
+
1040
+ new_list, extra = yield(current)
1041
+
1042
+ new_pending_count = new_list.count(&:pending?)
1043
+ metadata_updates = { approvals: new_list.map(&:to_h) }
1044
+ metadata_updates.merge!(waiting_on_approval_metadata_updates(was_pending_count, new_pending_count))
1045
+
1046
+ self.class.update!(number: number, repo: repo, metadata: metadata_updates)
1047
+ reload
1048
+
1049
+ sync_waiting_on_approval_label(was_pending_count, new_pending_count)
1050
+
1051
+ [extra, was_fully_approved]
1052
+ end
1053
+
1054
+ # Computes the metadata delta for the waiting-on-approval timer
1055
+ # based on the change in pending-approval count. The timer resets
1056
+ # only when pending count goes UP (add approver, revoke-to-pending)
1057
+ # so that remaining pending approvers keep their original schedule
1058
+ # when a peer approves. Drop-to-zero clears the timer entirely.
1059
+ #
1060
+ # @param was [Integer] pending count before the mutation
1061
+ # @param now [Integer] pending count after the mutation
1062
+ #
1063
+ # @return [Hash]
1064
+ #
1065
+ def waiting_on_approval_metadata_updates(was, now)
1066
+ if now > was
1067
+ ts = Time.now.utc
1068
+ {
1069
+ waiting_on_approval_at: ts.iso8601,
1070
+ next_reminder_at: format_next_reminder_at(from: ts),
1071
+ }
1072
+ elsif now.zero? && was.positive?
1073
+ {
1074
+ waiting_on_approval_at: nil,
1075
+ next_reminder_at: metadata.waiting_on_user_at ? format_time(metadata.next_reminder_at) : nil,
1076
+ }
1077
+ else
1078
+ {}
1079
+ end
1080
+ end
1081
+
1082
+ # Adds or removes the configured waiting-on-approval label when the
1083
+ # pending-approval count crosses the zero boundary. Mutations that
1084
+ # stay on the same side of zero leave the label untouched.
1085
+ #
1086
+ # @param was [Integer] pending count before the mutation
1087
+ # @param now [Integer] pending count after the mutation
1088
+ #
1089
+ # @return [void]
1090
+ #
1091
+ def sync_waiting_on_approval_label(was, now)
1092
+ label = PlanMyStuff.configuration.waiting_on_approval_label
1093
+
1094
+ if now.positive? && was.zero?
1095
+ PlanMyStuff::Label.ensure!(repo: repo, name: label)
1096
+ PlanMyStuff::Label.add(issue: self, labels: [label]) if labels.exclude?(label)
1097
+ elsif now.zero? && was.positive?
1098
+ PlanMyStuff::Label.remove(issue: self, labels: [label]) if labels.include?(label)
1099
+ end
1100
+ end
1101
+
1102
+ # Raises +AuthorizationError+ unless +user+ resolves to a support
1103
+ # user. +nil+ user is treated as unauthorized.
1104
+ #
1105
+ # @param user [Object, Integer, nil]
1106
+ #
1107
+ # @return [void]
1108
+ #
1109
+ def guard_support!(user)
1110
+ resolved = PlanMyStuff::UserResolver.resolve(user)
1111
+ return if resolved && PlanMyStuff::UserResolver.support?(resolved)
1112
+
1113
+ raise(PlanMyStuff::AuthorizationError, 'Only support users may manage approvers')
1114
+ end
1115
+
1116
+ # Resolves +user+ to an integer user_id. Raises +ArgumentError+
1117
+ # when +user+ is +nil+.
1118
+ #
1119
+ # @param user [Object, Integer]
1120
+ #
1121
+ # @return [Integer]
1122
+ #
1123
+ def resolve_actor_id!(user)
1124
+ raise(ArgumentError, 'user: is required') if user.nil?
1125
+
1126
+ resolved = PlanMyStuff::UserResolver.resolve(user)
1127
+ PlanMyStuff::UserResolver.user_id(resolved)
1128
+ end
1129
+
1130
+ # Fires +approval_requested+ (when any users were newly added) and,
1131
+ # if the aggregate state flipped out of fully-approved, the
1132
+ # +approvals_invalidated+ follow-up.
1133
+ #
1134
+ # @param added [Array<PlanMyStuff::Approval>]
1135
+ # @param user [Object, nil]
1136
+ # @param was_fully_approved [Boolean]
1137
+ #
1138
+ # @return [void]
1139
+ #
1140
+ def finish_request_approvals(added, user:, was_fully_approved:)
1141
+ return if added.empty?
1142
+
1143
+ PlanMyStuff::Notifications.instrument(
1144
+ 'issue.approval_requested',
1145
+ self,
1146
+ user: user,
1147
+ approvals: added,
1148
+ )
1149
+ emit_aggregate_events(was_fully_approved: was_fully_approved, trigger: :approver_added, user: user)
1150
+ end
1151
+
1152
+ # Fires the granular event (+approval_granted+ / +approval_revoked+)
1153
+ # then any aggregate follow-up triggered by the state flip.
1154
+ #
1155
+ # @param event [Symbol] +:approval_granted+ or +:approval_revoked+
1156
+ # @param approval [PlanMyStuff::Approval]
1157
+ # @param user [Object, nil]
1158
+ # @param was_fully_approved [Boolean]
1159
+ # @param trigger [Symbol, nil] passed through to +approvals_invalidated+
1160
+ #
1161
+ # @return [void]
1162
+ #
1163
+ def finish_state_change(event, approval, user:, was_fully_approved:, trigger: nil)
1164
+ PlanMyStuff::Notifications.instrument(
1165
+ "issue.#{event}",
1166
+ self,
1167
+ user: user,
1168
+ approval: approval,
1169
+ )
1170
+ emit_aggregate_events(was_fully_approved: was_fully_approved, trigger: trigger, user: user)
1171
+ end
1172
+
1173
+ # Fires +all_approved+ or +approvals_invalidated+ based on whether
1174
+ # +fully_approved?+ flipped. Suppresses +approvals_invalidated+
1175
+ # when the issue no longer has any approvers required (dropping
1176
+ # the list to empty is not an invalidation).
1177
+ #
1178
+ # @param was_fully_approved [Boolean]
1179
+ # @param trigger [Symbol, nil]
1180
+ # @param user [Object, nil]
1181
+ #
1182
+ # @return [void]
1183
+ #
1184
+ def emit_aggregate_events(was_fully_approved:, trigger:, user:)
1185
+ now = fully_approved?
1186
+
1187
+ if !was_fully_approved && now
1188
+ PlanMyStuff::Notifications.instrument('issue.all_approved', self, user: user)
1189
+ elsif was_fully_approved && !now && approvals_required?
1190
+ PlanMyStuff::Notifications.instrument(
1191
+ 'issue.approvals_invalidated',
1192
+ self,
1193
+ user: user,
1194
+ trigger: trigger,
1195
+ )
1196
+ end
1197
+ end
1198
+
1199
+ # Populates this instance from a GitHub API response.
1200
+ #
1201
+ # @param github_issue [Object] Octokit issue response
1202
+ # @param repo [String] resolved repo path
1203
+ #
1204
+ # @return [void]
1205
+ #
1206
+ def hydrate_from_github(github_issue, repo:)
1207
+ @github_response = github_issue
1208
+ self.number = read_field(github_issue, :number)
1209
+ self.title = read_field(github_issue, :title)
1210
+ self.state = read_field(github_issue, :state)
1211
+ self.raw_body = read_field(github_issue, :body) || ''
1212
+ self.updated_at = parse_github_time(safe_read_field(github_issue, :updated_at))
1213
+ self.closed_at = parse_github_time(safe_read_field(github_issue, :closed_at))
1214
+ self.locked = safe_read_field(github_issue, :locked) || false
1215
+ self.labels = extract_labels(github_issue)
1216
+ self.repo = repo
1217
+
1218
+ parsed = MetadataParser.parse(raw_body)
1219
+ self.metadata = IssueMetadata.from_hash(parsed[:metadata])
1220
+ self.body = parsed[:body]
1221
+ @body_dirty = false
1222
+ persisted!
1223
+ @comments = nil
1224
+ invalidate_links_cache!
1225
+ end
1226
+
1227
+ # Copies attributes from another Issue instance into self.
1228
+ #
1229
+ # @param other [PlanMyStuff::Issue]
1230
+ #
1231
+ # @return [void]
1232
+ #
1233
+ def hydrate_from_issue(other)
1234
+ @github_response = other.github_response
1235
+ self.number = other.number
1236
+ self.title = other.title
1237
+ self.state = other.state
1238
+ self.body = other.attributes['body']
1239
+ @body_dirty = false
1240
+ self.raw_body = other.raw_body
1241
+ self.updated_at = other.updated_at
1242
+ self.closed_at = other.closed_at
1243
+ self.locked = other.locked
1244
+ self.labels = other.labels
1245
+ self.repo = other.repo
1246
+ self.metadata = other.metadata
1247
+ persisted!
1248
+ @comments = nil
1249
+ invalidate_links_cache!
1250
+ end
1251
+
1252
+ # Formats the next reminder time as an ISO 8601 UTC string, using
1253
+ # per-issue +metadata.reminder_days+ when set or
1254
+ # +config.reminder_days+ otherwise. Returns +nil+ when the
1255
+ # effective schedule is empty.
1256
+ #
1257
+ # @param from [Time] baseline timestamp
1258
+ #
1259
+ # @return [String, nil]
1260
+ #
1261
+ def format_next_reminder_at(from:)
1262
+ days = metadata.reminder_days.presence || PlanMyStuff.configuration.reminder_days
1263
+ return if days.empty?
1264
+
1265
+ (from + days.first.days).utc.iso8601
1266
+ end
1267
+
1268
+ # Formats a +Time+ as an ISO 8601 UTC string, or +nil+ when the
1269
+ # input is nil.
1270
+ #
1271
+ # @param time [Time, nil]
1272
+ #
1273
+ # @return [String, nil]
1274
+ #
1275
+ def format_time(time)
1276
+ return if time.nil?
1277
+
1278
+ time.utc.iso8601
1279
+ end
1280
+
1281
+ # Fires the appropriate notification event for an update: +issue.closed+
1282
+ # or +issue.reopened+ on a state transition, otherwise +issue.updated+
1283
+ # with the captured dirty-tracking diff.
1284
+ #
1285
+ # @param captured [Hash] +ActiveModel::Dirty+ changes captured before +persist_update!+
1286
+ # @param user [Object, nil]
1287
+ #
1288
+ # @return [void]
1289
+ #
1290
+ def instrument_update(captured, user)
1291
+ case captured['state']
1292
+ when %w[open closed]
1293
+ PlanMyStuff::Notifications.instrument('issue.closed', self, user: user)
1294
+ when %w[closed open]
1295
+ PlanMyStuff::Notifications.instrument('issue.reopened', self, user: user)
1296
+ else
1297
+ PlanMyStuff::Notifications.instrument('issue.updated', self, user: user, changes: captured)
1298
+ end
1299
+ end
1300
+
1301
+ # When an issue is transitioning from open to closed, strips both
1302
+ # waiting labels from the outgoing labels array and clears the
1303
+ # waiting-related timestamps on +metadata+ so a single save writes
1304
+ # both state change and cleanup. No-op for any other transition.
1305
+ #
1306
+ # @param attrs [Hash] the kwargs hash being assembled for
1307
+ # +Issue.update!+; mutated in place
1308
+ #
1309
+ # @return [void]
1310
+ #
1311
+ def clear_waiting_state_on_close!(attrs)
1312
+ return unless state_changed?
1313
+ return unless state_was == 'open'
1314
+ return unless state == 'closed'
1315
+
1316
+ return if metadata.waiting_on_user_at.blank? && metadata.waiting_on_approval_at.blank?
1317
+
1318
+ waiting_labels = [
1319
+ PlanMyStuff.configuration.waiting_on_user_label,
1320
+ PlanMyStuff.configuration.waiting_on_approval_label,
1321
+ ]
1322
+ attrs[:labels] = Array.wrap(attrs[:labels]) - waiting_labels
1323
+
1324
+ metadata.waiting_on_user_at = nil
1325
+ metadata.waiting_on_approval_at = nil
1326
+ metadata.next_reminder_at = nil
1327
+ end
1328
+
1329
+ # When an inactivity-closed issue is being reopened, strips the
1330
+ # +user_inactive_label+ from the outgoing labels and clears
1331
+ # +metadata.closed_by_inactivity+ so the save writes both. No-op
1332
+ # for any other transition or for reopens of non-inactive closes.
1333
+ #
1334
+ # @param attrs [Hash] the kwargs hash being assembled for
1335
+ # +Issue.update!+; mutated in place
1336
+ #
1337
+ # @return [void]
1338
+ #
1339
+ def clear_inactivity_state_on_reopen!(attrs)
1340
+ return unless state_changed?
1341
+ return unless state_was == 'closed'
1342
+ return unless state == 'open'
1343
+ return unless metadata.closed_by_inactivity
1344
+
1345
+ attrs[:labels] = Array.wrap(attrs[:labels]) - [PlanMyStuff.configuration.user_inactive_label]
1346
+ metadata.closed_by_inactivity = false
1347
+ end
1348
+
1349
+ # Full-write persistence path for an already-persisted issue.
1350
+ # Delegates to +Issue.update!+ passing the full in-memory state
1351
+ # (title/state/labels plus the current +metadata+ object so the
1352
+ # class method serializes it authoritatively). Only passes
1353
+ # +body:+ when +@body_dirty+, so the PMS body comment is
1354
+ # rewritten exactly when +#body=+ has been called since load.
1355
+ #
1356
+ # @raise [PlanMyStuff::StaleObjectError]
1357
+ #
1358
+ # @return [void]
1359
+ #
1360
+ def persist_update!
1361
+ raise_if_stale!
1362
+
1363
+ attrs = {
1364
+ number: number,
1365
+ repo: repo,
1366
+ title: title,
1367
+ state: state,
1368
+ labels: labels || [],
1369
+ metadata: metadata,
1370
+ }
1371
+ attrs[:body] = body if @body_dirty
1372
+ attrs[:assignees] = @pending_assignees unless @pending_assignees.nil?
1373
+
1374
+ clear_waiting_state_on_close!(attrs)
1375
+ clear_inactivity_state_on_reopen!(attrs)
1376
+
1377
+ self.class.update!(**attrs)
1378
+
1379
+ @body_dirty = false
1380
+ @pending_assignees = nil
1381
+ reload
1382
+ end
1383
+
1384
+ # Applies in-memory updates from an +update!+ kwargs hash.
1385
+ # Top-level scalars go through their setters so +@body_dirty+
1386
+ # and friends stay in sync; +metadata:+ is merged into
1387
+ # +@metadata+ (top-level attrs assigned directly, custom_fields
1388
+ # merged key-by-key).
1389
+ #
1390
+ # @return [void]
1391
+ #
1392
+ def apply_update_attrs!(attrs)
1393
+ self.title = attrs[:title] if attrs.key?(:title)
1394
+ self.state = attrs[:state].to_s if attrs.key?(:state)
1395
+ self.labels = attrs[:labels] if attrs.key?(:labels)
1396
+ self.body = attrs[:body] if attrs.key?(:body)
1397
+ @pending_assignees = attrs[:assignees] if attrs.key?(:assignees)
1398
+ apply_metadata_attrs!(attrs[:metadata]) if attrs.key?(:metadata)
1399
+ end
1400
+
1401
+ # @return [void]
1402
+ def apply_metadata_attrs!(md_hash)
1403
+ return if md_hash.nil?
1404
+
1405
+ md_hash.each do |key, value|
1406
+ if key == :custom_fields
1407
+ value.each { |k, v| metadata.custom_fields[k] = v }
1408
+ elsif metadata.respond_to?("#{key}=")
1409
+ metadata.public_send("#{key}=", value)
1410
+ end
1411
+ end
1412
+ end
1413
+
1414
+ # Raises StaleObjectError if the remote issue has been modified
1415
+ # since this instance was loaded.
1416
+ #
1417
+ # @raise [PlanMyStuff::StaleObjectError]
1418
+ #
1419
+ # @return [void]
1420
+ #
1421
+ def raise_if_stale!
1422
+ return if new_record?
1423
+ return if updated_at.nil?
1424
+
1425
+ remote = self.class.find(number, repo: repo)
1426
+ remote_time = remote.updated_at
1427
+ local_time = updated_at
1428
+
1429
+ return if remote_time.nil?
1430
+ return if local_time && remote_time.to_i == local_time.to_i
1431
+
1432
+ raise(StaleObjectError.new(
1433
+ "Issue ##{number} has been modified remotely",
1434
+ local_updated_at: local_time,
1435
+ remote_updated_at: remote_time,
1436
+ ))
1437
+ end
1438
+
1439
+ # @return [Array<String>]
1440
+ def extract_labels(github_issue)
1441
+ raw = read_field(github_issue, :labels) || []
1442
+ raw.map { |label| label_name(label) }
1443
+ end
1444
+
1445
+ # @return [String]
1446
+ def label_name(label)
1447
+ return label.name if label.respond_to?(:name)
1448
+ return label[:name] || label['name'] if label.is_a?(Hash)
1449
+
1450
+ label.to_s
1451
+ end
1452
+
1453
+ # @return [Array<PlanMyStuff::Comment>]
1454
+ def load_comments
1455
+ Comment.list(issue: self)
1456
+ end
1457
+
1458
+ # @return [Hash{Symbol => Array}]
1459
+ def links_cache
1460
+ @links_cache ||= {}
1461
+ end
1462
+
1463
+ # Clears all memoized link readers. Called from +#hydrate_from_github+
1464
+ # and after any successful write.
1465
+ #
1466
+ # @return [void]
1467
+ #
1468
+ def invalidate_links_cache!
1469
+ @links_cache = {}
1470
+ end
1471
+
1472
+ # Normalizes +target+ to a +PlanMyStuff::Link+ with the source
1473
+ # repo defaulting to self's repo.
1474
+ #
1475
+ # @return [PlanMyStuff::Link]
1476
+ #
1477
+ def build_link(target, type:)
1478
+ PlanMyStuff::Link.build(target, type: type, source_repo: repo&.full_name)
1479
+ end
1480
+
1481
+ # @raise [PlanMyStuff::ValidationError]
1482
+ # @return [void]
1483
+ #
1484
+ def validate_not_self!(link)
1485
+ return if link.issue_number != number
1486
+ return unless link.same_repo?(repo)
1487
+
1488
+ raise(PlanMyStuff::ValidationError, 'Cannot link an issue to itself')
1489
+ end
1490
+
1491
+ # Reads +metadata.links+ and coerces any legacy hash entries to
1492
+ # +Link+ instances. Invalid entries are dropped.
1493
+ #
1494
+ # @return [Array<PlanMyStuff::Link>]
1495
+ #
1496
+ def current_links
1497
+ metadata.links.filter_map do |entry|
1498
+ next entry if entry.is_a?(PlanMyStuff::Link)
1499
+
1500
+ PlanMyStuff::Link.build(entry)
1501
+ rescue ActiveModel::ValidationError, ArgumentError
1502
+ next
1503
+ end
1504
+ end
1505
+
1506
+ # Writes the given link array back to GitHub via
1507
+ # +Issue.update!+ and updates local metadata so subsequent
1508
+ # in-memory reads see the change without a +reload+.
1509
+ #
1510
+ # @param new_links [Array<PlanMyStuff::Link>]
1511
+ #
1512
+ # @return [void]
1513
+ #
1514
+ def persist_links!(new_links)
1515
+ self.class.update!(
1516
+ number: number,
1517
+ repo: repo,
1518
+ metadata: { links: new_links.map(&:to_h) },
1519
+ )
1520
+ metadata.links = new_links
1521
+ invalidate_links_cache!
1522
+ end
1523
+
1524
+ # Walks every Projects V2 board this issue sits on and deletes the
1525
+ # corresponding item. Paginates via +LIST_ISSUE_PROJECT_ITEMS+ with
1526
+ # a safety cap to avoid runaway loops. Delete failures propagate.
1527
+ #
1528
+ # @return [void]
1529
+ #
1530
+ def remove_from_all_projects!
1531
+ client = PlanMyStuff.client
1532
+ owner = repo.organization
1533
+ repo_name = repo.name
1534
+ cursor = nil
1535
+
1536
+ 10.times do
1537
+ data = client.graphql(
1538
+ PlanMyStuff::GraphQL::Queries::LIST_ISSUE_PROJECT_ITEMS,
1539
+ variables: { owner: owner, repo: repo_name, number: number, cursor: cursor },
1540
+ )
1541
+
1542
+ connection = data.dig(:repository, :issue, :projectItems) || {}
1543
+ nodes = Array.wrap(connection[:nodes])
1544
+
1545
+ nodes.each do |node|
1546
+ PlanMyStuff::ProjectItem.delete_item(
1547
+ item_id: node[:id],
1548
+ project_number: node.dig(:project, :number),
1549
+ )
1550
+ end
1551
+
1552
+ page_info = connection[:pageInfo] || {}
1553
+ break unless page_info[:hasNextPage]
1554
+
1555
+ cursor = page_info[:endCursor]
1556
+ end
1557
+ end
1558
+
1559
+ # Attempts the reciprocal write on +link+'s target. On failure,
1560
+ # fires +plan_my_stuff.issue.link_reciprocal_failed+ so the
1561
+ # consuming app can surface the half-written pairing.
1562
+ #
1563
+ # @param link [PlanMyStuff::Link]
1564
+ # @param user [Object, nil]
1565
+ #
1566
+ # @return [void]
1567
+ #
1568
+ def mirror_on_target(link, user:)
1569
+ target = PlanMyStuff::Issue.find(link.issue_number, repo: link.repo)
1570
+ yield(target)
1571
+ rescue PlanMyStuff::Error, Octokit::Error => e
1572
+ PlanMyStuff::Notifications.instrument(
1573
+ 'issue.link_reciprocal_failed',
1574
+ self,
1575
+ user: user,
1576
+ link: link,
1577
+ error: e.message,
1578
+ )
1579
+ end
1580
+
1581
+ # @return [Array<PlanMyStuff::Issue>]
1582
+ def fetch_related
1583
+ current_links.filter_map do |link|
1584
+ next unless link.type == 'related'
1585
+
1586
+ PlanMyStuff::Issue.find(link.issue_number, repo: link.repo)
1587
+ rescue PlanMyStuff::APIError, Octokit::NotFound
1588
+ next
1589
+ end
1590
+ end
1591
+
1592
+ # @return [PlanMyStuff::Issue, nil]
1593
+ def fetch_parent
1594
+ response = PlanMyStuff.client.rest(:get, parent_path)
1595
+ return if response.blank?
1596
+
1597
+ parent_number = response.respond_to?(:number) ? response.number : response[:number]
1598
+ PlanMyStuff::Issue.find(parent_number, repo: repo)
1599
+ rescue PlanMyStuff::APIError => e
1600
+ return if e.status == 404
1601
+
1602
+ raise
1603
+ end
1604
+
1605
+ # @return [Array<PlanMyStuff::Issue>]
1606
+ def fetch_sub_tickets
1607
+ response = PlanMyStuff.client.rest(:get, sub_issues_path)
1608
+ Array.wrap(response).filter_map do |row|
1609
+ sub_number = row.respond_to?(:number) ? row.number : row[:number]
1610
+ PlanMyStuff::Issue.find(sub_number, repo: repo)
1611
+ rescue PlanMyStuff::APIError, Octokit::NotFound
1612
+ next
1613
+ end
1614
+ end
1615
+
1616
+ # Normalizes +target+ to a fully-hydrated +Issue+ (fetching when
1617
+ # we only have a +Link+ or hash). Used by +set_parent!+ /
1618
+ # +remove_parent!+ to invert the call back through
1619
+ # +#add_sub_issue!+ / +#remove_sub_issue!+ on the parent side.
1620
+ #
1621
+ # @return [PlanMyStuff::Issue]
1622
+ #
1623
+ def resolve_target_issue(target, type:)
1624
+ return target if target.is_a?(PlanMyStuff::Issue)
1625
+
1626
+ link = build_link(target, type: type)
1627
+ PlanMyStuff::Issue.find(link.issue_number, repo: link.repo)
1628
+ end
1629
+
1630
+ # Shared path for add_sub_issue! / remove_sub_issue!. Builds the
1631
+ # link, resolves the target, runs the mutation, busts caches.
1632
+ #
1633
+ # @return [PlanMyStuff::Link]
1634
+ #
1635
+ def mutate_sub_issue!(target, method:, path:)
1636
+ link = build_link(target, type: :sub_ticket)
1637
+ validate_not_self!(link)
1638
+
1639
+ target_issue = resolve_target_issue(target, type: :sub_ticket)
1640
+ PlanMyStuff.client.rest(
1641
+ method,
1642
+ path,
1643
+ { sub_issue_id: target_issue.__send__(:require_github_id!) },
1644
+ )
1645
+ invalidate_links_cache!
1646
+ link
1647
+ end
1648
+
1649
+ # @return [String]
1650
+ def parent_path
1651
+ "/repos/#{repo.organization}/#{repo.name}/issues/#{number}/parent"
1652
+ end
1653
+
1654
+ # @return [String]
1655
+ def sub_issues_path
1656
+ "/repos/#{repo.organization}/#{repo.name}/issues/#{number}/sub_issues"
1657
+ end
1658
+
1659
+ # GitHub's REMOVE endpoint is +/sub_issue+ (singular), distinct
1660
+ # from the list/add path +/sub_issues+ (plural).
1661
+ #
1662
+ # @return [String]
1663
+ #
1664
+ def remove_sub_issue_path
1665
+ "/repos/#{repo.organization}/#{repo.name}/issues/#{number}/sub_issue"
1666
+ end
1667
+
1668
+ # Fetches one side of the native issue-dependency graph for self
1669
+ # (+blocked_by+ or +blocking+) via REST. Response is an array of
1670
+ # Issue objects; we map through +Issue.find+ to get fully hydrated
1671
+ # instances (the dependency endpoint returns a slim projection).
1672
+ #
1673
+ # @param side [String] "blocked_by" or "blocking"
1674
+ #
1675
+ # @return [Array<PlanMyStuff::Issue>]
1676
+ #
1677
+ def fetch_dependencies(side)
1678
+ response = PlanMyStuff.client.rest(:get, dependency_path(side))
1679
+ Array.wrap(response).filter_map do |row|
1680
+ number = row.respond_to?(:number) ? row.number : row[:number]
1681
+ PlanMyStuff::Issue.find(number, repo: repo)
1682
+ rescue PlanMyStuff::APIError, Octokit::NotFound
1683
+ next
1684
+ end
1685
+ end
1686
+
1687
+ # @return [String]
1688
+ def dependency_path(side)
1689
+ "/repos/#{repo.organization}/#{repo.name}/issues/#{number}/dependencies/#{side}"
1690
+ end
1691
+
1692
+ # @raise [PlanMyStuff::Error]
1693
+ # @return [Integer]
1694
+ #
1695
+ def require_github_id!
1696
+ id = github_id
1697
+ raise(PlanMyStuff::Error, "Issue ##{number} has no database id; cannot run native REST mutation") if id.nil?
1698
+
1699
+ id
1700
+ end
1701
+
1702
+ # @return [PlanMyStuff::Issue, nil]
1703
+ def fetch_duplicate_of
1704
+ data = PlanMyStuff.client.graphql(
1705
+ PlanMyStuff::GraphQL::Queries::FETCH_DUPLICATE_OF,
1706
+ variables: { owner: repo.organization, repo: repo.name, number: number },
1707
+ )
1708
+ issue_data = data.dig(:repository, :issue) || {}
1709
+ return unless issue_data[:stateReason].to_s.casecmp?('DUPLICATE')
1710
+
1711
+ the_dupe = issue_data[:duplicateOf]
1712
+ return if the_dupe.blank?
1713
+
1714
+ PlanMyStuff::Issue.find(the_dupe[:number], repo: the_dupe.dig(:repository, :nameWithOwner))
1715
+ end
1716
+
1717
+ # Resolves +target+ to an +Issue+ and raises +ValidationError+
1718
+ # when the target cannot be found.
1719
+ #
1720
+ # @return [PlanMyStuff::Issue]
1721
+ #
1722
+ def resolve_duplicate_target!(target)
1723
+ resolve_target_issue(target, type: :duplicate_of)
1724
+ rescue Octokit::NotFound, PlanMyStuff::APIError => e
1725
+ raise(PlanMyStuff::ValidationError, "Duplicate target not found: #{e.message}")
1726
+ end
1727
+
1728
+ # Unions self's visibility_allowlist onto +target+'s.
1729
+ #
1730
+ # @return [void]
1731
+ #
1732
+ def merge_visibility_allowlist_onto(target)
1733
+ return if metadata.visibility_allowlist.blank?
1734
+
1735
+ merged = Array.wrap(target.metadata.visibility_allowlist) | Array.wrap(metadata.visibility_allowlist)
1736
+ target.update!(metadata: { visibility_allowlist: merged })
1737
+ end
1738
+
1739
+ # Unions self's GitHub assignees (by login) onto +target+'s.
1740
+ #
1741
+ # @return [void]
1742
+ #
1743
+ def merge_assignees_onto(target)
1744
+ source_logins = extract_assignee_logins(github_response)
1745
+ return if source_logins.empty?
1746
+
1747
+ merged = extract_assignee_logins(target.github_response) | source_logins
1748
+ target.update!(assignees: merged)
1749
+ end
1750
+
1751
+ # @param response [Object] Octokit issue response
1752
+ #
1753
+ # @return [Array<String>]
1754
+ #
1755
+ def extract_assignee_logins(response)
1756
+ raw = safe_read_field(response, :assignees) || []
1757
+ raw.filter_map { |a| a.respond_to?(:login) ? a.login : a[:login] || a['login'] }
1758
+ end
1759
+
1760
+ # @return [void]
1761
+ def post_duplicate_back_pointer(target, user:)
1762
+ visibility = target.metadata.visibility.presence || 'public'
1763
+ PlanMyStuff::Comment.create!(
1764
+ issue: target,
1765
+ body: "Marked duplicate of this by #{repo.full_name}##{number}",
1766
+ user: user,
1767
+ visibility: visibility.to_sym,
1768
+ )
1769
+ end
1770
+
1771
+ # Closes self as a duplicate of +target+ via GitHub's native
1772
+ # +closeIssue+ GraphQL mutation with +stateReason: DUPLICATE+ and
1773
+ # +duplicateIssueId+. The REST +duplicate_of+ body param is not
1774
+ # recognized; only this GraphQL path actually wires up
1775
+ # +Issue#duplicateOf+ on the closed issue.
1776
+ #
1777
+ # @return [void]
1778
+ #
1779
+ def close_as_duplicate!(target)
1780
+ source_node_id = github_node_id
1781
+ target_node_id = target.github_node_id
1782
+ raise(PlanMyStuff::Error, "Issue ##{number} has no node_id") if source_node_id.blank?
1783
+ raise(PlanMyStuff::Error, "Target issue ##{target.number} has no node_id") if target_node_id.blank?
1784
+
1785
+ PlanMyStuff.client.graphql(
1786
+ PlanMyStuff::GraphQL::Queries::CLOSE_AS_DUPLICATE,
1787
+ variables: { issueId: source_node_id, duplicateIssueId: target_node_id },
1788
+ )
488
1789
  end
489
1790
  end
490
1791
  end