"""Итог регрессии: summary.md из results.tsv, shas.tsv и логов шагов (вызывает regress.sh). Аргументы: каталог прогона, список этапов через пробел (порядок в таблице). Для логов pytest — последняя строка-итог («N passed, M skipped …») и число пропусков из-за отсутствия docs/fixtures: зелёный шаг с такими пропусками не доказывает сверку контракта и прохождение на реальных данных (dev/checks.md, «Внешние ресурсы»). """ import json import re import sys from pathlib import Path PYTEST_TAIL = re.compile(r"=+ (.*\b(passed|failed|error|skipped|deselected)\b.*) in [\d.]+s") # причины пропуска «нет соседнего репозитория» в сервисах (dev/checks.md) MISSING_RES = re.compile(r"SKIPPED.*(ARM_KA_FIXTURES|ARM_KA_DOCS|fixtures недоступ|docs недоступ|нет \.\./(docs|fixtures))") def pytest_info(log: Path) -> str: try: text = log.read_text(errors="replace") except OSError: return "" tails = [m.group(1) for m in PYTEST_TAIL.finditer(text)] missing = len(MISSING_RES.findall(text)) parts = [] if tails: parts.append(tails[-1] if len(tails) == 1 else " | ".join(tails)) if missing: parts.append(f"⚠ пропусков без docs/fixtures: {missing}") return "; ".join(parts) def first_error(log: Path) -> str: """Короткая подсказка для красного шага: первая строка FAILED/ERROR/КРАСНЫЙ/Error из лога.""" try: lines = log.read_text(errors="replace").splitlines() except OSError: return "" for pat in (r"^FAILED |^ERROR ", r"FAIL|КРАСН|✗", r"[Ee]rror|ошибк"): for ln in lines: if re.search(pat, ln): return ln.strip()[:160] return lines[-1].strip()[:160] if lines else "" def main() -> None: out, stages = Path(sys.argv[1]), sys.argv[2].split() rows = [ln.split("\t") for ln in (out / "results.tsv").read_text().splitlines() if ln.strip()] shas = [ln.split("\t") for ln in (out / "shas.tsv").read_text().splitlines()] if (out / "shas.tsv").exists() else [] red = [r for r in rows if r[2] != "0"] md = [f"# Регрессия {out.name}", ""] md.append(f"**Итог: {'ЗЕЛЁНАЯ' if not red else f'КРАСНАЯ — {len(red)} из {len(rows)} шагов'}**") if (out / "allow-dirty").exists(): md.append("") md.append("⚠ Прогон с `--allow-dirty`: preflight не пройден (не main, не origin/main или есть правки) — см. этап preflight.") md += ["", "## Репозитории", "", "| Репозиторий | Ветка | SHA | Последний коммит |", "|---|---|---|---|"] md += [f"| {r} | {b} | `{h[:10]}` | {s} |" for r, b, h, s in shas] md += ["", "## Шаги", "", "| Этап | Шаг | Статус | Время, с | Детали |", "|---|---|---|---|---|"] order = {s: i for i, s in enumerate(stages)} for stage, name, rc, sec, log in sorted(rows, key=lambda r: order.get(r[0], 99)): logp = Path(log) detail = pytest_info(logp) if rc != "0": detail = "; ".join(x for x in (detail, first_error(logp)) if x) status = "ok" if rc == "0" else f"**КРАСНЫЙ** ({rc})" md.append(f"| {stage} | {name} | {status} | {sec} | {detail.replace('|', '/')} |") stand = out / "stand" / "report.json" if stand.exists(): rep = json.loads(stand.read_text()) md += ["", "## Стенд: сценарий по API", ""] red = [c for c in rep["checks"] if not c["ok"]] md.append(f"Проверок {len(rep['checks'])}, красных {len(red)}.") md.append("") md += [f"- {'✓' if c['ok'] else '✗'} {c['name']}" + (f": {c['detail']}" if c["detail"] else "") for c in rep["checks"]] if rep.get("baseline"): md += ["", f"Сравнение снимков со стендом прошлого прогона `{rep['baseline']}`: расхождений {len(rep['diffs'])}."] md += [f"- `{d['file']}`: {d['detail']}" for d in rep["diffs"]] bank = out / "bank" / "summary.md" if bank.exists(): md += ["", bank.read_text().strip()] md += ["", f"Логи: `{out / 'logs'}`"] (out / "summary.md").write_text("\n".join(md) + "\n") print("\n".join(md)) if __name__ == "__main__": main()