Coverage for CIResults/bugtrackers.py: 100%

617 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-04-23 13:11 +0000

1from django.utils.functional import cached_property 

2from django.db import IntegrityError 

3from django.utils import timezone 

4 

5from dateutil import parser as dateparser 

6from jira import JIRA 

7from jira.exceptions import JIRAError 

8import xmlrpc.client 

9import traceback 

10import requests 

11import pytz 

12 

13from .models import Person, BugComment, BugTrackerAccount, Bug, ReplicationScript 

14from .sandbox.io import Client 

15from .serializers import serialize_bug 

16 

17 

18class BugTrackerCommon: 

19 @property 

20 def has_components(self): 

21 return True 

22 

23 def __init__(self, db_bugtracker): 

24 self.db_bugtracker = db_bugtracker 

25 

26 @classmethod 

27 def _list_to_str(cls, bl): 

28 if type(bl) is list: 

29 return ",".join(bl) 

30 else: 

31 return bl 

32 

33 @staticmethod 

34 def join(a, b): 

35 return str(a).rstrip("/") + "/" + str(b).lstrip("/") 

36 

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 

42 

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 

49 

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 

70 

71 def _replication_add_comments(self, bug, comments): 

72 if not comments: 

73 return 

74 

75 if isinstance(comments, str): 

76 self.add_comment(bug, comments) 

77 return 

78 

79 for comment in comments: 

80 self.add_comment(bug, comment) 

81 

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) 

90 

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 

105 

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) 

114 

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() 

125 

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 = [] 

131 

132 for bug in bugs: 

133 if not bug.id or bug.parent: 

134 continue 

135 

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() 

142 

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 

150 

151 if not json_resp: 

152 continue 

153 

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) 

164 

165 return responses 

166 

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 

171 

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) 

177 

178 def create_bug(self, bug): 

179 if bug.bug_id: 

180 raise ValueError("Bug already has a bug id assigned") 

181 

182 if not bug.tracker.project: 

183 raise ValueError("No project defined for tracker") 

184 

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} 

193 

194 return self.create_bug_from_json(fields) 

195 

196 def set_field(self, bug, bug_field, val): 

197 if bug_field in Bug.rd_only_fields: 

198 return False 

199 

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 

205 

206 return True 

207 

208 

209class Untracked(BugTrackerCommon): 

210 

211 def __init__(self, db_bugtracker): 

212 super().__init__(db_bugtracker) 

213 self.open_statuses = [] 

214 

215 def _get_tracker_time(self): 

216 return timezone.now() # pragma: no cover 

217 

218 def _to_tracker_tz(self, dt): 

219 return dt # pragma: no cover 

220 

221 def poll(self, bug, force_polling_comments=False): 

222 bug.title = "UNKNOWN" 

223 bug.status = "UNKNOWN" 

224 

225 def search_bugs_ids(self, components=None, created_since=None, status=None, updated_since=None): 

226 return set() 

227 

228 def create_bug_from_json(self, json_bug): 

229 pass # pragma: no cover 

230 

231 def update_bug_from_json(self, json_bug): 

232 pass # pragma: no cover 

233 

234 def add_comment(self, bug, comment): 

235 # Nothing to do, just silently ignore 

236 pass 

237 

238 

239class BugCommentTransport: 

240 def __init__(self, db_object, body): 

241 self.db_object = db_object 

242 self.body = body 

243 

244 

245class Bugzilla(BugTrackerCommon): 

246 

247 def __init__(self, db_bugtracker): 

248 super().__init__(db_bugtracker) 

249 self.open_statuses = ["NEW", "ASSIGNED", "REOPENED", "NEEDINFO"] 

250 

251 self._proxy = xmlrpc.client.ServerProxy("{}/xmlrpc.cgi".format(self.db_bugtracker.url), 

252 use_builtin_types=True) 

253 

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) 

258 

259 def _to_tracker_tz(self, dt): 

260 return dt.astimezone(pytz.utc) 

261 

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 

268 

