Skip to content
This repository has been archived by the owner on Mar 12, 2020. It is now read-only.

Allow a GitHub Organization to be used by multiple Classrooms #1248

Merged
merged 27 commits into from Oct 29, 2018
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
cb8f599
Drop uniqueness validation on `Organization` for `github_id`
tarebyte Jan 31, 2018
ec1e688
Update Sluggable to find alternative slugs
tarebyte Jan 31, 2018
02c8fd6
Updat organization model to use new slug logic
tarebyte Jan 31, 2018
fd8ab6a
Update controller and views to allow selection of used orgs
tarebyte Jan 31, 2018
e888391
Merge branch 'master' into let-an-org-be-multiple-classrooms
tarebyte Jan 31, 2018
70808ed
Merge branch 'master' into let-an-org-be-multiple-classrooms
srinjoym Jul 16, 2018
5751b79
added uniqueness check for classroom name
srinjoym Jul 23, 2018
2e3039e
working on not removing webhook on org unless classroom
srinjoym Jul 24, 2018
6a05c92
move uniqueness validation to title scoped to organization instead of…
srinjoym Aug 22, 2018
4da0ea1
test multiple classroom cases and webhook from github case
srinjoym Aug 22, 2018
6519934
all the linter fixes
srinjoym Aug 22, 2018
5291038
Merge branch 'master' into let-an-org-be-multiple-classrooms
srinjoym Aug 22, 2018
71b96a5
remove webook id validation
srinjoym Aug 22, 2018
6045412
please the hound and refactoring fixes
srinjoym Aug 22, 2018
69a6839
please the hound again
srinjoym Aug 22, 2018
a2d1e51
bug fix for adding user to all classrooms they're not part of and rem…
srinjoym Aug 22, 2018
d4bc33a
please please the hound:
srinjoym Aug 22, 2018
24f69ff
Merge branch 'master' into let-an-org-be-multiple-classrooms
BenEmdon Aug 22, 2018
e3ca367
Merge branch 'master' into let-an-org-be-multiple-classrooms
BenEmdon Aug 22, 2018
5425052
cleanup code
srinjoym Oct 5, 2018
6498d67
Merge branch 'master' into let-an-org-be-multiple-classrooms
srinjoym Oct 5, 2018
b565a06
fix typo in classroom->user_org
srinjoym Oct 5, 2018
848ddc1
update messages when deleting classroom
srinjoym Oct 5, 2018
08f9c8f
Merge branch 'master' into let-an-org-be-multiple-classrooms
srinjoym Oct 8, 2018
6ad530b
add feature flag support
srinjoym Oct 27, 2018
0710f7f
please the hound
srinjoym Oct 27, 2018
77ffe32
Merge branch 'master' into let-an-org-be-multiple-classrooms
srinjoym Oct 27, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
10 changes: 4 additions & 6 deletions app/controllers/organizations_controller.rb
Expand Up @@ -120,27 +120,25 @@ def new_organization_params
params.require(:organization).permit(:github_id).merge(users: [current_user])
end

# rubocop:disable AbcSize
def set_users_github_organizations
@users_github_organizations = current_user.github_user.organization_memberships.map do |membership|
{
classroom: Organization.unscoped.find_by(github_id: membership.organization.id),
github_id: membership.organization.id,
login: membership.organization.login,
owner_login: membership.user.login,
role: membership.role
}
end
end
# rubocop:enable AbcSize

# Check if the current user has any organizations with admin privilege,
# if so add the user to the corresponding classroom automatically.
def add_current_user_to_organizations
@users_github_organizations.each do |organization|
classroom = organization[:classroom]
if classroom.present? && !classroom.users.include?(current_user)
create_user_organization_access(classroom)
user_orgs = Organization.where(github_id: organization[:github_id])

user_orgs.map do |user_org|
create_user_organization_access(user_org) unless user_org.users.include?(current_user)
end
end
end
Expand Down
12 changes: 8 additions & 4 deletions app/models/concerns/sluggable.rb
Expand Up @@ -4,16 +4,20 @@ module Sluggable
extend ActiveSupport::Concern

included do
before_validation do
slugify
end
before_validation :slugify
end

def slugify
self.slug = title.parameterize
self.slug = name_for_slug.parameterize
end

