Skip to content

Commit

Permalink
🚧 Support of issues management
Browse files Browse the repository at this point in the history
- issue listing
- issue get/set/toggle of labels, milestones and open-close status
- issue edit of issue
- parsing of notification mail to extract repo_slug and issue number

fixes #104

Signed-off-by: Guyzmo <[email protected]>
  • Loading branch information
guyzmo committed Jan 27, 2017
1 parent 37eb7a0 commit 919fe14
Show file tree
Hide file tree
Showing 3 changed files with 417 additions and 2 deletions.
158 changes: 158 additions & 0 deletions git_repo/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@
{self} [--path=<path>] [-v...] <target> (gist|snippet) fetch <gist> [<gist_file>]
{self} [--path=<path>] [-v...] <target> (gist|snippet) create [--secret] <description> [<gist_path> <gist_path>...]
{self} [--path=<path>] [-v...] <target> (gist|snippet) delete <gist> [-f]
{self} [--path=<path>] [-v...] <target> issue (list|ls) [--filter=<filter>]
{self} [--path=<path>] [-v...] <target> issue (list|ls) [<action>|<issue_id>]
{self} [--path=<path>] [-v...] <target> issue get <action> [--filter=<filter>] [<issue_id> <issue_id>...]
{self} [--path=<path>] [-v...] <target> issue set <action> <value> [--filter=<filter>] [<issue_id> <issue_id>...]
{self} [--path=<path>] [-v...] <target> issue unset <action> <value> [--filter=<filter>] [<issue_id> <issue_id>...]
{self} [--path=<path>] [-v...] <target> issue toggle <action> <value> [--filter=<filter>] [<issue_id> <issue_id>...]
{self} [--path=<path>] [-v...] <target> issue edit [<issue_id>]
{self} [--path=<path>] [-v...] <target> issue <user>/<repo> (list|ls) [--filter=<filter>]
{self} [--path=<path>] [-v...] <target> issue <user>/<repo> (list|ls) [<action>|<issue_id>]
{self} [--path=<path>] [-v...] <target> issue <user>/<repo> get <action> [--filter=<filter>] [<issue_id> <issue_id>...]
{self} [--path=<path>] [-v...] <target> issue <user>/<repo> set <action> <value> [--filter=<filter>] [<issue_id> <issue_id>...]
{self} [--path=<path>] [-v...] <target> issue <user>/<repo> unset <action> <value> [--filter=<filter>] [<issue_id> <issue_id>...]
{self} [--path=<path>] [-v...] <target> issue <user>/<repo> toggle <action> <value> [--filter=<filter>] [<issue_id> <issue_id>...]
{self} [--path=<path>] [-v...] <target> issue <user>/<repo> edit [<issue_id>]
{self} [--path=<path>] [-v...] <target> config [--config=<gitconfig>]
{self} [-v...] config [--config=<gitconfig>]
{self} --help
Expand All @@ -44,6 +58,7 @@
list Lists the repositories for a given user
gist Manages gist files
request Handles requests for merge
issue Handles issues
open Open the given or current repository in a browser
config Run authentication process and configure the tool
Expand Down Expand Up @@ -92,6 +107,16 @@
<title> Title to give to the request for merge
-m,--message=<message> Description for the request for merge
Options for issues:
get Gets a value for the given action listed below
set Sets a value for the given action listed below
unset Unsets a value for the given action listed below
toggle Toggles a value for the given action listed below
<action> Action: label, milestone or mark
<value> Value for what shall be set
--filter=<filter> Filters the list of issues [Default: '']
<issue_id> Issue's number
Configuration options:
alias Name to use for the git remote
fqdn URL of the repository
Expand Down Expand Up @@ -262,6 +287,14 @@ def set_branch(self, branch):

self.branch = branch

@store_parameter('<action>')
def set_action(self, action):
self.action = action

@store_parameter('<issue_id>')
def set_issue_action(self, issue_id):
self.issues = issue_id

@store_parameter('<repo>')
def set_target_repo(self, repo):
self.target_repo = repo
Expand Down Expand Up @@ -492,6 +525,131 @@ def do_gist_delete(self):
log.info('Successfully deleted gist!')
return 0

'''Issues'''

@register_action('issue', 'ls')
@register_action('issue', 'list')
def do_issue_list(self):
def green(s):
return '\033[92m{}\033[0m'.format(s)
def red(s):
return '\033[91m{}\033[0m'.format(s)
service = self.get_service()
if self.action:
if self.action in ('milestone', 'm'):
for milestone in service.issue_milestone_list(self.user_name, self.repo_name):
print(milestone)
return 0
elif self.action in ('label', 'l'):
for label in service.issue_label_list(self.user_name, self.repo_name):
print(label)
return 0
elif self.action in ('mark', 'm'):
print('opened\nclosed')
return 0
else:
issue = service.issue_grab(self.user_name, self.repo_name, self.action)
print('\n'.join([
'Issue #{} ({}) by @{}'.format(
issue['id'],
green(issue['state']) if issue['state'] == 'open' else red(issue['state']),
issue['poster']),
'Created at:\t{} {}'.format(
issue['creation'],
'' if not issue['state'] == 'closed' else 'and closed at: {} by @{}'.format(
issue['closed_at'], issue['closed_by']
)
),
'Assigned:\t{}'.format('@{}'.format(issue['assignee']) or 'ø'),
'Milestone:\t{}'.format(issue['milestone']),
'Labels:\t\t{}'.format(', '.join(issue['labels'])),
'URI:\t\t{}'.format(issue['uri']),
'Title:\t\t{}'.format(issue['title']),
'Body:', '',
issue['body'],
])
)
else:

