generate-summary.py 10 KB

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