jira_dependency_visualizer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ # coding: utf-8
2
+ require 'rake'
3
+
4
+ lib = File.expand_path('../lib', __FILE__)
5
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
6
+ require 'jira_dependency_visualizer/version'
7
+
8
+ Gem::Specification.new do |s|
9
+ s.authors = ['Alejandro Figueroa']
10
+ s.bindir = 'bin'
11
+ s.description = 'Creates a graphviz file from a JIRA ticket\'s dependencies'
12
+ s.email = ['alejandro@ideasftw.com']
13
+ s.executables << 'jira_dependency_visualizer'
14
+ s.files = FileList['lib/**/*.rb', 'bin/*', '[A-Z]*', 'spec/**/*'].to_a
15
+ s.homepage = 'https://github.com/thejandroman/jira_dependency_visualizer'
16
+ s.license = 'MIT'
17
+ s.name = 'jira_dependency_visualizer'
18
+ s.summary = 'Creates a graphviz file from a JIRA ticket\'s dependencies'
19
+ s.required_ruby_version = ['>= 2.1.0', '<= 2.3.0']
20
+ s.version = JiraDependencyVisualizer::VERSION
21
+
22
+ s.add_dependency 'jira-ruby', '~> 0.1'
23
+ s.add_dependency 'ruby-graphviz', '~> 1.2'
24
+ s.add_dependency 'trollop', '~> 2.1'
25
+
26
+ s.add_development_dependency 'bundler', '~> 1.11'
27
+ s.add_development_dependency 'coveralls', '~> 0.8'
28
+ s.add_development_dependency 'factory_girl', '~> 4.5'
29
+ s.add_development_dependency 'guard-rspec', '~> 4.6'
30
+ s.add_development_dependency 'pry', '~> 0.10'
31
+ s.add_development_dependency 'rake', '~> 10.5'
32
+ s.add_development_dependency 'rspec', '~> 3.4'
33
+ s.add_development_dependency 'rubocop', '~> 0.37'
34
+ s.add_development_dependency 'yard', '~> 0.8'
35
+ end
@@ -0,0 +1,7 @@
1
+ require 'jira_dependency_visualizer/graph'
2
+ require 'jira_dependency_visualizer/jira'
3
+ require 'jira_dependency_visualizer/version'
4
+
5
+ # The gem namespace
6
+ module JiraDependencyVisualizer
7
+ end
@@ -0,0 +1,118 @@
1
+ require 'graphviz'
2
+
3
+ module JiraDependencyVisualizer
4
+ # Gets Jira issue dependencies and creates a Graphviz graph based on
5
+ # those dependencies
6
+ class Graph
7
+ # @param [String] the initial issue to build the dependency graph
8
+ # from
9
+ # @param [JiraDependencyVisualizer::Jira]
10
+ # @param [Array] list of issue link types to exclude from the
11
+ # graph
12
+ # @param [Hash] colors for graph; see `./config/colors.yaml`
13
+ # @param [Graphviz]
14
+ def initialize(start_issue_key, jira, excludes = [], colors = {}, graph = nil)
15
+ @start_issue_key = start_issue_key
16
+ @jira = jira
17
+ @excludes = excludes
18
+ @colors = colors
19
+ @graph = graph || GraphViz.new(start_issue_key.to_sym, type: :digraph)
20
+ @seen = []
21
+ end
22
+
23
+ # Retrieve a list of all tickets dependent to `@start_issue_key`
24
+ # and add them to a Graphviz graph.
25
+ def walk
26
+ issue_walker(@start_issue_key)
27
+ end
28
+
29
+ # @param [String] filename the output filename
30
+ # @param [String] format the graphviz output format for the graph
31
+ def write_graph(filename = './issue_graph.svg', format = 'svg')
32
+ @graph.output(format.to_sym => filename)
33
+ end
34
+
35
+ private
36
+
37
+ def status_color(issue)
38
+ return {} unless @colors.key?('status')
39
+ @colors['status'][issue['fields']['status']['name']] || {}
40
+ end
41
+
42
+ def get_key(issue)
43
+ issue.attrs['key']
44
+ end
45
+
46
+ def process_link(issue_key, link)
47
+ return unless link.key?('outwardIssue')
48
+
49
+ direction = 'outward'
50
+ linked_issue_key = link[direction + 'Issue']['key']
51
+ link_type = link['type'][direction]
52
+
53
+ return linked_issue_key if @excludes.include?(link_type)
54
+
55
+ attrs = { label: link_type }
56
+ attrs[:color] = 'red' if link_type == 'blocks'
57
+ node_edge_builder(issue_key, linked_issue_key, attrs)
58
+ # node_attr_builder(linked_issue_key, status_color(link['outwardIssue']))
59
+
60
+ linked_issue_key
61
+ end
62
+
63
+ def node_edge_builder(node1_key, node2_key, edge_attrs)
64
+ @graph.add_edges(node1_key, node2_key, edge_attrs)
65
+ end
66
+
67
+ def node_attr_builder(issue)
68
+ issue_key = get_key(issue)
69
+ attrs = status_color(issue.attrs).merge(
70
+ 'URL' => "#{@jira.base_url}/browse/#{issue_key}",
71
+ 'tooltip' => issue.attrs['fields']['summary']
72
+ )
73
+ node = @graph.search_node(issue_key)
74
+ attrs.each { |k, v| node[k] = v }
75
+ end
76
+
77
+ def epic_walker(issue_key, children)
78
+ issues = @jira.query("'Epic Link' = '#{issue_key}'")
79
+ issues.each do |subtask|
80
+ subtask_key = get_key(subtask)
81
+ node_edge_builder(issue_key, subtask_key, color: 'orange')
82
+ node_attr_builder(subtask)
83
+ children.push(subtask_key)
84
+ end
85
+ end
86
+
87
+ def issue_walker(issue_key)
88
+ issue = @jira.get_issue(issue_key)
89
+ @seen.push(issue_key)
90
+ fields = issue.attrs['fields']
91
+ fields.reject! { |_, v| v.nil? || v.empty? if v.is_a? Array }
92
+ children = []
93
+
94
+ epic_walker(issue_key, children) if fields['issuetype']['name'] == 'Epic'
95
+ subtasks_walker(fields['subtasks'], issue_key, children) if fields.key?('subtasks')
96
+ issuelinks_walker(fields['issuelinks'], issue_key, children) if fields.key?('issuelinks')
97
+
98
+ (children - @seen).each { |child| issue_walker(child) }
99
+ end
100
+
101
+ def issuelinks_walker(issuelinks, issue_key, children)
102
+ issuelinks.each do |other_link|
103
+ result = process_link(issue_key, other_link)
104
+ next if result.nil?
105
+ children.append(result)
106
+ end
107
+ end
108
+
109
+ def subtasks_walker(subtasks, issue_key, children)
110
+ subtasks.each do |subtask|
111
+ subtask_key = get_key(subtask)
112
+ node_edge_builder(issue_key, subtask_key, color: 'blue', label: 'subtask')
113
+ node_attr_builder(subtask)
114
+ children.append(subtask_key)
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,55 @@
1
+ require 'jira'
2
+
3
+ module JiraDependencyVisualizer
4
+ # Creates a Jira client and exposes functions to get Jira issues
5
+ class Jira
6
+ # @param [Hash] opts the options to create a Jira client
7
+ # @option opts [String] :context_path The Jira application's
8
+ # context path
9
+ # @option opts [String] :password The Jira user's password
10
+ # @option opts [String] :proxy_address The proxy address
11
+ # @option opts [Integer] :proxy_port The proxy port
12
+ # @option opts [Integer] :read_timeout Number of seconds to wait
13
+ # for data to be read
14
+ # @option opts [String] :rest_base_path The Jira rest API base
15
+ # path
16
+ # @option opts [String] :site URL for Jira
17
+ # @option opts [Boolean] :use_ssl Whether to use SSL
18
+ # @option opts [String] :username The Jira user's username
19
+ def initialize(opts = {})
20
+ @options = opts
21
+ client
22
+ end
23
+
24
+ # @param [String] issue the Jira issue ID
25
+ # @param [JIRA::Client] client a Jira client instance
26
+ # @return [JIRA::Issue] the matched Jira issue object
27
+ def get_issue(issue, client = @client)
28
+ client.Issue.find(issue)
29
+ end
30
+
31
+ # @param [String] query a Jira JQL query string
32
+ # @param [JIRA::Client] client a Jira client instance
33
+ # @return [Array[JIRA::Issue]] list of matching Jira issue objects
34
+ def query(query, client = @client)
35
+ client.Issue.jql(query)
36
+ end
37
+
38
+ # @return [String] the base Jira URL without a trailing slash
39
+ def base_url
40
+ site = @options[:site].dup
41
+ context_path = @options[:context_path].dup
42
+
43
+ site.chop! if site.ends_with?('/')
44
+ context_path.chop! if context_path.ends_with?('/')
45
+
46
+ URI.join(site, context_path).to_s
47
+ end
48
+
49
+ private
50
+
51
+ def client
52
+ @client ||= JIRA::Client.new(@options)
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,4 @@
1
+ module JiraDependencyVisualizer
2
+ # Current version
3
+ VERSION = '0.1.0'.freeze
4
+ end
@@ -0,0 +1,80 @@
1
+ FactoryGirl.define do
2
+ factory :subtask_issue, class: OpenStruct do
3
+ attrs do
4
+ {
5
+ 'key' => 'test3',
6
+ 'fields' => {
7
+ 'issuetype' => {
8
+ 'name' => 'notEpic'
9
+ },
10
+ 'status' => {
11
+ 'name' => 'To Do'
12
+ },
13
+ 'subtasks' => [build(:generic_issue)]
14
+ }
15
+ }
16
+ end
17
+ end
18
+
19
+ factory :generic_issue, class: OpenStruct do
20
+ attrs do
21
+ { 'key' => 'test5',
22
+ 'fields' => {
23
+ 'status' => {
24
+ 'name' => 'Blocked'
25
+ }
26
+ }
27
+ }
28
+ end
29
+ end
30
+
31
+ factory :issuelinks_generic_issue, class: Hash do
32
+ outwardIssue do
33
+ { 'key' => 'test3',
34
+ 'fields' => {
35
+ 'status' => {
36
+ 'name' => 'Done'
37
+ }
38
+ } }
39
+ end
40
+
41
+ type do
42
+ { 'outward' => 'blocks' }
43
+ end
44
+
45
+ initialize_with { attributes.stringify_keys }
46
+ end
47
+
48
+ factory :issuelinks_issue, class: OpenStruct do
49
+ attrs do
50
+ {
51
+ 'key' => 'test4',
52
+ 'fields' => {
53
+ 'issuetype' => {
54
+ 'name' => 'notEpic'
55
+ },
56
+ 'status' => {
57
+ 'name' => 'To Do'
58
+ },
59
+ 'issuelinks' => [build(:issuelinks_generic_issue)]
60
+ }
61
+ }
62
+ end
63
+ end
64
+
65
+ factory :epic_issue, class: OpenStruct do
66
+ attrs do
67
+ {
68
+ 'key' => 'test',
69
+ 'fields' => {
70
+ 'issuetype' => {
71
+ 'name' => 'Epic'
72
+ },
73
+ 'status' => {
74
+ 'name' => 'Planning'
75
+ }
76
+ }
77
+ }
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,4 @@
1
+ FactoryGirl.define do
2
+ factory :node, class: Hash do
3
+ end
4
+ end
@@ -0,0 +1,26 @@
1
+ require 'coveralls'
2
+ require 'simplecov'
3
+ SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new(
4
+ [SimpleCov::Formatter::HTMLFormatter,
5
+ Coveralls::SimpleCov::Formatter]
6
+ )
7
+ SimpleCov.start do
8
+ add_filter 'spec'
9
+ end
10
+
11
+ $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
12
+
13
+ require 'jira_dependency_visualizer'
14
+ require 'pry'
15
+ require 'factory_girl'
16
+
17
+ RSpec.configure do |config|
18
+ config.expect_with :rspec do |c|
19
+ c.syntax = :expect
20
+ end
21
+ config.include FactoryGirl::Syntax::Methods
22
+ config.before(:suite) do
23
+ FactoryGirl.find_definitions
24
+ # FactoryGirl.lint
25
+ end
26
+ end
@@ -0,0 +1,79 @@
1
+ require 'spec_helper'
2
+
3
+ describe JiraDependencyVisualizer::Graph do
4
+ subject { JiraDependencyVisualizer::Graph.new('issue id', jira, [], colors, graph) }
5
+
6
+ let(:epic_issue) { build(:epic_issue) }
7
+ let(:issues) { build_pair(:subtask_issue) }
8
+
9
+ let(:jira) do
10
+ response = double('jira')
11
+ allow(response).to receive(:get_issue).and_return(epic_issue)
12
+ allow(response).to receive(:query).and_return(issues)
13
+ allow(response).to receive(:base_url).and_return('http://localhost')
14
+ response
15
+ end
16
+
17
+ let(:node) { build(:node) }
18
+
19
+ let(:graph) do
20
+ response = double('graph')
21
+ allow(response).to receive(:add_edges).and_return(true)
22
+ allow(response).to receive(:search_node).and_return(node)
23
+ allow(response).to receive(:output).and_return(nil)
24
+ response
25
+ end
26
+
27
+ let(:colors) do
28
+ { 'status' =>
29
+ { 'Incoming' =>
30
+ { 'fillcolor' => 'purple',
31
+ 'style' => 'filled' },
32
+ 'To Do' =>
33
+ { 'fillcolor' => 'purple',
34
+ 'style' => 'filled',
35
+ 'fontcolor' => 'white' }
36
+ }
37
+ }
38
+ end
39
+
40
+ context '#new' do
41
+ it { is_expected.to be_instance_of JiraDependencyVisualizer::Graph }
42
+ end
43
+
44
+ context '#walk subtasks' do
45
+ let(:issues) { build_pair(:subtask_issue) }
46
+
47
+ let(:jira) do
48
+ response = double('jira')
49
+ allow(response).to receive(:get_issue).and_return(epic_issue, issues[0])
50
+ allow(response).to receive(:query).and_return(issues)
51
+ allow(response).to receive(:base_url).and_return('http://localhost')
52
+ response
53
+ end
54
+
55
+ it { expect(subject.walk).to eq(%w(test3 test3)) }
56
+ end
57
+
58
+ context '#walk issuelinks' do
59
+ let(:issues) { build_pair(:issuelinks_issue) }
60
+
61
+ let(:jira) do
62
+ response = double('jira')
63
+ allow(response).to receive(:get_issue).and_return(epic_issue, issues[0])
64
+ allow(response).to receive(:query).and_return(issues)
65
+ allow(response).to receive(:base_url).and_return('http://localhost')
66
+ response
67
+ end
68
+
69
+ it { expect(subject.walk).to eq(%w(test4 test4)) }
70
+ end
71
+
72
+ context '#write_graph' do
73
+ it { is_expected.to respond_to(:write_graph) }
74
+ it 'write a graph' do
75
+ written_graph = subject.write_graph
76
+ expect(written_graph).to eq(nil)
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,7 @@
1
+ require 'spec_helper'
2
+
3
+ describe JiraDependencyVisualizer do
4
+ it 'has a version number' do
5
+ expect(JiraDependencyVisualizer::VERSION).not_to be nil
6
+ end
7
+ end
@@ -0,0 +1,69 @@
1
+ require 'spec_helper'
2
+
3
+ describe JiraDependencyVisualizer::Jira do
4
+ context '#new' do
5
+ it { is_expected.to be_instance_of JiraDependencyVisualizer::Jira }
6
+ end
7
+
8
+ let(:issue_class) do
9
+ class_double('JIRA::Issue').as_stubbed_const
10
+ end
11
+
12
+ let(:issue) do
13
+ response = double('issue')
14
+ allow(response).to receive(:find).and_return(issue_class)
15
+ allow(response).to receive(:jql).and_return(issue_class)
16
+ response
17
+ end
18
+
19
+ let(:client) do
20
+ response = double('client')
21
+ allow(response).to receive(:Issue).and_return(issue)
22
+ response
23
+ end
24
+
25
+ context '#get_issue' do
26
+ it { is_expected.to respond_to(:get_issue) }
27
+ it 'get an issue' do
28
+ issue = subject.get_issue('issue id', client)
29
+ expect(issue).to eq(JIRA::Issue)
30
+ end
31
+ end
32
+
33
+ context '#query' do
34
+ it { is_expected.to respond_to(:query) }
35
+ it 'get a query' do
36
+ query = subject.query('query', client)
37
+ expect(query).to eq(JIRA::Issue)
38
+ end
39
+ end
40
+
41
+ context '#base_url' do
42
+ subject do
43
+ JiraDependencyVisualizer::Jira.new(
44
+ site: site,
45
+ context_path: context_path
46
+ )
47
+ end
48
+ context 'no trailing slash, no context path' do
49
+ let(:site) { 'http://localhost' }
50
+ let(:context_path) { '' }
51
+ it { expect(subject.base_url).to eq('http://localhost') }
52
+ end
53
+ context 'no trailing slash, context path' do
54
+ let(:site) { 'http://localhost' }
55
+ let(:context_path) { 'jira' }
56
+ it { expect(subject.base_url).to eq('http://localhost/jira') }
57
+ end
58
+ context 'trailing slash, no context path' do
59
+ let(:site) { 'http://localhost/' }
60
+ let(:context_path) { '' }
61
+ it { expect(subject.base_url).to eq('http://localhost') }
62
+ end
63
+ context 'trailing slash, context path' do
64
+ let(:site) { 'http://localhost/' }
65
+ let(:context_path) { 'jira' }
66
+ it { expect(subject.base_url).to eq('http://localhost/jira') }
67
+ end
68
+ end
69
+ end