This commit is contained in:
MasterofJOKers 2022-09-14 22:40:53 +02:00
parent 55bdeede87
commit d4491cf7ab
3 changed files with 338 additions and 0 deletions

310
jiracli/cli.py Normal file
View File

@ -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}})

24
setup.cfg Normal file
View File

@ -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

4
setup.py Normal file
View File

@ -0,0 +1,4 @@
import setuptools
setuptools.setup()