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
@@ -0,0 +1,562 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ # Shared base for GitHub Projects V2 item wrappers. Holds attribute
5
+ # definitions, generic build/create/move/update/delete/assign machinery,
6
+ # and core instance helpers.
7
+ #
8
+ # Not meant to be used directly. Concrete subclasses (ProjectItem,
9
+ # TestingProjectItem) add their own domain-specific methods.
10
+ #
11
+ # Class methods:
12
+ # BaseProjectItem.create!(issue) -- add existing issue
13
+ # BaseProjectItem.create!(title, draft: true, body: "...") -- create draft item
14
+ # BaseProjectItem.move_item(item_id:, status:)
15
+ # BaseProjectItem.assign(item_id:, assignee:)
16
+ #
17
+ # Instance methods:
18
+ # item.move_to!("Done")
19
+ # item.assign!("octocat")
20
+ #
21
+ class BaseProjectItem < PlanMyStuff::ApplicationRecord
22
+ # @return [String, nil] GitHub node ID (e.g. "PVTI_...")
23
+ attribute :id, :string
24
+ # @return [String, nil] node ID of the underlying content (Issue, PR, or DraftIssue)
25
+ attribute :content_node_id, :string
26
+ # @return [String, nil] GitHub item type (e.g. "DRAFT_ISSUE", "ISSUE", "PULL_REQUEST")
27
+ attribute :type, :string
28
+ # @return [String, nil]
29
+ attribute :title, :string
30
+ # @return [String, nil] full body as stored on GitHub (draft items only)
31
+ attribute :raw_body, :string
32
+ # @return [String, nil] user-visible body (metadata comment stripped)
33
+ attribute :body, :string
34
+ # @return [PlanMyStuff::ProjectItemMetadata] parsed metadata
35
+ attribute :metadata, default: -> { PlanMyStuff::ProjectItemMetadata.new }
36
+ # @return [Integer, nil]
37
+ attribute :number, :integer
38
+ # @return [String, nil]
39
+ attribute :url, :string
40
+ # @return [PlanMyStuff::Repo, nil]
41
+ attribute :repo
42
+ # @return [String, nil]
43
+ attribute :state, :string
44
+ # @return [String, nil]
45
+ attribute :status, :string
46
+ # @return [Hash]
47
+ attribute :field_values, default: -> { {} }
48
+ # @return [PlanMyStuff::BaseProject, nil]
49
+ attribute :project
50
+ # @return [PlanMyStuff::Issue, nil] linked issue (nil for draft items)
51
+ attribute :issue
52
+
53
+ class << self
54
+ # Builds a persisted item from parsed item data.
55
+ #
56
+ # @param item_hash [Hash] parsed item data (from BaseProject.parse_project_item)
57
+ # @param project [PlanMyStuff::BaseProject] parent project
58
+ #
59
+ # @return [PlanMyStuff::BaseProjectItem]
60
+ #
61
+ def build(item_hash, project:)
62
+ raw_body_val = item_hash[:body]
63
+
64
+ item = new(
65
+ id: item_hash[:id],
66
+ type: item_hash[:type],
67
+ content_node_id: item_hash[:content_node_id],
68
+ title: item_hash[:title],
69
+ raw_body: raw_body_val,
70
+ number: item_hash[:number],
71
+ url: item_hash[:url],
72
+ repo: item_hash[:repo],
73
+ state: item_hash[:state],
74
+ status: item_hash[:status],
75
+ field_values: item_hash[:field_values] || {},
76
+ project: project,
77
+ )
78
+
79
+ if raw_body_val
80
+ parsed = PlanMyStuff::MetadataParser.parse(raw_body_val)
81
+ item.metadata = PlanMyStuff::ProjectItemMetadata.from_hash(parsed[:metadata] || {})
82
+ item.body = parsed[:body]
83
+ end
84
+
85
+ item.instance_variable_set(:@github_response, item_hash[:github_response])
86
+ item.__send__(:persisted!)
87
+ item
88
+ end
89
+
90
+ # Creates a project item by adding an existing issue or creating a draft.
91
+ #
92
+ # @param issue_or_title [PlanMyStuff::Issue, String] Issue instance (non-draft) or title string (draft)
93
+ # @param draft [Boolean] when true, creates a draft item from a title string
94
+ # @param body [String, nil] body for draft items (ignored for non-draft)
95
+ # @param project_number [Integer, nil] defaults to config.default_project_number
96
+ #
97
+ # @return [PlanMyStuff::BaseProjectItem]
98
+ #
99
+ def create!(issue_or_title, draft: false, body: nil, project_number: nil, user: nil)
100
+ item =
101
+ if draft
102
+ add_draft_item(title: issue_or_title, body: body, project_number: project_number)
103
+ else
104
+ add_item(issue: issue_or_title, project_number: project_number)
105
+ end
106
+
107
+ PlanMyStuff::Notifications.instrument('project_item.added', item, user: user)
108
+ item
109
+ end
110
+
111
+ # Moves a project item to a new status column.
112
+ #
113
+ # @param item_id [String] project item ID (e.g. "PVTI_...")
114
+ # @param status [String] status name, resolved to option ID internally
115
+ # @param project_number [Integer, nil] defaults to config.default_project_number
116
+ #
117
+ # @return [Hash] the updated item
118
+ #
119
+ def move_item(item_id:, status:, project_number: nil)
120
+ project_number = resolve_default_project_number(project_number)
121
+ project = BaseProject.find(project_number)
122
+
123
+ status_field = project.status_field
124
+ option_id = resolve_status_option_id(status_field, status)
125
+
126
+ PlanMyStuff.client.graphql(
127
+ PlanMyStuff::GraphQL::Queries::UPDATE_SINGLE_SELECT_FIELD,
128
+ variables: {
129
+ projectId: project.id,
130
+ itemId: item_id,
131
+ fieldId: status_field[:id],
132
+ optionId: option_id,
133
+ },
134
+ )
135
+ end
136
+
137
+ # Updates a single-select custom field on a project item.
138
+ #
139
+ # @param item_id [String] project item ID (e.g. "PVTI_...")
140
+ # @param field_name [String] single-select field name (e.g. "Pass Mode")
141
+ # @param value [String] option name to select
142
+ # @param project_number [Integer, nil] defaults to config.default_project_number
143
+ #
144
+ # @return [Hash] mutation result
145
+ #
146
+ def update_single_select_field!(item_id:, field_name:, value:, project_number: nil)
147
+ project_number = resolve_default_project_number(project_number)
148
+ project = BaseProject.find(project_number)
149
+
150
+ field = resolve_single_select_field(project, field_name)
151
+ option_id = resolve_status_option_id(field, value)
152
+
153
+ PlanMyStuff.client.graphql(
154
+ PlanMyStuff::GraphQL::Queries::UPDATE_SINGLE_SELECT_FIELD,
155
+ variables: {
156
+ projectId: project.id,
157
+ itemId: item_id,
158
+ fieldId: field[:id],
159
+ optionId: option_id,
160
+ },
161
+ )
162
+ end
163
+
164
+ # Updates a text custom field on a project item.
165
+ #
166
+ # @param item_id [String] project item ID (e.g. "PVTI_...")
167
+ # @param field_name [String] text field name (e.g. "Deployment ID")
168
+ # @param value [String] text value to set
169
+ # @param project_number [Integer, nil] defaults to config.default_project_number
170
+ #
171
+ # @return [Hash] mutation result
172
+ #
173
+ def update_field!(item_id:, field_name:, value:, project_number: nil)
174
+ project_number = resolve_default_project_number(project_number)
175
+ project = BaseProject.find(project_number)
176
+
177
+ field = resolve_text_field(project, field_name)
178
+
179
+ PlanMyStuff.client.graphql(
180
+ PlanMyStuff::GraphQL::Queries::UPDATE_TEXT_FIELD,
181
+ variables: {
182
+ projectId: project.id,
183
+ itemId: item_id,
184
+ fieldId: field[:id],
185
+ value: value,
186
+ },
187
+ )
188
+ end
189
+
190
+ # Updates a date custom field on a project item.
191
+ #
192
+ # @param item_id [String] project item ID (e.g. "PVTI_...")
193
+ # @param field_name [String] date field name (e.g. "Due Date")
194
+ # @param date [Date, String] date value to set
195
+ # @param project_number [Integer, nil] defaults to config.default_project_number
196
+ #
197
+ # @return [Hash] mutation result
198
+ #
199
+ def update_date_field!(item_id:, field_name:, date:, project_number: nil)
200
+ project_number = resolve_default_project_number(project_number)
201
+ project = BaseProject.find(project_number)
202
+
203
+ field = resolve_text_field(project, field_name)
204
+
205
+ PlanMyStuff.client.graphql(
206
+ PlanMyStuff::GraphQL::Queries::UPDATE_DATE_FIELD,
207
+ variables: {
208
+ projectId: project.id,
209
+ itemId: item_id,
210
+ fieldId: field[:id],
211
+ date: date.to_s,
212
+ },
213
+ )
214
+ end
215
+
216
+ # Deletes a project item from its parent project. Returns the
217
+ # +deletedItemId+ from the GraphQL response on success.
218
+ #
219
+ # @param item_id [String] project item ID (e.g. "PVTI_...")
220
+ # @param project_number [Integer, nil] defaults to config.default_project_number
221
+ #
222
+ # @return [String] the deleted item ID echoed back by GitHub
223
+ #
224
+ # @raise [PlanMyStuff::APIError] if the GraphQL mutation fails
225
+ #
226
+ def delete_item(item_id:, project_number: nil)
227
+ project_number = resolve_default_project_number(project_number)
228
+ org = PlanMyStuff.configuration.organization
229
+ project_id = resolve_project_id(org, project_number)
230
+
231
+ data = PlanMyStuff.client.graphql(
232
+ PlanMyStuff::GraphQL::Queries::DELETE_PROJECT_ITEM,
233
+ variables: { projectId: project_id, itemId: item_id },
234
+ )
235
+
236
+ deleted_id = data.dig(:deleteProjectV2Item, :deletedItemId)
237
+ raise(PlanMyStuff::APIError, "Failed to delete project item #{item_id}") if deleted_id.nil?
238
+
239
+ deleted_id
240
+ end
241
+
242
+ # Assigns users to a project item.
243
+ # Issues/PRs use REST via Issue.update!, drafts use GraphQL.
244
+ #
245
+ # @param number [Integer, nil] issue number (nil for drafts)
246
+ # @param content_node_id [String] node ID of the underlying content
247
+ # @param assignees [Array<String>] GitHub usernames
248
+ # @param draft [Boolean] whether the item is a draft issue
249
+ # @param repo [Symbol, String, nil] repo key (for issues)
250
+ #
251
+ # @return [void]
252
+ #
253
+ def assign(number:, content_node_id:, assignees:, draft: false, repo: nil)
254
+ if draft
255
+ client = PlanMyStuff.client
256
+ user_ids = assignees.map do |assignee|
257
+ user_data = client.graphql(
258
+ PlanMyStuff::GraphQL::Queries::USER_NODE_ID,
259
+ variables: { login: assignee },
260
+ )
261
+ user_id = user_data.dig(:user, :id)
262
+
263
+ raise(APIError, "GitHub user not found: #{assignee}") if user_id.nil?
264
+
265
+ user_id
266
+ end
267
+
268
+ client.graphql(
269
+ PlanMyStuff::GraphQL::Queries::ASSIGN_DRAFT,
270
+ variables: { draftIssueId: content_node_id, assigneeIds: user_ids },
271
+ )
272
+ else
273
+ Issue.update!(number: number, repo: repo, assignees: assignees)
274
+ end
275
+ end
276
+
277
+ private
278
+
279
+ # Adds a GitHub issue to a project board.
280
+ #
281
+ # @param issue [PlanMyStuff::Issue]
282
+ # @param project_number [Integer, nil]
283
+ #
284
+ # @return [PlanMyStuff::BaseProjectItem]
285
+ #
286
+ def add_item(issue:, project_number:)
287
+ project_number = resolve_default_project_number(project_number)
288
+ client = PlanMyStuff.client
289
+ org = PlanMyStuff.configuration.organization
290
+
291
+ github_issue = client.rest(:issue, issue.repo, issue.number)
292
+ node_id = github_issue.respond_to?(:node_id) ? github_issue.node_id : github_issue[:node_id]
293
+
294
+ project_id = resolve_project_id(org, project_number)
295
+
296
+ data = client.graphql(
297
+ PlanMyStuff::GraphQL::Queries::ADD_ITEM,
298
+ variables: { projectId: project_id, contentId: node_id },
299
+ )
300
+
301
+ item_data = data.dig(:addProjectV2ItemById, :item) || {}
302
+ project = BaseProject.find(project_number)
303
+
304
+ item = build(
305
+ {
306
+ id: item_data[:id],
307
+ content_node_id: node_id,
308
+ title: issue.title,
309
+ number: issue.number,
310
+ url: nil,
311
+ state: issue.state,
312
+ status: nil,
313
+ field_values: {},
314
+ github_response: item_data,
315
+ },
316
+ project: project,
317
+ )
318
+ item.issue = issue
319
+ item
320
+ end
321
+
322
+ # Adds a draft issue to a project board.
323
+ #
324
+ # @param title [String]
325
+ # @param body [String, nil]
326
+ # @param project_number [Integer, nil]
327
+ #
328
+ # @return [PlanMyStuff::BaseProjectItem]
329
+ #
330
+ def add_draft_item(title:, body:, project_number:)
331
+ project_number = resolve_default_project_number(project_number)
332
+ org = PlanMyStuff.configuration.organization
333
+ project_id = resolve_project_id(org, project_number)
334
+
335
+ variables = { projectId: project_id, title: title }
336
+ variables[:body] = body if body.present?
337
+
338
+ data = PlanMyStuff.client.graphql(
339
+ PlanMyStuff::GraphQL::Queries::ADD_DRAFT_ITEM,
340
+ variables: variables,
341
+ )
342
+
343
+ item_data = data.dig(:addProjectV2DraftIssue, :projectItem) || {}
344
+ project = BaseProject.find(project_number)
345
+
346
+ build(
347
+ {
348
+ id: item_data[:id],
349
+ content_node_id: item_data.dig(:content, :id),
350
+ type: 'DRAFT_ISSUE',
351
+ title: title,
352
+ body: body,
353
+ number: nil,
354
+ url: nil,
355
+ state: nil,
356
+ status: nil,
357
+ field_values: {},
358
+ github_response: item_data,
359
+ },
360
+ project: project,
361
+ )
362
+ end
363
+
364
+ # @see PlanMyStuff::Project.resolve_default_project_number
365
+ def resolve_default_project_number(project_number)
366
+ PlanMyStuff::Project.resolve_default_project_number(project_number)
367
+ end
368
+
369
+ # Finds a text field by name on a project. Raises ArgumentError if
370
+ # the field is not found or is a single-select field.
371
+ #
372
+ # @param project [PlanMyStuff::BaseProject]
373
+ # @param field_name [String]
374
+ #
375
+ # @return [Hash]
376
+ #
377
+ def resolve_text_field(project, field_name)
378
+ field = project.fields.find { |f| f[:name] == field_name && f[:options].nil? }
379
+
380
+ unless field
381
+ available = project.fields.select { |f| f[:options].nil? }.pluck(:name).join(', ')
382
+ raise(ArgumentError, "Unknown text field '#{field_name}'. Available: #{available}")
383
+ end
384
+
385
+ field
386
+ end
387
+
388
+ # @param project [PlanMyStuff::BaseProject]
389
+ # @param field_name [String]
390
+ #
391
+ # @return [Hash]
392
+ #
393
+ def resolve_single_select_field(project, field_name)
394
+ field = project.fields.find { |f| f[:name] == field_name && f[:options].present? }
395
+
396
+ unless field
397
+ available = project.fields.select { |f| f[:options].present? }.pluck(:name).join(', ')
398
+ raise(ArgumentError, "Unknown single-select field '#{field_name}'. Available: #{available}")
399
+ end
400
+
401
+ field
402
+ end
403
+
404
+ # Resolves a status name to its option ID.
405
+ #
406
+ # @param status_field [Hash]
407
+ # @param status_name [String]
408
+ #
409
+ # @return [String]
410
+ #
411
+ def resolve_status_option_id(status_field, status_name)
412
+ option = status_field[:options].find { |o| o[:name] == status_name }
413
+
414
+ unless option
415
+ available = status_field[:options].pluck(:name).join(', ')
416
+ raise(ArgumentError, "Unknown status '#{status_name}'. Available: #{available}")
417
+ end
418
+
419
+ option[:id]
420
+ end
421
+
422
+ # Resolves a project number to its node ID.
423
+ #
424
+ # @param org [String]
425
+ # @param project_number [Integer]
426
+ #
427
+ # @return [String]
428
+ #
429
+ def resolve_project_id(org, project_number)
430
+ data = PlanMyStuff.client.graphql(
431
+ PlanMyStuff::GraphQL::Queries::PROJECT_ID,
432
+ variables: { org: org, number: project_number },
433
+ )
434
+
435
+ data.dig(:organization, :projectV2, :id)
436
+ end
437
+ end
438
+
439
+ # Moves this item to a new status column on its parent project.
440
+ #
441
+ # @param status [String] status name (e.g. "In Progress", "Done")
442
+ #
443
+ # @return [Hash] mutation result
444
+ #
445
+ def move_to!(status, user: nil)
446
+ previous_status = self.status
447
+ result = self.class.move_item(
448
+ project_number: project.number,
449
+ item_id: id,
450
+ status: status,
451
+ )
452
+
453
+ PlanMyStuff::Notifications.instrument(
454
+ 'project_item.status_changed',
455
+ self,
456
+ user: user,
457
+ status: status,
458
+ previous_status: previous_status,
459
+ )
460
+
461
+ result
462
+ end
463
+
464
+ # Updates a text custom field on this item.
465
+ #
466
+ # @param field_name [String] text field name (e.g. "Deployment ID")
467
+ # @param value [String] text value to set
468
+ #
469
+ # @return [Hash] mutation result
470
+ #
471
+ def update_field!(field_name, value)
472
+ self.class.update_field!(
473
+ project_number: project.number,
474
+ item_id: id,
475
+ field_name: field_name,
476
+ value: value,
477
+ )
478
+ end
479
+
480
+ # Deletes this item from its parent project. Marks the in-memory
481
+ # instance as destroyed so +destroyed?+ returns true and +persisted?+
482
+ # returns false.
483
+ #
484
+ # No-op if the instance is already destroyed.
485
+ #
486
+ # @return [String, nil] the deleted item ID, or nil if already destroyed
487
+ #
488
+ # @raise [PlanMyStuff::APIError] if the GraphQL mutation fails
489
+ #
490
+ def destroy!(user: nil)
491
+ return if destroyed?
492
+
493
+ deleted_id = self.class.delete_item(
494
+ project_number: project.number,
495
+ item_id: id,
496
+ )
497
+
498
+ PlanMyStuff::Notifications.instrument('project_item.removed', self, user: user)
499
+ destroyed!
500
+ deleted_id
501
+ end
502
+
503
+ # Assigns users to this item on its parent project.
504
+ #
505
+ # @param assignees [String, Array<String>] GitHub username(s)
506
+ #
507
+ # @return [void]
508
+ #
509
+ def assign!(assignees, user: nil)
510
+ assignee_list = Array.wrap(assignees)
511
+
512
+ self.class.assign(
513
+ number: number,
514
+ content_node_id: content_node_id,
515
+ assignees: assignee_list,
516
+ draft: draft?,
517
+ repo: repo,
518
+ )
519
+
520
+ PlanMyStuff::Notifications.instrument(
521
+ 'project_item.assigned',
522
+ self,
523
+ user: user,
524
+ assignees: assignee_list,
525
+ )
526
+ end
527
+
528
+ # Serializes the project item to a JSON-safe hash, excluding the back-reference
529
+ # to the parent project to prevent recursive serialization cycles.
530
+ #
531
+ # @return [Hash]
532
+ #
533
+ def as_json(options = {})
534
+ merged_except = Array.wrap(options[:except]) + ['project']
535
+ merged_methods = Array.wrap(options[:methods]) + [:draft?]
536
+ super(options.merge(except: merged_except, methods: merged_methods)).merge(
537
+ 'project_id' => project&.id,
538
+ 'project_number' => project&.number,
539
+ )
540
+ end
541
+
542
+ # @return [Boolean]
543
+ def draft?
544
+ type == 'DRAFT_ISSUE'
545
+ end
546
+
547
+ # @return [void]
548
+ def issue=(val)
549
+ @issue_assigned = true
550
+ super
551
+ end
552
+
553
+ # @return [PlanMyStuff::Issue, nil]
554
+ def issue
555
+ return super if @issue_assigned
556
+ return if draft?
557
+
558
+ self.issue = PMS::Issue.find(number, repo: repo)
559
+ super
560
+ end
561
+ end
562
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ # Shared base for project-style metadata (regular and testing). Holds the
5
+ # +kind+ dispatch field and serialization hook. Not instantiated directly;
6
+ # use +ProjectMetadata+ or +TestingProjectMetadata+.
7
+ class BaseProjectMetadata < BaseMetadata
8
+ # @return [String, nil] dispatch key: "project" or "testing"
9
+ attr_accessor :kind
10
+
11
+ # @return [Hash]
12
+ def to_h
13
+ super.merge(kind: kind)
14
+ end
15
+ end
16
+ end