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,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/hash/deep_merge'
4
+
5
+ module PlanMyStuff
6
+ module Markdown
7
+ class << self
8
+ # Renders markdown text to HTML using the configured renderer.
9
+ # Per-call options are deep-merged on top of config.markdown_options.
10
+ #
11
+ # @param text [String] raw markdown text
12
+ # @param options [Hash] renderer-specific options (merged over config defaults)
13
+ # For :commonmarker - passed as `options:` to `Commonmarker.to_html`
14
+ # For :redcarpet - :render_options and :renderer are extracted for the HTML renderer;
15
+ # remaining keys are passed as extensions to `Redcarpet::Markdown.new`
16
+ #
17
+ # @return [String] rendered HTML
18
+ #
19
+ def render(text, options = {})
20
+ config = PlanMyStuff.configuration
21
+ merged = config.markdown_options.deep_merge(options)
22
+
23
+ case config.markdown_renderer
24
+ when :commonmarker
25
+ render_commonmarker(text, merged)
26
+ when :redcarpet
27
+ render_redcarpet(text, merged)
28
+ when nil
29
+ "<code>#{text}</code>"
30
+ else
31
+ raise(
32
+ ArgumentError,
33
+ "Unknown markdown_renderer: #{config.markdown_renderer.inspect}. Use :commonmarker, :redcarpet, or nil.",
34
+ )
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ # @param text [String]
41
+ # @param options [Hash]
42
+ #
43
+ # @return [String]
44
+ #
45
+ def render_commonmarker(text, options)
46
+ require('commonmarker') unless defined?(Commonmarker)
47
+
48
+ if options.empty?
49
+ Commonmarker.to_html(text)
50
+ else
51
+ Commonmarker.to_html(text, options: options)
52
+ end
53
+ rescue LoadError
54
+ raise(
55
+ PlanMyStuff::Error,
56
+ 'commonmarker gem is required when markdown_renderer is :commonmarker. ' \
57
+ "Add gem 'commonmarker' to your Gemfile.",
58
+ )
59
+ end
60
+
61
+ # @param text [String]
62
+ # @param options [Hash]
63
+ #
64
+ # @return [String]
65
+ #
66
+ def render_redcarpet(text, options)
67
+ require('redcarpet') unless defined?(Redcarpet)
68
+
69
+ options = options.dup
70
+ render_options = options.delete(:render_options) || {}
71
+ renderer_class = options.delete(:renderer) || Redcarpet::Render::HTML
72
+
73
+ Redcarpet::Markdown.new(renderer_class.new(render_options), options).render(text)
74
+ rescue LoadError
75
+ raise(
76
+ PlanMyStuff::Error,
77
+ 'redcarpet gem is required when markdown_renderer is :redcarpet. ' \
78
+ "Add gem 'redcarpet' to your Gemfile.",
79
+ )
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module PlanMyStuff
6
+ module MetadataParser
7
+ METADATA_PATTERN = /\A<!-- pms-metadata:(.*?) -->\n*/m
8
+
9
+ module_function
10
+
11
+ # Extracts metadata JSON from the raw body
12
+ #
13
+ # @param raw_body [String, nil]
14
+ #
15
+ # @return [Hash{Symbol => Hash, String}] :metadata (Hash, empty when absent) and :body (String)
16
+ #
17
+ def parse(raw_body)
18
+ return { metadata: {}, body: '' } if raw_body.blank?
19
+
20
+ match = raw_body.match(METADATA_PATTERN)
21
+ return { metadata: {}, body: raw_body } if match.nil?
22
+
23
+ metadata = JSON.parse(match[1], symbolize_names: true)
24
+ body = raw_body.sub(METADATA_PATTERN, '')
25
+
26
+ { metadata: metadata, body: body }
27
+ rescue JSON::ParserError
28
+ { metadata: {}, body: raw_body }
29
+ end
30
+
31
+ # Serializes a metadata hash and body into the stored format
32
+ #
33
+ # @param metadata [Hash, PlanMyStuff::CustomFields]
34
+ # @param body [String]
35
+ #
36
+ # @return [String]
37
+ #
38
+ def serialize(metadata, body)
39
+ if !metadata.is_a?(Hash) && !metadata.is_a?(PlanMyStuff::CustomFields)
40
+ raise(ArgumentError, "metadata must be a Hash or PlanMyStuff::CustomFields, got #{metadata.class}")
41
+ end
42
+
43
+ json =
44
+ if metadata.is_a?(PlanMyStuff::CustomFields)
45
+ metadata.to_json
46
+ else
47
+ JSON.pretty_generate(metadata)
48
+ end
49
+
50
+ "<!-- pms-metadata:#{json} -->\n\n#{body}"
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,504 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ # Wraps a GitHub Projects V2 project with its statuses, items, and fields.
5
+ # Class methods provide the public API for CRUD and query operations.
6
+ #
7
+ # Follows an ActiveRecord-style pattern:
8
+ # - `Project.find` / `Project.list` return persisted instances
9
+ # - `ProjectItem.add_item` / `ProjectItem.add_draft_item` for adding items
10
+ # - `ProjectItem.move_item` / `ProjectItem.assign` for item mutations
11
+ class Project < PlanMyStuff::ApplicationRecord
12
+ MAX_AUTO_PAGINATE_ITEMS = 500
13
+ ITEMS_PER_PAGE = 100
14
+
15
+ # @return [String] GitHub node ID
16
+ attr_reader :id
17
+ # @return [Integer] project number
18
+ attr_reader :number
19
+ # @return [Boolean] whether the project is closed
20
+ attr_reader :closed
21
+
22
+ # @return [String] project title
23
+ attr_accessor :title
24
+ # @return [String] project URL
25
+ attr_accessor :url
26
+ # @return [Array<Hash>] status options ({id:, name:})
27
+ attr_accessor :statuses
28
+ # @return [Array<Hash>] all field definitions
29
+ attr_accessor :fields
30
+ # @return [Array<PlanMyStuff::ProjectItem>] project items
31
+ attr_accessor :items
32
+ # @return [String, nil] cursor for next page (only in cursor mode)
33
+ attr_accessor :next_cursor
34
+ # @return [Boolean, nil] whether more pages exist (only in cursor mode)
35
+ attr_accessor :has_next_page
36
+
37
+ class << self
38
+ # Creates a new project in the configured organization.
39
+ #
40
+ # @param title [String]
41
+ #
42
+ # @return [Object]
43
+ #
44
+ def create(title:)
45
+ raise(NotImplementedError, "#{name}.create is not yet implemented")
46
+ end
47
+
48
+ # Updates an existing project.
49
+ #
50
+ # @param project_number [Integer]
51
+ # @param title [String, nil]
52
+ #
53
+ # @return [Object]
54
+ #
55
+ def update(project_number:, title: nil)
56
+ raise(NotImplementedError, "#{name}.update is not yet implemented")
57
+ end
58
+
59
+ # Lists all projects in the configured organization.
60
+ #
61
+ # @return [Array<PlanMyStuff::Project>]
62
+ #
63
+ def list
64
+ org = PlanMyStuff.configuration.organization
65
+ data = PlanMyStuff.client.graphql(list_query, variables: { org: org })
66
+
67
+ nodes = data.dig(:organization, :projectsV2, :nodes) || []
68
+
69
+ nodes.map { |node| build_summary(node) }
70
+ end
71
+
72
+ # Finds a project by number with its statuses, items, and fields.
73
+ #
74
+ # @param number [Integer]
75
+ # @param paginate [Symbol] :auto (default) or :cursor
76
+ # @param cursor [String, nil] pagination cursor for :cursor mode (from a previous call's next_cursor)
77
+ #
78
+ # @return [PlanMyStuff::Project]
79
+ #
80
+ def find(number, paginate: :auto, cursor: nil)
81
+ org = PlanMyStuff.configuration.organization
82
+
83
+ case paginate
84
+ when :auto
85
+ find_auto_paginated(org, number)
86
+ when :cursor
87
+ find_with_cursor(org, number, cursor: cursor)
88
+ else
89
+ raise(ArgumentError, "Unknown paginate mode: #{paginate.inspect}. Use :auto or :cursor")
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ # Resolves a project number, falling back to config.default_project_number.
96
+ #
97
+ # @param project_number [Integer, nil]
98
+ #
99
+ # @return [Integer]
100
+ #
101
+ def resolve_default_project_number(project_number)
102
+ return project_number if project_number.present?
103
+
104
+ PlanMyStuff.configuration.default_project_number ||
105
+ raise(ArgumentError, 'project_number is required when config.default_project_number is not set')
106
+ end
107
+
108
+ # @return [String]
109
+ def list_query
110
+ <<~GRAPHQL
111
+ query($org: String!) {
112
+ organization(login: $org) {
113
+ projectsV2(first: 100) {
114
+ nodes {
115
+ id
116
+ number
117
+ title
118
+ url
119
+ closed
120
+ }
121
+ }
122
+ }
123
+ }
124
+ GRAPHQL
125
+ end
126
+
127
+ # @return [String]
128
+ def find_query
129
+ <<~GRAPHQL
130
+ query($org: String!, $number: Int!, $cursor: String) {
131
+ organization(login: $org) {
132
+ projectV2(number: $number) {
133
+ id
134
+ number
135
+ title
136
+ url
137
+ closed
138
+ fields(first: 50) {
139
+ nodes {
140
+ ... on ProjectV2SingleSelectField {
141
+ id
142
+ name
143
+ options {
144
+ id
145
+ name
146
+ }
147
+ }
148
+ ... on ProjectV2Field {
149
+ id
150
+ name
151
+ }
152
+ ... on ProjectV2IterationField {
153
+ id
154
+ name
155
+ }
156
+ }
157
+ }
158
+ items(first: #{ITEMS_PER_PAGE}, after: $cursor) {
159
+ pageInfo {
160
+ hasNextPage
161
+ endCursor
162
+ }
163
+ nodes {
164
+ id
165
+ type
166
+ content {
167
+ ... on Issue {
168
+ id
169
+ title
170
+ number
171
+ url
172
+ state
173
+ }
174
+ ... on PullRequest {
175
+ id
176
+ title
177
+ number
178
+ url
179
+ state
180
+ }
181
+ ... on DraftIssue {
182
+ id
183
+ title
184
+ }
185
+ }
186
+ fieldValues(first: 20) {
187
+ nodes {
188
+ ... on ProjectV2ItemFieldSingleSelectValue {
189
+ name
190
+ field {
191
+ ... on ProjectV2SingleSelectField {
192
+ name
193
+ }
194
+ }
195
+ }
196
+ ... on ProjectV2ItemFieldTextValue {
197
+ text
198
+ field {
199
+ ... on ProjectV2Field {
200
+ name
201
+ }
202
+ }
203
+ }
204
+ ... on ProjectV2ItemFieldUserValue {
205
+ users(first: 10) {
206
+ nodes {
207
+ login
208
+ }
209
+ }
210
+ field {
211
+ ... on ProjectV2Field {
212
+ name
213
+ }
214
+ }
215
+ }
216
+ }
217
+ }
218
+ }
219
+ }
220
+ }
221
+ }
222
+ }
223
+ GRAPHQL
224
+ end
225
+
226
+ # @return [String]
227
+ def project_id_query
228
+ <<~GRAPHQL
229
+ query($org: String!, $number: Int!) {
230
+ organization(login: $org) {
231
+ projectV2(number: $number) {
232
+ id
233
+ }
234
+ }
235
+ }
236
+ GRAPHQL
237
+ end
238
+
239
+ # Builds a summary Project from a list query node.
240
+ #
241
+ # @param node [Hash]
242
+ #
243
+ # @return [PlanMyStuff::Project]
244
+ #
245
+ def build_summary(node)
246
+ project = new
247
+ project.__send__(:hydrate_summary, node)
248
+ project
249
+ end
250
+
251
+ # Builds a detailed Project from a find query response.
252
+ #
253
+ # @param graphql_project [Hash]
254
+ # @param items [Array<Hash>]
255
+ # @param next_cursor [String, nil]
256
+ # @param has_next_page [Boolean, nil]
257
+ #
258
+ # @return [PlanMyStuff::Project]
259
+ #
260
+ def build_detail(graphql_project, items:, next_cursor: nil, has_next_page: nil)
261
+ project = new
262
+ project.__send__(
263
+ :hydrate_detail,
264
+ graphql_project,
265
+ items: items,
266
+ next_cursor: next_cursor,
267
+ has_next_page: has_next_page,
268
+ )
269
+ project
270
+ end
271
+
272
+ # @param org [String]
273
+ # @param number [Integer]
274
+ #
275
+ # @return [PlanMyStuff::Project]
276
+ #
277
+ def find_auto_paginated(org, number)
278
+ all_items = []
279
+ cursor = nil
280
+ raw_project = nil
281
+
282
+ loop do
283
+ page = fetch_project_page(org, number, cursor)
284
+ raw_project ||= page[:raw]
285
+ all_items.concat(page[:items])
286
+
287
+ break if !page[:has_next_page] || all_items.length >= MAX_AUTO_PAGINATE_ITEMS
288
+
289
+ cursor = page[:next_cursor]
290
+ end
291
+
292
+ build_detail(raw_project, items: all_items)
293
+ end
294
+
295
+ # @param org [String]
296
+ # @param number [Integer]
297
+ # @param cursor [String, nil]
298
+ #
299
+ # @return [PlanMyStuff::Project]
300
+ #
301
+ def find_with_cursor(org, number, cursor:)
302
+ page = fetch_project_page(org, number, cursor)
303
+ build_detail(
304
+ page[:raw],
305
+ items: page[:items],
306
+ next_cursor: page[:next_cursor],
307
+ has_next_page: page[:has_next_page],
308
+ )
309
+ end
310
+
311
+ # Fetches a single page of project data. Returns a lightweight hash
312
+ # for pagination loop consumption (not a Project instance).
313
+ #
314
+ # @param org [String]
315
+ # @param number [Integer]
316
+ # @param cursor [String, nil]
317
+ #
318
+ # @return [Hash] with :raw, :items, :next_cursor, :has_next_page
319
+ #
320
+ def fetch_project_page(org, number, cursor)
321
+ variables = { org: org, number: number }
322
+ variables[:cursor] = cursor if cursor
323
+
324
+ data = PlanMyStuff.client.graphql(find_query, variables: variables)
325
+
326
+ raw_project = data.dig(:organization, :projectV2)
327
+ page_info = raw_project.dig(:items, :pageInfo) || {}
328
+ items_data = raw_project.dig(:items, :nodes) || []
329
+
330
+ {
331
+ raw: raw_project,
332
+ items: items_data.map { |item| parse_project_item(item) },
333
+ next_cursor: page_info[:endCursor],
334
+ has_next_page: page_info[:hasNextPage],
335
+ }
336
+ end
337
+
338
+ # @param item [Hash] raw GraphQL project item node
339
+ #
340
+ # @return [Hash]
341
+ #
342
+ def parse_project_item(item)
343
+ content = item[:content] || {}
344
+ field_values = item.dig(:fieldValues, :nodes) || []
345
+
346
+ {
347
+ id: item[:id],
348
+ type: item[:type],
349
+ content_node_id: content[:id],
350
+ title: content[:title],
351
+ number: content[:number],
352
+ url: content[:url],
353
+ state: content[:state],
354
+ status: extract_item_status(field_values),
355
+ field_values: parse_field_values(field_values),
356
+ }
357
+ end
358
+
359
+ # @param field_values [Array<Hash>]
360
+ #
361
+ # @return [String, nil]
362
+ #
363
+ def extract_item_status(field_values)
364
+ status_value = field_values.find { |fv| fv.dig(:field, :name) == 'Status' }
365
+
366
+ status_value&.dig(:name)
367
+ end
368
+
369
+ # @param field_values [Array<Hash>]
370
+ #
371
+ # @return [Hash]
372
+ #
373
+ def parse_field_values(field_values)
374
+ result = {}
375
+
376
+ field_values.each do |fv|
377
+ field_name = fv.dig(:field, :name)
378
+ next unless field_name
379
+
380
+ value = fv[:name] || fv[:text]
381
+ users_node = fv[:users]
382
+ if users_node
383
+ value = (users_node[:nodes] || []).map { |u| u[:login] }
384
+ end
385
+
386
+ result[field_name] = value
387
+ end
388
+
389
+ result
390
+ end
391
+
392
+ # Resolves a project number to its node ID.
393
+ #
394
+ # @param org [String]
395
+ # @param project_number [Integer]
396
+ #
397
+ # @return [String]
398
+ #
399
+ def resolve_project_id(org, project_number)
400
+ data = PlanMyStuff.client.graphql(
401
+ project_id_query,
402
+ variables: { org: org, number: project_number },
403
+ )
404
+
405
+ data.dig(:organization, :projectV2, :id)
406
+ end
407
+ end
408
+
409
+ # @see super
410
+ def initialize(**attrs)
411
+ @id = attrs.delete(:id)
412
+ @number = attrs.delete(:number)
413
+ @closed = attrs.delete(:closed)
414
+ super
415
+ @statuses ||= []
416
+ @fields ||= []
417
+ @items ||= []
418
+ end
419
+
420
+ # Returns the Status single-select field definition.
421
+ #
422
+ # @return [Hash] with :id and :options keys
423
+ #
424
+ # @raise [PlanMyStuff::APIError] if no Status field exists
425
+ #
426
+ def status_field
427
+ field = fields.find { |f| f[:name] == 'Status' && f[:options] }
428
+
429
+ raise(APIError, "No 'Status' field found on project ##{number}") unless field
430
+
431
+ field
432
+ end
433
+
434
+ private
435
+
436
+ # Populates this instance from a list query node (summary only).
437
+ #
438
+ # @param node [Hash]
439
+ #
440
+ # @return [void]
441
+ #
442
+ def hydrate_summary(node)
443
+ @id = node[:id]
444
+ @number = node[:number]
445
+ @title = node[:title]
446
+ @url = node[:url]
447
+ @closed = node[:closed]
448
+ @persisted = true
449
+ end
450
+
451
+ # Populates this instance from a detailed find query response.
452
+ #
453
+ # @param graphql_project [Hash]
454
+ # @param items [Array<Hash>]
455
+ # @param next_cursor [String, nil]
456
+ # @param has_next_page [Boolean, nil]
457
+ #
458
+ # @return [void]
459
+ #
460
+ def hydrate_detail(graphql_project, items:, next_cursor: nil, has_next_page: nil)
461
+ @id = graphql_project[:id]
462
+ @number = graphql_project[:number]
463
+ @title = graphql_project[:title]
464
+ @url = graphql_project[:url]
465
+ @closed = graphql_project[:closed]
466
+
467
+ fields_nodes = graphql_project.dig(:fields, :nodes) || []
468
+ @statuses = extract_statuses(fields_nodes)
469
+ @fields = extract_fields(fields_nodes)
470
+ @items = items.map { |item_hash| ProjectItem.build(item_hash, project: self) }
471
+ @next_cursor = next_cursor
472
+ @has_next_page = has_next_page
473
+ @persisted = true
474
+ end
475
+
476
+ # Extracts status options from the "Status" single-select field.
477
+ #
478
+ # @param fields_nodes [Array<Hash>]
479
+ #
480
+ # @return [Array<Hash>]
481
+ #
482
+ def extract_statuses(fields_nodes)
483
+ status_field = fields_nodes.find { |f| f[:name] == 'Status' && f.key?(:options) }
484
+
485
+ return [] unless status_field
486
+
487
+ (status_field[:options] || []).map do |opt|
488
+ { id: opt[:id], name: opt[:name] }
489
+ end
490
+ end
491
+
492
+ # @param fields_nodes [Array<Hash>]
493
+ #
494
+ # @return [Array<Hash>]
495
+ #
496
+ def extract_fields(fields_nodes)
497
+ fields_nodes.map do |f|
498
+ field = { id: f[:id], name: f[:name] }
499
+ field[:options] = f[:options].map { |o| { id: o[:id], name: o[:name] } } if f[:options]
500
+ field
501
+ end
502
+ end
503
+ end
504
+ end