plan_my_stuff 0.7.0 → 0.8.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -1
  3. data/README.md +100 -103
  4. data/app/controllers/plan_my_stuff/application_controller.rb +22 -3
  5. data/app/controllers/plan_my_stuff/comments_controller.rb +14 -16
  6. data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +23 -13
  7. data/app/controllers/plan_my_stuff/issues/closures_controller.rb +7 -5
  8. data/app/controllers/plan_my_stuff/issues/links_controller.rb +14 -18
  9. data/app/controllers/plan_my_stuff/issues/takes_controller.rb +99 -28
  10. data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +13 -5
  11. data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +7 -5
  12. data/app/controllers/plan_my_stuff/issues_controller.rb +24 -28
  13. data/app/controllers/plan_my_stuff/labels_controller.rb +21 -5
  14. data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +13 -6
  15. data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +5 -4
  16. data/app/controllers/plan_my_stuff/project_items_controller.rb +30 -5
  17. data/app/controllers/plan_my_stuff/projects_controller.rb +16 -16
  18. data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +21 -11
  19. data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +9 -4
  20. data/app/controllers/plan_my_stuff/testing_projects_controller.rb +30 -14
  21. data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +50 -17
  22. data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +32 -49
  23. data/app/jobs/plan_my_stuff/application_job.rb +2 -3
  24. data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +15 -22
  25. data/app/views/plan_my_stuff/comments/edit.html.erb +1 -3
  26. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +1 -0
  27. data/app/views/plan_my_stuff/issues/edit.html.erb +2 -4
  28. data/app/views/plan_my_stuff/issues/index.html.erb +2 -2
  29. data/app/views/plan_my_stuff/issues/new.html.erb +2 -4
  30. data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +23 -2
  31. data/app/views/plan_my_stuff/issues/partials/_form.html.erb +1 -0
  32. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +2 -1
  33. data/app/views/plan_my_stuff/issues/partials/_links.html.erb +50 -7
  34. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +2 -1
  35. data/app/views/plan_my_stuff/issues/show.html.erb +5 -2
  36. data/app/views/plan_my_stuff/partials/_flash.html.erb +4 -0
  37. data/app/views/plan_my_stuff/projects/edit.html.erb +1 -3
  38. data/app/views/plan_my_stuff/projects/index.html.erb +1 -1
  39. data/app/views/plan_my_stuff/projects/new.html.erb +1 -3
  40. data/app/views/plan_my_stuff/projects/partials/_form.html.erb +1 -0
  41. data/app/views/plan_my_stuff/projects/show.html.erb +13 -3
  42. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +1 -3
  43. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +1 -3
  44. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +1 -3
  45. data/app/views/plan_my_stuff/testing_projects/new.html.erb +1 -3
  46. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +4 -3
  47. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +1 -0
  48. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +1 -0
  49. data/app/views/plan_my_stuff/testing_projects/show.html.erb +2 -2
  50. data/config/routes.rb +2 -2
  51. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +51 -2
  52. data/lib/plan_my_stuff/approval.rb +12 -4
  53. data/lib/plan_my_stuff/aws_sns_simulator.rb +12 -6
  54. data/lib/plan_my_stuff/base_metadata.rb +4 -15
  55. data/lib/plan_my_stuff/base_project.rb +68 -55
  56. data/lib/plan_my_stuff/base_project_item.rb +61 -57
  57. data/lib/plan_my_stuff/base_project_metadata.rb +1 -1
  58. data/lib/plan_my_stuff/client.rb +136 -48
  59. data/lib/plan_my_stuff/comment.rb +57 -57
  60. data/lib/plan_my_stuff/comment_metadata.rb +1 -1
  61. data/lib/plan_my_stuff/configuration.rb +95 -82
  62. data/lib/plan_my_stuff/errors.rb +10 -10
  63. data/lib/plan_my_stuff/graphql/queries.rb +1 -1
  64. data/lib/plan_my_stuff/issue.rb +467 -333
  65. data/lib/plan_my_stuff/issue_metadata.rb +10 -10
  66. data/lib/plan_my_stuff/label.rb +32 -16
  67. data/lib/plan_my_stuff/link.rb +15 -15
  68. data/lib/plan_my_stuff/markdown.rb +12 -6
  69. data/lib/plan_my_stuff/metadata_parser.rb +3 -1
  70. data/lib/plan_my_stuff/notifications.rb +1 -1
  71. data/lib/plan_my_stuff/pipeline/completed_sweep.rb +2 -2
  72. data/lib/plan_my_stuff/pipeline/issue_linker.rb +1 -1
  73. data/lib/plan_my_stuff/pipeline.rb +61 -83
  74. data/lib/plan_my_stuff/project.rb +4 -4
  75. data/lib/plan_my_stuff/project_item_metadata.rb +1 -1
  76. data/lib/plan_my_stuff/project_metadata.rb +1 -1
  77. data/lib/plan_my_stuff/reminders/closer.rb +1 -1
  78. data/lib/plan_my_stuff/reminders/fire.rb +3 -3
  79. data/lib/plan_my_stuff/reminders/sweep.rb +4 -4
  80. data/lib/plan_my_stuff/repo.rb +12 -6
  81. data/lib/plan_my_stuff/test_helpers.rb +11 -11
  82. data/lib/plan_my_stuff/testing_project.rb +12 -11
  83. data/lib/plan_my_stuff/testing_project_item.rb +11 -9
  84. data/lib/plan_my_stuff/testing_project_metadata.rb +2 -2
  85. data/lib/plan_my_stuff/version.rb +1 -1
  86. data/lib/plan_my_stuff/webhook_replayer.rb +14 -2
  87. data/lib/plan_my_stuff.rb +26 -2
  88. data/lib/tasks/plan_my_stuff.rake +33 -20
  89. metadata +3 -2