format_issue = lambda issue: '{} {}\t{}\t{}\t{}'.format(
issue[0] is True and green('📖') or issue[0] is False and red('📕') or '📚',
issue[1].rjust(3),
issue[2][:20].ljust(20) + ("…" if len(issue[2]) > 20 else ""),
issue[3][:60].ljust(60) + ("…" if len(issue[3]) > 60 else ""),
issue[4])
issues = service.issue_list(self.user_name, self.repo_name, self.filter or '')
print(format_issue(next(issues)), file=sys.stderr)
for issue in issues:
print(format_issue(issue))
return 0

@register_action('issue', 'set')
def do_issue_set(self):
service = self.get_service()
if len(self.issues) == 1 and self.issues[0] == '-':
self.user_name, self.repo_name, self.issues = service.issue_extract_from_file(sys.stdin)
if service.issue_set(self.user_name, self.repo_name, self.action, self.value, self.filter, self.issues):
return 0
return 1

@register_action('issue', 'unset')
def do_issue_unset(self):
service = self.get_service()
if len(self.issues) == 1 and self.issues[0] == '-':
self.user_name, self.repo_name, self.issues = service.issue_extract_from_file(sys.stdin)
if service.issue_unset(self.user_name, self.repo_name, self.action, self.value, self.filter, self.issues):
return 0
return 1

@register_action('issue', 'toggle')
def do_issue_toggle(self):
service = self.get_service()
if len(self.issues) == 1 and self.issues[0] == '-':
self.user_name, self.repo_name, self.issues = service.issue_extract_from_file(sys.stdin)
if service.issue_toggle(self.user_name, self.repo_name, self.action, self.value, self.filter, self.issues):
return 0
return 1

@register_action('issue', 'edit')
def do_issue_edit(self):
do_ask=False
if len(self.issues) == 1 and self.issues[0] == '-':
self.user_name, self.repo_name, self.issues = service.issue_extract_from_file(sys.stdin)
do_ask=True

def edit_issue(title, body):
from tempfile import NamedTemporaryFile
from subprocess import call
with NamedTemporaryFile(
prefix='git-repo-issue-',
suffix='.md',
mode='w+b') as issue_file:
issue_file.write('Title: {}\n\nBody:\n{}\n'.format(title, body).encode('utf-8'))
issue_file.flush()
call("{} {}".format(os.environ['EDITOR'], issue_file.name), shell=True)
issue_file.seek(0)
updated_issue = issue_file.read().decode('utf-8')
try:
_, updated_issue = updated_issue.split('Title: ')
title, body, *tail = updated_issue.split('\n\nBody:\n')
body = ''.join([body]+tail)
except Exception:
raise ResourceError("Format of the modified issue cannot be parsed.")

print('New issue\'s details:')
print('Title: {}'.format(title))
print('Body:\n{}'.format(body))
if do_ask and input('Do you confirm it\'s ok? [Yn] ').lower().startswith('n'):
return None
return {'title': title, 'body': body}

service = self.get_service()
if service.issue_edit(self.user_name, self.repo_name, self.issues[0], edit_issue):
return 0
return 1

'''Configuration'''

@register_action('config')
def do_config(self):
from getpass import getpass
Expand Down
184 changes: 182 additions & 2 deletions git_repo/services/ext/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
import logging
log = logging.getLogger('git_repo.github')

from ..service import register_target, RepositoryService, os
from ...exceptions import ResourceError, ResourceExistsError, ResourceNotFoundError
from ..service import register_target, RepositoryService, os, parse_comma_string_to_list
from ...exceptions import ResourceError, ResourceExistsError, ResourceNotFoundError, ArgumentError

import github3

from git.exc import GitCommandError
from collections import namedtuple


@register_target('hub', 'github')
class GithubService(RepositoryService):
Expand Down Expand Up @@ -304,6 +306,184 @@ def request_fetch(self, user, repo, request, pull=False, force=False):
raise ResourceNotFoundError('Could not find opened request #{}'.format(request)) from err
raise err

'''Issues'''

ISSUE_FILTER_DEFAULTS=dict(
state = 'all',
milestone = None,
assignee = None,
mentioned = None,
labels = [],
sort = None,
direction = 'desc',
since = None,
)

def issue_list_parse_filter_statement(self, filter_stmt, transform=None):
from copy import deepcopy

params = deepcopy(self.ISSUE_FILTER_DEFAULTS)

