plan_my_stuff 0.2.0 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0fdaa17653ccaf192c32d3ba21c5eb0ea4954b2a983d1d0d8f3665a9a792d065
4
- data.tar.gz: d61fb2fba100a20b0e2f40744e2e6c4c936ff4d55c2cbbdf428f8f6d30ed4ea8
3
+ metadata.gz: 9b5b0d6a9e0582853ebf435a48b49fabb8dc9bd1227bddc6f2c3467d74872c80
4
+ data.tar.gz: 3e1df8e562940c9c1435c2edc4b88c7b85c2c2c8309b95702f8f6d3f978bdf19
5
5
  SHA512:
6
- metadata.gz: cf7a2e48f07ef85f21725524db9a11bad2d4da8ecf403c1e60a3817a86087d0a06ee98d88d12a2a830584c950c5705ca0693c1c5b8f7fd5229b3ffbc0851174a
7
- data.tar.gz: 89bc0e780f36649e7abacefe7b0fa65f56d6302f66cc23c49cfb343d4ec8f1190b58cd8f4e59c2ba52a5ab8d199bf459a9060ea69321d0793d3da34f7a4e5294
6
+ metadata.gz: fdc842d462d7e04c163560c475bd6519e04b4b5f57b2684be5137ed32ff0bc9e257d9f09e7ab1cda06e6106065e38e4a1c80f0fea96d812841141cc14cbed4a6
7
+ data.tar.gz: 9e4954b597a281e4c68e1ea9ecbe2748efbcac31062f7fd3b925b887dc0616015df27df8c0122fc4165621227f5eefa13ead1ce0583cbe9dcd377ecaf0e47e23
@@ -7,11 +7,65 @@ module PlanMyStuff
7
7
  @projects = PMS::Project.list.reject(&:closed)
8
8
  end
9
9
 
10
+ # GET /projects/new
11
+ def new
12
+ @project = PMS::Project.new
13
+ end
14
+
15
+ # POST /projects
16
+ def create
17
+ @project = PMS::Project.create!(
18
+ title: project_params[:title],
19
+ readme: project_params[:readme] || '',
20
+ description: project_params[:description],
21
+ user: pms_current_user,
22
+ )
23
+
24
+ flash[:success] = 'Project was successfully created.'
25
+ redirect_to(plan_my_stuff.project_path(@project.number))
26
+ rescue PMS::ValidationError => e
27
+ @project = PMS::Project.new(
28
+ title: project_params[:title],
29
+ readme: project_params[:readme],
30
+ )
31
+ flash.now[:error] = e.message
32
+ render(:new, status: PMS.unprocessable_status)
33
+ end
34
+
10
35
  # GET /projects/:id
11
36
  def show
12
37
  @project = PMS::Project.find(params[:id].to_i)
13
38
  @statuses = @project.statuses.pluck(:name)
14
39
  @items_by_status = @project.items.group_by(&:status)
15
40
  end
41
+
42
+ # GET /projects/:id/edit
43
+ def edit
44
+ @project = PMS::Project.find(params[:id].to_i)
45
+ end
46
+
47
+ # PATCH/PUT /projects/:id
48
+ def update
49
+ @project = PMS::Project.find(params[:id].to_i)
50
+
51
+ @project.update!(
52
+ title: project_params[:title],
53
+ readme: project_params[:readme],
54
+ description: project_params[:description],
55
+ )
56
+
57
+ flash[:success] = 'Project was successfully updated.'
58
+ redirect_to(plan_my_stuff.project_path(@project.number))
59
+ rescue PMS::StaleObjectError
60
+ flash.now[:error] = 'Project was modified by someone else. Please review the latest changes and try again.'
61
+ render(:edit, status: PMS.unprocessable_status)
62
+ end
63
+
64
+ private
65
+
66
+ # @return [ActionController::Parameters]
67
+ def project_params
68
+ params.require(:project).permit(:title, :readme, :description)
69
+ end
16
70
  end
17
71
  end
@@ -0,0 +1,7 @@
1
+ <h1>Edit Project #<%= @project.number %></h1>
2
+
3
+ <% if flash[:error].present? %>
4
+ <p style="color: red;"><%= flash[:error] %></p>
5
+ <% end %>
6
+
7
+ <%= render({ partial: 'plan_my_stuff/projects/partials/form', locals: { project: @project } }) %>
@@ -11,3 +11,5 @@
11
11
  <% else %>