@@ -1,3 +1,4 @@
1
+ <%# locals: (project:) %>
1
2
  <%= form_with(url: plan_my_stuff.testing_project_items_path(project.number), method: :post, local: true) do |form| %>
2
3
  <div>
3
4
  <%= form.label(:title, 'Title') %>
@@ -7,7 +7,7 @@
7
7
  <% end %>
8
8
  </p>
9
9
 
10
- <% if @support_user && @project.metadata.subject_urls.any? %>
10
+ <% if @support_user && @project.metadata.subject_urls.present? %>
11
11
  <p>
12
12
  <strong>Subject URLs:</strong>
13
13
  <% @project.metadata.subject_urls.each do |url| %>
@@ -20,7 +20,7 @@
20
20
  <p><strong>Due:</strong> <%= @project.metadata.due_date %></p>
21
21
  <% end %>
22
22
 
23
- <% if @statuses.any? %>
23
+ <% if @statuses.present? %>
24
24
  <table>
25
25
  <thead>
26
26
  <tr>
data/config/routes.rb CHANGED
@@ -12,7 +12,7 @@ PlanMyStuff::Engine.routes.draw do
12
12
  if config.pipeline_enabled
13
13
  resource(
14
14
  :take,
15
- only: :create,
15
+ only: %i[create destroy],
16
16
  controller: config.controller_for(:'issues/takes'),
17
17
  )
18
18
  end
@@ -32,7 +32,7 @@ PlanMyStuff::Engine.routes.draw do
32
32
 
33
33
  if mount_groups.fetch(:projects, true)
34
34
  resources :projects, except: %i[destroy], controller: config.controller_for(:projects) do
35
- resources :items, only: %i[create], controller: config.controller_for(:project_items) do
35
+ resources :items, only: %i[create destroy], controller: config.controller_for(:project_items) do
36
36
  resource :status, only: %i[update], controller: config.controller_for(:'project_items/statuses')
37
37
  resource :assignment, only: %i[update destroy], controller: config.controller_for(:'project_items/assignments')
38
38
  end
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- PMS.configure do |config|
3
+ PlanMyStuff.configure do |config|
4
4
  # --------------------------------------------------------------------------
5
5
  # Authentication (required)
6
6
  # --------------------------------------------------------------------------
7
7
  # GitHub PAT with `repo` and `project` scopes. All API calls use this token.
8
8
  config.access_token = Rails.application.credentials.dig(:plan_my_stuff, :github_token)
9
9
 
10
+ # Classic PAT (requires `repo` scope) for the Issues Import API (golden-comet-preview).
11
+ # Fine-grained tokens are not supported by that endpoint.
12
+ # config.import_access_token = Rails.application.credentials.dig(:plan_my_stuff, :github_import_token)
13
+
10
14
  # --------------------------------------------------------------------------
