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
@@ -1,422 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PlanMyStuff
4
- # Wraps a single item within a GitHub Projects V2 project.
5
- # Stores a reference to its parent Project for delegation.
6
- #
7
- # Class methods:
8
- # ProjectItem.create!(issue) -- add existing issue
9
- # ProjectItem.create!(title, draft: true, body: "...") -- create draft item
10
- # ProjectItem.move_item(item_id:, status:)
11
- # ProjectItem.assign(item_id:, assignee:)
12
- #
13
- # Instance methods:
14
- # item.move_to!("Done")
15
- # item.assign!("octocat")
16
- #
17
- class ProjectItem < PlanMyStuff::ApplicationRecord
18
- # @return [String] GitHub node ID (e.g. "PVTI_...")
19
- attr_reader :id
20
- # @return [String, nil] node ID of the underlying content (Issue, PR, or DraftIssue)
21
- attr_reader :content_node_id
22
-
23
- # @return [String, nil] GitHub item type (e.g. "DRAFT_ISSUE", "ISSUE", "PULL_REQUEST")
24
- attr_reader :type
25
-
26
- # @return [String, nil]
27
- attr_accessor :title
28
- # @return [Integer, nil]
29
- attr_accessor :number
30
- # @return [String, nil]
31
- attr_accessor :url
32
- # @return [PlanMyStuff::Repo, nil]
33
- attr_accessor :repo
34
- # @return [String, nil]
35
- attr_accessor :state
36
- # @return [String, nil]
37
- attr_accessor :status
38
- # @return [Hash]
39
- attr_accessor :field_values
40
- # @return [PlanMyStuff::Project, nil]
41
- attr_accessor :project
42
- # @return [PlanMyStuff::Issue, nil] linked issue (nil for draft items)
43
- attr_writer :issue
44
-
45
- class << self
46
- # Builds a persisted ProjectItem from parsed item data.
47
- #
48
- # @param item_hash [Hash] parsed item data (from Project.parse_project_item)
49
- # @param project [PlanMyStuff::Project] parent project
50
- #
51
- # @return [PlanMyStuff::ProjectItem]
52
- #
53
- def build(item_hash, project:)
54
- item = new(
55
- id: item_hash[:id],
56
- type: item_hash[:type],
57
- content_node_id: item_hash[:content_node_id],
58
- title: item_hash[:title],
59
- number: item_hash[:number],
60
- url: item_hash[:url],
61
- repo: item_hash[:repo],
62
- state: item_hash[:state],
63
- status: item_hash[:status],
64
- field_values: item_hash[:field_values] || {},
65
- project: project,
66
- )
67
- item.__send__(:persisted!)
68
- item
69
- end
70
-
71
- # Creates a project item by adding an existing issue or creating a draft.
72
- #
73
- # @param issue_or_title [PlanMyStuff::Issue, String] Issue instance (non-draft) or title string (draft)
74
- # @param draft [Boolean] when true, creates a draft item from a title string
75
- # @param body [String, nil] body for draft items (ignored for non-draft)
76
- # @param project_number [Integer, nil] defaults to config.default_project_number
77
- #
78
- # @return [PlanMyStuff::ProjectItem]
79
- #
80
- def create!(issue_or_title, draft: false, body: nil, project_number: nil)
81
- if draft
82
- add_draft_item(title: issue_or_title, body: body, project_number: project_number)
83
- else
84
- add_item(issue: issue_or_title, project_number: project_number)
85
- end
86
- end
87
-
88
- # Moves a project item to a new status column.
89
- #
90
- # @param item_id [String] project item ID (e.g. "PVTI_...")
91
- # @param status [String] status name, resolved to option ID internally
92
- # @param project_number [Integer, nil] defaults to config.default_project_number
93
- #
94
- # @return [Hash] the updated item
95
- #
96
- def move_item(item_id:, status:, project_number: nil)
97
- project_number = resolve_default_project_number(project_number)
98
- project = Project.find(project_number)
99
-
100
- status_field = project.status_field
101
- option_id = resolve_status_option_id(status_field, status)
102
-
103
- PlanMyStuff.client.graphql(
104
- update_single_select_mutation,
105
- variables: {
106
- projectId: project.id,
107
- itemId: item_id,
108
- fieldId: status_field[:id],
109
- optionId: option_id,
110
- },
111
- )
112
- end
113
-
114
- # Assigns users to a project item.
115
- # Issues/PRs use REST via Issue.update!, drafts use GraphQL.
116
- #
117
- # @param number [Integer, nil] issue number (nil for drafts)
118
- # @param content_node_id [String] node ID of the underlying content
119
- # @param assignees [Array<String>] GitHub usernames
120
- # @param draft [Boolean] whether the item is a draft issue
121
- # @param repo [Symbol, String, nil] repo key (for issues)
122
- #
123
- # @return [void]
124
- #
125
- def assign(number:, content_node_id:, assignees:, draft: false, repo: nil)
126
- if draft
127
- client = PlanMyStuff.client
128
- user_ids = assignees.map do |assignee|
129
- user_data = client.graphql(user_node_id_query, variables: { login: assignee })
130
- user_id = user_data.dig(:user, :id)
131
-
132
- raise(APIError, "GitHub user not found: #{assignee}") if user_id.nil?
133
-
134
- user_id
135
- end
136
-
137
- client.graphql(
138
- assign_draft_mutation,
139
- variables: { draftIssueId: content_node_id, assigneeIds: user_ids },
140
- )
141
- else
142
- Issue.update!(number: number, repo: repo, assignees: assignees)
143
- end
144
- end
145
-
146
- private
147
-
148
- # Adds a GitHub issue to a project board.
149
- #
150
- # @param issue [PlanMyStuff::Issue]
151
- # @param project_number [Integer, nil]
152
- #
153
- # @return [PlanMyStuff::ProjectItem]
154
- #
155
- def add_item(issue:, project_number:)
156
- project_number = resolve_default_project_number(project_number)
157
- client = PlanMyStuff.client
158
- org = PlanMyStuff.configuration.organization
159
-
160
- github_issue = client.rest(:issue, issue.repo, issue.number)
161
- node_id = github_issue.respond_to?(:node_id) ? github_issue.node_id : github_issue[:node_id]
162
-
163
- project_id = resolve_project_id(org, project_number)
164
-
165
- data = client.graphql(
166
- add_item_mutation,
167
- variables: { projectId: project_id, contentId: node_id },
168
- )
169
-
170
- item_data = data.dig(:addProjectV2ItemById, :item) || {}
171
- project = Project.find(project_number)
172
-
173
- item = build(
174
- {
175
- id: item_data[:id],
176
- content_node_id: node_id,
177
- title: issue.title,
178
- number: issue.number,
179
- url: nil,
180
- state: issue.state,
181
- status: nil,
182
- field_values: {},
183
- },
184
- project: project,
185
- )
186
- item.issue = issue
187
- item
188
- end
189
-
190
- # Adds a draft issue to a project board.
191
- #
192
- # @param title [String]
193
- # @param body [String, nil]
194
- # @param project_number [Integer, nil]
195
- #
196
- # @return [PlanMyStuff::ProjectItem]
197
- #
198
- def add_draft_item(title:, body:, project_number:)
199
- project_number = resolve_default_project_number(project_number)
200
- org = PlanMyStuff.configuration.organization
201
- project_id = resolve_project_id(org, project_number)
202
-
203
- variables = { projectId: project_id, title: title }
204
- variables[:body] = body if body.present?
205
-
206
- data = PlanMyStuff.client.graphql(
207
- add_draft_item_mutation,
208
- variables: variables,
209
- )
210
-
211
- item_data = data.dig(:addProjectV2DraftIssue, :projectItem) || {}
212
- project = Project.find(project_number)
213
-
214
- build(
215
- {
216
- id: item_data[:id],
217
- content_node_id: item_data.dig(:content, :id),
218
- type: 'DRAFT_ISSUE',
219
- title: title,
220
- number: nil,
221
- url: nil,
222
- state: nil,
223
- status: nil,
224
- field_values: {},
225
- },
226
- project: project,
227
- )
228
- end
229
-
230
- # @see PlanMyStuff::Project.resolve_default_project_number
231
- def resolve_default_project_number(project_number)
232
- PlanMyStuff::Project.resolve_default_project_number(project_number)
233
- end
234
-
235
- # @return [String]
236
- def add_item_mutation
237
- <<~GRAPHQL
238
- mutation($projectId: ID!, $contentId: ID!) {
239
- addProjectV2ItemById(input: {
240
- projectId: $projectId,
241
- contentId: $contentId
242
- }) {
243
- item {
244
- id
245
- }
246
- }
247
- }
248
- GRAPHQL
249
- end
250
-
251
- # @return [String]
252
- def add_draft_item_mutation
253
- <<~GRAPHQL
254
- mutation($projectId: ID!, $title: String!, $body: String) {
255
- addProjectV2DraftIssue(input: {
256
- projectId: $projectId,
257
- title: $title,
258
- body: $body
259
- }) {
260
- projectItem {
261
- id
262
- content { ... on DraftIssue { id } }
263
- }
264
- }
265
- }
266
- GRAPHQL
267
- end
268
-
269
- # @return [String]
270
- def update_single_select_mutation
271
- <<~GRAPHQL
272
- mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
273
- updateProjectV2ItemFieldValue(input: {
274
- projectId: $projectId,
275
- itemId: $itemId,
276
- fieldId: $fieldId,
277
- value: { singleSelectOptionId: $optionId }
278
- }) {
279
- projectV2Item {
280
- id
281
- }
282
- }
283
- }
284
- GRAPHQL
285
- end
286
-
287
- # @return [String]
288
- def assign_draft_mutation
289
- <<~GRAPHQL
290
- mutation($draftIssueId: ID!, $assigneeIds: [ID!]) {
291
- updateProjectV2DraftIssue(input: {
292
- draftIssueId: $draftIssueId,
293
- assigneeIds: $assigneeIds
294
- }) {
295
- draftIssue { id }
296
- }
297
- }
298
- GRAPHQL
299
- end
300
-
301
- # @return [String]
302
- def user_node_id_query
303
- <<~GRAPHQL
304
- query($login: String!) {
305
- user(login: $login) {
306
- id
307
- }
308
- }
309
- GRAPHQL
310
- end
311
-
312
- # @return [String]
313
- def project_id_query
314
- <<~GRAPHQL
315
- query($org: String!, $number: Int!) {
316
- organization(login: $org) {
317
- projectV2(number: $number) {
318
- id
319
- }
320
- }
321
- }
322
- GRAPHQL
323
- end
324
-
325
- # Resolves a status name to its option ID.
326
- #
327
- # @param status_field [Hash]
328
- # @param status_name [String]
329
- #
330
- # @return [String]
331
- #
332
- def resolve_status_option_id(status_field, status_name)
333
- option = status_field[:options].find { |o| o[:name] == status_name }
334
-
335
- unless option
336
- available = status_field[:options].pluck(:name).join(', ')
337
- raise(ArgumentError, "Unknown status '#{status_name}'. Available: #{available}")
338
- end
339
-
340
- option[:id]
341
- end
342
-
343
- # Resolves a project number to its node ID.
344
- #
345
- # @param org [String]
346
- # @param project_number [Integer]
347
- #
348
- # @return [String]
349
- #
350
- def resolve_project_id(org, project_number)
351
- data = PlanMyStuff.client.graphql(
352
- project_id_query,
353
- variables: { org: org, number: project_number },
354
- )
355
-
356
- data.dig(:organization, :projectV2, :id)
357
- end
358
- end
359
-
360
- # @see super
361
- def initialize(**attrs)
362
- @id = attrs.delete(:id)
363
- @type = attrs.delete(:type)
364
- @content_node_id = attrs.delete(:content_node_id)
365
- super
366
- @field_values ||= {}
367
- end
368
-
369
- # Moves this item to a new status column on its parent project.
370
- #
371
- # @param status [String] status name (e.g. "In Progress", "Done")
372
- #
373
- # @return [Hash] mutation result
374
- #
375
- def move_to!(status)
376
- self.class.move_item(
377
- project_number: project.number,
378
- item_id: id,
379
- status: status,
380
- )
381
- end
382
-
383
- # Assigns users to this item on its parent project.
384
- #
385
- # @param assignees [String, Array<String>] GitHub username(s)
386
- #
387
- # @return [void]
388
- #
389
- def assign!(assignees)
390
- self.class.assign(
391
- number: number,
392
- content_node_id: content_node_id,
393
- assignees: Array.wrap(assignees),
394
- draft: draft?,
395
- repo: repo,
396
- )
397
- end
398
-
399
- # @return [Boolean]
400
- def draft?
401
- type == 'DRAFT_ISSUE'
402
- end
403
-
404
- # @return [PlanMyStuff::Issue, nil]
405
- def issue
406
- return @issue if defined?(@issue)
407
- return if draft?
408
-
409
- @issue = PMS::Issue.find(number, repo: repo)
410
- end
411
-
412
- private
413
-
414
- # Marks this record as persisted.
415
- #
416
- # @return [void]
417
- #
418
- def persisted!
419
- @persisted = true
420
- end
4
+ # Concrete project item subclass for standard (non-testing) GitHub Projects V2
5
+ # projects. All generic machinery lives in BaseProjectItem.
6
+ class ProjectItem < PlanMyStuff::BaseProjectItem
421
7
  end
