plan_my_stuff 0.1.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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +28 -0
  3. data/README.md +284 -0
  4. data/app/controllers/plan_my_stuff/application_controller.rb +76 -0
  5. data/app/controllers/plan_my_stuff/comments_controller.rb +82 -0
  6. data/app/controllers/plan_my_stuff/issues_controller.rb +145 -0
  7. data/app/controllers/plan_my_stuff/labels_controller.rb +30 -0
  8. data/app/controllers/plan_my_stuff/project_items_controller.rb +93 -0
  9. data/app/controllers/plan_my_stuff/projects_controller.rb +17 -0
  10. data/app/views/plan_my_stuff/comments/edit.html.erb +16 -0
  11. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +32 -0
  12. data/app/views/plan_my_stuff/issues/edit.html.erb +12 -0
  13. data/app/views/plan_my_stuff/issues/index.html.erb +37 -0
  14. data/app/views/plan_my_stuff/issues/new.html.erb +7 -0
  15. data/app/views/plan_my_stuff/issues/partials/_form.html.erb +41 -0
  16. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +23 -0
  17. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +32 -0
  18. data/app/views/plan_my_stuff/issues/show.html.erb +58 -0
  19. data/app/views/plan_my_stuff/projects/index.html.erb +13 -0
  20. data/app/views/plan_my_stuff/projects/show.html.erb +101 -0
  21. data/config/routes.rb +25 -0
  22. data/lib/generators/plan_my_stuff/install/install_generator.rb +38 -0
  23. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +106 -0
  24. data/lib/generators/plan_my_stuff/views/views_generator.rb +22 -0
  25. data/lib/plan_my_stuff/application_record.rb +39 -0
  26. data/lib/plan_my_stuff/base_metadata.rb +136 -0
  27. data/lib/plan_my_stuff/client.rb +143 -0
  28. data/lib/plan_my_stuff/comment.rb +360 -0
  29. data/lib/plan_my_stuff/comment_metadata.rb +56 -0
  30. data/lib/plan_my_stuff/configuration.rb +139 -0
  31. data/lib/plan_my_stuff/custom_fields.rb +65 -0
  32. data/lib/plan_my_stuff/engine.rb +11 -0
  33. data/lib/plan_my_stuff/errors.rb +87 -0
  34. data/lib/plan_my_stuff/issue.rb +486 -0
  35. data/lib/plan_my_stuff/issue_metadata.rb +111 -0
  36. data/lib/plan_my_stuff/label.rb +59 -0
  37. data/lib/plan_my_stuff/markdown.rb +83 -0
  38. data/lib/plan_my_stuff/metadata_parser.rb +53 -0
  39. data/lib/plan_my_stuff/project.rb +504 -0
  40. data/lib/plan_my_stuff/project_item.rb +414 -0
  41. data/lib/plan_my_stuff/test_helpers.rb +501 -0
  42. data/lib/plan_my_stuff/user_resolver.rb +61 -0
  43. data/lib/plan_my_stuff/verifier.rb +102 -0
  44. data/lib/plan_my_stuff/version.rb +19 -0
  45. data/lib/plan_my_stuff.rb +69 -0
  46. data/lib/tasks/plan_my_stuff.rake +23 -0
  47. metadata +126 -0
@@ -0,0 +1,414 @@
1
+ # frozen_string_literal: true
2
+
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
413
+ end
414
+ end