plan_my_stuff 0.1.0 → 1.0.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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +595 -0
  3. data/CONFIGURATION.md +487 -0
  4. data/README.md +612 -88
  5. data/app/controllers/plan_my_stuff/application_controller.rb +27 -5
  6. data/app/controllers/plan_my_stuff/comments_controller.rb +50 -19
  7. data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +127 -0
  8. data/app/controllers/plan_my_stuff/issues/closures_controller.rb +53 -0
  9. data/app/controllers/plan_my_stuff/issues/links_controller.rb +129 -0
  10. data/app/controllers/plan_my_stuff/issues/takes_controller.rb +161 -0
  11. data/app/controllers/plan_my_stuff/issues/testings_controller.rb +82 -0
  12. data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +62 -0
  13. data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +55 -0
  14. data/app/controllers/plan_my_stuff/issues_controller.rb +53 -70
  15. data/app/controllers/plan_my_stuff/labels_controller.rb +32 -10
  16. data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +88 -0
  17. data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +44 -0
  18. data/app/controllers/plan_my_stuff/project_items_controller.rb +32 -69
  19. data/app/controllers/plan_my_stuff/projects_controller.rb +81 -3
  20. data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +67 -0
  21. data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +49 -0
  22. data/app/controllers/plan_my_stuff/testing_projects_controller.rb +121 -0
  23. data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +202 -0
  24. data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +371 -0
  25. data/app/jobs/plan_my_stuff/application_job.rb +8 -0
  26. data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +75 -0
  27. data/app/views/plan_my_stuff/comments/edit.html.erb +1 -3
  28. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +8 -0
  29. data/app/views/plan_my_stuff/issues/edit.html.erb +2 -4
  30. data/app/views/plan_my_stuff/issues/index.html.erb +5 -5
  31. data/app/views/plan_my_stuff/issues/new.html.erb +2 -4
  32. data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +108 -0
  33. data/app/views/plan_my_stuff/issues/partials/_form.html.erb +11 -6
  34. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +4 -3
  35. data/app/views/plan_my_stuff/issues/partials/_links.html.erb +113 -0
  36. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +4 -3
  37. data/app/views/plan_my_stuff/issues/show.html.erb +67 -6
  38. data/app/views/plan_my_stuff/partials/_flash.html.erb +3 -0
  39. data/app/views/plan_my_stuff/projects/edit.html.erb +5 -0
  40. data/app/views/plan_my_stuff/projects/index.html.erb +18 -2
  41. data/app/views/plan_my_stuff/projects/new.html.erb +5 -0
  42. data/app/views/plan_my_stuff/projects/partials/_form.html.erb +30 -0
  43. data/app/views/plan_my_stuff/projects/show.html.erb +30 -11
  44. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +10 -0
  45. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +20 -0
  46. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +5 -0
  47. data/app/views/plan_my_stuff/testing_projects/new.html.erb +5 -0
  48. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +40 -0
  49. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +52 -0
  50. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +36 -0
  51. data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
  52. data/config/routes.rb +43 -15
  53. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +302 -20
  54. data/lib/plan_my_stuff/application_record.rb +158 -1
  55. data/lib/plan_my_stuff/approval.rb +88 -0
  56. data/lib/plan_my_stuff/archive/sweep.rb +85 -0
  57. data/lib/plan_my_stuff/archive.rb +12 -0
  58. data/lib/plan_my_stuff/attachment.rb +83 -0
  59. data/lib/plan_my_stuff/attachment_uploader.rb +245 -0
  60. data/lib/plan_my_stuff/aws_sns_simulator.rb +116 -0
  61. data/lib/plan_my_stuff/base_metadata.rb +25 -28
  62. data/lib/plan_my_stuff/base_project.rb +502 -0
  63. data/lib/plan_my_stuff/base_project_extractions/graphql_hydration.rb +186 -0
  64. data/lib/plan_my_stuff/base_project_item.rb +588 -0
  65. data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
  66. data/lib/plan_my_stuff/cache.rb +197 -0
  67. data/lib/plan_my_stuff/client.rb +139 -64
  68. data/lib/plan_my_stuff/comment.rb +225 -100
  69. data/lib/plan_my_stuff/comment_metadata.rb +68 -5
  70. data/lib/plan_my_stuff/configuration.rb +459 -28
  71. data/lib/plan_my_stuff/custom_fields.rb +96 -12
  72. data/lib/plan_my_stuff/engine.rb +14 -2
  73. data/lib/plan_my_stuff/errors.rb +65 -5
  74. data/lib/plan_my_stuff/graphql/queries.rb +454 -0
  75. data/lib/plan_my_stuff/issue.rb +1097 -166
  76. data/lib/plan_my_stuff/issue_extractions/approvals.rb +370 -0
  77. data/lib/plan_my_stuff/issue_extractions/links.rb +525 -0
  78. data/lib/plan_my_stuff/issue_extractions/viewers.rb +75 -0
  79. data/lib/plan_my_stuff/issue_extractions/waiting.rb +171 -0
  80. data/lib/plan_my_stuff/issue_field.rb +126 -0
  81. data/lib/plan_my_stuff/issue_field_translation.rb +67 -0
  82. data/lib/plan_my_stuff/issue_field_value_set.rb +68 -0
  83. data/lib/plan_my_stuff/issue_metadata.rb +132 -21
  84. data/lib/plan_my_stuff/label.rb +100 -13
  85. data/lib/plan_my_stuff/link.rb +144 -0
  86. data/lib/plan_my_stuff/markdown.rb +13 -7
  87. data/lib/plan_my_stuff/metadata_parser.rb +51 -12
  88. data/lib/plan_my_stuff/notifications.rb +148 -0
  89. data/lib/plan_my_stuff/pipeline/completed_sweep.rb +46 -0
  90. data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
  91. data/lib/plan_my_stuff/pipeline/status.rb +40 -0
  92. data/lib/plan_my_stuff/pipeline/testing.rb +23 -0
  93. data/lib/plan_my_stuff/pipeline.rb +310 -0
  94. data/lib/plan_my_stuff/project.rb +63 -465
  95. data/lib/plan_my_stuff/project_item.rb +3 -409
  96. data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
  97. data/lib/plan_my_stuff/project_metadata.rb +47 -0
  98. data/lib/plan_my_stuff/reminders/closer.rb +70 -0
  99. data/lib/plan_my_stuff/reminders/fire.rb +129 -0
  100. data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
  101. data/lib/plan_my_stuff/reminders.rb +12 -0
  102. data/lib/plan_my_stuff/repo.rb +145 -0
  103. data/lib/plan_my_stuff/test_helpers.rb +265 -25
  104. data/lib/plan_my_stuff/testing_project.rb +292 -0
  105. data/lib/plan_my_stuff/testing_project_item.rb +218 -0
  106. data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
  107. data/lib/plan_my_stuff/user_resolver.rb +24 -3
  108. data/lib/plan_my_stuff/verifier.rb +10 -0
  109. data/lib/plan_my_stuff/version.rb +2 -2
  110. data/lib/plan_my_stuff/webhook_replayer.rb +292 -0
  111. data/lib/plan_my_stuff.rb +55 -20
  112. data/lib/tasks/plan_my_stuff.rake +331 -0
  113. metadata +99 -4