def to_param
slug
end

private

def name_for_slug
title
end
end
6 changes: 6 additions & 0 deletions app/models/github_organization.rb
Expand Up @@ -116,6 +116,12 @@ def create_organization_webhook(config: {}, options: {})
end
end

def organization_webhooks
GitHub::Errors.with_error_handling do
@client.org_hooks(@id)
end
end

def remove_organization_webhook(webhook_id)
return if webhook_id.blank?
GitHub::Errors.with_error_handling do
Expand Down
16 changes: 11 additions & 5 deletions app/models/organization.rb
Expand Up @@ -17,15 +17,14 @@ class Organization < ApplicationRecord

has_and_belongs_to_many :users

validates :github_id, presence: true, uniqueness: true
validates :github_id, presence: true

validates :title, presence: true
validates :title, length: { maximum: 60 }
validates :title, uniqueness: { scope: :github_id }

validates :slug, uniqueness: true

validates :webhook_id, uniqueness: true, allow_nil: true

before_destroy :silently_remove_organization_webhook

def all_assignments(with_invitations: false)
Expand All @@ -48,15 +47,22 @@ def github_organization
@github_organization ||= GitHubOrganization.new(github_client, github_id)
end

def slugify
self.slug = "#{github_id} #{title}".parameterize
def name_for_slug
"#{github_id} #{title}"
end

def one_owner_remains?
users.count == 1
end

# Check if we are the last Classroom on this GitHub Organization
def last_classroom_on_org?
Organization.where(github_id: github_id).length <= 1
srinjoym marked this conversation as resolved.
Show resolved Hide resolved
end

def silently_remove_organization_webhook
return true unless last_classroom_on_org?
srinjoym marked this conversation as resolved.
Show resolved Hide resolved

begin
github_organization.remove_organization_webhook(webhook_id)
rescue GitHub::Error => err
Expand Down
80 changes: 75 additions & 5 deletions app/models/organization/creator.rb
Expand Up @@ -95,20 +95,30 @@ def perform
# Internal: Create an GitHub Organization WebHook if there
# is a user who has the correct token scope.
#
# Returns an Integer id, or raises a Result::Error
# Returns an Integer id, or raises a Result::Error#
# rubocop:disable MethodLength
# rubocop:disable AbcSize
def create_organization_webhook!
return unless (user = user_with_admin_org_hook_scope)

begin
return existing_webhook_id unless existing_webhook_id.nil?

github_organization = GitHubOrganization.new(user.github_client, github_id)
webhook = github_organization.create_organization_webhook(config: { url: webhook_url })

return webhook.id if webhook.try(:id).present?
raise GitHub::Error
rescue GitHub::Error
raise GitHub::Error if webhook.try(:id).nil?

webhook.id
rescue GitHub::Error => e
github_webhook_id = process_webhook_error(e.message, github_organization)
return github_webhook_id unless github_webhook_id.nil?

raise Result::Error, "Could not create WebHook, please try again."
end
end
# rubocop:enable AbcSize
# rubocop:enable MethodLength

# Internal: Make sure every user being added to the
# Organization is an admin on GitHub.
Expand Down Expand Up @@ -167,7 +177,10 @@ def title

github_client = users.sample.github_client
github_org = GitHubOrganization.new(github_client, github_id)
github_org.name.present? ? github_org.name : github_org.login

org_identifier = github_org.name.presence || github_org.login

find_unique_title(org_identifier)
end

# Internal: Find User that has the `admin:org_hook` scope
Expand Down Expand Up @@ -222,5 +235,62 @@ def webhook_url

raise Result::Error, error_message
end

# Internal: Check error from GitHub when we try to create the webhook
# If the hook already exists get the webhook id from GitHub and return
#
# Returns a String for the webhook_id or nil
def process_webhook_error(message, github_organization)
return nil unless message == "Hook: Hook already exists on this organization"

get_organization_webhook_id(github_organization)
end

# Internal: Get id of webhook already on GitHub
#
# Returns a String for the webhook_id or nil
def get_organization_webhook_id(github_organization)
webhooks = github_organization.organization_webhooks

# There should only be one webhook that Classroom creates in production
webhooks.first.id
rescue GitHub::Error
nil
end

