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.
- checksums.yaml +7 -0
- data/README.rdoc +126 -0
- data/bin/slurp +53 -0
- data/lib/cacert.pem +3509 -0
- data/lib/jira.rb +113 -0
- data/lib/pivotal.rb +115 -0
- data/lib/slurper.rb +103 -0
- data/lib/story.rb +48 -0
- data/spec/jira_spec.rb +137 -0
- data/spec/pivotal_spec.rb +57 -0
- data/spec/slurper_spec.rb +135 -0
- data/spec/story_spec.rb +82 -0
- metadata +123 -0
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
|