422
8
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ # Metadata stored on the body of a +ProjectItem+ -- primarily used for
5
+ # draft items, which have their own body field distinct from any linked
6
+ # issue. Regular (non-draft) items rely on their linked issue's
7
+ # +IssueMetadata+ instead, to avoid double-bookkeeping.
8
+ #
9
+ # Lays the groundwork for the Phase 13 testing-tracking workflow
10
+ # (T-049), which will assign non-GitHub users (e.g. internal QA
11
+ # accounts) to draft items via the +pms_assignee+ field.
12
+ #
13
+ class ProjectItemMetadata < BaseMetadata
14
+ # @return [Integer, nil] consuming app user id of the PMS-side assignee
15
+ attr_accessor :pms_assignee
16
+
17
+ class << self
18
+ # Builds a ProjectItemMetadata from a parsed hash (e.g. from MetadataParser)
19
+ #
20
+ # @param hash [Hash]
21
+ #
22
+ # @return [PlanMyStuff::ProjectItemMetadata]
23
+ #
24
+ def from_hash(hash)
25
+ metadata = new
26
+ apply_common_from_hash(metadata, hash, {})
27
+ metadata.pms_assignee = hash[:pms_assignee]
28
+ metadata
29
+ end
30
+
31
+ # Builds a new ProjectItemMetadata, auto-filling gem defaults.
32
+ #
33
+ # @param user [Object, Integer] user object or user_id
34
+ # @param visibility [String] "public" or "internal"
35
+ #
36
+ # @return [PlanMyStuff::ProjectItemMetadata]
37
+ #
38
+ def build(user:, visibility: 'internal')
39
+ metadata = new
40
+ apply_common_build(
41
+ metadata,
42
+ user: user,
43
+ visibility: visibility,
44
+ )
45
+ metadata.pms_assignee = nil
46
+ metadata
47
+ end
48
+ end
49
+
50
+ # @return [Hash]
51
+ def to_h
52
+ super.merge(pms_assignee: pms_assignee)
53
+ end
54
+ end
55
+ end
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PlanMyStuff
4
- class ProjectMetadata < BaseMetadata
4
+ # Metadata for regular (non-testing) projects.
5
+ class ProjectMetadata < BaseProjectMetadata
5
6
  class << self
