Skip to content

Commit 704285f

Browse files
committed
🚧 Support of issues management
- 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]>
1 parent 43999e6 commit 704285f

File tree

4 files changed

+418
-2
lines changed

4 files changed

+418
-2
lines changed

Diff for: git_repo/repo.py

+158
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,20 @@
2929
{self} [--path=<path>] [-v...] <target> (gist|snippet) fetch <gist> [<gist_file>]
3030
{self} [--path=<path>] [-v...] <target> (gist|snippet) create [--secret] <description> [<gist_path> <gist_path>...]
3131
{self} [--path=<path>] [-v...] <target> (gist|snippet) delete <gist> [-f]
32+
{self} [--path=<path>] [-v...] <target> issue (list|ls) [--filter=<filter>]
33+
{self} [--path=<path>] [-v...] <target> issue (list|ls) [<action>|<issue_id>]
34+
{self} [--path=<path>] [-v...] <target> issue get <action> [--filter=<filter>] [<issue_id> <issue_id>...]
35+
{self} [--path=<path>] [-v...] <target> issue set <action> <value> [--filter=<filter>] [<issue_id> <issue_id>...]
36+
{self} [--path=<path>] [-v...] <target> issue unset <action> <value> [--filter=<filter>] [<issue_id> <issue_id>...]
37+
{self} [--path=<path>] [-v...] <target> issue toggle <action> <value> [--filter=<filter>] [<issue_id> <issue_id>...]
38+
{self} [--path=<path>] [-v...] <target> issue edit [<issue_id>]
39+
{self} [--path=<path>] [-v...] <target> issue <user>/<repo> (list|ls) [--filter=<filter>]
40+
{self} [--path=<path>] [-v...] <target> issue <user>/<repo> (list|ls) [<action>|<issue_id>]
41+
{self} [--path=<path>] [-v...] <target> issue <user>/<repo> get <action> [--filter=<filter>] [<issue_id> <issue_id>...]
42+
{self} [--path=<path>] [-v...] <target> issue <user>/<repo> set <action> <value> [--filter=<filter>] [<issue_id> <issue_id>...]
43+
{self} [--path=<path>] [-v...] <target> issue <user>/<repo> unset <action> <value> [--filter=<filter>] [<issue_id> <issue_id>...]
44+
{self} [--path=<path>] [-v...] <target> issue <user>/<repo> toggle <action> <value> [--filter=<filter>] [<issue_id> <issue_id>...]
45+
{self} [--path=<path>] [-v...] <target> issue <user>/<repo> edit [<issue_id>]
3246
{self} [--path=<path>] [-v...] <target> config [--config=<gitconfig>]
3347
{self} [-v...] config [--config=<gitconfig>]
3448
{self} --help
@@ -44,6 +58,7 @@
4458
list Lists the repositories for a given user
4559
gist Manages gist files
4660
request Handles requests for merge
61+
issue Handles issues
4762
open Open the given or current repository in a browser
4863
config Run authentication process and configure the tool
4964
@@ -92,6 +107,16 @@
92107
<title> Title to give to the request for merge
93108
-m,--message=<message> Description for the request for merge
94109
110+
Options for issues:
111+
get Gets a value for the given action listed below
112+
set Sets a value for the given action listed below
113+
unset Unsets a value for the given action listed below
114+
toggle Toggles a value for the given action listed below
115+
<action> Action: label, milestone or mark
116+
<value> Value for what shall be set
117+
--filter=<filter> Filters the list of issues [Default: '']
118+
<issue_id> Issue's number
119+
95120
Configuration options:
96121
alias Name to use for the git remote
97122
fqdn URL of the repository
@@ -262,6 +287,14 @@ def set_branch(self, branch):
262287

263288
self.branch = branch
264289

290+
@store_parameter('<action>')
291+
def set_action(self, action):
292+
self.action = action
293+
294+
@store_parameter('<issue_id>')
295+
def set_issue_action(self, issue_id):
296+
self.issues = issue_id
297+
265298
@store_parameter('<repo>')
266299
def set_target_repo(self, repo):
267300
self.target_repo = repo
@@ -492,6 +525,131 @@ def do_gist_delete(self):
492525
log.info('Successfully deleted gist!')
493526
return 0
494527

