plan_my_stuff 0.2.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 (87) 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 +65 -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/edit.html.erb +7 -0
  31. data/app/views/plan_my_stuff/projects/index.html.erb +17 -1
  32. data/app/views/plan_my_stuff/projects/new.html.erb +7 -0
  33. data/app/views/plan_my_stuff/projects/partials/_form.html.erb +29 -0
  34. data/app/views/plan_my_stuff/projects/show.html.erb +11 -4
  35. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +12 -0
  36. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +22 -0
  37. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +7 -0
  38. data/app/views/plan_my_stuff/testing_projects/new.html.erb +7 -0
  39. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +39 -0
  40. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +51 -0
  41. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +35 -0
  42. data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
  43. data/config/routes.rb +38 -15
  44. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +138 -5
  45. data/lib/plan_my_stuff/application_record.rb +144 -0
  46. data/lib/plan_my_stuff/approval.rb +80 -0
  47. data/lib/plan_my_stuff/archive/sweep.rb +85 -0
  48. data/lib/plan_my_stuff/archive.rb +14 -0
  49. data/lib/plan_my_stuff/aws_sns_simulator.rb +110 -0
  50. data/lib/plan_my_stuff/base_metadata.rb +0 -11
  51. data/lib/plan_my_stuff/base_project.rb +661 -0
  52. data/lib/plan_my_stuff/base_project_item.rb +562 -0
  53. data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
  54. data/lib/plan_my_stuff/cache.rb +197 -0
  55. data/lib/plan_my_stuff/client.rb +7 -0
  56. data/lib/plan_my_stuff/comment.rb +174 -54
  57. data/lib/plan_my_stuff/configuration.rb +254 -8
  58. data/lib/plan_my_stuff/custom_fields.rb +31 -17
  59. data/lib/plan_my_stuff/engine.rb +0 -4
  60. data/lib/plan_my_stuff/errors.rb +49 -0
  61. data/lib/plan_my_stuff/graphql/queries.rb +392 -0
  62. data/lib/plan_my_stuff/issue.rb +1477 -174
  63. data/lib/plan_my_stuff/issue_metadata.rb +122 -0
  64. data/lib/plan_my_stuff/label.rb +82 -11
  65. data/lib/plan_my_stuff/link.rb +144 -0
  66. data/lib/plan_my_stuff/notifications.rb +142 -0
  67. data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
  68. data/lib/plan_my_stuff/pipeline/status.rb +44 -0
  69. data/lib/plan_my_stuff/pipeline.rb +293 -0
  70. data/lib/plan_my_stuff/project.rb +62 -468
  71. data/lib/plan_my_stuff/project_item.rb +3 -417
  72. data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
  73. data/lib/plan_my_stuff/project_metadata.rb +47 -0
  74. data/lib/plan_my_stuff/reminders/closer.rb +70 -0
  75. data/lib/plan_my_stuff/reminders/fire.rb +129 -0
  76. data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
  77. data/lib/plan_my_stuff/reminders.rb +16 -0
  78. data/lib/plan_my_stuff/test_helpers.rb +260 -15
  79. data/lib/plan_my_stuff/testing_project.rb +291 -0
  80. data/lib/plan_my_stuff/testing_project_item.rb +184 -0
  81. data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
  82. data/lib/plan_my_stuff/user_resolver.rb +8 -3
  83. data/lib/plan_my_stuff/version.rb +1 -1
  84. data/lib/plan_my_stuff/webhook_replayer.rb +280 -0
  85. data/lib/plan_my_stuff.rb +16 -0
  86. data/lib/tasks/plan_my_stuff.rake +163 -0
  87. metadata +54 -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
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ # Metadata for regular (non-testing) projects.
5
+ class ProjectMetadata < BaseProjectMetadata
6
+ class << self
7
+ # Builds a ProjectMetadata from a parsed hash (e.g. from MetadataParser)
8
+ #
9
+ # @param hash [Hash]
10
+ #
11
+ # @return [PlanMyStuff::ProjectMetadata]
12
+ #
13
+ def from_hash(hash)
14
+ metadata = new
15
+ apply_common_from_hash(metadata, hash, PlanMyStuff.configuration.custom_fields_for(:project))
16
+ metadata.kind = hash.fetch(:kind, 'project')
17
+ metadata
18
+ end
19
+
20
+ # Builds a new ProjectMetadata for project creation, auto-filling gem defaults
21
+ #
22
+ # @param user [Object, Integer] user object or user_id
23
+ # @param visibility [String] "public" or "internal"
24
+ # @param custom_fields [Hash] app-defined field values
25
+ #
26
+ # @return [PlanMyStuff::ProjectMetadata]
27
+ #
28
+ def build(user:, visibility: 'internal', custom_fields: {})
29
+ metadata = new
30
+ apply_common_build(
31
+ metadata,
32
+ user: user,
33
+ visibility: visibility,
34
+ custom_fields_data: custom_fields,
35
+ custom_fields_schema: PlanMyStuff.configuration.custom_fields_for(:project),
36
+ )
37
+ metadata.kind = 'project'
38
+ metadata
39
+ end
40
+ end
41
+
42
+ def initialize
43
+ super
44
+ @kind = 'project'
45
+ end
46
+ end
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