1 | """ |
||
2 | This module implements Kiwi TCMS interface to external issue tracking systems. |
||
3 | :class:`tcms.issuetracker.types.IssueTrackerType` provides the interface |
||
4 | while the rest of the classes in this module implement it! Refer to each |
||
5 | implementor class for integration specifics! |
||
6 | """ |
||
7 | |||
8 | import os |
||
9 | import re |
||
10 | import tempfile |
||
11 | from urllib.parse import urlencode, quote |
||
12 | |||
13 | import jira |
||
14 | import github |
||
15 | import bugzilla |
||
16 | import gitlab |
||
17 | import redminelib |
||
18 | |||
19 | from django.conf import settings |
||
20 | |||
21 | from tcms.issuetracker import bugzilla_integration |
||
22 | from tcms.issuetracker import jira_integration |
||
23 | from tcms.issuetracker import github_integration |
||
24 | from tcms.issuetracker import gitlab_integration |
||
25 | from tcms.issuetracker import redmine_integration |
||
26 | |||
27 | |||
28 | RE_ENDS_IN_INT = re.compile(r'[\d]+$') |
||
29 | |||
30 | |||
31 | class IssueTrackerType: |
||
32 | """ |
||
33 | Represents actions which can be performed with issue trackers. |
||
34 | This is a common interface for all issue trackers that Kiwi TCMS |
||
35 | supports! |
||
36 | """ |
||
37 | |||
38 | def __init__(self, tracker): |
||
39 | """ |
||
40 | :tracker: - BugSystem object |
||
41 | """ |
||
42 | self.tracker = tracker |
||
43 | |||
44 | @classmethod |
||
45 | def from_name(cls, name): |
||
46 | """ |
||
47 | Return the class which matches ``name`` if it exists inside this |
||
48 | module or raise an exception. |
||
49 | """ |
||
50 | if name not in globals(): |
||
51 | raise NotImplementedError('IT of type %s is not supported' % name) |
||
52 | return globals()[name] |
||
53 | |||
54 | @classmethod |
||
55 | def bug_id_from_url(cls, url): |
||
56 | """ |
||
57 | Returns a unique identifier for reported defect. This is used by the |
||
58 | underlying integration libraries. Usually that identifier is an |
||
59 | integer number. |
||
60 | |||
61 | The default implementation is to leave the last group of numeric |
||
62 | characters at the end of a string! |
||
63 | """ |
||
64 | return int(RE_ENDS_IN_INT.search(url.strip()).group(0)) |
||
65 | |||
66 | def report_issue_from_testcase(self, caserun): |
||
67 | """ |
||
68 | When marking Test Case results inside a Test Run there is a |
||
69 | `Report` link. When the `Report` link is clicked this method is called |
||
70 | to help the user report an issue in the IT. |
||
71 | |||
72 | This is implemented by constructing an URL string which will pre-fill |
||
73 | bug details like steps to reproduce, product, version, etc from the |
||
74 | test case. Then we open this URL into another browser window! |
||
75 | |||
76 | :caserun: - TestExecution object |
||
77 | :return: - string - URL |
||
78 | """ |
||
79 | raise NotImplementedError() |
||
80 | |||
81 | def add_testcase_to_issue(self, testcases, issue): |
||
82 | """ |
||
83 | When adding issues to Test Execution results there is a |
||
84 | 'Check to add test cases to Issue tracker' checkbox. If |
||
85 | selected this method is called to link the bug report to the |
||
86 | test case which was used to discover the bug. |
||
87 | |||
88 | Usually this is implemented by adding a new comment pointing |
||
89 | back to the test case via the internal RPC object. |
||
90 | |||
91 | :testcases: - list of TestCase objects |
||
92 | :issue: - Bug object |
||
93 | """ |
||
94 | raise NotImplementedError() |
||
95 | |||
96 | # pylint: disable = invalid-name, no-self-use |
||
97 | # todo: we should allow this method to raise and the specific error |
||
98 | # message must be returned to the caller instead of generic one. |
||
99 | # as it is LinkOnly tracker doesn't have any integrations but the error |
||
100 | # message is misleading |
||
101 | def is_adding_testcase_to_issue_disabled(self): |
||
102 | """ |
||
103 | When is linking a TC to a Bug report disabled? |
||
104 | Usually when all the required credentials are provided. |
||
105 | |||
106 | :return: - boolean |
||
107 | """ |
||
108 | return not (self.tracker.api_url |
||
109 | and self.tracker.api_username |
||
110 | and self.tracker.api_password) |
||
111 | |||
112 | def all_issues_link(self, _ids): |
||
113 | """ |
||
114 | Used in testruns.views.get() aka run/report.html to produce |
||
115 | a single URL which will open all reported issues into a single |
||
116 | page in the Issue tracker. For example Bugzilla supports listing |
||
117 | multiple bugs into a table. GitHub on the other hand doesn't |
||
118 | support this functionality. |
||
119 | |||
120 | :ids: - list of issues reported against test executions |
||
121 | |||
122 | :return: - None if not suported or string representing the URL |
||
123 | """ |
||
124 | |||
125 | |||
126 | class Bugzilla(IssueTrackerType): |
||
127 | """ |
||
128 | Support for Bugzilla. Requires: |
||
129 | |||
130 | :api_url: - the XML-RPC URL for your Bugzilla instance |
||
131 | :api_username: - a username registered in Bugzilla |
||
132 | :api_password: - the password for this username |
||
133 | |||
134 | You can also provide the ``BUGZILLA_AUTH_CACHE_DIR`` setting (in ``product.py``) |
||
135 | to control where authentication cookies for Bugzilla will be saved. If this |
||
136 | is not provided a temporary directory will be used each time we try to login |
||
137 | into Bugzilla! |
||
138 | """ |
||
139 | |||
140 | def __init__(self, tracker): |
||
141 | super().__init__(tracker) |
||
142 | |||
143 | # directory for Bugzilla credentials |
||
144 | self._bugzilla_cache_dir = getattr( |
||
145 | settings, |
||
146 | "BUGZILLA_AUTH_CACHE_DIR", |
||
147 | tempfile.mkdtemp(prefix='.bugzilla-') |
||
148 | ) |
||
149 | if not os.path.exists(self._bugzilla_cache_dir): |
||
150 | os.makedirs(self._bugzilla_cache_dir, 0o700) |
||
151 | |||
152 | self._rpc = None |
||
153 | |||
154 | @property |
||
155 | def rpc(self): |
||
156 | if self._rpc is None: |
||
157 | self._rpc = bugzilla.Bugzilla( |
||
158 | self.tracker.api_url, |
||
159 | user=self.tracker.api_username, |
||
160 | password=self.tracker.api_password, |
||
161 | cookiefile=self._bugzilla_cache_dir + 'cookie', |
||
162 | tokenfile=self._bugzilla_cache_dir + 'token', |
||
163 | ) |
||
164 | return self._rpc |
||
165 | |||
166 | def add_testcase_to_issue(self, testcases, issue): |
||
167 | for case in testcases: |
||
168 | bugzilla_integration.BugzillaThread(self.rpc, case, issue).start() |
||
169 | |||
170 | def all_issues_link(self, ids): |
||
171 | if not self.tracker.base_url: |
||
172 | return None |
||
173 | |||
174 | if not self.tracker.base_url.endswith('/'): |
||
175 | self.tracker.base_url += '/' |
||
176 | |||
177 | return self.tracker.base_url + 'buglist.cgi?bugidtype=include&bug_id=%s' % ','.join(ids) |
||
178 | |||
179 | def report_issue_from_testcase(self, caserun): |
||
180 | args = {} |
||
181 | args['cf_build_id'] = caserun.run.build.name |
||
182 | |||
183 | txt = caserun.case.get_text_with_version(case_text_version=caserun.case_text_version) |
||
184 | |||
185 | comment = "Filed from caserun %s\n\n" % caserun.get_full_url() |
||
186 | comment += "Version-Release number of selected " \ |
||
187 | "component (if applicable):\n" |
||
188 | comment += '%s\n\n' % caserun.build.name |
||
189 | comment += "Steps to Reproduce: \n%s\n\n" % txt |
||
190 | comment += "Actual results: \n<describe what happened>\n\n" |
||
191 | |||
192 | args['comment'] = comment |
||
193 | args['component'] = caserun.case.component.values_list('name', |
||
194 | flat=True) |
||
195 | args['product'] = caserun.run.plan.product.name |
||
196 | args['short_desc'] = 'Test case failure: %s' % caserun.case.summary |
||
197 | args['version'] = caserun.run.product_version |
||
198 | |||
199 | url = self.tracker.base_url |
||
200 | if not url.endswith('/'): |
||
201 | url += '/' |
||
202 | |||
203 | return url + 'enter_bug.cgi?' + urlencode(args, True) |
||
204 | |||
205 | |||
206 | class JIRA(IssueTrackerType): |
||
207 | """ |
||
208 | Support for JIRA. Requires: |
||
209 | |||
210 | :api_url: - the API URL for your JIRA instance |
||
211 | :api_username: - a username registered in JIRA |
||
212 | :api_password: - the password for this username |
||
213 | |||
214 | Additional control can be applied via the ``JIRA_OPTIONS`` configuration |
||
215 | setting (in ``product.py``). By default this setting is not provided and |
||
216 | the code uses ``jira.JIRA.DEFAULT_OPTIONS`` from the ``jira`` Python module! |
||
217 | """ |
||
218 | |||
219 | def __init__(self, tracker): |
||
220 | super(JIRA, self).__init__(tracker) |
||
221 | |||
222 | if hasattr(settings, 'JIRA_OPTIONS'): |
||
223 | options = settings.JIRA_OPTIONS |
||
224 | else: |
||
225 | options = None |
||
226 | |||
227 | # b/c jira.JIRA tries to connect when object is created |
||
228 | # see https://github.com/kiwitcms/Kiwi/issues/100 |
||
229 | if not self.is_adding_testcase_to_issue_disabled(): |
||
230 | self.rpc = jira.JIRA( |
||
231 | tracker.api_url, |
||
232 | basic_auth=(self.tracker.api_username, self.tracker.api_password), |
||
233 | options=options, |
||
234 | ) |
||
235 | |||
236 | @classmethod |
||
237 | def bug_id_from_url(cls, url): |
||
238 | """ |
||
239 | Jira IDs are the last group of chars at the end of the URL. |
||
240 | For example https://issues.jenkins-ci.org/browse/JENKINS-31044 |
||
241 | """ |
||
242 | return url.strip().split('/')[-1] |
||
243 | |||
244 | def add_testcase_to_issue(self, testcases, issue): |
||
245 | for case in testcases: |
||
246 | jira_integration.JiraThread(self.rpc, case, issue).start() |
||
247 | |||
248 | def all_issues_link(self, ids): |
||
249 | if not self.tracker.base_url: |
||
250 | return None |
||
251 | |||
252 | if not self.tracker.base_url.endswith('/'): |
||
253 | self.tracker.base_url += '/' |
||
254 | |||
255 | return self.tracker.base_url + 'issues/?jql=issueKey%%20in%%20(%s)' % '%2C%20'.join(ids) |
||
256 | |||
257 | def report_issue_from_testcase(self, caserun): |
||
258 | """ |
||
259 | For the HTML API description see: |
||
260 | https://confluence.atlassian.com/display/JIRA050/Creating+Issues+via+direct+HTML+links |
||
261 | """ |
||
262 | # note: your jira instance needs to have the same projects |
||
263 | # defined otherwise this will fail! |
||
264 | project = self.rpc.project(caserun.run.plan.product.name) |
||
265 | |||
266 | try: |
||
267 | issue_type = self.rpc.issue_type_by_name('Bug') |
||
268 | except KeyError: |
||
269 | issue_type = self.rpc.issue_types()[0] |
||
270 | |||
271 | args = { |
||
272 | 'pid': project.id, |
||
273 | 'issuetype': issue_type.id, |
||
274 | 'summary': 'Failed test: %s' % caserun.case.summary, |
||
275 | } |
||
276 | |||
277 | try: |
||
278 | # apparently JIRA can't search users via their e-mail so try to |
||
279 | # search by username and hope that it matches |
||
280 | tested_by = caserun.tested_by |
||
281 | if not tested_by: |
||
282 | tested_by = caserun.assignee |
||
283 | |||
284 | args['reporter'] = self.rpc.user(tested_by.username).key |
||
285 | except jira.JIRAError: |
||
286 | pass |
||
287 | |||
288 | txt = caserun.case.get_text_with_version(case_text_version=caserun.case_text_version) |
||
289 | |||
290 | comment = "Filed from caserun %s\n\n" % caserun.get_full_url() |
||
291 | comment += "Product:\n%s\n\n" % caserun.run.plan.product.name |
||
292 | comment += "Component(s):\n%s\n\n" % caserun.case.component.values_list('name', flat=True) |
||
293 | comment += "Version-Release number of selected " \ |
||
294 | "component (if applicable):\n" |
||
295 | comment += "%s\n\n" % caserun.build.name |
||
296 | comment += "Steps to Reproduce: \n%s\n\n" % txt |
||
297 | comment += "Actual results: \n<describe what happened>\n\n" |
||
298 | args['description'] = comment |
||
299 | |||
300 | url = self.tracker.base_url |
||
301 | if not url.endswith('/'): |
||
302 | url += '/' |
||
303 | |||
304 | return url + '/secure/CreateIssueDetails!init.jspa?' + urlencode(args, True) |
||
305 | |||
306 | |||
307 | View Code Duplication | class GitHub(IssueTrackerType): |
|
0 ignored issues
–
show
Duplication
introduced
by
Loading history...
|
|||
308 | """ |
||
309 | Support for GitHub. Requires: |
||
310 | |||
311 | :base_url: - URL to a GitHub repository for which we're going to report issues |
||
312 | :api_password: - GitHub API token. |
||
313 | |||
314 | .. note:: |
||
315 | |||
316 | You can leave the ``api_url`` and ``api_username`` fields blank because |
||
317 | the integration code doesn't use them! |
||
318 | |||
319 | .. note:: |
||
320 | |||
321 | GitHub does not support displaying multiple issues in a table format like |
||
322 | Bugzilla and JIRA do. This means that in Test Execution Report view you will |
||
323 | see GitHub issues listed one by one and there will not be a link to open all |
||
324 | of them inside GitHub's interface! |
||
325 | """ |
||
326 | |||
327 | def __init__(self, tracker): |
||
328 | super(GitHub, self).__init__(tracker) |
||
329 | |||
330 | # NOTE: we use an access token so only the password field is required |
||
331 | self.rpc = github.Github(self.tracker.api_password) |
||
332 | |||
333 | def add_testcase_to_issue(self, testcases, issue): |
||
334 | for case in testcases: |
||
335 | github_integration.GitHubThread(self.rpc, self.tracker, case, issue).start() |
||
336 | |||
337 | def is_adding_testcase_to_issue_disabled(self): |
||
338 | return not (self.tracker.base_url and self.tracker.api_password) |
||
339 | |||
340 | def report_issue_from_testcase(self, caserun): |
||
341 | """ |
||
342 | GitHub only supports title and body parameters |
||
343 | """ |
||
344 | args = { |
||
345 | 'title': 'Failed test: %s' % caserun.case.summary, |
||
346 | } |
||
347 | |||
348 | txt = caserun.case.get_text_with_version(case_text_version=caserun.case_text_version) |
||
349 | |||
350 | comment = "Filed from caserun %s\n\n" % caserun.get_full_url() |
||
351 | comment += "Product:\n%s\n\n" % caserun.run.plan.product.name |
||
352 | comment += "Component(s):\n%s\n\n" % caserun.case.component.values_list('name', flat=True) |
||
353 | comment += "Version-Release number of selected " \ |
||
354 | "component (if applicable):\n" |
||
355 | comment += "%s\n\n" % caserun.build.name |
||
356 | comment += "Steps to Reproduce: \n%s\n\n" % txt |
||
357 | comment += "Actual results: \n<describe what happened>\n\n" |
||
358 | args['body'] = comment |
||
359 | |||
360 | url = self.tracker.base_url |
||
361 | if not url.endswith('/'): |
||
362 | url += '/' |
||
363 | |||
364 | return url + '/issues/new?' + urlencode(args, True) |
||
365 | |||
366 | |||
367 | View Code Duplication | class Gitlab(IssueTrackerType): |
|
0 ignored issues
–
show
|
|||
368 | """ |
||
369 | Support for Gitlab. Requires: |
||
370 | |||
371 | :base_url: - URL to a Gitlab repository for which we're going to report issues |
||
372 | :api_url: - URL to GitLab instance. Usually gitlab.com! |
||
373 | :api_password: - Gitlab API token. |
||
374 | """ |
||
375 | |||
376 | def __init__(self, tracker): |
||
377 | super(Gitlab, self).__init__(tracker) |
||
378 | |||
379 | # we use an access token so only the password field is required |
||
380 | self.rpc = gitlab.Gitlab(self.tracker.api_url, private_token=self.tracker.api_password) |
||
381 | |||
382 | def add_testcase_to_issue(self, testcases, issue): |
||
383 | for case in testcases: |
||
384 | gitlab_integration.GitlabThread(self.rpc, self.tracker, case, issue).start() |
||
385 | |||
386 | def is_adding_testcase_to_issue_disabled(self): |
||
387 | return not (self.tracker.base_url and self.tracker.api_password) |
||
388 | |||
389 | def report_issue_from_testcase(self, caserun): |
||
390 | args = { |
||
391 | 'issue[title]': 'Failed test: %s' % caserun.case.summary, |
||
392 | } |
||
393 | |||
394 | txt = caserun.case.get_text_with_version(case_text_version=caserun.case_text_version) |
||
395 | |||
396 | comment = "Filed from caserun %s\n\n" % caserun.get_full_url() |
||
397 | comment += "**Product**:\n%s\n\n" % caserun.run.plan.product.name |
||
398 | comment += "**Component(s)**:\n%s\n\n"\ |
||
399 | % caserun.case.component.values_list('name', flat=True) |
||
400 | comment += "Version-Release number of selected " \ |
||
401 | "component (if applicable):\n" |
||
402 | comment += "%s\n\n" % caserun.build.name |
||
403 | comment += "**Steps to Reproduce**: \n%s\n\n" % txt |
||
404 | comment += "**Actual results**: \n<describe what happened>\n\n" |
||
405 | args['issue[description]'] = comment |
||
406 | |||
407 | url = self.tracker.base_url |
||
408 | if not url.endswith('/'): |
||
409 | url += '/' |
||
410 | |||
411 | return url + '/issues/new?' + urlencode(args, True) |
||
412 | |||
413 | |||
414 | class Redmine(IssueTrackerType): |
||
415 | """ |
||
416 | Support for Redmine. Requires: |
||
417 | |||
418 | :api_url: - the API URL for your Redmine instance |
||
419 | :api_username: - a username registered in Redmine |
||
420 | :api_password: - the password for this username |
||
421 | """ |
||
422 | |||
423 | def __init__(self, tracker): |
||
424 | super().__init__(tracker) |
||
425 | |||
426 | if not self.is_adding_testcase_to_issue_disabled(): |
||
427 | self.rpc = redminelib.Redmine( |
||
428 | self.tracker.api_url, |
||
429 | username=self.tracker.api_username, |
||
430 | password=self.tracker.api_password |
||
431 | ) |
||
432 | |||
433 | def add_testcase_to_issue(self, testcases, issue): |
||
434 | for case in testcases: |
||
435 | redmine_integration.RedmineThread(self.rpc, case, issue).start() |
||
436 | |||
437 | def all_issues_link(self, ids): |
||
438 | if not self.tracker.base_url: |
||
439 | return None |
||
440 | |||
441 | if not self.tracker.base_url.endswith('/'): |
||
442 | self.tracker.base_url += '/' |
||
443 | |||
444 | query = 'issues?utf8=✓&set_filter=1&sort=id%3Adesc&f%5B%5D=issue_id' |
||
445 | query += '&op%5Bissue_id%5D=%3D&v%5Bissue_id%5D%5B%5D={0}'.format('%2C'.join(ids)) |
||
446 | query += '&f%5B%5D=&c%5B%5D=tracker&c%5B%5D=status&c%5B%5D=priority' |
||
447 | query += '&c%5B%5D=subject&c%5B%5D=assigned_to&c%5B%5D=updated_on&group_by=&t%5B%5D=' |
||
448 | |||
449 | return self.tracker.base_url + query |
||
450 | |||
451 | def find_project_by_name(self, name): |
||
452 | """ |
||
453 | Return a Redmine project which matches the given product name. |
||
454 | |||
455 | .. note:: |
||
456 | |||
457 | If there is no match then return the first project in Redmine. |
||
458 | """ |
||
459 | try: |
||
460 | return self.rpc.project.get(name) |
||
461 | except redminelib.exceptions.ResourceNotFoundError: |
||
462 | projects = self.rpc.project.all() |
||
463 | return projects[0] |
||
464 | |||
465 | @staticmethod |
||
466 | def find_issue_type_by_name(project, name): |
||
467 | """ |
||
468 | Return a Redmine tracker matching name ('Bug'). |
||
469 | |||
470 | .. note:: |
||
471 | |||
472 | If there is no match then return the first one! |
||
473 | """ |
||
474 | for trk in project.trackers: |
||
475 | if str(trk).lower() == name.lower(): |
||
476 | return trk |
||
477 | |||
478 | return project.trackers[0] |
||
479 | |||
480 | def report_issue_from_testcase(self, caserun): |
||
481 | project = self.find_project_by_name(caserun.run.plan.product.name) |
||
482 | |||
483 | issue_type = self.find_issue_type_by_name(project, 'Bug') |
||
484 | |||
485 | query = "issue[tracker_id]=" + str(issue_type.id) |
||
486 | query += "&issue[subject]=" + quote('Failed test:{0}'.format(caserun.case.summary)) |
||
487 | |||
488 | txt = caserun.case.get_text_with_version(case_text_version=caserun.case_text_version) |
||
489 | |||
490 | comment = "Filed from caserun %s\n\n" % caserun.get_full_url() |
||
491 | comment += "Product:\n%s\n\n" % caserun.run.plan.product.name |
||
492 | comment += "Component(s):\n%s\n\n" % caserun.case.component.values_list('name', flat=True) |
||
493 | comment += "Version-Release number of selected " \ |
||
494 | "component (if applicable):\n" |
||
495 | comment += "%s\n\n" % caserun.build.name |
||
496 | comment += "Steps to Reproduce: \n%s\n\n" % txt |
||
497 | comment += "Actual results: \n<describe what happened>\n\n" |
||
498 | |||
499 | query += "&issue[description]={0}".format(quote(comment)) |
||
500 | |||
501 | url = self.tracker.base_url |
||
502 | if not url.endswith('/'): |
||
503 | url += '/' |
||
504 | |||
505 | return url + '/projects/{0}/issues/new?'.format(str(project.id)) + query |
||
506 | |||
507 | |||
508 | class LinkOnly(IssueTrackerType): |
||
509 | """ |
||
510 | Allow only linking issues to TestExecution records. Can be used when your |
||
511 | issue tracker is not integrated with Kiwi TCMS. |
||
512 | |||
513 | No additional API integration available! |
||
514 | """ |
||
515 | |||
516 | def is_adding_testcase_to_issue_disabled(self): |
||
517 | return True |
||
518 |