528+
'''Issues'''
529+
530+
@register_action('issue', 'ls')
531+
@register_action('issue', 'list')
532+
def do_issue_list(self):
533+
def green(s):
534+
return '\033[92m{}\033[0m'.format(s)
535+
def red(s):
536+
return '\033[91m{}\033[0m'.format(s)
537+
service = self.get_service()
538+
if self.action:
539+
if self.action in ('milestone', 'm'):
540+
for milestone in service.issue_milestone_list(self.user_name, self.repo_name):
541+
print(milestone)
542+
return 0
543+
elif self.action in ('label', 'l'):
544+
for label in service.issue_label_list(self.user_name, self.repo_name):
545+
print(label)
546+
return 0
547+
elif self.action in ('mark', 'm'):
548+
print('opened\nclosed')
549+
return 0
550+
else:
551+
issue = service.issue_grab(self.user_name, self.repo_name, self.action)
552+
print('\n'.join([
553+
'Issue #{} ({}) by @{}'.format(
554+
issue['id'],
555+
green(issue['state']) if issue['state'] == 'open' else red(issue['state']),
556+
issue['poster']),
557+
'Created at:\t{} {}'.format(
558+
issue['creation'],
559+
'' if not issue['state'] == 'closed' else 'and closed at: {} by @{}'.format(
560+
issue['closed_at'], issue['closed_by']
561+
)
562+
),
563+
'Assigned:\t{}'.format('@{}'.format(issue['assignee']) or 'ø'),
564+
'Milestone:\t{}'.format(issue['milestone']),
565+
'Labels:\t\t{}'.format(', '.join(issue['labels'])),
566+
'URI:\t\t{}'.format(issue['uri']),
567+
'Title:\t\t{}'.format(issue['title']),
568+
'Body:', '',
569+
issue['body'],
570+
])
571+
)
572+
else:
573+
574+
format_issue = lambda issue: '{} {}\t{}\t{}\t{}'.format(
575+
issue[0] is True and green('📖') or issue[0] is False and red('📕') or '📚',
576+
issue[1].rjust(3),
577+
issue[2][:20].ljust(20) + ("…" if len(issue[2]) > 20 else ""),
578+
issue[3][:60].ljust(60) + ("…" if len(issue[3]) > 60 else ""),
579+
issue[4])
580+
issues = service.issue_list(self.user_name, self.repo_name, self.filter or '')
581+
print(format_issue(next(issues)), file=sys.stderr)
582+
for issue in issues:
583+
print(format_issue(issue))
584+
return 0
585+
586+
@register_action('issue', 'set')
587+
def do_issue_set(self):
588+
service = self.get_service()
589+
if len(self.issues) == 1 and self.issues[0] == '-':
590+
self.user_name, self.repo_name, self.issues = service.issue_extract_from_file(sys.stdin)
591+
if service.issue_set(self.user_name, self.repo_name, self.action, self.value, self.filter, self.issues):
592+
return 0
593+
return 1
594+
595+
@register_action('issue', 'unset')
596+
def do_issue_unset(self):
597+
service = self.get_service()
598+
if len(self.issues) == 1 and self.issues[0] == '-':
599+
self.user_name, self.repo_name, self.issues = service.issue_extract_from_file(sys.stdin)
600+
if service.issue_unset(self.user_name, self.repo_name, self.action, self.value, self.filter, self.issues):
601+
return 0
602+
return 1
603+
604+
@register_action('issue', 'toggle')
605+
def do_issue_toggle(self):
606+
service = self.get_service()
607+
if len(self.issues) == 1 and self.issues[0] == '-':
608+
self.user_name, self.repo_name, self.issues = service.issue_extract_from_file(sys.stdin)
609+
if service.issue_toggle(self.user_name, self.repo_name, self.action, self.value, self.filter, self.issues):
610+
return 0
611+
return 1
612+
613+
@register_action('issue', 'edit')
614+
def do_issue_edit(self):
615+
do_ask=False
616+
if len(self.issues) == 1 and self.issues[0] == '-':
617+
self.user_name, self.repo_name, self.issues = service.issue_extract_from_file(sys.stdin)
618+
do_ask=True
619+
620+
def edit_issue(title, body):
621+
from tempfile import NamedTemporaryFile
622+
from subprocess import call
623+
with NamedTemporaryFile(
624+
prefix='git-repo-issue-',
625+
suffix='.md',
626+
mode='w+b') as issue_file:
627+
issue_file.write('Title: {}\n\nBody:\n{}\n'.format(title, body).encode('utf-8'))
628+
issue_file.flush()
629+
call("{} {}".format(os.environ['EDITOR'], issue_file.name), shell=True)
630+
issue_file.seek(0)
631+
updated_issue = issue_file.read().decode('utf-8')
632+
try:
633+
_, updated_issue = updated_issue.split('Title: ')
634+
title, body, *tail = updated_issue.split('\n\nBody:\n')
635+
body = ''.join([body]+tail)
636+
except Exception:
637+
raise ResourceError("Format of the modified issue cannot be parsed.")
638+
639+
print('New issue\'s details:')
640+
print('Title: {}'.format(title))
641+
print('Body:\n{}'.format(body))
642+
if do_ask and input('Do you confirm it\'s ok? [Yn] ').lower().startswith('n'):
643+
return None
644+
return {'title': title, 'body': body}
645+
646+
service = self.get_service()
647+
if service.issue_edit(self.user_name, self.repo_name, self.issues[0], edit_issue):
648+
return 0
649+
return 1
650+
651+
'''Configuration'''
652+
495653
@register_action('config')
496654
def do_config(self):
497655
from getpass import getpass

