Coverage for CIResults/bugtrackers.py: 100%
617 statements
« prev ^ index » next coverage.py v7.6.9, created at 2024-12-19 09:20 +0000
« prev ^ index » next coverage.py v7.6.9, created at 2024-12-19 09:20 +0000
1from django.utils.functional import cached_property
2from django.db import IntegrityError
3from django.utils import timezone
5from dateutil import parser as dateparser
6from jira import JIRA
7from jira.exceptions import JIRAError
8import xmlrpc.client
9import traceback
10import requests
11import pytz
13from .models import Person, BugComment, BugTrackerAccount, Bug, ReplicationScript
14from .sandbox.io import Client
15from .serializers import serialize_bug
18class BugTrackerCommon:
19 @property
20 def has_components(self):
21 return True
23 def __init__(self, db_bugtracker):
24 self.db_bugtracker = db_bugtracker
26 @classmethod
27 def _list_to_str(cls, bl):
28 if type(bl) is list:
29 return ",".join(bl)
30 else:
31 return bl
33 @staticmethod
34 def join(a, b):
35 return str(a).rstrip("/") + "/" + str(b).lstrip("/")
37 def _parse_custom_field(self, field_val, to_str=True):
38 if to_str:
39 return str(field_val)
40 else:
41 return field_val
43 @cached_property
44 def accounts_cached(self):
45 accounts = dict()
46 for account in BugTrackerAccount.objects.filter(tracker=self.db_bugtracker):
47 accounts[account.user_id] = account
48 return accounts
50 def find_or_create_account(self, user_id, full_name=None, email=None):
51 uid = str(user_id)
52 account = self.accounts_cached.get(uid)
53 if account is None:
54 person = Person.objects.create(full_name=full_name, email=email)
55 account = BugTrackerAccount.objects.create(tracker=self.db_bugtracker, person=person,
56 user_id=uid, is_developer=False)
57 self.accounts_cached[account.user_id] = account
58 else:
59 # We found a match. Update the full name and email address, if it changed
60 modified = False
61 if full_name is not None and account.person.full_name != full_name:
62 account.person.full_name = full_name
63 modified = True
64 if email is not None and account.person.email != email:
65 account.person.email = email
66 modified = True
67 if modified:
68 account.person.save()
69 return account
71 def _replication_add_comments(self, bug, comments):
72 if not comments:
73 return
75 if isinstance(comments, str):
76 self.add_comment(bug, comments)
77 return
79 for comment in comments:
80 self.add_comment(bug, comment)
82 def _replication_create_bug(self, json_resp, bug, dest_tracker):
83 json_bug = json_resp['set_fields']
84 dest_upd_fields = json_resp.pop('db_dest_fields_update', None)
85 if not dest_upd_fields:
86 dest_upd_fields = json_resp.pop('db_fields_update', None)
87 else:
88 json_resp.pop('db_fields_update', None) # NOTE: pop just in case both are set, for whatever reason
89 src_upd_fields = json_resp.pop('db_src_fields_update', None)
91 try:
92 id = dest_tracker.tracker.create_bug_from_json(json_bug)
93 except ValueError:
94 traceback.print_exc()
95 else:
96 new_bug = Bug(bug_id=id, parent=bug, tracker=dest_tracker)
97 dest_tracker.tracker._replication_add_comments(new_bug, json_resp.get('add_comments'))
98 dest_tracker.tracker.poll(new_bug)
99 bug.update_from_dict(src_upd_fields)
100 new_bug.update_from_dict(dest_upd_fields)
101 try:
102 new_bug.save()
103 except IntegrityError:
104 return
106 def _replication_update_bug(self, json_resp, bug, upd_bug, dest_tracker):
107 json_bug = json_resp['set_fields']
108 dest_upd_fields = json_resp.pop('db_dest_fields_update', None)
109 if not dest_upd_fields:
110 dest_upd_fields = json_resp.pop('db_fields_update', None)
111 else:
112 json_resp.pop('db_fields_update', None) # NOTE: pop just in case both are set, for whatever reason
113 src_upd_fields = json_resp.pop('db_src_fields_update', None)
115 try:
116 if json_bug: # Don't update empty fields if we are just setting comments
117 dest_tracker.tracker.update_bug_from_json(json_bug, upd_bug.bug_id)
118 except ValueError:
119 traceback.print_exc()
120 else:
121 dest_tracker.tracker._replication_add_comments(upd_bug, json_resp.get('add_comments'))
122 bug.update_from_dict(src_upd_fields)
123 upd_bug.update_from_dict(dest_upd_fields)
124 upd_bug.save()
126 def tracker_check_replication(self, bugs, dest_tracker, script, client, dryrun=False, new_comments=None):
127 # NOTE: This check is duplicated in 'check_replication', but is needed when
128 # calling this method directly. I don't think the overhead is significant
129 # enough to be an issue
130 responses = []
132 for bug in bugs:
133 if not bug.id or bug.parent:
134 continue
136 ser_bug = serialize_bug(bug, new_comments)
137 try:
138 # HACK: this is an optimization for script validation view
139 upd_bug = bug.children_bugs[0] if bug.children_bugs else None
140 except AttributeError:
141 upd_bug = Bug.objects.filter(parent=bug, tracker=dest_tracker).first()
143 ser_upd_bug = serialize_bug(upd_bug) if upd_bug else None
144 try:
145 json_resp = client.call_user_function("replication_check",
146 kwargs={"src_bug": ser_bug, "dest_bug": ser_upd_bug})
147 except Exception as e: # noqa
148 print(e)
149 continue
151 if not json_resp:
152 continue
154 if dryrun:
155 json_resp["operation"] = "update" if upd_bug else "create"
156 json_resp["src_bug"] = ser_bug
157 json_resp["dest_bug"] = ser_upd_bug
158 responses.append(json_resp)
159 else:
160 if upd_bug:
161 self._replication_update_bug(json_resp, bug, upd_bug, dest_tracker)
162 else:
163 self._replication_create_bug(json_resp, bug, dest_tracker)
165 return responses
167 def check_replication(self, bug, new_comments):
168 # TODO: send the list of comments and their content to the script
169 if not bug.id or bug.parent:
170 return
172 # FIXME: reduce the amount of queries to 1 instead of N+1 (N: # of replication scripts)
173 for rep_script in ReplicationScript.objects.filter(source_tracker=self.db_bugtracker, enabled=True):
174 client = Client.get_or_create_instance(rep_script.script)
175 self.tracker_check_replication([bug], rep_script.destination_tracker, rep_script.script,
176 client, new_comments=new_comments)
178 def create_bug(self, bug):
179 if bug.bug_id:
180 raise ValueError("Bug already has a bug id assigned")
182 if not bug.tracker.project:
183 raise ValueError("No project defined for tracker")
185 # convert the bug to a json_bug that is understood by all bugtrackers
186 fields = {'title': bug.title,
187 'status': bug.status,
188 'description': bug.description,
189 'product': bug.product,
190 'platforms': bug.platforms,
191 'priority': bug.priority,
192 'component': bug.component}
194 return self.create_bug_from_json(fields)
196 def set_field(self, bug, bug_field, val):
197 if bug_field in Bug.rd_only_fields:
198 return False
200 val = self._parse_custom_field(val)
201 if hasattr(bug, bug_field):
202 setattr(bug, bug_field, val) # NOTE: This relies on Django casting the val to proper type
203 else:
204 bug.custom_fields[bug_field] = val
206 return True
209class Untracked(BugTrackerCommon):
211 def __init__(self, db_bugtracker):
212 super().__init__(db_bugtracker)
213 self.open_statuses = []
215 def _get_tracker_time(self):
216 return timezone.now() # pragma: no cover
218 def _to_tracker_tz(self, dt):
219 return dt # pragma: no cover
221 def poll(self, bug, force_polling_comments=False):
222 bug.title = "UNKNOWN"
223 bug.status = "UNKNOWN"
225 def search_bugs_ids(self, components=None, created_since=None, status=None, updated_since=None):
226 return set()
228 def create_bug_from_json(self, json_bug):
229 pass # pragma: no cover
231 def update_bug_from_json(self, json_bug):
232 pass # pragma: no cover
234 def add_comment(self, bug, comment):
235 # Nothing to do, just silently ignore
236 pass
239class BugCommentTransport:
240 def __init__(self, db_object, body):
241 self.db_object = db_object
242 self.body = body
245class Bugzilla(BugTrackerCommon):
247 def __init__(self, db_bugtracker):
248 super().__init__(db_bugtracker)
249 self.open_statuses = ["NEW", "ASSIGNED", "REOPENED", "NEEDINFO"]
251 self._proxy = xmlrpc.client.ServerProxy("{}/xmlrpc.cgi".format(self.db_bugtracker.url),
252 use_builtin_types=True)
254 def _get_tracker_time(self):
255 # NOTE: Bugzilla time is always UTC
256 dt = self._proxy.Bugzilla.time()['db_time']
257 return timezone.make_aware(dt, timezone=pytz.utc)
259 def _to_tracker_tz(self, dt):
260 return dt.astimezone(pytz.utc)
262 @classmethod
263 def _get_user_id(cls, bug, field):
264 field_name = "{}_detail".format(field)
265 email = bug.get(field_name, dict()).get('email')
266 if email is not None:
267 return email
269 name = bug.get(field_name, dict()).get('name')
270 if name is not None:
271 return name
273 raise ValueError("Cannot find a good identifier for the user of the bug {}".format(bug['id']))
275 def __find_closure_date(self, bug_id):
276 bugs_history = self._proxy.Bug.history({"ids": bug_id})['bugs']
277 if len(bugs_history) == 1:
278 history = bugs_history[0]['history']
279 for update in reversed(history):
280 for change in update['changes']:
281 if (change['field_name'] == 'status' and
282 change['added'] not in self.open_statuses and
283 change['removed'] in self.open_statuses):
284 return timezone.make_aware(update['when'], pytz.utc)
285 return None # pragma: no cover
287 def _parse_custom_field(self, field_val, to_str=True):
288 if isinstance(field_val, list):
289 if to_str:
290 return self._list_to_str(field_val)
291 else:
292 return field_val
293 else:
294 if to_str:
295 return str(field_val)
296 else:
297 return field_val
299 @classmethod
300 def _bug_id_parser(cls, bug):
301 try:
302 return int(bug.bug_id)
303 except Exception as e:
304 raise ValueError("Bugzilla's IDs should be integers ({})".format(bug.bug_id)) from e
306 def __poll_comments(self, bug):
307 bug_id = self._bug_id_parser(bug)
308 new_comments = []
309 opts = {"ids": bug_id, 'include_fields': ['id', 'creator', 'count', 'time', 'text']}
310 polled = bug.comments_polled
311 if polled:
312 opts["new_since"] = polled
314 # Get the lists of comments and create objects in our DB
315 now = timezone.now()
316 comments = self._proxy.Bug.comments(opts)['bugs']["{}".format(bug_id)]['comments']
318 for c in comments:
319 account = self.find_or_create_account(c['creator'], email=c['creator'])
320 url = "{}#c{}".format(bug.url, c['count'])
321 created = timezone.make_aware(c['time'], pytz.utc)
323 try:
324 comment = BugComment.objects.create(bug=bug, account=account,
325 comment_id=c['id'], url=url,
326 created_on=created)
328 new_comments.append(BugCommentTransport(comment, c['text']))
329 except IntegrityError: # pragma: no cover
330 # We may have already imported the comment
331 pass # pragma: no cover
333 bug.comments_polled = now
334 return new_comments
336 def poll(self, bug, force_polling_comments=False):
337 bug_id = self._bug_id_parser(bug)
339 # Query the ID
340 bugs = self._proxy.Bug.get({"ids": bug_id})['bugs']
341 if len(bugs) == 1:
342 b = bugs[0]
344 bug.title = b['summary']
346 status = b['status']
347 if len(b['resolution']) > 0:
348 status += "/{}".format(b['resolution'])
349 bug.status = status
351 # Only get description if we haven't polled it before
352 if bug.description is None:
353 opts = {"ids": bug_id, "include_fields": ["text", "count"]}
354 # Bug description is the first comment in Bugzilla bug
355 comment = self._proxy.Bug.comments(opts)['bugs']["{}".format(bug_id)]['comments'][0]
356 if int(comment['count']) != 0:
357 raise ValueError("Comment parsed for description is not "
358 "first comment. Comment count: {}".format(comment['count']))
359 bug.description = comment['text']
361 bug.created = timezone.make_aware(b['creation_time'], pytz.utc)
362 bug.updated = timezone.make_aware(b['last_change_time'], pytz.utc)
364 # If the bug is closed and we don't know when it was, ask the history
365 if not b['is_open'] and bug.closed is None:
366 bug.closed = self.__find_closure_date(bug_id)
367 elif b['is_open']:
368 bug.closed = None
370 bug.creator = self.find_or_create_account(self._get_user_id(b, 'creator'),
371 email=b['creator_detail'].get('email'),
372 full_name=b['creator_detail']['real_name'])
373 bug.assignee = self.find_or_create_account(self._get_user_id(b, 'assigned_to'),
374 email=b['assigned_to_detail'].get('email'),
375 full_name=b['assigned_to_detail']['real_name'])
376 bug.product = b['product']
377 bug.component = b['component']
378 bug.priority = b['priority']
379 bug.severity = b['severity']
380 bug.platforms = None
381 bug.features = None
383 custom_fields_map = self.db_bugtracker.custom_fields_map
384 if custom_fields_map is not None:
385 for field in custom_fields_map:
386 if b.get(field) is not None:
387 val = b.get(field)
388 bug_field = custom_fields_map[field]
389 self.set_field(bug, bug_field, val)
391 # Get the list of comments, if the bug is already saved in the database
392 new_comments = []
393 if bug.id is not None and (bug.has_new_comments or force_polling_comments):
394 new_comments = self.__poll_comments(bug)
395 self.check_replication(bug, new_comments)
397 else:
398 raise ValueError("Could not find the bug ID {} on {}".format(bug_id, bug.tracker.name))
400 def search_bugs_ids(self, components=None, created_since=None, status=None, updated_since=None):
401 query = {"include_fields": ['id']}
403 if components is not None:
404 query['component'] = components
406 if created_since is not None:
407 query['creation_time'] = created_since
409 if updated_since is not None:
410 query['last_change_time'] = updated_since
412 if status is not None:
413 query['status'] = status
415 return set([str(r['id']) for r in self._proxy.Bug.search(query)['bugs']])
417 def get_auth_token(self):
418 username = self.db_bugtracker.username
419 password = self.db_bugtracker.password
421 if username is None or len(username) == 0 or password is None or len(password) == 0:
422 raise ValueError("Invalid credentials")
424 ret = self._proxy.User.login({"login": username, "password": password, "restrict_login": True})
425 return ret.get('token')
427 def update_bug_from_json(self, json_bug, bug_id):
428 token = self.get_auth_token()
429 if token is None:
430 raise ValueError("Authentication failed. Can't update the bug")
432 json_bug['token'] = token
433 json_bug['ids'] = bug_id
434 try:
435 self._proxy.Bug.update(json_bug)
436 except xmlrpc.client.Error:
437 raise ValueError("Couldn't update the bug using the following fields: {}".format(json_bug))
439 def create_bug_from_json(self, json_bug):
440 json_bug['token'] = self.get_auth_token()
441 if json_bug['token'] is None:
442 raise ValueError("Invalid credentials")
444 if 'summary' not in json_bug:
445 json_bug['summary'] = json_bug.pop('title')
447 try:
448 return self._proxy.Bug.create(json_bug)["id"]
449 except xmlrpc.client.Error:
450 raise ValueError("Couldn't create supplied bug: {}".format(json_bug))
452 def add_comment(self, bug, comment):
453 token = self.get_auth_token()
454 if token is None:
455 raise ValueError("Authentication failed. Can't post a comment")
457 bug_id = self._bug_id_parser(bug)
458 self._proxy.Bug.add_comment({'token': token, 'id': bug_id, 'comment': str(comment)})
460 def transition(self, bug_id, status, fields=None):
461 json_transition = {'status': status}
462 self.update_bug_from_json(json_transition, bug_id)
465class Jira(BugTrackerCommon):
467 def __init__(self, db_bugtracker):
468 super().__init__(db_bugtracker)
470 def _parse_custom_field(self, field_val, to_str=True):
471 if isinstance(field_val, list):
472 try:
473 parsed_val = [x.value for x in field_val]
474 except AttributeError:
475 parsed_val = field_val
476 if to_str:
477 return self._list_to_str(parsed_val)
478 else:
479 return parsed_val
480 else:
481 try:
482 parsed_val = field_val.value
483 except AttributeError:
484 parsed_val = field_val
485 if to_str:
486 return str(parsed_val)
487 else:
488 return parsed_val
490 def _get_tracker_time(self):
491 # WARNING: if using an instance with an anonymous/unauthenticated user
492 # server_info may raise an error or not contain 'serverTime' field
493 try:
494 t = self.jira.server_info()['serverTime']
495 return dateparser.parse(t).astimezone(pytz.utc)
496 except (JIRAError, KeyError):
497 return timezone.now()
499 def _to_tracker_tz(self, dt):
500 # WARNING: if using an instance with an anonymous/unauthenticated user
501 # we can't get the TZ info, hence the try/except.
502 try:
503 user_tz = pytz.timezone(self.jira.myself()['timeZone'])
504 except JIRAError:
505 return dt
506 return dt.astimezone(user_tz)
508 @cached_property
509 def jira(self):
510 jira_options = {
511 'server': self.db_bugtracker.url,
512 'verify': False
513 }
514 if len(self.db_bugtracker.username) > 0 and len(self.db_bugtracker.password) > 0:
515 return JIRA(jira_options, basic_auth=(self.db_bugtracker.username,
516 self.db_bugtracker.password))
517 else:
518 return JIRA(jira_options)
520 @cached_property
521 def open_statuses(self):
522 stats = self.jira.statuses()
523 open_stats = []
524 for stat in stats:
525 if stat.statusCategory.name in ['To Do', 'In Progress']:
526 open_stats.append(stat.name)
527 return open_stats
529 def __poll_comments(self, bug, issue):
530 new_comments = []
531 now = timezone.now()
533 for c in issue.fields.comment.comments:
534 account = self.find_or_create_account(c.author.name,
535 full_name=c.author.displayName,
536 email=getattr(c.author, 'emailAddress', None))
537 url = "{}#comment-{}".format(bug.url, c.id)
538 created = dateparser.parse(c.created)
539 try:
540 comment = BugComment.objects.create(bug=bug, account=account,
541 comment_id=c.id, url=url,
542 created_on=created)
543 new_comments.append(BugCommentTransport(comment, c.body))
545 except IntegrityError: # pragma: no cover
546 # We may have already imported the comment.
547 pass # pragma: no cover
549 bug.comments_polled = now
550 return new_comments
552 def poll(self, bug, force_polling_comments=False):
553 fields = ['summary', 'status', 'description', 'priority', 'created', 'updated', 'resolutiondate', 'creator',
554 'assignee', 'components', 'comment', 'labels']
555 custom_fields_map = self.db_bugtracker.custom_fields_map
556 if custom_fields_map is not None:
557 for field in custom_fields_map:
558 fields.append(field)
560 # NOTE: edge case exists where state is being transitioned in Jira and poll returns "New State" status.
561 # try 3 times to poll bug to account for this.
562 for _ in range(3):
563 issue = self.jira.issue(bug.bug_id, fields=",".join(fields))
564 if issue.fields.status.name != "New State":
565 break
567 bug.title = issue.fields.summary
568 bug.status = issue.fields.status.name
569 bug.description = issue.fields.description
571 if hasattr(issue.fields, "priority") and issue.fields.priority is not None:
572 bug.priority = issue.fields.priority.name
573 bug.created = dateparser.parse(issue.fields.created)
574 bug.updated = dateparser.parse(issue.fields.updated)
575 bug.closed = dateparser.parse(issue.fields.resolutiondate) if issue.fields.resolutiondate is not None else None
577 fields = issue.fields
578 bug.creator = self.find_or_create_account(issue.fields.creator.key,
579 full_name=issue.fields.creator.displayName,
580 email=getattr(fields.creator, 'emailAddress', None))
581 if issue.fields.assignee is not None:
582 bug.assignee = self.find_or_create_account(issue.fields.assignee.key,
583 full_name=issue.fields.assignee.displayName,
584 email=getattr(fields.assignee, 'emailAddress', None))
585 bug.component = ",".join([c.name for c in issue.fields.components])
586 bug.tags = ",".join(issue.fields.labels)
587 bug.product = None
588 bug.platforms = None
589 bug.features = None
590 bug.severity = None
592 if custom_fields_map is not None:
593 for field in custom_fields_map:
594 if hasattr(issue.fields, field):
595 val = getattr(issue.fields, field)
596 bug_field = custom_fields_map[field]
597 self.set_field(bug, bug_field, val)
599 # Get the list of comments
600 new_comments = []
601 if bug.id is not None and (bug.has_new_comments or force_polling_comments):
602 new_comments = self.__poll_comments(bug, issue)
603 self.check_replication(bug, new_comments)
605 def __list_to_jql(self, objects):
606 return ", ".join(['"{}"'.format(o) for o in objects])
608 def search_bugs_ids(self, components=None, created_since=None, status=None, updated_since=None):
609 query = []
611 if self.db_bugtracker.project is not None:
612 query.append("project = '{}'".format(self.db_bugtracker.project))
614 if components is not None:
615 query.append("component in ({})".format(self.__list_to_jql(components)))
617 if created_since is not None:
618 query.append("created > \"{}\"".format(created_since.strftime("%Y/%m/%d %H:%M")))
620 if updated_since is not None:
621 query.append("updated > \"{}\"".format(updated_since.strftime("%Y/%m/%d %H:%M")))
623 if status is not None:
624 query.append("status in ({})".format(self.__list_to_jql(status)))
626 jql_str = " AND ".join(query)
627 issues = self.jira.search_issues(jql_str, maxResults=1000, fields=['key'])
628 for i in range(len(issues), issues.total, 1000):
629 issues.extend(self.jira.search_issues(jql_str, startAt=i, maxResults=1000, fields=['key']))
631 return set([i.key for i in issues])
633 def transition(self, bug_id, status, fields=None):
634 try:
635 self.jira.transition_issue(bug_id, status, fields=fields)
636 except (JIRAError, ValueError):
637 raise ValueError("Couldn't transition using status: {}, and fields: {}".format(status, fields))
639 def update_bug_from_json(self, json_bug, bug_id):
640 trans = json_bug.pop('transition', None)
641 if trans:
642 self.transition(bug_id, trans.get('status'), trans.get('fields'))
643 if not json_bug: # Just performing transition, don't waste an update operation
644 return
646 json_bug['project'] = {'key': self.db_bugtracker.project}
647 update = json_bug.pop('update', None)
648 issue = self.jira.issue(bug_id)
649 try:
650 issue.update(update=update, fields=json_bug)
651 except (JIRAError, ValueError):
652 raise ValueError("Couldn't update the bug using the following fields: {}".format(json_bug))
654 def create_bug_from_json(self, json_bug):
655 # make sure the important fields are set correctly
656 trans = json_bug.pop('transition', None)
658 json_bug['project'] = {'key': self.db_bugtracker.project}
659 if 'issuetype' not in json_bug:
660 json_bug['issuetype'] = {'name': 'Bug'}
661 if 'summary' not in json_bug:
662 json_bug['summary'] = json_bug.pop('title')
664 try:
665 new_issue = self.jira.create_issue(fields=json_bug)
666 except (JIRAError, ValueError):
667 raise ValueError("Couldn't create supplied bug: {}".format(json_bug))
669 if trans:
670 try:
671 self.transition(new_issue.key, trans.get('status'), trans.get('fields'))
672 except ValueError as e:
673 print(e)
675 return new_issue.key
677 def add_comment(self, bug, comment):
678 self.jira.add_comment(bug.bug_id, str(comment))
681class GitLab(BugTrackerCommon):
682 GET = "get"
683 POST = "post"
684 PUT = "put"
686 @property
687 def has_components(self):
688 return False
690 def __init__(self, db_bugtracker):
691 super().__init__(db_bugtracker)
692 self.open_statuses = ['opened']
694 def __make_json_request(self, url, params={}, method="get", paginated=False):
695 headers = {'PRIVATE-TOKEN': self.db_bugtracker.password}
697 request_method = getattr(requests, method)
699 # Increase the size of the page to reduce the amount of requests
700 if paginated:
701 params = dict(params)
702 params['per_page'] = 100
704 next_page = 1
705 results = []
706 while True:
707 params['page'] = next_page
708 response = request_method(url, params=params, headers=headers)
709 response.raise_for_status()
711 results += response.json()
712 next_page = response.headers.get('X-Next-Page')
713 if next_page is None or len(next_page) == 0:
714 return results
715 else:
716 response = request_method(url, params=params, headers=headers)
717 response.raise_for_status()
719 return response.json()
721 def __json_user(self, json):
722 return self.find_or_create_account(json['id'], full_name=json['name'])
724 def __get_issues(self, query):
725 url = self.url
726 return self.__make_json_request(url, params=query, paginated=True)
728 def __get_issue(self, issue_iid):
729 url = self.join(self.url, str(issue_iid))
730 return self.__make_json_request(url)
732 def __get_notes_url(self, issue_iid):
733 note_url = str(issue_iid) + "/notes"
734 return self.join(self.url, note_url)
736 def __poll_comments(self, bug, web_url):
737 now = timezone.now()
738 notes = self.__make_json_request(self.__get_notes_url(bug.bug_id), paginated=True)
739 new_comments = []
741 for note in notes:
742 note_id = note['id']
743 author = self.__json_user(note['author'])
744 url = "{}#note_{}".format(web_url, note_id)
745 created_on = dateparser.parse(note['created_at'])
747 try:
748 comment = BugComment.objects.create(bug=bug, account=author,
749 comment_id=note_id, url=url,
750 created_on=created_on)
751 new_comments.append(BugCommentTransport(comment, note['body']))
753 except IntegrityError: # pragma: no cover
754 # We may have already imported the comment
755 pass # pragma: no cover
757 bug.comments_polled = now
758 return new_comments
760 def _get_tracker_time(self):
761 # NOTE: No API to get server time. Hacky way of getting it by
762 # making unused call and parsing response headers
763 headers = {'PRIVATE-TOKEN': self.db_bugtracker.password}
764 resp = requests.get(self.url, params={'per_page': 1}, headers=headers)
765 return dateparser.parse(resp.headers['Date']).astimezone(pytz.utc)
767 def _to_tracker_tz(self, dt):
768 # NOTE: GitLab supports datetime TZ info so no need
769 # to update
770 return dt
772 @property
773 def url(self):
774 project_id = self.db_bugtracker.project
775 base_url = self.db_bugtracker.url
776 proj_url = "api/v4/projects/{}/issues".format(project_id)
777 return self.join(base_url, proj_url) + "/"
779 def poll(self, bug, force_polling_comments=False):
780 issue = self.__get_issue(bug.bug_id)
782 bug.title = issue['title']
783 bug.status = issue['state']
784 bug.description = issue['description']
786 bug.created = dateparser.parse(issue['created_at'])
787 bug.updated = dateparser.parse(issue['updated_at'])
788 bug.closed = issue['closed_at'] # None if not closed
790 bug.creator = self.__json_user(issue['author'])
792 if issue['assignee'] is not None:
793 bug.assignee = self.__json_user(issue['assignee'])
795 bug.product = None
796 bug.component = None
797 bug.priority = None
798 bug.severity = None
799 if issue['labels']:
800 labels = list()
801 platforms = list()
802 features = list()
803 fields_map = self.db_bugtracker.custom_fields_map
804 for label in issue['labels']:
805 if label.lower().startswith('product::'):
806 bug.product = label.split("::")[1]
807 elif label.lower().startswith('component::'):
808 bug.component = label.split("::")[1]
809 elif label.lower().startswith('priority::'):
810 bug.priority = label.split("::")[1]
811 elif label.lower().startswith('severity::'):
812 bug.severity = label.split("::")[1]
813 elif label.lower().startswith('platform: '):
814 platforms.append(label.split(": ")[1])
815 elif label.lower().startswith('feature: '):
816 features.append(label.split(": ")[1])
817 elif fields_map:
818 field_set = False
819 for field in fields_map:
820 if not label.startswith(field):
821 continue
822 bug_field = fields_map[field]
823 val = label.split(field)[1]
824 field_set = self.set_field(bug, bug_field, val)
825 break
826 if not field_set:
827 labels.append(label)
828 else:
829 labels.append(label)
831 bug.platforms = self._list_to_str(platforms)
832 bug.features = self._list_to_str(features)
833 bug.tags = self._list_to_str(labels)
835 new_comments = []
836 if bug.id is not None and (bug.has_new_comments or force_polling_comments):
837 new_comments = self.__poll_comments(bug, issue['web_url'])
839 self.check_replication(bug, new_comments)
841 def search_bugs_ids(self, components=None, created_since=None, status=None, updated_since=None):
842 query = {}
844 if components is not None:
845 query['labels'] = ",".join(components)
847 if created_since is not None:
848 query['created_after'] = created_since
850 if updated_since is not None:
851 query['updated_after'] = updated_since
853 if status is not None:
854 if isinstance(status, str):
855 query['state'] = status
856 elif isinstance(status, list) and len(status) == 1:
857 query['state'] = status[0]
858 else:
859 raise ValueError('Status has to be a string')
861 issues = self.__get_issues(query)
862 iids = map(lambda x: str(x['iid']), issues)
864 return set(iids)
866 def add_comment(self, bug, comment):
867 url = self.__get_notes_url(bug.bug_id)
868 self.__make_json_request(url, params={'body': str(comment)}, method=GitLab.POST)
870 def update_bug_from_json(self, json_bug, bug_id):
871 upd_url = self.join(self.url, bug_id)
872 try:
873 self.__make_json_request(upd_url, params=json_bug, method=GitLab.PUT)
874 except requests.HTTPError:
875 raise ValueError("Couldn't update the bug with the following fields: {}".format(json_bug))
877 def create_bug_from_json(self, json_bug):
878 try:
879 return self.__make_json_request(self.url, params=json_bug, method='post')['iid']
880 except requests.HTTPError:
881 raise ValueError("Couldn't create supplied bug: {}".format(json_bug))
883 def transition(self, bug_id, status, fields=None):
884 json_transition = {'state_event': status}
885 self.update_bug_from_json(json_transition, bug_id)