11
15
  # Organization (required)
12
16
  # --------------------------------------------------------------------------
@@ -97,8 +101,9 @@ PMS.configure do |config|
97
101
  # number is appended to form `Issue#user_link`, which the gem also
98
102
  # writes as the visible body of the GitHub issue.
99
103
  # Recommended default:
104
+ # url_options = Rails.application.config.action_mailer.default_url_options
100
105
  # config.issues_url_prefix =
101
- # "#{Rails.application.config.action_mailer.default_url_options[:host]}/issues"
106
+ # "#{url_options[:protocol] || 'http'}://#{url_options[:host]}/issues"
102
107
 
103
108
  # --------------------------------------------------------------------------
104
109
  # Request gateway
@@ -118,6 +123,18 @@ PMS.configure do |config|
118
123
  # does not pass an explicit user: kwarg. Proc/lambda called at event time.
119
124
  # config.current_user = -> { Current.user }
120
125
 
126
+ # --------------------------------------------------------------------------
127
+ # Controller rescue hook
128
+ # --------------------------------------------------------------------------
129
+ # Invoked from every gem controller rescue block, after the gem logs the
130
+ # error class/message and stack trace via Rails.logger.error and just
131
+ # before the user-facing flash + redirect/render. Forwards the rescued
132
+ # exception so consuming apps can fire monitoring even though the rescue
133
+ # swallows the error. Receives the rescued exception; return value is
134
+ # ignored. Defaults to nil (no-op).
135
+ #
136
+ # config.controller_rescue = ->(error) { MonitoringService.notice_error(error) }
137
+
121
138
  # --------------------------------------------------------------------------
122
139
  # Custom fields
123
140
  # --------------------------------------------------------------------------
@@ -146,6 +163,22 @@ PMS.configure do |config|
146
163
  # Testing-project-only fields (merged on top of shared, context wins on conflicts):
147
164
  # config.testing_custom_fields = {}
148
165
 
166
+ # --------------------------------------------------------------------------
167
+ # Issue types (org-side renames)
168
+ # --------------------------------------------------------------------------
169
+ # The gem speaks in canonical type names: 'Bug', 'Feature',
170
+ # 'IT Issue / Hardware', 'Other', 'Performance', 'Question', 'Task'.
171
+ # Symbol shortcuts (`:bug`, `:feature`, `:it_issue`, `:other`, `:performance`,
172
+ # `:question`, `:task`) resolve to those canonical names automatically.
173
+ # If your GitHub org uses different names for any of them, map the
174
+ # canonical name to your org's name here and PMS will translate on
175
+ # write. Missing keys pass through unchanged.
176
+ #
177
+ # config.issue_types = {
178
+ # 'Bug' => 'User Bug',
179
+ # 'Feature' => 'Enhancement',
180
+ # }
181
+
149
182
  # --------------------------------------------------------------------------
150
183
  # Release pipeline
151
184
  # --------------------------------------------------------------------------
@@ -161,6 +194,22 @@ PMS.configure do |config|
161
194
  # 'Completed' => 'Done',
162
195
  # }
163
196
 
197
+ # Display name for the Testing single-select field on the pipeline project.
198
+ # config.pipeline_testing_field_name = 'Testing'
199
+
200
+ # Display labels for the canonical Testing field option keys.
201
+ # config.pipeline_testing_values = {
202
+ # active: 'Testing',
203
+ # inactive: 'Not testing',
204
+ # }
205
+
206
+ # Whether the pipeline sweep removes aged-out Completed items.
207
+ # config.pipeline_completion_purge_enabled = true
208
+
209
+ # Hours after a project item's last update at which the sweep removes it
210
+ # from the pipeline if its status is Completed.
211
+ # config.pipeline_completion_ttl_hours = 24
212
+
164
213
  # config.main_branch = 'main'
165
214
  # config.production_branch = 'production'
166
215
 
@@ -5,14 +5,14 @@ require 'active_model'
5
5
  module PlanMyStuff
6
6
  # Value object representing a single manager approval on an +Issue+.
7
7
  # Persisted in +IssueMetadata#approvals+ and returned from
