Source code for IGitt.GitLab.GitLabIssue

"""
This contains the Issue implementation for GitLab.
"""
from datetime import datetime
from datetime import timedelta
from typing import List
from typing import Optional
from typing import Set
from typing import Union
from urllib.parse import quote_plus

from IGitt.GitLab import GitLabMixin
from IGitt.GitLab import GitLabOAuthToken, GitLabPrivateToken
from IGitt.GitLab.GitLabComment import GitLabComment
from IGitt.GitLab.GitLabReaction import GitLabReaction
from IGitt.GitLab.GitLabUser import GitLabUser
from IGitt.Interfaces.Comment import CommentType
from IGitt.Interfaces.Issue import Issue
from IGitt.Interfaces import get, put, post, delete
from IGitt.Interfaces import IssueStates
from IGitt.Interfaces import MergeRequestStates


[docs]class GitLabIssue(GitLabMixin, Issue): """ This class represents an issue on GitLab. """ def __init__(self, token: Union[GitLabOAuthToken, GitLabPrivateToken], repository: str, number: int): """ Creates a new GitLabIssue with the given credentials. >>> from os import environ >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']), ... 'gitmate-test-user/repo_that_doesnt_exist', 1) ... # doctest: +IGNORE_EXCEPTION_DETAIL Traceback (most recent call last): ... RuntimeError: ({'message': 'Not Found', ...}, 404) :param token: A Token object to be used for authentication. :param repository: The full name of the repository. e.g. ``sils/baritone``. :param number: The issue internal identification number. :raises RuntimeError: If something goes wrong (network, auth, ...) """ self._token = token self._repository = repository self._iid = number self._url = '/projects/{repo}/issues/{issue_iid}'.format( repo=quote_plus(repository), issue_iid=number) @property def repository(self): """ Returns the GitLab repository this issue is linked with as a GitLabRepository instance. """ from IGitt.GitLab.GitLabRepository import GitLabRepository return GitLabRepository(self._token, self._repository) @property def title(self) -> str: """ Retrieves the title of the issue. >>> from os import environ >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']), ... 'gitmate-test-user/test', 1) >>> issue.title 'Take it serious, son!' You can simply set it using the property setter: >>> issue.title = 'dont panic' >>> issue.title 'dont panic' >>> issue.title = 'Take it serious, son!' :return: The title of the issue - as string. """ return self.data['title'] @title.setter def title(self, new_title): """ Sets the title of the issue. :param new_title: The new title. """ self.data = put(self._token, self.url, {'title': new_title}) @property def number(self) -> int: """ Returns the issue "number" or id. >>> from os import environ >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']), ... 'gitmate-test-user/test', 1) >>> issue.number 1 :return: The number of the issue. """ return self._iid @property def assignees(self): """ Retrieves the assignee of the issue: >>> from os import environ >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']), ... 'gitmate-test-user/test', 1) >>> issue.assignees {'gitmate-test-user'} >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']), ... 'gitmate-test-user/test', 2) >>> issue.assignees # Returns empty set, unassigned {} :return: A set containing the usernames of assignees. """ return {GitLabUser.from_data(user, self._token, user['id']) for user in self.data['assignees']}
[docs] def assign(self, *usernames: List[GitLabUser]): """ Adds the user as one of the assignees of the issue. :param users: User objects of the users to be added as an assignee. """ self.assignees |= set(usernames)
[docs] def unassign(self, *users: List[GitLabUser]): """ Removes the user from the assignees of the issue. :param users: User objects of the users to be unassigned. """ self.assignees = self.assignees - set(users)
@assignees.setter def assignees(self, value: Set[GitLabUser]): """ Setter for assignees. """ self.data = put(self._token, self.url, {'assignee_ids': [u.identifier for u in value]}) @property def available_assignees(self) -> Set[GitLabUser]: """ Retrieves a set of available assignees for the issue. >>> from os import environ >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']), ... 'gitmate-test-user/test', 1) >>> {a.username for a in issue.available_assignees} {'gitmate-test-user'} :return: A set of GitLabUsers. """ return { GitLabUser.from_data(user, self._token, user['id']) for user in get( self._token, self.absolute_url( '/projects/' + quote_plus(self._repository) + '/members')) } @property def description(self) -> str: r""" Retrieves the main description of the issue. >>> from os import environ >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']), ... 'gitmate-test-user/test', 1) >>> issue.description 'I am a serious issue. Fix me soon, dude.' :return: A string containing the main description of the issue. """ return self.data['description'] if self.data['description'] else '' @description.setter def description(self, new_description): """ Sets the description of the issue. :param new_description: The new description. """ self.data = put(self._token, self.url, {'description': new_description}) @property def author(self) -> GitLabUser: """ Retrieves the author of the issue. :return: A GitLabUser object. """ return GitLabUser.from_data(self.data['author'], self._token, self.data['author']['id'])
[docs] def add_comment(self, body): """ Adds a comment to the issue. >>> from os import environ >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']), ... 'gitmate-test-user/test', 1) >>> comment = issue.add_comment("Doh!") You can use the comment right after: >>> comment.body 'Doh!' >>> comment.delete() The comment will be created by the user authenticated via the oauth token. :param body: The body of the new comment to create. :return: The newly created comment. """ result = post(self._token, self.url + '/notes', {'body': body}) return GitLabComment(self._token, self._repository, self.number, CommentType.ISSUE, result['id'])
@property def comments(self) -> List[GitLabComment]: r""" Retrieves comments from the issue. As of now, the list of comments is not sorted. Related issue on GitLab CE here - https://gitlab.com/gitlab-org/gitlab-ce/issues/32057 >>> from os import environ >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']), ... 'gitmate-test-user/test', 3) >>> comments = issue.comments >>> for comment in comments: ... print(comment.body) Stop staring at me. Go, get your work done. :return: A list of Comment objects. """ return [ GitLabComment.from_data( result, self._token, self._repository, self.number, CommentType.ISSUE, result['id'] ) for result in get(self._token, self.url + '/notes') ] @property def labels(self) -> Set[str]: """ Retrieves all labels associated with this bug. >>> from os import environ >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']), ... 'gitmate-test-user/test', 1) >>> issue.labels set() Use the property setter to set the labels: >>> issue.labels = {'a', 'b', 'c'} >>> sorted(issue.labels) ['a', 'b', 'c'] Use the empty set intuitively to clear all labels: >>> issue.labels = set() :return: A list of label captions (str). """ return set(self.data['labels']) @labels.setter def labels(self, value: Set[str]): """ Sets the value of labels to the given set of labels. :param value: A set of label texts. """ # Only if self.data is populated we actually save a request here if 'labels' in self.data and value == self.labels: return # No need to patch self.data = put(self._token, self.url, {'labels': ','.join(map(str, value))}) @property def available_labels(self) -> Set[str]: """ Retrieves a set of captions that are available for labelling bugs. >>> from os import environ >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']), ... 'gitmate-test-user/test', 1) >>> sorted(issue.available_labels) ['a', 'b', 'c'] :return: A set of label captions (str). """ return {label['name'] for label in get( self._token, self.absolute_url( '/projects/' + quote_plus(self._repository) + '/labels'))} @property def created(self)->datetime: """ Retrieves a timestamp on when the issue was created. >>> from os import environ >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']), ... 'gitmate-test-user/test', 4) >>> issue.created datetime.datetime(2017, 6, 5, 9, 45, 20, 678000) """ return datetime.strptime(self.data['created_at'], '%Y-%m-%dT%H:%M:%S.%fZ') @property def updated(self) -> datetime: """ Retrieves a timestamp on when the issue was updated the last time. >>> from os import environ >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']), ... 'gitmate-test-user/test', 4) >>> issue.updated datetime.datetime(2017, 6, 5, 9, 45, 56, 115000) """ return datetime.strptime(self.data['updated_at'], '%Y-%m-%dT%H:%M:%S.%fZ')
[docs] def close(self): """ Closes the issue. :raises RuntimeError: If something goes wrong (network, auth...). """ self.data = put(self._token, self.url, {'state_event': 'close'})
[docs] def reopen(self): """ Reopens the issue. :raises RuntimeError: If something goes wrong (network, auth...). """ self.data = put(self._token, self.url, {'state_event': 'reopen'})
[docs] def delete(self): """ Deletes the issue. :raises RuntimeError: If something goes wrong (network, auth...). """ delete(self._token, self.url)
@property def state(self): """ Get's the state of the issue. >>> from os import environ >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']), ... 'gitmate-test-user/test', 1) >>> issue.state <IssueStates.OPEN: 'open'> >>> str(issue.state) 'open' So if we close it: >>> issue.close() >>> issue.state <IssueStates.CLOSED: 'closed'> >>> str(issue.state) 'closed' And reopen it: >>> issue.reopen() >>> issue.state <IssueStates.OPEN: 'open'> Note: GitLab Issues & Merge Requests API underwent a change to have only two states, <IssueStates.OPEN: 'open'> or <IssueStates.CLOSED: 'closed'>. No 'reopened' state anymore. :return: Either <IssueStates.OPEN: 'open'> or <IssueStates.CLOSED: 'closed'>. """ if self.data['state'] == 'opened': self.data['state'] = 'open' return IssueStates[self.data['state'].upper()] @property def reactions(self) -> Set[GitLabReaction]: """ Retrieves the reactions / award emojis applied on the issue. """ url = self.url + '/award_emoji' reactions = get(self._token, url) return {GitLabReaction.from_data(r, self._token, self, r['id']) for r in reactions} @property def weight(self) -> Optional[int]: """ Retrieves the weight associated with the current issue. """ return self.data['weight'] @weight.setter def weight(self, value: int): """ Updates the weight associated with the current issue. """ self.data = put(self._token, self.url, {'weight': value})
[docs] @staticmethod def create(token: Union[GitLabOAuthToken, GitLabPrivateToken], repository: str, title: str, body: str='', issue_type: Optional[str]=None): """ Create a new issue with given title and body. >>> from os import environ >>> issue = GitLabIssue.create( ... GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']), ... 'gitmate-test-user/test', ... 'test issue title', ... 'sample description' ... ) >>> issue.state <IssueStates.OPEN: 'open'> Delete the issue to avoid filling the test repo with issues. >>> issue.delete() :return: GitLabIssue object of the newly created issue. """ url = '/projects/{repo}/issues'.format(repo=quote_plus(repository)) issue = post(token, GitLabIssue.absolute_url(url), {'title': title, 'description': body}) return GitLabIssue.from_data(issue, token, repository, issue['iid'])
@property def mrs_closed_by(self): """ Returns the merge requests that close this issue. """ from IGitt.GitLab.GitLabMergeRequest import GitLabMergeRequest url = '{url}/closed_by'.format(url=self._url) mrs = get(self._token, self.absolute_url(url)) return {GitLabMergeRequest.from_data(mr, self._token, self._repository, mr['iid']) for mr in mrs if mr['state'] == MergeRequestStates.MERGED.value} @property def milestone(self): """ Retrieves the milestone. """ from IGitt.GitLab.GitLabProjectMilestone import GitLabProjectMilestone return GitLabProjectMilestone.from_data( self.data['milestone'], self._token, self._repository, self.data['milestone']['id'] ) if self.data['milestone'] else None @milestone.setter def milestone(self, new_milestone): """ Setter for the Milestone. Delete the Milestone with passing a 'None' """ self.data = put( self._token, self.url, {'milestone_id': new_milestone.number if new_milestone else ''}) @property def time_estimate(self) -> timedelta: """ Retrieves the time_estimate from the 'time_estimate' attribute in the GitLab Issue. """ return timedelta(seconds=self.data['time_stats']['time_estimate']) @time_estimate.setter def time_estimate(self, new_time_estimate: timedelta): """ Setter for the time_estimate. Sets the time on 'human_time_estimate' since it's not possible to set 'time_estimate'. GitLab then automatically calculates 'time_estimate'. time_estimate is set to 0 when passing a None or 0. """ if new_time_estimate: human_time_estimate = \ str(int(new_time_estimate.total_seconds())) + 's' self.data = post(self._token, self.url + '/time_estimate', {'duration': human_time_estimate}) else: self.data = post(self._token, self.url + '/reset_time_estimate', {'duration': None}) @property def total_time_spent(self) -> timedelta: """ Retrieves the time_estimate from the 'total_time_spent' attribute in the GitLab Issue. """ return timedelta(seconds=self.data['time_stats']['total_time_spent']) @total_time_spent.setter def total_time_spent(self, absolute_time_spent: timedelta): """ Writes the value of absolute_time_spent into total_time_spent. Can't be less than 0. Allows any time unit of the timedelta object. Allows total_time_spent to be reset to 0 by passing a None or 0. """ if absolute_time_spent and absolute_time_spent.total_seconds() != 0: self.data = post(self._token, self.url + '/reset_spent_time', {'duration': None}) human_time_spent = \ str(int(absolute_time_spent.total_seconds())) + 's' self.data = post(self._token, self.url + '/add_spent_time', {'duration': human_time_spent}) else: self.data = post(self._token, self.url + '/reset_spent_time', {'duration': None})
[docs] def add_to_total_time_spent(self, relative_time_spent: timedelta): """ Adds the value of relative_time_spent to total_time_spent. Allows for positive and negative values. Allows any time unit of the timedelta object. Does nothing when passing None or 0. """ if relative_time_spent and relative_time_spent.total_seconds() != 0: human_time_spent = \ str(int(relative_time_spent.total_seconds())) + 's' self.data = post(self._token, self.url + '/add_spent_time', {'duration': human_time_spent})