generate-summary.py 9.0 KB

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