generate-summary.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  1. #!/usr/bin/env python3
  2. import argparse
  3. import dataclasses
  4. import os
  5. import sys
  6. import traceback
  7. from codeowners import CodeOwners
  8. from enum import Enum
  9. from operator import attrgetter
  10. from typing import List, Dict
  11. from jinja2 import Environment, FileSystemLoader, StrictUndefined
  12. from junit_utils import get_property_value, iter_xml_files
  13. from get_test_history import get_test_history
  14. class TestStatus(Enum):
  15. PASS = 0
  16. FAIL = 1
  17. ERROR = 2
  18. SKIP = 3
  19. MUTE = 4
  20. @property
  21. def is_error(self):
  22. return self in (TestStatus.FAIL, TestStatus.ERROR, TestStatus.MUTE)
  23. def __lt__(self, other):
  24. return self.value < other.value
  25. @dataclasses.dataclass
  26. class TestResult:
  27. classname: str
  28. name: str
  29. status: TestStatus
  30. log_urls: Dict[str, str]
  31. elapsed: float
  32. count_of_passed: int
  33. owners: str
  34. status_description: str
  35. @property
  36. def status_display(self):
  37. return {
  38. TestStatus.PASS: "PASS",
  39. TestStatus.FAIL: "FAIL",
  40. TestStatus.ERROR: "ERROR",
  41. TestStatus.SKIP: "SKIP",
  42. TestStatus.MUTE: "MUTE",
  43. }[self.status]
  44. @property
  45. def elapsed_display(self):
  46. m, s = divmod(self.elapsed, 60)
  47. parts = []
  48. if m > 0:
  49. parts.append(f'{int(m)}m')
  50. parts.append(f"{s:.3f}s")
  51. return ' '.join(parts)
  52. def __str__(self):
  53. return f"{self.full_name:<138} {self.status_display}"
  54. @property
  55. def full_name(self):
  56. return f"{self.classname}/{self.name}"
  57. @classmethod
  58. def from_junit(cls, testcase):
  59. classname, name = testcase.get("classname"), testcase.get("name")
  60. status_description = None
  61. if testcase.find("failure") is not None:
  62. status = TestStatus.FAIL
  63. if testcase.find("failure").text is not None:
  64. status_description = testcase.find("failure").text
  65. elif testcase.find("error") is not None:
  66. status = TestStatus.ERROR
  67. if testcase.find("error").text is not None:
  68. status_description = testcase.find("error").text
  69. elif get_property_value(testcase, "mute") is not None:
  70. status = TestStatus.MUTE
  71. if testcase.find("skipped").text is not None:
  72. status_description = testcase.find("skipped").text
  73. elif testcase.find("skipped") is not None:
  74. status = TestStatus.SKIP
  75. if testcase.find("skipped").text is not None:
  76. status_description = testcase.find("skipped").text
  77. else:
  78. status = TestStatus.PASS
  79. log_urls = {
  80. 'Log': get_property_value(testcase, "url:Log"),
  81. 'log': get_property_value(testcase, "url:log"),
  82. 'logsdir': get_property_value(testcase, "url:logsdir"),
  83. 'stdout': get_property_value(testcase, "url:stdout"),
  84. 'stderr': get_property_value(testcase, "url:stderr"),
  85. }
  86. log_urls = {k: v for k, v in log_urls.items() if v}
  87. elapsed = testcase.get("time")
  88. try:
  89. elapsed = float(elapsed)
  90. except (TypeError, ValueError):
  91. elapsed = 0
  92. print(f"Unable to cast elapsed time for {classname}::{name} value={elapsed!r}")
  93. return cls(classname, name, status, log_urls, elapsed, 0, '', status_description)
  94. class TestSummaryLine:
  95. def __init__(self, title):
  96. self.title = title
  97. self.tests = []
  98. self.is_failed = False
  99. self.report_fn = self.report_url = None
  100. self.counter = {s: 0 for s in TestStatus}
  101. def add(self, test: TestResult):
  102. self.is_failed |= test.status in (TestStatus.ERROR, TestStatus.FAIL)
  103. self.counter[test.status] += 1
  104. self.tests.append(test)
  105. def add_report(self, fn, url):
  106. self.report_fn = fn
  107. self.report_url = url
  108. @property
  109. def test_count(self):
  110. return len(self.tests)
  111. @property
  112. def passed(self):
  113. return self.counter[TestStatus.PASS]
  114. @property
  115. def errors(self):
  116. return self.counter[TestStatus.ERROR]
  117. @property
  118. def failed(self):
  119. return self.counter[TestStatus.FAIL]
  120. @property
  121. def skipped(self):
  122. return self.counter[TestStatus.SKIP]
  123. @property
  124. def muted(self):
  125. return self.counter[TestStatus.MUTE]
  126. class TestSummary:
  127. def __init__(self, is_retry: bool):
  128. self.lines: List[TestSummaryLine] = []
  129. self.is_failed = False
  130. self.is_retry = is_retry
  131. def add_line(self, line: TestSummaryLine):
  132. self.is_failed |= line.is_failed
  133. self.lines.append(line)
  134. def render_line(self, items):
  135. return f"| {' | '.join(items)} |"
  136. def render(self, add_footnote=False, is_retry=False):
  137. github_srv = os.environ.get("GITHUB_SERVER_URL", "https://github.com")
  138. repo = os.environ.get("GITHUB_REPOSITORY", "ydb-platform/ydb")
  139. footnote_url = f"{github_srv}/{repo}/tree/main/.github/config/muted_ya.txt"
  140. footnote = "[^1]" if add_footnote else f'<sup>[?]({footnote_url} "All mute rules are defined here")</sup>'
  141. columns = [
  142. "TESTS", "PASSED", "ERRORS", "FAILED", "SKIPPED", f"MUTED{footnote}"
  143. ]
  144. need_first_column = len(self.lines) > 1
  145. if need_first_column:
  146. columns.insert(0, "")
  147. result = []
  148. result.append(self.render_line(columns))
  149. if need_first_column:
  150. result.append(self.render_line([':---'] + ['---:'] * (len(columns) - 1)))
  151. else:
  152. result.append(self.render_line(['---:'] * len(columns)))
  153. for line in self.lines:
  154. report_url = line.report_url
  155. row = []
  156. if need_first_column:
  157. row.append(line.title)
  158. row.extend([
  159. render_pm(f"{line.test_count}" + (" (only retried tests)" if self.is_retry else ""), f"{report_url}", 0),
  160. render_pm(line.passed, f"{report_url}#PASS", 0),
  161. render_pm(line.errors, f"{report_url}#ERROR", 0),
  162. render_pm(line.failed, f"{report_url}#FAIL", 0),
  163. render_pm(line.skipped, f"{report_url}#SKIP", 0),
  164. render_pm(line.muted, f"{report_url}#MUTE", 0),
  165. ])
  166. result.append(self.render_line(row))
  167. if add_footnote:
  168. result.append("")
  169. result.append(f"[^1]: All mute rules are defined [here]({footnote_url}).")
  170. return result
  171. def render_pm(value, url, diff=None):
  172. if value:
  173. text = f"[{value}]({url})"
  174. else:
  175. text = str(value)
  176. if diff is not None and diff != 0:
  177. if diff == 0:
  178. sign = "±"
  179. elif diff < 0:
  180. sign = "-"
  181. else:
  182. sign = "+"
  183. text = f"{text} {sign}{abs(diff)}"
  184. return text
  185. def render_testlist_html(rows, fn, build_preset):
  186. TEMPLATES_PATH = os.path.join(os.path.dirname(__file__), "templates")
  187. env = Environment(loader=FileSystemLoader(TEMPLATES_PATH), undefined=StrictUndefined)
  188. status_test = {}
  189. last_n_runs = 5
  190. has_any_log = set()
  191. for t in rows:
  192. status_test.setdefault(t.status, []).append(t)
  193. if any(t.log_urls.values()):
  194. has_any_log.add(t.status)
  195. for status in status_test.keys():
  196. status_test[status].sort(key=attrgetter("full_name"))
  197. status_order = [TestStatus.ERROR, TestStatus.FAIL, TestStatus.SKIP, TestStatus.MUTE, TestStatus.PASS]
  198. # remove status group without tests
  199. status_order = [s for s in status_order if s in status_test]
  200. # get testowners
  201. all_tests = [test for status in status_order for test in status_test.get(status)]
  202. dir = os.path.dirname(__file__)
  203. git_root = f"{dir}/../../.."
  204. codeowners = f"{git_root}/.github/TESTOWNERS"
  205. get_codeowners_for_tests(codeowners, all_tests)
  206. # statuses for history
  207. status_for_history = [TestStatus.FAIL, TestStatus.MUTE]
  208. status_for_history = [s for s in status_for_history if s in status_test]
  209. tests_names_for_history = []
  210. history= {}
  211. tests_in_statuses = [test for status in status_for_history for test in status_test.get(status)]
  212. # get tests for history
  213. for test in tests_in_statuses:
  214. tests_names_for_history.append(test.full_name)
  215. try:
  216. history = get_test_history(tests_names_for_history, last_n_runs, build_preset)
  217. except Exception:
  218. print(traceback.format_exc())
  219. #geting count of passed tests in history for sorting
  220. for test in tests_in_statuses:
  221. if test.full_name in history:
  222. test.count_of_passed = len(
  223. [
  224. history[test.full_name][x]
  225. for x in history[test.full_name]
  226. if history[test.full_name][x]["status"] == "passed"
  227. ]
  228. )
  229. # sorting,
  230. # at first - show tests with passed resuts in history
  231. # at second - sorted by test name
  232. for current_status in status_for_history:
  233. status_test.get(current_status,[]).sort(key=lambda val: (-val.count_of_passed, val.full_name))
  234. buid_preset_params = '--build unknown_build_type'
  235. if build_preset == 'release-asan' :
  236. buid_preset_params = '--build "release" --sanitize="address" -DDEBUGINFO_LINES_ONLY'
  237. elif build_preset == 'release-msan':
  238. buid_preset_params = '--build "release" --sanitize="memory" -DDEBUGINFO_LINES_ONLY'
  239. elif build_preset == 'release-tsan':
  240. buid_preset_params = '--build "release" --sanitize="thread" -DDEBUGINFO_LINES_ONLY'
  241. elif build_preset == 'relwithdebinfo':
  242. buid_preset_params = '--build "relwithdebinfo"'
  243. content = env.get_template("summary.html").render(
  244. status_order=status_order,
  245. tests=status_test,
  246. has_any_log=has_any_log,
  247. history=history,
  248. build_preset=buid_preset_params
  249. )
  250. with open(fn, "w") as fp:
  251. fp.write(content)
  252. def write_summary(summary: TestSummary):
  253. summary_fn = os.environ.get("GITHUB_STEP_SUMMARY")
  254. if summary_fn:
  255. fp = open(summary_fn, "at")
  256. else:
  257. fp = sys.stdout
  258. for line in summary.render(add_footnote=True):
  259. fp.write(f"{line}\n")
  260. fp.write("\n")
  261. if summary_fn:
  262. fp.close()
  263. def get_codeowners_for_tests(codeowners_file_path, tests_data):
  264. with open(codeowners_file_path, 'r') as file:
  265. data = file.read()
  266. owners_odj = CodeOwners(data)
  267. tests_data_with_owners = []
  268. for test in tests_data:
  269. target_path = test.classname
  270. owners = owners_odj.of(target_path)
  271. test.owners = joined_owners = ";;".join(
  272. [(":".join(x)) for x in owners])
  273. tests_data_with_owners.append(test)
  274. def gen_summary(public_dir, public_dir_url, paths, is_retry: bool, build_preset):
  275. summary = TestSummary(is_retry=is_retry)
  276. for title, html_fn, path in paths:
  277. summary_line = TestSummaryLine(title)
  278. for fn, suite, case in iter_xml_files(path):
  279. test_result = TestResult.from_junit(case)
  280. summary_line.add(test_result)
  281. if os.path.isabs(html_fn):
  282. html_fn = os.path.relpath(html_fn, public_dir)
  283. report_url = f"{public_dir_url}/{html_fn}"
  284. render_testlist_html(summary_line.tests, os.path.join(public_dir, html_fn),build_preset)
  285. summary_line.add_report(html_fn, report_url)
  286. summary.add_line(summary_line)
  287. return summary
  288. def get_comment_text(summary: TestSummary, summary_links: str, is_last_retry: bool, is_test_result_ignored: bool)->tuple[str, list[str]]:
  289. color = "red"
  290. if summary.is_failed:
  291. if is_test_result_ignored:
  292. color = "yellow"
  293. result = f"Some tests failed, follow the links below. This fail is not in blocking policy yet"
  294. else:
  295. color = "red" if is_last_retry else "yellow"
  296. result = f"Some tests failed, follow the links below."
  297. if not is_last_retry:
  298. result += " Going to retry failed tests..."
  299. else:
  300. color = "green"
  301. result = f"Tests successful."
  302. body = []
  303. body.append(result)
  304. if not is_last_retry:
  305. body.append("")
  306. body.append("<details>")
  307. body.append("")
  308. with open(summary_links) as f:
  309. links = f.readlines()
  310. links.sort()
  311. links = [line.split(" ", 1)[1].strip() for line in links]
  312. if links:
  313. body.append("")
  314. body.append(" | ".join(links))
  315. body.extend(summary.render())
  316. if not is_last_retry:
  317. body.append("")
  318. body.append("</details>")
  319. body.append("")
  320. else:
  321. body.append("")
  322. return color, body
  323. def main():
  324. parser = argparse.ArgumentParser()
  325. parser.add_argument("--public_dir", required=True)
  326. parser.add_argument("--public_dir_url", required=True)
  327. parser.add_argument("--summary_links", required=True)
  328. parser.add_argument('--build_preset', default="default-linux-x86-64-relwithdebinfo", required=False)
  329. parser.add_argument('--status_report_file', required=False)
  330. parser.add_argument('--is_retry', required=True, type=int)
  331. parser.add_argument('--is_last_retry', required=True, type=int)
  332. parser.add_argument('--is_test_result_ignored', required=True, type=int)
  333. parser.add_argument('--comment_color_file', required=True)
  334. parser.add_argument('--comment_text_file', required=True)
  335. parser.add_argument("args", nargs="+", metavar="TITLE html_out path")
  336. args = parser.parse_args()
  337. if len(args.args) % 3 != 0:
  338. print("Invalid argument count")
  339. raise SystemExit(-1)
  340. paths = iter(args.args)
  341. title_path = list(zip(paths, paths, paths))
  342. summary = gen_summary(args.public_dir, args.public_dir_url, title_path, is_retry=bool(args.is_retry),build_preset=args.build_preset)
  343. write_summary(summary)
  344. if summary.is_failed and not args.is_test_result_ignored:
  345. overall_status = "failure"
  346. else:
  347. overall_status = "success"
  348. color, text = get_comment_text(summary, args.summary_links, is_last_retry=bool(args.is_last_retry), is_test_result_ignored=args.is_test_result_ignored)
  349. with open(args.comment_color_file, "w") as f:
  350. f.write(color)
  351. with open(args.comment_text_file, "w") as f:
  352. f.write('\n'.join(text))
  353. f.write('\n')
  354. with open(args.status_report_file, "w") as f:
  355. f.write(overall_status)
  356. if __name__ == "__main__":
  357. main()