2 # -*- coding: utf-8 -*-
7 Copyright (c) 2012 Frédéric Massart - FMCorz.net
9 This program is free software: you can redistribute it and/or modify
10 it under the terms of the GNU General Public License as published by
11 the Free Software Foundation, either version 3 of the License, or
12 (at your option) any later version.
14 This program is distributed in the hope that it will be useful,
15 but WITHOUT ANY WARRANTY; without even the implied warranty of
16 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 GNU General Public License for more details.
19 You should have received a copy of the GNU General Public License
20 along with this program. If not, see <http://www.gnu.org/licenses/>.
22 http://github.com/FMCorz/mdk
26 from .tools
import question
27 from .config
import Conf
28 from urlparse
import urlparse
29 from datetime
import datetime
38 # TODO Find a better way of suggesting this
39 # debug('Could not load module keyring. You might want to install it.')
40 # debug('Try `apt-get install python-keyring`, or visit http://pypi.python.org/pypi/keyring')
61 def __new__(cls
, *args
, **kwargs
):
63 cls
._instance
= super(Jira
, cls
).__new__(cls
, *args
, **kwargs
)
71 def addComment(self
, key
, comment
):
72 """Add a comment to an issue"""
74 assert isinstance(comment
, str), "Expected a string"
77 {'add': {'body': comment
}}
80 self
.updateIssue(key
, {'update': {'comment': data
}})
84 def addLabels(self
, key
, labels
):
85 """Add labels to an issue"""
87 assert isinstance(labels
, list), "Expected a list of labels"
91 data
.append({'add': label
})
93 self
.updateIssue(key
, {'update': {'labels': data
}})
97 def deleteAttachment(self
, attachmentId
):
98 """Deletes an attachment"""
99 resp
= self
.request('attachment/%s' % str
(attachmentId
), method
='DELETE')
100 if resp
['status'] != 204:
101 raise JiraException('Could not delete the attachment')
104 def download(self
, url
, dest
):
105 """Download a URL to the destination while authenticating the user"""
107 r
= requests
.get(url
, auth
=requests
.auth
.HTTPBasicAuth(self
.username
, self
.password
))
108 if r
.status_code
== 403:
109 raise JiraException('403 Request not authorized. %s %s' %
('GET', url
))
117 return os
.path
.isfile(dest
)
119 def get(self
, param
, default
=None):
120 """Returns a property of this instance"""
121 return self
.info().get(param
, default
)
123 def getAttachments(self
, key
):
124 """Get a dict of attachments
126 The keys are the filenames, the values are another dict containing:
127 - id: The file ID on the Tracker
128 - filename: The file name
129 - URL: The URL to download the file
130 - date: A datetime object representing the date at which the file was created
131 - mimetype: The mimetype of the file
132 - size: The size of the file in bytes
133 - author: The username of the author of the file
135 issueInfo
= self
.getIssue(key
, fields
='attachment')
136 results
= issueInfo
.get('fields').get('attachment', [])
138 for attachment
in results
:
139 attachments
[attachment
.get('filename')] = {
140 'id': attachment
.get('id'),
141 'filename': attachment
.get('filename'),
142 'url': attachment
.get('content'),
143 'date': Jira
.parseDate(attachment
.get('created')),
144 'mimetype': attachment
.get('mimeType'),
145 'size': attachment
.get('size'),
146 'author': attachment
.get('author', {}).get('name'),
150 def getIssue(self
, key
, fields
='*all,-comment'):
151 """Load the issue info from the jira server using a rest api call.
153 The returned key 'named' of the returned dict is organised by name of the fields, not id.
156 querystring
= {'fields': fields
, 'expand': 'names'}
157 resp
= self
.request('issue/%s' %
(str(key
)), params
=querystring
)
159 if resp
['status'] == 404:
160 raise JiraIssueNotFoundException('Issue could not be found.')
161 elif not resp
['status'] == 200:
162 raise JiraException('The tracker is not available.')
167 # Populate the named fields in a separate key. Allows us to easily find them without knowing the field ID.
168 namelist
= issue
.get('names', {})
169 for fieldkey
, fieldvalue
in issue
.get('fields', {}).items():
170 if namelist
.get(fieldkey
, None) != None:
171 issue
['named'][namelist
.get(fieldkey
)] = fieldvalue
175 def getPullInfo(self
, key
):
176 """Get the pull information organised by branch"""
178 fields
= self
.getIssue(key
).get('named')
184 for key
, value
in C
.get('tracker.fieldnames').iteritems():
185 if key
== 'repositoryurl':
186 infos
['repo'] = fields
.get(value
)
188 elif key
== 'master' or key
.isdigit():
189 infos
['branches'][key
] = {
190 'branch': fields
.get(value
['branch']),
191 'compare': fields
.get(value
['diffurl'])
194 # We don't know that field...
199 def getServerInfo(self
):
200 """Load the version info from the jira server using a rest api call"""
201 resp
= self
.request('serverInfo')
202 if resp
['status'] != 200:
203 raise JiraException('Unexpected response code: %s' %
(str(resp
['status'])))
205 self
.version
= resp
['data']
208 """Returns a dictionary of information about this instance"""
211 for (k
, v
) in self
.version
.items():
215 def isSecurityIssue(self
, key
):
216 """Return whether or not the issue could be a security issue"""
217 resp
= self
.getIssue(key
, fields
='security')
218 return True if resp
.get('fields', {}).get('security', None) != None else False
221 """Loads the information"""
226 # First get the jira details from the config file.
227 self
.url
= C
.get('tracker.url').rstrip('/')
228 self
.username
= C
.get('tracker.username')
230 parsed
= urlparse(self
.url
)
231 self
.ssl
= True if parsed
.scheme
== 'https' else False
232 self
.host
= parsed
.netloc
233 self
.uri
= parsed
.path
236 # str() is needed because keyring does not handle unicode.
237 self
.password
= keyring
.get_password('mdk-jira-password', str(self
.username
))
239 # Do not die if keyring package is not available.
243 raise JiraException('The tracker host has not been configured in the config file.')
245 askUsername
= True if not self
.username
else False
246 while not self
._loaded
:
253 except JiraException
:
255 print 'Either the username and password don\'t match or you may need to enter a Captcha to continue.'
258 self
.username
= question('What is the username to use to connect to Moodle Tracker?', default
=self
.username
if self
.username
else None)
259 self
.password
= question('Enter the password for username \'%s\' on Moodle Tracker?' % self
.username
, password
=True)
261 # Save the username to the config file
262 if self
.username
!= C
.get('tracker.username'):
263 C
.set('tracker.username', self
.username
)
266 keyring
.set_password('mdk-jira-password', str(self
.username
), str(self
.password
))
268 # Do not die if keyring package is not available.
274 """Reloads the information"""
278 def request(self
, uri
, method
='GET', data
='', params
={}, headers
={}, files
=None):
279 """Sends a request to the server and returns the response status and data"""
281 url
= self
.url
+ self
.uri
+ '/rest/api/' + str(self
.apiversion
) + '/' + uri
.strip('/')
283 # Define method to method to use.
284 method
= method
.upper()
287 elif method
== 'POST':
289 elif method
== 'PUT':
291 elif method
== 'DELETE':
292 call
= requests
.delete
294 raise JiraException('Unimplemented method')
298 headers
['Content-Type'] = 'application/json'
301 r
= call(url
, params
=params
, data
=data
, auth
=requests
.auth
.HTTPBasicAuth(self
.username
, self
.password
),
302 headers
=headers
, files
=files
)
303 if r
.status_code
== 403:
304 raise JiraException('403 Request not authorized. %s %s' %
(method
, uri
))
311 return {'status': r
.status_code
, 'data': data
}
314 def parseDate(value
):
315 """Parse a date returned by Jira API and returns a datetime object."""
316 # Strips the timezone information because of some issues with %z.
317 value
= re
.sub(r
'[+-]\d+$', '', value
)
318 return datetime
.strptime(value
, '%Y-%m-%dT%H:%M:%S.%f')
320 def removeLabels(self
, key
, labels
):
321 """Remove labels from an issue"""
323 assert isinstance(labels
, list), "Expected a list of labels"
327 data
.append({'remove': label
})
329 self
.updateIssue(key
, {'update': {'labels': data
}})
331 def search(self
, query
):
332 return self
.request('search', params
={'jql': query
, 'fields': 'id'})
334 def setCustomFields(self
, key
, updates
):
335 """Set a list of fields for this issue in Jira
337 The updates parameter is a dictionary of key values where the key is the custom field name
338 and the value is the new value to set.
340 /!\ This only works for fields of type text.
343 issue
= self
.getIssue(key
)
344 update
= {'fields': {}}
346 for updatename
, updatevalue
in updates
.items():
347 remotevalue
= issue
.get('named').get(updatename
)
348 if not remotevalue
or remotevalue
!= updatevalue
:
349 # Map the label of the field with the field code.
351 for k
, v
in issue
.get('names').iteritems():
356 raise JiraException('Could not find the field named \'%s\'' %
(updatename
))
357 update
['fields'][fieldKey
] = updatevalue
359 if not update
['fields']:
360 # No fields to update.
361 logging
.info('No updates required')
364 resp
= self
.request('issue/%s' %
(str(key
)), method
='PUT', data
=json
.dumps(update
))
366 if resp
['status'] != 204:
367 raise JiraException('Issue was not updated: %s' %
(str(resp
['status'])))
371 def updateIssue(self
, key
, data
):
372 """Update an issue, the data must be well formatted"""
373 resp
= self
.request('issue/%s' %
(str(key
)), method
='PUT', data
=json
.dumps(data
))
375 if resp
['status'] != 204:
376 raise JiraException('Could not update the issue')
380 def upload(self
, key
, filepath
):
381 """Uploads a new attachment to the issue"""
383 uri
= 'issue/' + key
+ '/attachments'
385 mimetype
= mimetypes
.guess_type(filepath
)[0]
387 mimetype
= 'application/octet-stream'
390 'file': (os
.path
.basename(filepath
), open(filepath
, 'rb'), mimetype
)
394 'X-Atlassian-Token': 'nocheck'
397 resp
= self
.request(uri
, method
='POST', files
=files
, headers
=headers
)
398 if resp
.get('status') != 200:
399 raise JiraException('Could not upload file to Jira issue')
404 class JiraException(Exception):
408 class JiraIssueNotFoundException(JiraException
):