diff --git a/app/assets/javascripts/roster_setup.js b/app/assets/javascripts/roster_setup.js new file mode 100644 index 0000000000..b01606f8a8 --- /dev/null +++ b/app/assets/javascripts/roster_setup.js @@ -0,0 +1,109 @@ +(function(){ + var PROGRESS_HALF_LIFE = 1000; + var setup_roster_update_cable, + progress_asymptotically, + display_message, + set_progress, + display_progress_bar, + initialize_progress; + + var progress_complete = false; + setup_roster_update_cable = function(){ + var roster_id = $("#current_roster_id").val(); + var user_id = $("#current_user_id").val(); + App.add_students_to_roster = App.cable.subscriptions.create({ + channel: "AddStudentsToRosterChannel", + roster_id: roster_id, + user_id: user_id + }, + { + connected: function() { + // Called when the subscription is ready for use on the server + }, + + disconnected: function() { + // Called when the subscription has been terminated by the server + }, + + received: function(data) { + // Called when there's incoming data on the websocket for this channel + if(data.status == "completed"){ + progress_complete = true; + set_progress(100); + display_message(data.message); + } + } + }); + }; + + progress_asymptotically = function() { + recursive_progress_asymptotically = function(recursive_callback, counter) { + var progress; + var remaining = 100/counter; + progress = 100 - (100/counter); + $(document) + .find(".roster-update-progress-bar") + .animate( + { width: progress.toFixed() + "%" }, + { duration: PROGRESS_HALF_LIFE * counter } + ); + setTimeout(function() { + if(!progress_complete){ + recursive_callback(recursive_callback, counter + 1); + } + }, + PROGRESS_HALF_LIFE * counter + ); + }; + recursive_progress_asymptotically(recursive_progress_asymptotically, 1); +}; + +display_message = function(message){ + $(".roster-update-message").removeAttr("hidden"); + $("#roster-progress").text(message); +}; + +set_progress = function(percent) { + $(".roster-update-progress-bar").stop(true, false); + if (percent === 0) { + $(".roster-update-progress-bar").css("width", 0); + } else if (percent) { + $(".roster-update-progress-bar").animate({width: percent + "%"}); + } +}; + +toggle_roster_form = function(disable_fields){ + var text_area = $("#entries-field"); + var csv_button = $("#file-upload"); + if(disable_fields){ + text_area.attr("disabled", "disabled"); + csv_button.addClass("disabled"); + }else{ + text_area.removeAttr("disabled"); + csv_button.removeClass("disabled"); + } +}; + +ready = (function(){ + $("#add-students-roster-form").on("ajax:beforeSend", function(){ + toggle_roster_form(true); + $('.roster-update-progress').removeAttr("hidden"); + progress_asymptotically(); + }); + + $("#add-students-roster-form").on("ajax:complete", function(){ + toggle_roster_form(false); + $("#entries-field").val(""); + }); + + $(document).on('closing', '[data-remodal-id=new-student-modal]', function (e) { + set_progress("0"); + $('.roster-update-progress').attr("hidden", "hidden"); + }); + + setup_roster_update_cable(); +}); + +$(document).ready(ready); + +}).call(this); diff --git a/app/channels/add_students_to_roster_channel.rb b/app/channels/add_students_to_roster_channel.rb new file mode 100644 index 0000000000..ae5dcdda76 --- /dev/null +++ b/app/channels/add_students_to_roster_channel.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddStudentsToRosterChannel < ApplicationCable::Channel + def self.channel(user_id:, roster_id:) + "#{channel_name}_#{user_id}_#{roster_id}" + end + + def subscribed + stream_from self.class.channel(roster_id: params[:roster_id], user_id: current_user.id) + end + + def unsubscribed + # Any cleanup needed when channel is unsubscribed + end +end diff --git a/app/controllers/orgs/rosters_controller.rb b/app/controllers/orgs/rosters_controller.rb index 6d39dd0e67..d8787a73dc 100644 --- a/app/controllers/orgs/rosters_controller.rb +++ b/app/controllers/orgs/rosters_controller.rb @@ -21,6 +21,7 @@ class RostersController < Orgs::Controller depends_on :google_classroom # rubocop:disable AbcSize + # rubocop:disable MethodLength def show @google_course_name = current_organization_google_course_name @@ -34,9 +35,13 @@ def show .order(:id) .page(params[:unlinked_users_page]) + # Used for displaying all roster entries in tabs + @roster_entries_count = current_roster.roster_entries.count + download_roster if params.dig("format") end # rubocop:enable AbcSize + # rubocop:enable MethodLength def new @roster = Roster.new @@ -129,38 +134,19 @@ def edit_entry redirect_to roster_path(current_organization, params: { roster_entries_page: params[:roster_entries_page] }) end - # rubocop:disable Metrics/MethodLength # rubocop:disable Metrics/AbcSize def add_students - if params[:lms_user_ids].is_a? String - params[:lms_user_ids] = params[:lms_user_ids].split - end - identifiers = params[:identifiers].split("\r\n").reject(&:blank?) - lms_ids = params[:lms_user_ids] || [] - - begin - entries = RosterEntry.create_entries( - identifiers: identifiers, - roster: current_roster, - lms_user_ids: lms_ids - ) - - if entries.empty? - flash[:warning] = "No students created." - elsif entries.length == identifiers.length - flash[:success] = "Students created." - imported_students_lms_statsd(lms_user_ids: params[:lms_user_ids]) - else - flash[:success] = "Students created. Some duplicates have been omitted." - imported_students_lms_statsd(lms_user_ids: params[:lms_user_ids]) - end - rescue RosterEntry::IdentifierCreationError - flash[:error] = "An error has occurred. Please try again." - end + params[:lms_user_ids].split! if params[:lms_user_ids].is_a? String + lms_user_ids = Array.wrap(params[:lms_user_ids]) - redirect_to roster_path(current_organization) + identifiers = params[:identifiers].split("\r\n").reject(&:blank?) + job_info = AddStudentsToRosterJob.perform_later(identifiers, current_roster, current_user, lms_user_ids) + render json: { job_info: job_info } end + # rubocop:enable Metrics/AbcSize + # rubocop:disable Metrics/MethodLength + # rubocop:disable Metrics/AbcSize def download_roster grouping = current_organization.groupings.find(params[:grouping]) if params[:grouping] @@ -177,7 +163,6 @@ def download_roster end end end - # rubocop:enable Metrics/MethodLength # rubocop:enable Metrics/AbcSize @@ -194,7 +179,7 @@ def create_statsd(lms_user_ids:) end def imported_students_lms_statsd(lms_user_ids:) - return if lms_user_ids.nil? + return if lms_user_ids.blank? GitHubClassroom.statsd.increment("roster_entries.lms_imported", by: lms_user_ids.length) end diff --git a/app/jobs/add_students_to_roster_job.rb b/app/jobs/add_students_to_roster_job.rb new file mode 100644 index 0000000000..6a0b790a61 --- /dev/null +++ b/app/jobs/add_students_to_roster_job.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class AddStudentsToRosterJob < ApplicationJob + queue_as :roster + + ROSTER_UPDATE_SUCCESSFUL = "Roster successfully updated." + ROSTER_UPDATE_FAILED = "Could not add any students to roster, please try again." + ROSTER_UPDATE_PARTIAL_SUCCESS = "Could not add following students:" + + # Takes an array of identifiers and creates a + # roster entry for each. Omits duplicates, and + # + # rubocop:disable AbcSize + # rubocop:disable MethodLength + def perform(identifiers, roster, user, lms_user_ids = []) + channel = AddStudentsToRosterChannel.channel(roster_id: roster.id, user_id: user.id) + + identifiers = add_suffix_to_duplicates!(identifiers, roster) + invalid_roster_entries = + identifiers.zip(lms_user_ids).map do |identifier, lms_user_id| + roster_entry = RosterEntry.create(identifier: identifier, roster: roster, lms_user_id: lms_user_id) + roster_entry.identifier if roster_entry.errors.include?(:identifier) + end.compact! + + message = build_message(invalid_roster_entries, identifiers) + entries_created = identifiers.count - invalid_roster_entries.count + if lms_user_ids.present? && entries_created.positive? + GitHubClassroom.statsd.increment("roster_entries.lms_imported", by: entries_created) + end + ActionCable.server.broadcast(channel, message: message, status: "completed") + end + # rubocop:enable AbcSize + # rubocop:enable MethodLength + + def add_suffix_to_duplicates!(identifiers, roster) + existing_roster_entries = RosterEntry.where(roster: roster).pluck(:identifier) + RosterEntry.add_suffix_to_duplicates( + identifiers: identifiers, + existing_roster_entries: existing_roster_entries + ) + end + + # rubocop:disable MethodLength + def build_message(invalid_roster_entries, identifiers) + if invalid_roster_entries.empty? + ROSTER_UPDATE_SUCCESSFUL + " #{identifiers.count} roster #{'entries'.pluralize(identifiers.count)} were added." + elsif invalid_roster_entries.size == identifiers.size + ROSTER_UPDATE_FAILED + else + formatted_students = + invalid_roster_entries.map do |invalid_roster_entry| + "#{invalid_roster_entry} \n" + end.join("") + "#{ROSTER_UPDATE_PARTIAL_SUCCESS} \n#{formatted_students}" + end + end + # rubocop:enable MethodLength +end diff --git a/app/models/roster_entry.rb b/app/models/roster_entry.rb index cb06245c66..935e9dec53 100644 --- a/app/models/roster_entry.rb +++ b/app/models/roster_entry.rb @@ -112,35 +112,4 @@ def self.students_not_on_team(group_assignment) .uniq where(user_id: nil).or(where.not(user_id: students_on_team)) end - - # Takes an array of identifiers and creates a - # roster entry for each. Omits duplicates, and - # raises IdentifierCreationError if there is an - # error. - # - # Returns the created entries. - - # rubocop:disable Metrics/MethodLength - def self.create_entries(identifiers:, roster:, lms_user_ids: []) - created_entries = [] - RosterEntry.transaction do - identifiers = add_suffix_to_duplicates( - identifiers: identifiers, - existing_roster_entries: RosterEntry.where(roster: roster).pluck(:identifier) - ) - - identifiers.zip(lms_user_ids).each do |identifier, lms_user_id| - roster_entry = RosterEntry.create(identifier: identifier, roster: roster, lms_user_id: lms_user_id) - - if !roster_entry.persisted? - raise IdentifierCreationError unless roster_entry.errors.include?(:identifier) - else - created_entries << roster_entry - end - end - end - - created_entries - end - # rubocop:enable Metrics/MethodLength, Metrics/AbcSize end diff --git a/app/views/orgs/rosters/_new_student_modal.html.erb b/app/views/orgs/rosters/_new_student_modal.html.erb index 105b281f38..e8cd62c0e1 100644 --- a/app/views/orgs/rosters/_new_student_modal.html.erb +++ b/app/views/orgs/rosters/_new_student_modal.html.erb @@ -4,6 +4,27 @@

Add students to your Roster

+ <%= hidden_field_tag 'user_id', current_user.id, id: "current_user_id" %> + <%= hidden_field_tag 'roster_id', roster.id, id: "current_roster_id" %> + + + +

Import students from your institution

GitHub Classroom is able to automatically import your roster from your institution. If you @@ -18,7 +39,7 @@

Manually add students

- <%= form_tag add_students_roster_path(current_organization), method: :patch do |f| %> + <%= form_tag add_students_roster_path(current_organization), method: :patch, remote: true, id: "add-students-roster-form" do |f| %>
Enter your list of students' identifiers, one per line.
@@ -32,8 +53,8 @@
-
- <%= submit_tag 'Add roster entries', class: 'btn btn-primary mr-3' %> +
+ <%= submit_tag 'Add roster entries', class: 'btn btn-primary mr-3', data: { "disable-with": "Updating..." } %>
<% end %>
diff --git a/app/views/orgs/rosters/show.html.erb b/app/views/orgs/rosters/show.html.erb index 1c3ced812a..61ff4d7fa4 100644 --- a/app/views/orgs/rosters/show.html.erb +++ b/app/views/orgs/rosters/show.html.erb @@ -29,8 +29,8 @@
diff --git a/config/sidekiq.yml b/config/sidekiq.yml index b6a1b3409d..91cd108b26 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -13,3 +13,4 @@ - [boom, 3] - [create_repository, 3] - [porter_status, 3] + - [roster, 3] diff --git a/spec/controllers/orgs/rosters_controller_spec.rb b/spec/controllers/orgs/rosters_controller_spec.rb index 75555ec82a..bada650f56 100644 --- a/spec/controllers/orgs/rosters_controller_spec.rb +++ b/spec/controllers/orgs/rosters_controller_spec.rb @@ -494,11 +494,13 @@ it "sends successfully" do expect(GitHubClassroom.statsd).to receive(:increment).with("roster_entries.lms_imported", by: 2) - patch :add_students, params: { - id: organization.slug, - identifiers: "a\r\nb", - lms_user_ids: [1, 2] - } + perform_enqueued_jobs do + patch :add_students, params: { + id: organization.slug, + identifiers: "a\r\nb", + lms_user_ids: [1, 2] + } + end end end @@ -510,96 +512,25 @@ it "sends successfully" do expect(GitHubClassroom.statsd).to_not receive(:increment).with("roster_entries.lms_imported", by: 2) - patch :add_students, params: { - id: organization.slug, - identifiers: "a\r\nb", - lms_user_ids: [1, 2] - } + perform_enqueued_jobs do + patch :add_students, params: { + id: organization.slug, + identifiers: "a\r\nb", + lms_user_ids: [1, 2] + } + end end end end context "when all identifiers are valid" do - before do - patch :add_students, params: { - id: organization.slug, - identifiers: "a\r\nb" - } - end - - it "redirects to rosters page" do - expect(response).to redirect_to(roster_url(organization)) - end - - it "sets success message" do - expect(flash[:success]).to eq("Students created.") - end - - it "creates the student on the roster" do - expect(roster.reload.roster_entries).to include(RosterEntry.find_by(identifier: "a")) - end - end - - context "when there are duplicate identifiers" do - before do - create(:roster_entry, roster: roster, identifier: "a") - create(:roster_entry, roster: roster, identifier: "b") - patch :add_students, params: { - id: organization.slug, - identifiers: "a\r\nb" - } - end - - it "redirects to rosters page" do - expect(response).to redirect_to(roster_url(organization)) - end - - it "sets flash message" do - expect(flash[:success]).to eq("Students created.") - end - - it "creates roster entries" do - expect do - patch :add_students, params: { - id: organization.slug, - identifiers: "a\r\nb" - } - end.to change(roster.reload.roster_entries, :count).by(2) - end - - it "finds identifiers with suffix" do - expect(roster.reload.roster_entries).to include(RosterEntry.find_by(identifier: "a-1")) - expect(roster.reload.roster_entries).to include(RosterEntry.find_by(identifier: "b-1")) - end - end - - context "when there's an internal error" do - before do - errored_entry = RosterEntry.new(roster: roster) - errored_entry.errors[:base] << "Something went wrong ¯\\_(ツ)_/¯ " - allow(RosterEntry).to receive(:create).and_return(errored_entry) - - patch :add_students, params: { - id: organization.slug, - identifiers: "a\r\nb" - } - end - - it "redirects to rosters page" do - expect(response).to redirect_to(roster_url(organization)) - end - - it "sets flash message" do - expect(flash[:error]).to eq("An error has occurred. Please try again.") - end - - it "creates no roster entries" do + it "enqueues the AddStudentsToRosterJob" do expect do patch :add_students, params: { id: organization.slug, identifiers: "a\r\nb" } - end.to change(roster.reload.roster_entries, :count).by(0) + end.to have_enqueued_job(AddStudentsToRosterJob) end end end @@ -916,6 +847,7 @@ student_profiles = student_names.map do |name| GoogleAPI::UserProfile.new(name: GoogleAPI::Name.new(full_name: name)) end + @students = student_profiles.map do |prof| GoogleAPI::Student.new(profile: prof, user_id: SecureRandom.uuid) end @@ -924,7 +856,9 @@ .to receive(:students) .and_return(@students) - patch :sync_google_classroom, params: { id: organization.slug } + perform_enqueued_jobs do + patch :sync_google_classroom, params: { id: organization.slug } + end end it "adds the new student to the roster" do diff --git a/spec/jobs/add_students_to_roster_job_spec.rb b/spec/jobs/add_students_to_roster_job_spec.rb new file mode 100644 index 0000000000..19acd58174 --- /dev/null +++ b/spec/jobs/add_students_to_roster_job_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AddStudentsToRosterJob, type: :job do + let(:organization) { classroom_org } + let(:user) { classroom_teacher } + let!(:roster) { create(:roster) } + let(:channel) { AddStudentsToRosterChannel.channel(roster_id: roster.id, user_id: user.id) } + let(:identifiers) { %w[John Bob] } + let(:lms_user_ids) { [1, 2] } + + after do + roster.roster_entries.destroy_all + end + + describe "perform" do + context "add Roster Entries to roster" do + context "without lms_user_ids" do + it "adds new identifiers to roster" do + expect { described_class.perform_now(identifiers, roster, user) }.to change { RosterEntry.count }.by(2) + end + end + context "with lms_user_ids" do + it "adds lms_user_ids to correct roster entry" do + described_class.perform_now(identifiers, roster, user, lms_user_ids) + entry1 = RosterEntry.find_by(identifier: "John") + entry2 = RosterEntry.find_by(identifier: "Bob") + expect(entry1.lms_user_id).to eq("1") + expect(entry2.lms_user_id).to eq("2") + end + end + end + + context "sends ActionCable broadcastss" do + it "sends completed message when job ends" do + expect { described_class.perform_now(identifiers, roster, user) } + .to have_broadcasted_to(channel) + .with(hash_including(status: "completed")) + end + end + + context "sends DataDog stats" do + context "with google or lti integration" do + context "sends out statsd when successful" do + before do + create(:lti_configuration, organization: organization) + end + + it "sends successfully" do + expect(GitHubClassroom.statsd).to receive(:increment).with("roster_entries.lms_imported", by: 2) + described_class.perform_now(identifiers, roster, user, lms_user_ids) + end + end + + context "does not send out statsd when not successful" do + before(:each) do + end + it "sends successfully" do + expect(GitHubClassroom.statsd).to_not receive(:increment).with("roster_entries.lms_imported", by: 2) + described_class.perform_now(identifiers, roster, user, []) + end + end + end + end + end + + describe "#build_message" do + let(:identifiers) { %w[John Bob] } + + context "when all roster entries are valid" do + let(:invalid_roster_entries) { [] } + + let(:result) { described_class.new.build_message(invalid_roster_entries, identifiers) } + it "returns roster update successful message" do + expect(result).to eq("Roster successfully updated. 2 roster entries were added.") + end + end + + context "when all roster entries are invalid" do + let(:invalid_roster_entries) { %w[John Bob] } + + let(:result) { described_class.new.build_message(invalid_roster_entries, identifiers) } + it "returns roster update failed message" do + expect(result).to eq("Could not add any students to roster, please try again.") + end + end + + context "when some roster entries are invalid" do + let(:invalid_roster_entries) { %w[Bob] } + + let(:result) { described_class.new.build_message(invalid_roster_entries, identifiers) } + it "returns partial roster update successful message" do + expect(result).to eq("Could not add following students: \nBob \n") + end + end + end + + describe "#add_suffix_to_duplicates!" do + context "when duplicates exist" do + before do + RosterEntry.create(identifier: "John", roster: roster) + end + + let(:result) do + described_class.new.add_suffix_to_duplicates!(%w[John Bob], roster) + end + + it "adds suffix to duplicate entries" do + expect(result.length).to eq(2) + expect(result[0]).to eq("John-1") + end + end + context "when no duplicates exist" do + before do + roster.roster_entries.destroy_all + end + + let(:result) do + described_class.new.add_suffix_to_duplicates!(%w[John Bob], roster) + end + + it "keeps the entries the same" do + expect(result.length).to eq(2) + expect(result[0]).to eq("John") + end + end + end +end diff --git a/spec/models/roster_entry_spec.rb b/spec/models/roster_entry_spec.rb index 79aee9a2a1..d0c47d6426 100644 --- a/spec/models/roster_entry_spec.rb +++ b/spec/models/roster_entry_spec.rb @@ -131,53 +131,4 @@ expect(roster_entries).to match_array([earliest_student_entry, initial_entry]) end end - - describe "create_entries" do - let(:organization) { classroom_org } - let(:roster) { create(:roster) } - - context "all entries valid" do - let(:result) do - RosterEntry.create_entries(identifiers: %w[1 2], roster: roster) - end - - it "creates two roster entries" do - expect(result.length).to eq(2) - - expect(result[0]).to eq(RosterEntry.find_by(identifier: "1", roster: roster)) - expect(result[1]).to eq(RosterEntry.find_by(identifier: "2", roster: roster)) - end - end - - context "when duplicate entries" do - before do - RosterEntry.create(identifier: "John", roster: roster) - end - - let(:result) do - RosterEntry.create_entries(identifiers: %w[John Bob], roster: roster) - end - - it "creates adds suffix to duplicate entries" do - expect(result.length).to eq(2) - - expect(result[0]).to eq(RosterEntry.find_by(identifier: "John-1", roster: roster)) - end - end - - context "some other error" do - before do - errored_entry = RosterEntry.new(roster: roster) - errored_entry.errors[:base] << "Something went wrong ¯\\_(ツ)_/¯ " - - allow(RosterEntry).to receive(:create).and_return(errored_entry) - end - - it "raises RosterEntry::IdentifierCreationError" do - expect do - RosterEntry.create_entries(identifiers: %w[1], roster: roster) - end.to raise_error(RosterEntry::IdentifierCreationError) - end - end - end end diff --git a/spec/support/cassettes/Orgs_RostersController/PATCH_add_students/when_all_identifiers_are_valid/enqueues_the_AddStudentsToRosterJob.json b/spec/support/cassettes/Orgs_RostersController/PATCH_add_students/when_all_identifiers_are_valid/enqueues_the_AddStudentsToRosterJob.json new file mode 100644 index 0000000000..969a6fc64e --- /dev/null +++ b/spec/support/cassettes/Orgs_RostersController/PATCH_add_students/when_all_identifiers_are_valid/enqueues_the_AddStudentsToRosterJob.json @@ -0,0 +1 @@ +{"http_interactions":[{"request":{"method":"get","uri":"https://api.github.com/applications/\u003cTEST_APPLICATION_GITHUB_CLIENT_ID\u003e/tokens/\u003cTEST_CLASSROOM_OWNER_GITHUB_TOKEN\u003e","body":{"encoding":"US-ASCII","base64_string":""},"headers":{"Accept":["application/vnd.github.v3+json"],"User-Agent":["Octokit Ruby Gem 4.14.0"],"Content-Type":["application/json"],"Cache-Control":["no-cache, no-store"],"Accept-Encoding":["gzip;q=1.0,deflate;q=0.6,identity;q=0.3"]}},"response":{"status":{"code":200,"message":"OK"},"headers":{"Date":["Wed, 14 Aug 2019 23:00:29 GMT"],"Content-Type":["application/json; charset=utf-8"],"Transfer-Encoding":["chunked"],"Server":["GitHub.com"],"Status":["200 OK"],"X-Ratelimit-Limit":["5000"],"X-Ratelimit-Remaining":["4999"],"X-Ratelimit-Reset":["1565827229"],"Cache-Control":["public, max-age=60, s-maxage=60"],"Vary":["Accept","Accept-Encoding"],"Etag":["W/\"d79eae737c3ed77abd65ff87e5a7bb7e\""],"X-Github-Media-Type":["github.v3; format=json"],"Access-Control-Expose-Headers":["ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type"],"Access-Control-Allow-Origin":["*"],"Strict-Transport-Security":["max-age=31536000; includeSubdomains; preload"],"X-Frame-Options":["deny"],"X-Content-Type-Options":["nosniff"],"X-Xss-Protection":["1; mode=block"],"Referrer-Policy":["origin-when-cross-origin, strict-origin-when-cross-origin"],"Content-Security-Policy":["default-src 'none'"],"X-Github-Request-Id":["E62A:6011:12903E:16764A:5D54928D"]},"body":{"encoding":"ASCII-8BIT","base64_string":"eyJpZCI6Mjk4MDg5Mzk5LCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29t\nL2F1dGhvcml6YXRpb25zLzI5ODA4OTM5OSIsImFwcCI6eyJuYW1lIjoiQ2xh\nc3Nyb29tIFRlc3QiLCJ1cmwiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJj\nbGllbnRfaWQiOiI8VEVTVF9BUFBMSUNBVElPTl9HSVRIVUJfQ0xJRU5UX0lE\nPiJ9LCJ0b2tlbiI6IjxURVNUX0NMQVNTUk9PTV9PV05FUl9HSVRIVUJfVE9L\nRU4+IiwiaGFzaGVkX3Rva2VuIjoiODgxZDY5NmQ1ZDM0ZDE4NDYzYzUyM2Uy\nNDA4MGNkNzhiNjhiOGMyMjMyMzRiZjc2MjllNzQ1OTRlMDNhZjRhOSIsInRv\na2VuX2xhc3RfZWlnaHQiOiI5NDYxZThkMCIsIm5vdGUiOm51bGwsIm5vdGVf\ndXJsIjpudWxsLCJjcmVhdGVkX2F0IjoiMjAxOS0wNi0xMVQxODozOTozOVoi\nLCJ1cGRhdGVkX2F0IjoiMjAxOS0wNi0xMVQxODozOTozOVoiLCJzY29wZXMi\nOlsiYWRtaW46b3JnIiwiYWRtaW46b3JnX2hvb2siLCJkZWxldGVfcmVwbyIs\nInJlcG8iLCJ1c2VyOmVtYWlsIl0sImZpbmdlcnByaW50IjpudWxsLCJ1c2Vy\nIjp7ImxvZ2luIjoic2hhdW5ha3BwLXRlc3QtMSIsImlkIjo8VEVTVF9DTEFT\nU1JPT01fT1dORVJfR0lUSFVCX0lEPiwibm9kZV9pZCI6Ik1EUTZWWE5sY2pV\neE56RTVNVGt6IiwiYXZhdGFyX3VybCI6Imh0dHBzOi8vYXZhdGFyczEuZ2l0\naHVidXNlcmNvbnRlbnQuY29tL3UvPFRFU1RfQ0xBU1NST09NX09XTkVSX0dJ\nVEhVQl9JRD4/dj00IiwiZ3JhdmF0YXJfaWQiOiIiLCJ1cmwiOiJodHRwczov\nL2FwaS5naXRodWIuY29tL3VzZXJzL3NoYXVuYWtwcC10ZXN0LTEiLCJodG1s\nX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9zaGF1bmFrcHAtdGVzdC0xIiwi\nZm9sbG93ZXJzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMv\nc2hhdW5ha3BwLXRlc3QtMS9mb2xsb3dlcnMiLCJmb2xsb3dpbmdfdXJsIjoi\naHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9zaGF1bmFrcHAtdGVzdC0x\nL2ZvbGxvd2luZ3svb3RoZXJfdXNlcn0iLCJnaXN0c191cmwiOiJodHRwczov\nL2FwaS5naXRodWIuY29tL3VzZXJzL3NoYXVuYWtwcC10ZXN0LTEvZ2lzdHN7\nL2dpc3RfaWR9Iiwic3RhcnJlZF91cmwiOiJodHRwczovL2FwaS5naXRodWIu\nY29tL3VzZXJzL3NoYXVuYWtwcC10ZXN0LTEvc3RhcnJlZHsvb3duZXJ9ey9y\nZXBvfSIsInN1YnNjcmlwdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHVi\nLmNvbS91c2Vycy9zaGF1bmFrcHAtdGVzdC0xL3N1YnNjcmlwdGlvbnMiLCJv\ncmdhbml6YXRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNl\ncnMvc2hhdW5ha3BwLXRlc3QtMS9vcmdzIiwicmVwb3NfdXJsIjoiaHR0cHM6\nLy9hcGkuZ2l0aHViLmNvbS91c2Vycy9zaGF1bmFrcHAtdGVzdC0xL3JlcG9z\nIiwiZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMv\nc2hhdW5ha3BwLXRlc3QtMS9ldmVudHN7L3ByaXZhY3l9IiwicmVjZWl2ZWRf\nZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvc2hh\ndW5ha3BwLXRlc3QtMS9yZWNlaXZlZF9ldmVudHMiLCJ0eXBlIjoiVXNlciIs\nInNpdGVfYWRtaW4iOmZhbHNlfX0=\n"},"http_version":null},"recorded_at":"Wed, 14 Aug 2019 23:00:29 GMT"},{"request":{"method":"get","uri":"https://api.github.com/user","body":{"encoding":"US-ASCII","base64_string":""},"headers":{"Accept":["application/vnd.github.v3+json"],"User-Agent":["Octokit Ruby Gem 4.14.0"],"Content-Type":["application/json"],"Cache-Control":["no-cache, no-store"],"Accept-Encoding":["gzip;q=1.0,deflate;q=0.6,identity;q=0.3"]}},"response":{"status":{"code":200,"message":"OK"},"headers":{"Date":["Wed, 14 Aug 2019 23:00:30 GMT"],"Content-Type":["application/json; charset=utf-8"],"Transfer-Encoding":["chunked"],"Server":["GitHub.com"],"Status":["200 OK"],"X-Ratelimit-Limit":["5000"],"X-Ratelimit-Remaining":["4999"],"X-Ratelimit-Reset":["1565827230"],"Cache-Control":["private, max-age=60, s-maxage=60"],"Vary":["Accept, Authorization, Cookie, X-GitHub-OTP","Accept-Encoding"],"Etag":["W/\"a8625999343a045e665ed4e19e407b2d\""],"Last-Modified":["Tue, 06 Aug 2019 00:38:56 GMT"],"X-Oauth-Scopes":["admin:org, admin:org_hook, delete_repo, repo, user:email"],"X-Accepted-Oauth-Scopes":[""],"X-Oauth-Client-Id":["\u003cTEST_APPLICATION_GITHUB_CLIENT_ID\u003e"],"X-Github-Media-Type":["github.v3; format=json"],"Access-Control-Expose-Headers":["ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type"],"Access-Control-Allow-Origin":["*"],"Strict-Transport-Security":["max-age=31536000; includeSubdomains; preload"],"X-Frame-Options":["deny"],"X-Content-Type-Options":["nosniff"],"X-Xss-Protection":["1; mode=block"],"Referrer-Policy":["origin-when-cross-origin, strict-origin-when-cross-origin"],"Content-Security-Policy":["default-src 'none'"],"X-Github-Request-Id":["E62B:9F46:26B773:2EB719:5D54928E"]},"body":{"encoding":"ASCII-8BIT","base64_string":"eyJsb2dpbiI6InNoYXVuYWtwcC10ZXN0LTEiLCJpZCI6PFRFU1RfQ0xBU1NS\nT09NX09XTkVSX0dJVEhVQl9JRD4sIm5vZGVfaWQiOiJNRFE2VlhObGNqVXhO\nekU1TVRreiIsImF2YXRhcl91cmwiOiJodHRwczovL2F2YXRhcnMxLmdpdGh1\nYnVzZXJjb250ZW50LmNvbS91LzxURVNUX0NMQVNTUk9PTV9PV05FUl9HSVRI\nVUJfSUQ+P3Y9NCIsImdyYXZhdGFyX2lkIjoiIiwidXJsIjoiaHR0cHM6Ly9h\ncGkuZ2l0aHViLmNvbS91c2Vycy9zaGF1bmFrcHAtdGVzdC0xIiwiaHRtbF91\ncmwiOiJodHRwczovL2dpdGh1Yi5jb20vc2hhdW5ha3BwLXRlc3QtMSIsImZv\nbGxvd2Vyc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3No\nYXVuYWtwcC10ZXN0LTEvZm9sbG93ZXJzIiwiZm9sbG93aW5nX3VybCI6Imh0\ndHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvc2hhdW5ha3BwLXRlc3QtMS9m\nb2xsb3dpbmd7L290aGVyX3VzZXJ9IiwiZ2lzdHNfdXJsIjoiaHR0cHM6Ly9h\ncGkuZ2l0aHViLmNvbS91c2Vycy9zaGF1bmFrcHAtdGVzdC0xL2dpc3Rzey9n\naXN0X2lkfSIsInN0YXJyZWRfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNv\nbS91c2Vycy9zaGF1bmFrcHAtdGVzdC0xL3N0YXJyZWR7L293bmVyfXsvcmVw\nb30iLCJzdWJzY3JpcHRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5j\nb20vdXNlcnMvc2hhdW5ha3BwLXRlc3QtMS9zdWJzY3JpcHRpb25zIiwib3Jn\nYW5pemF0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJz\nL3NoYXVuYWtwcC10ZXN0LTEvb3JncyIsInJlcG9zX3VybCI6Imh0dHBzOi8v\nYXBpLmdpdGh1Yi5jb20vdXNlcnMvc2hhdW5ha3BwLXRlc3QtMS9yZXBvcyIs\nImV2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3No\nYXVuYWtwcC10ZXN0LTEvZXZlbnRzey9wcml2YWN5fSIsInJlY2VpdmVkX2V2\nZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3NoYXVu\nYWtwcC10ZXN0LTEvcmVjZWl2ZWRfZXZlbnRzIiwidHlwZSI6IlVzZXIiLCJz\naXRlX2FkbWluIjpmYWxzZSwibmFtZSI6bnVsbCwiY29tcGFueSI6bnVsbCwi\nYmxvZyI6IiIsImxvY2F0aW9uIjpudWxsLCJlbWFpbCI6bnVsbCwiaGlyZWFi\nbGUiOm51bGwsImJpbyI6bnVsbCwicHVibGljX3JlcG9zIjoxLCJwdWJsaWNf\nZ2lzdHMiOjAsImZvbGxvd2VycyI6MCwiZm9sbG93aW5nIjowLCJjcmVhdGVk\nX2F0IjoiMjAxOS0wNi0xMVQxODoyNjo0MloiLCJ1cGRhdGVkX2F0IjoiMjAx\nOS0wOC0wNlQwMDozODo1NloifQ==\n"},"http_version":null},"recorded_at":"Wed, 14 Aug 2019 23:00:30 GMT"}],"recorded_with":"VCR 3.0.3"} \ No newline at end of file