-
Notifications
You must be signed in to change notification settings - Fork 9
Add Create Organization call. #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,61 +1,117 @@ | ||
from configobj import ConfigObj | ||
import html5lib | ||
"""An API to do things that aren't in the official public GitHub REST API.""" | ||
|
||
from urllib.parse import urljoin | ||
from enum import Enum | ||
|
||
import os.path | ||
import html5lib | ||
import pyotp | ||
import requests | ||
|
||
from urllib.parse import urljoin | ||
from configobj import ConfigObj | ||
|
||
|
||
def _get_and_submit_form(session, url, data_callback): | ||
def _get_and_submit_form(session, url: str, data_callback = None, form_id: str = None): | ||
response = session.get(url) | ||
response.raise_for_status() | ||
|
||
doc = html5lib.parse(response.text, namespaceHTMLElements=False) | ||
form = doc.findall('.//form') | ||
inputs = doc.findall('.//form//input') | ||
|
||
action_url = form[0].attrib['action'] | ||
forms = doc.findall('.//form') | ||
|
||
# If no form_id is specified, just use the first (and probably only) | ||
# form. Otherwise find the named form to submit to. | ||
submit_form = forms[0] | ||
if form_id: | ||
for form in forms: | ||
if form.attrib.get('id') == form_id: | ||
submit_form = form | ||
break | ||
else: | ||
raise ValueError('%s form not found' % form_id) | ||
|
||
action_url = submit_form.attrib['action'] | ||
# Look at all the inputs under the given form. | ||
inputs = submit_form.findall('.//input') | ||
|
||
data = dict() | ||
for input in inputs: | ||
value = input.attrib.get('value') | ||
if value: | ||
data[input.attrib['name']] = value | ||
for form_input in inputs: | ||
value = form_input.attrib.get('value') | ||
if value and 'name' in form_input.attrib: | ||
data[form_input.attrib['name']] = value | ||
|
||
# Have the caller provide additional data | ||
data_callback(data) | ||
if data_callback: | ||
data_callback(data) | ||
|
||
response = session.post(urljoin(url, action_url), data=data) | ||
response.raise_for_status() | ||
return response | ||
|
||
|
||
def _create_login_session(username, password, tfa_callback, session=None): | ||
def _create_login_session(username: str, password: str, | ||
tfa_callback, session: requests.Session = None) -> requests.Session: | ||
session = session or requests.Session() | ||
|
||
def _LoginCallback(data): | ||
def _login_callback(data): | ||
data.update(dict(login=username, password=password)) | ||
_get_and_submit_form( | ||
session=session, url='https://github.com/login', data_callback=_LoginCallback) | ||
session=session, url='https://github.com/login', data_callback=_login_callback) | ||
|
||
def _TfaCallback(data): | ||
def _tfa_callback(data): | ||
data.update(dict(otp=tfa_callback())) | ||
_get_and_submit_form( | ||
session=session, url='https://github.com/sessions/two-factor', data_callback=_TfaCallback) | ||
session=session, url='https://github.com/sessions/two-factor', data_callback=_tfa_callback) | ||
|
||
return session | ||
|
||
|
||
_CREATE_ORG_URL = 'https://github.com/account/organizations/new?plan=free' | ||
|
||
|
||
class OrganizationUsage(Enum): | ||
"""Organization Usage for Organization Creation.""" | ||
|
||
PERSONAL = 'standard' | ||
BUSINESS = 'corporate' | ||
|
||
|
||
class Api(object): | ||
def __init__(self, username, password, tfa_callback, session=None): | ||
self._session = _create_login_session( | ||
"""API Endpoing for doing non-public things to GitHub. | ||
|
||
Ideally these would all exist as REST API endpoints, but instead we get | ||
to pretend to be a real user. | ||
""" | ||
|
||
def __init__(self, username: str = None, password: str = None, tfa_callback = None, | ||
session: requests.Session = None): | ||
self._session = session or _create_login_session( | ||
username=username, password=password, tfa_callback=tfa_callback, session=session) | ||
|
||
def create_organization(self, org_name: str, contact_email: str, | ||
org_usage: OrganizationUsage, business_name: str = None): | ||
"""Create the specified GitHub organization. | ||
|
||
Right now, only creates free tier organizations. | ||
""" | ||
|
||
def _create_org_callback(data): | ||
data['organization[profile_name]'] = org_name | ||
data['organization[billing_email]'] = contact_email | ||
data['terms_of_service_type'] = org_usage.value | ||
data['agreed_to_terms'] = 'yes' | ||
if org_usage == OrganizationUsage.BUSINESS: | ||
data['organization[company_name]'] = business_name | ||
|
||
_get_and_submit_form(session=self._session, | ||
url=_CREATE_ORG_URL, data_callback=_create_org_callback, | ||
form_id='org-new-form') | ||
|
||
|
||
if __name__ == "__main__": | ||
config = ConfigObj(os.path.expanduser('~/github.ini'), _inspec=True) | ||
|
||
api = Api(config['username'], config['password'], | ||
tfa_callback=lambda: pyotp.TOTP(config['otp_seed']).now()) | ||
resp = api._session.get('https://github.com/billnapier/aoc2021/settings') | ||
resp.raise_for_status() | ||
tfa_callback=lambda: pyotp.TOTP(config['otp_seed']).now()) | ||
api.create_organization(org_name='blah', | ||
contact_email='example@example.com', | ||
org_usage=OrganizationUsage.PERSONAL) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
<html> | ||
<body> | ||
|
||
<form action="new_org" id="org-new-form"> | ||
<input name="authenticity_token" value="value"> | ||
</form> | ||
</body> | ||
</html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,24 +1,79 @@ | ||
from github_nonpublic_api import api | ||
"""Unit tests for api.py.""" | ||
|
||
import os | ||
from unittest import TestCase, mock | ||
|
||
from truth.truth import AssertThat | ||
import unittest | ||
from unittest import mock | ||
|
||
from github_nonpublic_api import api | ||
|
||
GITHUB_FORM_HTML = os.path.join(os.path.dirname(__file__), 'github_form.html') | ||
NEW_ORG_FORM_HTML = os.path.join( | ||
os.path.dirname(__file__), 'new_org_form.html') | ||
|
||
|
||
class TestApi(unittest.TestCase): | ||
class TestApi(TestCase): | ||
def _seed_session_with_file(self, filename): | ||
self.session = mock.MagicMock() | ||
with open(filename) as fp: | ||
self.session.get.return_value.text = fp.read() | ||
|
||
"""Unit tests for api.py.""" | ||
|
||
def test_get_and_submit_form(self): | ||
session = mock.MagicMock() | ||
with open(GITHUB_FORM_HTML) as fp: | ||
session.get.return_value.text = fp.read() | ||
self._seed_session_with_file(GITHUB_FORM_HTML) | ||
|
||
def DataCallback(data): | ||
def _data_callback(data): | ||
data['add'] = 'yes' | ||
|
||
api._get_and_submit_form( | ||
session=session, url='http://github.com', data_callback=DataCallback) | ||
session=self.session, url='http://github.com', data_callback=_data_callback) | ||
|
||
AssertThat(session.post).WasCalled().Once().With( | ||
AssertThat(self.session.post).WasCalled().Once().With( | ||
'http://github.com/foo', data=dict(add='yes', key='value')) | ||
|
||
def test_get_and_submit_form_by_id(self): | ||
self._seed_session_with_file(GITHUB_FORM_HTML) | ||
|
||
api._get_and_submit_form( | ||
session=self.session, url='http://github.com', form_id='form2') | ||
|
||
AssertThat(self.session.post).WasCalled().Once().With( | ||
'http://github.com/form2', data=dict(key='value2')) | ||
|
||
def test_get_and_submit_form_by_id_error(self): | ||
self._seed_session_with_file(GITHUB_FORM_HTML) | ||
|
||
with AssertThat(ValueError).IsRaised(): | ||
api._get_and_submit_form( | ||
session=self.session, url='http://github.com', form_id='no_form') | ||
|
||
def test_create_business_org(self): | ||
self._seed_session_with_file(NEW_ORG_FORM_HTML) | ||
gh = api.Api(session=self.session) | ||
gh.create_organization(org_name='test', contact_email='nobody@google.com', | ||
org_usage=api.OrganizationUsage.BUSINESS, | ||
business_name='A Fake Business') | ||
AssertThat(self.session.post).WasCalled().Once().With( | ||
'https://github.com/account/organizations/new_org', data={ | ||
'authenticity_token': 'value', | ||
'agreed_to_terms': 'yes', | ||
'terms_of_service_type': 'corporate', | ||
'organization[billing_email]': 'nobody@google.com', | ||
'organization[profile_name]': 'test', | ||
'organization[company_name]': 'A Fake Business', | ||
}) | ||
|
||
def test_create_personal_org(self): | ||
self._seed_session_with_file(NEW_ORG_FORM_HTML) | ||
gh = api.Api(session=self.session) | ||
gh.create_organization(org_name='test', contact_email='nobody@google.com', | ||
org_usage=api.OrganizationUsage.PERSONAL) | ||
AssertThat(self.session.post).WasCalled().Once().With( | ||
'https://github.com/account/organizations/new_org', data={ | ||
'authenticity_token': 'value', | ||
'agreed_to_terms': 'yes', | ||
'terms_of_service_type': 'standard', | ||
'organization[billing_email]': 'nobody@google.com', | ||
'organization[profile_name]': 'test', | ||
}) |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.