├── .gitignore ├── Dockerfile ├── LICENSE.md ├── README.md ├── examples ├── issue_graph.png └── issue_graph_complex.png ├── jira-dependency-graph.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # OS / IDE specific 2 | .DS_Store 3 | .idea 4 | 5 | # Distribution / packaging 6 | .Python 7 | build/ 8 | develop-eggs/ 9 | dist/ 10 | downloads/ 11 | eggs/ 12 | .eggs/ 13 | lib/ 14 | lib64/ 15 | parts/ 16 | sdist/ 17 | var/ 18 | wheels/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | 23 | # Installer logs 24 | pip-log.txt 25 | pip-delete-this-directory.txt 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3-alpine 2 | 3 | ADD jira-dependency-graph.py /jira/ 4 | ADD requirements.txt /jira/ 5 | WORKDIR /jira 6 | RUN pip install -r requirements.txt 7 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Paweł Rychlik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | jira-dependency-graph 2 | ===================== 3 | 4 | Graph visualizer for dependencies between JIRA tickets. Takes into account subtasks and issue links. 5 | 6 | Uses JIRA rest API v2 for fetching information on issues. 7 | Uses [Google Chart API](https://developers.google.com/chart/) for graphical presentation. 8 | 9 | Example output 10 | ============== 11 | 12 | ![Example graph](examples/issue_graph_complex.png) 13 | 14 | Requirements: 15 | ============= 16 | * Python 2.7+ or Python 3+ 17 | * [requests](http://docs.python-requests.org/en/master/) 18 | 19 | Or 20 | * [docker](https://docs.docker.com/install/) 21 | 22 | Usage: 23 | ====== 24 | ```bash 25 | $ git clone https://github.com/pawelrychlik/jira-dependency-graph.git 26 | $ virtualenv .virtualenv && source .virtualenv/bin/activate # OPTIONAL 27 | $ cd jira-dependency-graph 28 | $ pip install -r requirements.txt 29 | $ python jira-dependency-graph.py --user=your-jira-username --password=your-jira-password --jira=url-of-your-jira-site issue-key 30 | ``` 31 | 32 | Or if you prefer running in docker: 33 | ```bash 34 | $ git clone https://github.com/pawelrychlik/jira-dependency-graph.git 35 | $ cd jira-dependency-graph 36 | $ docker build -t jira . 37 | $ docker run -v $PWD/out:/out jira python jira-dependency-graph.py --user=your-jira-username --password=your-jira-password --jira=url-of-your-jira-site --file=/out/output.png issue-key 38 | ``` 39 | 40 | ``` 41 | # e.g.: 42 | $ python jira-dependency-graph.py --user=pawelrychlik --password=s3cr3t --jira=https://your-company.jira.com JIRATICKET-718 43 | 44 | Fetching JIRATICKET-2451 45 | JIRATICKET-2451 <= is blocked by <= JIRATICKET-3853 46 | JIRATICKET-2451 <= is blocked by <= JIRATICKET-3968 47 | JIRATICKET-2451 <= is blocked by <= JIRATICKET-3126 48 | JIRATICKET-2451 <= is blocked by <= JIRATICKET-2977 49 | Fetching JIRATICKET-3853 50 | JIRATICKET-3853 => blocks => JIRATICKET-2451 51 | JIRATICKET-3853 <= relates to <= JIRATICKET-3968 52 | Fetching JIRATICKET-3968 53 | JIRATICKET-3968 => blocks => JIRATICKET-2451 54 | JIRATICKET-3968 => relates to => JIRATICKET-3853 55 | Fetching JIRATICKET-3126 56 | JIRATICKET-3126 => blocks => JIRATICKET-2451 57 | JIRATICKET-3126 => testing discovered => JIRATICKET-3571 58 | Fetching JIRATICKET-3571 59 | JIRATICKET-3571 <= discovered while testing <= JIRATICKET-3126 60 | Fetching JIRATICKET-2977 61 | JIRATICKET-2977 => blocks => JIRATICKET-2451 62 | 63 | Writing to issue_graph.png 64 | ``` 65 | Result: 66 | ![Example result](examples/issue_graph.png) 67 | 68 | 69 | Advanced Usage: 70 | =============== 71 | 72 | List of all configuration options with descriptions: 73 | 74 | ``` 75 | python jira-dependency-graph.py --help 76 | ``` 77 | 78 | ### Excluding Links 79 | 80 | In case you have specific issue links you don't want to see in your graph, you can exclude them: 81 | 82 | ```bash 83 | $ python jira-dependency-graph.py --user=your-jira-username --password=your-jira-password --jira=url-of-your-jira-site --exclude-link 'is required by' --exclude-link 'duplicates' issue-key 84 | ``` 85 | 86 | The grapher will still walk the link, just exclude the edge. This especially useful for bidirectional links and you only 87 | want to see one of them, e.g. *depends on* and *is required by*. 88 | 89 | ### Excluding Epics 90 | 91 | In case you want to exclude walking into issues of an Epic, you can ignore them: 92 | 93 | ```bash 94 | $ python jira-dependency-graph.py --user=your-jira-username --password=your-jira-password --jira=url-of-your-jira-site --ignore-epic issue-key 95 | ``` 96 | 97 | ### Including Issues 98 | 99 | In order to only specify issues with a certain prefix pass in `--issue-include ` and all tickets will be checked that they match the prefix `XXX`. 100 | 101 | ### Excluding Issues 102 | 103 | By passing in `--issue-exclude`, or `-xi` the system will explicitly ignore the ticket. It can be repeated multiple times, e.g. `-xi MYPR-456 -x MYPR-999` to ignore both issues. 104 | Use it as a last-resort only, when other means of exclusion do not suit your case, e.g. to omit a part of the graph for better readability. 105 | 106 | ### Authentication 107 | 108 | It is possible to either use: 109 | * the username/password combination 110 | * to login via a token passing in `--bearer `. This allows to use a Personal Access Token generated in your JIRA profile (https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html) 111 | * to login via the browser passing in `--cookie `. This logins via the browser and is useful in scenarios where Kerberos authentication is required. 112 | 113 | If you are using Atlassian Cloud, use your API token instead of your account password. You can generate one with the following steps: 114 | 115 | 1. Access https://id.atlassian.com/manage-profile/security/api-tokens. 116 | 2. Click "Create API token". 117 | 3. Copy the token and store it in a safe place. 118 | 119 | More details about API authentication is available in the [official documentation](https://developer.atlassian.com/cloud/jira/platform/basic-auth-for-rest-apis/). 120 | 121 | ### Closed Issues 122 | 123 | By passing in `--ignore-closed` the system will ignore any ticket that is closed. 124 | 125 | ### Multiple Issues 126 | 127 | Multiple issue-keys can be passed in via space separated format e.g. 128 | ```bash 129 | $ python jira-dependency-graph.py --cookie issue-key1 issue-key2 130 | ``` 131 | 132 | ### JQL Query 133 | 134 | Instead of passing issue-keys, a Jira Query Language command can be passed with `--jql` e.g. 135 | ```bash 136 | $ python jira-dependency-graph.py --cookie --jql 'project = JRADEV' 137 | ``` 138 | 139 | 140 | Usage without Google Graphviz API: 141 | ============ 142 | If you have issues with the Google Graphviz API limitations you can use your local graphviz installation like this: 143 | 144 | ```bash 145 | $ git clone https://github.com/pawelrychlik/jira-dependency-graph.git 146 | $ cd jira-dependency-graph 147 | $ python jira-dependency-graph.py --user=your-jira-username --password=your-jira-password --jira=url-of-your-jira-site --local issue-key | dot -Tpng > issue_graph.png 148 | ``` 149 | 150 | *Note*: Its possible that the graph produced is too wide if you have a number of issues. In this case, it is better to firstly pipe the graph to a 'dot' text file e.g. 151 | 152 | ```bash 153 | $ python jira-dependency-graph.py --jira=url-of-your-jira-site --local issue-key > graph.dot 154 | ``` 155 | 156 | and then process it using `unflatten`: 157 | 158 | ```bash 159 | unflatten -f -l 4 -c 16 graph.dot | dot | gvpack -array_t6 | neato -s -n2 -Tpng -o graph.png 160 | ``` 161 | 162 | For a slightly cleaner layout (that preserves the ranks), or if your system doesn't have `unflatten`, you can use `sed` to insert `rankdir=LR;` into the dot file before processing it: 163 | ```bash 164 | sed -i 's/digraph{/digraph{ rankdir=LR;/g' graph.dot | dot -o graph.png -Tpng 165 | ``` 166 | 167 | Notes: 168 | ====== 169 | Based on: [draw-chart.py](https://developer.atlassian.com/download/attachments/4227078/draw-chart.py) and [Atlassian JIRA development documentation](https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+Version+2+Tutorial#JIRARESTAPIVersion2Tutorial-Example#1:GraphingImageLinks), which seemingly was no longer compatible with JIRA REST API Version 2. 170 | -------------------------------------------------------------------------------- /examples/issue_graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelrychlik/jira-dependency-graph/6350fb79d9efcd6cf990d382276afda7cff43277/examples/issue_graph.png -------------------------------------------------------------------------------- /examples/issue_graph_complex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelrychlik/jira-dependency-graph/6350fb79d9efcd6cf990d382276afda7cff43277/examples/issue_graph_complex.png -------------------------------------------------------------------------------- /jira-dependency-graph.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from __future__ import print_function 4 | 5 | import argparse 6 | import getpass 7 | import sys 8 | import textwrap 9 | 10 | import requests 11 | from functools import reduce 12 | 13 | GOOGLE_CHART_URL = 'https://chart.apis.google.com/chart' 14 | MAX_SUMMARY_LENGTH = 30 15 | 16 | 17 | def log(*args): 18 | print(*args, file=sys.stderr) 19 | 20 | 21 | class JiraSearch(object): 22 | """ This factory will create the actual method used to fetch issues from JIRA. This is really just a closure that 23 | saves us having to pass a bunch of parameters all over the place all the time. """ 24 | 25 | __base_url = None 26 | 27 | def __init__(self, url, auth, no_verify_ssl): 28 | self.__base_url = url 29 | self.url = url + '/rest/api/latest' 30 | self.auth = auth 31 | self.no_verify_ssl = no_verify_ssl 32 | self.fields = ','.join(['key', 'summary', 'status', 'description', 'issuetype', 'issuelinks', 'subtasks']) 33 | 34 | def get(self, uri, params={}): 35 | headers = {'Content-Type' : 'application/json'} 36 | url = self.url + uri 37 | 38 | if isinstance(self.auth, dict): 39 | headers_with_auth = headers.copy() 40 | headers_with_auth.update(self.auth) 41 | return requests.get(url, params=params, headers=headers_with_auth, verify=(not self.no_verify_ssl)) 42 | else: 43 | return requests.get(url, params=params, auth=self.auth, headers=headers, verify=(not self.no_verify_ssl)) 44 | 45 | def get_issue(self, key): 46 | """ Given an issue key (i.e. JRA-9) return the JSON representation of it. This is the only place where we deal 47 | with JIRA's REST API. """ 48 | log('Fetching ' + key) 49 | # we need to expand subtasks and links since that's what we care about here. 50 | response = self.get('/issue/%s' % key, params={'fields': self.fields}) 51 | response.raise_for_status() 52 | return response.json() 53 | 54 | def query(self, query): 55 | log('Querying ' + query) 56 | response = self.get('/search', params={'jql': query, 'fields': self.fields}) 57 | content = response.json() 58 | return content['issues'] 59 | 60 | def list_ids(self, query): 61 | log('Querying ' + query) 62 | response = self.get('/search', params={'jql': query, 'fields': 'key', 'maxResults': 100}) 63 | return [issue["key"] for issue in response.json()["issues"]] 64 | 65 | def get_issue_uri(self, issue_key): 66 | return self.__base_url + '/browse/' + issue_key 67 | 68 | 69 | def build_graph_data(start_issue_key, jira, excludes, ignores, show_directions, directions, includes, issue_excludes, 70 | ignore_closed, ignore_epic, ignore_subtasks, traverse, word_wrap): 71 | """ Given a starting image key and the issue-fetching function build up the GraphViz data representing relationships 72 | between issues. This will consider both subtasks and issue links. 73 | """ 74 | def get_key(issue): 75 | return issue['key'] 76 | 77 | def get_status_color(status_field): 78 | status = status_field['statusCategory']['name'].upper() 79 | if status == 'IN PROGRESS': 80 | return 'yellow' 81 | elif status == 'DONE': 82 | return 'green' 83 | return 'white' 84 | 85 | def create_node_text(issue_key, fields, islink=True): 86 | summary = fields['summary'] 87 | status = fields['status'] 88 | 89 | if word_wrap == True: 90 | if len(summary) > MAX_SUMMARY_LENGTH: 91 | # split the summary into multiple lines adding a \n to each line 92 | summary = textwrap.fill(fields['summary'], MAX_SUMMARY_LENGTH) 93 | else: 94 | # truncate long labels with "...", but only if the three dots are replacing more than two characters 95 | # -- otherwise the truncated label would be taking more space than the original. 96 | if len(summary) > MAX_SUMMARY_LENGTH + 2: 97 | summary = fields['summary'][:MAX_SUMMARY_LENGTH] + '...' 98 | summary = summary.replace('"', '\\"') 99 | # log('node ' + issue_key + ' status = ' + str(status)) 100 | 101 | if islink: 102 | return '"{}\\n({})"'.format(issue_key, summary) 103 | return '"{}\\n({})" [href="{}", fillcolor="{}", style=filled]'.format(issue_key, summary, jira.get_issue_uri(issue_key), get_status_color(status)) 104 | 105 | def process_link(fields, issue_key, link): 106 | if 'outwardIssue' in link: 107 | direction = 'outward' 108 | elif 'inwardIssue' in link: 109 | direction = 'inward' 110 | else: 111 | return 112 | 113 | if direction not in directions: 114 | return 115 | 116 | linked_issue = link[direction + 'Issue'] 117 | linked_issue_key = get_key(linked_issue) 118 | if linked_issue_key in issue_excludes: 119 | log('Skipping ' + linked_issue_key + ' - explicitly excluded') 120 | return 121 | 122 | link_type = link['type'][direction] 123 | 124 | if ignore_closed: 125 | if ('inwardIssue' in link) and (link['inwardIssue']['fields']['status']['name'] in 'Closed'): 126 | log('Skipping ' + linked_issue_key + ' - linked key is Closed') 127 | return 128 | if ('outwardIssue' in link) and (link['outwardIssue']['fields']['status']['name'] in 'Closed'): 129 | log('Skipping ' + linked_issue_key + ' - linked key is Closed') 130 | return 131 | 132 | if includes not in linked_issue_key: 133 | return 134 | 135 | if link_type.strip() in ignores: 136 | return 137 | 138 | if link_type.strip() in excludes: 139 | return linked_issue_key, None 140 | 141 | arrow = ' => ' if direction == 'outward' else ' <= ' 142 | log(issue_key + arrow + link_type + arrow + linked_issue_key) 143 | 144 | extra = ',color="red"' if link_type == "blocks" else "" 145 | 146 | if direction not in show_directions: 147 | node = None 148 | else: 149 | # log("Linked issue summary " + linked_issue['fields']['summary']) 150 | node = '{}->{}[label="{}"{}]'.format( 151 | create_node_text(issue_key, fields), 152 | create_node_text(linked_issue_key, linked_issue['fields']), 153 | link_type, extra) 154 | 155 | return linked_issue_key, node 156 | 157 | # since the graph can be cyclic we need to prevent infinite recursion 158 | seen = [] 159 | 160 | def walk(issue_key, graph): 161 | """ issue is the JSON representation of the issue """ 162 | issue = jira.get_issue(issue_key) 163 | children = [] 164 | fields = issue['fields'] 165 | seen.append(issue_key) 166 | 167 | if ignore_closed and (fields['status']['name'] in 'Closed'): 168 | log('Skipping ' + issue_key + ' - it is Closed') 169 | return graph 170 | 171 | if not traverse and ((project_prefix + '-') not in issue_key): 172 | log('Skipping ' + issue_key + ' - not traversing to a different project') 173 | return graph 174 | 175 | graph.append(create_node_text(issue_key, fields, islink=False)) 176 | 177 | if not ignore_subtasks: 178 | if fields['issuetype']['name'] == 'Epic' and not ignore_epic: 179 | issues = jira.query('"Epic Link" = "%s"' % issue_key) 180 | for subtask in issues: 181 | subtask_key = get_key(subtask) 182 | log(subtask_key + ' => references epic => ' + issue_key) 183 | node = '{}->{}[color=orange]'.format( 184 | create_node_text(issue_key, fields), 185 | create_node_text(subtask_key, subtask['fields'])) 186 | graph.append(node) 187 | children.append(subtask_key) 188 | if 'subtasks' in fields and not ignore_subtasks: 189 | for subtask in fields['subtasks']: 190 | subtask_key = get_key(subtask) 191 | log(issue_key + ' => has subtask => ' + subtask_key) 192 | node = '{}->{}[color=blue][label="subtask"]'.format ( 193 | create_node_text(issue_key, fields), 194 | create_node_text(subtask_key, subtask['fields'])) 195 | graph.append(node) 196 | children.append(subtask_key) 197 | 198 | if 'issuelinks' in fields: 199 | for other_link in fields['issuelinks']: 200 | result = process_link(fields, issue_key, other_link) 201 | if result is not None: 202 | log('Appending ' + result[0]) 203 | children.append(result[0]) 204 | if result[1] is not None: 205 | graph.append(result[1]) 206 | # now construct graph data for all subtasks and links of this issue 207 | for child in (x for x in children if x not in seen): 208 | walk(child, graph) 209 | return graph 210 | 211 | project_prefix = start_issue_key.split('-', 1)[0] 212 | return walk(start_issue_key, []) 213 | 214 | 215 | def create_graph_image(graph_data, image_file, node_shape): 216 | """ Given a formatted blob of graphviz chart data[1], make the actual request to Google 217 | and store the resulting image to disk. 218 | 219 | [1]: http://code.google.com/apis/chart/docs/gallery/graphviz.html 220 | """ 221 | digraph = 'digraph{node [shape=' + node_shape +'];%s}' % ';'.join(graph_data) 222 | 223 | response = requests.post(GOOGLE_CHART_URL, data = {'cht':'gv', 'chl': digraph}) 224 | 225 | with open(image_file, 'w+b') as image: 226 | print('Writing to ' + image_file) 227 | binary_format = bytearray(response.content) 228 | image.write(binary_format) 229 | image.close() 230 | 231 | return image_file 232 | 233 | 234 | def print_graph(graph_data, node_shape): 235 | print('digraph{\nnode [shape=' + node_shape +'];\n\n%s\n}' % ';\n'.join(graph_data)) 236 | 237 | 238 | def parse_args(): 239 | parser = argparse.ArgumentParser() 240 | parser.add_argument('-u', '--user', dest='user', default=None, help='Username to access JIRA') 241 | parser.add_argument('-p', '--password', dest='password', default=None, help='Password to access JIRA') 242 | parser.add_argument('-c', '--cookie', dest='cookie', default=None, help='JSESSIONID session cookie value') 243 | parser.add_argument('-b', '--bearer', dest='bearer', default=None, help='Bearer Token (Personal Access Token)') 244 | parser.add_argument('-N', '--no-auth', dest='no_auth', action='store_true', default=False, help='Use no authentication') 245 | parser.add_argument('-j', '--jira', dest='jira_url', default='http://jira.example.com', help='JIRA Base URL (with protocol)') 246 | parser.add_argument('-f', '--file', dest='image_file', default='issue_graph.png', help='Filename to write image to') 247 | parser.add_argument('-l', '--local', action='store_true', default=False, help='Render graphviz code to stdout') 248 | parser.add_argument('-e', '--ignore-epic', action='store_true', default=False, help='Don''t follow an Epic into it''s children issues') 249 | parser.add_argument('-x', '--exclude-link', dest='excludes', default=[], action='append', help='Travel link type(s) but excluding it from the graph') 250 | parser.add_argument('-il', '--ignore-link', dest='ignores', default=[], action='append', help='Exclude link type(s)') 251 | parser.add_argument('-ic', '--ignore-closed', dest='closed', action='store_true', default=False, help='Ignore closed issues') 252 | parser.add_argument('-i', '--issue-include', dest='includes', default='', help='Include issue keys') 253 | parser.add_argument('-xi', '--issue-exclude', dest='issue_excludes', action='append', default=[], help='Exclude issue keys; can be repeated for multiple issues') 254 | parser.add_argument('-s', '--show-directions', dest='show_directions', default=['inward', 'outward'], help='which directions to show (inward, outward)') 255 | parser.add_argument('-d', '--directions', dest='directions', default=['inward', 'outward'], help='which directions to walk (inward, outward)') 256 | parser.add_argument('--jql', dest='jql_query', default=None, help='JQL search for issues (e.g. \'project = JRADEV\')') 257 | parser.add_argument('-ns', '--node-shape', dest='node_shape', default='box', help='which shape to use for nodes (circle, box, ellipse, etc)') 258 | parser.add_argument('-t', '--ignore-subtasks', action='store_true', default=False, help='Don''t include sub-tasks issues') 259 | parser.add_argument('-T', '--dont-traverse', dest='traverse', action='store_false', default=True, help='Do not traverse to other projects') 260 | parser.add_argument('-w', '--word-wrap', dest='word_wrap', default=False, action='store_true', help='Word wrap issue summaries instead of truncating them') 261 | parser.add_argument('--no-verify-ssl', dest='no_verify_ssl', default=False, action='store_true', help='Don\'t verify SSL certs for requests') 262 | parser.add_argument('issues', nargs='*', help='The issue key (e.g. JRADEV-1107, JRADEV-1391)') 263 | return parser.parse_args() 264 | 265 | 266 | def filter_duplicates(lst): 267 | # Enumerate the list to restore order lately; reduce the sorted list; restore order 268 | def append_unique(acc, item): 269 | return acc if acc[-1][1] == item[1] else acc.append(item) or acc 270 | srt_enum = sorted(enumerate(lst), key=lambda i_val: i_val[1]) 271 | return [item[1] for item in sorted(reduce(append_unique, srt_enum, [srt_enum[0]]))] 272 | 273 | 274 | def main(): 275 | options = parse_args() 276 | 277 | if options.bearer is not None: 278 | # Generate JIRA Personal Access Token and use --bearer=ABCDEF012345 commandline argument 279 | auth = {'Authorization': 'Bearer ' + options.bearer} 280 | elif options.cookie is not None: 281 | # Log in with browser and use --cookie=ABCDEF012345 commandline argument 282 | auth = {'JSESSIONID': options.cookie} 283 | elif options.no_auth is True: 284 | # Don't use authentication when it's not needed 285 | auth = None 286 | else: 287 | # Basic Auth is usually easier for scripts like this to deal with than Cookies. 288 | user = options.user if options.user is not None \ 289 | else input('Username: ') 290 | password = options.password if options.password is not None \ 291 | else getpass.getpass('Password: ') 292 | auth = (user, password) 293 | 294 | jira = JiraSearch(options.jira_url, auth, options.no_verify_ssl) 295 | 296 | if options.jql_query is not None: 297 | options.issues.extend(jira.list_ids(options.jql_query)) 298 | 299 | graph = [] 300 | for issue in options.issues: 301 | graph = graph + build_graph_data(issue, jira, options.excludes, options.ignores, options.show_directions, options.directions, 302 | options.includes, options.issue_excludes, options.closed, options.ignore_epic, 303 | options.ignore_subtasks, options.traverse, options.word_wrap) 304 | 305 | if options.local: 306 | print_graph(filter_duplicates(graph), options.node_shape) 307 | else: 308 | create_graph_image(filter_duplicates(graph), options.image_file, options.node_shape) 309 | 310 | 311 | if __name__ == '__main__': 312 | main() 313 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests ~= 2.22 2 | --------------------------------------------------------------------------------