# Internal: Find webhook_id of any existing classrooms with
# same github id
#
# Returns a String for the webhook_id or nil
def existing_webhook_id
classroom_with_same_org = Organization.find_by(github_id: github_id)
classroom_with_same_org ? classroom_with_same_org.webhook_id : nil
end

# Internal: Generates unique name for classroom if default
# name has already been taken
#
# Returns a String with an unique title
def find_unique_title(identifier)
# First Classroom on Org will have postfix 1
base_title = "#{identifier}-classroom-1"

count = 1
until unique_title?(base_title)
# Increments count at end of title
base_title = base_title.gsub(/\-\d+$/, "") + "-#{count}"
count += 1
end

base_title
end

# Internal: Checks if a classroom with the same title and github_id
# exists already
#
# Returns a Boolean on whether duplicate classroom title is
def unique_title?(base_title)
Organization.where(title: base_title, github_id: github_id).blank?
end
end
end
@@ -1,5 +1,4 @@
<% aria_label = org[:role] != 'admin' ? t('views.organizations.you_are_not_owner') : t('views.organizations.already_added', owner: org[:owner_login]) %>
<div class="organization-select-target disabled tooltipped tooltipped-s" aria-label="<%= aria_label %>">
<div class="organization-select-target disabled tooltipped tooltipped-s" aria-label="<%= t('views.organizations.you_are_not_owner') %>">
<%= image_tag "https://avatars.githubusercontent.com/u/#{org[:github_id]}?v=3&size=200", class: 'avatar organization-select-avatar', width: 100, height: 100, alt: "@#{org[:login]}" %>
<span class="organization css-truncate css-truncate-target" title=<%= "@#{org[:login]}" %>><%= "@#{org[:login]}" %></span>
</div>
Expand Up @@ -12,7 +12,7 @@
</p>
</div>

<%= form_for current_organization, html: { "data-name" => current_organization.github_organization.login, method: 'delete' } do |f| %>
<%= form_for current_organization, html: { "data-name" => current_organization.title, method: 'delete' } do |f| %>
<dl class="form js-normalize-submit">
<dt><%= t('views.organizations.delete_confirm') %></dt>
<dd><input type="text" class="js-input-block form-control input-contrast input-block" autofocus></dd>
Expand Down
2 changes: 1 addition & 1 deletion app/views/organizations/new.html.erb
Expand Up @@ -16,7 +16,7 @@

<div class="org-select-grid">
<% @users_github_organizations.each do |org| %>
<% if org[:role] == 'admin' && !org[:classroom].present? %>
<% if org[:role] == 'admin' %>
<%= render partial: 'organization_select', locals: { org: org } %>
<% else %>
<%= render partial: 'disabled_organization_select', locals: { org: org } %>
Expand Down
4 changes: 2 additions & 2 deletions config/locales/views/organizations/en.yml
Expand Up @@ -30,8 +30,8 @@ en:
group_assignments: 'Group Assignments'
are_you_sure: 'Are you ABSOLUTELY sure?'
delete_warning: "Unexpected things will happen if you don't read this!"
delete_consequences_html: "This action <strong>CANNOT</strong> be undone. Please note that reseting and removing this classroom will delete all repositories and teams under the <strong>%{organization}</strong> account that were created by GitHub Classroom."
delete_confirm: 'Please type the name of the organization to confirm'
delete_consequences_html: "This action <strong>CANNOT</strong> be undone. Please note that reseting and removing this classroom will delete all repositories and teams created by this classroom in the <strong>%{organization}</strong> account."
delete_confirm: 'Please type the name of the classroom to confirm'
delete_submit: 'Reset and remove this classroom'
classroom_settings: 'Classroom settings'
classroom_profile: 'Classroom profile'
Expand Down
7 changes: 0 additions & 7 deletions spec/controllers/organizations_controller_spec.rb
Expand Up @@ -114,13 +114,6 @@
end.to_not change(Organization, :count)
end

it "will not add an organization that already exists" do
existing_organization_options = { github_id: organization.github_id }
expect do
post :create, params: { organization: existing_organization_options }
end.to_not change(Organization, :count)
end

