jira-slurper 1.3.1

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.
data/lib/jira.rb ADDED
@@ -0,0 +1,113 @@
1
+ require 'faraday'
2
+ require 'configliere'
3
+ require 'base64'
4
+ require 'json'
5
+
6
+ # Jira handler
7
+ class Jira
8
+
9
+ def initialize(jira_mapper = nil, jira_api = nil)
10
+ @mapper = jira_mapper || JiraStoryMapper.new
11
+ @api = jira_api || JiraApi.new
12
+ end
13
+
14
+ def supports?(config)
15
+ config.fetch('tracker', '').downcase == 'jira'
16
+ end
17
+
18
+ def configure!(config)
19
+ settings = Configliere::Param.new
20
+
21
+ settings.define :username, :required => true, :env_var => 'JIRA_USERNAME'
22
+ settings.define :password, :required => true, :env_var => 'JIRA_PASSWORD'
23
+ settings.define :url, :required => true
24
+ settings.define :project, :required => true
25
+
26
+ settings.defaults api_version: 'latest', default_labels: ''
27
+
28
+ settings.use(config)
29
+ settings.resolve!
30
+
31
+ @config = settings
32
+ end
33
+
34
+ def handle(yaml_story)
35
+ @api.configure!(@config[:url], @config[:username], @config[:password], @config[:api_version])
36
+
37
+ issue = @mapper.map(yaml_story, @config[:project])
38
+
39
+ success, response_body = @api.create_issue(issue)
40
+ response = JSON.parse response_body
41
+
42
+ if success
43
+ message = "Issue key = #{response['key']}, #{@config[:url]}/browse/#{response['key']}"
44
+ else
45
+ message = JSON.pretty_generate response
46
+ end
47
+
48
+ yield success, message
49
+ end
50
+
51
+ end
52
+
53
+ class JiraApi
54
+
55
+ def configure!(url, username, password, api_version = 'latest')
56
+ @url = url
57
+ @username = username
58
+ @password = password
59
+ @api_version = api_version
60
+ end
61
+
62
+ def create_issue(jira_story)
63
+ response = post(url, jira_story.to_json, headers)
64
+ return response.success?, response.body
65
+ end
66
+
67
+ protected
68
+
69
+ def headers
70
+ {'Content-Type' => 'application/json', 'Authorization' => auth_header}
71
+ end
72
+
73
+ def post(issues_url, json_data, headers)
74
+
75
+ conn = Faraday.new do |faraday|
76
+ faraday.headers = headers
77
+ faraday.adapter Faraday.default_adapter
78
+ end
79
+
80
+ conn.post do |req|
81
+ req.url issues_url
82
+ req.body = json_data
83
+ end
84
+
85
+ end
86
+
87
+ def url
88
+ "#{@url}/rest/api/#{@api_version}/issue/"
89
+ end
90
+
91
+ def auth_header
92
+ login = Base64.encode64 @username + ':' + @password
93
+ "Basic #{login}"
94
+ end
95
+
96
+ end
97
+
98
+ class JiraStoryMapper
99
+
100
+ def map(story, project)
101
+ hash = {}
102
+ hash[:project] = {:key => project}
103
+ hash[:summary] = story.name
104
+ hash[:description] = story.description
105
+ hash[:issuetype] = {:name => story.story_type}
106
+
107
+ if story.labels
108
+ hash[:labels] = story.labels.map {|label| label.gsub(' ', '_')}
109
+ end
110
+
111
+ { :fields => hash }
112
+ end
113
+ end
data/lib/pivotal.rb ADDED
@@ -0,0 +1,115 @@
1
+ require 'json'
2
+
3
+ # Pivotal handler
4
+ class Pivotal
5
+
6
+ def initialize(pivotal_api = nil)
7
+ @api = pivotal_api || PivotalApi.new
8
+ end
9
+
10
+ def supports?(config)
11
+ (config['tracker'] == nil && config['project_id'] != nil) || config.fetch('tracker', '').downcase == 'pivotal'
12
+ end
13
+
14
+ def configure!(config)
15
+ settings = Configliere::Param.new
16
+
17
+ settings.define :project_id, :required => true
18
+ settings.define :token, :required => true, :env_var => 'PIVOTAL_TOKEN'
19
+
20
+ settings.use(config)
21
+ settings.resolve!
22
+
23
+ @config = settings
24
+ end
25
+
26
+ def handle(yaml_story)
27
+ @api.configure!(@config.project_id, @config.token)
28
+
29
+ success, response_body = @api.create_story(yaml_story)
30
+ response = JSON.parse response_body
31
+
32
+ if success
33
+ message = "Issue ID = #{response['id']}, #{response['url']}"
34
+ else
35
+ message = JSON.pretty_generate response
36
+ end
37
+
38
+ yield success, message
39
+ end
40
+
41
+ def dump(filter)
42
+ @api.configure!(@config.project_id, @config.token)
43
+ @api.stories(filter).each do |story|
44
+ yield YamlStory.new(
45
+ name: story['name'],
46
+ description: story['description'],
47
+ story_type: story['story_type'],
48
+ labels: story['labels'].map{|l| l['name']}.join(', ')
49
+ )
50
+ end
51
+ end
52
+
53
+ end
54
+
55
+ class PivotalApi
56
+
57
+ def configure!(project, token)
58
+ @token = token
59
+ @project = project
60
+ end
61
+
62
+ def create_story(yaml_story)
63
+ story = {
64
+ story_type: yaml_story.story_type,
65
+ name: yaml_story.name,
66
+ description: yaml_story.description,
67
+ labels: yaml_story.labels.map { |label| {:name => label} },
68
+ #requested_by_id: yaml_story.requested_by
69
+ }
70
+
71
+ response = post(url('stories'), story.to_json, headers)
72
+ return response.success?, response.body
73
+ end
74
+
75
+ def stories(filter)
76
+ response = get(url('stories'), filter, headers)
77
+ unless response.success?
78
+ raise Exception.new "#{response.status} #{response.body}"
79
+ end
80
+
81
+ JSON.parse(response.body.encode('ASCII', :invalid => :replace, :undef => :replace, :replace => '?'))
82
+ end
83
+
84
+ protected
85
+
86
+ def headers
87
+ {'Content-Type' => 'application/json', 'X-TrackerToken' => @token}
88
+ end
89
+
90
+ def get(issues_url, filter, headers)
91
+ conn = Faraday.new(ssl={:verify => false}) do |faraday|
92
+ faraday.headers = headers
93
+ faraday.adapter Faraday.default_adapter
94
+ end
95
+
96
+ conn.get issues_url, {filter: filter, limit: 1000}
97
+ end
98
+
99
+ def post(issues_url, json_data, headers)
100
+ conn = Faraday.new(ssl={:verify => false}) do |faraday|
101
+ faraday.headers = headers
102
+ faraday.adapter Faraday.default_adapter
103
+ end
104
+
105
+ conn.post do |req|
106
+ req.url issues_url
107
+ req.body = json_data
108
+ end
109
+
110
+ end
111
+
112
+ def url(resource)
113
+ "https://www.pivotaltracker.com/services/v5/projects/#{@project}/#{resource}"
114
+ end
115
+ end
data/lib/slurper.rb ADDED
@@ -0,0 +1,103 @@
1
+ require 'yaml'
2
+ require 'story'
3
+
4
+ class Slurper
5
+ attr_accessor :story_file, :stories, :handlers
6
+
7
+ def self.dump(config_file, filter, handlers, reverse)
8
+ config = self.load_config(config_file)
9
+ slurper = new(config)
10
+ slurper.handlers = handlers
11
+ slurper.dump_stories(filter)
12
+ end
13
+
14
+ def self.slurp(story_file, config_file, handlers, reverse)
15
+ config = self.load_config(config_file)
16
+
17
+ stories = self.load_stories(story_file, config)
18
+ stories.reverse! unless reverse
19
+
20
+ slurper = new(config, stories)
21
+ slurper.handlers = handlers
22
+ slurper.create_stories
23
+ end
24
+
25
+ def initialize(config, stories=[])
26
+ @config = config
27
+ @stories = stories
28
+ @handlers = []
29
+ end
30
+
31
+ def self.load_stories(story_file, defaults = {})
32
+ stories = []
33
+ yamlize_story_file(story_file).each do |story|
34
+ begin
35
+ story_hash = YAML.load(story)
36
+ stories << story_hash if story_hash.is_a?(Hash)
37
+ rescue
38
+ puts 'Error encountered when trying to parse the following story'
39
+ puts '-' * 10
40
+ puts story
41
+ puts '-' * 10
42
+ return []
43
+ end
44
+ end
45
+ stories.map { |story_hash| YamlStory.new(story_hash, defaults) }
46
+ end
47
+
48
+ def self.load_config(config_file)
49
+ YAML.load_file(config_file)
50
+ end
51
+
52
+ def dump_stories(filter)
53
+ handler.dump(filter) do |story|
54
+ puts story.to_yaml
55
+ end
56
+ end
57
+
58
+ def create_stories
59
+ puts "Preparing to slurp #{stories.size} stories into #{handler.class.name}..."
60
+
61
+ @stories.each_with_index do |story, index|
62
+ puts "#{index+1}. #{story.name}"
63
+
64
+ begin
65
+ handler.handle(story) do |status, message|
66
+ if status then
67
+ puts "Success: #{message}"
68
+ else
69
+ puts "Failed: #{message}"
70
+ end
71
+ end
72
+
73
+ rescue Exception => ex
74
+ puts "Failed: #{ex.message}"
75
+ end
76
+ end
77
+ end
78
+
79
+ protected
80
+
81
+ def handler
82
+ return @handler if @handler
83
+
84
+ config = @config
85
+ handler = @handlers.detect { |handler| handler.supports? config }
86
+
87
+ unless handler
88
+ error = "No handler found for the given configuration: #{config}"
89
+ raise error
90
+ end
91
+
92
+ handler.configure! config
93
+ @handler = handler
94
+ end
95
+
96
+ def self.yamlize_story_file(story_file)
97
+ IO.read(story_file).
98
+ gsub(/description:$/, 'description: |').
99
+ gsub(/\t/, ' ').
100
+ split(/==.*/)
101
+ end
102
+
103
+ end
data/lib/story.rb ADDED
@@ -0,0 +1,48 @@
1
+ # Simple value object for a story
2
+ # It's only concern is to store and format yaml data from stories
3
+ class YamlStory
4
+
5
+ attr_reader :name, :story_type, :requested_by
6
+
7
+ def initialize(attributes = {}, defaults = {})
8
+ # delete empty values (otherwise the default_proc bellow won't be called)
9
+ attributes.delete_if { |key, val| val == '' }
10
+
11
+ attributes.default_proc = proc do |hash, key|
12
+ defaults[key]
13
+ end
14
+
15
+ @name = attributes['name']
16
+ @description = attributes['description']
17
+ @story_type = attributes['story_type']
18
+ @labels = attributes['labels']
19
+ @requested_by = attributes['requested_by']
20
+ end
21
+
22
+ def labels
23
+ return [] if not @labels
24
+ @labels.split(',').map { |label| label.strip }
25
+ end
26
+
27
+ def description
28
+ return nil if @description == nil || @description == ''
29
+
30
+ @description.gsub(' ', '').gsub(" \n", "\n")
31
+ end
32
+
33
+ def yaml_description
34
+ return '' if @description == nil || @description == ''
35
+ return @description.gsub(/^/, ' ')
36
+ end
37
+
38
+ def to_yaml
39
+ return "==
40
+ story_type: #{story_type}
41
+ name: \"#{name.gsub('"', "'")}\"
42
+ description:
43
+ #{yaml_description}
44
+ labels: #{labels.join(', ')}
45
+ "
46
+ end
47
+
48
+ end
data/spec/jira_spec.rb ADDED
@@ -0,0 +1,137 @@
1
+ require 'rubygems'
2
+ require 'slurper'
3
+ require 'story'
4
+ require 'jira'
5
+
6
+ describe JiraStoryMapper do
7
+ before do
8
+ @story = YamlStory.new('name' => 'Story name', 'story_type' => 'Feature',
9
+ 'description' => 'Story desc', 'labels' => 'a, b')
10
+ @mapper = JiraStoryMapper.new
11
+ end
12
+
13
+ it 'should take the project key from config' do
14
+ result = @mapper.map(@story, 'AB')
15
+ result[:fields][:project][:key].should == 'AB'
16
+ end
17
+
18
+ it 'should set the issuetype field with the story_type' do
19
+ result = @mapper.map(@story, 'AB')
20
+ result[:fields][:issuetype][:name].should == 'Feature'
21
+ end
22
+
23
+ it 'should split and trim labels' do
24
+ result = @mapper.map(@story, 'AB')
25
+ result[:fields][:labels].should == ['a', 'b']
26
+ end
27
+ end
28
+
29
+ describe JiraApi do
30
+ it 'should post with correctly configured the url' do
31
+ good_response = double(success?: true, body: '{"id": 1, "key": "TEST-1", "self":"http://localhost:8090/rest/api/2/issue/1"}')
32
+
33
+ api = JiraApi.new
34
+ api.configure!('http://server', 'user', 'pass', '2')
35
+
36
+ api.should_receive(:post).with('http://server/rest/api/2/issue/', 'json', kind_of(Hash)).and_return good_response
37
+
38
+ api.create_issue(double(to_json: 'json'))
39
+
40
+ end
41
+
42
+ it 'should raise error if the issue was not saved' do
43
+ bad_response = double(success?: false, status: 401)
44
+ issue = double(to_json: 'json')
45
+
46
+ api = JiraApi.new
47
+ api.configure!('http://server', 'user', 'pass', '2')
48
+ api.should_receive(:post).and_return(bad_response)
49
+
50
+ expect { api.create_issue(issue) }.to raise_error Exception
51
+
52
+ end
53
+
54
+ end
55
+
56
+ describe Jira do
57
+
58
+ before do
59
+ @jira = Jira.new
60
+ end
61
+
62
+ context '#supports' do
63
+ it 'should support the jira config if tracker is given' do
64
+ @jira.supports?('tracker' => 'jira').should == true
65
+ end
66
+
67
+ it 'should not support if tracker is missing' do
68
+ @jira.supports?({}).should == false
69
+ end
70
+
71
+ it 'should not support if tracker is anything else' do
72
+ @jira.supports?('tracker' => 'pivotal').should == false
73
+ end
74
+ end
75
+
76
+ context "#configure" do
77
+ it 'should fail if a required field is missing' do
78
+ expect {
79
+ @jira.configure!(:project => 'a project').should be_false
80
+ }.to raise_error 'Missing values for: password, url, username'
81
+ end
82
+
83
+ context 'good config provided' do
84
+ before do
85
+ required = [:username, :password, :project, :url]
86
+ @raw_config = Hash[required.map {|v| [v, 'value']} ]
87
+ end
88
+
89
+ it 'should pass if the required fields are present' do
90
+ config = @jira.configure! @raw_config
91
+ config.username.should == 'value'
92
+ end
93
+
94
+ it 'should return the default api version' do
95
+ config = @jira.configure! @raw_config
96
+ config[:api_version].should == 'latest'
97
+ end
98
+
99
+ it 'should overwrite the default api_version' do
100
+ @raw_config[:api_version] = '2'
101
+ config = @jira.configure! @raw_config
102
+ config[:api_version].should == '2'
103
+ end
104
+ end
105
+ end
106
+
107
+ context '#handle' do
108
+
109
+ before do
110
+ @config = {
111
+ :project => 'AB',
112
+ :username => 'user',
113
+ :password => 'pass',
114
+ :url => 'http://host'
115
+ }
116
+ @story = YamlStory.new(:description => 'description', :name => 'A story', :story_type => 'New Feature', :labels => 'a,b')
117
+ end
118
+
119
+ it 'should create a json issue using the api' do
120
+ issue = double(to_json: 'json')
121
+ mapper = double(map: issue)
122
+
123
+ api = double()
124
+ api.should_receive(:configure!).with('http://host', 'user', 'pass', 'latest')
125
+ api.should_receive(:create_issue).with(issue).and_return([true, '{"id": 1, "key": "TEST-1", "self":"http://localhost:8090/rest/api/2/issue/1"}'])
126
+
127
+ handler = Jira.new(mapper, api)
128
+ handler.configure! @config
129
+ handler.handle @story do |status, response|
130
+ expect(status).to be_true
131
+ expect(response).to include('TEST-1')
132
+ end
133
+ end
134
+
135
+ end
136
+
137
+ end