269 name = bug.get(field_name, dict()).get('name') 

270 if name is not None: 

271 return name 

272 

273 raise ValueError("Cannot find a good identifier for the user of the bug {}".format(bug['id'])) 

274 

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 

286 

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 

298 

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 

305 

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 

313 

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'] 

317 

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) 

322 

323 try: 

324 comment = BugComment.objects.create(bug=bug, account=account, 

325 comment_id=c['id'], url=url, 

326 created_on=created) 

327 

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 

332 

333 bug.comments_polled = now 

334 return new_comments 

335 

336 def poll(self, bug, force_polling_comments=False): 

337 bug_id = self._bug_id_parser(bug) 

338 

339 # Query the ID 

340 bugs = self._proxy.Bug.get({"ids": bug_id})['bugs'] 

341 if len(bugs) == 1: 

342 b = bugs[0] 

343 

344 bug.title = b['summary'] 

345 

346 status = b['status'] 

347 if len(b['resolution']) > 0: 

348 status += "/{}".format(b['resolution']) 

349 bug.status = status 

350 

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'] 

360 

361 bug.created = timezone.make_aware(b['creation_time'], pytz.utc) 

362 bug.updated = timezone.make_aware(b['last_change_time'], pytz.utc) 

363 

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 

369 

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 

382 

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) 

390 

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) 

396 

397 else: 

398 raise ValueError("Could not find the bug ID {} on {}".format(bug_id, bug.tracker.name)) 

399 

400 def search_bugs_ids(self, components=None, created_since=None, status=None, updated_since=None): 

401 query = {"include_fields": ['id']} 

402 

403 if components is not None: 

404 query['component'] = components 

405 

406 if created_since is not None: 

407 query['creation_time'] = created_since 

408 

409 if updated_since is not None: 

410 query['last_change_time'] = updated_since 

411 

412 if status is not None: 

413 query['status'] = status 

414 

415 return set([str(r['id']) for r in self._proxy.Bug.search(query)['bugs']]) 

416 

417 def get_auth_token(self): 

418 username = self.db_bugtracker.username 

419 password = self.db_bugtracker.password 

420 

421 if username is None or len(username) == 0 or password is None or len(password) == 0: 

422 raise ValueError("Invalid credentials") 

423 

424 ret = self._proxy.User.login({"login": username, "password": password, "restrict_login": True}) 

425 return ret.get('token') 

426 

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") 

431 

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)) 

438 

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") 

443 

444 if 'summary' not in json_bug: 

445 json_bug['summary'] = json_bug.pop('title') 

446 

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)) 

451 

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") 

456 

457 bug_id = self._bug_id_parser(bug) 

458 self._proxy.Bug.add_comment({'token': token, 'id': bug_id, 'comment': str(comment)}) 

459 

460 def transition(self, bug_id, status, fields=None): 

461 json_transition = {'status': status} 

462 self.update_bug_from_json(json_transition, bug_id) 

463 

464 

465class Jira(BugTrackerCommon): 

466 

467 def __init__(self, db_bugtracker): 

468 super().__init__(db_bugtracker) 

469 

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 

489 

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() 

498 

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) 

507 

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) 

519 

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 

528 

529 def __poll_comments(self, bug, issue): 

530 new_comments = [] 

531 now = timezone.now() 

532 

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)) 

544 

545 except IntegrityError: # pragma: no cover 

546 # We may have already imported the comment. 

547 pass # pragma: no cover 

548 

549 bug.comments_polled = now 

550 return new_comments 

551 

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) 

559 

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 

566 

567 bug.title = issue.fields.summary 

568 bug.status = issue.fields.status.name 

569 bug.description = issue.fields.description 

570 

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 

576 

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 

591 

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) 

598 

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) 

604 

605 def __list_to_jql(self, objects): 

606 return ", ".join(['"{}"'.format(o) for o in objects]) 

607 

608 def search_bugs_ids(self, components=None, created_since=None, status=None, updated_since=None): 

609 query = [] 

