generate-summary.py 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. #!/usr/bin/env python3
  2. import argparse
  3. import dataclasses
  4. import datetime
  5. import os
  6. import re
  7. import json
  8. import sys
  9. from github import Github, Auth as GithubAuth
  10. from github.PullRequest import PullRequest
  11. from enum import Enum
  12. from operator import attrgetter
  13. from typing import List, Optional, Dict
  14. from jinja2 import Environment, FileSystemLoader, StrictUndefined
  15. from junit_utils import get_property_value, iter_xml_files
  16. from gh_status import update_pr_comment_text
  17. class TestStatus(Enum):
  18. PASS = 0
  19. FAIL = 1
  20. ERROR = 2
  21. SKIP = 3
  22. MUTE = 4
  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. @property
  33. def status_display(self):
  34. return {
  35. TestStatus.PASS: "PASS",
  36. TestStatus.FAIL: "FAIL",
  37. TestStatus.ERROR: "ERROR",
  38. TestStatus.SKIP: "SKIP",
  39. TestStatus.MUTE: "MUTE",
  40. }[self.status]
  41. @property
  42. def elapsed_display(self):
  43. m, s = divmod(self.elapsed, 60)
  44. parts = []
  45. if m > 0:
  46. parts.append(f'{int(m)}m')
  47. parts.append(f"{s:.3f}s")
  48. return ' '.join(parts)
  49. def __str__(self):
  50. return f"{self.full_name:<138} {self.status_display}"
  51. @property
  52. def full_name(self):
  53. return f"{self.classname}/{self.name}"
  54. @classmethod
  55. def from_junit(cls, testcase):
  56. classname, name = testcase.get("classname"), testcase.get("name")
  57. if testcase.find("failure") is not None:
  58. status = TestStatus.FAIL
  59. elif testcase.find("error") is not None:
  60. status = TestStatus.ERROR
  61. elif get_property_value(testcase, "mute") is not None:
  62. status = TestStatus.MUTE
  63. elif testcase.find("skipped") is not None:
  64. status = TestStatus.SKIP
  65. else:
  66. status = TestStatus.PASS
  67. log_urls = {
  68. 'Log': get_property_value(testcase, "url:Log"),
  69. 'log': get_property_value(testcase, "url:log"),
  70. 'stdout': get_property_value(testcase, "url:stdout"),
  71. 'stderr': get_property_value(testcase, "url:stderr"),
  72. }
  73. log_urls = {k: v for k, v in log_urls.items() if v}
  74. elapsed = testcase.get("time")
  75. try:
  76. elapsed = float(elapsed)
  77. except (TypeError, ValueError):
  78. elapsed = 0
  79. print(f"Unable to cast elapsed time for {classname}::{name} value={elapsed!r}")
  80. return cls(classname, name, status, log_urls, elapsed)
  81. class TestSummaryLine:
  82. def __init__(self, title):
  83. self.title = title
  84. self.tests = []
  85. self.is_failed = False
  86. self.report_fn = self.report_url = None
  87. self.counter = {s: 0 for s in TestStatus}
  88. def add(self, test: TestResult):
  89. self.is_failed |= test.status in (TestStatus.ERROR, TestStatus.FAIL)
  90. self.counter[test.status] += 1
  91. self.tests.append(test)
  92. def add_report(self, fn, url):
  93. self.report_fn = fn
  94. self.report_url = url
  95. @property
  96. def test_count(self):
  97. return len(self.tests)
  98. @property
  99. def passed(self):
  100. return self.counter[TestStatus.PASS]
  101. @property
  102. def errors(self):
  103. return self.counter[TestStatus.ERROR]
  104. @property
  105. def failed(self):
  106. return self.counter[TestStatus.FAIL]
  107. @property
  108. def skipped(self):
  109. return self.counter[TestStatus.SKIP]
  110. @property
  111. def muted(self):
  112. return self.counter[TestStatus.MUTE]
  113. class TestSummary:
  114. def __init__(self):
  115. self.lines: List[TestSummaryLine] = []
  116. self.is_failed = False
  117. def add_line(self, line: TestSummaryLine):
  118. self.is_failed |= line.is_failed
  119. self.lines.append(line)
  120. @property
  121. def is_empty(self):
  122. return len(self.lines) == 0
  123. def render_line(self, items):
  124. return f"| {' | '.join(items)} |"
  125. def render(self, add_footnote=False):
  126. github_srv = os.environ.get("GITHUB_SERVER_URL", "https://github.com")
  127. repo = os.environ.get("GITHUB_REPOSITORY", "ydb-platform/ydb")
  128. footnote_url = f"{github_srv}/{repo}/tree/main/.github/config/muted_ya.txt"
  129. footnote = "[^1]" if add_footnote else f'<sup>[?]({footnote_url} "All mute rules are defined here")</sup>'
  130. columns = [
  131. "TESTS", "PASSED", "ERRORS", "FAILED", "SKIPPED", f"MUTED{footnote}"
  132. ]
  133. need_first_column = len(self.lines) > 1
  134. if need_first_column:
  135. columns.insert(0, "")
  136. result = [
  137. self.render_line(columns),
  138. ]
  139. if need_first_column:
  140. result.append(self.render_line([':---'] + ['---:'] * (len(columns) - 1)))
  141. else:
  142. result.append(self.render_line(['---:'] * len(columns)))
  143. for line in self.lines:
  144. report_url = line.report_url
  145. row = []
  146. if need_first_column:
  147. row.append(line.title)
  148. row.extend([
  149. render_pm(line.test_count, f"{report_url}", 0),
  150. render_pm(line.passed, f"{report_url}#PASS", 0),
  151. render_pm(line.errors, f"{report_url}#ERROR", 0),
  152. render_pm(line.failed, f"{report_url}#FAIL", 0),
  153. render_pm(line.skipped, f"{report_url}#SKIP", 0),
  154. render_pm(line.muted, f"{report_url}#MUTE", 0),
  155. ])
  156. result.append(self.render_line(row))
  157. if add_footnote:
  158. result.append("")
  159. result.append(f"[^1]: All mute rules are defined [here]({footnote_url}).")
  160. return result
  161. def render_pm(value, url, diff=None):
  162. if value:
  163. text = f"[{value}]({url})"
  164. else:
  165. text = str(value)
  166. if diff is not None and diff != 0:
  167. if diff == 0:
  168. sign = "±"
  169. elif diff < 0:
  170. sign = "-"
  171. else:
  172. sign = "+"
  173. text = f"{text} {sign}{abs(diff)}"
  174. return text
  175. def render_testlist_html(rows, fn):
  176. TEMPLATES_PATH = os.path.join(os.path.dirname(__file__), "templates")
  177. env = Environment(loader=FileSystemLoader(TEMPLATES_PATH), undefined=StrictUndefined)
  178. status_test = {}
  179. has_any_log = set()
  180. for t in rows:
  181. status_test.setdefault(t.status, []).append(t)
  182. if any(t.log_urls.values()):
  183. has_any_log.add(t.status)
  184. for status in status_test.keys():
  185. status_test[status].sort(key=attrgetter("full_name"))
  186. status_order = [TestStatus.ERROR, TestStatus.FAIL, TestStatus.SKIP, TestStatus.MUTE, TestStatus.PASS]
  187. # remove status group without tests
  188. status_order = [s for s in status_order if s in status_test]
  189. content = env.get_template("summary.html").render(
  190. status_order=status_order, tests=status_test, has_any_log=has_any_log
  191. )
  192. with open(fn, "w") as fp:
  193. fp.write(content)
  194. def write_summary(summary: TestSummary):
  195. summary_fn = os.environ.get("GITHUB_STEP_SUMMARY")
  196. if summary_fn:
  197. fp = open(summary_fn, "at")
  198. else:
  199. fp = sys.stdout
  200. if summary.is_empty:
  201. fp.write(":red_circle: Test run completed, no test results found. Please check build logs.")
  202. else:
  203. for line in summary.render(add_footnote=True):
  204. fp.write(f"{line}\n")
  205. fp.write("\n")
  206. if summary_fn:
  207. fp.close()
  208. def gen_summary(summary_url_prefix, summary_out_folder, paths):
  209. summary = TestSummary()
  210. for title, html_fn, path in paths:
  211. summary_line = TestSummaryLine(title)
  212. for fn, suite, case in iter_xml_files(path):
  213. test_result = TestResult.from_junit(case)
  214. summary_line.add(test_result)
  215. if not summary_line.tests:
  216. continue
  217. report_url = f"{summary_url_prefix}{html_fn}"
  218. render_testlist_html(summary_line.tests, os.path.join(summary_out_folder, html_fn))
  219. summary_line.add_report(html_fn, report_url)
  220. summary.add_line(summary_line)
  221. return summary
  222. def get_comment_text(pr: PullRequest, summary: TestSummary, test_history_url: str):
  223. if summary.is_empty:
  224. return [
  225. f"Test run completed, no test results found for commit {pr.head.sha}. "
  226. f"Please check build logs."
  227. ]
  228. elif summary.is_failed:
  229. result = f"Some tests failed, follow the links below."
  230. else:
  231. result = f"Tests successful."
  232. body = [
  233. result
  234. ]
  235. if test_history_url:
  236. body.append("")
  237. body.append(f"[Test history]({test_history_url})")
  238. body.extend(summary.render())
  239. return body
  240. def main():
  241. parser = argparse.ArgumentParser()
  242. parser.add_argument("--summary-out-path", required=True)
  243. parser.add_argument("--summary-url-prefix", required=True)
  244. parser.add_argument('--test-history-url', required=False)
  245. parser.add_argument('--build-preset', default="default-linux-x86-64-relwithdebinfo", required=False)
  246. parser.add_argument("args", nargs="+", metavar="TITLE html_out path")
  247. args = parser.parse_args()
  248. if len(args.args) % 3 != 0:
  249. print("Invalid argument count")
  250. raise SystemExit(-1)
  251. paths = iter(args.args)
  252. title_path = list(zip(paths, paths, paths))
  253. summary = gen_summary(args.summary_url_prefix, args.summary_out_path, title_path)
  254. write_summary(summary)
  255. if os.environ.get("GITHUB_EVENT_NAME") in ("pull_request", "pull_request_target"):
  256. gh = Github(auth=GithubAuth.Token(os.environ["GITHUB_TOKEN"]))
  257. with open(os.environ["GITHUB_EVENT_PATH"]) as fp:
  258. event = json.load(fp)
  259. pr = gh.create_from_raw_data(PullRequest, event["pull_request"])
  260. text = get_comment_text(pr, summary, args.test_history_url)
  261. if summary.is_empty | summary.is_failed:
  262. color = 'red'
  263. else:
  264. color = 'green'
  265. run_number = int(os.environ.get("GITHUB_RUN_NUMBER"))
  266. update_pr_comment_text(pr, args.build_preset, run_number, color, text='\n'.join(text), rewrite=False)
  267. if __name__ == "__main__":
  268. main()