WIP
This commit is contained in:
parent
55bdeede87
commit
d4491cf7ab
|
@ -0,0 +1,310 @@
|
|||
import functools
|
||||
from textwrap import indent, shorten, wrap
|
||||
from typing import Any, Callable, Dict
|
||||
|
||||
import click
|
||||
import editor
|
||||
|
||||
config = {
|
||||
'host': 'http://localhost:2990/jira/',
|
||||
'user': 'admin',
|
||||
'password': 'admin'
|
||||
}
|
||||
|
||||
|
||||
def ipython():
|
||||
import IPython
|
||||
IPython.embed()
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.pass_context
|
||||
def main(ctx):
|
||||
from jira import JIRA
|
||||
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj['client'] = JIRA(config['host'], basic_auth=(config['user'], config['password']))
|
||||
|
||||
|
||||
def pass_client(fn):
|
||||
@functools.wraps(fn)
|
||||
@click.pass_obj
|
||||
def wrapped(obj, *args, **kwargs):
|
||||
client = obj['client']
|
||||
return fn(client, *args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
|
||||
@main.command()
|
||||
@pass_client
|
||||
def version(client):
|
||||
print('Version {}.{}.{}-{}p{}'.format(*client.sys_version_info))
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.option('--status', multiple=True, help='Filter for issues with *any* of the given statuses')
|
||||
@click.option('--label', 'labels', multiple=True, help='Filter for issues with all the given labels')
|
||||
@click.option('--text', 'texts', multiple=True, help='Filter for issues containing all the given strings')
|
||||
@click.option('--order', help='Sort by this field')
|
||||
@click.option('--asc/--desc', 'asc', help='Sort ascending/descending. Default: descending')
|
||||
@click.option('--jql', default='', help='Provide your own JQL query to search for issues. '
|
||||
'Setting this ignores all other query options.')
|
||||
@pass_client
|
||||
def issues(client, status, labels, texts, order, asc, jql):
|
||||
if not jql:
|
||||
filters = []
|
||||
|
||||
if texts:
|
||||
texts_jql = ' AND '.join([f'text ~ "{text}"' for text in texts])
|
||||
filters.append(f"( {texts_jql} )")
|
||||
if status:
|
||||
status_list = ', '.join([f'"{s}"' for s in status])
|
||||
status_jql = f"status IN ({status_list})"
|
||||
filters.append(status_jql)
|
||||
|
||||
if labels:
|
||||
labels_jql = ' AND '.join([f'labels = "{label}"' for label in labels])
|
||||
filters.append(f"( {labels_jql} )")
|
||||
|
||||
jql = ' AND '.join(filters)
|
||||
|
||||
if order:
|
||||
jql += f" ORDER BY {order} {'ASC' if asc else 'DESC'}"
|
||||
|
||||
print(f"Searching with query: {jql}")
|
||||
issues = client.search_issues(jql)
|
||||
for issue in issues:
|
||||
# TODO tabulate?
|
||||
print(f"{issue.key:>8} - {issue.fields.status} - {issue.fields.created} - {shorten(issue.fields.summary, 72)}")
|
||||
|
||||
|
||||
def get_more_attr(obj, fields):
|
||||
fields = fields.split('.')
|
||||
for field in fields:
|
||||
obj = getattr(obj, field)
|
||||
if not obj:
|
||||
break
|
||||
return obj
|
||||
|
||||
|
||||
def comment_to_dict(comment):
|
||||
fields = ['created', 'author.displayName', 'body']
|
||||
return {f: get_more_attr(comment, f) for f in fields}
|
||||
|
||||
|
||||
def issue_to_yaml(issue):
|
||||
import yaml
|
||||
|
||||
info_fields = ['creator.displayName', 'reporter.displayName', 'created']
|
||||
editable_fields = ['summary', 'description', 'status.name', 'priority.name', 'labels',
|
||||
'issuelinks', 'components', 'assignee.displayName']
|
||||
d = {
|
||||
'info': {f: get_more_attr(issue.fields, f) for f in info_fields},
|
||||
'editable': {f: get_more_attr(issue.fields, f) for f in editable_fields},
|
||||
'comments': [comment_to_dict(c) for c in issue.fields.comment.comments]
|
||||
}
|
||||
return yaml.dump(d)
|
||||
|
||||
|
||||
# @main.command()
|
||||
# @click.argument('issue_id')
|
||||
# def issue(issue_id):
|
||||
# try:
|
||||
# issue = client.issue(issue_id)
|
||||
# except JIRAError as e:
|
||||
# print(f'Problem retrieving issue: {e.text}')
|
||||
# return
|
||||
#
|
||||
# # import IPython
|
||||
# # IPython.embed()
|
||||
# original_content = issue_to_yaml(issue)
|
||||
# print(original_content)
|
||||
# # new_content = editor.edit(contents=original_content).decode('utf-8')
|
||||
# # print(dir(issue))
|
||||
|
||||
|
||||
@main.command()
|
||||
def dump():
|
||||
try:
|
||||
from yaml import CDumper as Dumper
|
||||
except ImportError:
|
||||
from yaml import Dumper
|
||||
|
||||
import ruamel.yaml
|
||||
# from ruamel.yaml import YAML
|
||||
import yaml
|
||||
|
||||
def str_presenter(dumper, data):
|
||||
"""configures yaml for dumping multiline strings
|
||||
Ref: https://stackoverflow.com/questions/8640959/how-can-i-control-what-scalar-form-pyyaml-uses-for-my-data"""
|
||||
if data.count('\n') > 0: # check for multiline string
|
||||
return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|')
|
||||
return dumper.represent_scalar('tag:yaml.org,2002:str', data)
|
||||
|
||||
# yaml.add_representer(str, str_presenter)
|
||||
# yaml.representer.SafeRepresenter.add_representer(str, str_presenter) # to use with safe_dum
|
||||
|
||||
def yaml_multiline_string_pipe(dumper, data):
|
||||
text_list = [line for line in data.splitlines()]
|
||||
fixed_data = "\n".join(text_list)
|
||||
if len(text_list) > 1:
|
||||
return dumper.represent_scalar('tag:yaml.org,2002:str', data, style="|")
|
||||
return dumper.represent_scalar('tag:yaml.org,2002:str', fixed_data)
|
||||
|
||||
# yaml.add_representer(str, yaml_multiline_string_pipe)
|
||||
print(yaml.dump({"multiline": "First line \nSecond line\nThird line"},
|
||||
allow_unicode=True, Dumper=Dumper, default_style="|"))
|
||||
import sys
|
||||
ruamel.yaml.dump({"multiline": "First line \nSecond line\nThirdline"}, sys.stdout, default_style='|')
|
||||
|
||||
|
||||
@main.group(invoke_without_command=True)
|
||||
@click.argument('issue_id')
|
||||
@pass_client
|
||||
@click.pass_obj
|
||||
def issue(obj: Dict[str, Any], client, issue_id: str):
|
||||
from jira.exceptions import JIRAError
|
||||
|
||||
try:
|
||||
issue = client.issue(issue_id)
|
||||
except JIRAError as e:
|
||||
raise click.ClickException(f"Problem retrieving {issue_id}: {e.text}")
|
||||
|
||||
obj['issue'] = issue
|
||||
obj['issue_id'] = issue_id
|
||||
|
||||
|
||||
def pass_issue(fn: Callable) -> Callable:
|
||||
@functools.wraps(fn)
|
||||
@click.pass_obj
|
||||
def wrapped(obj, *args, **kwargs):
|
||||
issue = obj['issue']
|
||||
return fn(issue, *args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
|
||||
@issue.group()
|
||||
def link():
|
||||
pass
|
||||
|
||||
|
||||
@link.command('list')
|
||||
@pass_issue
|
||||
@pass_client
|
||||
def link_list(client, issue):
|
||||
|
||||
issuelinks = issue.fields.issuelinks
|
||||
if issuelinks:
|
||||
print("\nIssue Links")
|
||||
# TODO group by type?
|
||||
for issuelink in issuelinks:
|
||||
summary = indent('\n'.join(wrap(issuelink.inwardIssue.fields.summary, width=72)), prefix=' ' * 4)
|
||||
print(f" {issuelink.type} - {issuelink.inwardIssue.key}\n{summary}")
|
||||
|
||||
remote_links = client.remote_links(issue.id)
|
||||
if remote_links:
|
||||
print("\nRemote Links:")
|
||||
for link in remote_links:
|
||||
# TODO tabulate?
|
||||
print(f" {link.object.title} => {link.object.url}")
|
||||
|
||||
|
||||
def prompt(description: str, *, multiline_ok: bool = True, initial_content: str = '') -> str:
|
||||
divider_line = '--- Everything after this line will be removed'
|
||||
|
||||
contents = f"{initial_content}\n{divider_line}\n\n{description}"
|
||||
contents = editor.edit(contents=contents).decode('utf-8')
|
||||
if not contents:
|
||||
raise click.ClickException('Empty content. Cannot continue.')
|
||||
|
||||
contents_lines = contents.splitlines()
|
||||
for i, line in enumerate(reversed(contents_lines), start=1):
|
||||
if line == divider_line:
|
||||
break
|
||||
else:
|
||||
raise click.ClickException('Could not find divider line. Please keep the footer intact.')
|
||||
contents_lines = contents_lines[:-i]
|
||||
|
||||
if not multiline_ok and len(contents_lines) > 1:
|
||||
# TODO build a retry into this?
|
||||
raise click.ClickException('Input needs to be a single line only')
|
||||
|
||||
if not contents_lines:
|
||||
raise click.ClickException('Empty content. Cannot continue.')
|
||||
|
||||
return '\n'.join(contents_lines)
|
||||
|
||||
|
||||
@link.command('create')
|
||||
@click.argument('URL', required=False)
|
||||
@click.argument('TITLE', required=False)
|
||||
@pass_issue
|
||||
@pass_client
|
||||
def link_create(client, issue, url: str, title: str):
|
||||
|
||||
if url is None:
|
||||
url = prompt(f"Input the URL for the link to add to {issue.key}")
|
||||
if title is None:
|
||||
title = prompt(f"Input a single line of title for {url}. This will be added to {issue.key}.",
|
||||
multiline_ok=False)
|
||||
|
||||
client.add_simple_link(issue.key, {'url': url, 'title': title})
|
||||
|
||||
|
||||
@link.command('issue')
|
||||
@pass_issue
|
||||
@pass_client
|
||||
def link_issue(client, issue):
|
||||
"""Link to another issue"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@issue.command('open')
|
||||
@pass_issue
|
||||
def issue_open(issue):
|
||||
import subprocess
|
||||
|
||||
subprocess.call(['xdg-open', issue.permalink()])
|
||||
|
||||
|
||||
@issue.group()
|
||||
def label():
|
||||
pass
|
||||
|
||||
|
||||
@label.command('list')
|
||||
@pass_issue
|
||||
def label_list(issue):
|
||||
"""List all labels on the issue"""
|
||||
print('\n'.join(issue.fields.labels))
|
||||
|
||||
|
||||
@label.command('edit')
|
||||
@pass_issue
|
||||
def label_edit(issue):
|
||||
"""Edit the labels of the issue"""
|
||||
import jira
|
||||
|
||||
original_labels = issue.fields.labels
|
||||
# TODO add a configurable list of "often used" labels into the description
|
||||
labels = set(prompt('Add or remove labels by adding them line by line or removing one',
|
||||
initial_content='\n'.join(original_labels)).splitlines())
|
||||
|
||||
original_labels = set(original_labels)
|
||||
|
||||
if labels == original_labels:
|
||||
print(f"Not updating {issue.key} as the labels are the same.")
|
||||
return
|
||||
|
||||
changes = []
|
||||
|
||||
added_labels = labels - original_labels
|
||||
for label in added_labels:
|
||||
changes.append({'add': label})
|
||||
|
||||
removed_labels = original_labels - labels
|
||||
for label in removed_labels:
|
||||
changes.append({'remove': label})
|
||||
|
||||
# inspired by jira.Issue.add_field_value
|
||||
jira.resources.Resource.update(issue, fields={"update": {"labels": changes}})
|
|
@ -0,0 +1,24 @@
|
|||
[metadata]
|
||||
name = jiracli
|
||||
version = 0.1
|
||||
description = JIRA command line interface
|
||||
|
||||
[options]
|
||||
packages = find:
|
||||
install_requires =
|
||||
click
|
||||
python-editor
|
||||
jira
|
||||
pyaml
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
jiracli = jiracli.cli:main
|
||||
|
||||
[options.packages.find]
|
||||
exclude=env
|
||||
|
||||
[flake8]
|
||||
max-line-length = 120
|
||||
exclude = .git,__pycache__,*.egg-info,*lib/python*
|
||||
ignore = E241,E741,W503,W504
|
Loading…
Reference in New Issue