it "will add an organization that the user is admin of on GitHub" do
organization_params = { github_id: organization.github_id, users: organization.users }
organization.destroy!
Expand Down
37 changes: 36 additions & 1 deletion spec/models/organization/creator_spec.rb
Expand Up @@ -7,7 +7,7 @@
let(:user) { classroom_teacher }

after(:each) do
@organization.try(:destroy)
Organization.find_each(&:destroy)
end

describe "::perform", :vcr do
Expand All @@ -26,6 +26,41 @@

Organization::Creator.perform(github_id: github_organization_id, users: [user])
end

context "multiple classrooms on same organization" do
before do
result = Organization::Creator.perform(github_id: github_organization_id, users: [user])
@org = result.organization
end

it "creates a classroom with the same webhook id as the existing one" do
result = Organization::Creator.perform(github_id: github_organization_id, users: [user])
expect(result.organization.webhook_id).to eql(@org.webhook_id)
end

it "creates a classroom with the default title but incremented id" do
result = Organization::Creator.perform(github_id: github_organization_id, users: [user])
expect(result.organization.title).to eql("#{@org.title[0...-2]}-2")
end
end

context "already created webhook on GitHub org" do
before do
GitHubOrganization
.any_instance.stub(:create_organization_webhook)
.and_raise GitHub::Error, "Hook: Hook already exists on this organization"

@dummy_webhook_id = 12_345
Organization::Creator
.any_instance.stub(:get_organization_webhook_id)
.and_return @dummy_webhook_id
end

it "sets webhook id to what it is already set on org" do
result = Organization::Creator.perform(github_id: github_organization_id, users: [user])
expect(result.organization.webhook_id).to eql(@dummy_webhook_id)
end
end
end

describe "unsucessful creation" do
Expand Down
59 changes: 53 additions & 6 deletions spec/models/organization_spec.rb
Expand Up @@ -58,18 +58,65 @@
end
end

describe "#last_classroom_on_org?" do
context "only one classroom with github_id" do
it "returns true" do
expect(subject.last_classroom_on_org?).to be_truthy
end
end

context "multiple classrooms with same github_id" do
before do
create(:organization, github_id: 12_345)
end

it "returns false" do
expect(subject.last_classroom_on_org?).to be_falsey
end
end

context "multiple classrooms with different github_ids" do
before do
create(:organization, github_id: 0)
end

it "returns true" do
expect(subject.last_classroom_on_org?).to be_truthy
end
end
end

describe "callbacks" do
describe "before_destroy" do
describe "#silently_remove_organization_webhook", :vcr do
it "deletes the webhook from GitHub" do
subject.update_attributes(webhook_id: 9_999_999, is_webhook_active: true)
context "multiple classrooms on organization" do
before do
create(:organization, github_id: 12_345)
end

it "does not delete the webhook from GitHub" do
subject.update(webhook_id: 9_999_999, is_webhook_active: true)

org_id = subject.github_id
webhook_id = subject.webhook_id

subject.destroy

expect(WebMock).to_not have_requested(:delete, github_url("/organizations/#{org_id}/hooks/#{webhook_id}"))
end
end

context "last classroom on organization" do
it "deletes the webhook from GitHub" do
subject.update(webhook_id: 9_999_999, is_webhook_active: true)

org_id = subject.github_id
webhook_id = subject.webhook_id
org_id = subject.github_id
webhook_id = subject.webhook_id

subject.destroy
subject.destroy

