generate-summary.py 12 KB

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