12
12
  <p>No projects found.</p>
13
13
  <% end %>
14
+
15
+ <p><%= link_to('New Project', plan_my_stuff.new_project_path) %></p>
@@ -0,0 +1,7 @@
1
+ <h1>New Project</h1>
2
+
3
+ <% if flash[:error].present? %>
4
+ <p style="color: red;"><%= flash[:error] %></p>
5
+ <% end %>
6
+
7
+ <%= render({ partial: 'plan_my_stuff/projects/partials/form', locals: { project: @project } }) %>
@@ -0,0 +1,29 @@
1
+ <%
2
+ persisted = project.persisted?
3
+ url =
4
+ if persisted
5
+ plan_my_stuff.project_path(project.number)
6
+ else
7
+ plan_my_stuff.projects_path
8
+ end
9
+ %>
10
+ <%= form_with(url: url, method: persisted ? :patch : :post, scope: :project) do |form| %>
11
+ <div>
12
+ <%= form.label(:title, 'Title') %>
13
+ <%= form.text_field(:title, value: project.title, required: true) %>
14
+ </div>
15
+
16
+ <div>
17
+ <%= form.label(:description, 'Description') %>
18
+ <%= form.text_field(:description, value: project.description) %>
19
+ </div>
20
+
21
+ <div>
22
+ <%= form.label(:readme, 'Readme') %>
23
+ <%= form.text_area(:readme, rows: 8, value: project.readme) %>
24
+ </div>
25
+
26
+ <div>
27
+ <%= form.submit(persisted ? 'Update Project' : 'Create Project') %>
28
+ </div>
29
+ <% end %>
@@ -1,5 +1,7 @@
1
1
  <h1><%= @project.title %></h1>
2
2
 
3
+ <p><%= link_to('Edit', plan_my_stuff.edit_project_path(@project.number)) %></p>
4
+
3
5
  <% if @statuses.any? %>
4
6
  <table>
5
7
  <thead>
data/config/routes.rb CHANGED
@@ -13,7 +13,7 @@ PlanMyStuff::Engine.routes.draw do
13
13
  delete 'labels/:name', to: 'labels#remove_from_issue', as: :remove_label
14
14
  end
15
15
 
16
- resources :projects, only: %i[index show] do
16
+ resources :projects, except: %i[destroy] do
17
17
  resources :items, only: %i[create], controller: 'project_items' do
18
18
  member do
19
19
  patch :move
@@ -48,5 +48,28 @@ module PlanMyStuff
48
48
  def read_field(obj, field)
49
49
  self.class.read_field(obj, field)
50
50
  end
51
+
52
+ # Reads a field from an object, returning nil if the field does not exist.
53
+ #
54
+ # @param obj [Object]
55
+ # @param field [Symbol]
56
+ #
57
+ # @return [Object, nil]
58
+ #
59
+ def safe_read_field(obj, field)
60
+ read_field(obj, field)
61
+ rescue NameError
62
+ nil
63
+ end
64
+
65
+ # @return [Time, nil]
66
+ def parse_github_time(value)
67
+ return if value.nil?
68
+ return value.utc if value.is_a?(Time)
69
+
70
+ Time.parse(value.to_s).utc
71
+ rescue ArgumentError
72
+ nil
73
+ end
51
74
  end
52
75
  end
@@ -15,10 +15,6 @@ module PlanMyStuff
15
15
  attr_accessor :rails_env
16
16
  # @return [String, nil] consuming app name from config
17
17
  attr_accessor :app_name
18
- # @return [Time, nil] timestamp of creation
19
- attr_accessor :created_at
20
- # @return [Time, nil] timestamp of last update
21
- attr_accessor :updated_at
22
18
  # @return [Integer, nil] consuming app's user ID of the creator
23
19
  attr_accessor :created_by
24
20
  # @return [String] "public" or "internal"
@@ -42,8 +38,6 @@ module PlanMyStuff
42
38
  metadata.gem_version = hash[:gem_version]
43
39
  metadata.rails_env = hash[:rails_env]
44
40
  metadata.app_name = hash[:app_name]
45
- metadata.created_at = parse_time(hash[:created_at])
46
- metadata.updated_at = parse_time(hash[:updated_at])
47
41
  metadata.created_by = hash[:created_by]
48
42
  metadata.visibility = hash.fetch(:visibility, 'internal')
