├── 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 |
--------------------------------------------------------------------------------