Example #1
0
 def fetch_workspaces(self):
     """Fetch Github repository Zenhub workspaces."""
     for config in self.configs:
         gh_host = config.get('github_host', GH_HOST_URL)
         zh_root = config.get('api_root', ZH_API_ROOT)
         repo = config['github_repo']
         repo_hash = get_sha256_hash([gh_host, repo], 10)
         fname = f'zh_repo_{repo_hash}_workspaces.json'
         self.config.add_evidences([
             RawEvidence(
                 fname, 'issues', DAY,
                 f'Zenhub workspaces for {gh_host}/{repo} repository')
         ])
         with raw_evidence(self.locker, f'issues/{fname}') as evidence:
             if evidence:
                 if gh_host not in self.gh_pool.keys():
                     self.gh_pool[gh_host] = Github(base_url=gh_host)
                 if zh_root not in self.zh_pool.keys():
                     self.zh_pool[zh_root] = BaseSession(zh_root)
                     service = 'zenhub'
                     if zh_root != ZH_API_ROOT:
                         service = 'zenhub_enterprise'
                     token = self.config.creds[service].token
                     self.zh_pool[zh_root].headers.update({
                         'Content-Type':
                         'application/json',
                         'X-Authentication-Token':
                         token
                     })
                 workspaces = self._get_workspaces(repo,
                                                   config.get('workspaces'),
                                                   gh_host, zh_root)
                 evidence.set_content(json.dumps(workspaces))
Example #2
0
    def session(cls, url=None, creds=None, **headers):
        """
        Provide a requests session object with User-Agent header.

        :param url: optional base URL for the session requests to use.  A url
          argument triggers a new session object to be created whereas no url
          argument will return the current session object if one exists.
        :param creds: optional authentication credentials.
        :param headers: optional kwargs to add to session headers.

        :returns: a requests Session object.
        """
        if url is not None and hasattr(cls, '_session'):
            cls._session.close()
            delattr(cls, '_session')
        if not hasattr(cls, '_session'):
            if url:
                cls._session = BaseSession(url)
            else:
                cls._session = requests.Session()
            if creds:
                cls._session.auth = creds
            cls._session.headers.update(headers)
            org = cls.config.raw_config.get('org', {}).get('name', '')
            ua = f'{org.lower().replace(" ", "-")}-compliance-checks'
            cls._session.headers.update({'User-Agent': ua})
        return cls._session
Example #3
0
    def __init__(self, config=None, base_url='https://github.com'):
        """Construct the Github service object."""
        if not config:
            config = Config()

        self.base_url = base_url
        api_url = 'https://api.github.com'
        service = 'github'
        if self.base_url != 'https://github.com':
            service = 'github_enterprise'
            api_url = f'{self.base_url}/api/v3/'
        self._creds = config[service]
        self.session = BaseSession(api_url)
        token = self._creds.token
        if hasattr(self._creds, 'username'):
            self.session.auth = (self._creds.username, token)
        else:
            self.session.headers['Authorization'] = 'token ' + token
        self.session.headers.update(
            {'Accept': 'application/vnd.github.inertia-preview+json'})