49
43
  metadata.custom_fields = CustomFields.new(
@@ -70,14 +64,11 @@ module PlanMyStuff
70
64
  custom_fields_schema: {}
71
65
  )
72
66
  config = PlanMyStuff.configuration
73
- now = Time.now.utc
74
67
 
75
68
  metadata.schema_version = self::SCHEMA_VERSION
76
69
  metadata.gem_version = PlanMyStuff::VERSION::STRING
77
70
  metadata.rails_env = (defined?(Rails) && Rails.respond_to?(:env)) ? Rails.env.to_s : nil
78
71
  metadata.app_name = config.app_name
79
- metadata.created_at = now
80
- metadata.updated_at = now
81
72
  resolved = UserResolver.resolve(user)
82
73
  metadata.created_by = resolved.present? ? UserResolver.user_id(resolved) : nil
83
74
  metadata.visibility = visibility
@@ -110,8 +101,6 @@ module PlanMyStuff
110
101
  gem_version: gem_version,
111
102
  rails_env: rails_env,
112
103
  app_name: app_name,
113
- created_at: format_time(created_at),
114
- updated_at: format_time(updated_at),
115
104
  created_by: created_by,
116
105
  visibility: visibility,
117
106
  custom_fields: custom_fields.to_h,
@@ -20,6 +20,8 @@ module PlanMyStuff
20
20
  attr_accessor :body
21
21
  # @return [PlanMyStuff::Issue] parent issue
22
22
  attr_accessor :issue
23
+ # @return [Time, nil] GitHub's updated_at timestamp
24
+ attr_reader :updated_at
23
25
  # @param value [Symbol, String, nil]
24
26
  attr_writer :visibility
25
27
 
@@ -224,7 +226,6 @@ module PlanMyStuff
224
226
  if attrs.key?(:visibility)
225
227
  new_visibility = attrs[:visibility].to_s
226
228
  meta_hash[:visibility] = new_visibility
227
- meta_hash[:updated_at] = Time.now.utc.iso8601
228
229
  end
229
230
 
230
231
  serialized = MetadataParser.serialize(meta_hash, new_body)
@@ -318,6 +319,7 @@ module PlanMyStuff
318
319
  def hydrate_from_github(github_comment, issue:)
319
320
  @id = read_field(github_comment, :id)
320
321
  @raw_body = read_field(github_comment, :body)
322
+ @updated_at = parse_github_time(safe_read_field(github_comment, :updated_at))
321
323
  @issue = issue
322
324
 
323
325
  parsed = MetadataParser.parse(@raw_body)
@@ -337,6 +339,7 @@ module PlanMyStuff
337
339
  @id = other.id
338
340
  @body = other.body
339
341
  @raw_body = other.raw_body
342
+ @updated_at = other.updated_at
340
343
  @issue = other.issue
341
344
  @metadata = other.metadata
342
345
  @visibility = other.visibility
@@ -352,15 +355,11 @@ module PlanMyStuff
352
355
  #
353
356
  def raise_if_stale!
354
357
  return if new_record?
355
- return if metadata.updated_at.nil?
358
+ return if updated_at.nil?
356
359
 
357
360
  github_comment = PlanMyStuff.client.rest(:issue_comment, issue.repo, id)
358
- parsed = MetadataParser.parse(
359
- github_comment.respond_to?(:body) ? github_comment.body : github_comment[:body],
360
- )
361
- remote_metadata = CommentMetadata.from_hash(parsed[:metadata])
362
- remote_time = remote_metadata.updated_at
363
- local_time = metadata.updated_at
361
+ remote_time = parse_github_time(safe_read_field(github_comment, :updated_at))
362
+ local_time = updated_at
364
363
 
365
364
  return if remote_time.nil?
366
365
  return if local_time && remote_time.to_i == local_time.to_i
@@ -92,12 +92,51 @@ module PlanMyStuff
92
92
  #
93
93
  attr_accessor :comment_custom_fields
94
94
 
95
+ # Project-only field definitions, deep-merged on top of shared custom_fields.
96
+ # Context-specific config wins on key conflicts.
97
+ #
98
+ # @return [Hash{Symbol => Hash}]
99
+ #
100
+ attr_accessor :project_custom_fields
101
+
95
102
  # @return [String, nil] URL prefix for building user-facing ticket URLs in the consuming app
96
103
  attr_accessor :issues_url_prefix
97
104
 
98
105
  # @return [String, nil] name of the consuming app, stored in metadata (e.g. "Atlas")
