plan_my_stuff 0.7.0 → 0.9.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 +46 -1
- data/CONFIGURATION.md +351 -0
- 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 +3 -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 +52 -14
- 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 +62 -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 +59 -57
- data/lib/plan_my_stuff/comment_metadata.rb +1 -1
- data/lib/plan_my_stuff/configuration.rb +93 -93
- 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 +471 -333
- data/lib/plan_my_stuff/issue_metadata.rb +10 -10
- data/lib/plan_my_stuff/label.rb +34 -18
- 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 +4 -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")
|
|
@@ -74,6 +73,7 @@ module PlanMyStuff
|
|
|
74
73
|
repo: item_hash[:repo],
|
|
75
74
|
state: item_hash[:state],
|
|
76
75
|
status: item_hash[:status],
|
|
76
|
+
updated_at: item_hash[:updated_at],
|
|
77
77
|
field_values: item_hash[:field_values] || {},
|
|
78
78
|
project: project,
|
|
79
79
|
)
|
|
@@ -101,9 +101,9 @@ module PlanMyStuff
|
|
|
101
101
|
def create!(issue_or_title, draft: false, body: nil, project_number: nil, user: nil)
|
|
102
102
|
item =
|
|
103
103
|
if draft
|
|
104
|
-
add_draft_item(title: issue_or_title, body: body, project_number: project_number)
|
|
104
|
+
add_draft_item!(title: issue_or_title, body: body, project_number: project_number)
|
|
105
105
|
else
|
|
106
|
-
add_item(issue: issue_or_title, project_number: project_number)
|
|
106
|
+
add_item!(issue: issue_or_title, project_number: project_number)
|
|
107
107
|
end
|
|
108
108
|
|
|
109
109
|
PlanMyStuff::Notifications.instrument('project_item.added', item, user: user)
|
|
@@ -118,12 +118,12 @@ module PlanMyStuff
|
|
|
118
118
|
#
|
|
119
119
|
# @return [Hash] the updated item
|
|
120
120
|
#
|
|
121
|
-
def move_item(item_id:, status:, project_number: nil)
|
|
122
|
-
project_number = resolve_default_project_number(project_number)
|
|
121
|
+
def move_item!(item_id:, status:, project_number: nil)
|
|
122
|
+
project_number = resolve_default_project_number!(project_number)
|
|
123
123
|
project = BaseProject.find(project_number)
|
|
124
124
|
|
|
125
125
|
status_field = project.status_field
|
|
126
|
-
option_id = resolve_status_option_id(status_field, status)
|
|
126
|
+
option_id = resolve_status_option_id!(status_field, status)
|
|
127
127
|
|
|
128
128
|
PlanMyStuff.client.graphql(
|
|
129
129
|
PlanMyStuff::GraphQL::Queries::UPDATE_SINGLE_SELECT_FIELD,
|
|
@@ -146,11 +146,11 @@ module PlanMyStuff
|
|
|
146
146
|
# @return [Hash] mutation result
|
|
147
147
|
#
|
|
148
148
|
def update_single_select_field!(item_id:, field_name:, value:, project_number: nil)
|
|
149
|
-
project_number = resolve_default_project_number(project_number)
|
|
149
|
+
project_number = resolve_default_project_number!(project_number)
|
|
150
150
|
project = BaseProject.find(project_number)
|
|
151
151
|
|
|
152
|
-
field = resolve_single_select_field(project, field_name)
|
|
153
|
-
option_id = resolve_status_option_id(field, value)
|
|
152
|
+
field = resolve_single_select_field!(project, field_name)
|
|
153
|
+
option_id = resolve_status_option_id!(field, value)
|
|
154
154
|
|
|
155
155
|
PlanMyStuff.client.graphql(
|
|
156
156
|
PlanMyStuff::GraphQL::Queries::UPDATE_SINGLE_SELECT_FIELD,
|
|
@@ -173,10 +173,10 @@ module PlanMyStuff
|
|
|
173
173
|
# @return [Hash] mutation result
|
|
174
174
|
#
|
|
175
175
|
def update_field!(item_id:, field_name:, value:, project_number: nil)
|
|
176
|
-
project_number = resolve_default_project_number(project_number)
|
|
176
|
+
project_number = resolve_default_project_number!(project_number)
|
|
177
177
|
project = BaseProject.find(project_number)
|
|
178
178
|
|
|
179
|
-
field = resolve_text_field(project, field_name)
|
|
179
|
+
field = resolve_text_field!(project, field_name)
|
|
180
180
|
|
|
181
181
|
PlanMyStuff.client.graphql(
|
|
182
182
|
PlanMyStuff::GraphQL::Queries::UPDATE_TEXT_FIELD,
|
|
@@ -199,10 +199,10 @@ module PlanMyStuff
|
|
|
199
199
|
# @return [Hash] mutation result
|
|
200
200
|
#
|
|
201
201
|
def update_date_field!(item_id:, field_name:, date:, project_number: nil)
|
|
202
|
-
project_number = resolve_default_project_number(project_number)
|
|
202
|
+
project_number = resolve_default_project_number!(project_number)
|
|
203
203
|
project = BaseProject.find(project_number)
|
|
204
204
|
|
|
205
|
-
field = resolve_text_field(project, field_name)
|
|
205
|
+
field = resolve_text_field!(project, field_name)
|
|
206
206
|
|
|
207
207
|
PlanMyStuff.client.graphql(
|
|
208
208
|
PlanMyStuff::GraphQL::Queries::UPDATE_DATE_FIELD,
|
|
@@ -215,18 +215,18 @@ module PlanMyStuff
|
|
|
215
215
|
)
|
|
216
216
|
end
|
|
217
217
|
|
|
218
|
-
# Deletes a project item from its parent project. Returns the
|
|
219
|
-
#
|
|
218
|
+
# Deletes a project item from its parent project. Returns the +deletedItemId+ from the GraphQL response on
|
|
219
|
+
# success.
|
|
220
|
+
#
|
|
221
|
+
# @raise [PlanMyStuff::APIError] if the GraphQL mutation fails
|
|
220
222
|
#
|
|
221
223
|
# @param item_id [String] project item ID (e.g. "PVTI_...")
|
|
222
224
|
# @param project_number [Integer, nil] defaults to config.default_project_number
|
|
223
225
|
#
|
|
224
226
|
# @return [String] the deleted item ID echoed back by GitHub
|
|
225
227
|
#
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
def delete_item(item_id:, project_number: nil)
|
|
229
|
-
project_number = resolve_default_project_number(project_number)
|
|
228
|
+
def delete_item!(item_id:, project_number: nil)
|
|
229
|
+
project_number = resolve_default_project_number!(project_number)
|
|
230
230
|
org = PlanMyStuff.configuration.organization
|
|
231
231
|
project_id = resolve_project_id(org, project_number)
|
|
232
232
|
|
|
@@ -241,8 +241,9 @@ module PlanMyStuff
|
|
|
241
241
|
deleted_id
|
|
242
242
|
end
|
|
243
243
|
|
|
244
|
-
# Assigns users to a project item.
|
|
245
|
-
#
|
|
244
|
+
# Assigns users to a project item. Issues/PRs use REST via Issue.update!, drafts use GraphQL.
|
|
245
|
+
#
|
|
246
|
+
# @raise [PlanMyStuff::APIError] if a username cannot be resolved or the GraphQL mutation fails
|
|
246
247
|
#
|
|
247
248
|
# @param number [Integer, nil] issue number (nil for drafts)
|
|
248
249
|
# @param content_node_id [String] node ID of the underlying content
|
|
@@ -252,7 +253,7 @@ module PlanMyStuff
|
|
|
252
253
|
#
|
|
253
254
|
# @return [void]
|
|
254
255
|
#
|
|
255
|
-
def assign(number:, content_node_id:, assignees:, draft: false, repo: nil)
|
|
256
|
+
def assign!(number:, content_node_id:, assignees:, draft: false, repo: nil)
|
|
256
257
|
if draft
|
|
257
258
|
client = PlanMyStuff.client
|
|
258
259
|
user_ids = assignees.map do |assignee|
|
|
@@ -262,7 +263,7 @@ module PlanMyStuff
|
|
|
262
263
|
)
|
|
263
264
|
user_id = user_data.dig(:user, :id)
|
|
264
265
|
|
|
265
|
-
raise(APIError, "GitHub user not found: #{assignee}") if user_id.nil?
|
|
266
|
+
raise(PlanMyStuff::APIError, "GitHub user not found: #{assignee}") if user_id.nil?
|
|
266
267
|
|
|
267
268
|
user_id
|
|
268
269
|
end
|
|
@@ -272,7 +273,7 @@ module PlanMyStuff
|
|
|
272
273
|
variables: { draftIssueId: content_node_id, assigneeIds: user_ids },
|
|
273
274
|
)
|
|
274
275
|
else
|
|
275
|
-
Issue.update!(number: number, repo: repo, assignees: assignees)
|
|
276
|
+
PlanMyStuff::Issue.update!(number: number, repo: repo, assignees: assignees)
|
|
276
277
|
end
|
|
277
278
|
end
|
|
278
279
|
|
|
@@ -285,8 +286,8 @@ module PlanMyStuff
|
|
|
285
286
|
#
|
|
286
287
|
# @return [PlanMyStuff::BaseProjectItem]
|
|
287
288
|
#
|
|
288
|
-
def add_item(issue:, project_number:)
|
|
289
|
-
project_number = resolve_default_project_number(project_number)
|
|
289
|
+
def add_item!(issue:, project_number:)
|
|
290
|
+
project_number = resolve_default_project_number!(project_number)
|
|
290
291
|
client = PlanMyStuff.client
|
|
291
292
|
org = PlanMyStuff.configuration.organization
|
|
292
293
|
|
|
@@ -329,8 +330,8 @@ module PlanMyStuff
|
|
|
329
330
|
#
|
|
330
331
|
# @return [PlanMyStuff::BaseProjectItem]
|
|
331
332
|
#
|
|
332
|
-
def add_draft_item(title:, body:, project_number:)
|
|
333
|
-
project_number = resolve_default_project_number(project_number)
|
|
333
|
+
def add_draft_item!(title:, body:, project_number:)
|
|
334
|
+
project_number = resolve_default_project_number!(project_number)
|
|
334
335
|
org = PlanMyStuff.configuration.organization
|
|
335
336
|
project_id = resolve_project_id(org, project_number)
|
|
336
337
|
|
|
@@ -363,23 +364,25 @@ module PlanMyStuff
|
|
|
363
364
|
)
|
|
364
365
|
end
|
|
365
366
|
|
|
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)
|
|
367
|
+
# @see PlanMyStuff::Project.resolve_default_project_number!
|
|
368
|
+
def resolve_default_project_number!(project_number)
|
|
369
|
+
PlanMyStuff::Project.resolve_default_project_number!(project_number)
|
|
369
370
|
end
|
|
370
371
|
|
|
371
|
-
# Finds a text field by name on a project. Raises ArgumentError if
|
|
372
|
-
#
|
|
372
|
+
# Finds a text field by name on a project. Raises ArgumentError if the field is not found or is a single-select
|
|
373
|
+
# field.
|
|
374
|
+
#
|
|
375
|
+
# @raise [ArgumentError] if the field is not found or is a single-select field
|
|
373
376
|
#
|
|
374
377
|
# @param project [PlanMyStuff::BaseProject]
|
|
375
378
|
# @param field_name [String]
|
|
376
379
|
#
|
|
377
380
|
# @return [Hash]
|
|
378
381
|
#
|
|
379
|
-
def resolve_text_field(project, field_name)
|
|
382
|
+
def resolve_text_field!(project, field_name)
|
|
380
383
|
field = project.fields.find { |f| f[:name] == field_name && f[:options].nil? }
|
|
381
384
|
|
|
382
|
-
|
|
385
|
+
if field.nil?
|
|
383
386
|
available = project.fields.select { |f| f[:options].nil? }.pluck(:name).join(', ')
|
|
384
387
|
raise(ArgumentError, "Unknown text field '#{field_name}'. Available: #{available}")
|
|
385
388
|
end
|
|
@@ -387,15 +390,17 @@ module PlanMyStuff
|
|
|
387
390
|
field
|
|
388
391
|
end
|
|
389
392
|
|
|
393
|
+
# @raise [ArgumentError] if the field is not found or is not a single-select field
|
|
394
|
+
#
|
|
390
395
|
# @param project [PlanMyStuff::BaseProject]
|
|
391
396
|
# @param field_name [String]
|
|
392
397
|
#
|
|
393
398
|
# @return [Hash]
|
|
394
399
|
#
|
|
395
|
-
def resolve_single_select_field(project, field_name)
|
|
400
|
+
def resolve_single_select_field!(project, field_name)
|
|
396
401
|
field = project.fields.find { |f| f[:name] == field_name && f[:options].present? }
|
|
397
402
|
|
|
398
|
-
|
|
403
|
+
if field.nil?
|
|
399
404
|
available = project.fields.select { |f| f[:options].present? }.pluck(:name).join(', ')
|
|
400
405
|
raise(ArgumentError, "Unknown single-select field '#{field_name}'. Available: #{available}")
|
|
401
406
|
end
|
|
@@ -405,15 +410,17 @@ module PlanMyStuff
|
|
|
405
410
|
|
|
406
411
|
# Resolves a status name to its option ID.
|
|
407
412
|
#
|
|
413
|
+
# @raise [ArgumentError] if +status_name+ is not a valid option for +status_field+
|
|
414
|
+
#
|
|
408
415
|
# @param status_field [Hash]
|
|
409
416
|
# @param status_name [String]
|
|
410
417
|
#
|
|
411
418
|
# @return [String]
|
|
412
419
|
#
|
|
413
|
-
def resolve_status_option_id(status_field, status_name)
|
|
420
|
+
def resolve_status_option_id!(status_field, status_name)
|
|
414
421
|
option = status_field[:options].find { |o| o[:name] == status_name }
|
|
415
422
|
|
|
416
|
-
|
|
423
|
+
if option.nil?
|
|
417
424
|
available = status_field[:options].pluck(:name).join(', ')
|
|
418
425
|
raise(ArgumentError, "Unknown status '#{status_name}'. Available: #{available}")
|
|
419
426
|
end
|
|
@@ -446,7 +453,7 @@ module PlanMyStuff
|
|
|
446
453
|
#
|
|
447
454
|
def move_to!(status, user: nil)
|
|
448
455
|
previous_status = self.status
|
|
449
|
-
result = self.class.move_item(
|
|
456
|
+
result = self.class.move_item!(
|
|
450
457
|
project_number: project.number,
|
|
451
458
|
item_id: id,
|
|
452
459
|
status: status,
|
|
@@ -495,20 +502,17 @@ module PlanMyStuff
|
|
|
495
502
|
)
|
|
496
503
|
end
|
|
497
504
|
|
|
498
|
-
# Deletes this item from its parent project. Marks the in-memory
|
|
499
|
-
#
|
|
500
|
-
# returns false.
|
|
505
|
+
# Deletes this item from its parent project. Marks the in-memory instance as destroyed so +destroyed?+ returns
|
|
506
|
+
# true and +persisted?+ returns false.
|
|
501
507
|
#
|
|
502
508
|
# No-op if the instance is already destroyed.
|
|
503
509
|
#
|
|
504
510
|
# @return [String, nil] the deleted item ID, or nil if already destroyed
|
|
505
511
|
#
|
|
506
|
-
# @raise [PlanMyStuff::APIError] if the GraphQL mutation fails
|
|
507
|
-
#
|
|
508
512
|
def destroy!(user: nil)
|
|
509
513
|
return if destroyed?
|
|
510
514
|
|
|
511
|
-
deleted_id = self.class.delete_item(
|
|
515
|
+
deleted_id = self.class.delete_item!(
|
|
512
516
|
project_number: project.number,
|
|
513
517
|
item_id: id,
|
|
514
518
|
)
|
|
@@ -518,7 +522,8 @@ module PlanMyStuff
|
|
|
518
522
|
deleted_id
|
|
519
523
|
end
|
|
520
524
|
|
|
521
|
-
# Assigns users to this item on its parent project.
|
|
525
|
+
# Assigns users to this item on its parent project. For draft items the underlying call uses the draft-issue
|
|
526
|
+
# assign mutation; for issue-backed items it delegates to +Issue.update!+ with +assignees:+.
|
|
522
527
|
#
|
|
523
528
|
# @param assignees [String, Array<String>] GitHub username(s)
|
|
524
529
|
#
|
|
@@ -527,7 +532,7 @@ module PlanMyStuff
|
|
|
527
532
|
def assign!(assignees, user: nil)
|
|
528
533
|
assignee_list = Array.wrap(assignees)
|
|
529
534
|
|
|
530
|
-
self.class.assign(
|
|
535
|
+
self.class.assign!(
|
|
531
536
|
number: number,
|
|
532
537
|
content_node_id: content_node_id,
|
|
533
538
|
assignees: assignee_list,
|
|
@@ -543,8 +548,8 @@ module PlanMyStuff
|
|
|
543
548
|
)
|
|
544
549
|
end
|
|
545
550
|
|
|
546
|
-
# Serializes the project item to a JSON-safe hash, excluding the back-reference
|
|
547
|
-
#
|
|
551
|
+
# Serializes the project item to a JSON-safe hash, excluding the back-reference to the parent project to prevent
|
|
552
|
+
# recursive serialization cycles.
|
|
548
553
|
#
|
|
549
554
|
# @return [Hash]
|
|
550
555
|
#
|
|
@@ -573,7 +578,7 @@ module PlanMyStuff
|
|
|
573
578
|
return super if @issue_assigned
|
|
574
579
|
return if draft?
|
|
575
580
|
|
|
576
|
-
self.issue =
|
|
581
|
+
self.issue = PlanMyStuff::Issue.find(number, repo: repo)
|
|
577
582
|
super
|
|
578
583
|
end
|
|
579
584
|
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]
|