PageRenderTime 27ms CodeModel.GetById 43ms RepoModel.GetById 0ms app.codeStats 1ms

/tools/jenkins/jenkinsbot.py

https://github.com/VoltDB/voltdb
Python | 1206 lines | 1180 code | 13 blank | 13 comment | 13 complexity | 50d53db4d3e96338f14d990be0705e50 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. # Script that runs jenkinsbot for VoltDB Slack
  5. import logging
  6. import os
  7. import sys
  8. import thread
  9. from threading import Thread
  10. import mysql.connector
  11. from jira import JIRA
  12. from logging import handlers
  13. from mysql.connector.errors import Error as MySQLError
  14. from slackclient import SlackClient
  15. from tabulate import tabulate # Used for pretty printing tables
  16. from urllib import urlretrieve
  17. # Get job names from environment variables
  18. COMMUNITY = os.environ.get('community', None)
  19. PRO = os.environ.get('pro', None)
  20. VDM = os.environ.get('vdm', None)
  21. MEMVALDEBUG = os.environ.get('memvaldebug', None)
  22. DEBUG = os.environ.get('debug', None)
  23. MEMVAL = os.environ.get('memval', None)
  24. FULLMEMCHECK = os.environ.get('fullmemcheck', None)
  25. MASTER_JOBS = [PRO, COMMUNITY, VDM]
  26. CORE_JOBS = [MEMVALDEBUG, DEBUG, MEMVAL, FULLMEMCHECK]
  27. # Channel to message in case something goes wrong
  28. ADMIN_CHANNEL = os.environ.get('admin', None)
  29. # Other channels
  30. GENERAL_CHANNEL = os.environ.get('general', None)
  31. RANDOM_CHANNEL = os.environ.get('random', None)
  32. JUNIT = os.environ.get('junit', None)
  33. # Jira credentials and info
  34. JIRA_USER = os.environ.get('jirauser', None)
  35. JIRA_PASS = os.environ.get('jirapass', None)
  36. # TODO: change this back to 'ENG', before merging to master
  37. JIRA_PROJECT = os.environ.get('jiraproject', 'ENG')
  38. # Queries
  39. # System Leaderboard - Leaderboard for master system tests on apprunner.
  40. SL_QUERY = ("""
  41. SELECT job_name AS 'Job name',
  42. workload AS 'Workload',
  43. fails AS 'Fails',
  44. total AS 'Total',
  45. fails/total*100. AS 'Fail %',
  46. latest AS 'Latest'
  47. FROM
  48. (
  49. SELECT job_name,
  50. workload,
  51. COUNT(*) AS fails,
  52. (
  53. SELECT COUNT(*)
  54. FROM `apprunnerfailures` AS run
  55. WHERE NOW() - INTERVAL 30 DAY <= run.datetime AND
  56. run.job_name=failure.job_name AND
  57. run.workload=failure.workload AND
  58. run.branch_name=failure.branch_name
  59. ) AS total,
  60. MAX(failure.datetime) AS latest
  61. FROM `apprunnerfailures` AS failure
  62. WHERE NOW() - INTERVAL 30 DAY <= datetime AND
  63. result='FAIL' AND
  64. failure.branch_name='master'
  65. GROUP BY job_name,
  66. workload
  67. ) AS intermediate
  68. GROUP BY 6 DESC
  69. """)
  70. # Tests since leaderboard - See all the tests that have been failing the most since a certain build and view them as
  71. # most frequently occurring.
  72. TS_QUERY = ("""
  73. SELECT tf.name AS 'Test name',
  74. COUNT(*) AS 'Failures'
  75. FROM `junit-test-failures` AS tf
  76. WHERE NOT tf.status='FIXED' AND
  77. tf.build >= %(beginning)s AND
  78. tf.job=%(job)s
  79. GROUP BY tf.name
  80. ORDER BY 2 DESC
  81. """)
  82. # Days leaderboard - See the tests that have been failing the most in the past certain amount of days and view them as
  83. # most frequently occurring.
  84. DL_QUERY = ("""
  85. SELECT tf.name AS 'Test name',
  86. COUNT(*) AS 'Failures'
  87. FROM `junit-test-failures` AS tf
  88. WHERE NOT tf.status='FIXED' AND
  89. NOW() - INTERVAL %(days)s DAY <= tf.stamp AND
  90. tf.job=%(job)s
  91. GROUP BY tf.name
  92. ORDER BY 2 DESC
  93. """)
  94. # Build range leaderboard - See the tests that have been failing the most in a range of builds and view them as most
  95. # frequently occurring.
  96. BR_QUERY = ("""
  97. SELECT tf.name AS 'Test name',
  98. COUNT(*) AS 'Number of failures in this build range'
  99. FROM `junit-test-failures` AS tf
  100. INNER JOIN `junit-builds` AS jb
  101. ON NOT tf.status='FIXED' AND
  102. jb.name=tf.job AND
  103. jb.build=tf.build AND
  104. jb.name=%(job)s AND
  105. %(build_low)s <= jb.build AND
  106. jb.build <= %(build_high)s
  107. GROUP BY tf.name,
  108. tf.job
  109. ORDER BY 2 DESC
  110. """)
  111. # All failures leaderboard - See all failures for a job over time and view them as most recent.
  112. AF_QUERY = ("""
  113. SELECT tf.name AS 'Test name',
  114. tf.build AS 'Build',
  115. tf.stamp AS 'Time'
  116. FROM `junit-test-failures` AS tf
  117. WHERE NOT STATUS='FIXED' AND
  118. tf.job=%(job)s
  119. ORDER BY 2 DESC
  120. """)
  121. # Latest build - See the latest build for a job in the database.
  122. LB_QUERY = ("""
  123. SELECT name AS 'Job name',
  124. stamp AS 'Latest run',
  125. url AS 'Build url',
  126. build AS 'Build number'
  127. FROM `junit-builds`
  128. WHERE stamp = (
  129. SELECT MAX(jb.stamp)
  130. FROM `junit-builds` AS jb
  131. WHERE jb.name=%(job)s
  132. ) AND
  133. name=%(job)s
  134. """)
  135. # Recent failure - See if test is failing and how recently.
  136. RF_QUERY = ("""
  137. SELECT MAX(tf.build) AS 'Most recent failure',
  138. MAX(jb.build) AS 'Most recent build of job'
  139. FROM `junit-test-failures` AS tf
  140. INNER JOIN `junit-builds` AS jb
  141. ON NOT tf.status='FIXED' AND
  142. jb.name=tf.job AND
  143. tf.name=%(test)s AND
  144. jb.name=%(job)s
  145. """)
  146. # Recent status - See the most recent status for a test.
  147. RS_QUERY = ("""
  148. SELECT name AS 'Test name',
  149. status AS 'Latest status',
  150. stamp AS 'Latest run',
  151. build AS 'Latest build'
  152. FROM `junit-test-failures`
  153. WHERE stamp = (
  154. SELECT MAX(tf.stamp)
  155. FROM `junit-test-failures` AS tf
  156. WHERE tf.name=%(test)s AND
  157. tf.job=%(job)s
  158. ) AND
  159. name=%(test)s AND
  160. job=%(job)s
  161. """)
  162. # Add alias - Add an alias for the user.
  163. AA_QUERY = ("""
  164. INSERT INTO `jenkinsbot-user-aliases`
  165. (slack_user_id, command, alias)
  166. VALUES (%(slack_user_id)s, %(command)s, %(alias)s)
  167. """)
  168. # Remove alias - Remove an alias for the user.
  169. RA_QUERY = ("""
  170. DELETE FROM `jenkinsbot-user-aliases`
  171. WHERE slack_user_id=%(slack_user_id)s AND
  172. alias=%(alias)s
  173. """)
  174. # Get alias - Get the command for an alias for the user.
  175. GA_QUERY = ("""
  176. SELECT command
  177. FROM `jenkinsbot-user-aliases`
  178. WHERE alias=%(alias)s AND
  179. slack_user_id=%(slack_user_id)s
  180. """)
  181. # See aliases - Get all aliases for the user.
  182. SA_QUERY = ("""
  183. SELECT alias,
  184. command
  185. FROM `jenkinsbot-user-aliases`
  186. WHERE slack_user_id=%(slack_user_id)s
  187. """)
  188. class WorkerThread(Thread):
  189. def __init__(self, incoming_data, jenkins_bot):
  190. Thread.__init__(self)
  191. self.incoming = incoming_data
  192. self.jenkins_bot = jenkins_bot
  193. def run(self):
  194. jenkins_bot = self.jenkins_bot
  195. try:
  196. if not self.can_reply():
  197. return
  198. text = self.incoming.get('text', None)
  199. channel = self.incoming.get('channel', None)
  200. user = self.incoming.get('user', None)
  201. if 'shut-down' in text:
  202. # Keyword command for shutting down jenkinsbot. Script has to be run again with proper
  203. # environment variables to turn jenkinsbot on.
  204. jenkins_bot.post_message(channel, 'Shutting down..')
  205. thread.interrupt_main()
  206. elif 'help' in text:
  207. jenkins_bot.post_message(channel, jenkins_bot.help_text)
  208. else:
  209. query = None
  210. params = None
  211. filename = None
  212. try:
  213. # TODO get insert option from parse_text rather than inferring it
  214. (query, params, filename) = jenkins_bot.parse_text(text, channel, user)
  215. except IndexError:
  216. jenkins_bot.logger.exception('Incorrect number or formatting of arguments')
  217. if channel:
  218. jenkins_bot.post_message(channel, 'Incorrect number or formatting of arguments\n\n' +
  219. jenkins_bot.help_text)
  220. if query and params and filename:
  221. jenkins_bot.post_message(channel, 'Please wait..')
  222. jenkins_bot.query_and_response(query, params, [channel], filename)
  223. elif query and params:
  224. jenkins_bot.query([channel], query, params, insert=True)
  225. except KeyboardInterrupt:
  226. # Propagate the keyboard interrupt from the worker thread that received the shut-down command
  227. raise
  228. except:
  229. jenkins_bot.logger.exception('Something unexpected went wrong')
  230. # Try to reconnect
  231. if not jenkins_bot.connect_to_slack():
  232. jenkins_bot.logger.info('Could not connect to Slack')
  233. jenkins_bot.post_message(ADMIN_CHANNEL, 'Cannot connect to Slack')
  234. def can_reply(self):
  235. """
  236. :return: true if bot can act on incoming data - There is incoming data, it is text data, it's not from a bot,
  237. the text isn't in a file, the channel isn't in #general, #random, #junit which jenkinsbot is part of
  238. """
  239. # TODO rather than check all channels, just check if this is an direct message
  240. return (self.incoming.get('text', None) is not None and self.incoming.get('bot_id', None) is None
  241. and self.incoming.get('file', None) is None and self.incoming['channel'] != GENERAL_CHANNEL
  242. and self.incoming['channel'] != RANDOM_CHANNEL and self.incoming['channel'] != JUNIT)
  243. class JenkinsBot(object):
  244. def __init__(self):
  245. self.client = None
  246. self.help_text = '\n'.join(
  247. ['*Instructions:*',
  248. 'Alias a command:\n\tmy alias = valid command',
  249. 'Remove an alias:\n\t`unalias` my alias',
  250. 'See your aliases:\n\t`aliases`',
  251. 'See which tests are failing the most since this build:\n\t`tests-since` <job> <build #>',
  252. 'See which tests are failing in the past x days:\n\t`days` <job> <days>',
  253. 'Failing the most in this build range:\n\t`build-range` <job> <build #>-<build #>',
  254. 'Most recent failure of a non-passing test:\n\t`recent-failure` <job> <testname>'
  255. ' (ex. testname: org.voltdb.iv2..)',
  256. 'Most recent status of a non-passing test:\n\t`recent-status` <job> <testname>',
  257. 'All failures for a job:\n\t`all-failures` <job>',
  258. 'Display this help:\n\t`help`',
  259. 'For <job>, you can specify *pro* or *com* for the respective junit master jobs',
  260. 'Examples: `tests-since pro 860`, `days com 14`, now = `days pro 1`']
  261. )
  262. self.logger = self.setup_logging()
  263. def setup_logging(self):
  264. logger = logging.getLogger(__name__)
  265. logger.setLevel(logging.DEBUG)
  266. # Limit log file to 1GB, no rotation
  267. handler = handlers.RotatingFileHandler('jenkinsbot.log', maxBytes=1 << 30)
  268. handler.setLevel(logging.DEBUG)
  269. formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
  270. handler.setFormatter(formatter)
  271. logger.addHandler(handler)
  272. return logger
  273. def connect_to_slack(self):
  274. """
  275. :return: True if token for bot exists, client was created, and bot connected to Real Time Messaging
  276. """
  277. token = os.environ.get('token', None)
  278. if token is None:
  279. self.logger.info('Could not retrieve token for jenkinsbot')
  280. return False
  281. self.client = SlackClient(token)
  282. # This might only be required for listen()
  283. # Connect to real time messaging
  284. if not self.client.rtm_connect():
  285. self.logger.info('Could not connect to real time messaging (Slack).')
  286. return False
  287. return True
  288. def listen(self):
  289. """
  290. Establishes session and responds to commands
  291. """
  292. while True:
  293. try:
  294. incoming = []
  295. try:
  296. incoming = list(self.client.rtm_read())
  297. except KeyboardInterrupt:
  298. # Propagate the keyboard interrupt from the worker thread that received the shut-down command
  299. raise
  300. except:
  301. self.logger.exception('Could not read from Real Time Messaging. Connection may have been closed')
  302. # Try to reconnect
  303. if not self.connect_to_slack():
  304. self.logger.info('Could not connect to Slack')
  305. self.post_message(ADMIN_CHANNEL, 'Cannot connect to Slack')
  306. if len(incoming) == 0:
  307. continue
  308. workers = []
  309. for data in incoming:
  310. workers.append(WorkerThread(data, self))
  311. for worker in workers:
  312. worker.start()
  313. except KeyboardInterrupt:
  314. # Raised by a worker thread that calls thread.interrupt_main()
  315. self.logger.info('Shutting down due to "shut-down" command or Control C')
  316. self.post_message(ADMIN_CHANNEL, 'Shutting down due to `shut-down` command or Control C')
  317. return
  318. def parse_text(self, text, channel, user):
  319. """
  320. Parses the text in valid incoming data to determine what command it is. Could raise an IndexError
  321. :param text: The text
  322. :param channel: The channel this text is coming from
  323. :param user: The user who wrote the text
  324. :return: The query, parameters, and filename derived from the text
  325. """
  326. # TODO replace with argparse or something similar
  327. query = ''
  328. params = {}
  329. filename = ''
  330. # Check to see if text is an alias
  331. alias_params = {
  332. 'alias': text.strip(),
  333. 'slack_user_id': user
  334. }
  335. table = self.query([channel], GA_QUERY, alias_params)
  336. if len(table) == 2:
  337. rows = table[1]
  338. if len(rows) > 0:
  339. command = rows[0]
  340. text = command[0]
  341. # Check to see setting alias or getting aliases
  342. if '=' in text:
  343. args = text.split('=')
  344. if len(args) != 2:
  345. self.post_message(channel, 'Couldn\'t parse alias.')
  346. return query, params, filename
  347. alias = args[0]
  348. command = args[1]
  349. query = AA_QUERY
  350. params = {
  351. 'slack_user_id': user,
  352. 'command': command.strip(),
  353. 'alias': alias.strip()
  354. }
  355. return query, params, filename
  356. elif 'unalias' in text:
  357. args = text.split(' ')
  358. if len(args) != 2:
  359. return query, params, filename
  360. alias = args[1]
  361. query = RA_QUERY
  362. params = {
  363. 'slack_user_id': user,
  364. 'alias': alias.strip()
  365. }
  366. return query, params, filename
  367. elif 'aliases' in text:
  368. query = SA_QUERY
  369. params = {
  370. 'slack_user_id': user
  371. }
  372. filename = 'aliases.txt'
  373. return query, params, filename
  374. # Check to see if dealing with normal command
  375. args = text.split(' ')
  376. if 'pro' in text:
  377. job = PRO
  378. elif 'com' in text:
  379. job = COMMUNITY
  380. else:
  381. if len(args) > 1:
  382. job = args[1]
  383. else:
  384. job = args[0]
  385. if 'recent-failure' in text:
  386. query = RF_QUERY
  387. params = {
  388. 'job': job,
  389. 'test': args[2],
  390. }
  391. filename = '%s-recent-failure.txt' % (args[2])
  392. elif 'recent-status' in text:
  393. query = RS_QUERY
  394. params = {
  395. 'job': job,
  396. 'test': args[2],
  397. }
  398. filename = '%s-recent-status.txt' % (args[2])
  399. elif 'all-failures' in text:
  400. query = AF_QUERY
  401. params = {
  402. 'job': job
  403. }
  404. filename = '%s-allfailures.txt' % job
  405. elif 'days' in text:
  406. query = DL_QUERY
  407. params = {
  408. 'job': job,
  409. 'days': args[2]
  410. }
  411. filename = '%s-leaderboard-past-%s-days.txt' % (job, args[2])
  412. elif 'tests-since' in text:
  413. query = TS_QUERY
  414. params = {
  415. 'job': job,
  416. 'beginning': args[2]
  417. }
  418. filename = '%s-testssince-%s.txt' % (job, args[2])
  419. elif 'build-range' in text:
  420. builds = args[2].split('-')
  421. query = BR_QUERY
  422. params = {
  423. 'job': job,
  424. 'build_low': builds[0],
  425. 'build_high': builds[1]
  426. }
  427. filename = '%s-buildrange-%s-to-%s.txt' % (job, builds[0], builds[1])
  428. else:
  429. self.post_message(channel, 'Couldn\'t parse: `' + text + '`\nType `help` to see command usage')
  430. return query, params, filename
  431. def query(self, channels, query, params, is_retry=False, insert=False):
  432. """
  433. Make a query and return a table
  434. :param channels: Channels this query is for
  435. :param query: Query to execute
  436. :param params: Parameters for the query
  437. :param is_retry: If this call of the query is a retry. Will not attempt to retry after calling.
  438. :param insert: This query is an insert so the data needs to be committed
  439. :return: Tuple of (headers, rows) as results
  440. """
  441. table = ()
  442. cursor = None
  443. database = None
  444. try:
  445. database = mysql.connector.connect(host=os.environ.get('dbhost', None),
  446. user=os.environ.get('dbuser', None),
  447. password=os.environ.get('dbpass', None),
  448. database=os.environ.get('dbdb', None))
  449. cursor = database.cursor()
  450. cursor.execute(query, params)
  451. if insert:
  452. database.commit()
  453. else:
  454. headers = list(cursor.column_names) # List of strings
  455. rows = cursor.fetchall() # List of tuples
  456. table = (headers, rows)
  457. except MySQLError:
  458. self.logger.exception('Either could not connect to database or execution error')
  459. for channel in channels:
  460. self.post_message(channel, 'Something went wrong with getting database information.')
  461. if query == AA_QUERY:
  462. self.post_message(channel, 'Are you sure this alias isn\'t defined? Type `aliases` to see aliases.')
  463. except:
  464. self.logger.exception('Something unexpected went wrong')
  465. # Try to reconnect only once
  466. if self.connect_to_slack() and not is_retry:
  467. table = self.query(query, params, True, insert=insert)
  468. finally:
  469. if cursor is not None:
  470. cursor.close()
  471. if database is not None:
  472. database.close()
  473. return table
  474. def response(self, tables, channels, filename, vertical=False, edit=False, log=""):
  475. """
  476. Respond to a file to a channel
  477. :param tables: List of (header, rows) tuples i.e. tables to construct leaderboards from
  478. :param channels: The channels to post this file to
  479. :param filename: The filename to respond with
  480. :param vertical: Whether a vertical version of the table should be included
  481. :param edit: Whether the row entries should be edited
  482. :param log: Message prepended to the response
  483. """
  484. filecontent = ""
  485. for i, table in enumerate(tables):
  486. if len(table) != 2:
  487. continue
  488. headers = table[0]
  489. rows = table[1]
  490. content = ""
  491. if vertical:
  492. # If this is set generate a vertical leaderboard. Append to end of normal leaderboard.
  493. content = '\n\n*Vertical Leaderboard*:\n\n' + self.vertical_leaderboard(rows, headers)
  494. if edit:
  495. # Generate ordinal numbers (1st, 2nd, 3rd..)
  496. n = i + 1
  497. suffix = {1: "st", 2: "nd", 3: "rd"}.get(n if (n < 20) else (n % 10), 'th')
  498. log += '\n*Names in %d%s table might be shortened to fit on screen*' % (n, suffix)
  499. # Do some specific edits.
  500. self.edit_rows(rows)
  501. # Prepend leaderboard which might have edited rows.
  502. content = tabulate(rows, headers) + content
  503. filecontent = filecontent + content
  504. filecontent = log + '\n\n' + filecontent
  505. self.client.api_call(
  506. 'files.upload', channels=channels, content=filecontent, filetype='text', filename=filename
  507. )
  508. def generate_html(self, tables, filename, message=''):
  509. with open(filename, 'r+') as html_file:
  510. table_html = """
  511. <style style="text/css">
  512. table {
  513. border-collapse: collapse;
  514. width: 100%;
  515. font-family: verdana,arial,sans-serif;
  516. }
  517. th, td {
  518. padding: 8px;
  519. border-bottom: 1px solid #ddd;
  520. }
  521. tr:hover{
  522. background-color:#f5f5f5
  523. }
  524. </style>
  525. """
  526. for table in tables:
  527. if len(table) != 2:
  528. continue
  529. headers = table[0]
  530. rows = table[1]
  531. table_html += tabulate(rows, headers, tablefmt='html')
  532. html_file.write(table_html)
  533. if message:
  534. self.post_message(JUNIT, message)
  535. def vertical_leaderboard(self, rows, headers):
  536. """
  537. Displays each row in the table as one over the other. Similar to mysql's '\G'
  538. :param headers: Column names for the table
  539. :param rows: List of tuples representing the rows
  540. :return: A string representing the table vertically
  541. """
  542. # TODO (Femi) encapsulate in Leaderboard class
  543. table = ''
  544. for i, row in enumerate(rows):
  545. rows[i] = list(row)
  546. table += '%d\n' % (i + 1)
  547. for j, entry in enumerate(row):
  548. table += headers[j] + ': ' + str(entry) + '\n'
  549. table += '\n\n'
  550. return table
  551. def edit_rows(self, rows):
  552. """
  553. Edit the rows to fit on most screens.
  554. :param rows: Rows to edit. This method is specific to leaderboards.
  555. """
  556. # TODO (Femi) encapsulate in Leaderboard class
  557. for i, row in enumerate(rows):
  558. rows[i] = list(row)
  559. rows[i][0] = rows[i][0].replace('branch-2-', '')
  560. rows[i][0] = rows[i][0].replace('test-', '')
  561. rows[i][0] = rows[i][0].replace('nextrelease-', '')
  562. rows[i][1] = rows[i][1].replace('org.voltdb.', '')
  563. rows[i][1] = rows[i][1].replace('org.voltcore.', '')
  564. rows[i][1] = rows[i][1].replace('regressionsuites.', '')
  565. def leaderboard_query(self, jobs, days=30):
  566. """
  567. This constructs a completed leaderboard query which doesn't need parameters. Returns () for parameters
  568. :param jobs: The jobs to coalesce into a leaderboard
  569. :param days: Number of days to go back in query
  570. :return: A completed leaderboard query for the jobs and empty params
  571. """
  572. # TODO (Femi) encapsulate in Leaderboard class
  573. jobs_filter = map(lambda j: 'job="%s"' % j, jobs)
  574. job_params = ' OR '.join(jobs_filter)
  575. job_params = '(' + job_params + ')'
  576. # Leaderboard - See a leaderboard for jobs.
  577. junit_leaderboard_query = (
  578. """
  579. SELECT job AS 'Job name',
  580. name AS 'Test name',
  581. fails AS 'Fails',
  582. total AS 'Total',
  583. fails/total*100. AS 'Fail %',
  584. latest AS 'Latest failure'
  585. FROM
  586. (
  587. SELECT job,
  588. name,
  589. COUNT(*) AS fails,
  590. (
  591. SELECT COUNT(*)
  592. FROM `junit-builds` AS jb
  593. WHERE jb.name = tf.job AND
  594. NOW() - INTERVAL 30 DAY <= jb.stamp
  595. ) AS total,
  596. MAX(tf.stamp) AS latest
  597. FROM `junit-test-failures` AS tf
  598. WHERE NOT status='FIXED' AND
  599. """ + job_params + """ AND
  600. NOW() - INTERVAL """ + str(days) + """ DAY <= tf.stamp
  601. GROUP BY job,
  602. name,
  603. total
  604. ) AS intermediate
  605. ORDER BY 6 DESC
  606. """)
  607. return junit_leaderboard_query
  608. def post_message(self, channel, text):
  609. """
  610. Post a message on the channel.
  611. :param channel: Channel to post message to
  612. :param text: Text in message
  613. """
  614. self.client.api_call(
  615. 'chat.postMessage', channel=channel, text=text, as_user=True
  616. )
  617. def get_log(self, jobs):
  618. """
  619. Get a log of Jenkins jobs. Logs to file and also returns a log message. Adds a query for each job. For now
  620. only works for junit jobs.
  621. :param jobs: List of job names
  622. :return: Log message string
  623. """
  624. tables = []
  625. for job in jobs:
  626. tables.append(self.query([ADMIN_CHANNEL], LB_QUERY, {'job': job}))
  627. log_message = ['Status of jobs:']
  628. for table in tables:
  629. if len(table) != 2:
  630. continue
  631. headers = table[0]
  632. rows = table[1][0]
  633. log = []
  634. for field, value in zip(headers, rows):
  635. log.append('%s: %s' % (field, value))
  636. log_message.append(', '.join(log))
  637. log = '\n'.join(log_message)
  638. self.logger.info(log)
  639. return log
  640. def query_and_response(self, query, params, channels, filename, vertical=False, edit=False, jobs=None):
  641. """
  642. Perform a single query and response
  643. :param query: Query to run
  644. :param params: Parameters for query
  645. :param channels: Channels to respond to
  646. :param filename: Filename for the post, or the html file
  647. :param vertical: Whether a vertical version of the table should be included
  648. :param edit: Whether the row entries should be edited
  649. :param jobs: Generate status logs for these jobs
  650. """
  651. table = self.query(channels, query, params)
  652. log = ''
  653. if jobs is not None:
  654. log = self.get_log(jobs)
  655. #self.response([table], channels, filename, vertical, edit, log=log)
  656. else:
  657. self.response_html()
  658. def get_jira_interface(self, username=JIRA_USER, password=JIRA_PASS):
  659. """ TODO
  660. """
  661. if not (username and password):
  662. self.logger.error('Did not provide either a Jira username ('
  663. +username+') or a Jira password ('+password+').')
  664. try:
  665. jira_interface = JIRA(server='https://issues.voltdb.com/', basic_auth=(username, password), options=dict(verify=False))
  666. # jira_interface = JIRA(server='https://issues.voltdb.com/',
  667. # basic_auth=(username, password),
  668. # options=dict(verify=False))
  669. except Exception as e:
  670. self.logger.exception('Could not connect to Jira!!! Exception is:\n'+str(e))
  671. return None
  672. return jira_interface
  673. def find_jira_bug_tickets(self, summary_keys, labels,
  674. jira=None, user=JIRA_USER, passwd=JIRA_PASS,
  675. project=JIRA_PROJECT):
  676. """
  677. Finds one or more existing, open bug tickets in Jira.
  678. :param summary_keys: One or more substrings of the Summary, used to
  679. find a related, open Jira ticket; typically the first one is the Test Suite in which the failed
  680. test exists, and the second is the name of the test itself.
  681. :param labels: The Labels to list in the Jira ticket.
  682. :param jira: A JIRA access object, used to create a Jira ticket; if not
  683. specified, the user and passwd will be used to create one.
  684. :param user: The Jira Username used to access Jira.
  685. :param passwd: The Jira Password for that User.
  686. :param project: The Jira Project in which the Jira ticket should be created.
  687. """
  688. logging.debug('In find_jira_bug_tickets:')
  689. logging.debug(' summary_keys: '+str(summary_keys))
  690. logging.debug(' labels : '+str(labels))
  691. logging.debug(' jira : '+str(jira))
  692. logging.debug(' user : '+str(user))
  693. logging.debug(' passwd : '+str(passwd))
  694. logging.debug(' project : '+str(project))
  695. if not jira:
  696. jira = self.get_jira_interface(user, passwd)
  697. logging.debug(' jira : '+str(jira))
  698. labels_partial_query = ""
  699. if labels:
  700. labels_partial_query = "' AND labels = '" + "' AND labels = '".join(labels)
  701. summary_partial_query = " AND summary ~ '" + "' AND summary ~ '".join(summary_keys)
  702. full_jira_query = ("project = %s AND status != Closed"
  703. + summary_partial_query + labels_partial_query
  704. + "' ORDER BY key ASC"
  705. ) % str(project)
  706. tickets = []
  707. try:
  708. tickets = jira.search_issues(full_jira_query)
  709. except Exception as e:
  710. logging.exception('Jira ticket query failed with Exception:'
  711. '\n %s\n using Jira query:\n %s'
  712. % (str(e), full_jira_query) )
  713. logging.debug(' summary_partial_query: '+summary_partial_query)
  714. logging.debug(' full_jira_query:\n '+str(full_jira_query))
  715. logging.debug(' tickets : '+str(tickets))
  716. return tickets
  717. def find_jira_bug_ticket(self, summary_keys, labels,
  718. jira=None, user=JIRA_USER, passwd=JIRA_PASS,
  719. project=JIRA_PROJECT):
  720. """
  721. Finds (exactly) one existing, open bug ticket in Jira.
  722. :param summary_keys: One or more substrings of the Summary, used to
  723. find a related, open Jira ticket; typically the first one is the Test Suite in which the failed
  724. test exists, and the second is the name of the test itself.
  725. :param labels: The Labels to list in the Jira ticket.
  726. :param jira: A JIRA access object, used to create a Jira ticket; if not
  727. specified, the user and passwd will be used to create one.
  728. :param user: The Jira Username used to access Jira.
  729. :param passwd: The Jira Password for that User.
  730. :param project: The Jira Project in which the Jira ticket should be created.
  731. """
  732. logging.debug('In find_jira_bug_ticket:')
  733. logging.debug(' summary_keys: '+str(summary_keys))
  734. logging.debug(' labels : '+str(labels))
  735. logging.debug(' jira : '+str(jira))
  736. logging.debug(' user : '+str(user))
  737. logging.debug(' passwd : '+str(passwd))
  738. logging.debug(' project : '+str(project))
  739. ticket = None
  740. existing_tickets = self.find_jira_bug_tickets(summary_keys, labels,
  741. jira, user, passwd, project)
  742. if existing_tickets:
  743. if len(existing_tickets) > 1:
  744. logging.warn('More than 1 Jira ticket found; using first one listed below:\n'
  745. +str(existing_tickets))
  746. for et in existing_tickets:
  747. logging.warn(" %s: '%s'" % (et.key, et.fields.summary))
  748. ticket = existing_tickets[0]
  749. return ticket
  750. def add_attachments(self, jira, ticket_id, attachments):
  751. added_attachments = []
  752. for file in attachments:
  753. urlretrieve(attachments[file], file)
  754. a = jira.add_attachment(ticket_id, os.getcwd() + '/' + file, file)
  755. added_attachments.append(a)
  756. os.unlink(file)
  757. return added_attachments
  758. def enforce_max_num_attachments(self, jira, ticket, max_num_attachments=10):
  759. if len(ticket.fields.attachment) > max_num_attachments:
  760. attachment_ids = [a.id for a in ticket.fields.attachment]
  761. attachment_ids.sort(reverse=True)
  762. for i in range(max_num_attachments, len(attachment_ids)):
  763. jira.delete_attachment(attachment_ids[i])
  764. logging.info('Deleted, from ticket %s, attachment: %s'
  765. % (str(ticket.key), str(attachment_ids[i])) )
  766. def is_number(self, s):
  767. try:
  768. return float(s)
  769. except ValueError:
  770. return False
  771. def get_jira_component_list(self, jira, component='Core',
  772. project=JIRA_PROJECT):
  773. jira_component = component
  774. components = jira.project_components(project)
  775. for c in components:
  776. if c.name == component:
  777. jira_component = {
  778. 'name': c.name,
  779. 'id': c.id
  780. }
  781. break
  782. return [jira_component]
  783. def get_jira_version_list(self, jira, version='Autofiled',
  784. project=JIRA_PROJECT):
  785. jira_version = version
  786. if not version.startswith('V') and self.is_number(version):
  787. version = 'V' + version
  788. versions = jira.project_versions(project)
  789. for v in versions:
  790. if str(v.name) == version.strip():
  791. jira_version = {
  792. 'name': v.name,
  793. 'id': v.id
  794. }
  795. break
  796. return [jira_version]
  797. def create_jira_bug_ticket(self, channel, test_suite, summary,
  798. jenkins_job, build_number,
  799. description, version, labels,
  800. priority='Major', attachments={},
  801. jira=None, user=JIRA_USER, passwd=JIRA_PASS,
  802. project=JIRA_PROJECT, component='Core',
  803. DRY_RUN=False):
  804. """
  805. Creates a new bug ticket in Jira.
  806. :param channel: A slack channel to be notified.
  807. :param test_suite: The Test Suite in which the failed test exists;
  808. used to find other bug tickets in the same Test Suite, which
  809. are marked 'is related to'.
  810. :param summary: The Summary to be used in the Jira ticket that is to
  811. be created.
  812. :param description: The Description for the new Jira ticket.
  813. :param version: The (VoltDB) Version that this bug affects.
  814. :param labels: The Labels to list in the Jira ticket.
  815. :param priority: The Priority of the Jira ticket.
  816. :param attachments: Any Attachments for the Jira ticket.
  817. :param component: The Component to be used in the Jira ticket, i.e.,
  818. the Component affected by this bug.
  819. :param jira: A JIRA access object, used to create a Jira ticket; if not
  820. specified, the user and passwd will be used to create one.
  821. :param user: The Jira Username used to access Jira.
  822. :param passwd: The Jira Password for that User.
  823. :param project: The Jira Project in which the Jira ticket should be created.
  824. :param DRY_RUN: When set to True, no Jira ticket will be created.
  825. """
  826. logging.debug('In create_jira_bug_ticket:')
  827. logging.debug(' channel : '+str(channel))
  828. logging.debug(' test_suite : '+str(test_suite))
  829. logging.debug(' summary : '+str(summary))
  830. logging.debug(' description :\n'+str(description)+'\n')
  831. logging.debug(' version : '+str(version))
  832. logging.debug(' labels : '+str(labels))
  833. logging.debug(' priority : '+str(priority))
  834. logging.debug(' attachments : '+str(attachments))
  835. logging.debug(' component : '+str(component))
  836. logging.debug(' jira : '+str(jira))
  837. logging.debug(' project : '+str(project))
  838. logging.debug(' DRY_RUN : '+str(DRY_RUN))
  839. if not jira:
  840. jira = self.get_jira_interface(user, passwd)
  841. logging.debug(' jira : '+str(jira))
  842. issue_dict = {
  843. 'project': project,
  844. 'summary': summary,
  845. 'description': description,
  846. 'issuetype': {
  847. 'name': 'Bug'
  848. },
  849. 'labels': labels
  850. }
  851. issue_dict['components'] = self.get_jira_component_list(jira, component, project)
  852. issue_dict['versions'] = self.get_jira_version_list(jira, version, project)
  853. issue_dict['fixVersions'] = [{'name':'Autofiled'}]
  854. issue_dict['priority'] = {'name': priority}
  855. logging.debug("Filing ticket with summary:\n%s" % summary)
  856. logging.debug(' issue_dict :\n '+str(issue_dict))
  857. if DRY_RUN:
  858. new_issue = None
  859. # Kludge to prevent too many of these tickets:
  860. elif 'TestFixedSQLSuite' in summary:
  861. logging.warn("NOT creating the following ticket, with summary:"
  862. +"\n '%s'\nbecause it is for 'TestFixedSQLSuite':\n%s "
  863. % (summary, str(issue_dict)))
  864. else:
  865. try:
  866. new_issue = jira.create_issue(fields=issue_dict)
  867. except Exception as e:
  868. logging.exception("Jira ticket creation failed with Exception:"
  869. "\n %s\n using:\n %s"
  870. % (str(e), str(issue_dict)) )
  871. raise e
  872. # Add attachments to the Jira ticket
  873. with_attachments = ''
  874. if attachments:
  875. try:
  876. new_attachments = self.add_attachments(jira, new_issue.id, attachments)
  877. with_attachments = ', with attachment' + ' (ID ' + ', '.join(
  878. new_attachments[i].id for i in range(len(new_attachments)) ) + ')'
  879. except Exception as e:
  880. with_attachments = ', without specified attachment'
  881. logging.warn("Unable (in create_jira_bug_ticket) to add attachment(s):"
  882. "\n '%s'\n due to Exception:\n %s"
  883. % (str(attachments), str(e)) )
  884. # Add a comment to the Jira ticket; and log a message
  885. logging_message = ("Filed ticket %s (https://issues.voltdb.com/browse/%s)%s, "
  886. "with summary:\n '%s'"
  887. % (new_issue.key, new_issue.key,
  888. with_attachments, summary) )
  889. comment = ("Filed ticket due to %s, build #%s%s."
  890. % (jenkins_job, build_number, with_attachments) )
  891. jira.add_comment(new_issue.key, comment)
  892. logging.info(logging_message)
  893. # Post a message in the specified slack channel (if any)
  894. try:
  895. if channel and self.connect_to_slack():
  896. self.post_message(channel, logging_message)
  897. except Exception as e:
  898. logging.warn('Unable to connect to Slack!! (in create_jira_bug_ticket)')
  899. # Find all tickets within the same test suite and link them
  900. labels_partial_query = ""
  901. if labels:
  902. labels_partial_query = " AND labels = '" + "' AND labels = '".join(labels) + "'"
  903. full_jira_query = ("project = %s AND status != Closed AND summary ~ '%s'"
  904. + labels_partial_query
  905. ) % (str(project), str(test_suite))
  906. link_tickets = []
  907. try:
  908. link_tickets = jira.search_issues(full_jira_query)
  909. except TypeError as e:
  910. logging.warn('Caught TypeError('+str(e)+'), in create_jira_bug_ticket, using:'
  911. '\n labels_partial_query: '+str(labels_partial_query)+
  912. '\n test_suite : '+str(test_suite) )
  913. for ticket in link_tickets:
  914. if ticket.key != new_issue.key:
  915. jira.create_issue_link('Related', new_issue.key, ticket)
  916. logging.debug('Linked ticket: %s' % str(ticket))
  917. return new_issue
  918. def summary_differs_significantly(self, old_summary, new_summary):
  919. """Determines whether the old and new (Jira ticket) summaries are
  920. 'significantly' different: if they are identical, or if they are
  921. identical except for the failure percentage, returns False;
  922. otherwise, returns True.
  923. """
  924. if old_summary == new_summary:
  925. return False
  926. percent_sign = '%'
  927. if (percent_sign in old_summary and percent_sign in new_summary):
  928. old_index = old_summary.index(percent_sign)
  929. new_index = new_summary.index(percent_sign)
  930. if (old_summary[0:max(0,old_index-3)] == new_summary[0:max(0,new_index-3)]
  931. and old_summary[old_index:] == new_summary[new_index:]):
  932. return False
  933. return True
  934. def modify_jira_bug_ticket(self, channel, summary_keys, summary,
  935. jenkins_job, build_number,
  936. description, version, labels,
  937. priority='Major', attachments={}, ticket_to_modify=None,
  938. jira=None, user=JIRA_USER, passwd=JIRA_PASS,
  939. project=JIRA_PROJECT, component='Core',
  940. max_num_attachments=10, DRY_RUN=False):
  941. """
  942. Modifies an existing bug ticket in Jira.
  943. # TODO: finish this doc:
  944. :param channel: A slack channel to be notified
  945. :param summary_keys ????: One or more substrings of the Summary, used to
  946. determine whether a Jira ticket already exists for this issue
  947. :param summary: The Summary to be used in the Jira ticket that is to
  948. be created or modified
  949. :param jenkins_job ????: One or more substrings of the Summary, used to
  950. determine whether a Jira ticket already exists for this issue
  951. :param description: The Description for the modified Jira ticket.
  952. :param version ???: The (VoltDB) Version that this bug affects
  953. :param labels ??: The Labels to list in the Jira ticket
  954. :param priority: The Priority of the Jira ticket
  955. :param attachments: Any Attachments for the Jira ticket
  956. :param component ??: The Component to be used in the Jira ticket, i.e.,
  957. the Component affected by this bug
  958. :param jira: A JIRA access object, used to modify a Jira ticket; if not
  959. specified, the user and passwd will be used to create one.
  960. :param user: The Jira Username used to access Jira.
  961. :param passwd: The Jira Password for that User.
  962. :param project: The Jira Project in which the Jira ticket should be modified
  963. :param DRY_RUN: When set to True, no Jira ticket will be modified
  964. """
  965. logging.debug('In modify_jira_bug_ticket:')
  966. logging.debug(' channel : '+str(channel))
  967. logging.debug(' summary_keys: '+str(summary_keys))
  968. logging.debug(' summary : '+str(summary))
  969. logging.debug(' jenkins_job : '+str(jenkins_job))
  970. logging.debug(' build_number: '+str(build_number))
  971. logging.debug(' description : '+str(description))
  972. logging.debug(' version : '+str(version))
  973. logging.debug(' labels : '+str(labels))
  974. logging.debug(' priority : '+str(priority))
  975. logging.debug(' attachments : '+str(attachments))
  976. logging.debug(' component : '+str(component))
  977. logging.debug(' jira : '+str(jira))
  978. logging.debug(' project : '+str(project))
  979. logging.debug(' max_num_attachments: '+str(max_num_attachments))
  980. logging.debug(' DRY_RUN : '+str(DRY_RUN))
  981. if not jira:
  982. jira = self.get_jira_interface(user, passwd)
  983. logging.debug(' jira : '+str(jira))
  984. if not ticket_to_modify:
  985. ticket_to_modify = self.find_jira_bug_ticket(summary_keys, labels,
  986. jira, user, passwd, project)
  987. logging.debug(' ticket_to_modify: '+str(ticket_to_modify))
  988. if ticket_to_modify and not DRY_RUN:
  989. # Update the Jira ticket's summary, description, etc.
  990. previous_summary = ticket_to_modify.fields.summary
  991. old_description = ticket_to_modify.fields.description
  992. previous_priority = ticket_to_modify.fields.priority
  993. # If ticket has been marked as a "Blocker" (presumably manually),
  994. # then do not downgrade it
  995. if previous_priority == 'Blocker':
  996. priority = previous_priority
  997. # Try to update the Jira ticket without email notification; but if
  998. # that fails (as seems to happen fairly often, but unpredictably),
  999. # update it with email notification (which is the default)
  1000. exception = None
  1001. exception_count = 0
  1002. with_attachments = ''
  1003. for notification in [False, True]:
  1004. try:
  1005. if notification:
  1006. with_attachments = ' (notify=True)'
  1007. ticket_to_modify.update(fields={'summary' : summary,
  1008. 'description': description,
  1009. 'labels' : labels,
  1010. 'priority' : {'name': priority}
  1011. }
  1012. )
  1013. else:
  1014. with_attachments = ' (notify=False)'
  1015. ticket_to_modify.update(notify=False,
  1016. fields={'summary' : summary,
  1017. 'description': description,
  1018. 'labels' : labels,
  1019. 'priority' : {'name': priority}
  1020. },
  1021. )
  1022. break
  1023. except Exception as e:
  1024. exception = e
  1025. exception_count += 1
  1026. logging.warn("Jira ticket update (notify=%s) failed with Exception:"
  1027. "\n %s"
  1028. "\n for Jira ticket %s, using:"
  1029. "\n version '%s', priority '%s', labels %s;"
  1030. "\n old and new summaries:"
  1031. "\n '%s'"
  1032. "\n '%s'"
  1033. "\n old description:"
  1034. "\n %s"
  1035. "\n new (updated) description:"
  1036. "\n %s\n"
  1037. % (str(notification), str(e),
  1038. str(ticket_to_modify.key),
  1039. version, priority, str(labels),
  1040. previous_summary, summary,
  1041. old_description, description) )
  1042. # If an exception was thrown for both values of 'notification',
  1043. # throw the latter exception
  1044. if exception_count > 1:
  1045. raise exception
  1046. # Add attachments to the Jira ticket
  1047. if attachments:
  1048. try:
  1049. new_attachments = self.add_attachments(jira, ticket_to_modify.id, attachments)
  1050. with_attachments += ', with attachment' + ' (ID ' + ', '.join(
  1051. new_attachments[i].id for i in range(len(new_attachments)) ) + ')'
  1052. except Exception as e:
  1053. with_attachments += ', without specified attachment'
  1054. logging.warn("Unable (in modify_jira_bug_ticket) to add attachment(s):"
  1055. "\n '%s'\n due to Exception:\n %s"
  1056. % (str(attachments), str(e)) )
  1057. try:
  1058. self.enforce_max_num_attachments(jira, ticket_to_modify,
  1059. max_num_attachments - len(new_attachments) )
  1060. except Exception as e:
  1061. logging.warn("Unable (in modify_jira_bug_ticket) to enforce max. number "
  1062. "of attachments (%d) for %s, due to Exception:\n %s"
  1063. % (max_num_attachments, str(ticket_to_modify.key), str(e)) )
  1064. # Add a comment to the Jira ticket, if appropriate; and log a message
  1065. message1 = ("Modified ticket %s (https://issues.voltdb.com/browse/%s)"
  1066. % (ticket_to_modify.key, ticket_to_modify.key) )
  1067. if previous_summary == summary:
  1068. logging_message = ("%s%s, with summary unchanged:\n '%s'"