99
106
  attr_accessor :app_name
100
107
 
108
+ # @return [Boolean] whether the release pipeline feature is enabled
109
+ attr_accessor :pipeline_enabled
110
+
111
+ # @return [Integer, nil] GitHub Projects V2 number for the pipeline board (falls back to default_project_number)
112
+ attr_accessor :pipeline_project_number
113
+
114
+ # @return [String, nil] HMAC secret for GitHub webhook signature verification (required when webhooks mounted)
115
+ attr_accessor :webhook_secret
116
+
117
+ # @return [String, nil] shared secret for AWS webhook signature verification (required when AWS webhook mounted)
118
+ attr_accessor :aws_webhook_secret
119
+
120
+ # Canonical status name to display alias map. Allows consuming apps to rename
121
+ # pipeline statuses (e.g. "Submitted" to "Triaged").
122
+ #
123
+ # @return [Hash{String => String}]
124
+ #
125
+ attr_accessor :pipeline_statuses
126
+
127
+ # @return [String] branch name that PRs merge into for "Ready for release" transition
128
+ attr_accessor :main_branch
129
+
130
+ # @return [String] branch name that triggers deployment when a PR merges
131
+ attr_accessor :production_branch
132
+
133
+ # Per-group route mounting toggles. Keys: :webhooks, :issues, :projects.
134
+ # Set a key to false to skip mounting that route group.
135
+ #
136
+ # @return [Hash{Symbol => Boolean}]
137
+ #
138
+ attr_accessor :mount_groups
139
+
101
140
  # Named repo configs. Set via config.repos[:element] = 'BrandsInsurance/Element'.
102
141
  #
103
142
  # @return [Hash{Symbol => String}]
@@ -117,6 +156,12 @@ module PlanMyStuff
117
156
  @custom_fields = {}
118
157
  @issue_custom_fields = {}
119
158
  @comment_custom_fields = {}
159
+ @project_custom_fields = {}
160
+ @pipeline_enabled = true
161
+ @pipeline_statuses = {}
162
+ @main_branch = 'main'
163
+ @production_branch = 'production'
164
+ @mount_groups = { webhooks: true, issues: true, projects: true }
120
165
  end
121
166
 
122
167
  # Sets the authentication block for engine controllers.
@@ -162,6 +207,7 @@ module PlanMyStuff
162
207
  case context
163
208
  when :issue then issue_custom_fields
164
209
  when :comment then comment_custom_fields
210
+ when :project then project_custom_fields
165
211
  else {}
166
212
  end
167
213
 
@@ -24,6 +24,8 @@ module PlanMyStuff
24
24
  attr_accessor :state
25
25
  # @return [Array<String>] label names
26
26
  attr_accessor :labels
27
+ # @return [Time, nil] GitHub's updated_at timestamp
28
+ attr_reader :updated_at
27
29
  # @return [PlanMyStuff::Repo, nil]
28
30
  attr_reader :repo
29
31
 
@@ -126,7 +128,6 @@ module PlanMyStuff
126
128
  merged_custom_fields,
127
129
  ).validate!
128
130
 
129
- existing_metadata[:updated_at] = Time.now.utc.iso8601
130
131
  options[:body] = MetadataParser.serialize(existing_metadata, '')
131
132
  end
132
133
 
@@ -250,7 +251,6 @@ module PlanMyStuff
250
251
  existing_metadata = parsed[:metadata]
251
252
  allowlist = Array.wrap(existing_metadata[:visibility_allowlist])
252
253
  existing_metadata[:visibility_allowlist] = yield(allowlist)
253
- existing_metadata[:updated_at] = Time.now.utc.iso8601
254
254
 
255
255
  new_body = MetadataParser.serialize(existing_metadata, parsed[:body])
256
256
  client.rest(:update_issue, resolved_repo, number, body: new_body)
@@ -412,6 +412,7 @@ module PlanMyStuff
412
412
  @title = read_field(github_issue, :title)
413
413
  @state = read_field(github_issue, :state)
414
414
  @raw_body = read_field(github_issue, :body) || ''
415
+ @updated_at = parse_github_time(safe_read_field(github_issue, :updated_at))
415
416
  @labels = extract_labels(github_issue)
416
417
  self.repo = repo
417
418
 
@@ -434,6 +435,7 @@ module PlanMyStuff
434
435
  @state = other.state
435
436
  @body = other.instance_variable_get(:@body)