8
- # +Issue.request_approvals!+, +Issue.approve!+, and
8
+ # +Issue.request_approvals!+, +Issue.approve!+, +Issue.reject!+, and
9
9
  # +Issue.revoke_approval!+.
10
10
  #
11
11
  # Mirrors +PlanMyStuff::Link+: +ActiveModel::Attributes+-backed, with
12
12
  # +Serializers::JSON+ for round-trip through the metadata blob.
13
13
  #
14
14
  class Approval
15
- STATUSES = %w[pending approved].freeze
15
+ STATUSES = %w[pending approved rejected].freeze
16
16
 
17
17
  include ActiveModel::Model
18
18
  include ActiveModel::Attributes
@@ -20,10 +20,12 @@ module PlanMyStuff
20
20
 
21
21
  # @return [Integer] app-side user id of the required approver
22
22
  attribute :user_id, :integer
23
- # @return [String] +"pending"+ or +"approved"+
23
+ # @return [String] +"pending"+, +"approved"+, or +"rejected"+
24
24
  attribute :status, :string, default: 'pending'
25
25
  # @return [DateTime, nil] timestamp when status flipped to +"approved"+
26
26
  attribute :approved_at, :datetime
27
+ # @return [DateTime, nil] timestamp when status flipped to +"rejected"+
28
+ attribute :rejected_at, :datetime
27
29
 
28
30
  validates :user_id, presence: true, numericality: { greater_than: 0, only_integer: true }
29
31
  validates :status, inclusion: { in: STATUSES }
@@ -38,6 +40,11 @@ module PlanMyStuff
38
40
  status == 'approved'
39
41
  end
40
42
 
43
+ # @return [Boolean]
44
+ def rejected?
45
+ status == 'rejected'
46
+ end
47
+
41
48
  # Lazy-resolves the app-side user for this approval.
42
49
  # Not memoized -- +PlanMyStuff::UserResolver+ owns caching.
43
50
  #
@@ -52,7 +59,8 @@ module PlanMyStuff
52
59
  {
53
60
  user_id: user_id,
54
61
  status: status,
55
- approved_at: approved_at&.iso8601,
62
+ approved_at: PlanMyStuff.format_time(approved_at),
63
+ rejected_at: PlanMyStuff.format_time(rejected_at),
56
64
  }
57
65
  end
58
66
 
@@ -27,15 +27,21 @@ module PlanMyStuff
27
27
  # +production_commit_sha+ from +PlanMyStuff.configuration+ (this
28
28
  # task is dev-only, so there's no reason to parameterize them).
29
29
  #
30
+ # @raise [PlanMyStuff::ConfigurationError] if sns details are missing
31
+ #
30
32
  # @param endpoint_url [String] full URL including path
31
33
  # @param event_name [String] ECS eventName (default completed)
32
34
  #
33
35
  # @return [Net::HTTPResponse]
34
36
  #
35
- def post(endpoint_url:, event_name: DEFAULT_EVENT)
37
+ def post!(endpoint_url:, event_name: DEFAULT_EVENT)
36
38
  config = PlanMyStuff.configuration
37
- raise('PlanMyStuff.configuration.sns_topic_arn is blank') if config.sns_topic_arn.blank?
38
- raise('PlanMyStuff.configuration.aws_service_identifier is blank') if config.aws_service_identifier.blank?
39
+ if config.sns_topic_arn.blank?
40
+ raise(PlanMyStuff::ConfigurationError, 'PlanMyStuff.configuration.sns_topic_arn is blank')
41
+ end
42
+ if config.aws_service_identifier.blank?
43
+ raise(PlanMyStuff::ConfigurationError, 'PlanMyStuff.configuration.aws_service_identifier is blank')
44
+ end
39
45
 
40
46
  topic_arn = config.sns_topic_arn
41
47
  service_arn = "arn:aws:ecs:us-east-1:000000000000:service/simulated-cluster/#{config.aws_service_identifier}"
@@ -73,7 +79,7 @@ module PlanMyStuff
73
79
  'eventType' => 'INFO',
74
80
  'eventName' => event_name,
75
81
  'deploymentId' => "ecs-svc/#{SecureRandom.hex(8)}",
