Merge pull request #32024 from rallytime/github-module

Add new repo, issue, and milestone functions to github module
This commit is contained in:
Mike Place 2016-03-21 14:15:49 -06:00
commit 97ef22993f

View File

@ -20,8 +20,13 @@ For example:
github:
token: abc1234
org_name: my_organization
# optional: some functions require a repo_name, which
# can be set in the config file, or passed in at the CLI.
repo_name: my_repo
# optional: only some functions, such as 'add_user',
# require a dev_team_id
# require a dev_team_id.
dev_team_id: 1234
'''
@ -31,6 +36,7 @@ import logging
# Import Salt Libs
from salt.exceptions import CommandExecutionError
import salt.utils.http
# Import third party libs
HAS_LIBS = False
@ -59,58 +65,50 @@ def __virtual__():
'PyGithub library is not installed.')
def _get_profile(profile):
def _get_config_value(profile, config_name):
'''
Helper function that returns a profile's configuration value based on
the supplied configuration name.
profile
The profile name that contains configuration information.
config_name
The configuration item's name to use to return configuration values.
'''
config = __salt__['config.option'](profile)
if not config:
raise CommandExecutionError(
'Authentication information could not be found for the '
'\'{0}\' profile.'.format(profile)
)
return config
def _get_secret_key(profile):
token = _get_profile(profile).get('token')
if not token:
config_value = config.get(config_name)
if not config_value:
raise CommandExecutionError(
'The required \'token\' parameter was not found in the '
'\'{0}\' profile.'.format(profile)
'The \'{0}\' parameter was not found in the \'{1}\' '
'profile.'.format(
config_name,
profile
)
)
return token
def _get_org_name(profile):
org_name = _get_profile(profile).get('org_name')
if not org_name:
raise CommandExecutionError(
'The required \'org_name\' parameter was not found in the '
'\'{0}\' profile.'.format(profile)
)
return org_name
def _get_dev_team_id(profile):
dev_team_id = _get_profile(profile).get('dev_team_id')
if not dev_team_id:
raise CommandExecutionError(
'The \'dev_team_id\' option was not found in the \'{0}\' '
'profile.'.format(profile)
)
return dev_team_id
return config_value
def _get_client(profile):
'''
Return the GitHub client, cached into __context__ for performance
'''
token = _get_config_value(profile, 'token')
key = 'github.{0}:{1}'.format(
_get_secret_key(profile),
_get_org_name(profile)
token,
_get_config_value(profile, 'org_name')
)
if key not in __context__:
__context__[key] = github.Github(
_get_secret_key(profile),
token,
)
return __context__[key]
@ -124,6 +122,19 @@ def _get_members(organization, params=None):
)
def _get_repos(profile, params=None):
org_name = _get_config_value(profile, 'org_name')
client = _get_client(profile)
organization = client.get_organization(org_name)
return github.PaginatedList.PaginatedList(
github.Repository.Repository,
organization._requester,
organization.url + '/repos',
params
)
def list_users(profile="github"):
'''
List all users within the organization.
@ -138,13 +149,14 @@ def list_users(profile="github"):
salt myminion github.list_users
salt myminion github.list_users profile='my-github-profile'
'''
org_name = _get_config_value(profile, 'org_name')
key = "github.{0}:users".format(
_get_org_name(profile)
org_name
)
if key not in __context__:
client = _get_client(profile)
organization = client.get_organization(_get_org_name(profile))
organization = client.get_organization(org_name)
users = [member.login for member in _get_members(organization, None)]
__context__[key] = users
@ -183,7 +195,9 @@ def get_user(name, profile='github', user_details=False):
response = {}
client = _get_client(profile)
organization = client.get_organization(_get_org_name(profile))
organization = client.get_organization(
_get_config_value(profile, 'org_name')
)
try:
user = client.get_user(name)
@ -235,7 +249,9 @@ def add_user(name, profile='github'):
'''
client = _get_client(profile)
organization = client.get_organization(_get_org_name(profile))
organization = client.get_organization(
_get_config_value(profile, 'org_name')
)
try:
github_named_user = client.get_user(name)
@ -243,7 +259,9 @@ def add_user(name, profile='github'):
logging.exception("Resource not found {0}: ".format(str(e)))
return False
org_team = organization.get_team(_get_dev_team_id(profile))
org_team = organization.get_team(
_get_config_value(profile, 'dev_team_id')
)
try:
headers, data = org_team._requester.requestJsonAndCheck(
@ -282,7 +300,9 @@ def remove_user(name, profile='github'):
'''
client = _get_client(profile)
organization = client.get_organization(_get_org_name(profile))
organization = client.get_organization(
_get_config_value(profile, 'org_name')
)
try:
git_user = client.get_user(name)
@ -294,3 +314,588 @@ def remove_user(name, profile='github'):
organization.remove_from_members(git_user)
return not organization.has_in_members(git_user)
def get_issue(issue_number, repo_name=None, profile='github', output='min'):
'''
Return information about a single issue in a named repository.
.. versionadded:: Carbon
issue_number
The number of the issue to retrieve.
repo_name
The name of the repository for which to list issues. This argument is
required, either passed via the CLI, or defined in the configured
profile. A ``repo_name`` passed as a CLI argument will override the
repo_name defined in the configured profile, if provided.
profile
The name of the profile configuration to use. Defaults to ``github``.
output
The amount of data returned by each issue. Defaults to ``min``. Change
to ``full`` to see all issue output.
CLI Example:
.. code-block:: bash
salt myminion github.get_issue 514
salt myminion github.get_issue 514 repo_name=salt
'''
org_name = _get_config_value(profile, 'org_name')
if repo_name is None:
repo_name = _get_config_value(profile, 'repo_name')
action = '/'.join(['repos', org_name, repo_name])
command = 'issues/' + str(issue_number)
ret = {}
issue_data = _query(profile, action=action, command=command)
issue_id = issue_data.get('id')
if output == 'full':
ret[issue_id] = issue_data
else:
ret[issue_id] = _format_issue(issue_data)
return ret
def get_issues(repo_name=None,
profile='github',
milestone=None,
state='open',
assignee=None,
creator=None,
mentioned=None,
labels=None,
sort='created',
direction='desc',
since=None,
output='min',
per_page=None):
'''
Returns information for all issues in a given repository, based on the search options.
.. versionadded:: Carbon
repo_name
The name of the repository for which to list issues. This argument is
required, either passed via the CLI, or defined in the configured
profile. A ``repo_name`` passed as a CLI argument will override the
repo_name defined in the configured profile, if provided.
profile
The name of the profile configuration to use. Defaults to ``github``.
milestone
The number of a GitHub milestone, or a string of either ``*`` or
``none``.
If a number is passed, it should refer to a milestone by its number
field. Use the ``github.get_milestone`` function to obtain a milestone's
number.
If the string ``*`` is passed, issues with any milestone are
accepted. If the string ``none`` is passed, issues without milestones
are returned.
state
Indicates the state of the issues to return. Can be either ``open``,
``closed``, or ``all``. Default is ``open``.
assignee
Can be the name of a user. Pass in ``none`` (as a string) for issues
with no assigned user or ``*`` for issues assigned to any user.
creator
The user that created the issue.
mentioned
A user that's mentioned in the issue.
labels
A string of comma separated label names. For example, ``bug,ui,@high``.
sort
What to sort results by. Can be either ``created``, ``updated``, or
``comments``. Default is ``created``.
direction
The direction of the sort. Can be either ``asc`` or ``desc``. Default
is ``desc``.
since
Only issues updated at or after this time are returned. This is a
timestamp in ISO 8601 format: ``YYYY-MM-DDTHH:MM:SSZ``.
output
The amount of data returned by each issue. Defaults to ``min``. Change
to ``full`` to see all issue output.
per_page
GitHub paginates data in their API calls. Use this value to increase or
decrease the number of issues gathered from GitHub, per page. If not set,
GitHub defaults are used. Maximum is 100.
CLI Example:
.. code-block:: bash
salt myminion github.get_issues my-github-repo
'''
org_name = _get_config_value(profile, 'org_name')
if repo_name is None:
repo_name = _get_config_value(profile, 'repo_name')
action = '/'.join(['repos', org_name, repo_name])
args = {}
# Build API arguments, as necessary.
if milestone:
args['milestone'] = milestone
if assignee:
args['assignee'] = assignee
if creator:
args['creator'] = creator
if mentioned:
args['mentioned'] = mentioned
if labels:
args['labels'] = labels
if since:
args['since'] = since
if per_page:
args['per_page'] = per_page
# Only pass the following API args if they're not the defaults listed.
if state and state != 'open':
args['state'] = state
if sort and sort != 'created':
args['sort'] = sort
if direction and direction != 'desc':
args['direction'] = direction
ret = {}
issues = _query(profile, action=action, command='issues', args=args)
for issue in issues:
# Pull requests are included in the issue list from GitHub
# Let's not include those in the return.
if issue.get('pull_request'):
continue
issue_id = issue.get('id')
if output == 'full':
ret[issue_id] = issue
else:
ret[issue_id] = _format_issue(issue)
return ret
def get_milestones(repo_name=None,
profile='github',
state='open',
sort='due_on',
direction='asc',
output='min',
per_page=None):
'''
Return information about milestones for a given repository.
.. versionadded:: Carbon
repo_name
The name of the repository for which to list issues. This argument is
required, either passed via the CLI, or defined in the configured
profile. A ``repo_name`` passed as a CLI argument will override the
repo_name defined in the configured profile, if provided.
profile
The name of the profile configuration to use. Defaults to ``github``.
state
The state of the milestone. Either ``open``, ``closed``, or ``all``.
Default is ``open``.
sort
What to sort results by. Either ``due_on`` or ``completeness``. Default
is ``due_on``.
direction
The direction of the sort. Either ``asc`` or ``desc``. Default is ``asc``.
output
The amount of data returned by each issue. Defaults to ``min``. Change
to ``full`` to see all issue output.
per_page
GitHub paginates data in their API calls. Use this value to increase or
decrease the number of issues gathered from GitHub, per page. If not set,
GitHub defaults are used.
CLI Example:
.. code-block:: bash
salt myminion github.get_milestones
'''
org_name = _get_config_value(profile, 'org_name')
if repo_name is None:
repo_name = _get_config_value(profile, 'repo_name')
action = '/'.join(['repos', org_name, repo_name])
args = {}
if per_page:
args['per_page'] = per_page
# Only pass the following API args if they're not the defaults listed.
if state and state != 'open':
args['state'] = state
if sort and sort != 'due_on':
args['sort'] = sort
if direction and direction != 'asc':
args['direction'] = direction
ret = {}
milestones = _query(profile, action=action, command='milestones', args=args)
for milestone in milestones:
milestone_id = milestone.get('id')
if output == 'full':
ret[milestone_id] = milestone
else:
milestone.pop('creator')
milestone.pop('html_url')
milestone.pop('labels_url')
ret[milestone_id] = milestone
return ret
def get_milestone(number=None,
name=None,
repo_name=None,
profile='github',
output='min'):
'''
Return information about a single milestone in a named repository.
.. versionadded:: Carbon
number
The number of the milestone to retrieve. If provided, this option
will be favored over ``name``.
name
The name of the milestone to retrieve.
repo_name
The name of the repository for which to list issues. This argument is
required, either passed via the CLI, or defined in the configured
profile. A ``repo_name`` passed as a CLI argument will override the
repo_name defined in the configured profile, if provided.
profile
The name of the profile configuration to use. Defaults to ``github``.
output
The amount of data returned by each issue. Defaults to ``min``. Change
to ``full`` to see all issue output.
CLI Example:
.. code-block:: bash
salt myminion github.get_milestone 72
salt myminion github.get_milestone milestone_name=my_milestone
'''
ret = {}
if not any([number, name]):
raise CommandExecutionError(
'Either a milestone \'name\' or \'number\' must be provided.'
)
org_name = _get_config_value(profile, 'org_name')
if repo_name is None:
repo_name = _get_config_value(profile, 'repo_name')
action = '/'.join(['repos', org_name, repo_name])
if number:
command = 'milestones/' + str(number)
milestone_data = _query(profile, action=action, command=command)
milestone_id = milestone_data.get('id')
if output == 'full':
ret[milestone_id] = milestone_data
else:
milestone_data.pop('creator')
milestone_data.pop('html_url')
milestone_data.pop('labels_url')
ret[milestone_id] = milestone_data
return ret
else:
milestones = get_milestones(repo_name=repo_name, profile=profile, output=output)
for key, val in milestones.iteritems():
if val.get('title') == name:
ret[key] = val
return ret
return ret
def get_repo_info(repo_name, profile='github'):
'''
Return information for a given repo.
.. versionadded:: Carbon
repo_name
The name of repository.
profile
The name of the profile configuration to use. Defaults to ``github``.
CLI Example:
.. code-block:: bash
salt myminion github.get_repo_info salt
salt myminion github.get_repo_info salt profile='my-github-profile'
'''
ret = {}
org_name = _get_config_value(profile, 'org_name')
client = _get_client(profile)
repo = client.get_repo('/'.join([org_name, repo_name]))
if repo:
# client.get_repo will return a github.Repository.Repository object,
# even if the repo is invalid. We need to catch the exception when
# we try to perform actions on the repo object, rather than above
# the if statement.
try:
ret['id'] = repo.id
except github.UnknownObjectException:
raise CommandExecutionError(
'The \'{0}\' repository under the \'{1}\' organization could not '
'be found.'.format(
repo_name,
org_name
)
)
ret['name'] = repo.name
ret['full_name'] = repo.full_name
ret['owner'] = repo.owner.login
ret['private'] = repo.private
ret['html_url'] = repo.html_url
ret['description'] = repo.description
ret['fork'] = repo.fork
ret['homepage'] = repo.homepage
ret['size'] = repo.size
ret['stargazers_count'] = repo.stargazers_count
ret['watchers_count'] = repo.watchers_count
ret['language'] = repo.language
ret['open_issues_count'] = repo.open_issues_count
ret['forks'] = repo.forks
ret['open_issues'] = repo.open_issues
ret['watchers'] = repo.watchers
ret['default_branch'] = repo.default_branch
return ret
def list_repos(profile='github'):
'''
List all repositories within the organization. Includes public and private
repositories within the organization Dependent upon the access rights of
the profile token.
.. versionadded:: Carbon
profile
The name of the profile configuration to use. Defaults to ``github``.
.. code-block:: bash
salt myminion github.list_repos
salt myminion github.list_repos profile='my-github-profile'
'''
return [repo.name for repo in _get_repos(profile)]
def list_private_repos(profile='github'):
'''
List private repositories within the organization. Dependent upon the access
rights of the profile token.
.. versionadded:: Carbon
profile
The name of the profile configuration to use. Defaults to ``github``.
.. code-block:: bash
salt myminion github.list_private_repos
salt myminion github.list_private_repos profile='my-github-profile'
'''
repos = []
for repo in _get_repos(profile):
if repo.private is True:
repos.append(repo.name)
return repos
def list_public_repos(profile='github'):
'''
List public repositories within the organization.
.. versionadded:: Carbon
profile
The name of the profile configuration to use. Defaults to ``github``.
.. code-block:: bash
salt myminion github.list_public_repos
salt myminion github.list_public_repos profile='my-github-profile'
'''
repos = []
for repo in _get_repos(profile):
if repo.private is False:
repos.append(repo.name)
return repos
def _format_issue(issue):
'''
Helper function to format API return information into a more manageable
and useful dictionary.
issue
The issue to format.
'''
ret = {'id': issue.get('id'),
'issue_number': issue.get('number'),
'state': issue.get('state'),
'title': issue.get('title'),
'user': issue.get('user').get('login')}
assignee = issue.get('assignee')
if assignee:
assignee = assignee.get('login')
labels = issue.get('labels')
label_names = []
for label in labels:
label_names.append(label.get('name'))
milestone = issue.get('milestone')
if milestone:
milestone = milestone.get('title')
ret['assignee'] = assignee
ret['labels'] = label_names
ret['milestone'] = milestone
return ret
def _query(profile,
action=None,
command=None,
args=None,
method='GET',
header_dict=None,
data=None,
url='https://api.github.com/',
per_page=None):
'''
Make a web call to the GitHub API and deal with paginated results.
'''
if not isinstance(args, dict):
args = {}
if action:
url += action
if command:
url += '/{0}'.format(command)
log.debug('GitHub URL: {0}'.format(url))
if 'access_token' not in args.keys():
args['access_token'] = _get_config_value(profile, 'token')
if per_page and 'per_page' not in args.keys():
args['per_page'] = per_page
if header_dict is None:
header_dict = {}
if method != 'POST':
header_dict['Accept'] = 'application/json'
decode = True
if method == 'DELETE':
decode = False
# GitHub paginates all queries when returning many items.
# Gather all data using multiple queries and handle pagination.
complete_result = []
next_page = True
page_number = ''
while next_page is True:
if page_number:
args['page'] = page_number
result = salt.utils.http.query(url,
method,
params=args,
data=data,
header_dict=header_dict,
decode=decode,
decode_type='json',
headers=True,
status=True,
text=True,
hide_fields=['access_token'],
opts=__opts__,
)
log.debug(
'GitHub Response Status Code: {0}'.format(
result['status']
)
)
if result['status'] == 200:
if isinstance(result['dict'], dict):
# If only querying for one item, such as a single issue
# The GitHub API returns a single dictionary, instead of
# A list of dictionaries. In that case, we can return.
return result['dict']
complete_result = complete_result + result['dict']
else:
raise CommandExecutionError(
'GitHub Response Error: {0}'.format(result.get('error'))
)
try:
link_info = result.get('headers').get('Link').split(',')[0]
except AttributeError:
# Only one page of data was returned; exit the loop.
next_page = False
continue
if 'next' in link_info:
# Get the 'next' page number from the Link header.
page_number = link_info.split('>')[0][-1]
else:
# Last page already processed; break the loop.
next_page = False
return complete_result