Source code for IGitt.GitLab.GitLabCommit

"""
Contains the abstraction for a commit in GitLab.
"""
from typing import Optional
from typing import Set
from typing import Union
from urllib.parse import quote_plus

from IGitt import ElementDoesntExistError
from IGitt.GitHub.GitHubCommit import get_diff_index
from IGitt.GitLab import GitLabMixin
from IGitt.GitLab import GitLabOAuthToken, GitLabPrivateToken
from IGitt.GitLab.GitLabComment import GitLabComment
from IGitt.GitLab.GitLabRepository import GitLabRepository
from IGitt.GitLab.GitLabIssue import GitLabIssue
from IGitt.Interfaces import get, post
from IGitt.Interfaces.Comment import CommentType
from IGitt.Interfaces.Commit import Commit
from IGitt.Interfaces.CommitStatus import Status, CommitStatus

GL_STATE_TRANSLATION = {
    Status.RUNNING: 'running',
    Status.CANCELED: 'canceled',
    Status.ERROR: 'failed',
    Status.FAILED: 'failed',
    Status.PENDING: 'pending',
    Status.SUCCESS: 'success',
    Status.MANUAL: 'manual',
    Status.CREATED: 'created',
    Status.SKIPPED: 'skipped'
}

INV_GL_STATE_TRANSLATION = {val: key for key, val
                            in GL_STATE_TRANSLATION.items()}
GITLAB_KEYWORD_REGEX = {'fix': r'[Ff]ix(?:e[sd]|ing)?',
                        'close': r'[Cc]los(?:e[sd]?|ing)',
                        'resolve': r'[Rr]esolv(?:e[sd]?|ing)'}