436
437
  @raw_body = other.raw_body
438
+ @updated_at = other.updated_at
437
439
  @labels = other.labels
438
440
  @repo = other.repo
439
441
  @metadata = other.metadata
@@ -450,11 +452,11 @@ module PlanMyStuff
450
452
  #
451
453
  def raise_if_stale!
452
454
  return if new_record?
453
- return if metadata.updated_at.nil?
455
+ return if updated_at.nil?
454
456
 
455
457
  remote = self.class.find(number, repo: repo)
456
- remote_time = remote.metadata.updated_at
457
- local_time = metadata.updated_at
458
+ remote_time = remote.updated_at
459
+ local_time = updated_at
458
460
 
459
461
  return if remote_time.nil?
460
462
  return if local_time && remote_time.to_i == local_time.to_i
@@ -18,11 +18,21 @@ module PlanMyStuff
18
18
  attr_reader :number
19
19
  # @return [Boolean] whether the project is closed
20
20
  attr_reader :closed
21
+ # @return [PlanMyStuff::ProjectMetadata] parsed metadata (empty when no PMS metadata present)
22
+ attr_reader :metadata
23
+ # @return [String] full readme as stored on GitHub
24
+ attr_reader :raw_readme
25
+ # @return [String, nil] project short description (from shortDescription)
26
+ attr_reader :description
27
+ # @return [Time, nil] GitHub's updatedAt timestamp
28
+ attr_reader :updated_at
21
29
 
22
30
  # @return [String] project title
23
31
  attr_accessor :title
24
32
  # @return [String] project URL
25
33
  attr_accessor :url
34
+ # @return [String, nil] user-visible readme content (without metadata comment)
35
+ attr_accessor :readme
26
36
  # @return [Array<Hash>] status options ({id:, name:})
27
37
  attr_accessor :statuses
28
38
  # @return [Array<Hash>] all field definitions
@@ -35,25 +45,97 @@ module PlanMyStuff
35
45
  attr_accessor :has_next_page
36
46
 
37
47
  class << self
38
- # Creates a new project in the configured organization.
48
+ # Creates a new project in the configured organization with PMS metadata.
39
49
  #
40
50
  # @param title [String]
51
+ # @param user [Object, Integer, nil] user object or user_id
52
+ # @param visibility [String] "public" or "internal"
53
+ # @param custom_fields [Hash] app-defined field values
54
+ # @param readme [String] user-visible readme content
55
+ # @param description [String, nil] project short description
41
56
  #
42
- # @return [Object]
57
+ # @return [PlanMyStuff::Project]
43
58
  #
44
- def create(title:)
45
- raise(NotImplementedError, "#{name}.create is not yet implemented")
59
+ def create!(title:, user: nil, visibility: 'internal', custom_fields: {}, readme: '', description: nil)
60
+ org = PlanMyStuff.configuration.organization
61
+
62
+ project_metadata = PlanMyStuff::ProjectMetadata.build(
63
+ user: user,
64
+ visibility: visibility,
65
+ custom_fields: custom_fields,
66
+ )
67
+ project_metadata.validate_custom_fields!
68
+
69
+ org_id = resolve_org_id(org)
70
+ data = PlanMyStuff.client.graphql(
71
+ create_mutation,
72
+ variables: { input: { ownerId: org_id, title: title } },
73
+ )
74
+
75
+ new_project = data.dig(:createProjectV2, :projectV2) || {}
76
+ project_id = new_project[:id]
77
+ project_number = new_project[:number]
78
+
79
+ serialized_readme = PlanMyStuff::MetadataParser.serialize(project_metadata.to_h, readme)
80
+ update_input = { projectId: project_id, readme: serialized_readme }
81
+ update_input[:shortDescription] = description if description.present?
82
+
83
+ PlanMyStuff.client.graphql(
84
+ update_mutation,
85
+ variables: { input: update_input },
86
+ )
87
+
88
+ find(project_number)
46
89
  end
47
90
 
48
91
  # Updates an existing project.
49
92
  #
50
93
  # @param project_number [Integer]
51
94
  # @param title [String, nil]
95
+ # @param readme [String, nil] user-visible readme content (metadata preserved)
96
+ # @param description [String, nil] project short description
97
+ # @param metadata [Hash, nil] custom fields to merge into existing metadata
52
98
  #
53
- # @return [Object]
99
+ # @return [PlanMyStuff::Project]
54
100
  #