Example #4
0
class Github(object):
    """Github service helper class."""
    def __init__(self, config=None, base_url='https://github.com'):
        """Construct the Github service object."""
        if not config:
            config = Config()

        self.base_url = base_url
        api_url = 'https://api.github.com'
        service = 'github'
        if self.base_url != 'https://github.com':
            service = 'github_enterprise'
            api_url = f'{self.base_url}/api/v3/'
        self._creds = config[service]
        self.session = BaseSession(api_url)
        token = self._creds.token
        if hasattr(self._creds, 'username'):
            self.session.auth = (self._creds.username, token)
        else:
            self.session.headers['Authorization'] = 'token ' + token
        self.session.headers.update(
            {'Accept': 'application/vnd.github.inertia-preview+json'})

    def extract_path_chunks(self, url):
        """Retrieve the path from the url."""
        if not url.startswith(self.base_url):
            raise ValueError(f'URL "{url}" is not valid. '
                             f'Expected base URL is: "{self.base_url}"')
        return url.split(self.base_url)[1].strip('/').split('/')

    def extract_owner_repo(self, url):
        """Retrieve the owner (org) and repo from the url."""
        path_chunks = self.extract_path_chunks(url)
        if len(path_chunks) < 2:
            raise ValueError(
                f'URL "{url}" needs to include, at least, "<owner>/<repo>"')
        return path_chunks[0:2]

    def extract_owner_repo_issue(self, url):
        """Retrieve the owner (org), repo and issue number from the url."""
        path_chunks = self.extract_path_chunks(url)
        if len(path_chunks) != 4 or path_chunks[2] != 'issues':
            raise ValueError(
                f'URL "{url}" '
                'needs to include "<owner>/<repo>/issues/<number>"')
        owner, repo, _, issue_number = path_chunks
        return owner, repo, issue_number

    def get_all_projects(self, repo_path):
        """
        Retrieve all GH repo projects.

        repo_path looks like: my-gh-org/my-gh-repo
        """
        owner, repo = repo_path.split('/')

        # /repos/:owner/:repo/projects
        return self._make_request('get',
                                  '/'.join(['repos', owner, repo, 'projects']))

    def get_project(self, project, org=False):
        """
        Retrieve the GH org or org/repo project.

        For a repo project the project variable looks like:
            my-gh-org/my-gh-repo/projects/1

        For an org project the project variable looks like:
            my-gh-org/projects/1
        """
        pieces = []
        if org:
            owner, _, number = project.split('/')
            # /orgs/:org/projects
            pieces = ['orgs', owner]
        else:
            owner, repo, _, number = project.split('/')
            # /repos/:owner/:repo/projects
            pieces = ['repos', owner, repo]
        pieces.append('projects')
        r = self._make_request('get', '/'.join(pieces))
        return [x['id'] for x in r if x['number'] == int(number)][0]

    def get_columns(self, project_id):
        """Retrieve the columns for a project."""
        return self._make_request(
            'get', '/'.join(['projects',
                             str(project_id), 'columns']))

    def get_all_cards(self, columns):
        """Retrieve all cards for a given list of project columns."""
        cards = OrderedDict()
        for column_id in columns:
            cards[column_id] = self.get_cards(column_id)
        return cards

    def get_cards(self, column_id):
        """Retrieve all cards for a given project column."""
        return self._paginate_api('/'.join(
            ['projects', 'columns',
             str(column_id), 'cards']))

    def move_card(self, card, to_column_id):
        """Move a card from one project column to another."""
        data = {'position': 'bottom', 'column_id': to_column_id}
        return self._make_request(
            'post',
            '/'.join(['projects', 'columns', 'cards',
                      str(card), 'moves']),
            json=data)

    def add_card(self, column_id, message=None, issue=0):
        """Create a card in a project column."""
        data = {}
        if issue > 0:
            data = {'content_id': issue, 'content_type': 'Issue'}
        else:
            data = {'note': message}
        return self._make_request(
            'post',
            '/'.join(['projects', 'columns',
                      str(column_id), 'cards']),
            json=data)

    def add_milestone(self, owner, repo, milestone):
        """Create a repository milestone."""
        return self._make_request('post',
                                  '/'.join(
                                      ['repos', owner, repo, 'milestones']),
                                  json=milestone)

    def list_milestones(self,
                        owner,
                        repo,
                        state='open',
                        sort='due_on',
                        direction='asc'):
        """Retrieve a repository's milestones."""
        return self._paginate_api(
            '/'.join(['repos', owner, repo, 'milestones']), **{
                'state': state,
                'sort': sort,
                'direction': direction
            })

    def add_issue(self,
                  owner,
                  repo,
                  title,
                  body='',
                  annotation=None,
                  **kwargs):
        """Create a repository issue."""
        issue = {'title': title, 'body': body}
        issue.update(kwargs)
        if annotation:
            issue['body'] = self._annotate_body(issue['body'], annotation)
        return self._make_request('POST',
                                  '/'.join(['repos', owner, repo, 'issues']),
                                  json=issue)

    def patch_issue(self, owner, repo, issue, annotation=None, **params):
        """Edit a repository issue."""
        if annotation and 'body' in params:
            params['body'] = self._annotate_body(params['body'], annotation)
        return self._make_request(
            'PATCH',
            '/'.join(['repos', owner, repo, 'issues',
                      str(issue)]),
            json=params)

    def get_issue(self, owner, repo, issue, parse_annotations=False):
        """
        Retrieve the content and metadata for a repository issue.

        If parse_annotations is True, then returns (issue, body, annotations),
        where body is the body with the JSON annotations removed, and
        annotations is a dictionary of the annotations. The annotations will
        be an empty dictionary if there aren't any.
        """
        issue = self._make_request(
            'get', '/'.join(['repos', owner, repo, 'issues',
                             str(issue)]))
        if parse_annotations:
            body, annotations = extract_annotations(issue['body'])
            return issue, body, annotations
        return issue

    def update_annotations(self, owner, repo, issue, annotations):
        """
        Update the body of an existing issue, only changing the annotations.

        If there are no existing annotations, the annotation block will be
        added. If there is existing annotations, the given annotations will
        be merged into them.
        """
        _, body, old_anno = self.get_issue(owner,
                                           repo,
                                           issue,
                                           parse_annotations=True)
        new_anno = deep_merge(old_anno, annotations)
        return self.patch_issue(owner,
                                repo,
                                issue,
                                annotation=new_anno,
                                body=body)

    def get_issue_comments(self, owner, repo, issue, parse_annotations=False):
        """Retrieve a repository issue's comments."""
        comments = self._make_request(
            'get',
            '/'.join(['repos', owner, repo, 'issues',
                      str(issue), 'comments']))
        if parse_annotations:
            annotated_comments = []
            # TODO: make this actually work...
            for comment in comments:
                body, annotations = extract_annotations(comment['body'])
                annotated_comments.extend((body, annotations))
            return comments, annotated_comments
        return comments

    def get_issues_page(self, owner, repo, **kwargs):
        """Retrieve a repository's issues by page."""
        params = kwargs
        # get the page number or default to 1
        params['page'] = params.get('page', 1)
        response = self._make_request('get',
                                      '/'.join(
                                          ['repos', owner, repo, 'issues']),
                                      parse=False,
                                      params=params)
        return response

    def get_all_issues(self, owner, repo, **kwargs):
        """Retrieve all issues for a repository."""
        all_issues = {}
        page = 1
        response = self.get_issues_page(owner, repo, page=page, **kwargs)
        max_page = 1
        if 'Link' in response.headers:
            # Link is only present if there are multiple pages
            link = response.headers['Link']
            urls = link.replace('>', '').replace('<', '').split()
            parsed_url = urlparse(urls[2].strip(';'))
            max_page = int(parse_qs(parsed_url.query)['page'][0])
        while response:
            for i in response.json():
                all_issues[i['number']] = i
            page += 1
            if page > max_page:
                response = False
            else:
                response = self.get_issues_page(owner,
                                                repo,
                                                page=page,
                                                **kwargs)
        return all_issues

    def get_issue_template(self,
                           owner,
                           repo,
                           template_name,
                           strip_annotations=False,
                           strip_header=True,
                           annotations=None,
                           render=False):
        """
        Retrieve the contents of an issue template based on template name.

        The .md extension and any numeric prefix) from the owner and repo
        are ignored.  Both the header (the YAML section at the top), and any
        defined annotations (the JSON code block inside the issue body) are
        returned as dictionaries along with the body of the template. You can
        pass ``strip_annotations`` or ``strip_header`` to remove these sections
        from the body that's returned.  You can pass a dictionary as
        ``annotations``, and the values will be merged with those extracted
        from the template.

        If ``render`` is ``True``, then all annotations will be substituted on
        the body and header.
        """
        # get a list of all templates in this repo
        path_elements = [
            'repos', owner, repo, 'contents', '.github', 'ISSUE_TEMPLATE'
        ]
        response = self._make_request('get', '/'.join(path_elements))
        templates = [t['name'] for t in response]

        # find the required template, ignoring any numeric prefix
        template_file = next(
            (t for t in templates
             if re.sub(r'(^[0-9]+\.)?(.*)\.md$', r'\2', t) == template_name),
            None)
        if not template_file:
            raise ValueError(f'Template {template_name} not found')

        # get the template from .github/ISSUE_TEMPLATE in the
        # repo (master branch)
        path_elements = [
            'repos', owner, repo, 'contents', '.github', 'ISSUE_TEMPLATE',
            template_file
        ]
        response = self._make_request('get', '/'.join(path_elements))

        # result is base64 encoded (and bytes in py3)
        assert response['encoding'] == 'base64'
        template = base64.b64decode(response['content'])
        template = template.decode(sys.stdout.encoding)

        # extract header data structure (YAML)
        body_no_header, extracted_header = extract_header(template)

        # optionally strip off the leading header section
        if strip_header:
            template = body_no_header

        # extract annotations (JSON)
        body_no_annotations, extracted_annotations = extract_annotations(
            template)

        # if we were passed some annotations,
        # merge them into what's in the template
        if annotations:
            deep_merge(extracted_annotations, annotations)

        if render:
            body_no_annotations = body_no_annotations.format(
                **extracted_annotations)
            extracted_header = {
                k: v.format(**extracted_annotations)
                for k, v in extracted_header.items()
            }
        # optionally strip off the annotations section
        if strip_annotations:
            template = body_no_annotations
        else:
            template = self._annotate_body(body_no_annotations,
                                           extracted_annotations)

        return template, extracted_annotations, extracted_header

    def search_issues(self,
                      query,
                      sort=None,
                      order=None,
                      owner=None,
                      repo=None):
        """
        Perform a search against all issues based on the query provided.

        If an owner and repo are passed in, then restrict the results to
        that repo. Note that this can also be done in the query directly.
        """
        if not query:
            raise ValueError('Must specify a query')
        if owner and repo:
            query += f' repo:{owner}/{repo}'
        return self._paginate_api('search/issues',
                                  q=query,
                                  sort=sort,
                                  order=order)

    def add_issue_comment(self, owner, repo, issue, body, annotation=None):
        """Create a comment for a repository issue."""
        if annotation:
            body = self._annotate_body(body, annotation)
        return self._make_request(
            'POST',
            '/'.join(['repos', owner, repo, 'issues',
                      str(issue), 'comments']),
            json={'body': body})

    def create_project(self, repo, name, body='', org=False):
        """Create a repository project."""
        owner, repo = repo.split('/')
        return self.creates_for_project(
            '/'.join(['repos', owner, repo, 'projects']), {
                'name': name,
                'body': body
            })

    def create_column(self, project_id, column, org=False):
        """Create a project column."""
        return self.creates_for_project(
            '/'.join(['projects', str(project_id), 'columns']),
            {'name': column})

    def creates_for_project(self, url, data, org=False):
        """Create a repository project based on a properly formed url."""
        if org:
            raise NotImplementedError('orgs not supported yet')
        return self._make_request('post', url, json=data)

    def rand_color(self):
        """Generate a random color for labels."""
        return (f'{random.randint(0, 255):02X}'
                f'{random.randint(0, 255):02X}'
                f'{random.randint(0, 255):02X}')

    def create_label(self, repo, name, org=False):
        """Create a label within a repository."""
        return self.creates_for_project('/'.join(['repos', repo, 'labels']), {
            'name': name,
            'color': self.rand_color()
        })

    def apply_labels(self, repo, issue, *labels):
        """
        Add label(s) to an issue.

        repo looks like: my-gh-org/my-gh-repo
        issue is an issue number (not id)
        API takes a json list of labels
        POST /repos/:owner/:repo/issues/:number/labels
        """
        response = self._make_request(
            'post',
            '/'.join(['repos', repo, 'issues',
                      str(issue), 'labels']),
            json={'labels': labels})

        return response

    def remove_labels(self, repo, issue, *labels):
        """
        Remove label(s) from an issue.

        repo looks like: my-gh-org/my-gh-repo
        issue is an issue number (not id)
        API takes a json list of labels
        POST /repos/:owner/:repo/issues/:number/labels
        """
        for line in labels:
            response = self._make_request(
                'delete',
                '/'.join(['repos', repo, 'issues',
                          str(issue), 'labels', line]))
        # Each response has all the labels, so only return the last one
        return response

    def get_repo_details(self, repo):
        """
        Retrieve a repository's metadata.

        :param repo: the organization/repository as a string.

        :returns: the repository's metadata details.
        """
        self.session.headers.update(
            {'Accept': 'application/vnd.github.v3+json'})
        return self._make_request('get', f'repos/{repo}')

    def get_commit_details(self, repo, since, branch='master', path=None):
        """
        Retrieve a repository branch's commit details since a given date/time.

        :param repo: the organization/repository as a string.
        :param since: the starting date/time as a datetime.
        :param branch: the branch as a string.  Defaults to master.
        :param path: if provided, only commits for the path will be returned.

        :returns: the repo branch's commit details since a given date/time.
        """
        self.session.headers.update(
            {'Accept': 'application/vnd.github.v3+json'})
        opts = {'since': since.strftime('%Y-%m-%dT%H:%M:%SZ'), 'sha': branch}
        if path:
            opts['path'] = path
        return self._make_request('get', f'repos/{repo}/commits', params=opts)

    def get_branch_protection_details(self, repo, branch='master'):
        """
        Retrieve a repository branch's branch protection details.

        :param repo: the organization/repository as a string.
        :param branch: the branch as a string.

        :returns: the repository branch's branch protection details.
        """
        self.session.headers.update(
            {'Accept': 'application/vnd.github.zzzax-preview+json'})
        return self._make_request(
            'get', f'repos/{repo}/branches/{branch}/protection')

    def _make_request(self, method, url, parse=True, **kwargs):
        r = self.session.request(method, url, **kwargs)
        r.raise_for_status()
        if parse:
            return r.json()
        return r

    def _annotate_body(self, body, annotation):
        anno_str = json.dumps(annotation, indent=2)
        return f'```application/json+utilitarian\n{anno_str}\n```\n{body}'

    def _paginate_api(self, api_url, **kwargs):
        params = kwargs
        params['page'] = params.get('page', 1)
        response = self._make_request('get',
                                      api_url,
                                      parse=False,
                                      params=params)
        max_page = 1
        all_items = []
        if 'Link' in response.headers:
            # Link is only present if there are multiple pages
            link = response.headers['Link']
            urls = link.replace('>', '').replace('<', '').split()
            parsed_url = urlparse(urls[2].strip(';'))
            max_page = int(parse_qs(parsed_url.query)['page'][0])
        while response:
            if api_url.startswith('search/'):
                all_items.extend(response.json()['items'])
            else:
                all_items.extend(response.json())
            params['page'] += 1
            if params['page'] > max_page:
                response = False
            else:
                response = self._make_request('get',
                                              api_url,
                                              parse=False,
                                              params=params)
        return all_items