plan_my_stuff 0.3.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +569 -38
- data/app/controllers/plan_my_stuff/comments_controller.rb +5 -1
- data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +102 -0
- data/app/controllers/plan_my_stuff/issues/closures_controller.rb +37 -0
- data/app/controllers/plan_my_stuff/issues/links_controller.rb +127 -0
- data/app/controllers/plan_my_stuff/issues/takes_controller.rb +88 -0
- data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +48 -0
- data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +47 -0
- data/app/controllers/plan_my_stuff/issues_controller.rb +22 -55
- data/app/controllers/plan_my_stuff/labels_controller.rb +4 -4
- data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +75 -0
- data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +40 -0
- data/app/controllers/plan_my_stuff/project_items_controller.rb +0 -75
- data/app/controllers/plan_my_stuff/projects_controller.rb +11 -1
- data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +54 -0
- data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +39 -0
- data/app/controllers/plan_my_stuff/testing_projects_controller.rb +93 -0
- data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +148 -0
- data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +284 -0
- data/app/jobs/plan_my_stuff/application_job.rb +9 -0
- data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +81 -0
- data/app/views/plan_my_stuff/comments/partials/_form.html.erb +7 -0
- data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +87 -0
- data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +2 -2
- data/app/views/plan_my_stuff/issues/partials/_links.html.erb +70 -0
- data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +2 -2
- data/app/views/plan_my_stuff/issues/show.html.erb +46 -3
- data/app/views/plan_my_stuff/projects/index.html.erb +15 -1
- data/app/views/plan_my_stuff/projects/show.html.erb +10 -5
- data/app/views/plan_my_stuff/testing_project_items/new.html.erb +12 -0
- data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +22 -0
- data/app/views/plan_my_stuff/testing_projects/edit.html.erb +7 -0
- data/app/views/plan_my_stuff/testing_projects/new.html.erb +7 -0
- data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +39 -0
- data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +51 -0
- data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +35 -0
- data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
- data/config/routes.rb +38 -15
- data/lib/generators/plan_my_stuff/install/templates/initializer.rb +138 -5
- data/lib/plan_my_stuff/application_record.rb +121 -0
- data/lib/plan_my_stuff/approval.rb +80 -0
- data/lib/plan_my_stuff/archive/sweep.rb +85 -0
- data/lib/plan_my_stuff/archive.rb +14 -0
- data/lib/plan_my_stuff/aws_sns_simulator.rb +110 -0
- data/lib/plan_my_stuff/base_project.rb +661 -0
- data/lib/plan_my_stuff/base_project_item.rb +562 -0
- data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
- data/lib/plan_my_stuff/cache.rb +197 -0
- data/lib/plan_my_stuff/client.rb +7 -0
- data/lib/plan_my_stuff/comment.rb +171 -50
- data/lib/plan_my_stuff/configuration.rb +210 -10
- data/lib/plan_my_stuff/custom_fields.rb +31 -17
- data/lib/plan_my_stuff/engine.rb +0 -4
- data/lib/plan_my_stuff/errors.rb +49 -0
- data/lib/plan_my_stuff/graphql/queries.rb +392 -0
- data/lib/plan_my_stuff/issue.rb +1476 -175
- data/lib/plan_my_stuff/issue_metadata.rb +122 -0
- data/lib/plan_my_stuff/label.rb +82 -11
- data/lib/plan_my_stuff/link.rb +144 -0
- data/lib/plan_my_stuff/notifications.rb +142 -0
- data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
- data/lib/plan_my_stuff/pipeline/status.rb +44 -0
- data/lib/plan_my_stuff/pipeline.rb +293 -0
- data/lib/plan_my_stuff/project.rb +30 -693
- data/lib/plan_my_stuff/project_item.rb +3 -417
- data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
- data/lib/plan_my_stuff/project_metadata.rb +9 -3
- data/lib/plan_my_stuff/reminders/closer.rb +70 -0
- data/lib/plan_my_stuff/reminders/fire.rb +129 -0
- data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
- data/lib/plan_my_stuff/reminders.rb +16 -0
- data/lib/plan_my_stuff/test_helpers.rb +260 -15
- data/lib/plan_my_stuff/testing_project.rb +291 -0
- data/lib/plan_my_stuff/testing_project_item.rb +184 -0
- data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
- data/lib/plan_my_stuff/user_resolver.rb +8 -3
- data/lib/plan_my_stuff/version.rb +1 -1
- data/lib/plan_my_stuff/webhook_replayer.rb +280 -0
- data/lib/plan_my_stuff.rb +15 -0
- data/lib/tasks/plan_my_stuff.rake +163 -0
- metadata +50 -2
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
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+.
|
|
8
|
+
#
|
|
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.
|
|
12
|
+
class BaseProject < PlanMyStuff::ApplicationRecord
|
|
13
|
+
MAX_AUTO_PAGINATE_ITEMS = 500
|
|
14
|
+
ITEMS_PER_PAGE = 100
|
|
15
|
+
|
|
16
|
+
# @return [String, nil] GitHub node ID
|
|
17
|
+
attribute :id, :string
|
|
18
|
+
# @return [Integer, nil] project number
|
|
19
|
+
attribute :number, :integer
|
|
20
|
+
# @return [Boolean, nil] whether the project is closed
|
|
21
|
+
attribute :closed
|
|
22
|
+
# @return [PlanMyStuff::BaseProjectMetadata] parsed metadata (empty when no PMS metadata present)
|
|
23
|
+
attribute :metadata, default: -> { PlanMyStuff::ProjectMetadata.new }
|
|
24
|
+
# @return [String, nil] full readme as stored on GitHub
|
|
25
|
+
attribute :raw_readme, :string
|
|
26
|
+
# @return [String, nil] project short description (from shortDescription)
|
|
27
|
+
attribute :description, :string
|
|
28
|
+
# @return [Time, nil] GitHub's updatedAt timestamp
|
|
29
|
+
attribute :updated_at
|
|
30
|
+
# @return [String, nil] project title
|
|
31
|
+
attribute :title, :string
|
|
32
|
+
# @return [String, nil] project URL
|
|
33
|
+
attribute :url, :string
|
|
34
|
+
# @return [String, nil] user-visible readme content (without metadata comment)
|
|
35
|
+
attribute :readme, :string
|
|
36
|
+
# @return [Array<Hash>] status options ({id:, name:})
|
|
37
|
+
attribute :statuses, default: -> { [] }
|
|
38
|
+
# @return [Array<Hash>] all field definitions
|
|
39
|
+
attribute :fields, default: -> { [] }
|
|
40
|
+
# @return [Array<PlanMyStuff::ProjectItem>] project items
|
|
41
|
+
attribute :items, default: -> { [] }
|
|
42
|
+
# @return [String, nil] cursor for next page (only in cursor mode)
|
|
43
|
+
attribute :next_cursor, :string
|
|
44
|
+
# @return [Boolean, nil] whether more pages exist (only in cursor mode)
|
|
45
|
+
attribute :has_next_page
|
|
46
|
+
|
|
47
|
+
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).
|
|
51
|
+
#
|
|
52
|
+
# @param number [Integer]
|
|
53
|
+
# @param paginate [Symbol] :auto (default) or :cursor
|
|
54
|
+
# @param cursor [String, nil] pagination cursor for :cursor mode
|
|
55
|
+
#
|
|
56
|
+
# @return [PlanMyStuff::BaseProject]
|
|
57
|
+
#
|
|
58
|
+
def find(number, paginate: :auto, cursor: nil)
|
|
59
|
+
org = PlanMyStuff.configuration.organization
|
|
60
|
+
|
|
61
|
+
case paginate
|
|
62
|
+
when :auto
|
|
63
|
+
find_auto_paginated(org, number)
|
|
64
|
+
when :cursor
|
|
65
|
+
find_with_cursor(org, number, cursor: cursor)
|
|
66
|
+
else
|
|
67
|
+
raise(ArgumentError, "Unknown paginate mode: #{paginate.inspect}. Use :auto or :cursor")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
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.
|
|
74
|
+
#
|
|
75
|
+
# @return [Array<PlanMyStuff::BaseProject>]
|
|
76
|
+
#
|
|
77
|
+
def list
|
|
78
|
+
org = PlanMyStuff.configuration.organization
|
|
79
|
+
data = PlanMyStuff.client.graphql(
|
|
80
|
+
PlanMyStuff::GraphQL::Queries::LIST_PROJECTS,
|
|
81
|
+
variables: { org: org },
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
nodes = data.dig(:organization, :projectsV2, :nodes) || []
|
|
85
|
+
nodes.map { |node| build_summary(node) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Updates an existing project.
|
|
89
|
+
#
|
|
90
|
+
# @param project_number [Integer]
|
|
91
|
+
# @param title [String, nil]
|
|
92
|
+
# @param readme [String, nil] user-visible readme content (metadata preserved)
|
|
93
|
+
# @param description [String, nil] project short description
|
|
94
|
+
# @param metadata [Hash, nil] custom fields to merge into existing metadata
|
|
95
|
+
#
|
|
96
|
+
# @return [PlanMyStuff::BaseProject]
|
|
97
|
+
#
|
|
98
|
+
def update!(project_number:, title: nil, readme: nil, description: nil, metadata: nil)
|
|
99
|
+
org = PlanMyStuff.configuration.organization
|
|
100
|
+
project_id = resolve_project_id(org, project_number)
|
|
101
|
+
|
|
102
|
+
update_input = { projectId: project_id }
|
|
103
|
+
update_input[:title] = title unless title.nil?
|
|
104
|
+
update_input[:shortDescription] = description unless description.nil?
|
|
105
|
+
|
|
106
|
+
if metadata.present? || !readme.nil?
|
|
107
|
+
current = find(project_number)
|
|
108
|
+
parsed = PlanMyStuff::MetadataParser.parse(current.raw_readme)
|
|
109
|
+
existing_metadata = parsed[:metadata]
|
|
110
|
+
|
|
111
|
+
if metadata.present?
|
|
112
|
+
# Seed with fresh metadata when project has no existing PMS metadata
|
|
113
|
+
if existing_metadata[:schema_version].blank?
|
|
114
|
+
existing_metadata = PlanMyStuff::ProjectMetadata.build(user: nil).to_h
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
merged_custom_fields = (existing_metadata[:custom_fields] || {}).merge(metadata[:custom_fields] || {})
|
|
118
|
+
existing_metadata = existing_metadata.merge(metadata)
|
|
119
|
+
existing_metadata[:custom_fields] = merged_custom_fields
|
|
120
|
+
PlanMyStuff::CustomFields.new(
|
|
121
|
+
PlanMyStuff.configuration.custom_fields_for(:project),
|
|
122
|
+
merged_custom_fields,
|
|
123
|
+
).validate!
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
body = readme.nil? ? parsed[:body] : readme
|
|
127
|
+
update_input[:readme] = PlanMyStuff::MetadataParser.serialize(existing_metadata, body)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
PlanMyStuff.client.graphql(
|
|
131
|
+
PlanMyStuff::GraphQL::Queries::UPDATE_PROJECT,
|
|
132
|
+
variables: { input: update_input },
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
find(project_number)
|
|
136
|
+
end
|
|
137
|
+
|
|
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.
|
|
142
|
+
#
|
|
143
|
+
# @param source_number [Integer] project number of the project to copy from
|
|
144
|
+
# @param title [String] title for the new project
|
|
145
|
+
#
|
|
146
|
+
# @return [PlanMyStuff::BaseProject]
|
|
147
|
+
#
|
|
148
|
+
def clone!(source_number:, title:)
|
|
149
|
+
org = PlanMyStuff.configuration.organization
|
|
150
|
+
org_id = resolve_org_id(org)
|
|
151
|
+
source_project_id = resolve_project_id(org, source_number)
|
|
152
|
+
|
|
153
|
+
data = PlanMyStuff.client.graphql(
|
|
154
|
+
PlanMyStuff::GraphQL::Queries::COPY_PROJECT_V2,
|
|
155
|
+
variables: {
|
|
156
|
+
input: {
|
|
157
|
+
ownerId: org_id,
|
|
158
|
+
projectId: source_project_id,
|
|
159
|
+
title: title,
|
|
160
|
+
includeDraftIssues: false,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
new_number = data.dig(:copyProjectV2, :projectV2, :number)
|
|
166
|
+
PlanMyStuff::BaseProject.find(new_number)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Resolves a project number, falling back to config.default_project_number.
|
|
170
|
+
#
|
|
171
|
+
# @param project_number [Integer, nil]
|
|
172
|
+
#
|
|
173
|
+
# @return [Integer]
|
|
174
|
+
#
|
|
175
|
+
def resolve_default_project_number(project_number)
|
|
176
|
+
return project_number if project_number.present?
|
|
177
|
+
|
|
178
|
+
PlanMyStuff.configuration.default_project_number ||
|
|
179
|
+
raise(ArgumentError, 'project_number is required when config.default_project_number is not set')
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
# Builds a summary Project from a list query node.
|
|
185
|
+
# Dispatches to TestingProject when the readme metadata has kind: "testing".
|
|
186
|
+
#
|
|
187
|
+
# @param node [Hash]
|
|
188
|
+
#
|
|
189
|
+
# @return [PlanMyStuff::BaseProject]
|
|
190
|
+
#
|
|
191
|
+
def build_summary(node)
|
|
192
|
+
raw_readme = node[:readme] || ''
|
|
193
|
+
parsed_meta = PlanMyStuff::MetadataParser.parse(raw_readme)
|
|
194
|
+
klass = dispatch_project_class(parsed_meta[:metadata])
|
|
195
|
+
project = klass.new
|
|
196
|
+
project.__send__(:hydrate_summary, node)
|
|
197
|
+
project
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Builds a detailed Project from a find query response.
|
|
201
|
+
# Dispatches to TestingProject when the readme metadata has kind: "testing".
|
|
202
|
+
#
|
|
203
|
+
# @param graphql_project [Hash]
|
|
204
|
+
# @param items [Array<Hash>]
|
|
205
|
+
# @param next_cursor [String, nil]
|
|
206
|
+
# @param has_next_page [Boolean, nil]
|
|
207
|
+
#
|
|
208
|
+
# @return [PlanMyStuff::BaseProject]
|
|
209
|
+
#
|
|
210
|
+
def build_detail(graphql_project, items:, next_cursor: nil, has_next_page: nil)
|
|
211
|
+
raw_readme = graphql_project[:readme] || ''
|
|
212
|
+
parsed_meta = PlanMyStuff::MetadataParser.parse(raw_readme)
|
|
213
|
+
klass = dispatch_project_class(parsed_meta[:metadata])
|
|
214
|
+
project = klass.new
|
|
215
|
+
project.__send__(
|
|
216
|
+
:hydrate_detail,
|
|
217
|
+
graphql_project,
|
|
218
|
+
items: items,
|
|
219
|
+
next_cursor: next_cursor,
|
|
220
|
+
has_next_page: has_next_page,
|
|
221
|
+
)
|
|
222
|
+
project
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Returns the appropriate project class based on the metadata kind field.
|
|
226
|
+
# Always dispatches to a concrete subclass (never BaseProject itself).
|
|
227
|
+
#
|
|
228
|
+
# @param meta_hash [Hash]
|
|
229
|
+
#
|
|
230
|
+
# @return [Class]
|
|
231
|
+
#
|
|
232
|
+
def dispatch_project_class(meta_hash)
|
|
233
|
+
return PlanMyStuff::TestingProject if meta_hash[:kind] == 'testing'
|
|
234
|
+
|
|
235
|
+
PlanMyStuff::Project
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# @param org [String]
|
|
239
|
+
# @param number [Integer]
|
|
240
|
+
#
|
|
241
|
+
# @return [PlanMyStuff::BaseProject]
|
|
242
|
+
#
|
|
243
|
+
def find_auto_paginated(org, number)
|
|
244
|
+
all_items = []
|
|
245
|
+
cursor = nil
|
|
246
|
+
raw_project = nil
|
|
247
|
+
page = nil
|
|
248
|
+
|
|
249
|
+
loop do
|
|
250
|
+
page = fetch_project_page(org, number, cursor)
|
|
251
|
+
raw_project ||= page[:raw]
|
|
252
|
+
all_items.concat(page[:items])
|
|
253
|
+
|
|
254
|
+
break if !page[:has_next_page] || all_items.length >= MAX_AUTO_PAGINATE_ITEMS
|
|
255
|
+
|
|
256
|
+
cursor = page[:next_cursor]
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
build_detail(
|
|
260
|
+
raw_project,
|
|
261
|
+
items: all_items,
|
|
262
|
+
next_cursor: page[:next_cursor],
|
|
263
|
+
has_next_page: page[:has_next_page],
|
|
264
|
+
)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# @param org [String]
|
|
268
|
+
# @param number [Integer]
|
|
269
|
+
# @param cursor [String, nil]
|
|
270
|
+
#
|
|
271
|
+
# @return [PlanMyStuff::BaseProject]
|
|
272
|
+
#
|
|
273
|
+
def find_with_cursor(org, number, cursor:)
|
|
274
|
+
page = fetch_project_page(org, number, cursor)
|
|
275
|
+
build_detail(
|
|
276
|
+
page[:raw],
|
|
277
|
+
items: page[:items],
|
|
278
|
+
next_cursor: page[:next_cursor],
|
|
279
|
+
has_next_page: page[:has_next_page],
|
|
280
|
+
)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Fetches a single page of project data. Returns a lightweight hash
|
|
284
|
+
# for pagination loop consumption (not a Project instance).
|
|
285
|
+
#
|
|
286
|
+
# @param org [String]
|
|
287
|
+
# @param number [Integer]
|
|
288
|
+
# @param cursor [String, nil]
|
|
289
|
+
#
|
|
290
|
+
# @return [Hash] with :raw, :items, :next_cursor, :has_next_page
|
|
291
|
+
#
|
|
292
|
+
def fetch_project_page(org, number, cursor)
|
|
293
|
+
variables = { org: org, number: number }
|
|
294
|
+
variables[:cursor] = cursor if cursor
|
|
295
|
+
|
|
296
|
+
data = PlanMyStuff.client.graphql(
|
|
297
|
+
PlanMyStuff::GraphQL::Queries::FIND_PROJECT,
|
|
298
|
+
variables: variables,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
raw_project = data.dig(:organization, :projectV2)
|
|
302
|
+
page_info = raw_project.dig(:items, :pageInfo) || {}
|
|
303
|
+
items_data = raw_project.dig(:items, :nodes) || []
|
|
304
|
+
|
|
305
|
+
{
|
|
306
|
+
raw: raw_project,
|
|
307
|
+
items: items_data.map { |item| parse_project_item(item) },
|
|
308
|
+
next_cursor: page_info[:endCursor],
|
|
309
|
+
has_next_page: page_info[:hasNextPage],
|
|
310
|
+
}
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# @param item [Hash] raw GraphQL project item node
|
|
314
|
+
#
|
|
315
|
+
# @return [Hash]
|
|
316
|
+
#
|
|
317
|
+
def parse_project_item(item)
|
|
318
|
+
content = item[:content] || {}
|
|
319
|
+
field_values = item.dig(:fieldValues, :nodes) || []
|
|
320
|
+
repo_name = content.dig(:repository, :nameWithOwner)
|
|
321
|
+
|
|
322
|
+
{
|
|
323
|
+
id: item[:id],
|
|
324
|
+
type: item[:type],
|
|
325
|
+
content_node_id: content[:id],
|
|
326
|
+
title: content[:title],
|
|
327
|
+
body: content[:body],
|
|
328
|
+
number: content[:number],
|
|
329
|
+
url: content[:url],
|
|
330
|
+
state: content[:state],
|
|
331
|
+
repo: repo_name.present? ? PlanMyStuff::Repo.resolve(repo_name) : nil,
|
|
332
|
+
status: extract_item_status(field_values),
|
|
333
|
+
field_values: parse_field_values(field_values),
|
|
334
|
+
github_response: item,
|
|
335
|
+
}
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# @param field_values [Array<Hash>]
|
|
339
|
+
#
|
|
340
|
+
# @return [String, nil]
|
|
341
|
+
#
|
|
342
|
+
def extract_item_status(field_values)
|
|
343
|
+
status_value = field_values.find { |fv| fv.dig(:field, :name) == 'Status' }
|
|
344
|
+
|
|
345
|
+
status_value&.dig(:name)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# @param field_values [Array<Hash>]
|
|
349
|
+
#
|
|
350
|
+
# @return [Hash]
|
|
351
|
+
#
|
|
352
|
+
def parse_field_values(field_values)
|
|
353
|
+
result = {}
|
|
354
|
+
|
|
355
|
+
field_values.each do |fv|
|
|
356
|
+
field_name = fv.dig(:field, :name)
|
|
357
|
+
next unless field_name
|
|
358
|
+
|
|
359
|
+
value = fv[:name] || fv[:text]
|
|
360
|
+
users_node = fv[:users]
|
|
361
|
+
if users_node
|
|
362
|
+
value = (users_node[:nodes] || []).map { |u| u[:login] }
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
result[field_name] = value
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
result
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Resolves a project number to its node ID.
|
|
372
|
+
#
|
|
373
|
+
# @param org [String]
|
|
374
|
+
# @param project_number [Integer]
|
|
375
|
+
#
|
|
376
|
+
# @return [String]
|
|
377
|
+
#
|
|
378
|
+
def resolve_project_id(org, project_number)
|
|
379
|
+
data = PlanMyStuff.client.graphql(
|
|
380
|
+
PlanMyStuff::GraphQL::Queries::PROJECT_ID,
|
|
381
|
+
variables: { org: org, number: project_number },
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
data.dig(:organization, :projectV2, :id)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Resolves an organization login to its node ID.
|
|
388
|
+
#
|
|
389
|
+
# @param org [String]
|
|
390
|
+
#
|
|
391
|
+
# @return [String]
|
|
392
|
+
#
|
|
393
|
+
def resolve_org_id(org)
|
|
394
|
+
data = PlanMyStuff.client.graphql(
|
|
395
|
+
PlanMyStuff::GraphQL::Queries::ORG_ID,
|
|
396
|
+
variables: { org: org },
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
data.dig(:organization, :id)
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Returns the Status single-select field definition.
|
|
404
|
+
#
|
|
405
|
+
# @return [Hash] with :id and :options keys
|
|
406
|
+
#
|
|
407
|
+
# @raise [PlanMyStuff::APIError] if no Status field exists
|
|
408
|
+
#
|
|
409
|
+
def status_field
|
|
410
|
+
field = fields.find { |f| f[:name] == 'Status' && f[:options] }
|
|
411
|
+
|
|
412
|
+
raise(APIError, "No 'Status' field found on project ##{number}") unless field
|
|
413
|
+
|
|
414
|
+
field
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# @return [Boolean]
|
|
418
|
+
def pms_project?
|
|
419
|
+
metadata.schema_version.present?
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Persists the project. Creates if new, updates if persisted.
|
|
423
|
+
#
|
|
424
|
+
# @raise [PlanMyStuff::StaleObjectError] on update if stale
|
|
425
|
+
#
|
|
426
|
+
# @return [self]
|
|
427
|
+
#
|
|
428
|
+
def save!
|
|
429
|
+
if new_record?
|
|
430
|
+
created = self.class.create!(
|
|
431
|
+
title: title,
|
|
432
|
+
readme: readme || '',
|
|
433
|
+
description: description,
|
|
434
|
+
**create_kwargs_from_metadata,
|
|
435
|
+
)
|
|
436
|
+
hydrate_from_project(created)
|
|
437
|
+
else
|
|
438
|
+
update!(
|
|
439
|
+
title: title,
|
|
440
|
+
readme: readme,
|
|
441
|
+
description: description,
|
|
442
|
+
metadata: metadata_for_update,
|
|
443
|
+
)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
self
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# Updates this project on GitHub. Raises StaleObjectError if the remote
|
|
450
|
+
# has been modified since this instance was loaded.
|
|
451
|
+
#
|
|
452
|
+
# @param attrs [Hash] attributes to update (title:, readme:, description:, metadata:)
|
|
453
|
+
#
|
|
454
|
+
# @raise [PlanMyStuff::StaleObjectError] if remote updated_at differs from local
|
|
455
|
+
#
|
|
456
|
+
# @return [self]
|
|
457
|
+
#
|
|
458
|
+
def update!(**attrs)
|
|
459
|
+
raise_if_stale!
|
|
460
|
+
|
|
461
|
+
self.class.update!(
|
|
462
|
+
project_number: number,
|
|
463
|
+
**attrs,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
reload
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Re-fetches this project from GitHub and updates all local attributes.
|
|
470
|
+
#
|
|
471
|
+
# @return [self]
|
|
472
|
+
#
|
|
473
|
+
def reload
|
|
474
|
+
fresh = self.class.find(number)
|
|
475
|
+
hydrate_from_project(fresh)
|
|
476
|
+
self
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
private
|
|
480
|
+
|
|
481
|
+
# Kwargs derived from +metadata+ for forwarding to +self.class.create!+
|
|
482
|
+
# so +save!+ preserves in-memory metadata mutations on new records.
|
|
483
|
+
# Subclasses override to include type-specific metadata attributes.
|
|
484
|
+
#
|
|
485
|
+
# @return [Hash]
|
|
486
|
+
#
|
|
487
|
+
def create_kwargs_from_metadata
|
|
488
|
+
{
|
|
489
|
+
user: metadata.created_by,
|
|
490
|
+
visibility: metadata.visibility,
|
|
491
|
+
custom_fields: metadata.custom_fields.to_h,
|
|
492
|
+
}
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Metadata hash forwarded to the class +update!+ so +save!+ preserves
|
|
496
|
+
# in-memory metadata mutations on persisted records. Returns +nil+ when
|
|
497
|
+
# the instance has no PMS metadata to avoid clobbering remote values.
|
|
498
|
+
#
|
|
499
|
+
# @return [Hash, nil]
|
|
500
|
+
#
|
|
501
|
+
def metadata_for_update
|
|
502
|
+
return if metadata.schema_version.blank?
|
|
503
|
+
|
|
504
|
+
metadata.to_h
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# Populates this instance from a list query node (summary only).
|
|
508
|
+
#
|
|
509
|
+
# @param node [Hash]
|
|
510
|
+
#
|
|
511
|
+
# @return [void]
|
|
512
|
+
#
|
|
513
|
+
def hydrate_summary(node)
|
|
514
|
+
@github_response = node
|
|
515
|
+
self.id = node[:id]
|
|
516
|
+
self.number = node[:number]
|
|
517
|
+
self.title = node[:title]
|
|
518
|
+
self.description = node[:shortDescription]
|
|
519
|
+
self.url = node[:url]
|
|
520
|
+
self.closed = node[:closed]
|
|
521
|
+
self.updated_at = parse_github_time(node[:updatedAt])
|
|
522
|
+
persisted!
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# Populates this instance from a detailed find query response.
|
|
526
|
+
#
|
|
527
|
+
# @param graphql_project [Hash]
|
|
528
|
+
# @param items [Array<Hash>]
|
|
529
|
+
# @param next_cursor [String, nil]
|
|
530
|
+
# @param has_next_page [Boolean, nil]
|
|
531
|
+
#
|
|
532
|
+
# @return [void]
|
|
533
|
+
#
|
|
534
|
+
def hydrate_detail(graphql_project, items:, next_cursor: nil, has_next_page: nil)
|
|
535
|
+
@github_response = graphql_project
|
|
536
|
+
self.id = graphql_project[:id]
|
|
537
|
+
self.number = graphql_project[:number]
|
|
538
|
+
self.title = graphql_project[:title]
|
|
539
|
+
self.description = graphql_project[:shortDescription]
|
|
540
|
+
self.url = graphql_project[:url]
|
|
541
|
+
self.closed = graphql_project[:closed]
|
|
542
|
+
self.updated_at = parse_github_time(graphql_project[:updatedAt])
|
|
543
|
+
|
|
544
|
+
self.raw_readme = graphql_project[:readme] || ''
|
|
545
|
+
parsed = PlanMyStuff::MetadataParser.parse(raw_readme)
|
|
546
|
+
self.metadata = build_project_metadata(parsed[:metadata])
|
|
547
|
+
self.readme = parsed[:body]
|
|
548
|
+
|
|
549
|
+
fields_nodes = graphql_project.dig(:fields, :nodes) || []
|
|
550
|
+
self.statuses = extract_statuses(fields_nodes)
|
|
551
|
+
self.fields = extract_fields(fields_nodes)
|
|
552
|
+
self.items = items.map { |item_hash| item_class.build(item_hash, project: self) }
|
|
553
|
+
self.next_cursor = next_cursor
|
|
554
|
+
self.has_next_page = has_next_page
|
|
555
|
+
persisted!
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# Copies attributes from another Project instance into self.
|
|
559
|
+
#
|
|
560
|
+
# @param other [PlanMyStuff::BaseProject]
|
|
561
|
+
#
|
|
562
|
+
# @return [void]
|
|
563
|
+
#
|
|
564
|
+
def hydrate_from_project(other)
|
|
565
|
+
@github_response = other.github_response
|
|
566
|
+
self.id = other.id
|
|
567
|
+
self.number = other.number
|
|
568
|
+
self.title = other.title
|
|
569
|
+
self.description = other.description
|
|
570
|
+
self.url = other.url
|
|
571
|
+
self.closed = other.closed
|
|
572
|
+
self.updated_at = other.updated_at
|
|
573
|
+
self.raw_readme = other.raw_readme
|
|
574
|
+
self.readme = other.readme
|
|
575
|
+
self.metadata = other.metadata
|
|
576
|
+
self.statuses = other.statuses
|
|
577
|
+
self.fields = other.fields
|
|
578
|
+
self.items = other.items
|
|
579
|
+
self.next_cursor = other.next_cursor
|
|
580
|
+
self.has_next_page = other.has_next_page
|
|
581
|
+
persisted!
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# Raises StaleObjectError if the remote project has been modified
|
|
585
|
+
# since this instance was loaded.
|
|
586
|
+
#
|
|
587
|
+
# @raise [PlanMyStuff::StaleObjectError]
|
|
588
|
+
#
|
|
589
|
+
# @return [void]
|
|
590
|
+
#
|
|
591
|
+
def raise_if_stale!
|
|
592
|
+
return if new_record?
|
|
593
|
+
return if updated_at.nil?
|
|
594
|
+
|
|
595
|
+
remote = self.class.find(number)
|
|
596
|
+
remote_time = remote.updated_at
|
|
597
|
+
local_time = updated_at
|
|
598
|
+
|
|
599
|
+
return if remote_time.nil?
|
|
600
|
+
return if local_time && remote_time.to_i == local_time.to_i
|
|
601
|
+
|
|
602
|
+
raise(PlanMyStuff::StaleObjectError.new(
|
|
603
|
+
"Project ##{number} has been modified remotely",
|
|
604
|
+
local_updated_at: local_time,
|
|
605
|
+
remote_updated_at: remote_time,
|
|
606
|
+
))
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
# Builds the appropriate metadata object for this project instance.
|
|
610
|
+
# Dispatches to TestingProjectMetadata when kind is "testing".
|
|
611
|
+
#
|
|
612
|
+
# @param meta_hash [Hash]
|
|
613
|
+
#
|
|
614
|
+
# @return [PlanMyStuff::BaseProjectMetadata]
|
|
615
|
+
#
|
|
616
|
+
def build_project_metadata(meta_hash)
|
|
617
|
+
if meta_hash[:kind] == 'testing'
|
|
618
|
+
PlanMyStuff::TestingProjectMetadata.from_hash(meta_hash)
|
|
619
|
+
else
|
|
620
|
+
PlanMyStuff::ProjectMetadata.from_hash(meta_hash)
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# Extracts status options from the "Status" single-select field.
|
|
625
|
+
#
|
|
626
|
+
# @param fields_nodes [Array<Hash>]
|
|
627
|
+
#
|
|
628
|
+
# @return [Array<Hash>]
|
|
629
|
+
#
|
|
630
|
+
def extract_statuses(fields_nodes)
|
|
631
|
+
status_field = fields_nodes.find { |f| f[:name] == 'Status' && f.key?(:options) }
|
|
632
|
+
|
|
633
|
+
return [] unless status_field
|
|
634
|
+
|
|
635
|
+
(status_field[:options] || []).map do |opt|
|
|
636
|
+
{ id: opt[:id], name: opt[:name] }
|
|
637
|
+
end
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
# @param fields_nodes [Array<Hash>]
|
|
641
|
+
#
|
|
642
|
+
# @return [Array<Hash>]
|
|
643
|
+
#
|
|
644
|
+
def extract_fields(fields_nodes)
|
|
645
|
+
fields_nodes.map do |f|
|
|
646
|
+
field = { id: f[:id], name: f[:name] }
|
|
647
|
+
field[:options] = f[:options].map { |o| { id: o[:id], name: o[:name] } } if f[:options]
|
|
648
|
+
field
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
# Returns the item class used to build items for this project type.
|
|
653
|
+
# Subclasses override to return a domain-specific item class.
|
|
654
|
+
#
|
|
655
|
+
# @return [Class]
|
|
656
|
+
#
|
|
657
|
+
def item_class
|
|
658
|
+
raise(NotImplementedError, 'Subclass must implement #item_class to return the appropriate item class')
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
end
|