76
- 'updatedAt' => Time.now.utc.iso8601,
82
+ 'updatedAt' => PlanMyStuff.format_time(Time.now.utc),
77
83
  }
78
84
  detail['commitSha'] = commit_sha if commit_sha
79
85
 
@@ -83,7 +89,7 @@ module PlanMyStuff
83
89
  'detail-type' => 'ECS Deployment State Change',
84
90
  'source' => 'aws.ecs',
85
91
  'account' => '000000000000',
86
- 'time' => Time.now.utc.iso8601,
92
+ 'time' => PlanMyStuff.format_time(Time.now.utc),
87
93
  'region' => 'us-east-1',
88
94
  'resources' => [service_arn],
89
95
  'detail' => detail,
@@ -92,7 +98,7 @@ module PlanMyStuff
92
98
 
93
99
  # @return [Hash]
94
100
  def build_sns_envelope(message:, topic_arn:)
95
- now = Time.now.utc.iso8601
101
+ now = PlanMyStuff.format_time(Time.now.utc)
96
102
  {
97
103
  'Type' => 'Notification',
98
104
  'MessageId' => SecureRandom.uuid,
@@ -40,7 +40,7 @@ module PlanMyStuff
40
40
  metadata.app_name = hash[:app_name]
41
41
  metadata.created_by = hash[:created_by]
42
42
  metadata.visibility = hash.fetch(:visibility, 'internal')
43
- metadata.custom_fields = CustomFields.new(
43
+ metadata.custom_fields = PlanMyStuff::CustomFields.new(
44
44
  custom_fields_schema,
45
45
  hash[:custom_fields] || {},
46
46
  )
@@ -69,10 +69,10 @@ module PlanMyStuff
69
69
  metadata.gem_version = PlanMyStuff::VERSION::STRING
70
70
  metadata.rails_env = (defined?(Rails) && Rails.respond_to?(:env)) ? Rails.env.to_s : nil
71
71
  metadata.app_name = config.app_name
72
- resolved = UserResolver.resolve(user)
73
- metadata.created_by = resolved.present? ? UserResolver.user_id(resolved) : nil
72
+ resolved = PlanMyStuff::UserResolver.resolve(user)
73
+ metadata.created_by = resolved.present? ? PlanMyStuff::UserResolver.user_id(resolved) : nil
74
74
  metadata.visibility = visibility
75
- metadata.custom_fields = CustomFields.new(
75
+ metadata.custom_fields = PlanMyStuff::CustomFields.new(
76
76
  custom_fields_schema,
77
77
  custom_fields_data,
78
78
  )
@@ -119,8 +119,6 @@ module PlanMyStuff
119
119
 
120
120
  # Validates custom fields against the schema.
121
121
  #
122
- # @raise [ActiveModel::ValidationError] if validation fails
123
- #
124
122
  # @return [true]
125
123
  #
126
124
  def validate_custom_fields!
@@ -131,14 +129,5 @@ module PlanMyStuff
131
129
  def to_json(...)
132
130
  to_h.to_json(...)
133
131
  end
134
-
135
- private
136
-
137
- # @return [String, nil]
138
- def format_time(time)
139
- return if time.nil?
140
-
141
- time.utc.iso8601
142
- end
143
132
  end
144
133
  end
@@ -1,14 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PlanMyStuff
4
- # Shared base for GitHub Projects V2 wrappers. Holds attribute definitions,
5
- # generic find/list/update machinery, hydration, and instance helpers.
6
- # Concrete subclasses (Project, TestingProject) add their own +create!+
7
- # behavior and optional filtering on +find+/+list+.
4
+ # Shared base for GitHub Projects V2 wrappers. Holds attribute definitions, generic find/list/update machinery,
5
+ # hydration, and instance helpers. Concrete subclasses (Project, TestingProject) add their own +create!+ behavior
6
+ # and optional filtering on +find+/+list+.
8
7
  #
9
- # Not instantiated directly. +find+ and +list+ dispatch via +build_detail+/
10
- # +build_summary+ to the correct concrete subclass based on the metadata
11
- # +kind+ field in the project readme.
8
+ # Not instantiated directly. +find+ and +list+ dispatch via +build_detail+/+build_summary+ to the correct concrete
9
+ # subclass based on the metadata +kind+ field in the project readme.
12
10
  class BaseProject < PlanMyStuff::ApplicationRecord
13
11
  MAX_AUTO_PAGINATE_ITEMS = 500
14
12
  ITEMS_PER_PAGE = 100
@@ -45,9 +43,10 @@ module PlanMyStuff
45
43
  attribute :has_next_page
46
44
 
47
45
  class << self
48
- # Generic find - returns whichever concrete project type is at the given
49
- # number, dispatching on metadata kind. Subclasses may override to apply
50
- # filtering (e.g. Project raises for testing projects by default).
46
+ # Generic find - returns whichever concrete project type is at the given number, dispatching on metadata kind.
47
+ # Subclasses may override to apply filtering (e.g. Project raises for testing projects by default).
48
+ #
49
+ # @raise [ArgumentError] if paginate mode is invalid
51
50
  #
52
51
  # @param number [Integer]
53
52
  # @param paginate [Symbol] :auto (default) or :cursor
@@ -68,9 +67,8 @@ module PlanMyStuff
68
67
  end
69
68
  end
70
69
 
71
- # Generic list - returns all projects in the configured organization,
72
- # each dispatched to its concrete type (Project or TestingProject).
73
- # Subclasses may override to apply filtering.
70
+ # Generic list - returns all projects in the configured organization, each dispatched to its concrete type
71
+ # (Project or TestingProject). Subclasses may override to apply filtering.
74
72
  #
75
73
  # @return [Array<PlanMyStuff::BaseProject>]
76
74
  #
@@ -124,7 +122,7 @@ module PlanMyStuff
124
122
  end
125
123
 
126
124
  body = readme.nil? ? parsed[:body] : readme
127
- update_input[:readme] = PlanMyStuff::MetadataParser.serialize(existing_metadata, body)
125
+ update_input[:readme] = PlanMyStuff::MetadataParser.serialize!(existing_metadata, body)
128
126
  end
129
127
 
130
128
  PlanMyStuff.client.graphql(
@@ -135,10 +133,9 @@ module PlanMyStuff
135
133
  find(project_number)
136
134
  end
137
135
 
138
- # Clones an existing GitHub Project into the configured organization.
139
- # The copy inherits all custom fields and board layout from the source.
140
- # Returns the newly created project via +find+, dispatched to the correct
141
- # concrete subclass (Project or TestingProject) based on the cloned readme.
136
+ # Clones an existing GitHub Project into the configured organization. The copy inherits all custom fields and
137
+ # board layout from the source. Returns the newly created project via +find+, dispatched to the correct concrete
138
+ # subclass (Project or TestingProject) based on the cloned readme.
142
139
  #
143
140
  # @param source_number [Integer] project number of the project to copy from
144
141
  # @param title [String] title for the new project
@@ -168,11 +165,13 @@ module PlanMyStuff
168
165
 
169
166
  # Resolves a project number, falling back to config.default_project_number.
170
167
  #
168
+ # @raise [ArgumentError] if project_number is nil and config.default_project_number is not set
169
+ #
171
170
  # @param project_number [Integer, nil]
172
171
  #
173
172
  # @return [Integer]
174
173
  #
175
- def resolve_default_project_number(project_number)
174
+ def resolve_default_project_number!(project_number)
176
175
  return project_number if project_number.present?
177
176
 
178
177
  PlanMyStuff.configuration.default_project_number ||
@@ -181,8 +180,8 @@ module PlanMyStuff
181
180
 
182
181
  private
183
182
 
184
- # Builds a summary Project from a list query node.
185
- # Dispatches to TestingProject when the readme metadata has kind: "testing".
183
+ # Builds a summary Project from a list query node. Dispatches to TestingProject when the readme metadata has
184
+ # kind: "testing".
186
185
  #
187
186
  # @param node [Hash]
188
187
  #
@@ -193,12 +192,12 @@ module PlanMyStuff
193
192
  parsed_meta = PlanMyStuff::MetadataParser.parse(raw_readme)
194
193
  klass = dispatch_project_class(parsed_meta[:metadata])
195
194
  project = klass.new
196
- project.__send__(:hydrate_summary, node)
195
+ project.__send__(:hydrate_summary, node, raw_readme: raw_readme, parsed_meta: parsed_meta)
197
196
  project
198
197
  end
199
198
 
200
- # Builds a detailed Project from a find query response.
201
- # Dispatches to TestingProject when the readme metadata has kind: "testing".
199
+ # Builds a detailed Project from a find query response. Dispatches to TestingProject when the readme metadata
200
+ # has kind: "testing".
202
201
  #
203
202
  # @param graphql_project [Hash]
204
203
  # @param items [Array<Hash>]
@@ -222,8 +221,8 @@ module PlanMyStuff
222
221
  project
223
222
  end
224
223
 
225
- # Returns the appropriate project class based on the metadata kind field.
226
- # Always dispatches to a concrete subclass (never BaseProject itself).
224
+ # Returns the appropriate project class based on the metadata kind field. Always dispatches to a concrete
225
+ # subclass (never BaseProject itself).
227
226
  #
228
227
  # @param meta_hash [Hash]
229
228
  #
@@ -280,8 +279,8 @@ module PlanMyStuff
280
279
  )
281
280
  end
282
281
 
283
- # Fetches a single page of project data. Returns a lightweight hash
284
- # for pagination loop consumption (not a Project instance).
282
+ # Fetches a single page of project data. Returns a lightweight hash for pagination loop consumption (not a
283
+ # Project instance).
285
284
  #
286
285
  # @param org [String]
287
286
  # @param number [Integer]
@@ -328,7 +327,7 @@ module PlanMyStuff
328
327
  number: content[:number],
329
328
  url: content[:url],
330
329
  state: content[:state],
331
- repo: repo_name.present? ? PlanMyStuff::Repo.resolve(repo_name) : nil,
330
+ repo: repo_name.present? ? PlanMyStuff::Repo.resolve!(repo_name) : nil,
332
331
  status: extract_item_status(field_values),
333
332
  field_values: parse_field_values(field_values),
334
333
  updated_at: item[:updatedAt],
@@ -405,14 +404,24 @@ module PlanMyStuff
405
404
  #
406
405
  # @return [Hash] with :id and :options keys
407
406
  #
407
+ def status_field
408
+ status_field!
409
+ rescue
410
+ nil
411
+ end
412
+
413
+ # Returns the Status single-select field definition.
414
+ #
408
415
  # @raise [PlanMyStuff::APIError] if no Status field exists
409
416
  #
410
- def status_field
411
- field = fields.find { |f| f[:name] == 'Status' && f[:options] }
417
+ # @return [Hash] with :id and :options keys
418
+ #
419
+ def status_field!
420
+ status_field = fields.find { |f| f[:name] == 'Status' && f[:options] }
412
421
 
413
- raise(APIError, "No 'Status' field found on project ##{number}") unless field
422
+ raise(PlanMyStuff::APIError, "No 'Status' field found on project ##{number}") if status_field.nil?
414
423
 
415
- field
424
+ status_field
416
425
  end
417
426
 
418
427
  # @return [Boolean]
@@ -422,8 +431,6 @@ module PlanMyStuff
422
431
 
423
432
  # Persists the project. Creates if new, updates if persisted.
424
433
  #
425
- # @raise [PlanMyStuff::StaleObjectError] on update if stale
426
- #
427
434
  # @return [self]
428
435
  #
429
436
  def save!
@@ -447,13 +454,11 @@ module PlanMyStuff
447
454
  self
448
455
  end
449
456
 
450
- # Updates this project on GitHub. Raises StaleObjectError if the remote
451
- # has been modified since this instance was loaded.
457
+ # Updates this project on GitHub. Raises StaleObjectError if the remote has been modified since this instance was
458
+ # loaded.
452
459
  #
453
460
  # @param attrs [Hash] attributes to update (title:, readme:, description:, metadata:)
454
461
  #
455
- # @raise [PlanMyStuff::StaleObjectError] if remote updated_at differs from local
456
- #
457
462
  # @return [self]
458
463
  #
459
464
  def update!(**attrs)
@@ -479,9 +484,8 @@ module PlanMyStuff
479
484
 
480
485
  private
481
486
 
482
- # Kwargs derived from +metadata+ for forwarding to +self.class.create!+
483
- # so +save!+ preserves in-memory metadata mutations on new records.
484
- # Subclasses override to include type-specific metadata attributes.
487
+ # Kwargs derived from +metadata+ for forwarding to +self.class.create!+ so +save!+ preserves in-memory metadata
488
+ # mutations on new records. Subclasses override to include type-specific metadata attributes.
485
489
  #
486
490
  # @return [Hash]
487
491
  #
@@ -493,9 +497,8 @@ module PlanMyStuff
493
497
  }
494
498
  end
495
499
 
496
- # Metadata hash forwarded to the class +update!+ so +save!+ preserves
497
- # in-memory metadata mutations on persisted records. Returns +nil+ when
498
- # the instance has no PMS metadata to avoid clobbering remote values.
500
+ # Metadata hash forwarded to the class +update!+ so +save!+ preserves in-memory metadata mutations on persisted
501
+ # records. Returns +nil+ when the instance has no PMS metadata to avoid clobbering remote values.
499
502
  #
500
503
  # @return [Hash, nil]
501
504
  #
@@ -511,7 +514,7 @@ module PlanMyStuff
511
514
  #
512
515
  # @return [void]
513
516
  #
514
- def hydrate_summary(node)
517
+ def hydrate_summary(node, raw_readme:, parsed_meta:)
515
518
  @github_response = node
516
519
  self.id = node[:id]
517
520
  self.number = node[:number]
@@ -520,6 +523,9 @@ module PlanMyStuff
520
523
  self.url = node[:url]
521
524
  self.closed = node[:closed]
522
525
  self.updated_at = parse_github_time(node[:updatedAt])
526
+ self.raw_readme = raw_readme
527
+ self.metadata = build_project_metadata(parsed_meta[:metadata])
528
+ self.readme = parsed_meta[:body]
523
529
  persisted!
524
530
  end
525
531
 
@@ -553,6 +559,9 @@ module PlanMyStuff
553
559
  self.items = items.map { |item_hash| item_class.build(item_hash, project: self) }
554
560
  self.next_cursor = next_cursor
555
561
  self.has_next_page = has_next_page
562
+
563
+ status_field!
564
+
556
565
  persisted!
557
566
  end
558
567
 
@@ -579,11 +588,13 @@ module PlanMyStuff
579
588
  self.items = other.items
580
589
  self.next_cursor = other.next_cursor
581
590
  self.has_next_page = other.has_next_page
591
+
592
+ status_field!
593
+
582
594
  persisted!
583
595
  end
584
596
 
585
- # Raises StaleObjectError if the remote project has been modified
586
- # since this instance was loaded.
597
+ # Raises StaleObjectError if the remote project has been modified since this instance was loaded.
587
598
  #
588
599
  # @raise [PlanMyStuff::StaleObjectError]
589
600
  #
@@ -607,8 +618,8 @@ module PlanMyStuff
607
618
  ))
