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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -1
- data/README.md +100 -103
- data/app/controllers/plan_my_stuff/application_controller.rb +22 -3
- data/app/controllers/plan_my_stuff/comments_controller.rb +14 -16
- data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +23 -13
- data/app/controllers/plan_my_stuff/issues/closures_controller.rb +7 -5
- data/app/controllers/plan_my_stuff/issues/links_controller.rb +14 -18
- data/app/controllers/plan_my_stuff/issues/takes_controller.rb +99 -28
- data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +13 -5
- data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +7 -5
- data/app/controllers/plan_my_stuff/issues_controller.rb +24 -28
- data/app/controllers/plan_my_stuff/labels_controller.rb +21 -5
- data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +13 -6
- data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +5 -4
- data/app/controllers/plan_my_stuff/project_items_controller.rb +30 -5
- data/app/controllers/plan_my_stuff/projects_controller.rb +16 -16
- data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +21 -11
- data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +9 -4
- data/app/controllers/plan_my_stuff/testing_projects_controller.rb +30 -14
- data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +50 -17
- data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +32 -49
- data/app/jobs/plan_my_stuff/application_job.rb +2 -3
- data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +15 -22
- data/app/views/plan_my_stuff/comments/edit.html.erb +1 -3
- data/app/views/plan_my_stuff/comments/partials/_form.html.erb +1 -0
- data/app/views/plan_my_stuff/issues/edit.html.erb +2 -4
- data/app/views/plan_my_stuff/issues/index.html.erb +2 -2
- data/app/views/plan_my_stuff/issues/new.html.erb +2 -4
- data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +23 -2
- data/app/views/plan_my_stuff/issues/partials/_form.html.erb +1 -0
- data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +2 -1
- data/app/views/plan_my_stuff/issues/partials/_links.html.erb +50 -7
- data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +2 -1
- data/app/views/plan_my_stuff/issues/show.html.erb +5 -2
- data/app/views/plan_my_stuff/partials/_flash.html.erb +4 -0
- data/app/views/plan_my_stuff/projects/edit.html.erb +1 -3
- data/app/views/plan_my_stuff/projects/index.html.erb +1 -1
- data/app/views/plan_my_stuff/projects/new.html.erb +1 -3
- data/app/views/plan_my_stuff/projects/partials/_form.html.erb +1 -0
- data/app/views/plan_my_stuff/projects/show.html.erb +13 -3
- data/app/views/plan_my_stuff/testing_project_items/new.html.erb +1 -3
- data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +1 -3
- data/app/views/plan_my_stuff/testing_projects/edit.html.erb +1 -3
- data/app/views/plan_my_stuff/testing_projects/new.html.erb +1 -3
- data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +4 -3
- data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +1 -0
- data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +1 -0
- data/app/views/plan_my_stuff/testing_projects/show.html.erb +2 -2
- data/config/routes.rb +2 -2
- data/lib/generators/plan_my_stuff/install/templates/initializer.rb +51 -2
- data/lib/plan_my_stuff/approval.rb +12 -4
- data/lib/plan_my_stuff/aws_sns_simulator.rb +12 -6
- data/lib/plan_my_stuff/base_metadata.rb +4 -15
- data/lib/plan_my_stuff/base_project.rb +68 -55
- data/lib/plan_my_stuff/base_project_item.rb +61 -57
- data/lib/plan_my_stuff/base_project_metadata.rb +1 -1
- data/lib/plan_my_stuff/client.rb +136 -48
- data/lib/plan_my_stuff/comment.rb +57 -57
- data/lib/plan_my_stuff/comment_metadata.rb +1 -1
- data/lib/plan_my_stuff/configuration.rb +95 -82
- data/lib/plan_my_stuff/errors.rb +10 -10
- data/lib/plan_my_stuff/graphql/queries.rb +1 -1
- data/lib/plan_my_stuff/issue.rb +467 -333
- data/lib/plan_my_stuff/issue_metadata.rb +10 -10
- data/lib/plan_my_stuff/label.rb +32 -16
- data/lib/plan_my_stuff/link.rb +15 -15
- data/lib/plan_my_stuff/markdown.rb +12 -6
- data/lib/plan_my_stuff/metadata_parser.rb +3 -1
- data/lib/plan_my_stuff/notifications.rb +1 -1
- data/lib/plan_my_stuff/pipeline/completed_sweep.rb +2 -2
- data/lib/plan_my_stuff/pipeline/issue_linker.rb +1 -1
- data/lib/plan_my_stuff/pipeline.rb +61 -83
- data/lib/plan_my_stuff/project.rb +4 -4
- data/lib/plan_my_stuff/project_item_metadata.rb +1 -1
- data/lib/plan_my_stuff/project_metadata.rb +1 -1
- data/lib/plan_my_stuff/reminders/closer.rb +1 -1
- data/lib/plan_my_stuff/reminders/fire.rb +3 -3
- data/lib/plan_my_stuff/reminders/sweep.rb +4 -4
- data/lib/plan_my_stuff/repo.rb +12 -6
- data/lib/plan_my_stuff/test_helpers.rb +11 -11
- data/lib/plan_my_stuff/testing_project.rb +12 -11
- data/lib/plan_my_stuff/testing_project_item.rb +11 -9
- data/lib/plan_my_stuff/testing_project_metadata.rb +2 -2
- data/lib/plan_my_stuff/version.rb +1 -1
- data/lib/plan_my_stuff/webhook_replayer.rb +14 -2
- data/lib/plan_my_stuff.rb +26 -2
- data/lib/tasks/plan_my_stuff.rake +33 -20
- metadata +3 -2
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PlanMyStuff
|
|
4
|
-
# Shared base for GitHub Projects V2 item wrappers. Holds attribute
|
|
5
|
-
#
|
|
6
|
-
# and core instance helpers.
|
|
4
|
+
# Shared base for GitHub Projects V2 item wrappers. Holds attribute definitions, generic
|
|
5
|
+
# build/create/move/update/delete/assign machinery, and core instance helpers.
|
|
7
6
|
#
|
|
8
|
-
# Not meant to be used directly. Concrete subclasses (ProjectItem,
|
|
9
|
-
#
|
|
7
|
+
# Not meant to be used directly. Concrete subclasses (ProjectItem, TestingProjectItem) add their own domain-specific
|
|
8
|
+
# methods.
|
|
10
9
|
#
|
|
11
10
|
# Class methods:
|
|
12
11
|
# BaseProjectItem.create!(issue) -- add existing issue
|
|
13
12
|
# BaseProjectItem.create!(title, draft: true, body: "...") -- create draft item
|
|
14
|
-
# BaseProjectItem.move_item(item_id:, status:)
|
|
15
|
-
# BaseProjectItem.assign(item_id:, assignee:)
|
|
13
|
+
# BaseProjectItem.move_item!(item_id:, status:)
|
|
14
|
+
# BaseProjectItem.assign!(item_id:, assignee:)
|
|
16
15
|
#
|
|
17
16
|
# Instance methods:
|
|
18
17
|
# item.move_to!("Done")
|
|
@@ -101,9 +100,9 @@ module PlanMyStuff
|
|
|
101
100
|
def create!(issue_or_title, draft: false, body: nil, project_number: nil, user: nil)
|
|
102
101
|
item =
|
|
103
102
|
if draft
|
|
104
|
-
add_draft_item(title: issue_or_title, body: body, project_number: project_number)
|
|
103
|
+
add_draft_item!(title: issue_or_title, body: body, project_number: project_number)
|
|
105
104
|
else
|
|
106
|
-
add_item(issue: issue_or_title, project_number: project_number)
|
|
105
|
+
add_item!(issue: issue_or_title, project_number: project_number)
|
|
107
106
|
end
|
|
108
107
|
|
|
109
108
|
PlanMyStuff::Notifications.instrument('project_item.added', item, user: user)
|
|
@@ -118,12 +117,12 @@ module PlanMyStuff
|
|
|
118
117
|
#
|
|
119
118
|
# @return [Hash] the updated item
|
|
120
119
|
#
|
|
121
|
-
def move_item(item_id:, status:, project_number: nil)
|
|
122
|
-
project_number = resolve_default_project_number(project_number)
|
|
120
|
+
def move_item!(item_id:, status:, project_number: nil)
|
|
121
|
+
project_number = resolve_default_project_number!(project_number)
|
|
123
122
|
project = BaseProject.find(project_number)
|
|
124
123
|
|
|
125
124
|
status_field = project.status_field
|
|
126
|
-
option_id = resolve_status_option_id(status_field, status)
|
|
125
|
+
option_id = resolve_status_option_id!(status_field, status)
|
|
127
126
|
|
|
128
127
|
PlanMyStuff.client.graphql(
|
|
129
128
|
PlanMyStuff::GraphQL::Queries::UPDATE_SINGLE_SELECT_FIELD,
|
|
@@ -146,11 +145,11 @@ module PlanMyStuff
|
|
|
146
145
|
# @return [Hash] mutation result
|
|
147
146
|
#
|
|
148
147
|
def update_single_select_field!(item_id:, field_name:, value:, project_number: nil)
|
|
149
|
-
project_number = resolve_default_project_number(project_number)
|
|
148
|
+
project_number = resolve_default_project_number!(project_number)
|
|
150
149
|
project = BaseProject.find(project_number)
|
|
151
150
|
|
|
152
|
-
field = resolve_single_select_field(project, field_name)
|
|
153
|
-
option_id = resolve_status_option_id(field, value)
|
|
151
|
+
field = resolve_single_select_field!(project, field_name)
|
|
152
|
+
option_id = resolve_status_option_id!(field, value)
|
|
154
153
|
|
|
155
154
|
PlanMyStuff.client.graphql(
|
|
156
155
|
PlanMyStuff::GraphQL::Queries::UPDATE_SINGLE_SELECT_FIELD,
|
|
@@ -173,10 +172,10 @@ module PlanMyStuff
|
|
|
173
172
|
# @return [Hash] mutation result
|
|
174
173
|
#
|
|
175
174
|
def update_field!(item_id:, field_name:, value:, project_number: nil)
|
|
176
|
-
project_number = resolve_default_project_number(project_number)
|
|
175
|
+
project_number = resolve_default_project_number!(project_number)
|
|
177
176
|
project = BaseProject.find(project_number)
|
|
178
177
|
|
|
179
|
-
field = resolve_text_field(project, field_name)
|
|
178
|
+
field = resolve_text_field!(project, field_name)
|
|
180
179
|
|
|
181
180
|
PlanMyStuff.client.graphql(
|
|
182
181
|
PlanMyStuff::GraphQL::Queries::UPDATE_TEXT_FIELD,
|
|
@@ -199,10 +198,10 @@ module PlanMyStuff
|
|
|
199
198
|
# @return [Hash] mutation result
|
|
200
199
|
#
|
|
201
200
|
def update_date_field!(item_id:, field_name:, date:, project_number: nil)
|
|
202
|
-
project_number = resolve_default_project_number(project_number)
|
|
201
|
+
project_number = resolve_default_project_number!(project_number)
|
|
203
202
|
project = BaseProject.find(project_number)
|
|
204
203
|
|
|
205
|
-
field = resolve_text_field(project, field_name)
|
|
204
|
+
field = resolve_text_field!(project, field_name)
|
|
206
205
|
|
|
207
206
|
PlanMyStuff.client.graphql(
|
|
208
207
|
PlanMyStuff::GraphQL::Queries::UPDATE_DATE_FIELD,
|
|
@@ -215,18 +214,18 @@ module PlanMyStuff
|
|
|
215
214
|
)
|
|
216
215
|
end
|
|
217
216
|
|
|
218
|
-
# Deletes a project item from its parent project. Returns the
|
|
219
|
-
#
|
|
217
|
+
# Deletes a project item from its parent project. Returns the +deletedItemId+ from the GraphQL response on
|
|
218
|
+
# success.
|
|
219
|
+
#
|
|
220
|
+
# @raise [PlanMyStuff::APIError] if the GraphQL mutation fails
|
|
220
221
|
#
|
|
221
222
|
# @param item_id [String] project item ID (e.g. "PVTI_...")
|
|
222
223
|
# @param project_number [Integer, nil] defaults to config.default_project_number
|
|
223
224
|
#
|
|
224
225
|
# @return [String] the deleted item ID echoed back by GitHub
|
|
225
226
|
#
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
def delete_item(item_id:, project_number: nil)
|
|
229
|
-
project_number = resolve_default_project_number(project_number)
|
|
227
|
+
def delete_item!(item_id:, project_number: nil)
|
|
228
|
+
project_number = resolve_default_project_number!(project_number)
|
|
230
229
|
org = PlanMyStuff.configuration.organization
|
|
231
230
|
project_id = resolve_project_id(org, project_number)
|
|
232
231
|
|
|
@@ -241,8 +240,9 @@ module PlanMyStuff
|
|
|
241
240
|
deleted_id
|
|
242
241
|
end
|
|
243
242
|
|
|
244
|
-
# Assigns users to a project item.
|
|
245
|
-
#
|
|
243
|
+
# Assigns users to a project item. Issues/PRs use REST via Issue.update!, drafts use GraphQL.
|
|
244
|
+
#
|
|
245
|
+
# @raise [PlanMyStuff::APIError] if a username cannot be resolved or the GraphQL mutation fails
|
|
246
246
|
#
|
|
247
247
|
# @param number [Integer, nil] issue number (nil for drafts)
|
|
248
248
|
# @param content_node_id [String] node ID of the underlying content
|
|
@@ -252,7 +252,7 @@ module PlanMyStuff
|
|
|
252
252
|
#
|
|
253
253
|
# @return [void]
|
|
254
254
|
#
|
|
255
|
-
def assign(number:, content_node_id:, assignees:, draft: false, repo: nil)
|
|
255
|
+
def assign!(number:, content_node_id:, assignees:, draft: false, repo: nil)
|
|
256
256
|
if draft
|
|
257
257
|
client = PlanMyStuff.client
|
|
258
258
|
user_ids = assignees.map do |assignee|
|
|
@@ -262,7 +262,7 @@ module PlanMyStuff
|
|
|
262
262
|
)
|
|
263
263
|
user_id = user_data.dig(:user, :id)
|
|
264
264
|
|
|
265
|
-
raise(APIError, "GitHub user not found: #{assignee}") if user_id.nil?
|
|
265
|
+
raise(PlanMyStuff::APIError, "GitHub user not found: #{assignee}") if user_id.nil?
|
|
266
266
|
|
|
267
267
|
user_id
|
|
268
268
|
end
|
|
@@ -272,7 +272,7 @@ module PlanMyStuff
|
|
|
272
272
|
variables: { draftIssueId: content_node_id, assigneeIds: user_ids },
|
|
273
273
|
)
|
|
274
274
|
else
|
|
275
|
-
Issue.update!(number: number, repo: repo, assignees: assignees)
|
|
275
|
+
PlanMyStuff::Issue.update!(number: number, repo: repo, assignees: assignees)
|
|
276
276
|
end
|
|
277
277
|
end
|
|
278
278
|
|
|
@@ -285,8 +285,8 @@ module PlanMyStuff
|
|
|
285
285
|
#
|
|
286
286
|
# @return [PlanMyStuff::BaseProjectItem]
|
|
287
287
|
#
|
|
288
|
-
def add_item(issue:, project_number:)
|
|
289
|
-
project_number = resolve_default_project_number(project_number)
|
|
288
|
+
def add_item!(issue:, project_number:)
|
|
289
|
+
project_number = resolve_default_project_number!(project_number)
|
|
290
290
|
client = PlanMyStuff.client
|
|
291
291
|
org = PlanMyStuff.configuration.organization
|
|
292
292
|
|
|
@@ -329,8 +329,8 @@ module PlanMyStuff
|
|
|
329
329
|
#
|
|
330
330
|
# @return [PlanMyStuff::BaseProjectItem]
|
|
331
331
|
#
|
|
332
|
-
def add_draft_item(title:, body:, project_number:)
|
|
333
|
-
project_number = resolve_default_project_number(project_number)
|
|
332
|
+
def add_draft_item!(title:, body:, project_number:)
|
|
333
|
+
project_number = resolve_default_project_number!(project_number)
|
|
334
334
|
org = PlanMyStuff.configuration.organization
|
|
335
335
|
project_id = resolve_project_id(org, project_number)
|
|
336
336
|
|
|
@@ -363,23 +363,25 @@ module PlanMyStuff
|
|
|
363
363
|
)
|
|
364
364
|
end
|
|
365
365
|
|
|
366
|
-
# @see PlanMyStuff::Project.resolve_default_project_number
|
|
367
|
-
def resolve_default_project_number(project_number)
|
|
368
|
-
PlanMyStuff::Project.resolve_default_project_number(project_number)
|
|
366
|
+
# @see PlanMyStuff::Project.resolve_default_project_number!
|
|
367
|
+
def resolve_default_project_number!(project_number)
|
|
368
|
+
PlanMyStuff::Project.resolve_default_project_number!(project_number)
|
|
369
369
|
end
|
|
370
370
|
|
|
371
|
-
# Finds a text field by name on a project. Raises ArgumentError if
|
|
372
|
-
#
|
|
371
|
+
# Finds a text field by name on a project. Raises ArgumentError if the field is not found or is a single-select
|
|
372
|
+
# field.
|
|
373
|
+
#
|
|
374
|
+
# @raise [ArgumentError] if the field is not found or is a single-select field
|
|
373
375
|
#
|
|
374
376
|
# @param project [PlanMyStuff::BaseProject]
|
|
375
377
|
# @param field_name [String]
|
|
376
378
|
#
|
|
377
379
|
# @return [Hash]
|
|
378
380
|
#
|
|
379
|
-
def resolve_text_field(project, field_name)
|
|
381
|
+
def resolve_text_field!(project, field_name)
|
|
380
382
|
field = project.fields.find { |f| f[:name] == field_name && f[:options].nil? }
|
|
381
383
|
|
|
382
|
-
|
|
384
|
+
if field.nil?
|
|
383
385
|
available = project.fields.select { |f| f[:options].nil? }.pluck(:name).join(', ')
|
|
384
386
|
raise(ArgumentError, "Unknown text field '#{field_name}'. Available: #{available}")
|
|
385
387
|
end
|
|
@@ -387,15 +389,17 @@ module PlanMyStuff
|
|
|
387
389
|
field
|
|
388
390
|
end
|
|
389
391
|
|
|
392
|
+
# @raise [ArgumentError] if the field is not found or is not a single-select field
|
|
393
|
+
#
|
|
390
394
|
# @param project [PlanMyStuff::BaseProject]
|
|
391
395
|
# @param field_name [String]
|
|
392
396
|
#
|
|
393
397
|
# @return [Hash]
|
|
394
398
|
#
|
|
395
|
-
def resolve_single_select_field(project, field_name)
|
|
399
|
+
def resolve_single_select_field!(project, field_name)
|
|
396
400
|
field = project.fields.find { |f| f[:name] == field_name && f[:options].present? }
|
|
397
401
|
|
|
398
|
-
|
|
402
|
+
if field.nil?
|
|
399
403
|
available = project.fields.select { |f| f[:options].present? }.pluck(:name).join(', ')
|
|
400
404
|
raise(ArgumentError, "Unknown single-select field '#{field_name}'. Available: #{available}")
|
|
401
405
|
end
|
|
@@ -405,15 +409,17 @@ module PlanMyStuff
|
|
|
405
409
|
|
|
406
410
|
# Resolves a status name to its option ID.
|
|
407
411
|
#
|
|
412
|
+
# @raise [ArgumentError] if +status_name+ is not a valid option for +status_field+
|
|
413
|
+
#
|
|
408
414
|
# @param status_field [Hash]
|
|
409
415
|
# @param status_name [String]
|
|
410
416
|
#
|
|
411
417
|
# @return [String]
|
|
412
418
|
#
|
|
413
|
-
def resolve_status_option_id(status_field, status_name)
|
|
419
|
+
def resolve_status_option_id!(status_field, status_name)
|
|
414
420
|
option = status_field[:options].find { |o| o[:name] == status_name }
|
|
415
421
|
|
|
416
|
-
|
|
422
|
+
if option.nil?
|
|
417
423
|
available = status_field[:options].pluck(:name).join(', ')
|
|
418
424
|
raise(ArgumentError, "Unknown status '#{status_name}'. Available: #{available}")
|
|
419
425
|
end
|
|
@@ -446,7 +452,7 @@ module PlanMyStuff
|
|
|
446
452
|
#
|
|
447
453
|
def move_to!(status, user: nil)
|
|
448
454
|
previous_status = self.status
|
|
449
|
-
result = self.class.move_item(
|
|
455
|
+
result = self.class.move_item!(
|
|
450
456
|
project_number: project.number,
|
|
451
457
|
item_id: id,
|
|
452
458
|
status: status,
|
|
@@ -495,20 +501,17 @@ module PlanMyStuff
|
|
|
495
501
|
)
|
|
496
502
|
end
|
|
497
503
|
|
|
498
|
-
# Deletes this item from its parent project. Marks the in-memory
|
|
499
|
-
#
|
|
500
|
-
# returns false.
|
|
504
|
+
# Deletes this item from its parent project. Marks the in-memory instance as destroyed so +destroyed?+ returns
|
|
505
|
+
# true and +persisted?+ returns false.
|
|
501
506
|
#
|
|
502
507
|
# No-op if the instance is already destroyed.
|
|
503
508
|
#
|
|
504
509
|
# @return [String, nil] the deleted item ID, or nil if already destroyed
|
|
505
510
|
#
|
|
506
|
-
# @raise [PlanMyStuff::APIError] if the GraphQL mutation fails
|
|
507
|
-
#
|
|
508
511
|
def destroy!(user: nil)
|
|
509
512
|
return if destroyed?
|
|
510
513
|
|
|
511
|
-
deleted_id = self.class.delete_item(
|
|
514
|
+
deleted_id = self.class.delete_item!(
|
|
512
515
|
project_number: project.number,
|
|
513
516
|
item_id: id,
|
|
514
517
|
)
|
|
@@ -518,7 +521,8 @@ module PlanMyStuff
|
|
|
518
521
|
deleted_id
|
|
519
522
|
end
|
|
520
523
|
|
|
521
|
-
# Assigns users to this item on its parent project.
|
|
524
|
+
# Assigns users to this item on its parent project. For draft items the underlying call uses the draft-issue
|
|
525
|
+
# assign mutation; for issue-backed items it delegates to +Issue.update!+ with +assignees:+.
|
|
522
526
|
#
|
|
523
527
|
# @param assignees [String, Array<String>] GitHub username(s)
|
|
524
528
|
#
|
|
@@ -527,7 +531,7 @@ module PlanMyStuff
|
|
|
527
531
|
def assign!(assignees, user: nil)
|
|
528
532
|
assignee_list = Array.wrap(assignees)
|
|
529
533
|
|
|
530
|
-
self.class.assign(
|
|
534
|
+
self.class.assign!(
|
|
531
535
|
number: number,
|
|
532
536
|
content_node_id: content_node_id,
|
|
533
537
|
assignees: assignee_list,
|
|
@@ -543,8 +547,8 @@ module PlanMyStuff
|
|
|
543
547
|
)
|
|
544
548
|
end
|
|
545
549
|
|
|
546
|
-
# Serializes the project item to a JSON-safe hash, excluding the back-reference
|
|
547
|
-
#
|
|
550
|
+
# Serializes the project item to a JSON-safe hash, excluding the back-reference to the parent project to prevent
|
|
551
|
+
# recursive serialization cycles.
|
|
548
552
|
#
|
|
549
553
|
# @return [Hash]
|
|
550
554
|
#
|
|
@@ -573,7 +577,7 @@ module PlanMyStuff
|
|
|
573
577
|
return super if @issue_assigned
|
|
574
578
|
return if draft?
|
|
575
579
|
|
|
576
|
-
self.issue =
|
|
580
|
+
self.issue = PlanMyStuff::Issue.find(number, repo: repo)
|
|
577
581
|
super
|
|
578
582
|
end
|
|
579
583
|
end
|
|
@@ -4,7 +4,7 @@ module PlanMyStuff
|
|
|
4
4
|
# Shared base for project-style metadata (regular and testing). Holds the
|
|
5
5
|
# +kind+ dispatch field and serialization hook. Not instantiated directly;
|
|
6
6
|
# use +ProjectMetadata+ or +TestingProjectMetadata+.
|
|
7
|
-
class BaseProjectMetadata < BaseMetadata
|
|
7
|
+
class BaseProjectMetadata < PlanMyStuff::BaseMetadata
|
|
8
8
|
# @return [String, nil] dispatch key: "project" or "testing"
|
|
9
9
|
attr_accessor :kind
|
|
10
10
|
|
data/lib/plan_my_stuff/client.rb
CHANGED
|
@@ -4,25 +4,63 @@ require 'active_support/core_ext/hash/keys'
|
|
|
4
4
|
require 'octokit'
|
|
5
5
|
|
|
6
6
|
module PlanMyStuff
|
|
7
|
-
# Infrastructure wrapper around Octokit. Handles auth, error normalization,
|
|
8
|
-
#
|
|
9
|
-
# internally via PlanMyStuff.client.
|
|
7
|
+
# Infrastructure wrapper around Octokit. Handles auth, error normalization, and repo resolution. Domain modules
|
|
8
|
+
# (Issues, Projects, etc.) use this internally via PlanMyStuff.client.
|
|
10
9
|
class Client
|
|
11
10
|
# @return [Octokit::Client]
|
|
12
11
|
attr_reader :octokit
|
|
13
12
|
|
|
14
|
-
# Returns the Faraday response from the most recent Octokit call.
|
|
15
|
-
#
|
|
16
|
-
# that aren't included in the parsed response body.
|
|
13
|
+
# Returns the Faraday response from the most recent Octokit call. Useful for reading headers (e.g. `ETag`,
|
|
14
|
+
# `X-RateLimit-Remaining`) that aren't included in the parsed response body.
|
|
17
15
|
#
|
|
18
16
|
# @return [Faraday::Response, nil]
|
|
19
17
|
delegate :last_response, to: :octokit
|
|
20
18
|
|
|
19
|
+
class << self
|
|
20
|
+
# Activates trace mode: every +rest+ and +graphql+ call is recorded into +traced_requests+ until
|
|
21
|
+
# +exit_trace_mode!+ is called. Useful for debugging which GitHub calls a code path makes.
|
|
22
|
+
#
|
|
23
|
+
# @return [void]
|
|
24
|
+
#
|
|
25
|
+
def trace_mode!
|
|
26
|
+
@trace_mode = true
|
|
27
|
+
@traced_requests = []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Disables trace mode and clears any recorded requests.
|
|
31
|
+
#
|
|
32
|
+
# @return [void]
|
|
33
|
+
#
|
|
34
|
+
def exit_trace_mode!
|
|
35
|
+
@trace_mode = false
|
|
36
|
+
@traced_requests = []
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Boolean]
|
|
40
|
+
def trace_mode?
|
|
41
|
+
@trace_mode == true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Array<Hash>]
|
|
45
|
+
def traced_requests
|
|
46
|
+
@traced_requests ||= []
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @raise [PlanMyStuff::ConfigurationError] if +import_access_token+ is blank when +importing: true+
|
|
51
|
+
#
|
|
21
52
|
# @return [Client]
|
|
22
|
-
|
|
53
|
+
#
|
|
54
|
+
def initialize(importing: false)
|
|
23
55
|
PlanMyStuff.configuration.validate!
|
|
24
56
|
|
|
25
|
-
|
|
57
|
+
access_token = importing ? PlanMyStuff.configuration.import_access_token : PlanMyStuff.configuration.access_token
|
|
58
|
+
|
|
59
|
+
if importing && access_token.blank?
|
|
60
|
+
raise(PlanMyStuff::ConfigurationError, 'Import access token is required for import client but not configured')
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
@octokit = Octokit::Client.new(access_token: access_token)
|
|
26
64
|
end
|
|
27
65
|
|
|
28
66
|
# Delegates a REST API call to Octokit, normalizing errors.
|
|
@@ -33,16 +71,10 @@ module PlanMyStuff
|
|
|
33
71
|
#
|
|
34
72
|
# @return [Object] Octokit response
|
|
35
73
|
#
|
|
36
|
-
def rest(method,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
else
|
|
40
|
-
octokit.public_send(method, *, **kwargs, &)
|
|
74
|
+
def rest(method, *args, **kwargs, &block)
|
|
75
|
+
trace!(:rest, method: method, args: args, kwargs: kwargs) do
|
|
76
|
+
execute_rest!(method, *args, **kwargs, &block)
|
|
41
77
|
end
|
|
42
|
-
rescue Octokit::TooManyRequests => e
|
|
43
|
-
raise_rate_limit_error(e)
|
|
44
|
-
rescue Octokit::ClientError, Octokit::ServerError => e
|
|
45
|
-
raise(APIError.new(e.message, status: e.respond_to?(:response_status) ? e.response_status : nil))
|
|
46
78
|
end
|
|
47
79
|
|
|
48
80
|
# Executes a GraphQL query against GitHub's /graphql endpoint.
|
|
@@ -53,26 +85,9 @@ module PlanMyStuff
|
|
|
53
85
|
# @return [Hash] parsed response data
|
|
54
86
|
#
|
|
55
87
|
def graphql(query, variables: {})
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
response = octokit.post('/graphql', payload.to_json)
|
|
60
|
-
data =
|
|
61
|
-
if response.is_a?(Hash)
|
|
62
|
-
response
|
|
63
|
-
else
|
|
64
|
-
(response.respond_to?(:to_h) ? response.to_h : response)
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
data = data.deep_symbolize_keys if data.respond_to?(:deep_symbolize_keys)
|
|
68
|
-
|
|
69
|
-
check_graphql_errors!(data)
|
|
70
|
-
|
|
71
|
-
data[:data]
|
|
72
|
-
rescue Octokit::TooManyRequests => e
|
|
73
|
-
raise_rate_limit_error(e)
|
|
74
|
-
rescue Octokit::ClientError, Octokit::ServerError => e
|
|
75
|
-
raise(APIError.new(e.message, status: e.respond_to?(:response_status) ? e.response_status : nil))
|
|
88
|
+
trace!(:graphql, query: query, variables: variables) do
|
|
89
|
+
execute_graphql!(query, variables)
|
|
90
|
+
end
|
|
76
91
|
end
|
|
77
92
|
|
|
78
93
|
# Resolves a repo param to a full "Org/Repo" string.
|
|
@@ -81,17 +96,90 @@ module PlanMyStuff
|
|
|
81
96
|
#
|
|
82
97
|
# @return [String] full repo path (e.g. "BrandsInsurance/Element")
|
|
83
98
|
#
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def resolve_repo(repo = nil)
|
|
87
|
-
PlanMyStuff::Repo.resolve(repo).full_name
|
|
99
|
+
def resolve_repo!(repo = nil)
|
|
100
|
+
PlanMyStuff::Repo.resolve!(repo).full_name
|
|
88
101
|
end
|
|
89
102
|
|
|
90
103
|
private
|
|
91
104
|
|
|
92
|
-
# @
|
|
105
|
+
# @raise [PlanMyStuff::APIError] if Octokit call fails with client or server error
|
|
106
|
+
#
|
|
107
|
+
# @return [Object]
|
|
108
|
+
#
|
|
109
|
+
def execute_rest!(method, *, **kwargs, &)
|
|
110
|
+
if kwargs.empty?
|
|
111
|
+
octokit.public_send(method, *, &)
|
|
112
|
+
else
|
|
113
|
+
octokit.public_send(method, *, **kwargs, &)
|
|
114
|
+
end
|
|
115
|
+
rescue Octokit::TooManyRequests => e
|
|
116
|
+
raise_rate_limit_error!(e)
|
|
117
|
+
rescue Octokit::ClientError, Octokit::ServerError => e
|
|
118
|
+
raise(PlanMyStuff::APIError.new(e.message, status: e.respond_to?(:response_status) ? e.response_status : nil))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @raise [PlanMyStuff::APIError] if Octokit call fails with client or server error
|
|
122
|
+
#
|
|
123
|
+
# @return [Hash]
|
|
124
|
+
#
|
|
125
|
+
def execute_graphql!(query, variables)
|
|
126
|
+
payload = { query: query }
|
|
127
|
+
payload[:variables] = variables unless variables.empty?
|
|
128
|
+
|
|
129
|
+
response = octokit.post('/graphql', payload.to_json)
|
|
130
|
+
data =
|
|
131
|
+
if response.is_a?(Hash)
|
|
132
|
+
response
|
|
133
|
+
else
|
|
134
|
+
(response.respond_to?(:to_h) ? response.to_h : response)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
data = data.deep_symbolize_keys if data.respond_to?(:deep_symbolize_keys)
|
|
138
|
+
|
|
139
|
+
check_graphql_errors!(data)
|
|
140
|
+
|
|
141
|
+
data[:data]
|
|
142
|
+
rescue Octokit::TooManyRequests => e
|
|
143
|
+
raise_rate_limit_error!(e)
|
|
144
|
+
rescue Octokit::ClientError, Octokit::ServerError => e
|
|
145
|
+
raise(PlanMyStuff::APIError.new(e.message, status: e.respond_to?(:response_status) ? e.response_status : nil))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Records the wrapped call into +Client.traced_requests+ when trace mode is on; otherwise just yields.
|
|
93
149
|
#
|
|
94
|
-
# @
|
|
150
|
+
# @param kind [Symbol] :rest or :graphql
|
|
151
|
+
# @param details [Hash] call-specific details (method/args/query/variables)
|
|
152
|
+
#
|
|
153
|
+
# @return [Object]
|
|
154
|
+
#
|
|
155
|
+
def trace!(kind, **details)
|
|
156
|
+
return yield unless self.class.trace_mode?
|
|
157
|
+
|
|
158
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
159
|
+
begin
|
|
160
|
+
result = yield
|
|
161
|
+
record_trace(kind, start, details)
|
|
162
|
+
result
|
|
163
|
+
rescue => e
|
|
164
|
+
record_trace(kind, start, details, error: e)
|
|
165
|
+
raise
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @return [void]
|
|
170
|
+
def record_trace(kind, start, details, error: nil)
|
|
171
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1_000).round(2)
|
|
172
|
+
entry = { kind: kind, duration_ms: duration_ms, status: octokit.last_response&.status, **details }
|
|
173
|
+
if error
|
|
174
|
+
entry[:error] = error.class.name
|
|
175
|
+
entry[:error_message] = error.message
|
|
176
|
+
end
|
|
177
|
+
self.class.traced_requests << entry
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @raise [PlanMyStuff::GraphQLError] if response contains errors
|
|
181
|
+
#
|
|
182
|
+
# @param data [Hash] parsed GraphQL response
|
|
95
183
|
#
|
|
96
184
|
# @return [void]
|
|
97
185
|
#
|
|
@@ -100,16 +188,16 @@ module PlanMyStuff
|
|
|
100
188
|
return if errors.blank?
|
|
101
189
|
|
|
102
190
|
messages = errors.filter_map { |e| e[:message] }
|
|
103
|
-
raise(GraphQLError.new(messages.join('; '), errors: errors))
|
|
191
|
+
raise(PlanMyStuff::GraphQLError.new(messages.join('; '), errors: errors))
|
|
104
192
|
end
|
|
105
193
|
|
|
106
|
-
# @
|
|
194
|
+
# @raise [PlanMyStuff::RateLimitError] if GitHub has been rate limited
|
|
107
195
|
#
|
|
108
|
-
# @
|
|
196
|
+
# @param exception [Octokit::TooManyRequests]
|
|
109
197
|
#
|
|
110
|
-
def raise_rate_limit_error(exception)
|
|
198
|
+
def raise_rate_limit_error!(exception)
|
|
111
199
|
retry_after = parse_retry_after(exception)
|
|
112
|
-
raise(RateLimitError.new(exception.message, retry_after: retry_after))
|
|
200
|
+
raise(PlanMyStuff::RateLimitError.new(exception.message, retry_after: retry_after))
|
|
113
201
|
end
|
|
114
202
|
|
|
115
203
|
# @param exception [Octokit::TooManyRequests]
|