"""
This contains the Issue implementation for GitHub.
"""
from datetime import datetime
from typing import Optional
from typing import Set
import re
import requests
from IGitt.GitHub import GitHubMixin, GH_INSTANCE_URL
from IGitt.GitHub import GitHubToken
from IGitt.GitHub.GitHubComment import GitHubComment
from IGitt.GitHub.GitHubReaction import GitHubReaction
from IGitt.GitHub.GitHubReaction import PREVIEW_HEADER
from IGitt.GitHub.GitHubUser import GitHubUser
from IGitt.Interfaces.Comment import CommentType
from IGitt.Interfaces.Issue import Issue
from IGitt.Interfaces import get, patch, post, delete
from IGitt.Interfaces import IssueStates
CLOSED_BY_PATTERN = re.compile('closed this(?:\n| )+in(?:\n| )+<a href=\"/(.+)/'
'pull/([0-9]+)\">#(?:[0-9]+)</a>')
[docs]class GitHubIssue(GitHubMixin, Issue):
"""
This class represents an issue on GitHub.
"""
def __init__(self, token: GitHubToken, repository: str, number: int):
"""
Creates a new GitHubIssue with the given credentials.
>>> from os import environ
>>> issue = GitHubIssue(environ['GITHUB_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 GitHubToken object.
:param repository: The full name of the repository,
e.g. ``sils/something``.
:param number: The issue number.
:raises RuntimeError: If something goes wrong (network, auth, ...)
"""
self._token = token
self._repository = repository
self._number = number
self._url = '/repos/'+repository+'/issues/'+str(number)
@property
def repository(self):
"""
Returns the GitHub repository this issue is linked with as a
GitHubRepository instance.
"""
from IGitt.GitHub.GitHubRepository import GitHubRepository
return GitHubRepository(self._token, self._repository)
@property
def title(self):
"""
Retrieves the title of the issue.
>>> from os import environ
>>> issue = GitHubIssue(GitHubToken(environ['GITHUB_TEST_TOKEN']),
... 'gitmate-test-user/test', 1)
>>> issue.title
'test issue'
You can simply set it using the property setter:
>>> issue.title = 'dont panic'
>>> issue.title
'dont panic'
>>> issue.title = 'test issue'
: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 = patch(self._token, self.url, {'title': new_title})
@property
def number(self) -> int:
"""
Returns the issue "number" or id.
>>> from os import environ
>>> issue = GitHubIssue(environ['GITHUB_TEST_TOKEN'],
... 'gitmate-test-user/test', 1)
>>> issue.number
1
:return: The number of the issue.
"""
return self._number
@property
def assignees(self) -> Set[GitHubUser]:
"""
Retrieves the assignee of the issue:
>>> from os import environ
>>> issue = GitHubIssue(GitHubToken(environ['GITHUB_TEST_TOKEN']),
... 'gitmate-test-user/test', 1)
>>> {a.username for a in issue.assignees}
{'gitmate-test-user'}
>>> issue = GitHubIssue(GitHubToken(environ['GITHUB_TEST_TOKEN']),
... 'gitmate-test-user/test', 2)
>>> issue.assignees # Returns empty set, unassigned
{}
:return: A set containing the usernames of assignees.
"""
return set(
GitHubUser.from_data(user, self._token, user['login'])
for user in self.data['assignees']
)
@assignees.setter
def assignees(self, value: Set[GitHubUser]):
"""
Setter for ssignees.
"""
if value - self.assignees:
self.assign(*(value - self.assignees))
if self.assignees - value:
self.unassign(*(self.assignees - value))
[docs] def assign(self, *users: Set[GitHubUser]):
"""
Adds the user as one of the assignees of the issue.
:param username: Username of the user to be added as an assignee.
"""
url = self.url + '/assignees'
self.data = post(self._token, url,
{'assignees': [user.username for user in users]})
[docs] def unassign(self, *users: Set[GitHubUser]):
"""
Removes the user from the assignees of the issue.
:param users: Username of the user to be unassigned.
"""
url = self.url + '/assignees'
delete(self._token, url,
{'assignees': [user.username for user in users]})
self.data = get(self._token, self.url)
@property
def available_assignees(self) -> Set[GitHubUser]:
"""
Retrieves a set of available assignees for the issue.
>>> from os import environ
>>> issue = GitHubIssue(GitHubToken(environ['GITHUB_TEST_TOKEN']),
... 'gitmate-test-user/test', 1)
>>> {a.username for a in issue.available_assignees}
{'gitmate-test-user'}
:return: A set of GitHubUsers.
"""
return {
GitHubUser.from_data(user, self._token, user['login'])
for user in get(
self._token,
self.absolute_url('/repos/' + self._repository + '/assignees'))
}
@property
def description(self):
r"""
Retrieves the main description of the issue:
>>> from os import environ
>>> issue = GitHubIssue(GitHubToken(environ['GITHUB_TEST_TOKEN']),
... 'gitmate-test-user/test', 1)
>>> issue.description
'A nice description!\n'
:return: A string containing the main description of the issue.
"""
return self.data['body'] if self.data['body'] else ''
@description.setter
def description(self, new_description):
"""
Sets the description of the issue.
:param new_description: The new description.
"""
self.data = patch(self._token, self.url, {'body': new_description})
@property
def author(self) -> GitHubUser:
"""
Retrieves the author of the issue.
:return: A GitHubUser object.
"""
return GitHubUser.from_data(self.data['user'],
self._token,
self.data['user']['login'])
@property
def comments(self):
r"""
Retrieves comments from the issue.
>>> from os import environ
>>> issue = GitHubIssue(GitHubToken(environ['GITHUB_TEST_TOKEN']),
... 'gitmate-test-user/test', 9)
>>> comments = issue.comments
Now we can e.g. access the last comment:
>>> comments[-1].body
'Do not comment here.\n'
:return: A list of Comment objects.
"""
return [GitHubComment.from_data(result, self._token, self._repository,
CommentType.ISSUE, result['id'])
for result in get(self._token, self.url + '/comments')]
@property
def labels(self):
"""
Retrieves all labels associated with this bug.
>>> from os import environ
>>> issue = GitHubIssue(GitHubToken(environ['GITHUB_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(label['name'] for label in self.data['labels'])
@labels.setter
def labels(self, value: Set[str]):
"""
Sets the 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 = patch(self._token, self.url, {'labels': list(value)})
@property
def available_labels(self):
"""
Retrieves a set of captions that are available for labelling bugs.
>>> from os import environ
>>> issue = GitHubIssue(GitHubToken(environ['GITHUB_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('/repos/' + self._repository + '/labels'))}
@property
def created(self) -> datetime:
"""
Retrieves a timestamp on when the issue was created.
>>> from os import environ
>>> issue = GitHubIssue(GitHubToken(environ['GITHUB_TEST_TOKEN']),
... 'gitmate-test-user/test', 1)
>>> issue.created
datetime.datetime(2016, 1, 13, 7, 56, 23)
"""
return datetime.strptime(self.data['created_at'],
'%Y-%m-%dT%H:%M:%SZ')
@property
def updated(self) -> datetime:
"""
Retrieves a timestamp on when the issue was updated the last time.
>>> from os import environ
>>> issue = GitHubIssue(GitHubToken(environ['GITHUB_TEST_TOKEN']),
... 'gitmate-test-user/test', 9)
>>> issue.updated
datetime.datetime(2016, 10, 9, 11, 27, 11)
"""
return datetime.strptime(self.data['updated_at'],
'%Y-%m-%dT%H:%M:%SZ')
[docs] def close(self):
"""
Closes the issue.
:raises RuntimeError: If something goes wrong (network, auth...).
"""
self.data = patch(self._token, self.url, {'state': 'closed'})
[docs] def reopen(self):
"""
Reopens the issue.
:raises RuntimeError: If something goes wrong (network, auth...).
"""
self.data = patch(self._token, self.url, {'state': 'open'})
[docs] def delete(self):
"""
Should delete the issue, but GitHub doesn't allow it yet.
Reference: https://github.com/isaacs/github/issues/253
"""
raise NotImplementedError("GitHub doesn't allow deleting issues.")
@property
def state(self) -> str:
"""
Get's the state of the issue.
>>> from os import environ
>>> issue = GitHubIssue(GitHubToken(environ['GITHUB_TEST_TOKEN']),
... 'gitmate-test-user/test', 10)
>>> 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'>
:return: Either <IssueStates.OPEN: 'open'> or
<IssueStates.CLOSED: 'closed'>.
"""
return IssueStates[self.data['state'].upper()]
@property
def reactions(self) -> Set[GitHubReaction]:
"""
Retrieves the reactions / award emojis applied on the issue.
"""
url = self.url + '/reactions'
reactions = get(self._token, url, headers=PREVIEW_HEADER)
return {GitHubReaction.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.
"""
raise NotImplementedError
@weight.setter
def weight(self, value: int):
"""
Updates the weight associated with the current issue.
"""
raise NotImplementedError
[docs] @staticmethod
def create(token: str, 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 = GitHubIssue.create(
... GitHubToken(environ['GITHUB_TEST_TOKEN']),
... 'gitmate-test-user/test',
... 'test issue title',
... 'sample description'
... )
>>> issue.state
<IssueStates.OPEN: 'open'>
>>> str(issue.state)
'open'
Let's delete the newly created one, because it's useless.
>>> issue.delete()
... # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
NotImplementedError
Because GitHub is stupid, they don't allow deleting issues. So, let's
atleast close this for now.
>>> issue.close()
:return: GitHubIssue object of the newly created issue.
"""
post_url = '/repos/' + repository + '/issues'
data = {
'title': title,
'body': body,
}
resp = post(token, GitHubIssue.absolute_url(post_url), data)
issue_number = resp['number']
return GitHubIssue.from_data(resp, token, repository, issue_number)
@property
def mrs_closed_by(self):
"""
Returns the merge requests that close this issue.
"""
from IGitt.GitHub.GitHubMergeRequest import GitHubMergeRequest
r = requests.get(GH_INSTANCE_URL + self._url.replace('/repos', ''))
matches = CLOSED_BY_PATTERN.findall(r.text)
return {GitHubMergeRequest(self._token, repo_name, int(number))
for repo_name, number in matches}
@property
def milestone(self):
"""
Retrieves the milestone.
"""
from IGitt.GitHub.GitHubMilestone import GitHubMilestone
return GitHubMilestone.from_data(
self.data['milestone'], self._token, self._repository,
self.data['milestone']['number']
) 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 = patch(
self._token, self.url,
{'milestone': new_milestone.number if new_milestone else ''})