6
7
  # Builds a ProjectMetadata from a parsed hash (e.g. from MetadataParser)
7
8
  #
@@ -12,7 +13,7 @@ module PlanMyStuff
12
13
  def from_hash(hash)
13
14
  metadata = new
14
15
  apply_common_from_hash(metadata, hash, PlanMyStuff.configuration.custom_fields_for(:project))
15
-
16
+ metadata.kind = hash.fetch(:kind, 'project')
16
17
  metadata
17
18
  end
18
19
 
@@ -33,9 +34,14 @@ module PlanMyStuff
33
34
  custom_fields_data: custom_fields,
34
35
  custom_fields_schema: PlanMyStuff.configuration.custom_fields_for(:project),
35
36
  )
36
-
37
+ metadata.kind = 'project'
37
38
  metadata
38
39
  end
39
40
  end
41
+
42
+ def initialize
43
+ super
44
+ @kind = 'project'
45
+ end
40
46
  end
41
47
  end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module Reminders
5
+ # Auto-closes a waiting issue that has exceeded the configured
6
+ # +inactivity_close_days+ ceiling. Clears waiting state and emits a
7
+ # dedicated +plan_my_stuff.issue.closed_inactive+ event.
8
+ #
9
+ # Goes through +Issue#update!+ with +skip_notification: true+ so the
10
+ # regular +issue.closed+ event is suppressed in favor of the
11
+ # dedicated +issue.closed_inactive+. +Issue#persist_update!+'s
12
+ # +clear_waiting_state_on_close!+ hook handles stripping waiting
13
+ # labels and clearing waiting timestamps in the single save write.
14
+ class Closer
15
+ # Returns +true+ when the issue has waited longer than
16
+ # +inactivity_close_days+ relative to +now+.
17
+ #
18
+ # @param issue [PlanMyStuff::Issue]
19
+ # @param now [Time]
20
+ #
21
+ # @return [Boolean]
22
+ #
23
+ def self.should_close?(issue, now: Time.now.utc)
24
+ start = issue.metadata.waiting_on_user_at || issue.metadata.waiting_on_approval_at
25
+ return false if start.nil?
26
+
27
+ ((now.utc - start) / 1.day) >= PlanMyStuff.configuration.inactivity_close_days
28
+ end
29
+
30
+ # @param issue [PlanMyStuff::Issue] candidate issue from the sweep
31
+ # @param now [Time] clock reference
32
+ #
33
+ def initialize(issue, now: Time.now.utc)
34
+ @issue = issue
35
+ @now = now.utc
36
+ end
37
+
38
+ # Closes the issue, tags it with the configured
39
+ # +user_inactive_label+, then emits
40
+ # +plan_my_stuff.issue.closed_inactive+.
41
+ #
42
+ # @return [void]
43
+ #
44
+ def call
45
+ @issue.update!(
46
+ state: :closed,
47
+ metadata: { closed_by_inactivity: true },
48
+ skip_notification: true,
49
+ )
50
+ add_user_inactive_label
51
+ PlanMyStuff::Notifications.instrument(
52
+ 'issue.closed_inactive',
53
+ @issue,
54
+ reason: :inactivity,
55
+ )
56
+ end
57
+
58
+ private
59
+
60
+ # @return [void]
61
+ def add_user_inactive_label
62
+ label = PlanMyStuff.configuration.user_inactive_label
63
+ return if @issue.labels.include?(label)
64
+
65
+ PlanMyStuff::Label.ensure!(repo: @issue.repo, name: label)
66
+ PlanMyStuff::Label.add(issue: @issue, labels: [label])
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module Reminders
5
+ # Emits +plan_my_stuff.issue.reminder_due+ for a single waiting issue
6
+ # and advances its +next_reminder_at+ to the next milestone in the
7
+ # effective +reminder_days+ schedule (or +nil+ when the last milestone
8
+ # has passed).
9
+ class Fire
10
+ # Returns +true+ when the issue has a +next_reminder_at+ in the
11
+ # past relative to +now+.
12
+ #
13
+ # @param issue [PlanMyStuff::Issue]
14
+ # @param now [Time]
15
+ #
16
+ # @return [Boolean]
17
+ #
18
+ def self.ready?(issue, now: Time.now.utc)
19
+ due = issue.metadata.next_reminder_at
20
+ due.present? && due <= now
21
+ end
22
+
23
+ # @param issue [PlanMyStuff::Issue] candidate issue from the sweep
24
+ # @param now [Time] clock reference (defaults to +Time.now.utc+)
25
+ #
26
+ def initialize(issue, now: Time.now.utc)
27
+ @issue = issue
28
+ @now = now.utc
29
+ end
30
+
31
+ # Emits the reminder event and advances the schedule.
32
+ #
33
+ # @return [void]
34
+ #
35
+ def call
36
+ payload = build_payload
37
+ PlanMyStuff::Notifications.instrument('issue.reminder_due', @issue, **payload)
38
+
39
+ @issue.update!(
40
+ metadata: { next_reminder_at: next_reminder_at_value },
41
+ skip_notification: true,
42
+ )
43
+ end
44
+
45
+ private
46
+
47
+ # @return [Hash]
48
+ def build_payload
49
+ payload = {
50
+ waiting_kind: waiting_kind,
51
+ days_waiting: days_waiting,
52
+ reminder_day: reminder_day,
53
+ last_activity_at: last_activity_at,
54
+ }
55
+ payload[:pending_approvers] = pending_approvers if waiting_kind == :approval
56
+ payload
57
+ end
58
+
59
+ # @return [Symbol] +:user+ or +:approval+
60
+ def waiting_kind
61
+ @issue.metadata.waiting_on_user_at.present? ? :user : :approval
62
+ end
63
+
64
+ # @return [Time] the timestamp waiting started for the active kind
65
+ def starting_clock
66
+ @issue.metadata.waiting_on_user_at || @issue.metadata.waiting_on_approval_at
67
+ end
68
+
69
+ # @return [Integer]
70
+ def days_waiting
71
+ ((@now - starting_clock) / 1.day).to_i
72
+ end
73
+
74
+ # Most recent milestone passed, used as the +reminder_day+ label in
75
+ # the event payload.
76
+ #
77
+ # @return [Integer, nil]
78
+ #
79
+ def reminder_day
80
+ effective_reminder_days.select { |d| d <= days_waiting }.max
81
+ end
82
+
83
+ # Informational: last comment timestamp (falling back to the
84
+ # issue's GitHub +updated_at+ when there are no comments). Uses
85
+ # +updated_at+ on Comment since that's the only timestamp PMS
86
+ # exposes on comment records.
87
+ #
88
+ # @return [Time, nil]
89
+ #
90
+ def last_activity_at
91
+ last_comment = @issue.comments.max_by { |c| c.updated_at || Time.at(0).utc }
92
+ last_comment&.updated_at || @issue.updated_at
93
+ end
94
+
95
+ # Resolves pending-approval user IDs via +UserResolver+. IDs that
96
+ # fail to resolve (e.g. deleted users) are dropped.
97
+ #
98
+ # @return [Array<Object>]
99
+ #
100
+ def pending_approvers
101
+ @issue.pending_approvals.filter_map do |approval|
102
+ PlanMyStuff::UserResolver.resolve(approval.user_id)
103
+ rescue
104
+ next
105
+ end
106
+ end
107
+
108
+ # @return [Array<Integer>]
109
+ def effective_reminder_days
110
+ @issue.metadata.reminder_days.presence || PlanMyStuff.configuration.reminder_days
111
+ end
112
+
113
+ # Time of the next milestone past +now+, or +nil+ when the last
114
+ # milestone has passed. Returned as a native +Time+ (not ISO
115
+ # string) so +IssueMetadata#to_h+'s +format_time+ serializes it
116
+ # cleanly.
117
+ #
118
+ # @return [Time, nil]
119
+ #
120
+ def next_reminder_at_value
121
+ start = starting_clock
122
+ remaining = effective_reminder_days.select { |d| start + d.days > @now }
123
+ return if remaining.empty?
124
+
125
+ (start + remaining.first.days).utc
126
+ end
127
+ end
128
+ end
129
+ end