608
619
  end
609
620
 
610
- # Builds the appropriate metadata object for this project instance.
611
- # Dispatches to TestingProjectMetadata when kind is "testing".
621
+ # Builds the appropriate metadata object for this project instance. Dispatches to TestingProjectMetadata when
622
+ # kind is "testing".
612
623
  #
613
624
  # @param meta_hash [Hash]
614
625
  #
@@ -629,11 +640,11 @@ module PlanMyStuff
629
640
  # @return [Array<Hash>]
630
641
  #
631
642
  def extract_statuses(fields_nodes)
632
- status_field = fields_nodes.find { |f| f[:name] == 'Status' && f.key?(:options) }
643
+ field = fields_nodes.find { |f| f[:name] == 'Status' && f.key?(:options) }
633
644
 
634
- return [] unless status_field
645
+ return [] unless field
635
646
 
636
- (status_field[:options] || []).map do |opt|
647
+ (field[:options] || []).map do |opt|
637
648
  { id: opt[:id], name: opt[:name] }
638
649
  end
639
650
  end
@@ -650,8 +661,10 @@ module PlanMyStuff
650
661
  end
651
662
  end
652
663
 
653
- # Returns the item class used to build items for this project type.
654
- # Subclasses override to return a domain-specific item class.
664
+ # Returns the item class used to build items for this project type. Subclasses override to return a
665
+ # domain-specific item class.
666
+ #
667
+ # @raise [NotImplementedError] if not implemented in subclass
655
668
  #
656
669
  # @return [Class]
657
670
  #