[docs]class GitLabCommit(GitLabMixin, Commit): """ Represents a commit on GitLab. """ def __init__(self, token: Union[GitLabOAuthToken, GitLabPrivateToken], repository: str, sha: Optional[str], branch: Optional[str]=None): """ Creates a new GitLabCommit object. :param token: A Token object to be used for authentication. :param repository: The full repository name. :param sha: The full commit SHA, if None given provide a branch. :param branch: A branch name if SHA is unavailable. Note that lazy loading won't work in that case. """ assert sha or branch, 'Either full SHA or branch name has to be given!' self._token = token self._repository = repository self._sha = sha self._branch = branch self._url = '/projects/{id}/repository/commits/{sha}'.format( id=quote_plus(repository), sha=sha if sha else branch) @property def message(self) -> str: """ Returns the commit message. :return: Commit message as string. """ return self.data['message'] @property def sha(self): """ Retrieves the SHA of the commit: >>> from os import environ >>> commit = GitLabCommit( ... GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']), ... 'gitmate-test-user/test', '674498' ... ) >>> commit.sha '674498' :return: A string holding the SHA of the commit. """ return self._sha if self._sha else self.data['id'] @property def repository(self): """ Retrieves the repository that holds this commit. >>> from os import environ >>> commit = GitLabCommit( ... GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']), ... 'gitmate-test-user/test', '3fc4b86' ... ) >>> commit.repository.full_name 'gitmate-test-user/test' :return: A usable Repository instance. """ return GitLabRepository(self._token, self._repository) @property def parent(self): """ Retrieves the parent commit. In case of a merge commit the first parent will be returned. >>> from os import environ >>> commit = GitLabCommit( ... GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']), ... 'gitmate-test-user/test', '3fc4b86' ... ) >>> commit.parent.sha '674498fd415cfadc35c5eb28b8951e800f357c6f' :return: A Commit object. """ return GitLabCommit(self._token, self._repository, self.data['parent_ids'][0])
[docs] def get_statuses(self) -> Set[CommitStatus]: """ Retrieves the all commit statuses. :return: A (frozen)set of CommitStatus objects. :raises RuntimeError: If something goes wrong (network, auth...). """ # rebuild the url with full sha because gitlab doesn't work that way url = '/projects/{repo}/repository/commits/{sha}/statuses'.format( repo=quote_plus(self._repository), sha=self.sha) statuses = get(self._token, self.absolute_url(url)) # Only the first of each context is the one we want result = set() contexts = set() for status in statuses: if status['name'] not in contexts: result.add(CommitStatus( INV_GL_STATE_TRANSLATION[status['status']], status['description'], status['name'], status['target_url'])) contexts.add(status['name']) return result
@property def combined_status(self) -> Status: """ Retrieves a combined status of all the commits. :return: Status.FAILED if any of the commits report as error or failure or canceled Status.PENDING if there are no statuses or a commit is pending or a test is running Status.SUCCESS if the latest status for all commits is success :raises AssertionError: If the status couldn't be matched with any of the possible outcomes Status.SUCCESS, Status.FAILED and Status.PENDING. """ statuses = set(map(lambda status: status.status, self.get_statuses())) if ( not len(statuses) or Status.PENDING in statuses or Status.RUNNING in statuses or Status.CREATED in statuses): return Status.PENDING if ( Status.FAILED in statuses or Status.ERROR in statuses or Status.CANCELED in statuses): return Status.FAILED assert all(status in {Status.SUCCESS, Status.MANUAL} for status in statuses) return Status.SUCCESS
[docs] def set_status(self, status: CommitStatus): """ Adds the given status to the commit. >>> from os import environ >>> commit = GitLabCommit( ... GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']), ... 'gitmate-test-user/test', '3fc4b86' ... ) >>> status = CommitStatus(Status.FAILED, 'Theres a problem', ... 'gitmate/test') >>> commit.set_status(status) >>> commit.get_statuses().pop().description 'Theres a problem' If a status with the same context already exists, it will be bluntly overridden: >>> status.status = Status.SUCCESS >>> status.description = "Theres no problem" >>> commit.set_status(status) >>> len(commit.get_statuses()) 1 >>> commit.get_statuses().pop().description 'Theres no problem' :param status: The CommitStatus to set to this commit. :raises RuntimeError: If something goes wrong (network, auth...). """ data = {'state': GL_STATE_TRANSLATION[status.status], 'target_url': status.url, 'description': status.description, 'name': status.context} status_url = '/projects/{repo}/statuses/{sha}'.format( repo=quote_plus(self._repository), sha=self.sha) post(self._token, self.absolute_url(status_url), data)
[docs] def get_patch_for_file(self, filename: str): r""" Retrieves the unified diff for the commit. >>> from os import environ >>> commit = GitLabCommit( ... GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']), ... 'gitmate-test-user/test', '3fc4b86' ... ) >>> assert (commit.get_patch_for_file('README.md') == ... '--- a/README.md\n+++ b/README.md\n@@ -1,2 +1,4 @@\n ' ... '# test\n a test repo\n+\n+a tst pr\n') But only if it exists! >>> commit.get_patch_for_file('IDONTEXISTFILE') Traceback (most recent call last): ... IGitt.ElementDoesntExistError: The file does not exist. :param filename: The file to retrieve patch for. :return: A string containing the patch. :raises ElementDoesntExistError: If the given filename does not exist. """ diff = get(self._token, self.url + '/diff') for patch in diff: if filename in (patch['new_path'], patch['old_path']): return patch['diff'] raise ElementDoesntExistError('The file does not exist.')
[docs] def comment(self, message: str, file: Optional[str]=None, line: Optional[int]=None, mr_number: Optional[int]=None) -> GitLabComment: """ Places a comment on the commit. >>> from os import environ >>> commit = GitLabCommit( ... GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']), ... 'gitmate-test-user/test', '3fc4b86' ... ) So this line places a comment on the bottom of the commit, not associated to any particular piece of code: >>> commit.comment("An issue is here!") However, we can also comment on a particular file and line, if that is included in the diff: >>> commit.comment("Here in line 4, there's a spelling mistake!", ... 'README.md', 4) If you supply the ``pr_number`` argument, the comment will appear in the review UI of that pull request: >>> commit.comment("Here in line 4, there's a spelling mistake!", ... 'README.md', 4, mr_number=7) Beat that! Of course, there's a lot of error handling. If you give the wrong file, the comment will appear below the commit with a note about the commit, file and line: >>> commit.comment("Oh, this'll end up below!!", 'READMENOT.md', 4) Also if the line isn't contained in the diff GitLab won't accept that and it'll also end up below - sorry! >>> commit.comment("Oh, this'll too end up below!!", 'README.md', 8) If you give a pull request, the comment will appear on the PR instead: >>> commit.comment("Oh, this'll too end up on the PR.", ... 'README.md', 8, mr_number=7) :param message: The body of the comment. :param file: The file to place the comment, relative to repo root. :param line: The line in the file in the comment or None. :param mr_number: The iid of a merge request if this should end up in the discussions UI of the merge request. """ data = {'note': message, 'line_type': 'new'} if file is not None and line is not None: try: patch = self.get_patch_for_file(file) index = get_diff_index(patch, line) if index: # Else, fallback to comment below file data['line'] = index data['path'] = file except ElementDoesntExistError: pass # Fallback to comment below the file if 'line' not in data: file_str = '' if file is None else ', file ' + file line_str = '' if line is None else ', line ' + str(line) data['note'] = ('Comment on ' + self.sha + file_str + line_str + '.\n\n' + data['note']) # post a comment on commit if 'line' in data and 'path' in data or mr_number is None: url = '/projects/{id}/repository/commits/{sha}/comments'.format( id=quote_plus(self._repository), sha=self.sha) res = post(self._token, self.absolute_url(url), data) return # fallback to post the comment on relevant merge request if mr_number is not None: data['body'] = data['note'] # because gitlab is stupid url = '/projects/{id}/merge_requests/{mr_iid}/notes'.format( id=quote_plus(self._repository), mr_iid=mr_number) res = post(self._token, self.absolute_url(url), data) return GitLabComment.from_data(res, self._token, self._repository, mr_number, CommentType.MERGE_REQUEST, res['id'])
@property def unified_diff(self): """ Retrieves the unified diff for the commit excluding the diff index. """ return '\n'.join(patch['diff'] for patch in get(self._token, self.url + '/diff') ) @property def closes_issues(self) -> Set[GitLabIssue]: """ Returns a set of GitLabIssue objects which would be closed upon merging this pull request. """ issues = self._get_closes_issues() return {GitLabIssue(self._token, repo_name, number) for number, repo_name in issues} @property def mentioned_issues(self) -> Set[GitLabIssue]: """ Returns a set of GitLabIssue objects which are related to the merge request. """ issues = self._get_mentioned_issues() return {GitLabIssue(self._token, repo_name, number) for number, repo_name in issues} @property def will_fix_issues(self) -> Set[GitLabIssue]: """ Returns a set of GitLabIssue objects which would be fixed as stated in this commit message. """ issues = self.get_keywords_issues(GITLAB_KEYWORD_REGEX['fix'], [self.message]) return {GitLabIssue(self._token, repo_name, number) for number, repo_name in issues} @property def will_close_issues(self) -> Set[GitLabIssue]: """ Returns a set of GitLabIssue objects which would be closed as stated in this commit message. """ issues = self.get_keywords_issues(GITLAB_KEYWORD_REGEX['close'], [self.message]) return {GitLabIssue(self._token, repo_name, number) for number, repo_name in issues} @property def will_resolve_issues(self) -> Set[GitLabIssue]: """ Returns a set of GitLabIssue objects which would be resolved as stated in this commit message. """ issues = self.get_keywords_issues(GITLAB_KEYWORD_REGEX['resolve'], [self.message]) return {GitLabIssue(self._token, repo_name, number) for number, repo_name in issues}