PageRenderTime 30ms CodeModel.GetById 20ms RepoModel.GetById 0ms app.codeStats 1ms

/tools/jenkins/junit-stats.py

https://github.com/VoltDB/voltdb
Python | 1041 lines | 990 code | 21 blank | 30 comment | 0 complexity | 1503ee7be6595236f04ed5e26134749c MD5 | raw file
  1. #!/usr/bin/env python
  2. # This file is part of VoltDB.
  3. # Copyright (C) 2008-2022 Volt Active Data Inc.
  4. # A command line tool for getting junit job statistics from Jenkins CI
  5. import logging
  6. import os
  7. import sys
  8. import mysql.connector
  9. from datetime import datetime, timedelta
  10. from jenkinsbot import JenkinsBot
  11. from mysql.connector.errors import Error as MySQLError
  12. from numpy import std, mean
  13. from re import search, sub
  14. from string import whitespace
  15. from traceback import format_exc
  16. from urllib2 import HTTPError, URLError, urlopen
  17. # Constants used in posting messages on Slack
  18. JUNIT = os.environ.get('junit', None)
  19. AUTO_FILED = os.environ.get('auto-filed', None)
  20. # For now, this should work (?); need a constant for the 'auto-filed' channel
  21. SLACK_CHANNEL_FOR_AUTO_FILING = JUNIT
  22. # set to True if you need to suppress updating the 'qa' database or JIRA
  23. DRY_RUN = False
  24. # Default is None (null); but once initialized, it may be reused
  25. JENKINSBOT = None
  26. # All possible failure "types", as detailed below
  27. ALL_FAILURE_TYPES = ['NEW', 'INTERMITTENT', 'FREQUENT', 'CONSISTENT', 'INCONSISTENT', 'OLD']
  28. # Constants used to determine which test failures are considered Consistent,
  29. # Intermittent or New failures
  30. NEW_FAILURE_WINDOW_SIZE = 10
  31. NEW_NUM_FAILURES_THRESHOLD = 2 # 2 out of 10 failures is 'New' - if not seen recently
  32. INTERMITTENT_FAILURE_WINDOW_SIZE = 25
  33. INTERMITTENT_NUM_FAILURES_THRESHOLD = 3 # 3 out of 25 failures is 'Intermittent'
  34. CONSISTENT_FAILURE_WINDOW_SIZE = 3
  35. CONSISTENT_NUM_FAILURES_THRESHOLD = 3 # 3 out of 3 (consecutive) failures is 'Consistent'
  36. # Constants used to determine after how many passing it should be closed, or
  37. # when its status should be changed to Old, Inconsistent or Intermittent.
  38. # Note: "Inconsistent" means formerly deemed Consistent, but we're not yet
  39. # certain whether it is fixed or actually Intermittent.
  40. CHANGE_NEW_TO_OLD_WINDOW_SIZE = 5
  41. CHANGE_NEW_TO_OLD_NUM_FAILURES_THRESHOLD = 1 # if less than 1 in 5 failed, downgrade to 'Old'
  42. CHANGE_INTERMITTENT_TO_OLD_WINDOW_SIZE = 10
  43. CHANGE_INTERMITTENT_TO_OLD_NUM_FAILURES_THRESHOLD = 1 # if less than 1 in 10 failed, downgrade to 'Old'
  44. CHANGE_INTERMITTENT_TO_CONSISTENT_WINDOW_SIZE = 5
  45. CHANGE_INTERMITTENT_TO_CONSISTENT_NUM_FAILURES_THRESHOLD = 5 # if 5 out of 5 failed, upgrade to 'Consistent'
  46. CHANGE_INTERMITTENT_TO_FREQUENT_WINDOW_SIZE = 10
  47. CHANGE_INTERMITTENT_TO_FREQUENT_NUM_FAILURES_THRESHOLD = 8 # if 8 out of 10 failed, upgrade to 'Frequent'
  48. CHANGE_FREQUENT_TO_CONSISTENT_WINDOW_SIZE = 10
  49. CHANGE_FREQUENT_TO_CONSISTENT_NUM_FAILURES_THRESHOLD = 10 # if 10 out of 10 failed, change to 'Consistent'
  50. CHANGE_FREQUENT_TO_INTERMITTENT_WINDOW_SIZE = 10
  51. CHANGE_FREQUENT_TO_INTERMITTENT_NUM_FAILURES_THRESHOLD = 7 # if less than 7 in 10 failed, downgrade to 'Intermittent'
  52. CHANGE_CONSISTENT_TO_INCONSISTENT_WINDOW_SIZE = 1
  53. CHANGE_CONSISTENT_TO_INCONSISTENT_NUM_FAILURES_THRESHOLD = 1 # if less than 1 in 1 failed, downgrade to 'Inconsistent'
  54. CHANGE_INCONSISTENT_TO_INTERMITTENT_WINDOW_SIZE = 1
  55. CHANGE_INCONSISTENT_TO_INTERMITTENT_NUM_FAILURES_THRESHOLD = 1 # if 1 out of 1 failed, change to 'Intermittent'
  56. # Constants used to determine after how many passing it should be closed
  57. CLOSE_INCONSISTENT_WINDOW_SIZE = 5
  58. CLOSE_INCONSISTENT_NUM_FAILURES_THRESHOLD = 1 # if less than 1 in 5 failed, close the ticket
  59. CLOSE_OLD_WINDOW_SIZE = 10
  60. CLOSE_OLD_NUM_FAILURES_THRESHOLD = 1 # if less than 1 in 10 failed, close the ticket
  61. # Constants used in filing (or modifying) Jira tickets
  62. JIRA_PRIORITY_FOR_CONSISTENT_FAILURES = 'Critical'
  63. JIRA_PRIORITY_FOR_FREQUENT_FAILURES = 'Critical'
  64. JIRA_PRIORITY_FOR_INCONSISTENT_FAILURES = 'Major'
  65. JIRA_PRIORITY_FOR_INTERMITTENT_FAILURES = 'Major'
  66. JIRA_PRIORITY_FOR_NEW_FAILURES = 'Minor'
  67. JIRA_PRIORITY_FOR_OLD_FAILURES = 'Trivial'
  68. JIRA_LABEL_FOR_AUTO_FILING = 'auto-filed'
  69. JIRA_LABEL_FOR_CONSISTENT_FAILURES = 'junit-consistent-failure'
  70. JIRA_LABEL_FOR_FREQUENT_FAILURES = 'junit-intermittent-failure'
  71. JIRA_LABEL_FOR_INCONSISTENT_FAILURES = 'junit-intermittent-failure'
  72. JIRA_LABEL_FOR_INTERMITTENT_FAILURES = 'junit-intermittent-failure'
  73. JIRA_LABEL_FOR_NEW_FAILURES = 'junit-intermittent-failure'
  74. JIRA_LABEL_FOR_OLD_FAILURES = 'junit-intermittent-failure'
  75. MAX_NUM_ATTACHMENTS_PER_JIRA_TICKET = 8
  76. # Used to help prevent a Jira ticket from exceeding Jira's maximum
  77. # description size (32,767 characters, total)
  78. MAX_NUM_CHARS_PER_JIRA_DESCRIPTION = 32767
  79. MAX_NUM_CHARS_PER_DESCRIPTION_PIECE = 2000
  80. # Characters that don't work well in Jira seqrches
  81. JIRA_SEARCH_PROBLEMATIC_CHARACTERS = '._'
  82. # Used in Jira ticket descriptions:
  83. DASHES = '-------------------------'
  84. STACK_TRACE_LINE = '\n'+DASHES+'\-Stack Trace\-'+DASHES+'\n\n'
  85. SEPARATOR_LINE = '\n'+DASHES+'--------------' +DASHES+'\n\n'
  86. JENKINS_JOBS = {
  87. 'branch-2-community-junit-master' : {'nickname' : 'community-junit', 'label' : 'junit-community-failure'},
  88. 'branch-2-pro-junit-master' : {'nickname' : 'pro-junit', 'label' : 'junit-pro-failure'},
  89. 'test-nextrelease-debug-pro' : {'nickname' : 'debug-pro', 'label' : 'junit-debug-failure'},
  90. 'test-nextrelease-memcheck-pro' : {'nickname' : 'memcheck-pro', 'label' : 'junit-memcheck-debug-failure'},
  91. 'test-nextrelease-memcheck-nodebug-pro' : {'nickname' : 'memcheck-nodebug', 'label' : 'junit-memcheck-failure'},
  92. 'test-nextrelease-fulljmemcheck-pro-junit': {'nickname' : 'fulljmemcheck', 'label' : 'junit-fulljmemcheck-failure'},
  93. 'test-nextrelease-nonflaky-pro-junit' : {'nickname' : 'nonflaky-pro', 'label' : 'junit-nonflaky-failure'},
  94. 'test-nextrelease-pool-community-junit' : {'nickname' : 'pool-community', 'label' : 'junit-pool-community-failure'},
  95. 'test-nextrelease-pool-pro-junit' : {'nickname' : 'pool-pro', 'label' : 'junit-pool-pro-failure'},
  96. }
  97. # Used for getting the preferred URL prefix; we prefer the latter to the former,
  98. # because it works even over the VPN
  99. BAD_URL_PREFIX = 'ci:8080'
  100. GOOD_URL_PREFIX = 'ci.voltdb.lan:8080'
  101. # Used to count errors and warnings encountered during execution
  102. ERROR_COUNT = 0
  103. WARNING_COUNT = 0
  104. # Use to modify URLs by changing problematic characters into underscores
  105. from string import maketrans
  106. TT = maketrans("[]-<> ", "______")
  107. # Print a log (info) message after every group of this many test cases are processed
  108. # (in each "run" of a build, e.g. junit_other_p4 vs. junit_regression_h2)
  109. LOG_MESSAGE_EVERY_NUM_TEST_CASES = 200
  110. # TODO: possibly obsolete?? :
  111. # set threshold (greater than or equal to) of failures in a row to be significant
  112. FAIL_THRESHOLD = 2
  113. # TODO: probably obsolete:
  114. QUERY1 = """
  115. SELECT count(*) AS fails
  116. FROM `junit-test-failures` m
  117. WHERE m.job = %(job)s
  118. AND m.name = %(name)s
  119. AND m.status in ('FAILED', 'REGRESSION')
  120. AND m.stamp > %(stamp)s - INTERVAL 30 DAY
  121. AND m.build <= %(build)s
  122. """
  123. QUERY2 = """
  124. SELECT count(*) AS fixes
  125. FROM `junit-test-failures` m
  126. WHERE m.job = %(job)s
  127. AND m.name = %(name)s
  128. AND m.status in ('FIXED')
  129. AND m.stamp > %(stamp)s - INTERVAL 30 DAY
  130. AND m.build <= %(build)s
  131. HAVING fixes > 0
  132. LIMIT 1
  133. """
  134. QUERY3 = """
  135. SELECT count(*) as runs
  136. FROM `junit-builds` m
  137. WHERE m.name = %(job)s
  138. AND m.stamp > %(stamp)s - INTERVAL 30 DAY
  139. AND m.stamp <= %(stamp)s
  140. """
  141. QUERY4 = """
  142. SELECT job, build, name, ord-1-COALESCE(pre, 0) AS runs, current
  143. FROM
  144. (SELECT job, build, name, status, ord, stamp,
  145. LAG(ord) OVER w2 AS pre,
  146. LEAD(ord) OVER w2 AS post,
  147. (SELECT last-MAX(ord)
  148. FROM
  149. (SELECT job, name, status, stamp,
  150. ROW_NUMBER() OVER w1 AS ord,
  151. (SELECT count(*)
  152. FROM `junit-test-failures` n
  153. WHERE n.job=%(job)s
  154. AND n.name=%(name)s
  155. AND n.stamp > %(stamp)s - INTERVAL 30 DAY
  156. AND n.build <= %(build)s
  157. ) last
  158. FROM `junit-test-failures` n
  159. WHERE n.job=%(job)s
  160. AND n.name=%(name)s
  161. AND n.stamp > %(stamp)s - INTERVAL 30 DAY
  162. AND n.build <= %(build)s
  163. WINDOW w1 AS (ORDER BY build)
  164. ) q1
  165. WHERE q1.status in ('FIXED')
  166. LIMIT 1
  167. ) current
  168. FROM
  169. (SELECT job, build, name, status, stamp,
  170. ROW_NUMBER() OVER w1 AS ord
  171. FROM `junit-test-failures` n
  172. WHERE n.job=%(job)s
  173. AND n.name=%(name)s
  174. AND n.stamp > %(stamp)s - INTERVAL 30 DAY
  175. AND n.build <= %(build)s
  176. WINDOW w1 AS (ORDER BY build)
  177. ) q2
  178. WHERE q2.status in ('FIXED')
  179. WINDOW w2 AS (ORDER BY ord)
  180. ) q3;
  181. """
  182. class Stats(object):
  183. def __init__(self):
  184. self.jhost = 'http://ci.voltdb.lan'
  185. self.dbhost = 'junitstatsdb.voltdb.lan'
  186. self.dbuser = os.environ.get('dbuser', None)
  187. self.dbpass = os.environ.get('dbpass', None)
  188. self.dbname = os.environ.get('dbname', 'qa')
  189. self.cmdhelp = """
  190. usage: junit-stats <job> <build_range>
  191. ex: junit-stats branch-2-pro-junit-master 800-990
  192. ex: junit-stats branch-2-community-junit-master 550-550
  193. You can also specify 'job' and 'build_range' environment variables
  194. """
  195. log_format = '%(asctime)s %(module)14s:%(lineno)-6d %(levelname)-8s [%(threadName)-10s] %(message)s'
  196. # logging.basicConfig(stream=sys.stdout, level=logging.INFO)
  197. # file = logging.FileHandler("junit-stats.log", mode='w')
  198. # file.setLevel(logging.INFO)
  199. # formatter = logging.Formatter(log_format)
  200. # file.setFormatter(formatter)
  201. # logging.getLogger('').handlers = []
  202. # logging.getLogger('').addHandler(file)
  203. loglevel = logging.INFO
  204. console_loglevel = loglevel
  205. logfile = "junit-stats.log"
  206. logger = logging.getLogger()
  207. logger.setLevel(logging.NOTSET)
  208. logger.propogate = True
  209. file = logging.FileHandler(logfile, mode='a')
  210. console = logging.StreamHandler()
  211. file.setLevel(loglevel)
  212. console.setLevel(console_loglevel)
  213. formatter = logging.Formatter(log_format)
  214. file.setFormatter(formatter)
  215. console.setFormatter(formatter)
  216. logging.getLogger('').handlers = []
  217. logging.getLogger('').addHandler(file)
  218. logging.getLogger('').addHandler(console)
  219. logging.info("starting... %s" % sys.argv)
  220. def error(self, message='', caused_by=None):
  221. """TODO
  222. :param
  223. """
  224. global ERROR_COUNT
  225. ERROR_COUNT = ERROR_COUNT + 1
  226. if caused_by:
  227. message += '\nCaused by:\n' + str(caused_by)
  228. logging.error(message)
  229. def warn(self, message='', caused_by=None):
  230. """TODO
  231. :param
  232. """
  233. global WARNING_COUNT
  234. WARNING_COUNT = WARNING_COUNT + 1
  235. if caused_by:
  236. message += '\nCaused by:\n' + str(caused_by)
  237. logging.warn(message)
  238. def fix_url(self, url):
  239. """
  240. :param url: url to download data from
  241. :return: TODO
  242. """
  243. if not url:
  244. return None
  245. return GOOD_URL_PREFIX.join(url.split(BAD_URL_PREFIX))
  246. def read_url(self, url, ignore404=False):
  247. """
  248. :param url: url to download data from
  249. :return: Dictionary representation of json object
  250. """
  251. logging.debug('In read_url:')
  252. logging.debug(' url: '+url)
  253. url = self.fix_url(url)
  254. logging.debug(' url: '+url)
  255. data = None
  256. try:
  257. data = eval(urlopen(url).read())
  258. except Exception as e:
  259. if (ignore404 and type(e) is HTTPError and e.code == 404):
  260. logging.debug('Ignoring HTTPError (%s) at URL:\n %s' % (str(e), str(url)))
  261. else:
  262. self.error('Exception trying to open data from URL:\n %s'
  263. '\n The URL may not be formed correctly.'
  264. % str(url), e )
  265. return data
  266. def get_number_of_jenkins_failures(self, cursor, testName, jenkins_job,
  267. last_build, num_builds):
  268. """TODO
  269. """
  270. logging.debug('In get_number_of_jenkins_failures:')
  271. logging.debug(' cursor : '+str(cursor))
  272. logging.debug(' testName : '+str(testName))
  273. logging.debug(' jenkins_job : '+str(jenkins_job))
  274. logging.debug(' last_build : '+str(last_build))
  275. logging.debug(' num_builds : '+str(num_builds))
  276. query_base = """SELECT count(*) as numfails
  277. FROM `junit-test-failures` f
  278. WHERE f.name = '%s'
  279. AND f.job = '%s'
  280. AND f.status in ('FAILED', 'REGRESSION')
  281. AND f.build <= %s
  282. AND f.build > %s
  283. """
  284. query = query_base % (testName, jenkins_job, last_build,
  285. (last_build - num_builds))
  286. logging.debug(' query :\n '+str(query))
  287. cursor.execute(query)
  288. num_failures = float(cursor.fetchone()[0])
  289. #num_failures = int(cursor.fetchone()[0])
  290. logging.debug(' num_failures: '+str(num_failures))
  291. return num_failures
  292. def get_intermittent_failure_percent(self, cursor=None, testName=None,
  293. jenkins_job=None, last_build=None,
  294. num_failures=None):
  295. """TODO
  296. """
  297. logging.debug('In get_intermittent_failure_percent...')
  298. if not num_failures:
  299. num_failures = self.get_number_of_jenkins_failures(cursor, testName,
  300. jenkins_job, last_build, INTERMITTENT_FAILURE_WINDOW_SIZE)
  301. return (100.0 * num_failures
  302. / INTERMITTENT_FAILURE_WINDOW_SIZE)
  303. def qualifies_as_new_failure(self, cursor, testName,
  304. jenkins_job, last_build, status):
  305. """TODO
  306. """
  307. logging.debug('In qualifies_as_new_failure...')
  308. # Possible shortcut to skip querying the 'qa' database,
  309. # if we're just checking the most recent build
  310. if (status is 'REGRESSION' and
  311. NEW_FAILURE_WINDOW_SIZE is 1 and
  312. NEW_NUM_FAILURES_THRESHOLD is 1):
  313. logging.debug('...qualifies as new, via shortcut (assuming 4% failure percent)')
  314. return 4.0 # assume 1 failure out of the last 25 builds
  315. num_failures = self.get_number_of_jenkins_failures(cursor, testName,
  316. jenkins_job, last_build, NEW_FAILURE_WINDOW_SIZE)
  317. if num_failures >= NEW_NUM_FAILURES_THRESHOLD:
  318. # recompute failure percent, as if an intermittent failure
  319. return self.get_intermittent_failure_percent(cursor, testName,
  320. jenkins_job, last_build)
  321. else:
  322. return 0 # does not qualify
  323. def qualifies_as_intermittent_failure(self, cursor, testName,
  324. jenkins_job, last_build):
  325. """TODO
  326. """
  327. logging.debug('In qualifies_as_intermittent_failure...')
  328. num_failures = self.get_number_of_jenkins_failures(cursor, testName,
  329. jenkins_job, last_build, INTERMITTENT_FAILURE_WINDOW_SIZE)
  330. if num_failures >= INTERMITTENT_NUM_FAILURES_THRESHOLD:
  331. # compute failure percent, as an intermittent failure
  332. return self.get_intermittent_failure_percent(num_failures=num_failures)
  333. else:
  334. return 0 # does not qualify
  335. def qualifies_as_consistent_failure(self, cursor, testName,
  336. jenkins_job, last_build):
  337. """TODO
  338. """
  339. logging.debug('In qualifies_as_consistent_failure...')
  340. num_failures = self.get_number_of_jenkins_failures(cursor, testName,
  341. jenkins_job, last_build, CONSISTENT_FAILURE_WINDOW_SIZE)
  342. if num_failures >= CONSISTENT_NUM_FAILURES_THRESHOLD:
  343. failurePercent = (100.0 * num_failures
  344. / CONSISTENT_FAILURE_WINDOW_SIZE)
  345. return failurePercent
  346. else:
  347. return 0 # does not qualify
  348. def change_intermittent_failure_to_frequent(self, cursor, testName,
  349. jenkins_job, last_build):
  350. """TODO
  351. """
  352. logging.debug('In change_intermittent_failure_to_frequent...')
  353. num_failures = self.get_number_of_jenkins_failures(cursor, testName,
  354. jenkins_job, last_build, CHANGE_INTERMITTENT_TO_FREQUENT_WINDOW_SIZE)
  355. if num_failures >= CHANGE_INTERMITTENT_TO_FREQUENT_NUM_FAILURES_THRESHOLD:
  356. failurePercent = (100.0 * num_failures
  357. / CHANGE_INTERMITTENT_TO_FREQUENT_WINDOW_SIZE)
  358. return failurePercent
  359. else:
  360. return 0 # do not change
  361. def change_intermittent_failure_to_consistent(self, cursor, testName,
  362. jenkins_job, last_build):
  363. """TODO
  364. """
  365. logging.debug('In change_intermittent_failure_to_consistent_...')
  366. num_failures = self.get_number_of_jenkins_failures(cursor, testName,
  367. jenkins_job, last_build, CHANGE_INTERMITTENT_TO_CONSISTENT_WINDOW_SIZE)
  368. if num_failures >= CHANGE_INTERMITTENT_TO_CONSISTENT_NUM_FAILURES_THRESHOLD:
  369. failurePercent = (100.0 * num_failures
  370. / CHANGE_INTERMITTENT_TO_CONSISTENT_WINDOW_SIZE)
  371. return failurePercent
  372. else:
  373. return 0 # do not change
  374. def change_new_failure_to_old(self, cursor, testName,
  375. jenkins_job, last_build):
  376. """TODO
  377. """
  378. logging.debug('In change_new_failure_to_old...')
  379. num_failures = self.get_number_of_jenkins_failures(cursor, testName,
  380. jenkins_job, last_build, CHANGE_NEW_TO_OLD_WINDOW_SIZE)
  381. if num_failures < CHANGE_NEW_TO_OLD_NUM_FAILURES_THRESHOLD:
  382. # recompute failure percent, as if an intermittent failure
  383. return self.get_intermittent_failure_percent(cursor, testName,
  384. jenkins_job, last_build)
  385. else:
  386. return 0 # do not change
  387. def change_intermittent_failure_to_old(self, cursor, testName,
  388. jenkins_job, last_build):
  389. """TODO
  390. """
  391. logging.debug('In change_intermittent_failure_to_old...')
  392. num_failures = self.get_number_of_jenkins_failures(cursor, testName,
  393. jenkins_job, last_build, CHANGE_INTERMITTENT_TO_OLD_WINDOW_SIZE)
  394. if num_failures < CHANGE_INTERMITTENT_TO_OLD_NUM_FAILURES_THRESHOLD:
  395. # recompute failure percent, as if still an intermittent failure
  396. return self.get_intermittent_failure_percent(cursor, testName,
  397. jenkins_job, last_build)
  398. else:
  399. return 0 # do not change
  400. def change_consistent_failure_to_inconsistent(self, cursor, testName,
  401. jenkins_job, last_build, status):
  402. """TODO
  403. """
  404. logging.debug('In change_consistent_failure_to_inconsistent...')
  405. # Possible shortcut to skip querying the 'qa' database,
  406. # if we're just checking the most recent build
  407. if (status is 'FIXED' and
  408. CHANGE_CONSISTENT_TO_INCONSISTENT_WINDOW_SIZE is 1 and
  409. CHANGE_CONSISTENT_TO_INCONSISTENT_NUM_FAILURES_THRESHOLD is 1):
  410. logging.debug('...do change to inconsistent, via shortcut (recompute failure percent)')
  411. # recompute failure percent, as if an intermittent failure
  412. return self.get_intermittent_failure_percent(cursor, testName,
  413. jenkins_job, last_build)
  414. num_failures = self.get_number_of_jenkins_failures(cursor, testName,
  415. jenkins_job, last_build, CHANGE_CONSISTENT_TO_INCONSISTENT_WINDOW_SIZE)
  416. if num_failures < CHANGE_CONSISTENT_TO_INCONSISTENT_NUM_FAILURES_THRESHOLD:
  417. # recompute failure percent, as if an intermittent failure
  418. return self.get_intermittent_failure_percent(cursor, testName,
  419. jenkins_job, last_build)
  420. else:
  421. return 0 # do not change
  422. def change_inconsistent_failure_to_intermittent(self, cursor, testName,
  423. jenkins_job, last_build, status):
  424. """TODO
  425. """
  426. logging.debug('In change_inconsistent_failure_to_intermittent...')
  427. # Possible shortcut to skip querying the 'qa' database,
  428. # if we're just checking the most recent build
  429. if (status is 'REGRESSION' and
  430. CHANGE_INCONSISTENT_TO_INTERMITTENT_WINDOW_SIZE is 1 and
  431. CHANGE_INCONSISTENT_TO_INTERMITTENT_NUM_FAILURES_THRESHOLD is 1):
  432. logging.debug('...do change to intermittent, via shortcut (recompute failure percent)')
  433. # recompute failure percent, as an intermittent failure
  434. return self.get_intermittent_failure_percent(cursor, testName,
  435. jenkins_job, last_build)
  436. num_failures = self.get_number_of_jenkins_failures(cursor, testName,
  437. jenkins_job, last_build, CHANGE_INCONSISTENT_TO_INTERMITTENT_WINDOW_SIZE)
  438. if num_failures >= CHANGE_INCONSISTENT_TO_INTERMITTENT_NUM_FAILURES_THRESHOLD:
  439. # recompute failure percent, as an intermittent failure
  440. return self.get_intermittent_failure_percent(cursor, testName,
  441. jenkins_job, last_build)
  442. else:
  443. return 0 # do not change
  444. def change_frequent_failure_to_consistent(self, cursor, testName,
  445. jenkins_job, last_build, status):
  446. """TODO
  447. """
  448. logging.debug('In change_frequent_failure_to_consistent...')
  449. # Possible shortcut to skip querying the 'qa' database,
  450. # if we're just checking the most recent build
  451. if (status is 'REGRESSION' and
  452. CHANGE_FREQUENT_TO_CONSISTENT_WINDOW_SIZE is 1 and
  453. CHANGE_FREQUENT_TO_CONSISTENT_NUM_FAILURES_THRESHOLD is 1):
  454. logging.debug('...do change to consistent, via shortcut (recompute failure percent)')
  455. # recompute failure percent, as an intermittent failure
  456. return self.get_intermittent_failure_percent(cursor, testName,
  457. jenkins_job, last_build)
  458. num_failures = self.get_number_of_jenkins_failures(cursor, testName,
  459. jenkins_job, last_build, CHANGE_FREQUENT_TO_CONSISTENT_WINDOW_SIZE)
  460. if num_failures >= CHANGE_FREQUENT_TO_CONSISTENT_NUM_FAILURES_THRESHOLD:
  461. # recompute failure percent, as an intermittent failure
  462. return self.get_intermittent_failure_percent(cursor, testName,
  463. jenkins_job, last_build)
  464. else:
  465. return 0 # do not change
  466. def change_frequent_failure_to_intermittent(self, cursor, testName,
  467. jenkins_job, last_build, status):
  468. """TODO
  469. """
  470. logging.debug('In change_frequent_failure_to_intermittent...')
  471. # Possible shortcut to skip querying the 'qa' database,
  472. # if we're just checking the most recent build
  473. if (status is 'REGRESSION' and
  474. CHANGE_FREQUENT_TO_INTERMITTENT_WINDOW_SIZE is 1 and
  475. CHANGE_FREQUENT_TO_INTERMITTENT_NUM_FAILURES_THRESHOLD is 1):
  476. logging.debug('...do change to intermittent, via shortcut (recompute failure percent)')
  477. # recompute failure percent, as an intermittent failure
  478. return self.get_intermittent_failure_percent(cursor, testName,
  479. jenkins_job, last_build)
  480. num_failures = self.get_number_of_jenkins_failures(cursor, testName,
  481. jenkins_job, last_build, CHANGE_FREQUENT_TO_INTERMITTENT_WINDOW_SIZE)
  482. if num_failures < CHANGE_FREQUENT_TO_INTERMITTENT_NUM_FAILURES_THRESHOLD:
  483. # recompute failure percent, as an intermittent failure
  484. return self.get_intermittent_failure_percent(cursor, testName,
  485. jenkins_job, last_build)
  486. else:
  487. return 0 # do not change
  488. def should_close_inconsistent_failure(self, cursor, testName,
  489. jenkins_job, last_build,
  490. ticket_description=''):
  491. """TODO
  492. """
  493. logging.debug('In should_close_intermittent_failure...')
  494. num_failures = self.get_number_of_jenkins_failures(cursor, testName,
  495. jenkins_job, last_build, CLOSE_INCONSISTENT_WINDOW_SIZE)
  496. if (num_failures < CLOSE_INCONSISTENT_NUM_FAILURES_THRESHOLD
  497. and jenkins_job in ticket_description):
  498. return True
  499. else:
  500. return False # do not close
  501. def should_close_old_failure(self, cursor, testName,
  502. jenkins_job, last_build,
  503. ticket_description=''):
  504. """TODO
  505. """
  506. logging.debug('In should_close_old_failure...')
  507. num_failures = self.get_number_of_jenkins_failures(cursor, testName,
  508. jenkins_job, last_build, CLOSE_OLD_WINDOW_SIZE)
  509. if (num_failures < CLOSE_OLD_NUM_FAILURES_THRESHOLD
  510. and jenkins_job in ticket_description):
  511. return True
  512. else:
  513. return False # do not close
  514. def truncate_if_needed(self, text, truncate_in_middle=True,
  515. insert_text='\n...[truncated]...\n',
  516. max_num_chars=MAX_NUM_CHARS_PER_DESCRIPTION_PIECE):
  517. """Takes a text string (typically part of a Jira ticket description),
  518. and makes sure that it does not exceed a specified maximum number of
  519. characters, and truncates it if it does. By default: the max_num_chars
  520. is the constant MAX_NUM_CHARS_PER_DESCRIPTION_PIECE; it 'truncates'
  521. the text in the middle, taking half the available characters from the
  522. beginning and half from the end of the original text; and it adds
  523. '\n...[truncated]...\n' in the middle. But by specifying the optional
  524. parameters, you may change it to instead truncate the end of the text
  525. (when truncate_in_middle=False); or to insert a different piece of
  526. text to indicate the truncation; or to use a different maximum number
  527. of characters.
  528. """
  529. if len(text) <= max_num_chars:
  530. return text
  531. logging.debug(' In junit-stats.truncate_if_needed:')
  532. new_text = ''
  533. insert_length = len(insert_text)
  534. # "Truncate" the text in the middle
  535. if truncate_in_middle:
  536. half_length = int( (max_num_chars - insert_length) / 2 )
  537. new_text = text[:half_length] + insert_text \
  538. + text[-half_length:]
  539. logging.warn('Jira description piece of length %d characters '
  540. 'truncated to %d characters:\n %s'
  541. % (len(text), len(new_text), new_text) )
  542. # Truncate the text at the end
  543. else:
  544. last_char_index = max_num_chars - insert_length
  545. new_text = text[:last_char_index] + insert_text
  546. logging.warn('Updated Jira description truncated to:\n %s' % new_text)
  547. logging.warn('This part was left out of the truncated Jira description:\n %s'
  548. % text[last_char_index:] )
  549. logging.debug(' new (truncated) text:\n %s' % str(new_text))
  550. return new_text
  551. def combine_since_build_messages(self, description, new_since_build,
  552. info_messages, jenkins_job_name=None,
  553. build_type=None):
  554. """If the current (Jira ticket) description includes 'since-build'
  555. messages related to the current Jenkins job, compress them and the
  556. new_since_build message into one, and return the resulting (Jira
  557. ticket) description.
  558. """
  559. updated_description = False
  560. if jenkins_job_name:
  561. # Older formats of "since-build" messages:
  562. format1_message = '\nFailing consistently since '+jenkins_job_name+' build #'
  563. format2_message = '\nFailing intermittently since '+jenkins_job_name+' build #'
  564. # Current format of "since-build" messages (omitting beginning):
  565. format3_message = 'failure in '+jenkins_job_name+' since build #'
  566. # Set initial values
  567. found_old_version_of_same_message = False
  568. old_build_number = sys.maxint # an initial, very large value
  569. for msg in [format1_message, format2_message, format3_message]:
  570. count = 0
  571. while msg in description and count < 10:
  572. count += 1 # just in case the text replacement does not work
  573. found_old_version_of_same_message = True
  574. # Get the index of the message's start - from the
  575. # beginning of the line
  576. message_start = description.index(msg)
  577. if description[message_start:message_start+1] != '\n':
  578. message_start = description.rfind('\n', 0, message_start)
  579. # Get the index of the message's end - to the beginning
  580. # of the next line (or of the entire description)
  581. message_end = description.find('\n', message_start+1)
  582. if message_end < 0:
  583. message_end = len(description)
  584. matching_message = description[message_start:message_end]
  585. msg_build_start = matching_message.rfind('#') + 1
  586. msg_build_end = len(matching_message)
  587. match = search('\D', matching_message[msg_build_start:])
  588. if match:
  589. msg_build_end = msg_build_start + match.start()
  590. logging.debug(' msg : '+str(msg))
  591. logging.debug(' message_start : '+str(message_start))
  592. logging.debug(' message_end : '+str(message_end))
  593. logging.debug(' matching_message: '+matching_message)
  594. logging.debug(' msg_build_start : '+str(msg_build_start))
  595. logging.debug(' msg_build_end : '+str(msg_build_end))
  596. logging.debug(' matching_message[msg_build_start:msg_build_end]: '
  597. + matching_message[msg_build_start:msg_build_end])
  598. try:
  599. msg_build_number = int(matching_message[msg_build_start:msg_build_end])
  600. old_build_number = min(old_build_number, msg_build_number)
  601. except ValueError as e:
  602. self.error('While trying to get build number from old description '
  603. '(index %d-%d):\n %s\n Found Exception:\n %s'
  604. % (msg_build_start, msg_build_end, matching_message, str(e)) )
  605. break
  606. description = description.replace(matching_message,'')
  607. updated_description = True
  608. # For a Consistent failure, keep the build number in the new message,
  609. # which is presumably the build at which the current Jenkins job started
  610. # failing consistently; for others (Intermittent, Inconsistent, etc.),
  611. # change it to the oldest build in which we've seen this failure
  612. if found_old_version_of_same_message and build_type is not 'CONSISTENT':
  613. msg_build_index = new_since_build.rfind('#') + 1
  614. try:
  615. new_build_number = int(new_since_build[msg_build_index:])
  616. except ValueError as e:
  617. self.error("While trying to get build number from new 'since-build' "
  618. "message (index %d):\n %s\n Found Exception:\n %s"
  619. % (msg_build_index, new_since_build, str(e)) )
  620. new_build_number = sys.maxint
  621. if old_build_number < new_build_number:
  622. new_since_build = new_since_build.replace(str(new_build_number),
  623. str(old_build_number))
  624. if updated_description:
  625. info_messages.append('since-build message updated to: %s' % new_since_build)
  626. else:
  627. info_messages.append('added since-build message: %s' % new_since_build)
  628. return description + new_since_build
  629. def get_modified_description(self, old_description, new_description_pieces,
  630. jenkins_job_name=None, build_type=None,
  631. issue_key=''):
  632. """Combines an old description (if any) of a Jira ticket with new pieces
  633. of text that we want to add to that description, if they are not
  634. already there; in some cases, the old and new descriptions will be
  635. combined in some way. Unlike the previous version, this method is
  636. very aware of, and treats slightly differently, the 4 usual parts
  637. (plus 'other') of an Auto-filer ticket description: the full name of
  638. the failing test (including package name, which is omitted from the
  639. Summary); a link to the failure history of this test (in a particular
  640. Jenkings job); a stack trace; and a brief message saying how often
  641. (in a Jenkings job) this test has failed, and since which build. Note
  642. that, over time, multiple versions of each section may appear in the
  643. same Jira ticket. For example, links to the failure history for each
  644. Jenkins job in which the test has failed; or multiple stack traces,
  645. if the stack trace is not always identical, etc.
  646. """
  647. logging.debug('In junit-stats.get_modified_description:')
  648. logging.debug(' old_description:\n %s' % str(old_description))
  649. logging.debug(' new_description_pieces:\n %s' % str(new_description_pieces))
  650. logging.debug(' jenkins_job_name: %s' % str(jenkins_job_name))
  651. logging.debug(' build_type : %s' % str(build_type))
  652. logging.debug(' issue_key : %s' % str(issue_key))
  653. new_description = ''
  654. # Used to identify the relevant pieces of the old description
  655. old_desc_pieces = {}
  656. old_desc_pieces['failingtest'] = []
  657. old_desc_pieces['failurehistory'] = []
  658. old_desc_pieces['stacktrace'] = []
  659. old_desc_pieces['sincebuild'] = []
  660. old_desc_pieces['other'] = []
  661. # Loop through sections of the old description, which are generally
  662. # separated by a line of dashes
  663. old_desc_index = 0
  664. while old_desc_index < len(old_description):
  665. stack_trace = False
  666. # Initial guesses for where the next section starts and ends
  667. next_section_start = old_desc_index
  668. next_section_end = old_description.find(DASHES, next_section_start)
  669. logging.debug(' old_desc_index : %d' % old_desc_index)
  670. logging.debug(' next_section_start: %d' % next_section_start)
  671. logging.debug(' next_section_end : %d' % next_section_end)
  672. # If the current section of the old_description starts with dashes,
  673. # skip over that line, and any white space or dashes that follow
  674. if next_section_start == next_section_end:
  675. next_section_start = old_description.find('\n', next_section_start)
  676. if next_section_start < 0:
  677. break # if the last line starts with dashes, ignore it
  678. while next_section_start < len(old_description) and (
  679. old_description[next_section_start:next_section_start+1]
  680. in whitespace+'-' ):
  681. next_section_start += 1
  682. next_section_end = old_description.find(DASHES, next_section_start)
  683. # Unlike other sections, a Stack Trace should include its dashes
  684. # (if we have multiple Stack Traces, we want them separated)
  685. if 'Stack Trace' in old_description[old_desc_index:next_section_start]:
  686. stack_trace = True
  687. next_section_start = old_desc_index
  688. # If there are no more dashes, then this is the last section, so it
  689. # ends at the end of the old_description
  690. if next_section_end < 0:
  691. next_section_end = len(old_description)
  692. logging.debug(' stack_trace : %s' % str(stack_trace))
  693. logging.debug(' next_section_start: %d' % next_section_start)
  694. logging.debug(' next_section_end : %d' % next_section_end)
  695. if next_section_start >= next_section_end:
  696. self.warn('In get_modified_description, next_section_start (%d) '
  697. '>= next_section_end (%d): this should not normally '
  698. 'happen; for comparison, old_description has length %d.'
  699. % (next_section_start, next_section_end, len(old_description)) )
  700. break
  701. old_desc_index = next_section_end
  702. next_section = self.truncate_if_needed(
  703. old_description[next_section_start:next_section_end] )
  704. logging.debug(' next_section:\n %s' % str(next_section))
  705. if stack_trace:
  706. old_desc_pieces['stacktrace'].append(next_section)
  707. logging.debug(' - added as Stack Trace')
  708. elif next_section.startswith('Failure history'):
  709. old_desc_pieces['failurehistory'].append(next_section)
  710. logging.debug(' - added as Failure history')
  711. elif next_section.startswith('Failing Test:'):
  712. old_desc_pieces['failingtest'].append(next_section)
  713. logging.debug(' - added as Failing Test')
  714. elif next_section.startswith('Failing') or any(
  715. next_section.startswith(ft) for ft in ALL_FAILURE_TYPES ):
  716. old_desc_pieces['sincebuild'].append(next_section)
  717. logging.debug(' - added as Since build')
  718. else:
  719. old_desc_pieces['other'].append(next_section)
  720. logging.debug(" - added as 'other'")
  721. logging.debug(' old_desc_pieces:\n %s' % str(old_desc_pieces))
  722. # Collect the various pieces of the old description
  723. old_failing_test = '\n'.join(sec for sec in old_desc_pieces['failingtest'])
  724. old_failure_history = '\n'.join(sec for sec in old_desc_pieces['failurehistory'])
  725. old_stack_trace = '\n'.join(sec for sec in old_desc_pieces['stacktrace'])
  726. old_other = '\n'.join(sec for sec in old_desc_pieces['other'])
  727. old_since_build = '\n'.join(sec for sec in old_desc_pieces['sincebuild'])
  728. # Collect the various pieces of the new description
  729. new_failing_test = self.truncate_if_needed(new_description_pieces.get('failingtest', ''))
  730. new_failure_history = self.truncate_if_needed(new_description_pieces.get('failurehistory', ''))
  731. new_stack_trace = self.truncate_if_needed(new_description_pieces.get('stacktrace', ''))
  732. new_other = self.truncate_if_needed(new_description_pieces.get('other', ''))
  733. new_since_build = self.truncate_if_needed(new_description_pieces.get('sincebuild', ''))
  734. logging.debug(' old_failing_test: \n %s' % str(old_failing_test.replace('\r', 'CR').replace('\n', 'LF\n')) )
  735. logging.debug(' old_failure_history:\n %s' % str(old_failure_history.replace('\r', 'CR').replace('\n', 'LF\n')) )
  736. logging.debug(' old_stack_trace: \n %s' % str(old_stack_trace.replace('\r', 'CR').replace('\n', 'LF\n')) )
  737. logging.debug(' old_other: \n %s' % str(old_other.replace('\r', 'CR').replace('\n', 'LF\n')) )
  738. logging.debug(' old_since_build: \n %s' % str(old_since_build.replace('\r', 'CR').replace('\n', 'LF\n')) )
  739. logging.debug(' new_failing_test: \n %s' % str(new_failing_test.replace('\r', 'CR').replace('\n', 'LF\n')) )
  740. logging.debug(' new_failure_history:\n %s' % str(new_failure_history.replace('\r', 'CR').replace('\n', 'LF\n')) )
  741. logging.debug(' new_stack_trace: \n %s' % str(new_stack_trace.replace('\r', 'CR').replace('\n', 'LF\n')) )
  742. logging.debug(' new_other: \n %s' % str(new_other.replace('\r', 'CR').replace('\n', 'LF\n')) )
  743. logging.debug(' new_since_build: \n %s' % str(new_since_build.replace('\r', 'CR').replace('\n', 'LF\n')) )
  744. # Combine the various pieces of the old and new descriptions, in the
  745. # 'proper' order (regardless of how they used to be); ignore carriage
  746. # returns, which Jira tends to add, when checking whether new
  747. # description pieces are already found in the (old) description
  748. new_description += old_failing_test
  749. info_messages = []
  750. if new_failing_test.strip().replace('\r', '') not in new_description.replace('\r', ''):
  751. new_description += new_failing_test
  752. info_messages.append('failing test')
  753. new_description += SEPARATOR_LINE + old_failure_history
  754. if new_failure_history.strip().replace('\r', '') not in new_description.replace('\r', ''):
  755. new_description += new_failure_history
  756. info_messages.append('failure history')
  757. new_description += old_stack_trace
  758. # Do not add new Stack Traces that are identical except for
  759. # the line numbers (or other digits)
  760. if ( sub('\d', 'x', new_stack_trace.replace('\r', '').strip()) not in
  761. sub('\d', 'x', new_description.replace('\r', '')) ):
  762. new_description += new_stack_trace
  763. info_messages.append('stack trace')
  764. if old_other or new_other:
  765. new_description += SEPARATOR_LINE + old_other
  766. if new_other.strip().replace('\r', '') not in new_description.replace('\r', ''):
  767. new_description += new_other
  768. info_messages.append('other description')
  769. new_description += SEPARATOR_LINE + old_since_build
  770. if new_since_build.strip().replace('\r', '') not in new_description.replace('\r', ''):
  771. new_description = self.combine_since_build_messages(new_description, new_since_build,
  772. info_messages, jenkins_job_name,
  773. build_type)
  774. # Make sure there are not too many new line (line feed) characters in a row
  775. for i in range(10):
  776. if '\n\n\n\n\n\n' in new_description.replace('\r', ''):
  777. new_description = new_description.replace('\r', '').replace('\n\n\n\n\n\n', '\n\n\n')
  778. else:
  779. break
  780. logging.debug(' new_description:\n %s' % str(new_description))
  781. if info_messages and old_description:
  782. logging.info('Description of ticket %s modified, including: %s'
  783. % (issue_key, '; '.join(info_messages)) )
  784. return self.truncate_if_needed(new_description, False, '\n[Truncated]',
  785. MAX_NUM_CHARS_PER_JIRA_DESCRIPTION)
  786. def get_modified_labels(self, old_labels, new_labels, description,
  787. jenkins_job_name=None):
  788. """TODO
  789. """
  790. modified_labels = []
  791. modified_labels.extend(old_labels)
  792. for label in new_labels:
  793. if label not in modified_labels:
  794. modified_labels.append(label)
  795. # A Jira ticket should not normally be labeled as both Consistent
  796. # and Intermittent
  797. if (label == JIRA_LABEL_FOR_CONSISTENT_FAILURES
  798. and JIRA_LABEL_FOR_INTERMITTENT_FAILURES in modified_labels):
  799. modified_labels.remove(JIRA_LABEL_FOR_INTERMITTENT_FAILURES)
  800. elif (label == JIRA_LABEL_FOR_INTERMITTENT_FAILURES
  801. and JIRA_LABEL_FOR_CONSISTENT_FAILURES in modified_labels):
  802. modified_labels.remove(JIRA_LABEL_FOR_CONSISTENT_FAILURES)
  803. if jenkins_job_name:
  804. jenkins_job_label = JENKINS_JOBS.get(jenkins_job_name, {}).get('label')
  805. if jenkins_job_label and jenkins_job_label not in modified_labels:
  806. modified_labels.append(jenkins_job_label)
  807. logging.debug('In get_modified_labels:')
  808. logging.debug(' old_labels:\n %s' % str(old_labels))
  809. logging.debug(' new_labels:\n %s' % str(new_labels))
  810. logging.debug(' modified_labels:\n %s' % str(modified_labels))
  811. return modified_labels
  812. def get_short_test_name(self, testName):
  813. """Given a testName, returns a (possibly) shorter test name, that omits
  814. any suffix starting with '_localCluster'. For example, the following
  815. test names:
  816. TestSqlUpdateSuite.testUpdate_localCluster_1_1_JNI
  817. TestSqlUpdateSuite.testUpdate_localCluster_2_3_JNI
  818. TestSqlUpdateSuite.testUpdate_localCluster_1_1_VALGRIND_IPC
  819. are actually failures of the same test, so just one Jira ticket should
  820. be filed, not three.
  821. """
  822. result = testName.translate(TT)
  823. localCluster_index = result.find('_localCluster')
  824. if localCluster_index > 0:
  825. result = result[:localCluster_index]
  826. return result
  827. def get_summary_keys(self, className, testName):
  828. """Given a className and a testName, returns a list containing the
  829. 'keys' to be searched for in a Jira summary. Normally, these keys
  830. consist simply of two items, the className and the (possibly
  831. shortened) testName; however, if either one contains certain
  832. characters that cause problems for Jira searches (e.g. '.' or '_'),
  833. then the keys will be split up to include certain substrings on
  834. either side of those characters.
  835. """
  836. summary_keys = []
  837. # Shorten the testName to omit any suffix beginning with '_localCluster'
  838. for name in [className, self.get_short_test_name(testName)]:
  839. if all(char not in name for char in JIRA_SEARCH_PROBLEMATIC_CHARACTERS):
  840. summary_keys.append(name)
  841. continue
  842. # Handle any underscore ('_') characters: use only the substrings
  843. # before the first and after the last underscore
  844. first_underscore_index = name.find('_')
  845. last_underscore_index = name.rfind('_')
  846. if first_underscore_index < 0 or last_underscore_index < 0:
  847. pieces = [name]
  848. else:
  849. pieces = [name[:first_underscore_index], name[last_underscore_index+1:]]
  850. # Handle any dot ('.') characters: use each substring before and
  851. # after any dots
  852. for piece in pieces:
  853. indexes = [i for i, char in enumerate(piece) if char == '.']
  854. indexes.append(len(piece))
  855. previous_index = -1
  856. for index in indexes:
  857. summary_keys.append(piece[previous_index+1:index])
  858. return summary_keys
  859. def file_jira_issue(self, issue, DRY_RUN=False, failing_consistently=False):
  860. global JENKINSBOT
  861. if not JENKINSBOT:
  862. JENKINSBOT = JenkinsBot()
  863. error_url = issue['url']
  864. error_report = self.read_url(error_url + '/api/python')
  865. if error_report is None:
  866. return None
  867. fullTestName = issue['packageName']+'.'+issue['className']+'.'+issue['testName']
  868. summary_keys = [issue['className'], issue['testName']]
  869. # summary_keys = self.get_summary_keys(issue['className'], issue['testName'])
  870. channel = issue['channel']
  871. labels = issue['labels']
  872. priority = issue['priority']
  873. build_number = issue['build']
  874. jenkins_job = issue['job']
  875. jenkins_job_nickname = JENKINS_JOBS.get(jenkins_job, {}).get('nickname', jenkins_job)
  876. existing_ticket = issue['existing_ticket']
  877. logging.debug('In file_jira_issue:')
  878. logging.debug(' issue : '+str(issue))
  879. logging.debug(' fullTestName : '