plan_my_stuff 0.3.0 → 0.5.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 +22 -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 +172 -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 +216 -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 +179 -0
- metadata +77 -3
|
@@ -1,51 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PlanMyStuff
|
|
4
|
-
# Wraps a GitHub Projects V2 project
|
|
5
|
-
#
|
|
4
|
+
# Wraps a regular (non-testing) GitHub Projects V2 project.
|
|
5
|
+
# Inherits all shared find/list/update/hydrate behavior from BaseProject.
|
|
6
|
+
# Adds +create!+ for regular project creation, and overrides +find+/+list+
|
|
7
|
+
# to exclude testing projects. For testing projects use +TestingProject+;
|
|
8
|
+
# for either type use +BaseProject.find+/+list+.
|
|
6
9
|
#
|
|
7
10
|
# Follows an ActiveRecord-style pattern:
|
|
8
11
|
# - `Project.find` / `Project.list` return persisted instances
|
|
9
12
|
# - `ProjectItem.add_item` / `ProjectItem.add_draft_item` for adding items
|
|
10
13
|
# - `ProjectItem.move_item` / `ProjectItem.assign` for item mutations
|
|
11
|
-
class Project < PlanMyStuff::
|
|
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
|
-
# @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
|
|
29
|
-
|
|
30
|
-
# @return [String] project title
|
|
31
|
-
attr_accessor :title
|
|
32
|
-
# @return [String] project URL
|
|
33
|
-
attr_accessor :url
|
|
34
|
-
# @return [String, nil] user-visible readme content (without metadata comment)
|
|
35
|
-
attr_accessor :readme
|
|
36
|
-
# @return [Array<Hash>] status options ({id:, name:})
|
|
37
|
-
attr_accessor :statuses
|
|
38
|
-
# @return [Array<Hash>] all field definitions
|
|
39
|
-
attr_accessor :fields
|
|
40
|
-
# @return [Array<PlanMyStuff::ProjectItem>] project items
|
|
41
|
-
attr_accessor :items
|
|
42
|
-
# @return [String, nil] cursor for next page (only in cursor mode)
|
|
43
|
-
attr_accessor :next_cursor
|
|
44
|
-
# @return [Boolean, nil] whether more pages exist (only in cursor mode)
|
|
45
|
-
attr_accessor :has_next_page
|
|
46
|
-
|
|
14
|
+
class Project < PlanMyStuff::BaseProject
|
|
47
15
|
class << self
|
|
48
|
-
# Creates a new project in the configured organization with PMS metadata.
|
|
16
|
+
# Creates a new regular project in the configured organization with PMS metadata.
|
|
49
17
|
#
|
|
50
18
|
# @param title [String]
|
|
51
19
|
# @param user [Object, Integer, nil] user object or user_id
|
|
@@ -68,7 +36,7 @@ module PlanMyStuff
|
|
|
68
36
|
|
|
69
37
|
org_id = resolve_org_id(org)
|
|
70
38
|
data = PlanMyStuff.client.graphql(
|
|
71
|
-
|
|
39
|
+
PlanMyStuff::GraphQL::Queries::CREATE_PROJECT,
|
|
72
40
|
variables: { input: { ownerId: org_id, title: title } },
|
|
73
41
|
)
|
|
74
42
|
|
|
@@ -81,685 +49,54 @@ module PlanMyStuff
|
|
|
81
49
|
update_input[:shortDescription] = description if description.present?
|
|
82
50
|
|
|
83
51
|
PlanMyStuff.client.graphql(
|
|
84
|
-
|
|
52
|
+
PlanMyStuff::GraphQL::Queries::UPDATE_PROJECT,
|
|
85
53
|
variables: { input: update_input },
|
|
86
54
|
)
|
|
87
55
|
|
|
88
56
|
find(project_number)
|
|
89
57
|
end
|
|
90
58
|
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
#
|
|
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
|
|
98
|
-
#
|
|
99
|
-
# @return [PlanMyStuff::Project]
|
|
100
|
-
#
|
|
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)
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
# Lists all projects in the configured organization.
|
|
142
|
-
#
|
|
143
|
-
# @return [Array<PlanMyStuff::Project>]
|
|
144
|
-
#
|
|
145
|
-
def list
|
|
146
|
-
org = PlanMyStuff.configuration.organization
|
|
147
|
-
data = PlanMyStuff.client.graphql(list_query, variables: { org: org })
|
|
148
|
-
|
|
149
|
-
nodes = data.dig(:organization, :projectsV2, :nodes) || []
|
|
150
|
-
|
|
151
|
-
nodes.map { |node| build_summary(node) }
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
# Finds a project by number with its statuses, items, and fields.
|
|
59
|
+
# Finds a regular project by number. Raises if the project is a testing
|
|
60
|
+
# project - call +TestingProject.find+ for those, or +BaseProject.find+
|
|
61
|
+
# if you want either type.
|
|
155
62
|
#
|
|
156
63
|
# @param number [Integer]
|
|
157
64
|
# @param paginate [Symbol] :auto (default) or :cursor
|
|
158
|
-
# @param cursor [String, nil] pagination cursor for :cursor mode
|
|
65
|
+
# @param cursor [String, nil] pagination cursor for :cursor mode
|
|
66
|
+
#
|
|
67
|
+
# @raise [ArgumentError] if the project is a testing project
|
|
159
68
|
#
|
|
160
69
|
# @return [PlanMyStuff::Project]
|
|
161
70
|
#
|
|
162
71
|
def find(number, paginate: :auto, cursor: nil)
|
|
163
|
-
|
|
72
|
+
project = super
|
|
164
73
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
find_auto_paginated(org, number)
|
|
168
|
-
when :cursor
|
|
169
|
-
find_with_cursor(org, number, cursor: cursor)
|
|
170
|
-
else
|
|
171
|
-
raise(ArgumentError, "Unknown paginate mode: #{paginate.inspect}. Use :auto or :cursor")
|
|
74
|
+
unless project.is_a?(self)
|
|
75
|
+
raise(ArgumentError, "Project ##{number} is a testing project; use PlanMyStuff::TestingProject.find")
|
|
172
76
|
end
|
|
77
|
+
|
|
78
|
+
project
|
|
173
79
|
end
|
|
174
80
|
|
|
175
|
-
#
|
|
81
|
+
# Lists all regular projects in the configured organization.
|
|
82
|
+
# Testing projects are excluded; use +TestingProject.list+ or
|
|
83
|
+
# +BaseProject.list+ to reach them.
|
|
176
84
|
#
|
|
177
|
-
# @
|
|
178
|
-
#
|
|
179
|
-
# @return [Integer]
|
|
85
|
+
# @return [Array<PlanMyStuff::Project>]
|
|
180
86
|
#
|
|
181
|
-
def
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
PlanMyStuff.configuration.default_project_number ||
|
|
185
|
-
raise(ArgumentError, 'project_number is required when config.default_project_number is not set')
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
private
|
|
189
|
-
|
|
190
|
-
# @return [String]
|
|
191
|
-
def list_query
|
|
192
|
-
<<~GRAPHQL
|
|
193
|
-
query($org: String!) {
|
|
194
|
-
organization(login: $org) {
|
|
195
|
-
projectsV2(first: 100) {
|
|
196
|
-
nodes {
|
|
197
|
-
id
|
|
198
|
-
number
|
|
199
|
-
title
|
|
200
|
-
shortDescription
|
|
201
|
-
url
|
|
202
|
-
closed
|
|
203
|
-
updatedAt
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
GRAPHQL
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
# @return [String]
|
|
212
|
-
def find_query
|
|
213
|
-
<<~GRAPHQL
|
|
214
|
-
query($org: String!, $number: Int!, $cursor: String) {
|
|
215
|
-
organization(login: $org) {
|
|
216
|
-
projectV2(number: $number) {
|
|
217
|
-
id
|
|
218
|
-
number
|
|
219
|
-
title
|
|
220
|
-
shortDescription
|
|
221
|
-
readme
|
|
222
|
-
url
|
|
223
|
-
closed
|
|
224
|
-
updatedAt
|
|
225
|
-
fields(first: 50) {
|
|
226
|
-
nodes {
|
|
227
|
-
... on ProjectV2SingleSelectField {
|
|
228
|
-
id
|
|
229
|
-
name
|
|
230
|
-
options {
|
|
231
|
-
id
|
|
232
|
-
name
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
... on ProjectV2Field {
|
|
236
|
-
id
|
|
237
|
-
name
|
|
238
|
-
}
|
|
239
|
-
... on ProjectV2IterationField {
|
|
240
|
-
id
|
|
241
|
-
name
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
items(first: #{ITEMS_PER_PAGE}, after: $cursor) {
|
|
246
|
-
pageInfo {
|
|
247
|
-
hasNextPage
|
|
248
|
-
endCursor
|
|
249
|
-
}
|
|
250
|
-
nodes {
|
|
251
|
-
id
|
|
252
|
-
type
|
|
253
|
-
content {
|
|
254
|
-
... on Issue {
|
|
255
|
-
id
|
|
256
|
-
title
|
|
257
|
-
number
|
|
258
|
-
url
|
|
259
|
-
state
|
|
260
|
-
repository { nameWithOwner }
|
|
261
|
-
}
|
|
262
|
-
... on PullRequest {
|
|
263
|
-
id
|
|
264
|
-
title
|
|
265
|
-
number
|
|
266
|
-
url
|
|
267
|
-
state
|
|
268
|
-
repository { nameWithOwner }
|
|
269
|
-
}
|
|
270
|
-
... on DraftIssue {
|
|
271
|
-
id
|
|
272
|
-
title
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
fieldValues(first: 20) {
|
|
276
|
-
nodes {
|
|
277
|
-
... on ProjectV2ItemFieldSingleSelectValue {
|
|
278
|
-
name
|
|
279
|
-
field {
|
|
280
|
-
... on ProjectV2SingleSelectField {
|
|
281
|
-
name
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
... on ProjectV2ItemFieldTextValue {
|
|
286
|
-
text
|
|
287
|
-
field {
|
|
288
|
-
... on ProjectV2Field {
|
|
289
|
-
name
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
... on ProjectV2ItemFieldUserValue {
|
|
294
|
-
users(first: 10) {
|
|
295
|
-
nodes {
|
|
296
|
-
login
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
field {
|
|
300
|
-
... on ProjectV2Field {
|
|
301
|
-
name
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
GRAPHQL
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
# @return [String]
|
|
316
|
-
def project_id_query
|
|
317
|
-
<<~GRAPHQL
|
|
318
|
-
query($org: String!, $number: Int!) {
|
|
319
|
-
organization(login: $org) {
|
|
320
|
-
projectV2(number: $number) {
|
|
321
|
-
id
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
GRAPHQL
|
|
326
|
-
end
|
|
327
|
-
|
|
328
|
-
# Builds a summary Project from a list query node.
|
|
329
|
-
#
|
|
330
|
-
# @param node [Hash]
|
|
331
|
-
#
|
|
332
|
-
# @return [PlanMyStuff::Project]
|
|
333
|
-
#
|
|
334
|
-
def build_summary(node)
|
|
335
|
-
project = new
|
|
336
|
-
project.__send__(:hydrate_summary, node)
|
|
337
|
-
project
|
|
338
|
-
end
|
|
339
|
-
|
|
340
|
-
# Builds a detailed Project from a find query response.
|
|
341
|
-
#
|
|
342
|
-
# @param graphql_project [Hash]
|
|
343
|
-
# @param items [Array<Hash>]
|
|
344
|
-
# @param next_cursor [String, nil]
|
|
345
|
-
# @param has_next_page [Boolean, nil]
|
|
346
|
-
#
|
|
347
|
-
# @return [PlanMyStuff::Project]
|
|
348
|
-
#
|
|
349
|
-
def build_detail(graphql_project, items:, next_cursor: nil, has_next_page: nil)
|
|
350
|
-
project = new
|
|
351
|
-
project.__send__(
|
|
352
|
-
:hydrate_detail,
|
|
353
|
-
graphql_project,
|
|
354
|
-
items: items,
|
|
355
|
-
next_cursor: next_cursor,
|
|
356
|
-
has_next_page: has_next_page,
|
|
357
|
-
)
|
|
358
|
-
project
|
|
359
|
-
end
|
|
360
|
-
|
|
361
|
-
# @param org [String]
|
|
362
|
-
# @param number [Integer]
|
|
363
|
-
#
|
|
364
|
-
# @return [PlanMyStuff::Project]
|
|
365
|
-
#
|
|
366
|
-
def find_auto_paginated(org, number)
|
|
367
|
-
all_items = []
|
|
368
|
-
cursor = nil
|
|
369
|
-
raw_project = nil
|
|
370
|
-
|
|
371
|
-
loop do
|
|
372
|
-
page = fetch_project_page(org, number, cursor)
|
|
373
|
-
raw_project ||= page[:raw]
|
|
374
|
-
all_items.concat(page[:items])
|
|
375
|
-
|
|
376
|
-
break if !page[:has_next_page] || all_items.length >= MAX_AUTO_PAGINATE_ITEMS
|
|
377
|
-
|
|
378
|
-
cursor = page[:next_cursor]
|
|
379
|
-
end
|
|
380
|
-
|
|
381
|
-
build_detail(raw_project, items: all_items)
|
|
382
|
-
end
|
|
383
|
-
|
|
384
|
-
# @param org [String]
|
|
385
|
-
# @param number [Integer]
|
|
386
|
-
# @param cursor [String, nil]
|
|
387
|
-
#
|
|
388
|
-
# @return [PlanMyStuff::Project]
|
|
389
|
-
#
|
|
390
|
-
def find_with_cursor(org, number, cursor:)
|
|
391
|
-
page = fetch_project_page(org, number, cursor)
|
|
392
|
-
build_detail(
|
|
393
|
-
page[:raw],
|
|
394
|
-
items: page[:items],
|
|
395
|
-
next_cursor: page[:next_cursor],
|
|
396
|
-
has_next_page: page[:has_next_page],
|
|
397
|
-
)
|
|
398
|
-
end
|
|
399
|
-
|
|
400
|
-
# Fetches a single page of project data. Returns a lightweight hash
|
|
401
|
-
# for pagination loop consumption (not a Project instance).
|
|
402
|
-
#
|
|
403
|
-
# @param org [String]
|
|
404
|
-
# @param number [Integer]
|
|
405
|
-
# @param cursor [String, nil]
|
|
406
|
-
#
|
|
407
|
-
# @return [Hash] with :raw, :items, :next_cursor, :has_next_page
|
|
408
|
-
#
|
|
409
|
-
def fetch_project_page(org, number, cursor)
|
|
410
|
-
variables = { org: org, number: number }
|
|
411
|
-
variables[:cursor] = cursor if cursor
|
|
412
|
-
|
|
413
|
-
data = PlanMyStuff.client.graphql(find_query, variables: variables)
|
|
414
|
-
|
|
415
|
-
raw_project = data.dig(:organization, :projectV2)
|
|
416
|
-
page_info = raw_project.dig(:items, :pageInfo) || {}
|
|
417
|
-
items_data = raw_project.dig(:items, :nodes) || []
|
|
418
|
-
|
|
419
|
-
{
|
|
420
|
-
raw: raw_project,
|
|
421
|
-
items: items_data.map { |item| parse_project_item(item) },
|
|
422
|
-
next_cursor: page_info[:endCursor],
|
|
423
|
-
has_next_page: page_info[:hasNextPage],
|
|
424
|
-
}
|
|
425
|
-
end
|
|
426
|
-
|
|
427
|
-
# @param item [Hash] raw GraphQL project item node
|
|
428
|
-
#
|
|
429
|
-
# @return [Hash]
|
|
430
|
-
#
|
|
431
|
-
def parse_project_item(item)
|
|
432
|
-
content = item[:content] || {}
|
|
433
|
-
field_values = item.dig(:fieldValues, :nodes) || []
|
|
434
|
-
repo_name = content.dig(:repository, :nameWithOwner)
|
|
435
|
-
|
|
436
|
-
{
|
|
437
|
-
id: item[:id],
|
|
438
|
-
type: item[:type],
|
|
439
|
-
content_node_id: content[:id],
|
|
440
|
-
title: content[:title],
|
|
441
|
-
number: content[:number],
|
|
442
|
-
url: content[:url],
|
|
443
|
-
state: content[:state],
|
|
444
|
-
repo: repo_name.present? ? PlanMyStuff::Repo.resolve(repo_name) : nil,
|
|
445
|
-
status: extract_item_status(field_values),
|
|
446
|
-
field_values: parse_field_values(field_values),
|
|
447
|
-
}
|
|
448
|
-
end
|
|
449
|
-
|
|
450
|
-
# @param field_values [Array<Hash>]
|
|
451
|
-
#
|
|
452
|
-
# @return [String, nil]
|
|
453
|
-
#
|
|
454
|
-
def extract_item_status(field_values)
|
|
455
|
-
status_value = field_values.find { |fv| fv.dig(:field, :name) == 'Status' }
|
|
456
|
-
|
|
457
|
-
status_value&.dig(:name)
|
|
458
|
-
end
|
|
459
|
-
|
|
460
|
-
# @param field_values [Array<Hash>]
|
|
461
|
-
#
|
|
462
|
-
# @return [Hash]
|
|
463
|
-
#
|
|
464
|
-
def parse_field_values(field_values)
|
|
465
|
-
result = {}
|
|
466
|
-
|
|
467
|
-
field_values.each do |fv|
|
|
468
|
-
field_name = fv.dig(:field, :name)
|
|
469
|
-
next unless field_name
|
|
470
|
-
|
|
471
|
-
value = fv[:name] || fv[:text]
|
|
472
|
-
users_node = fv[:users]
|
|
473
|
-
if users_node
|
|
474
|
-
value = (users_node[:nodes] || []).map { |u| u[:login] }
|
|
475
|
-
end
|
|
476
|
-
|
|
477
|
-
result[field_name] = value
|
|
478
|
-
end
|
|
479
|
-
|
|
480
|
-
result
|
|
481
|
-
end
|
|
482
|
-
|
|
483
|
-
# Resolves a project number to its node ID.
|
|
484
|
-
#
|
|
485
|
-
# @param org [String]
|
|
486
|
-
# @param project_number [Integer]
|
|
487
|
-
#
|
|
488
|
-
# @return [String]
|
|
489
|
-
#
|
|
490
|
-
def resolve_project_id(org, project_number)
|
|
491
|
-
data = PlanMyStuff.client.graphql(
|
|
492
|
-
project_id_query,
|
|
493
|
-
variables: { org: org, number: project_number },
|
|
494
|
-
)
|
|
495
|
-
|
|
496
|
-
data.dig(:organization, :projectV2, :id)
|
|
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
|
|
552
|
-
end
|
|
553
|
-
|
|
554
|
-
# @see super
|
|
555
|
-
def initialize(**attrs)
|
|
556
|
-
@id = attrs.delete(:id)
|
|
557
|
-
@number = attrs.delete(:number)
|
|
558
|
-
@closed = attrs.delete(:closed)
|
|
559
|
-
@metadata = PlanMyStuff::ProjectMetadata.new
|
|
560
|
-
@raw_readme = nil
|
|
561
|
-
@readme = nil
|
|
562
|
-
@description = nil
|
|
563
|
-
@updated_at = nil
|
|
564
|
-
super
|
|
565
|
-
@statuses ||= []
|
|
566
|
-
@fields ||= []
|
|
567
|
-
@items ||= []
|
|
568
|
-
end
|
|
569
|
-
|
|
570
|
-
# Returns the Status single-select field definition.
|
|
571
|
-
#
|
|
572
|
-
# @return [Hash] with :id and :options keys
|
|
573
|
-
#
|
|
574
|
-
# @raise [PlanMyStuff::APIError] if no Status field exists
|
|
575
|
-
#
|
|
576
|
-
def status_field
|
|
577
|
-
field = fields.find { |f| f[:name] == 'Status' && f[:options] }
|
|
578
|
-
|
|
579
|
-
raise(APIError, "No 'Status' field found on project ##{number}") unless field
|
|
580
|
-
|
|
581
|
-
field
|
|
582
|
-
end
|
|
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)
|
|
87
|
+
def list
|
|
88
|
+
super.select { |p| p.is_a?(self) }
|
|
601
89
|
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
90
|
end
|
|
635
91
|
|
|
636
92
|
private
|
|
637
93
|
|
|
638
|
-
#
|
|
94
|
+
# Returns the item class used to build items for testing projects.
|
|
639
95
|
#
|
|
640
|
-
# @
|
|
96
|
+
# @return [Class]
|
|
641
97
|
#
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
def hydrate_summary(node)
|
|
645
|
-
@id = node[:id]
|
|
646
|
-
@number = node[:number]
|
|
647
|
-
@title = node[:title]
|
|
648
|
-
@description = node[:shortDescription]
|
|
649
|
-
@url = node[:url]
|
|
650
|
-
@closed = node[:closed]
|
|
651
|
-
@updated_at = parse_github_time(node[:updatedAt])
|
|
652
|
-
@persisted = true
|
|
653
|
-
end
|
|
654
|
-
|
|
655
|
-
# Populates this instance from a detailed find query response.
|
|
656
|
-
#
|
|
657
|
-
# @param graphql_project [Hash]
|
|
658
|
-
# @param items [Array<Hash>]
|
|
659
|
-
# @param next_cursor [String, nil]
|
|
660
|
-
# @param has_next_page [Boolean, nil]
|
|
661
|
-
#
|
|
662
|
-
# @return [void]
|
|
663
|
-
#
|
|
664
|
-
def hydrate_detail(graphql_project, items:, next_cursor: nil, has_next_page: nil)
|
|
665
|
-
@id = graphql_project[:id]
|
|
666
|
-
@number = graphql_project[:number]
|
|
667
|
-
@title = graphql_project[:title]
|
|
668
|
-
@description = graphql_project[:shortDescription]
|
|
669
|
-
@url = graphql_project[:url]
|
|
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]
|
|
677
|
-
|
|
678
|
-
fields_nodes = graphql_project.dig(:fields, :nodes) || []
|
|
679
|
-
@statuses = extract_statuses(fields_nodes)
|
|
680
|
-
@fields = extract_fields(fields_nodes)
|
|
681
|
-
@items = items.map { |item_hash| ProjectItem.build(item_hash, project: self) }
|
|
682
|
-
@next_cursor = next_cursor
|
|
683
|
-
@has_next_page = has_next_page
|
|
684
|
-
@persisted = true
|
|
685
|
-
end
|
|
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
|
-
|
|
737
|
-
# Extracts status options from the "Status" single-select field.
|
|
738
|
-
#
|
|
739
|
-
# @param fields_nodes [Array<Hash>]
|
|
740
|
-
#
|
|
741
|
-
# @return [Array<Hash>]
|
|
742
|
-
#
|
|
743
|
-
def extract_statuses(fields_nodes)
|
|
744
|
-
status_field = fields_nodes.find { |f| f[:name] == 'Status' && f.key?(:options) }
|
|
745
|
-
|
|
746
|
-
return [] unless status_field
|
|
747
|
-
|
|
748
|
-
(status_field[:options] || []).map do |opt|
|
|
749
|
-
{ id: opt[:id], name: opt[:name] }
|
|
750
|
-
end
|
|
751
|
-
end
|
|
752
|
-
|
|
753
|
-
# @param fields_nodes [Array<Hash>]
|
|
754
|
-
#
|
|
755
|
-
# @return [Array<Hash>]
|
|
756
|
-
#
|
|
757
|
-
def extract_fields(fields_nodes)
|
|
758
|
-
fields_nodes.map do |f|
|
|
759
|
-
field = { id: f[:id], name: f[:name] }
|
|
760
|
-
field[:options] = f[:options].map { |o| { id: o[:id], name: o[:name] } } if f[:options]
|
|
761
|
-
field
|
|
762
|
-
end
|
|
98
|
+
def item_class
|
|
99
|
+
PlanMyStuff::ProjectItem
|
|
763
100
|
end
|
|
764
101
|
end
|
|
765
102
|
end
|