MDK Tracker commenting
[mdk.git] / mdk / jira.py
1 #!/usr/bin/env python
2 # -*- coding: utf-8 -*-
3
4 """
5 Moodle Development Kit
6
7 Copyright (c) 2012 Frédéric Massart - FMCorz.net
8
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.
13
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.
18
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/>.
21
22 http://github.com/FMCorz/mdk
23 """
24
25 import json
26 from .tools import question
27 from .config import Conf
28 from urlparse import urlparse
29 from datetime import datetime
30 import re
31 import logging
32 import os
33 import requests
34 import mimetypes
35 try:
36 import keyring
37 except:
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')
41 pass
42
43 C = Conf()
44
45
46 class Jira(object):
47
48 username = ''
49 password = ''
50 apiversion = '2'
51 version = None
52 url = None
53
54 host = ''
55 ssl = False
56 uri = ''
57
58 _loaded = False
59 _instance = None
60
61 def __new__(cls, *args, **kwargs):
62 if not cls._instance:
63 cls._instance = super(Jira, cls).__new__(cls, *args, **kwargs)
64 return cls._instance
65
66 def __init__(self):
67 self.version = {}
68 self._load()
69
70
71 def addComment(self, key, comment):
72 """Add a comment to an issue"""
73
74 assert isinstance(comment, str), "Expected a string"
75
76 data = [
77 {'add': {'body': comment}}
78 ]
79
80 self.updateIssue(key, {'update': {'comment': data}})
81
82 return True
83
84 def addLabels(self, key, labels):
85 """Add labels to an issue"""
86
87 assert isinstance(labels, list), "Expected a list of labels"
88
89 data = []
90 for label in labels:
91 data.append({'add': label})
92
93 self.updateIssue(key, {'update': {'labels': data}})
94
95 return True
96
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')
102 return True
103
104 def download(self, url, dest):
105 """Download a URL to the destination while authenticating the user"""
106
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))
110
111 data = r.text
112 if len(data) > 0:
113 f = open(dest, 'w')
114 f.write(data)
115 f.close()
116
117 return os.path.isfile(dest)
118
119 def get(self, param, default=None):
120 """Returns a property of this instance"""
121 return self.info().get(param, default)
122
123 def getAttachments(self, key):
124 """Get a dict of attachments
125
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
134 """
135 issueInfo = self.getIssue(key, fields='attachment')
136 results = issueInfo.get('fields').get('attachment', [])
137 attachments = {}
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'),
147 }
148 return attachments
149
150 def getIssue(self, key, fields='*all,-comment'):
151 """Load the issue info from the jira server using a rest api call.
152
153 The returned key 'named' of the returned dict is organised by name of the fields, not id.
154 """
155
156 querystring = {'fields': fields, 'expand': 'names'}
157 resp = self.request('issue/%s' % (str(key)), params=querystring)
158
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.')
163
164 issue = resp['data']
165 issue['named'] = {}
166
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
172
173 return issue
174
175 def getPullInfo(self, key):
176 """Get the pull information organised by branch"""
177
178 fields = self.getIssue(key).get('named')
179 infos = {
180 'repo': None,
181 'branches': {}
182 }
183
184 for key, value in C.get('tracker.fieldnames').iteritems():
185 if key == 'repositoryurl':
186 infos['repo'] = fields.get(value)
187
188 elif key == 'master' or key.isdigit():
189 infos['branches'][key] = {
190 'branch': fields.get(value['branch']),
191 'compare': fields.get(value['diffurl'])
192 }
193 else:
194 # We don't know that field...
195 continue
196
197 return infos
198
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'])))
204
205 self.version = resp['data']
206
207 def info(self):
208 """Returns a dictionary of information about this instance"""
209 info = {}
210 self._load()
211 for (k, v) in self.version.items():
212 info[k] = v
213 return info
214
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
219
220 def _load(self):
221 """Loads the information"""
222
223 if self._loaded:
224 return True
225
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')
229
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
234
235 try:
236 # str() is needed because keyring does not handle unicode.
237 self.password = keyring.get_password('mdk-jira-password', str(self.username))
238 except:
239 # Do not die if keyring package is not available.
240 self.password = None
241
242 if not self.url:
243 raise JiraException('The tracker host has not been configured in the config file.')
244
245 askUsername = True if not self.username else False
246 while not self._loaded:
247
248 # Testing basic auth
249 if self.password:
250 try:
251 self.getServerInfo()
252 self._loaded = True
253 except JiraException:
254 askUsername = True
255 print 'Either the username and password don\'t match or you may need to enter a Captcha to continue.'
256 if not self._loaded:
257 if askUsername:
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)
260
261 # Save the username to the config file
262 if self.username != C.get('tracker.username'):
263 C.set('tracker.username', self.username)
264
265 try:
266 keyring.set_password('mdk-jira-password', str(self.username), str(self.password))
267 except:
268 # Do not die if keyring package is not available.
269 pass
270
271 return True
272
273 def reload(self):
274 """Reloads the information"""
275 self._loaded = False
276 return self._load()
277
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"""
280
281 url = self.url + self.uri + '/rest/api/' + str(self.apiversion) + '/' + uri.strip('/')
282
283 # Define method to method to use.
284 method = method.upper()
285 if method == 'GET':
286 call = requests.get
287 elif method == 'POST':
288 call = requests.post
289 elif method == 'PUT':
290 call = requests.put
291 elif method == 'DELETE':
292 call = requests.delete
293 else:
294 raise JiraException('Unimplemented method')
295
296 # Headers.
297 if not files:
298 headers['Content-Type'] = 'application/json'
299
300 # Call.
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))
305
306 try:
307 data = r.json()
308 except:
309 data = r.text
310
311 return {'status': r.status_code, 'data': data}
312
313 @staticmethod
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')
319
320 def removeLabels(self, key, labels):
321 """Remove labels from an issue"""
322
323 assert isinstance(labels, list), "Expected a list of labels"
324
325 data = []
326 for label in labels:
327 data.append({'remove': label})
328
329 self.updateIssue(key, {'update': {'labels': data}})
330
331 def search(self, query):
332 return self.request('search', params={'jql': query, 'fields': 'id'})
333
334 def setCustomFields(self, key, updates):
335 """Set a list of fields for this issue in Jira
336
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.
339
340 /!\ This only works for fields of type text.
341 """
342
343 issue = self.getIssue(key)
344 update = {'fields': {}}
345
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.
350 fieldKey = None
351 for k, v in issue.get('names').iteritems():
352 if v == updatename:
353 fieldKey = k
354 break
355 if not fieldKey:
356 raise JiraException('Could not find the field named \'%s\'' % (updatename))
357 update['fields'][fieldKey] = updatevalue
358
359 if not update['fields']:
360 # No fields to update.
361 logging.info('No updates required')
362 return True
363
364 resp = self.request('issue/%s' % (str(key)), method='PUT', data=json.dumps(update))
365
366 if resp['status'] != 204:
367 raise JiraException('Issue was not updated: %s' % (str(resp['status'])))
368
369 return True
370
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))
374
375 if resp['status'] != 204:
376 raise JiraException('Could not update the issue')
377
378 return True
379
380 def upload(self, key, filepath):
381 """Uploads a new attachment to the issue"""
382
383 uri = 'issue/' + key + '/attachments'
384
385 mimetype = mimetypes.guess_type(filepath)[0]
386 if not mimetype:
387 mimetype = 'application/octet-stream'
388
389 files = {
390 'file': (os.path.basename(filepath), open(filepath, 'rb'), mimetype)
391 }
392
393 headers = {
394 'X-Atlassian-Token': 'nocheck'
395 }
396
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')
400
401 return True
402
403
404 class JiraException(Exception):
405 pass
406
407
408 class JiraIssueNotFoundException(JiraException):
409 pass