for f in parse_comma_string_to_list(filter_stmt):
if ':' in f:
param, value_head, *value_tail = f.split(':')
value = "".join([value_head] + value_tail) # fix labels containing :
if transform:
param, value = transform(param, value)
if not param in params.keys():
raise ArgumentError('Unknown filter key {}'.format(param))
if isinstance(params[param], list):
params[param].append(value)
else:
params[param] = value
return params

def issue_extract_from_file(self, it):
# Message-ID: <guyzmo/git-repo/issues/1/[email protected]>
for line in it:
if line.lower().startswith('message-id:'):
_, line = line.lower().split('message-id: <')
user, repo, _, issue, *_ = line.lower().split('/')
return user, repo, [issue]

def issue_label_list(self, user, repo):
repository = self.gh.repository(user, repo)
yield ("Name",)
return [(yield l.name) for l in repository.iter_labels()]

def issue_milestone_list(self, user, repo):
repository = self.gh.repository(user, repo)
yield ("Name",)
return [(yield l.title) for l in repository.iter_milestones()]

def issue_grab(self, user, repo, issue_id):
repository = self.gh.repository(user, repo)
issue = repository.issue(issue_id)
return dict(
id=issue.number,
state=issue.state,
title=issue.title,
uri=issue.html_url,
poster=issue.user.login,
milestone=issue.milestone,
labels=[label.name for label in issue.labels],
creation=issue.created_at.isoformat(),
closed_at=issue.closed_at,
closed_by=issue.closed_by,
body=issue.body,
assignee=issue.assignee.login if issue.assignee else None,
repository='/'.join(issue.repository)
)

def issue_list(self, user, repo, filter_str=''):
params = self.issue_list_parse_filter_statement(
filter_stmt=filter_str,
transform=lambda k,v: (k.replace('status', 'state').replace('label', 'labels'), v)
)

repository = self.gh.repository(user, repo)
yield (None, "Id", "Labels", "Title", "URL")
for issue in repository.iter_issues(**params):
yield ( not issue.is_closed(),
str(issue.number),
','.join([l.name for l in issue.labels]),
issue.title,
issue.html_url,
issue.pull_request)

def issue_edit(self, user, repo, issue, edit_cb):
repository = self.gh.repository(user, repo)
issue_obj = repository.issue(issue)
updated_issue = edit_cb(issue_obj.title, issue_obj.body)
if not updated_issue:
return False
return issue_obj.edit(title=updated_issue['title'], body=updated_issue['body'])

def issue_action(self, user, repo, action, value, filter_str, issues, application):
repository = self.gh.repository(user, repo)
params = self.issue_list_parse_filter_statement(
filter_stmt=filter_str,
transform=lambda k,v: (k.replace('label', 'labels'), v)
)
for issue in repository.iter_issues(**params):
if not issues or str(issue.number) in issues:
if action == "mark":
if value.lower() in ('opened', 'open', 'o'):
return application['mark'](issue, opened=True)
elif value.lower() in ('closed', 'close', 'c'):
return application['mark'](issue, opened=False)

if action == "label":
labels = set()
labels_avail = {l.name: l for l in repository.iter_labels()}
for label in parse_comma_string_to_list(value):
if label in labels_avail:
labels.add(labels_avail[label])
else:
raise ArgumentError("Label '{}' is invalid.".format(value))
return application['label'](issue, list(labels))

if action == "milestone":
milestones = list(repository.iter_milestones())
for milestone in milestones:
if value == milestone.title:
return application['milestone'](issue, milestone)
raise ArgumentError("Milestone '{}' is invalid.".format(value))

def issue_set(self, user, repo, action, value, filter_str, issues):
def set_mark(issue, opened):
if opened:
return issue.reopen()
return issue.close()
def add_labels(issue, labels):
return issue.add_labels(*[l.name for l in labels])
def set_milestone(issue, milestone):
return issue.edit(milestone=milestone.number)

return self.issue_action(user, repo, action, value, filter_str, issues, dict(
mark=set_mark,
label=add_labels,
milestone=set_milestone
)
)

def issue_unset(self, user, repo, action, value, filter_str, issues):
def unset_mark(issue, opened):
raise ArgumentError('Cannot unset marks.')
def remove_labels(issue, labels):
for l in labels:
if not issue.remove_label(l.name):
return False
def unset_milestone(issue, milestone):
return issue.edit(milestone=0)

return self.issue_action(user, repo, action, value, filter_str, issues, dict(
mark=unset_mark,
label=remove_labels,
milestone=unset_milestone
)
)

def issue_toggle(self, user, repo, action, filter_str, issues):
def toggle_mark(issue, opened):
if issue.is_closed():
return issue.reopen()
return issue.close()
def toggle_labels(issue, labels):
issue_labels = set(issue.iter_labels()).symmetric_difference(labels)
return issue.replace_labels(*[l.name for l in labels])
def set_milestone(issue, milestone):
if issue.milestone:
return issue.edit(milestone=0)
return issue.edit(milestone=milestone.number)

return self.issue_action(user, repo, action, filter_str, issues, dict(
mark=unset_mark,
label=remove_labels,
milestone=unset_milestone
)
)

@classmethod
def get_auth_token(cls, login, password, prompt=None):
import platform
Expand Down
Loading

0 comments on commit 919fe14

Please sign in to comment.