jiraMule 0.1.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/.gitignore +23 -0
- data/Gemfile +17 -0
- data/LICENSE +22 -0
- data/README.md +37 -0
- data/Rakefile +95 -0
- data/TODO.taskpaper +6 -0
- data/bin/jm +87 -0
- data/jiraMule.gemspec +43 -0
- data/lib/JiraMule/Tempo.rb +75 -0
- data/lib/JiraMule/commands/assign.rb +38 -0
- data/lib/JiraMule/commands/githubImport.rb +46 -0
- data/lib/JiraMule/commands/link.rb +41 -0
- data/lib/JiraMule/commands/timesheet.rb +113 -0
- data/lib/JiraMule/gb.rb +33 -0
- data/lib/JiraMule.rb +7 -0
- data/lib/jiraMule/Config.rb +224 -0
- data/lib/jiraMule/Passwords.rb +81 -0
- data/lib/jiraMule/commands/attach.rb +69 -0
- data/lib/jiraMule/commands/config.rb +68 -0
- data/lib/jiraMule/commands/goto.rb +166 -0
- data/lib/jiraMule/commands/kanban.rb +243 -0
- data/lib/jiraMule/commands/logWork.rb +39 -0
- data/lib/jiraMule/commands/next.rb +89 -0
- data/lib/jiraMule/commands/progress.rb +68 -0
- data/lib/jiraMule/commands/query.rb +80 -0
- data/lib/jiraMule/commands/release.rb +44 -0
- data/lib/jiraMule/commands/testReady.rb +68 -0
- data/lib/jiraMule/commands.rb +20 -0
- data/lib/jiraMule/gitUtils.rb +11 -0
- data/lib/jiraMule/http.rb +147 -0
- data/lib/jiraMule/jiraUtils.rb +288 -0
- data/lib/jiraMule/verbosing.rb +28 -0
- data/lib/jiraMule/version.rb +3 -0
- metadata +268 -0
@@ -0,0 +1,147 @@
|
|
1
|
+
require 'uri'
|
2
|
+
require 'net/http'
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module JiraMule
|
6
|
+
module Http
|
7
|
+
|
8
|
+
def json_opts
|
9
|
+
return @json_opts unless not defined?(@json_opts) or @json_opts.nil?
|
10
|
+
@json_opts = {
|
11
|
+
:allow_nan => true,
|
12
|
+
:symbolize_names => true,
|
13
|
+
:create_additions => false
|
14
|
+
}
|
15
|
+
end
|
16
|
+
|
17
|
+
def curldebug(request)
|
18
|
+
if $cfg['tool.curldebug'] then
|
19
|
+
a = []
|
20
|
+
a << %{curl -s }
|
21
|
+
if request.key?('Authorization') then
|
22
|
+
a << %{-H 'Authorization: #{request['Authorization']}'}
|
23
|
+
end
|
24
|
+
a << %{-H 'User-Agent: #{request['User-Agent']}'}
|
25
|
+
a << %{-H 'Content-Type: #{request.content_type}'}
|
26
|
+
a << %{-X #{request.method}}
|
27
|
+
a << %{'#{request.uri.to_s}'}
|
28
|
+
a << %{-d '#{request.body}'} unless request.body.nil?
|
29
|
+
puts a.join(' ')
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def http
|
34
|
+
uri = URI($cfg['net.url'])
|
35
|
+
if not defined?(@http) or @http.nil? then
|
36
|
+
@http = Net::HTTP.new(uri.host, uri.port)
|
37
|
+
@http.use_ssl = true
|
38
|
+
@http.start
|
39
|
+
end
|
40
|
+
@http
|
41
|
+
end
|
42
|
+
def http_reset
|
43
|
+
@http = nil
|
44
|
+
end
|
45
|
+
|
46
|
+
def set_def_headers(request)
|
47
|
+
request.content_type = 'application/json'
|
48
|
+
request.basic_auth(username(), password())
|
49
|
+
request['User-Agent'] = "JiraMule/#{JiraMule::VERSION}"
|
50
|
+
request
|
51
|
+
end
|
52
|
+
|
53
|
+
def isJSON(data)
|
54
|
+
begin
|
55
|
+
return true, JSON.parse(data, json_opts)
|
56
|
+
rescue
|
57
|
+
return false, data
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def showHttpError(request, response)
|
62
|
+
if $cfg['tool.debug'] then
|
63
|
+
puts "Sent #{request.method} #{request.uri.to_s}"
|
64
|
+
request.each_capitalized{|k,v| puts "> #{k}: #{v}"}
|
65
|
+
if request.body.nil? then
|
66
|
+
else
|
67
|
+
puts " > #{request.body[0..156]}"
|
68
|
+
end
|
69
|
+
puts "Got #{response.code} #{response.message}"
|
70
|
+
response.each_capitalized{|k,v| puts "< #{k}: #{v}"}
|
71
|
+
end
|
72
|
+
isj, jsn = isJSON(response.body)
|
73
|
+
resp = "Request Failed: #{response.code}: "
|
74
|
+
if isj then
|
75
|
+
if $cfg['tool.fullerror'] then
|
76
|
+
resp << JSON.pretty_generate(jsn)
|
77
|
+
else
|
78
|
+
resp << "[#{jsn[:statusCode]}] " if jsn.has_key? :statusCode
|
79
|
+
resp << jsn[:message] if jsn.has_key? :message
|
80
|
+
end
|
81
|
+
else
|
82
|
+
resp << jsn
|
83
|
+
end
|
84
|
+
say_error resp
|
85
|
+
end
|
86
|
+
|
87
|
+
def workit(request, &block)
|
88
|
+
curldebug(request)
|
89
|
+
if block_given? then
|
90
|
+
return yield request, http()
|
91
|
+
else
|
92
|
+
response = http().request(request)
|
93
|
+
case response
|
94
|
+
when Net::HTTPSuccess
|
95
|
+
return {} if response.body.nil?
|
96
|
+
begin
|
97
|
+
return JSON.parse(response.body, json_opts)
|
98
|
+
rescue
|
99
|
+
return response.body
|
100
|
+
end
|
101
|
+
else
|
102
|
+
showHttpError(request, response)
|
103
|
+
raise response
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def get(path='', query=nil, &block)
|
109
|
+
uri = endPoint(path)
|
110
|
+
uri.query = URI.encode_www_form(query) unless query.nil?
|
111
|
+
req = Net::HTTP::Get.new(uri)
|
112
|
+
workit(set_def_headers(req), &block)
|
113
|
+
end
|
114
|
+
|
115
|
+
def post(path='', body={}, &block)
|
116
|
+
uri = endPoint(path)
|
117
|
+
req = Net::HTTP::Post.new(uri)
|
118
|
+
set_def_headers(req)
|
119
|
+
req.body = JSON.generate(body)
|
120
|
+
workit(req, &block)
|
121
|
+
end
|
122
|
+
|
123
|
+
def postf(path='', form={}, &block)
|
124
|
+
uri = endPoint(path)
|
125
|
+
req = Net::HTTP::Post.new(uri)
|
126
|
+
set_def_headers(req)
|
127
|
+
req.content_type = 'application/x-www-form-urlencoded; charset=utf-8'
|
128
|
+
req.form_data = form
|
129
|
+
workit(req, &block)
|
130
|
+
end
|
131
|
+
|
132
|
+
def put(path='', body={}, &block)
|
133
|
+
uri = endPoint(path)
|
134
|
+
req = Net::HTTP::Put.new(uri)
|
135
|
+
set_def_headers(req)
|
136
|
+
req.body = JSON.generate(body)
|
137
|
+
workit(req, &block)
|
138
|
+
end
|
139
|
+
|
140
|
+
def delete(path='', &block)
|
141
|
+
uri = endPoint(path)
|
142
|
+
workit(set_def_headers(Net::HTTP::Delete.new(uri)), &block)
|
143
|
+
end
|
144
|
+
|
145
|
+
end
|
146
|
+
end
|
147
|
+
# vim: set ai et sw=2 ts=2 :
|
@@ -0,0 +1,288 @@
|
|
1
|
+
require 'uri'
|
2
|
+
require 'net/http'
|
3
|
+
require 'net/http/post/multipart'
|
4
|
+
require 'json'
|
5
|
+
require 'date'
|
6
|
+
require 'pp'
|
7
|
+
require 'mime/types'
|
8
|
+
require 'JiraMule/Config'
|
9
|
+
require 'JiraMule/Passwords'
|
10
|
+
require 'JiraMule/http'
|
11
|
+
require 'JiraMule/verbosing'
|
12
|
+
|
13
|
+
module JiraMule
|
14
|
+
class JiraUtilsException < Exception
|
15
|
+
attr_accessor :request, :response
|
16
|
+
end
|
17
|
+
|
18
|
+
class JiraUtils
|
19
|
+
include Verbose
|
20
|
+
include Http
|
21
|
+
|
22
|
+
# TODO: all params are now optional.
|
23
|
+
def initialize(args=nil, options=nil, cfg=nil)
|
24
|
+
acc = Account.new
|
25
|
+
up = acc.loginInfo
|
26
|
+
@username = up[:email]
|
27
|
+
@password = up[:password]
|
28
|
+
end
|
29
|
+
attr_reader :username, :password
|
30
|
+
|
31
|
+
def jiraEndPoint
|
32
|
+
endPoint()
|
33
|
+
end
|
34
|
+
|
35
|
+
def endPoint(path='')
|
36
|
+
URI($cfg['net.url'] + '/rest/api/2/' + path.to_s)
|
37
|
+
end
|
38
|
+
|
39
|
+
def project
|
40
|
+
return @project unless @project.nil?
|
41
|
+
@project = $cfg['jira.project']
|
42
|
+
return @project
|
43
|
+
end
|
44
|
+
|
45
|
+
##
|
46
|
+
# Given an array of issues keys that may or may not have the project prefixed
|
47
|
+
# Return an array with the project prefixed.
|
48
|
+
#
|
49
|
+
# So on project APP, from %w{1 56 BUG-78} you get %w{APP-1 APP-56 BUG-78}
|
50
|
+
def expandKeys(keys)
|
51
|
+
return keys.map do |k|
|
52
|
+
k.match(/([a-zA-Z]+-)?(\d+)/) do |m|
|
53
|
+
if m[1].nil? then
|
54
|
+
"#{project}-#{m[2]}"
|
55
|
+
else
|
56
|
+
m[0]
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end.compact
|
60
|
+
end
|
61
|
+
|
62
|
+
##
|
63
|
+
# Allow for some sloppy matching.
|
64
|
+
# +transition+:: The transition hash to match against
|
65
|
+
# +couldBe+:: The string from a human to match for
|
66
|
+
def fuzzyMatchStatus(transition, couldBe)
|
67
|
+
return transition[:id] == couldBe if couldBe =~ /^\d+$/
|
68
|
+
|
69
|
+
# Build a regexp for all sorts of variations.
|
70
|
+
|
71
|
+
# Replace whitespace with a rex for dashes, whitespace, or nospace.
|
72
|
+
cb = couldBe.gsub(/\s+/, '[-_\s]*')
|
73
|
+
|
74
|
+
matcher = Regexp.new(cb, Regexp::IGNORECASE)
|
75
|
+
debug "Fuzzing: #{transition[:name]} =~ #{cb}"
|
76
|
+
return transition[:name] =~ matcher
|
77
|
+
end
|
78
|
+
|
79
|
+
##
|
80
|
+
# Lookup a path from one state to another in a map
|
81
|
+
# +at+:: The starting state
|
82
|
+
# +to+:: The stopping state
|
83
|
+
def getPath(at, to)
|
84
|
+
verbose "Getting path from '#{at}' to '#{to}'"
|
85
|
+
|
86
|
+
# [goto-map]
|
87
|
+
# at-to: each, step, to, end
|
88
|
+
tr = $cfg["goto-maps.#{at.gsub(/\W+/,'_')}-#{to.gsub(/\W+/,'_')}"]
|
89
|
+
(tr or "").split(/,/).map{|p| p.strip}
|
90
|
+
end
|
91
|
+
|
92
|
+
##
|
93
|
+
# Run a JQL query and get issues with the selected fields
|
94
|
+
def getIssues(query, fields=[ 'key', 'summary' ])
|
95
|
+
verbose "Get keys: #{query}"
|
96
|
+
data = post('search', {:jql=>query, :fields=>fields})
|
97
|
+
data[:issues]
|
98
|
+
end
|
99
|
+
|
100
|
+
##
|
101
|
+
# make sure #user is an actual user in the system.
|
102
|
+
def checkUser(user, keyMatch=true)
|
103
|
+
verbose "Get user: #{user}"
|
104
|
+
users = get("user/search", {:username=>user})
|
105
|
+
return [] if users.empty?
|
106
|
+
userKeys = users.map{|i| i[:key]}
|
107
|
+
return [user] if keyMatch and userKeys.index(user)
|
108
|
+
return userKeys
|
109
|
+
end
|
110
|
+
|
111
|
+
##
|
112
|
+
# Create a new version for release.
|
113
|
+
# TODO: test this.
|
114
|
+
def createVersion(project, version)
|
115
|
+
verbose "Creating #{request.body}"
|
116
|
+
unless $cfg['tool.dry'] then
|
117
|
+
data = post('version', {
|
118
|
+
'name' => version,
|
119
|
+
'archived' => false,
|
120
|
+
'released' => true,
|
121
|
+
'releaseDate' => DateTime.now.strftime('%Y-%m-%d'),
|
122
|
+
'project' => project,
|
123
|
+
})
|
124
|
+
unless data[:released] then
|
125
|
+
# Sometimes setting released on create doesn't work.
|
126
|
+
# So modify it.
|
127
|
+
put("version/#{data[:id]}", {:released=>true})
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
##
|
133
|
+
# Create a new issue
|
134
|
+
# +type+:: The type of issue this is
|
135
|
+
# +summary+:: Short title text
|
136
|
+
# +description+:: Full details.
|
137
|
+
def createIssue(type, summary, description)
|
138
|
+
verbose "Creating #{type} issue for #{summary}"
|
139
|
+
unless $cfg['tool.dry'] then
|
140
|
+
post('issue', {
|
141
|
+
:fields=>{
|
142
|
+
:issuetype=>{:name=>type},
|
143
|
+
:project=>{:key=>project},
|
144
|
+
:summary=>summary,
|
145
|
+
:description=>description,
|
146
|
+
:labels=>['auto-imported'],
|
147
|
+
}
|
148
|
+
})
|
149
|
+
else
|
150
|
+
{:key=>'_'}
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
##
|
155
|
+
# Check this issue type in current project
|
156
|
+
def checkIssueType(type='bug')
|
157
|
+
rt = Regexp.new(type.gsub(/\s+/, '[-_\s]*'), Regexp::IGNORECASE)
|
158
|
+
cmeta = get('issue/createmeta')
|
159
|
+
prj = cmeta[:projects].select{|p| p[:key] == project}.first
|
160
|
+
prj[:issuetypes].select{|it| it[:name] =~ rt}
|
161
|
+
end
|
162
|
+
|
163
|
+
# Update fields on a key
|
164
|
+
# +keys+:: Array of keys to update
|
165
|
+
# +update+:: Hash of fields to update. (see https://docs.atlassian.com/jira/REST/6.4.7/#d2e261)
|
166
|
+
def updateKeys(keys, update)
|
167
|
+
keys = [keys] unless keys.kind_of? Array
|
168
|
+
keys.each do |key|
|
169
|
+
verbose "Updating key #{key} with #{update}"
|
170
|
+
put("issue/#{key}", {:update=>update}) unless $cfg['tool.dry']
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
# Transition key into a new status
|
175
|
+
# +key+:: The key to transition
|
176
|
+
# +toID+:: The ID of the transition to make
|
177
|
+
def transition(key, toID)
|
178
|
+
verbose "Transitioning key #{key} to #{toID}"
|
179
|
+
post('issue/' + key + '/transitions', {:transition=>{:id=>toID}}) unless $cfg['tool.dry']
|
180
|
+
end
|
181
|
+
|
182
|
+
# Get the transitions that a key can move to.
|
183
|
+
# +key+:: The issue
|
184
|
+
def transitionsFor(key)
|
185
|
+
verbose "Fetching transitions for #{key}"
|
186
|
+
data = get('issue/' + key + '/transitions')
|
187
|
+
data[:transitions]
|
188
|
+
end
|
189
|
+
|
190
|
+
# Get the status for a project
|
191
|
+
# +project+:: The project to fetch status from
|
192
|
+
def statusesFor(project)
|
193
|
+
verbose "Fetching statuses for #{project}"
|
194
|
+
get('project/' + project + '/statuses')
|
195
|
+
end
|
196
|
+
|
197
|
+
# Assign issues to a user
|
198
|
+
# +keys+:: Array of keys to assign
|
199
|
+
# +to+:: The user to assign to (or -1 for default)
|
200
|
+
def assignTo(keys, to="-1")
|
201
|
+
keys = [keys] unless keys.kind_of? Array
|
202
|
+
r=[]
|
203
|
+
keys.each do |key|
|
204
|
+
verbose "Assigning #{key} to #{to}"
|
205
|
+
r << put("issue/#{key}/assignee", {:name=>to}) unless $cfg['tool.dry']
|
206
|
+
end
|
207
|
+
r
|
208
|
+
end
|
209
|
+
|
210
|
+
# Log a work entry to Jira
|
211
|
+
# +key+:: The issue to log work on
|
212
|
+
# +timespend+:: The time spent in seconds
|
213
|
+
# +notes+:: Any notes to add.
|
214
|
+
# +on+:: When this work happened. (default is now)
|
215
|
+
def logWork(key, timespent, notes="", on=nil)
|
216
|
+
body = {
|
217
|
+
:comment => notes,
|
218
|
+
:timeSpentSeconds => timespent,
|
219
|
+
}
|
220
|
+
body[:started] = on.to_time.strftime('%FT%T.%3N%z') unless on.nil?
|
221
|
+
|
222
|
+
verbose "Logging #{timespent} of work to #{key} with note \"#{notes}\""
|
223
|
+
post('issue/' + key + '/worklog', body) unless $cfg['tool.dry']
|
224
|
+
end
|
225
|
+
|
226
|
+
# Get the work log for an Issue
|
227
|
+
# +key+:: The issue to retrive the work log for
|
228
|
+
def workLogs(key)
|
229
|
+
verbose "Fetching work logs for #{key}"
|
230
|
+
get('issue/' + key + '/worklog')
|
231
|
+
end
|
232
|
+
|
233
|
+
## Get links on an issue
|
234
|
+
# +key+:: The issue to retrive the remote links for
|
235
|
+
def remote_links(key)
|
236
|
+
get("issue/#{key}/remotelink")
|
237
|
+
end
|
238
|
+
|
239
|
+
## Attach a URL to an issue
|
240
|
+
# +key+:: The issue to set a remote link on
|
241
|
+
# +url+:: The URL to link to
|
242
|
+
# +title+:: The title of the link
|
243
|
+
def linkTo(key, url, title)
|
244
|
+
verbose "Attaching [#{title}](#{url}) to #{key}"
|
245
|
+
unless $cfg['tool.dry'] then
|
246
|
+
post("issue/#{key}/remotelink", {
|
247
|
+
:object=>{
|
248
|
+
:url=>url,
|
249
|
+
:title=>title,
|
250
|
+
}
|
251
|
+
})
|
252
|
+
# XXX Lookup favicon for site and include?
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
def linkKeys(key, toKey, relation='related to')
|
257
|
+
end
|
258
|
+
|
259
|
+
# Attach a file to an issue.
|
260
|
+
# +key+:: The issue to attach to
|
261
|
+
# +file+:: Full path to the file to be attached
|
262
|
+
# +type+:: MIME type of the fiel data
|
263
|
+
# +name+:: Aternate name of file being uploaded
|
264
|
+
def attach(key, file, type=nil, name=nil)
|
265
|
+
file = Pathname.new(file) unless file.kind_of? Pathname
|
266
|
+
|
267
|
+
name = file.basename if name.nil?
|
268
|
+
|
269
|
+
if type.nil? then
|
270
|
+
mime = MIME::Types.type_for(file.to_s)[0] || MIME::Types["application/octet-stream"][0]
|
271
|
+
type = mime.simplified
|
272
|
+
end
|
273
|
+
|
274
|
+
verbose "Going to upload #{file} [#{type}] to #{key}"
|
275
|
+
|
276
|
+
uri = endPoint('issue/' + key + '/attachments')
|
277
|
+
fuio = UploadIO.new(file.open, type, name)
|
278
|
+
req = Net::HTTP::Post::Multipart.new(uri, 'file'=> fuio )
|
279
|
+
req.basic_auth(username(), password())
|
280
|
+
req['User-Agent'] = "JiraMule/#{JiraMule::VERSION}"
|
281
|
+
#set_def_headers(req)
|
282
|
+
req['X-Atlassian-Token'] = 'nocheck'
|
283
|
+
workit(req) unless $cfg['tool.dry']
|
284
|
+
end
|
285
|
+
|
286
|
+
end
|
287
|
+
end
|
288
|
+
# vim: set sw=4 ts=4 :
|
@@ -0,0 +1,28 @@
|
|
1
|
+
require 'pp'
|
2
|
+
|
3
|
+
module JiraMule
|
4
|
+
module Verbose
|
5
|
+
|
6
|
+
def verbose(msg)
|
7
|
+
if $cfg['tool.verbose'] then
|
8
|
+
say "\033[1m=#\033[0m #{msg}"
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def debug(msg)
|
13
|
+
if $cfg['tool.debug'] then
|
14
|
+
say "\033[1m=#\033[0m #{msg}"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def printVars(map)
|
19
|
+
$stdout.print("\033[1m=:\033[0m ")
|
20
|
+
map.each {|k,v|
|
21
|
+
$stdout.print("\033[1m#{k}:\033[0m #{v} ")
|
22
|
+
}
|
23
|
+
$stdout.print("\n")
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
end
|
28
|
+
# vim: set ai et sw=2 ts=2 :
|