@@ -1,414 +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 [String, nil]
33
- attr_accessor :state
34
- # @return [String, nil]
35
- attr_accessor :status
36
- # @return [Hash]
37
- attr_accessor :field_values
38
- # @return [PlanMyStuff::Project, nil]
39
- attr_accessor :project
40
- # @return [PlanMyStuff::Issue, nil] linked issue (nil for draft items)
41
- attr_accessor :issue
42
-
43
- class << self
44
- # Builds a persisted ProjectItem from parsed item data.
45
- #
46
- # @param item_hash [Hash] parsed item data (from Project.parse_project_item)
47
- # @param project [PlanMyStuff::Project] parent project
48
- #
49
- # @return [PlanMyStuff::ProjectItem]
50
- #
51
- def build(item_hash, project:)
52
- item = new(
53
- id: item_hash[:id],
54
- type: item_hash[:type],
55
- content_node_id: item_hash[:content_node_id],
56
- title: item_hash[:title],
57
- number: item_hash[:number],
58
- url: item_hash[:url],
59
- state: item_hash[:state],
60
- status: item_hash[:status],
61
- field_values: item_hash[:field_values] || {},
62
- project: project,
63
- )
64
- item.__send__(:persisted!)
65
- item
66
- end
67
-
68
- # Creates a project item by adding an existing issue or creating a draft.
69
- #
70
- # @param issue_or_title [PlanMyStuff::Issue, String] Issue instance (non-draft) or title string (draft)
71
- # @param draft [Boolean] when true, creates a draft item from a title string
72
- # @param body [String, nil] body for draft items (ignored for non-draft)
73
- # @param project_number [Integer, nil] defaults to config.default_project_number
74
- #
75
- # @return [PlanMyStuff::ProjectItem]
76
- #
77
- def create!(issue_or_title, draft: false, body: nil, project_number: nil)
78
- if draft
79
- add_draft_item(title: issue_or_title, body: body, project_number: project_number)
80
- else
81
- add_item(issue: issue_or_title, project_number: project_number)
82
- end
83
- end
84
-
85
- # Moves a project item to a new status column.
86
- #
87
- # @param item_id [String] project item ID (e.g. "PVTI_...")
88
- # @param status [String] status name, resolved to option ID internally
89
- # @param project_number [Integer, nil] defaults to config.default_project_number
90
- #
91
- # @return [Hash] the updated item
92
- #
93
- def move_item(item_id:, status:, project_number: nil)
94
- project_number = resolve_default_project_number(project_number)
95
- project = Project.find(project_number)
96
-
97
- status_field = project.status_field
98
- option_id = resolve_status_option_id(status_field, status)
99
-
100
- PlanMyStuff.client.graphql(
101
- update_single_select_mutation,
102
- variables: {
103
- projectId: project.id,
104
- itemId: item_id,
105
- fieldId: status_field[:id],
106
- optionId: option_id,
107
- },
108
- )
109
- end
110
-
111
- # Assigns users to a project item.
112
- # Issues/PRs use REST via Issue.update!, drafts use GraphQL.
113
- #
114
- # @param number [Integer, nil] issue number (nil for drafts)
115
- # @param content_node_id [String] node ID of the underlying content
116
- # @param assignees [Array<String>] GitHub usernames
117
- # @param draft [Boolean] whether the item is a draft issue
118
- # @param repo [Symbol, String, nil] repo key (for issues)
119
- #
120
- # @return [void]
121
- #
122
- def assign(number:, content_node_id:, assignees:, draft: false, repo: nil)
123
- if draft
124
- client = PlanMyStuff.client
125
- user_ids = assignees.map do |assignee|
126
- user_data = client.graphql(user_node_id_query, variables: { login: assignee })
127
- user_id = user_data.dig(:user, :id)
128
-
129
- raise(APIError, "GitHub user not found: #{assignee}") if user_id.nil?
130
-
131
- user_id
132
- end
133
-
134
- client.graphql(
135
- assign_draft_mutation,
136
- variables: { draftIssueId: content_node_id, assigneeIds: user_ids },
137
- )
138
- else
139
- Issue.update!(number: number, repo: repo, assignees: assignees)
140
- end
141
- end
142
-
143
- private
144
-
145
- # Adds a GitHub issue to a project board.
146
- #
147
- # @param issue [PlanMyStuff::Issue]
148
- # @param project_number [Integer, nil]
149
- #
150
- # @return [PlanMyStuff::ProjectItem]
151
- #
152
- def add_item(issue:, project_number:)
153
- project_number = resolve_default_project_number(project_number)
154
- client = PlanMyStuff.client
155
- org = PlanMyStuff.configuration.organization
156
-
157
- github_issue = client.rest(:issue, issue.repo, issue.number)
158
- node_id = github_issue.respond_to?(:node_id) ? github_issue.node_id : github_issue[:node_id]
159
-
160
- project_id = resolve_project_id(org, project_number)
161
-
162
- data = client.graphql(
163
- add_item_mutation,
164
- variables: { projectId: project_id, contentId: node_id },
165
- )
166
-
167
- item_data = data.dig(:addProjectV2ItemById, :item) || {}
168
- project = Project.find(project_number)
169
-
170
- item = build(
171
- {
172
- id: item_data[:id],
173
- title: issue.title,
174
- number: issue.number,
175
- url: nil,
176
- state: issue.state,
177
- status: nil,
178
- field_values: {},
179
- },
180
- project: project,
181
- )
182
- item.issue = issue
183
- item
184
- end
185
-
186
- # Adds a draft issue to a project board.
187
- #
188
- # @param title [String]
189
- # @param body [String, nil]
190
- # @param project_number [Integer, nil]
191
- #
192
- # @return [PlanMyStuff::ProjectItem]
193
- #
194
- def add_draft_item(title:, body:, project_number:)
195
- project_number = resolve_default_project_number(project_number)
196
- org = PlanMyStuff.configuration.organization
197
- project_id = resolve_project_id(org, project_number)
198
-
199
- variables = { projectId: project_id, title: title }
200
- variables[:body] = body if body.present?
201
-
202
- data = PlanMyStuff.client.graphql(
203
- add_draft_item_mutation,
204
- variables: variables,
205
- )
206
-
207
- item_data = data.dig(:addProjectV2DraftIssue, :projectItem) || {}
208
- project = Project.find(project_number)
209
-
210
- build(
211
- {
212
- id: item_data[:id],
213
- title: title,
214
- number: nil,
215
- url: nil,
216
- state: nil,
217
- status: nil,
218
- field_values: {},
219
- },
220
- project: project,
221
- )
222
- end
223
-
224
- # Resolves a project number, falling back to config.default_project_number.
225
- #
226
- # @param project_number [Integer, nil]
227
- #
228
- # @return [Integer]
229
- #
230
- def resolve_default_project_number(project_number)
231
- return project_number if project_number.present?
232
-
233
- PlanMyStuff.configuration.default_project_number ||
234
- raise(ArgumentError, 'project_number is required when config.default_project_number is not set')
235
- end
236
-
237
- # @return [String]
238
- def add_item_mutation
239
- <<~GRAPHQL
240
- mutation($projectId: ID!, $contentId: ID!) {
241
- addProjectV2ItemById(input: {
242
- projectId: $projectId,
243
- contentId: $contentId
244
- }) {
245
- item {
246
- id
247
- }
248
- }
249
- }
250
- GRAPHQL
251
- end
252
-
253
- # @return [String]
254
- def add_draft_item_mutation
255
- <<~GRAPHQL
256
- mutation($projectId: ID!, $title: String!, $body: String) {
257
- addProjectV2DraftIssue(input: {
258
- projectId: $projectId,
259
- title: $title,
260
- body: $body
261
- }) {
262
- projectItem {
263
- id
264
- }
265
- }
266
- }
267
- GRAPHQL
268
- end
269
-
270
- # @return [String]
271
- def update_single_select_mutation
272
- <<~GRAPHQL
273
- mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
274
- updateProjectV2ItemFieldValue(input: {
275
- projectId: $projectId,
276
- itemId: $itemId,
277
- fieldId: $fieldId,
278
- value: { singleSelectOptionId: $optionId }
279
- }) {
280
- projectV2Item {
281
- id
282
- }
283
- }
284
- }
285
- GRAPHQL
286
- end
287
-
288
- # @return [String]
289
- def assign_draft_mutation
290
- <<~GRAPHQL
291
- mutation($draftIssueId: ID!, $assigneeIds: [ID!]) {
292
- updateProjectV2DraftIssue(input: {
293
- draftIssueId: $draftIssueId,
294
- assigneeIds: $assigneeIds
295
- }) {
296
- draftIssue { id }
297
- }
298
- }
299
- GRAPHQL
300
- end
301
-
302
- # @return [String]
303
- def user_node_id_query
304
- <<~GRAPHQL
305
- query($login: String!) {
306
- user(login: $login) {
307
- id
308
- }
309
- }
310
- GRAPHQL
311
- end
312
-
313
- # @return [String]
314
- def project_id_query
315
- <<~GRAPHQL
316
- query($org: String!, $number: Int!) {
317
- organization(login: $org) {
318
- projectV2(number: $number) {
319
- id
320
- }
321
- }
322
- }
323
- GRAPHQL
324
- end
325
-
326
- # Resolves a status name to its option ID.
327
- #
328
- # @param status_field [Hash]
329
- # @param status_name [String]
330
- #
331
- # @return [String]
332
- #
333
- def resolve_status_option_id(status_field, status_name)
334
- option = status_field[:options].find { |o| o[:name] == status_name }
335
-
336
- unless option
337
- available = status_field[:options].pluck(:name).join(', ')
338
- raise(ArgumentError, "Unknown status '#{status_name}'. Available: #{available}")
339
- end
340
-
341
- option[:id]
342
- end
343
-
344
- # Resolves a project number to its node ID.
345
- #
346
- # @param org [String]
347
- # @param project_number [Integer]
348
- #
349
- # @return [String]
350
- #
351
- def resolve_project_id(org, project_number)
352
- data = PlanMyStuff.client.graphql(
353
- project_id_query,
354
- variables: { org: org, number: project_number },
355
- )
356
-
357
- data.dig(:organization, :projectV2, :id)
358
- end
359
- end
360
-
361
- # @see super
362
- def initialize(**attrs)
363
- @id = attrs.delete(:id)
364
- @type = attrs.delete(:type)
365
- @content_node_id = attrs.delete(:content_node_id)
366
- super
367
- @field_values ||= {}
368
- end
369
-
370
- # Moves this item to a new status column on its parent project.
371
- #
372
- # @param status [String] status name (e.g. "In Progress", "Done")
373
- #
374
- # @return [Hash] mutation result
375
- #
376
- def move_to!(status)
377
- self.class.move_item(
378
- project_number: project.number,
379
- item_id: id,
380
- status: status,
381
- )
382
- end
383
-
384
- # Assigns users to this item on its parent project.
385
- #
386
- # @param assignees [String, Array<String>] GitHub username(s)
387
- #
388
- # @return [void]
389
- #
390
- def assign!(assignees)
391
- self.class.assign(
392
- number: number,
393
- content_node_id: content_node_id,
394
- assignees: Array.wrap(assignees),
395
- draft: draft?,
396
- )
397
- end
398
-
399
- # @return [Boolean]
400
- def draft?
401
- type == 'DRAFT_ISSUE'
402
- end
403
-
404
- private
405
-
406
- # Marks this record as persisted.
407
- #
408
- # @return [void]
409
- #
410
- def persisted!
411
- @persisted = true
412
- 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
413
7
  end
414
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 < PlanMyStuff::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 < PlanMyStuff::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 +issue_closed_inactive.plan_my_stuff+ 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
+ # +issue_closed_inactive.plan_my_stuff+.
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