final_summary.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. #!/usr/bin/env python3
  2. from __future__ import annotations
  3. import json
  4. import os
  5. import glob
  6. import pathlib
  7. import urllib.error
  8. import urllib.parse
  9. import urllib.request
  10. import datetime
  11. from typing import Any, Dict, Iterable, List, Tuple
  12. # ----------------------- Constants -----------------------
  13. ICON_WIN = "https://raw.githubusercontent.com/EgoistDeveloper/operating-system-logos/master/src/32x32/WIN.png"
  14. ICON_MAC = "https://raw.githubusercontent.com/EgoistDeveloper/operating-system-logos/master/src/32x32/MAC.png"
  15. ICON_IOS = "https://raw.githubusercontent.com/EgoistDeveloper/operating-system-logos/master/src/32x32/IOS.png"
  16. ICON_AND = "https://raw.githubusercontent.com/EgoistDeveloper/operating-system-logos/master/src/32x32/AND.png"
  17. ICON_LIN = "https://raw.githubusercontent.com/EgoistDeveloper/operating-system-logos/master/src/32x32/LIN.png"
  18. ICON_CPP = "https://raw.githubusercontent.com/isocpp/logos/master/cpp_logo.png"
  19. ICON_PM = "https://avatars.githubusercontent.com/u/96267164?s=32"
  20. ALIGN_4_COLS = "|:--|:--:|:--:|:--:|\n" # reused in Validation/Tests/Build matrix sections
  21. FAMILIES = ("windows-msvc", "windows-mingw", "macos", "linux", "ios", "android")
  22. VALIDATION_ORDER = {"LF line endings": 0, "JSON": 1, "Markdown": 2}
  23. TESTS_ORDER = {"Clang Latest": 0, "GCC Latest": 1, "Clang Oldest": 2, "GCC Oldest": 3}
  24. # ----------------------- Helpers -----------------------
  25. def env(name: str, default: str = "") -> str:
  26. v = os.getenv(name)
  27. return v if v is not None else default
  28. def now_utc() -> datetime.datetime:
  29. # Sonar: avoid utcnow(); use tz-aware now()
  30. return datetime.datetime.now(datetime.timezone.utc)
  31. def hms_from_ms(ms: int) -> str:
  32. s = max(0, round(ms / 1000))
  33. hh = f"{s // 3600:02d}"
  34. mm = f"{(s % 3600) // 60:02d}"
  35. ss = f"{s % 60:02d}"
  36. return f"{hh}:{mm}:{ss}"
  37. def parse_iso8601(s: str | None) -> datetime.datetime | None:
  38. if not s:
  39. return None
  40. try:
  41. return datetime.datetime.fromisoformat(s.replace("Z", "+00:00"))
  42. except Exception:
  43. return None
  44. def gh_api(path: str, method: str = "GET", data: Any = None, token: str | None = None,
  45. params: Dict[str, Any] | None = None) -> Any:
  46. base = "https://api.github.com"
  47. url = f"{base}{path}"
  48. if params:
  49. url = f"{url}?{urllib.parse.urlencode(params)}"
  50. headers = {
  51. "Accept": "application/vnd.github+json",
  52. "User-Agent": "final-summary-script",
  53. }
  54. if token:
  55. headers["Authorization"] = f"Bearer {token}"
  56. req = urllib.request.Request(url, method=method, headers=headers)
  57. payload = None
  58. if data is not None:
  59. payload = json.dumps(data).encode("utf-8")
  60. req.add_header("Content-Type", "application/json")
  61. try:
  62. with urllib.request.urlopen(req, payload, timeout=60) as r:
  63. raw = r.read()
  64. return {} if not raw else json.loads(raw.decode("utf-8"))
  65. except urllib.error.HTTPError as e:
  66. msg = e.read().decode("utf-8", errors="replace")
  67. print(f"[WARN] GitHub API {method} {url} -> {e.code}: {msg}")
  68. raise
  69. except Exception as e:
  70. print(f"[WARN] GitHub API {method} {url} -> {e}")
  71. raise
  72. def read_json_file(p: str) -> Any:
  73. try:
  74. with open(p, "r", encoding="utf-8") as f:
  75. return json.load(f)
  76. except FileNotFoundError:
  77. return None
  78. except Exception as e:
  79. print(f"[WARN] Cannot read JSON '{p}': {e}")
  80. return None
  81. def write_json_file(p: str, data: Any) -> None:
  82. pathlib.Path(p).parent.mkdir(parents=True, exist_ok=True)
  83. with open(p, "w", encoding="utf-8") as f:
  84. json.dump(data, f, ensure_ascii=False, indent=2)
  85. def append_summary(md: str) -> None:
  86. summary_path = env("GITHUB_STEP_SUMMARY") or "SUMMARY.md"
  87. with open(summary_path, "a", encoding="utf-8") as f:
  88. f.write(md)
  89. def status_icon(status: str) -> str:
  90. return {
  91. "success": "✅",
  92. "failure": "❌",
  93. "cancelled": "🚫",
  94. "timed_out": "⌛",
  95. "skipped": "⏭",
  96. "neutral": "⚠️",
  97. "action_required": "⚠️",
  98. }.get(status, "❓")
  99. def family_title_and_icon(fam: str) -> Tuple[str, str]:
  100. match fam:
  101. case "windows-msvc": return "Windows (MSVC)", ICON_WIN
  102. case "windows-mingw": return "Windows (MinGW)", ICON_WIN
  103. case "macos": return "macOS", ICON_MAC
  104. case "ios": return "iOS", ICON_IOS
  105. case "android": return "Android", ICON_AND
  106. case "linux": return "Linux (AppImage)", ICON_LIN
  107. case _: return fam, ICON_PM
  108. # ----------------------- 1) Collect validation & tests -----------------------
  109. def _job_duration(job: Dict[str, Any]) -> str:
  110. st = parse_iso8601(job.get("started_at"))
  111. en = parse_iso8601(job.get("completed_at"))
  112. if st and en:
  113. return hms_from_ms(int((en - st).total_seconds() * 1000))
  114. return ""
  115. def _test_pretty_name(name: str) -> str:
  116. pretty = name.replace("Test (", "").removesuffix(")")
  117. low = pretty.lower()
  118. if "gcc-latest" in low: return "GCC Latest"
  119. if "gcc-oldest" in low: return "GCC Oldest"
  120. if "clang-latest" in low: return "Clang Latest"
  121. if "clang-oldest" in low: return "Clang Oldest"
  122. return pretty
  123. def _rows_for_job(j: Dict[str, Any]) -> List[Dict[str, Any]]:
  124. rows: List[Dict[str, Any]] = []
  125. dur = _job_duration(j)
  126. name = j.get("name") or ""
  127. # Build matrix
  128. if name.startswith("Build "):
  129. pretty = name.replace("Build (", "").removesuffix(")")
  130. rows.append({
  131. "group": "builds",
  132. "name": pretty,
  133. "status": j.get("conclusion") or "neutral",
  134. "duration": dur,
  135. "url": j.get("html_url"),
  136. })
  137. # Code validation
  138. if name == "Validate Code":
  139. mapping = {
  140. "Validate JSON": "JSON",
  141. "Validate Markdown": "Markdown",
  142. "Ensure LF line endings": "LF line endings",
  143. }
  144. for st in (j.get("steps") or []):
  145. stname = st.get("name")
  146. if stname in mapping:
  147. rows.append({
  148. "group": "validation",
  149. "name": mapping[stname],
  150. "status": st.get("conclusion") or "skipped",
  151. "duration": dur,
  152. "url": j.get("html_url"),
  153. })
  154. # Tests matrix
  155. if name.startswith("Test "):
  156. pretty = _test_pretty_name(name)
  157. steps = j.get("steps") or []
  158. test_step = next((s for s in steps if s.get("name") == "Test"), None)
  159. status = (test_step.get("conclusion") if test_step else None) or j.get("conclusion") or "neutral"
  160. rows.append({
  161. "group": "tests",
  162. "name": pretty,
  163. "status": status,
  164. "duration": dur,
  165. "url": j.get("html_url"),
  166. })
  167. return rows
  168. def collect_validation_and_tests() -> None:
  169. token = env("GITHUB_TOKEN")
  170. repo_full = env("GITHUB_REPOSITORY") # "owner/repo"
  171. run_id = env("GITHUB_RUN_ID")
  172. if not (token and repo_full and run_id):
  173. print("[INFO] Missing GITHUB_TOKEN / GITHUB_REPOSITORY / GITHUB_RUN_ID; skipping GH API collect.")
  174. return
  175. owner, repo = repo_full.split("/", 1)
  176. r = gh_api(f"/repos/{owner}/{repo}/actions/runs/{run_id}/jobs",
  177. method="GET", token=token, params={"per_page": 100})
  178. jobs = r.get("jobs") or []
  179. rows: List[Dict[str, Any]] = []
  180. for j in jobs:
  181. rows.extend(_rows_for_job(j))
  182. pathlib.Path("partials").mkdir(parents=True, exist_ok=True)
  183. write_json_file("partials/validation.json", rows)
  184. # ----------------------- 2) Compose Summary -----------------------
  185. def _load_primary_items() -> List[Dict[str, Any]]:
  186. files = [p for p in glob.glob("partials/**/*.json", recursive=True)
  187. if not any(p.endswith(x) for x in ("validation.json", "source.json"))
  188. and not pathlib.Path(p).name.startswith("installer-")]
  189. items: List[Dict[str, Any]] = []
  190. for p in files:
  191. data = read_json_file(p)
  192. if isinstance(data, dict) and "family" in data:
  193. items.append(data)
  194. return items
  195. def _load_installer_map() -> Dict[str, str]:
  196. inst_map: Dict[str, str] = {}
  197. for p in glob.glob("partials/installer-*.json"):
  198. obj = read_json_file(p) or {}
  199. plat, url = obj.get("platform"), obj.get("installer_url")
  200. if plat and url:
  201. inst_map[plat] = url
  202. return inst_map
  203. def _durations_map(val_rows: List[Dict[str, Any]]) -> Dict[str, str]:
  204. return {r["name"]: r.get("duration", "-") for r in val_rows if r.get("group") == "builds"}
  205. def _render_family_table(fam: str, rows: List[Dict[str, Any]],
  206. inst_map: Dict[str, str], dur_map: Dict[str, str]) -> None:
  207. title, icon = family_title_and_icon(fam)
  208. append_summary(f'### <img src="{icon}" width="22"/> {title}\n\n')
  209. cols_arch = ["| Architecture |"]
  210. cols_stats = ["| Cache statistic |"]
  211. cols_time = ["| Build time |"]
  212. cols_down = ["| Download |"]
  213. for it in rows:
  214. plat = it.get("platform", "")
  215. arch = it.get("arch", "")
  216. hits = it.get("hits", "")
  217. total = it.get("total", "")
  218. rate = it.get("rate", "")
  219. hms = dur_map.get(plat, "-")
  220. main = it.get("artifact_url") or ""
  221. dbg = it.get("debug_symbols_url") or ""
  222. aab = it.get("aab_url") or ""
  223. inst = inst_map.get(plat, "")
  224. cols_arch.append(f" {arch} |")
  225. cols_stats.append(f" {rate} ({hits} / {total}) |")
  226. cols_time.append(f" {hms} |")
  227. dl_parts = []
  228. if inst: dl_parts.append(f"[Installer]({inst})")
  229. if dbg: dl_parts.append(f"[Debug symbols]({dbg})")
  230. if main: dl_parts.append(f"[Archive]({main})")
  231. if aab: dl_parts.append(f"[AAB]({aab})")
  232. dl = "<br/>".join(dl_parts) if dl_parts else "—"
  233. cols_down.append(f" {dl} |")
  234. count = len(rows)
  235. align = "|:--|" + ":--:|" * count
  236. append_summary("".join(cols_arch) + "\n")
  237. append_summary(align + "\n")
  238. append_summary("".join(cols_stats) + "\n")
  239. append_summary("".join(cols_time) + "\n")
  240. append_summary("".join(cols_down) + "\n\n")
  241. def _render_validation_section(val_rows: List[Dict[str, Any]]) -> None:
  242. rows = sorted((r for r in val_rows if r.get("group") == "validation"),
  243. key=lambda r: (VALIDATION_ORDER.get(r.get("name"), 999), r.get("name", "")))
  244. if not rows:
  245. return
  246. append_summary("### 🔍 Validation\n")
  247. append_summary("| Check | Status | Time | Logs |\n")
  248. append_summary(ALIGN_4_COLS)
  249. for r in rows:
  250. icon = status_icon(r.get("status", ""))
  251. dur = r.get("duration") or "-"
  252. url = r.get("url") or ""
  253. logs = f"[Logs]({url})" if url else "—"
  254. append_summary(f"| {r.get('name','')} | {icon} | {dur} | {logs} |\n")
  255. append_summary("\n")
  256. def _render_tests_section(val_rows: List[Dict[str, Any]]) -> None:
  257. rows = sorted((r for r in val_rows if r.get("group") == "tests"),
  258. key=lambda r: (TESTS_ORDER.get(r.get("name"), 999), r.get("name", "")))
  259. if not rows:
  260. return
  261. append_summary("### 🧪 Tests\n")
  262. append_summary("| Matrix | Status | Time | Logs |\n")
  263. append_summary(ALIGN_4_COLS)
  264. for r in rows:
  265. icon = status_icon(r.get("status", ""))
  266. dur = r.get("duration") or "-"
  267. url = r.get("url") or ""
  268. logs = f"[Logs]({url})" if url else "—"
  269. append_summary(f"| {r.get('name','')} | {icon} | {dur} | {logs} |\n")
  270. append_summary("\n")
  271. def _render_build_matrix_section(val_rows: List[Dict[str, Any]]) -> None:
  272. rows = sorted((r for r in val_rows if r.get("group") == "builds"),
  273. key=lambda r: r.get("name", ""))
  274. if not rows:
  275. return
  276. append_summary("### 🚦 Build matrix\n")
  277. append_summary("| Platform | Status | Time | Logs |\n")
  278. append_summary(ALIGN_4_COLS)
  279. for r in rows:
  280. icon = status_icon(r.get("status", ""))
  281. dur = r.get("duration") or "-"
  282. url = r.get("url") or ""
  283. logs = f"[Logs]({url})" if url else "—"
  284. append_summary(f"| {r.get('name','')} | {icon} | {dur} | {logs} |\n")
  285. append_summary("\n")
  286. def compose_summary() -> None:
  287. # Source code section
  288. src_json = read_json_file("partials/source.json")
  289. if src_json and src_json.get("source_url"):
  290. append_summary("\n\n")
  291. append_summary(
  292. f'### <img src="{ICON_CPP}" width="20"/> Source code - [Download]({src_json["source_url"]})\n\n\n'
  293. )
  294. items = _load_primary_items()
  295. inst_map = _load_installer_map()
  296. val_rows: List[Dict[str, Any]] = read_json_file("partials/validation.json") or []
  297. dur_map = _durations_map(val_rows)
  298. # Family tables
  299. for fam in FAMILIES:
  300. fam_rows = [x for x in items if x.get("family") == fam]
  301. if fam_rows:
  302. _render_family_table(fam, fam_rows, inst_map, dur_map)
  303. # Validation, Tests, Build matrix
  304. _render_validation_section(val_rows)
  305. _render_tests_section(val_rows)
  306. _render_build_matrix_section(val_rows)
  307. # ----------------------- 3) Delete partial artifacts -----------------------
  308. def delete_partial_artifacts() -> None:
  309. token = env("GITHUB_TOKEN")
  310. repo_full = env("GITHUB_REPOSITORY")
  311. run_id = env("GITHUB_RUN_ID")
  312. if not (token and repo_full and run_id):
  313. print("[INFO] Missing env for deleting artifacts; skipping.")
  314. return
  315. owner, repo = repo_full.split("/", 1)
  316. r = gh_api(f"/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts",
  317. token=token, params={"per_page": 100})
  318. arts = r.get("artifacts") or []
  319. for a in arts:
  320. name = a.get("name", "")
  321. if name.startswith("partial-json-"):
  322. aid = a.get("id")
  323. print(f"Deleting artifact {name} (id={aid})")
  324. gh_api(f"/repos/{owner}/{repo}/actions/artifacts/{aid}",
  325. method="DELETE", token=token)
  326. # ----------------------- Main -----------------------
  327. def main() -> None:
  328. pathlib.Path("partials").mkdir(parents=True, exist_ok=True)
  329. try:
  330. collect_validation_and_tests()
  331. except Exception as e:
  332. print(f"[WARN] collect_validation_and_tests failed: {e}")
  333. try:
  334. compose_summary()
  335. except Exception as e:
  336. print(f"[WARN] compose_summary failed: {e}")
  337. try:
  338. delete_partial_artifacts()
  339. except Exception as e:
  340. print(f"[WARN] delete_partial_artifacts failed: {e}")
  341. if __name__ == "__main__":
  342. main()