expect(WebMock).to have_requested(:delete, github_url("/organizations/#{org_id}/hooks/#{webhook_id}"))
expect(WebMock).to have_requested(:delete, github_url("/organizations/#{org_id}/hooks/#{webhook_id}"))
end
end
end
end
Expand Down
@@ -0,0 +1 @@
{"http_interactions":[{"request":{"method":"get","uri":"https://api.github.com/organizations/12345","body":{"encoding":"US-ASCII","base64_string":""},"headers":{"User-Agent":["Octokit Ruby Gem 4.9.0"],"Accept":["application/vnd.github.v3+json"],"Content-Type":["application/json"],"Expect":[""]}},"response":{"status":{"code":401,"message":"Unauthorized"},"headers":{"Date":["Wed, 25 Jul 2018 05:38:11 GMT"],"Content-Type":["application/json; charset=utf-8"],"Content-Length":["83"],"Server":["GitHub.com"],"Status":["401 Unauthorized"],"X-Github-Media-Type":["github.v3; format=json"],"X-Ratelimit-Limit":["60"],"X-Ratelimit-Remaining":["59"],"X-Ratelimit-Reset":["1532500691"],"Access-Control-Expose-Headers":["ETag, Link, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval"],"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-Runtime-Rack":["0.019451"],"X-Github-Request-Id":["F509:1708:93F504:C1FC47:5B580CC3"]},"body":{"encoding":"UTF-8","base64_string":"eyJtZXNzYWdlIjoiQmFkIGNyZWRlbnRpYWxzIiwiZG9jdW1lbnRhdGlvbl91\ncmwiOiJodHRwczovL2RldmVsb3Blci5naXRodWIuY29tL3YzIn0=\n"},"http_version":null},"recorded_at":"Wed, 25 Jul 2018 05:38:11 GMT"},{"request":{"method":"get","uri":"https://api.github.com/organizations/12345?client_id=\u003cTEST_APPLICATION_GITHUB_CLIENT_ID\u003e\u0026client_secret=\u003cTEST_APPLICATION_GITHUB_CLIENT_SECRET\u003e","body":{"encoding":"US-ASCII","base64_string":""},"headers":{"User-Agent":["Octokit Ruby Gem 4.9.0"],"Accept":["application/vnd.github.v3+json"],"Content-Type":["application/json"],"Expect":[""]}},"response":{"status":{"code":404,"message":"Not Found"},"headers":{"Date":["Wed, 25 Jul 2018 05:38:11 GMT"],"Content-Type":["application/json; charset=utf-8"],"Content-Length":["103"],"Server":["GitHub.com"],"Status":["404 Not Found"],"X-Ratelimit-Limit":["5000"],"X-Ratelimit-Remaining":["4998"],"X-Ratelimit-Reset":["1532500691"],"X-Github-Media-Type":["github.v3; format=json"],"Access-Control-Expose-Headers":["ETag, Link, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval"],"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-Runtime-Rack":["0.016530"],"X-Github-Request-Id":["F509:1708:93F516:C1FC5D:5B580CC3"]},"body":{"encoding":"UTF-8","base64_string":"eyJtZXNzYWdlIjoiTm90IEZvdW5kIiwiZG9jdW1lbnRhdGlvbl91cmwiOiJo\ndHRwczovL2RldmVsb3Blci5naXRodWIuY29tL3YzL29yZ3MvI2dldC1hbi1v\ncmdhbml6YXRpb24ifQ==\n"},"http_version":null},"recorded_at":"Wed, 25 Jul 2018 05:38:11 GMT"},{"request":{"method":"delete","uri":"https://api.github.com/organizations/12345/hooks/9999999","body":{"encoding":"UTF-8","base64_string":"e30=\n"},"headers":{"User-Agent":["Octokit Ruby Gem 4.9.0"],"Accept":["application/vnd.github.v3+json"],"Content-Type":["application/json"],"Expect":[""]}},"response":{"status":{"code":401,"message":"Unauthorized"},"headers":{"Date":["Wed, 25 Jul 2018 05:38:11 GMT"],"Content-Type":["application/json; charset=utf-8"],"Content-Length":["83"],"Server":["GitHub.com"],"Status":["401 Unauthorized"],"X-Github-Media-Type":["github.v3; format=json"],"X-Ratelimit-Limit":["60"],"X-Ratelimit-Remaining":["57"],"X-Ratelimit-Reset":["1532500691"],"Access-Control-Expose-Headers":["ETag, Link, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval"],"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-Runtime-Rack":["0.013376"],"X-Github-Request-Id":["F509:1708:93F522:C1FC73:5B580CC3"]},"body":{"encoding":"UTF-8","base64_string":"eyJtZXNzYWdlIjoiQmFkIGNyZWRlbnRpYWxzIiwiZG9jdW1lbnRhdGlvbl91\ncmwiOiJodHRwczovL2RldmVsb3Blci5naXRodWIuY29tL3YzIn0=\n"},"http_version":null},"recorded_at":"Wed, 25 Jul 2018 05:38:11 GMT"}],"recorded_with":"VCR 3.0.3"}

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.