Diff for: git_repo/services/ext/github.py

+182-2
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
import logging
44
log = logging.getLogger('git_repo.github')
55

6-
from ..service import register_target, RepositoryService, os
7-
from ...exceptions import ResourceError, ResourceExistsError, ResourceNotFoundError
6+
from ..service import register_target, RepositoryService, os, parse_comma_string_to_list
7+
from ...exceptions import ResourceError, ResourceExistsError, ResourceNotFoundError, ArgumentError
88

99
import github3
1010

1111
from git.exc import GitCommandError
12+
from collections import namedtuple
13+
1214

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

309+
'''Issues'''
310+
311+
ISSUE_FILTER_DEFAULTS=dict(
312+
state = 'all',
313+
milestone = None,
314+
assignee = None,
315+
mentioned = None,
316+
labels = [],
317+
sort = None,
318+
direction = 'desc',
319+
since = None,
320+
)
321+
322+
def issue_list_parse_filter_statement(self, filter_stmt, transform=None):
323+
from copy import deepcopy
324+
325+
params = deepcopy(self.ISSUE_FILTER_DEFAULTS)
326+
327+
for f in parse_comma_string_to_list(filter_stmt):
328+
if ':' in f:
329+
param, value_head, *value_tail = f.split(':')
330+
value = "".join([value_head] + value_tail) # fix labels containing :
331+
if transform:
332+
param, value = transform(param, value)
333+
if not param in params.keys():
334+
raise ArgumentError('Unknown filter key {}'.format(param))
335+
if isinstance(params[param], list):
336+
params[param].append(value)
337+
else:
338+
params[param] = value
339+
return params
340+
341+
def issue_extract_from_file(self, it):
342+
# Message-ID: <guyzmo/git-repo/issues/1/[email protected]>
343+
for line in it:
344+
if line.lower().startswith('message-id:'):
345+
_, line = line.lower().split('message-id: <')
346+
user, repo, _, issue, *_ = line.lower().split('/')
347+
return user, repo, [issue]
348+
349+
def issue_label_list(self, user, repo):
350+
repository = self.gh.repository(user, repo)
351+
yield ("Name",)
352+
return [(yield l.name) for l in repository.iter_labels()]
353+
354+
def issue_milestone_list(self, user, repo):
355+
repository = self.gh.repository(user, repo)
356+
yield ("Name",)
357+
return [(yield l.title) for l in repository.iter_milestones()]
358+
359+
def issue_grab(self, user, repo, issue_id):
360+
repository = self.gh.repository(user, repo)
361+
issue = repository.issue(issue_id)
362+
return dict(
363+
id=issue.number,
364+
state=issue.state,
365+
title=issue.title,
366+
uri=issue.html_url,
367+
poster=issue.user.login,
368+
milestone=issue.milestone,
369+
labels=[label.name for label in issue.labels],
370+
creation=issue.created_at.isoformat(),
371+
closed_at=issue.closed_at,
372+
closed_by=issue.closed_by,
373+
body=issue.body,
374+
assignee=issue.assignee.login if issue.assignee else None,
375+
repository='/'.join(issue.repository)
376+
)
377+
378+
def issue_list(self, user, repo, filter_str=''):
379+
params = self.issue_list_parse_filter_statement(
380+
filter_stmt=filter_str,
381+
transform=lambda k,v: (k.replace('status', 'state').replace('label', 'labels'), v)
382+
)
383+
384+
repository = self.gh.repository(user, repo)
385+
yield (None, "Id", "Labels", "Title", "URL")
386+
for issue in repository.iter_issues(**params):
387+
yield ( not issue.is_closed(),
388+
str(issue.number),
389+
','.join([l.name for l in issue.labels]),
390+
issue.title,
391+
issue.html_url,
392+
issue.pull_request)
393+
394+
def issue_edit(self, user, repo, issue, edit_cb):
395+
repository = self.gh.repository(user, repo)
396+
issue_obj = repository.issue(issue)
397+
updated_issue = edit_cb(issue_obj.title, issue_obj.body)
398+
if not updated_issue:
399+
return False
400+
return issue_obj.edit(title=updated_issue['title'], body=updated_issue['body'])
401+
402+
def issue_action(self, user, repo, action, value, filter_str, issues, application):
403+
repository = self.gh.repository(user, repo)
404+
params = self.issue_list_parse_filter_statement(
405+
filter_stmt=filter_str,
406+
transform=lambda k,v: (k.replace('label', 'labels'), v)
407+
)
408+
for issue in repository.iter_issues(**params):
409+
if not issues or str(issue.number) in issues:
410+
if action == "mark":
411+
if value.lower() in ('opened', 'open', 'o'):
412+
return application['mark'](issue, opened=True)
413+
elif value.lower() in ('closed', 'close', 'c'):
414+
return application['mark'](issue, opened=False)
415+
416+
if action == "label":
417+
labels = set()
418+
labels_avail = {l.name: l for l in repository.iter_labels()}
419+
for label in parse_comma_string_to_list(value):
420+
if label in labels_avail:
421+
labels.add(labels_avail[label])
422+
else:
423+
raise ArgumentError("Label '{}' is invalid.".format(value))
424+
return application['label'](issue, list(labels))
425+
426+
if action == "milestone":
427+
milestones = list(repository.iter_milestones())
428+
for milestone in milestones:
429+
if value == milestone.title:
430+
return application['milestone'](issue, milestone)
431+
raise ArgumentError("Milestone '{}' is invalid.".format(value))
432+
433+
def issue_set(self, user, repo, action, value, filter_str, issues):
434+
def set_mark(issue, opened):
435+
if opened:
436+
return issue.reopen()
437+
return issue.close()
438+
def add_labels(issue, labels):
439+
return issue.add_labels(*[l.name for l in labels])
440+
def set_milestone(issue, milestone):
441+
return issue.edit(milestone=milestone.number)
442+
443+
return self.issue_action(user, repo, action, value, filter_str, issues, dict(
444+
mark=set_mark,
445+
label=add_labels,
446+
milestone=set_milestone
447+
)
448+
)
449+
450+
def issue_unset(self, user, repo, action, value, filter_str, issues):
451+
def unset_mark(issue, opened):
452+
raise ArgumentError('Cannot unset marks.')
453+
def remove_labels(issue, labels):
454+
for l in labels:
455+
if not issue.remove_label(l.name):
456+
return False
457+
def unset_milestone(issue, milestone):
458+
return issue.edit(milestone=0)
459+
460+
return self.issue_action(user, repo, action, value, filter_str, issues, dict(
461+
mark=unset_mark,
462+
label=remove_labels,
463+
milestone=unset_milestone
464+
)
465+
)
466+
467+
def issue_toggle(self, user, repo, action, filter_str, issues):
468+
def toggle_mark(issue, opened):
469+
if issue.is_closed():
470+
return issue.reopen()
471+
return issue.close()
472+
def toggle_labels(issue, labels):
473+
issue_labels = set(issue.iter_labels()).symmetric_difference(labels)
474+
return issue.replace_labels(*[l.name for l in labels])
475+
def set_milestone(issue, milestone):
476+
if issue.milestone:
477+
return issue.edit(milestone=0)
478+
return issue.edit(milestone=milestone.number)
479+
480+
return self.issue_action(user, repo, action, filter_str, issues, dict(
481+
mark=unset_mark,
482+
label=remove_labels,
483+
milestone=unset_milestone
484+
)
485+
)
486+
307487
@classmethod
308488
def get_auth_token(cls, login, password, prompt=None):
309489
import platform

0 commit comments

Comments
 (0)