├── jira_term ├── __init__.py ├── config.py ├── utils.py └── cli.py ├── requirements.txt ├── .isort.cfg ├── setup.py ├── README.rst └── .gitignore /jira_term/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click==6.6 2 | jira==1.0.7 3 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | line_length=99 3 | multi_line_output=5 4 | include_trailing_comma=True 5 | known_future_library=future,pies 6 | known_standard_library=std,std2 7 | known_first_party=jira_cli 8 | default_section=THIRDPARTY 9 | indent=' ' 10 | sections=FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from setuptools import setup 5 | 6 | if sys.argv[-1] == 'publish': 7 | os.system('python setup.py sdist upload') 8 | sys.exit() 9 | 10 | setup( 11 | name='jira-term', 12 | version='0.1.0', 13 | py_modules=['jira_term'], 14 | install_requires=[ 15 | 'click', 16 | 'jira', 17 | ], 18 | extras_require={ 19 | 'tables': ['tabulate==0.7.5'], 20 | }, 21 | entry_points=''' 22 | [console_scripts] 23 | jira-term=jira_term.cli:cli 24 | ''' 25 | ) 26 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | JIRA CLI 2 | ======== 3 | 4 | JIRA CLI is a command line interface for JIRA that attempts to make common JIRA tasks easier. 5 | 6 | Usage 7 | ----- 8 | 9 | .. code-block:: shell 10 | 11 | >>> jira-term 12 | 13 | 14 | Install 15 | ------- 16 | 17 | Install Lassie via `pip `_ 18 | 19 | .. code-block:: bash 20 | 21 | $ pip install jira-term 22 | 23 | or, with `easy_install `_ 24 | 25 | .. code-block:: bash 26 | 27 | $ easy_install jira-term 28 | 29 | But, hey... `that's up to you `_. 30 | 31 | Documentation 32 | ------------- 33 | 34 | TBA 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | .venv/ 83 | venv/ 84 | ENV/ 85 | 86 | # Spyder project settings 87 | .spyderproject 88 | 89 | # Rope project settings 90 | .ropeproject 91 | -------------------------------------------------------------------------------- /jira_term/config.py: -------------------------------------------------------------------------------- 1 | import json 2 | from os import makedirs 3 | from os.path import expanduser, join 4 | 5 | import click 6 | 7 | 8 | class Config(dict): 9 | def __init__(self, *args, **kwargs): 10 | self.config = join(expanduser('~'), '.jira') 11 | 12 | try: 13 | makedirs(self.config) 14 | except OSError: 15 | pass 16 | 17 | self.config = join(self.config, 'config.json') 18 | 19 | super(Config, self).__init__(*args, **kwargs) 20 | 21 | def load(self): 22 | """load a JSON config file from disk""" 23 | try: 24 | _config_file = open(self.config, 'r+') 25 | data = json.loads(_config_file.read()) 26 | except (ValueError, IOError): 27 | data = {} 28 | 29 | self.update(data) 30 | 31 | def save(self): 32 | # self.config.ensure() 33 | _file = open(self.config, 'w+') 34 | _file.write(json.dumps(self)) 35 | 36 | def _setup(self): 37 | username = self.get('username') 38 | password = self.get('password') 39 | domain = self.get('domain') 40 | 41 | if not username or not password or not domain: 42 | if not domain: 43 | domain = click.prompt('Please enter your JIRA domain') 44 | 45 | if not username: 46 | username = click.prompt('Please enter your JIRA username') 47 | 48 | if not password: 49 | password = click.prompt('Please enter your JIRA password') 50 | 51 | self['domain'] = domain.rstrip('/') 52 | self['username'] = username 53 | self['password'] = password 54 | self.save() 55 | 56 | pass_config = click.make_pass_decorator(Config, ensure=True) 57 | -------------------------------------------------------------------------------- /jira_term/utils.py: -------------------------------------------------------------------------------- 1 | import click 2 | from jira import JIRAError 3 | 4 | 5 | def get_self(cli): 6 | try: 7 | _self = cli.jira.myself()['name'] 8 | return _self 9 | except JIRAError: 10 | click.echo( 11 | click.style( 12 | 'Passed "me" as the assignee. Unable to get information for the current user.', 13 | fg='yellow', 14 | ) 15 | ) 16 | 17 | 18 | 19 | def list_inner(cli, config, project, assignee, assignees, start_at, statuses, labels, label_operator, reporter, reporters, format, headers): 20 | jql = [] 21 | 22 | if project: 23 | jql.append('project={}'.format(project)) 24 | 25 | if assignee: 26 | assignees = [assignee] 27 | del assignee 28 | 29 | if assignees: 30 | _assignee_jql = [] 31 | for assignee in assignees: 32 | if assignee == 'me': 33 | assignee = get_self(cli) 34 | 35 | _assignee_jql.append('assignee={}'.format(assignee)) 36 | 37 | jql.append('({})'.format(' OR '.join(_assignee_jql))) 38 | 39 | if reporter: 40 | reporters = [reporter] 41 | del reporter 42 | 43 | if reporters: 44 | _reporter_jql = [] 45 | for reporter in reporters: 46 | if reporter == 'me': 47 | reporter = get_self(cli) 48 | 49 | _reporter_jql.append('reporter={}'.format(reporter)) 50 | 51 | jql.append('({})'.format(' OR '.join(_reporter_jql))) 52 | 53 | if statuses: 54 | _status_jql = [] 55 | for status in statuses: 56 | _status_jql.append('status={}'.format(status)) 57 | 58 | jql.append('({})'.format(' OR '.join(_status_jql))) 59 | 60 | if labels: 61 | _label_jql = [] 62 | for label in labels: 63 | _label_jql.append('labels={}'.format(label)) 64 | 65 | jql.append('({})'.format(' {} '.format(label_operator).join(_label_jql))) 66 | 67 | _jql = ' AND '.join(jql) 68 | print '_JQL', _jql 69 | 70 | issues = cli.jira.search_issues(_jql, startAt=start_at) 71 | 72 | if format == 'table' and headers: 73 | from tabulate import tabulate 74 | issues_table = [] 75 | for issue in issues: 76 | issue_row = [] 77 | for header in headers: 78 | if header == 'key': 79 | issue_row.append(issue.key) 80 | 81 | if header == 'summary': 82 | issue_row.append(issue.fields.summary) 83 | 84 | if header == 'labels': 85 | issue_row.append(', '.join(issue.fields.labels)) 86 | 87 | if header == 'assignee': 88 | issue_row.append(issue.fields.assignee) 89 | 90 | if header == 'reporter': 91 | issue_row.append(issue.fields.reporter) 92 | 93 | issues_table.append(issue_row) 94 | click.echo(tabulate(issues_table, headers=[click.style(header.upper(), bold=True) for header in headers], tablefmt='fancy_grid')) 95 | elif format == 'list': 96 | for issue in issues: 97 | click.echo('{}: {}'.format(issue.key, issue.fields.summary.encode('utf-8'))) 98 | 99 | total = issues.total 100 | total_returned = len(issues) 101 | new_start_at = (issues.startAt + total_returned) 102 | 103 | if new_start_at < total: 104 | if click.confirm('View the next page of results?', abort=True): 105 | list_inner(cli, config, project, assignee, assignees, new_start_at, statuses, labels, label_operator, reporter, reporters, format, headers) 106 | -------------------------------------------------------------------------------- /jira_term/cli.py: -------------------------------------------------------------------------------- 1 | import click 2 | from jira import JIRA, JIRAError 3 | 4 | from .config import pass_config 5 | from .utils import get_self, list_inner 6 | 7 | 8 | @click.group() 9 | @pass_config 10 | @click.pass_context 11 | def cli(ctx, config,): 12 | config.load() 13 | 14 | if not ctx.invoked_subcommand == 'config': 15 | config._setup() 16 | config.load() 17 | 18 | try: 19 | cli.jira = JIRA(config['domain'], basic_auth=(config['username'], config['password'])) 20 | except: 21 | pass 22 | 23 | 24 | @cli.group() 25 | @pass_config 26 | def config(config): 27 | pass 28 | 29 | 30 | @config.command() 31 | @pass_config 32 | @click.option('--username', '-u') 33 | @click.option('--password', '-pw') 34 | @click.option('--domain', '-d') 35 | @click.option('--project', '-p') 36 | @click.option('--type', '-t') 37 | @click.option('--assignee') 38 | def set(config, username, password, domain, project, type, assignee): 39 | if username: 40 | config['username'] = username 41 | 42 | if password: 43 | config['password'] = password 44 | 45 | if domain: 46 | config['domain'] = domain 47 | 48 | if project: 49 | config['project'] = project 50 | 51 | if type: 52 | config['type'] = type 53 | 54 | if assignee: 55 | config['assignee'] = assignee 56 | 57 | config.save() 58 | config.load() 59 | 60 | 61 | 62 | 63 | @cli.group() 64 | @pass_config 65 | def issues(config): 66 | pass 67 | 68 | @issues.command() 69 | @pass_config 70 | @click.option('--project', '-p') 71 | @click.option('--assignee') 72 | @click.option('--assignees', multiple=True) 73 | @click.option('--start_at', '-sa', default=0, type=int) 74 | @click.option('--statuses', '-s', multiple=True) 75 | @click.option('--labels', '-l', multiple=True) 76 | @click.option('--label_operator', default='OR', type=click.Choice(['OR', 'AND'])) 77 | @click.option('--reporter') 78 | @click.option('--reporters', '-r', multiple=True) 79 | @click.option('--format', '-f', default='list', type=click.Choice(['list', 'table'])) 80 | @click.option('--headers', '-h', multiple=True, type=click.Choice(['key', 'summary', 'labels', 'assignee', 'reporter'])) 81 | def list(config, project, assignee, assignees, start_at, statuses, labels, label_operator, reporter, reporters, format, headers): 82 | list_inner(cli, config, project, assignee, assignees, start_at, statuses, labels, label_operator, reporter, reporters, format, headers) 83 | 84 | 85 | @issues.command() 86 | @pass_config 87 | @click.option('--project', '-p') 88 | @click.option('--summary', '-s', required=True) 89 | @click.option('--description', '-d', default='') 90 | @click.option('--type', '-t') 91 | @click.option('--attachments', '-a', multiple=True, type=click.File('rb')) 92 | @click.option('--assignee') 93 | @click.option('--reporter', '-r') 94 | @click.option('--labels', '-l', multiple=True) 95 | def create(config, project, summary, description, type, attachments, assignee, labels, reporter): 96 | project = project or config.get('project') 97 | type = type or config.get('type') 98 | assignee = assignee or config.get('assignee') 99 | 100 | if not project: 101 | click.echo( 102 | click.style( 103 | 'Please supply a project in the config or pass --project to this command.', 104 | fg='red', 105 | ) 106 | ) 107 | return False 108 | 109 | if not type: 110 | click.echo( 111 | click.style( 112 | 'Please supply a project in the config or pass --type to this command.', 113 | fg='red', 114 | ) 115 | ) 116 | return False 117 | 118 | optional_kwargs = {} 119 | 120 | if assignee: 121 | if assignee == 'me': 122 | assignee = get_self(cli) 123 | 124 | optional_kwargs['assignee'] = {'name': assignee} 125 | 126 | if reporter: 127 | if reporter == 'me': 128 | reporter = get_self(cli) 129 | 130 | optional_kwargs['reporter'] = {'name': reporter} 131 | 132 | try: 133 | issue = cli.jira \ 134 | .create_issue( 135 | project=project, 136 | summary=summary, 137 | description=description, 138 | issuetype={'name': type}, 139 | labels=labels, 140 | **optional_kwargs 141 | ) 142 | 143 | url = '{}/browse/{}'.format(config['domain'], issue.key) 144 | click.echo( 145 | click.style( 146 | 'Issue created!', 147 | fg='green', 148 | ), 149 | ) 150 | click.echo('View issue: {}'.format(url)) 151 | except JIRAError as e: 152 | extra_message = '' 153 | if 'issue type is required' in e.text: 154 | extra_message = 'For a list of available issue types, run `jira-cli issues types list`' 155 | 156 | click.echo( 157 | click.style( 158 | '{} \n{}'.format(str(e.text), extra_message), 159 | fg='red', 160 | ) 161 | ) 162 | 163 | return False 164 | 165 | if attachments: 166 | with click.progressbar(attachments, label='Uploading attachments', length=len(attachments)) as bar: 167 | for attachment in attachments: 168 | try: 169 | cli.jira.add_attachment(issue=issue, attachment=attachment) 170 | bar.update(1) # this progress bar represents total attachments uploaded, not total bytes uploaded 171 | except JIRAError as e: 172 | click.echo( 173 | click.style( 174 | '{}'.format(str(e.text)), 175 | fg='red', 176 | ) 177 | ) 178 | --------------------------------------------------------------------------------