610 

611 if self.db_bugtracker.project is not None: 

612 query.append("project = '{}'".format(self.db_bugtracker.project)) 

613 

614 if components is not None: 

615 query.append("component in ({})".format(self.__list_to_jql(components))) 

616 

617 if created_since is not None: 

618 query.append("created > \"{}\"".format(created_since.strftime("%Y/%m/%d %H:%M"))) 

619 

620 if updated_since is not None: 

621 query.append("updated > \"{}\"".format(updated_since.strftime("%Y/%m/%d %H:%M"))) 

622 

623 if status is not None: 

624 query.append("status in ({})".format(self.__list_to_jql(status))) 

625 

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'])) 

630 

631 return set([i.key for i in issues]) 

632 

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)) 

638 

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 

645 

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)) 

653 

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) 

657 

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') 

663 

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)) 

668 

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) 

674 

675 return new_issue.key 

676 

677 def add_comment(self, bug, comment): 

678 self.jira.add_comment(bug.bug_id, str(comment)) 

679 

680 

681class GitLab(BugTrackerCommon): 

682 GET = "get" 

683 POST = "post" 

684 PUT = "put" 

685 

686 @property 

687 def has_components(self): 

688 return False 

689 

690 def __init__(self, db_bugtracker): 

691 super().__init__(db_bugtracker) 

692 self.open_statuses = ['opened'] 

693 

694 def __make_json_request(self, url, params={}, method="get", paginated=False): 

695 headers = {'PRIVATE-TOKEN': self.db_bugtracker.password} 

696 

697 request_method = getattr(requests, method) 

698 

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 

703 

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() 

710 

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() 

718 

719 return response.json() 

720 

721 def __json_user(self, json): 

722 return self.find_or_create_account(json['id'], full_name=json['name']) 

723 

724 def __get_issues(self, query): 

725 url = self.url 

726 return self.__make_json_request(url, params=query, paginated=True) 

727 

728 def __get_issue(self, issue_iid): 

729 url = self.join(self.url, str(issue_iid)) 

730 return self.__make_json_request(url) 

731 

732 def __get_notes_url(self, issue_iid): 

733 note_url = str(issue_iid) + "/notes" 

734 return self.join(self.url, note_url) 

735 

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 = [] 

740 

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']) 

746 

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'])) 

752 

753 except IntegrityError: # pragma: no cover 

754 # We may have already imported the comment 

755 pass # pragma: no cover 

756 

757 bug.comments_polled = now 

758 return new_comments 

759 

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) 

766 

767 def _to_tracker_tz(self, dt): 

768 # NOTE: GitLab supports datetime TZ info so no need 

769 # to update 

770 return dt 

771 

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) + "/" 

778 

779 def poll(self, bug, force_polling_comments=False): 

780 issue = self.__get_issue(bug.bug_id) 

781 

782 bug.title = issue['title'] 

783 bug.status = issue['state'] 

784 bug.description = issue['description'] 

785 

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 

789 

790 bug.creator = self.__json_user(issue['author']) 

791 

792 if issue['assignee'] is not None: 

793 bug.assignee = self.__json_user(issue['assignee']) 

794 

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) 

830 

831 bug.platforms = self._list_to_str(platforms) 

832 bug.features = self._list_to_str(features) 

833 bug.tags = self._list_to_str(labels) 

834 

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']) 

838 

839 self.check_replication(bug, new_comments) 

840 

841 def search_bugs_ids(self, components=None, created_since=None, status=None, updated_since=None): 

842 query = {} 

843 

844 if components is not None: 

845 query['labels'] = ",".join(components) 

846 

847 if created_since is not None: 

848 query['created_after'] = created_since 

849 

850 if updated_since is not None: 

851 query['updated_after'] = updated_since 

852 

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') 

860 

861 issues = self.__get_issues(query) 

862 iids = map(lambda x: str(x['iid']), issues) 

863 

864 return set(iids) 

865 

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) 

869 

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)) 

876 

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)) 

882 

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)