55
- def update(project_number:, title: nil)
56
- raise(NotImplementedError, "#{name}.update is not yet implemented")
101
+ def update!(project_number:, title: nil, readme: nil, description: nil, metadata: nil)
102
+ org = PlanMyStuff.configuration.organization
103
+ project_id = resolve_project_id(org, project_number)
104
+
105
+ update_input = { projectId: project_id }
106
+ update_input[:title] = title unless title.nil?
107
+ update_input[:shortDescription] = description unless description.nil?
108
+
109
+ if metadata.present? || !readme.nil?
110
+ current = find(project_number)
111
+ parsed = PlanMyStuff::MetadataParser.parse(current.raw_readme)
112
+ existing_metadata = parsed[:metadata]
113
+
114
+ if metadata.present?
115
+ # Seed with fresh metadata when project has no existing PMS metadata
116
+ if existing_metadata[:schema_version].blank?
117
+ existing_metadata = PlanMyStuff::ProjectMetadata.build(user: nil).to_h
118
+ end
119
+
120
+ merged_custom_fields = (existing_metadata[:custom_fields] || {}).merge(metadata[:custom_fields] || {})
121
+ existing_metadata = existing_metadata.merge(metadata)
122
+ existing_metadata[:custom_fields] = merged_custom_fields
123
+ PlanMyStuff::CustomFields.new(
124
+ PlanMyStuff.configuration.custom_fields_for(:project),
125
+ merged_custom_fields,
126
+ ).validate!
127
+ end
128
+
129
+ body = readme.nil? ? parsed[:body] : readme
130
+ update_input[:readme] = PlanMyStuff::MetadataParser.serialize(existing_metadata, body)
131
+ end
132
+
133
+ PlanMyStuff.client.graphql(
134
+ update_mutation,
135
+ variables: { input: update_input },
136
+ )
137
+
138
+ find(project_number)
57
139
  end
58
140
 
59
141
  # Lists all projects in the configured organization.
@@ -115,8 +197,10 @@ module PlanMyStuff
115
197
  id
116
198
  number
117
199
  title
200
+ shortDescription
118
201
  url
119
202
  closed
203
+ updatedAt
120
204
  }
121
205
  }
122
206
  }
@@ -133,8 +217,11 @@ module PlanMyStuff
133
217
  id
134
218
  number
135
219
  title
220
+ shortDescription
221
+ readme
136
222
  url
137
223
  closed
