Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 79 additions & 23 deletions github_nonpublic_api/api.py
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)
4 changes: 3 additions & 1 deletion tests/github_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
<form action="foo">
<input name="key" value="value">
</form>
<form>fake form</form>
<form id="form2" action="form2">
<input name="key" value="value2">
</form>
</body>
</html>
8 changes: 8 additions & 0 deletions tests/new_org_form.html
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>
75 changes: 65 additions & 10 deletions tests/test_api.py
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',
})