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