224
+ updatedAt
138
225
  fields(first: 50) {
139
226
  nodes {
140
227
  ... on ProjectV2SingleSelectField {
@@ -408,6 +495,60 @@ module PlanMyStuff
408
495
 
409
496
  data.dig(:organization, :projectV2, :id)
410
497
  end
498
+
499
+ # Resolves an organization login to its node ID.
500
+ #
501
+ # @param org [String]
502
+ #
503
+ # @return [String]
504
+ #
505
+ def resolve_org_id(org)
506
+ data = PlanMyStuff.client.graphql(
507
+ org_id_query,
508
+ variables: { org: org },
509
+ )
510
+
511
+ data.dig(:organization, :id)
512
+ end
513
+
514
+ # @return [String]
515
+ def org_id_query
516
+ <<~GRAPHQL
517
+ query($org: String!) {
518
+ organization(login: $org) {
519
+ id
520
+ }
521
+ }
522
+ GRAPHQL
523
+ end
524
+
525
+ # @return [String]
526
+ def create_mutation
527
+ <<~GRAPHQL
528
+ mutation($input: CreateProjectV2Input!) {
529
+ createProjectV2(input: $input) {
530
+ projectV2 {
531
+ id
532
+ number
533
+ }
534
+ }
535
+ }
536
+ GRAPHQL
537
+ end
538
+
539
+ # @return [String]
540
+ def update_mutation
541
+ <<~GRAPHQL
542
+ mutation($input: UpdateProjectV2Input!) {
543
+ updateProjectV2(input: $input) {
544
+ projectV2 {
545
+ id
546
+ number
547
+ }
548
+ }
549
+ }
550
+ GRAPHQL
551
+ end
411
552
  end
412
553
 
413
554
  # @see super
@@ -415,6 +556,11 @@ module PlanMyStuff
415
556
  @id = attrs.delete(:id)
416
557
  @number = attrs.delete(:number)
417
558
  @closed = attrs.delete(:closed)
559
+ @metadata = PlanMyStuff::ProjectMetadata.new
560
+ @raw_readme = nil
561
+ @readme = nil
562
+ @description = nil
563
+ @updated_at = nil
418
564
  super
419
565
  @statuses ||= []
420
566
  @fields ||= []
@@ -435,6 +581,58 @@ module PlanMyStuff
435
581
  field
436
582
  end
437
583
 
584
+ # @return [Boolean]
585
+ def pms_project?
586
+ metadata.schema_version.present?
587
+ end
588
+
589
+ # Persists the project. Creates if new, updates if persisted.
590
+ #
591
+ # @raise [PlanMyStuff::StaleObjectError] on update if stale
592
+ #
593
+ # @return [self]
594
+ #
595
+ def save!
596
+ if new_record?
597
+ created = self.class.create!(title: title, readme: readme || '')
598
+ hydrate_from_project(created)
599
+ else
600
+ update!(title: title, readme: readme)
601
+ end
602
+
603
+ self
604
+ end
605
+
606
+ # Updates this project on GitHub. Raises StaleObjectError if the remote
607
+ # has been modified since this instance was loaded.
608
+ #
609
+ # @param attrs [Hash] attributes to update (title:, readme:, description:, metadata:)
610
+ #
611
+ # @raise [PlanMyStuff::StaleObjectError] if remote updated_at differs from local
612
+ #
613
+ # @return [self]
614
+ #
615
+ def update!(**attrs)
616
+ raise_if_stale!
617
+
618
+ self.class.update!(
619
+ project_number: number,
620
+ **attrs,
621
+ )
622
+
623
+ reload
624
+ end
625
+
626
+ # Re-fetches this project from GitHub and updates all local attributes.
627
+ #
628
+ # @return [self]
629
+ #
630
+ def reload
631
+ fresh = self.class.find(number)
632
+ hydrate_from_project(fresh)
633
+ self
634
+ end
635
+
438
636
  private
439
637
 
440
638
  # Populates this instance from a list query node (summary only).
@@ -447,8 +645,10 @@ module PlanMyStuff
447
645
  @id = node[:id]
448
646
  @number = node[:number]
449
647
  @title = node[:title]
648
+ @description = node[:shortDescription]
450
649
  @url = node[:url]
451
650
  @closed = node[:closed]
651
+ @updated_at = parse_github_time(node[:updatedAt])
452
652
  @persisted = true
453
653
  end
454
654
 
@@ -465,8 +665,15 @@ module PlanMyStuff
465
665
  @id = graphql_project[:id]
466
666
  @number = graphql_project[:number]
467
667
  @title = graphql_project[:title]
668
+ @description = graphql_project[:shortDescription]
468
669
  @url = graphql_project[:url]
469
670
  @closed = graphql_project[:closed]
671
+ @updated_at = parse_github_time(graphql_project[:updatedAt])
672
+
673
+ @raw_readme = graphql_project[:readme] || ''
674
+ parsed = PlanMyStuff::MetadataParser.parse(@raw_readme)
675
+ @metadata = PlanMyStuff::ProjectMetadata.from_hash(parsed[:metadata])
676
+ @readme = parsed[:body]
470
677
 
471
678
  fields_nodes = graphql_project.dig(:fields, :nodes) || []
472
679
  @statuses = extract_statuses(fields_nodes)
@@ -477,6 +684,56 @@ module PlanMyStuff
477
684
  @persisted = true
478
685
  end
479
686
 
687
+ # Copies attributes from another Project instance into self.
688
+ #
689
+ # @param other [PlanMyStuff::Project]
690
+ #
691
+ # @return [void]
692
+ #
693
+ def hydrate_from_project(other)
694
+ @id = other.id
695
+ @number = other.number
696
+ @title = other.title
697
+ @description = other.description
698
+ @url = other.url
699
+ @closed = other.closed
700
+ @updated_at = other.updated_at
701
+ @raw_readme = other.raw_readme
702
+ @readme = other.readme
703
+ @metadata = other.metadata
704
+ @statuses = other.statuses
705
+ @fields = other.fields
706
+ @items = other.items
707
+ @next_cursor = other.next_cursor
708
+ @has_next_page = other.has_next_page
709
+ @persisted = true
710
+ end
711
+
712
+ # Raises StaleObjectError if the remote project has been modified
713
+ # since this instance was loaded.
714
+ #
715
+ # @raise [PlanMyStuff::StaleObjectError]
716
+ #
717
+ # @return [void]
718
+ #
719
+ def raise_if_stale!
720
+ return if new_record?
721
+ return if updated_at.nil?
722
+
723
+ remote = self.class.find(number)
724
+ remote_time = remote.updated_at
725
+ local_time = updated_at
726
+
727
+ return if remote_time.nil?
728
+ return if local_time && remote_time.to_i == local_time.to_i
729
+
730
+ raise(PlanMyStuff::StaleObjectError.new(
731
+ "Project ##{number} has been modified remotely",
732
+ local_updated_at: local_time,
733
+ remote_updated_at: remote_time,
734
+ ))
735
+ end
736
+
480
737
  # Extracts status options from the "Status" single-select field.
481
738
  #
482
739
  # @param fields_nodes [Array<Hash>]
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ class ProjectMetadata < BaseMetadata
5
+ class << self
6
+ # Builds a ProjectMetadata from a parsed hash (e.g. from MetadataParser)
7
+ #
8
+ # @param hash [Hash]
9
+ #
10
+ # @return [PlanMyStuff::ProjectMetadata]
11
+ #
12
+ def from_hash(hash)
13
+ metadata = new
14
+ apply_common_from_hash(metadata, hash, PlanMyStuff.configuration.custom_fields_for(:project))
15
+
16
+ metadata
17
+ end
18
+
19
+ # Builds a new ProjectMetadata for project creation, auto-filling gem defaults
20
+ #
21
+ # @param user [Object, Integer] user object or user_id
22
+ # @param visibility [String] "public" or "internal"
23
+ # @param custom_fields [Hash] app-defined field values
24
+ #
25
+ # @return [PlanMyStuff::ProjectMetadata]
26
+ #
27
+ def build(user:, visibility: 'internal', custom_fields: {})
28
+ metadata = new
29
+ apply_common_build(
30
+ metadata,
31
+ user: user,
32
+ visibility: visibility,
33
+ custom_fields_data: custom_fields,
34
+ custom_fields_schema: PlanMyStuff.configuration.custom_fields_for(:project),
35
+ )
36
+
37
+ metadata
38
+ end
39
+ end
40
+ end
41
+ end
@@ -3,7 +3,7 @@
3
3
  module PlanMyStuff
4
4
  module VERSION
5
5
  MAJOR = 0
6
- MINOR = 2
6
+ MINOR = 3
7
7
  TINY = 0
8
8
 
9
9
  # Set PRE to nil unless it's a pre-release (beta, rc, etc.)
data/lib/plan_my_stuff.rb CHANGED
@@ -19,6 +19,7 @@ require_relative 'plan_my_stuff/markdown'
19
19
  require_relative 'plan_my_stuff/metadata_parser'
20
20
  require_relative 'plan_my_stuff/project'
21
21
  require_relative 'plan_my_stuff/project_item'
22
+ require_relative 'plan_my_stuff/project_metadata'
22
23
  require_relative 'plan_my_stuff/repo'
23
24
  require_relative 'plan_my_stuff/user_resolver'
24
25
  require_relative 'plan_my_stuff/verifier'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plan_my_stuff
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brands Insurance
@@ -69,7 +69,10 @@ files:
69
69
  - app/views/plan_my_stuff/issues/partials/_labels.html.erb
70
70
  - app/views/plan_my_stuff/issues/partials/_viewers.html.erb
71
71
  - app/views/plan_my_stuff/issues/show.html.erb
72
+ - app/views/plan_my_stuff/projects/edit.html.erb
72
73
  - app/views/plan_my_stuff/projects/index.html.erb
74
+ - app/views/plan_my_stuff/projects/new.html.erb
75
+ - app/views/plan_my_stuff/projects/partials/_form.html.erb
73
76
  - app/views/plan_my_stuff/projects/show.html.erb
74
77
  - config/routes.rb
75
78
  - lib/generators/plan_my_stuff/install/install_generator.rb
@@ -92,6 +95,7 @@ files:
92
95
  - lib/plan_my_stuff/metadata_parser.rb
93
96
  - lib/plan_my_stuff/project.rb
94
97
  - lib/plan_my_stuff/project_item.rb
98
+ - lib/plan_my_stuff/project_metadata.rb
95
99
  - lib/plan_my_stuff/repo.rb
96
100
  - lib/plan_my_stuff/test_helpers.rb
97
101
  - lib/plan_my_stuff/user_resolver.rb