|
32 | 32 | AUTO_RETRY = os.environ.get("AUTO_RETRY", "false") == "true" |
33 | 33 | DRY_RUN = os.environ.get("DRY_RUN", "true") == "true" |
34 | 34 | RETRY_DEADLINE_S = int(os.environ.get("RETRY_DEADLINE_S", "3600")) |
35 | | -SLACK_WEBHOOK = os.environ.get("SLACK_WEBHOOK_URL", "") |
36 | | -# Separate from DRY_RUN: DRY_RUN also gates issue filing, so folding "no webhook" |
37 | | -# into it would silently stop filing issues on any repo without the secret. |
38 | | -SLACK_DRY = DRY_RUN or not SLACK_WEBHOOK |
39 | 35 |
|
40 | 36 | API = "https://api.github.com" |
41 | 37 | POLL_INTERVAL_S = 30 |
42 | 38 | JOBS_PER_PAGE = 50 # the jobs endpoint 502s at per_page=100 on large runs |
43 | 39 | AI_MAX_CALLS = 15 |
44 | 40 | LOG_TAIL_CHARS = 4000 |
45 | | -RETRY_CODES = {429, 500, 502, 503, 504} |
46 | | -SPARK = "▁▂▃▄▅▆▇█" |
47 | 41 |
|
48 | 42 |
|
49 | 43 | def gh(path, method="GET", body=None, retry=True): |
@@ -528,226 +522,6 @@ def run_url(): |
528 | 522 | return f"https://github.com/{REPO}/actions/runs/{RUN_ID}" |
529 | 523 |
|
530 | 524 |
|
531 | | -# --- Slack: the only "did the nightly pass" signal --- |
532 | | -# Ported from wolfProvider's osp-triage.py. Green is otherwise communicated by |
533 | | -# absence, which is indistinguishable from the nightly never running. |
534 | | - |
535 | | - |
536 | | -def backoff_delay(err, attempt): |
537 | | - ra = err.headers.get("Retry-After", "") if hasattr(err, "headers") else "" |
538 | | - return int(ra) if ra.isdigit() else 2 ** attempt |
539 | | - |
540 | | - |
541 | | -def section(text): |
542 | | - return {"type": "section", "text": {"type": "mrkdwn", "text": text}} |
543 | | - |
544 | | - |
545 | | -def chunk_lines(lines, limit=2900): |
546 | | - """Slack hard-caps a block at 3000 chars.""" |
547 | | - buf, size = [], 0 |
548 | | - for ln in lines: |
549 | | - if size + len(ln) + 1 > limit and buf: |
550 | | - yield "\n".join(buf) |
551 | | - buf, size = [], 0 |
552 | | - buf.append(ln) |
553 | | - size += len(ln) + 1 |
554 | | - if buf: |
555 | | - yield "\n".join(buf) |
556 | | - |
557 | | - |
558 | | -def render_text(blocks): |
559 | | - out = [] |
560 | | - for b in blocks: |
561 | | - t = b.get("type") |
562 | | - if t == "header": |
563 | | - out.append(b["text"]["text"]) |
564 | | - elif t == "divider": |
565 | | - out.append("-" * 48) |
566 | | - elif t == "section" and "fields" in b: |
567 | | - out.append(" ".join(f["text"].replace("\n", " ") for f in b["fields"])) |
568 | | - elif t == "section": |
569 | | - out.append(b["text"]["text"]) |
570 | | - elif t == "context": |
571 | | - out.append(" ".join(e["text"] for e in b["elements"])) |
572 | | - return "\n".join(out) |
573 | | - |
574 | | - |
575 | | -def post_slack(color, fallback, blocks): |
576 | | - payload = {"attachments": [{"color": color, "fallback": fallback, |
577 | | - "blocks": blocks}]} |
578 | | - if SLACK_DRY: |
579 | | - print("=== DRY RUN (no Slack post) ===\n") |
580 | | - print(render_text(blocks)) |
581 | | - return |
582 | | - data = json.dumps(payload).encode() |
583 | | - for attempt in range(4): |
584 | | - req = urllib.request.Request(SLACK_WEBHOOK, data=data, method="POST") |
585 | | - req.add_header("Content-Type", "application/json") |
586 | | - try: |
587 | | - with urllib.request.urlopen(req, timeout=30) as r: |
588 | | - r.read() |
589 | | - return |
590 | | - except urllib.error.HTTPError as e: |
591 | | - if e.code not in RETRY_CODES or attempt == 3: |
592 | | - raise |
593 | | - delay = backoff_delay(e, attempt) |
594 | | - except urllib.error.URLError: |
595 | | - if attempt == 3: |
596 | | - raise |
597 | | - delay = 2 ** attempt |
598 | | - time.sleep(delay) |
599 | | - |
600 | | - |
601 | | -def pass_rate(run_id): |
602 | | - js = all_jobs(run_id) |
603 | | - s = sum(1 for j in js if j.get("conclusion") == "success") |
604 | | - f = sum(1 for j in js if j.get("conclusion") == "failure") |
605 | | - return (s, s + f) if (s + f) else None |
606 | | - |
607 | | - |
608 | | -def history(n=6): |
609 | | - """Pass rates of the last n nightlies (newest first). Best-effort: [] on error.""" |
610 | | - try: |
611 | | - runs = gh(f"/repos/{REPO}/actions/workflows/nightly.yml/runs" |
612 | | - f"?per_page=20&status=completed").get("workflow_runs", []) |
613 | | - except Exception: |
614 | | - return [] |
615 | | - out = [] |
616 | | - for r in runs: |
617 | | - if str(r["id"]) == str(RUN_ID) or r.get("conclusion") not in ("success", "failure"): |
618 | | - continue |
619 | | - try: |
620 | | - pr = pass_rate(r["id"]) |
621 | | - except Exception: |
622 | | - continue |
623 | | - if pr and pr[1] >= 50: |
624 | | - out.append(pr[0] / pr[1]) |
625 | | - if len(out) >= n: |
626 | | - break |
627 | | - return out |
628 | | - |
629 | | - |
630 | | -def sparkline(rates): |
631 | | - if not rates: |
632 | | - return "" |
633 | | - # map a 90-100% band onto the 8 spark levels (clamped) |
634 | | - return "".join(SPARK[min(7, max(0, int((x - 0.90) / 0.10 * 7)))] for x in rates) |
635 | | - |
636 | | - |
637 | | -SEV_RANK = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3} |
638 | | - |
639 | | - |
640 | | -def post_report(obs, fail_refs, verdicts, filed, results, attempt, retry_note): |
641 | | - """One card per nightly, pass or fail. Everything here is already computed. |
642 | | -
|
643 | | - `filed` maps example id -> issue number (or None), collected from apply(). |
644 | | - """ |
645 | | - jobs = all_jobs(RUN_ID) |
646 | | - n_success = sum(1 for j in jobs if j.get("conclusion") == "success") |
647 | | - n_cancelled = sum(1 for j in jobs if j.get("conclusion") == "cancelled") |
648 | | - total_pf = sum(1 for j in jobs if j.get("conclusion") in ("success", "failure")) |
649 | | - pct = round(100 * n_success / total_pf) if total_pf else 0 |
650 | | - |
651 | | - # Per EXAMPLE DIR, to match the reals/flakes lists below. Counting result |
652 | | - # rows here instead would put rows and dirs on one line: "355 passed, 1 real" |
653 | | - # reads as 356 of something, and there is no such number. |
654 | | - n_pass = sum(1 for o in obs.values() if o == store.PASS) |
655 | | - # Rows are still worth stating -- one dir runs several targets -- but as its |
656 | | - # own unit, and skip/xfail only exist at row level. |
657 | | - tally = defaultdict(int) |
658 | | - for r in results: |
659 | | - tally[r["status"]] += 1 |
660 | | - checks = sum(tally.values()) |
661 | | - |
662 | | - reals, flakes = [], [] |
663 | | - for eid, o in sorted(obs.items()): |
664 | | - if o == store.PASS: |
665 | | - continue |
666 | | - ai = verdicts.get(eid) or {} |
667 | | - refs = ", ".join(fail_refs.get(eid, [])) or "unknown" |
668 | | - if o == store.FLAKE_ONLY: |
669 | | - flakes.append(f"`{eid}` ({refs})") |
670 | | - continue |
671 | | - sev = ai.get("severity") if ai.get("severity") in SEV_RANK else "Medium" |
672 | | - num = filed.get(eid) |
673 | | - reals.append({ |
674 | | - "sev": sev, |
675 | | - "id": eid, |
676 | | - "refs": refs, |
677 | | - "issue": num, |
678 | | - "unconfirmed": o == store.UNCONFIRMED, |
679 | | - "symptom": store.scrub(ai.get("symptom", "") or "failed twice"), |
680 | | - "cause": store.scrub(ai.get("cause", "")), |
681 | | - "next": store.scrub(ai.get("next", "")), |
682 | | - "upstream": ai.get("upstream"), |
683 | | - }) |
684 | | - reals.sort(key=lambda r: SEV_RANK.get(r["sev"], 9)) |
685 | | - |
686 | | - if not reals: |
687 | | - color, status = "good", "healthy" |
688 | | - elif pct >= 90: |
689 | | - color, status = "warning", f"{len(reals)} real — action needed" |
690 | | - else: |
691 | | - color, status = "danger", f"{len(reals)} real — degraded" |
692 | | - |
693 | | - trend = sparkline(history()) |
694 | | - date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") |
695 | | - blocks = [ |
696 | | - {"type": "header", "text": {"type": "plain_text", |
697 | | - "text": "wolfssl-examples Nightly", "emoji": True}}, |
698 | | - {"type": "section", "fields": [ |
699 | | - {"type": "mrkdwn", "text": f"*Date:*\n{date_str}"}, |
700 | | - {"type": "mrkdwn", "text": f"*Status:*\n{status}"}, |
701 | | - {"type": "mrkdwn", "text": f"*Pass rate:*\n{n_success} / {total_pf} jobs ({pct}%)"}, |
702 | | - {"type": "mrkdwn", "text": f"*Trend:*\n{trend or 'building history'}"}, |
703 | | - ]}, |
704 | | - section(f"*Examples:* \U0001F7E2 {n_pass} passed" |
705 | | - f" \U0001F7E1 {len(flakes)} flaked" |
706 | | - f" \U0001F534 {len(reals)} real" |
707 | | - + (f" ⚪ {n_cancelled} cancelled job(s)" if n_cancelled else "")), |
708 | | - section(f"*Checks:* {checks} across those examples" |
709 | | - f" · {tally['pass']} pass" |
710 | | - f" · {tally['fail']} fail" |
711 | | - + (f" · {tally['skip']} skip" if tally["skip"] else "") |
712 | | - + (f" · {tally['xfail']} known-fail" if tally["xfail"] else "")), |
713 | | - ] |
714 | | - if retry_note: |
715 | | - blocks.append(section(f":warning: {retry_note}")) |
716 | | - if reals: |
717 | | - meter = " · ".join( |
718 | | - f"{sum(1 for r in reals if r['sev'] == s)} {s}" |
719 | | - for s in ("Critical", "High", "Medium", "Low") |
720 | | - if any(r["sev"] == s for r in reals) |
721 | | - ) |
722 | | - blocks += [section(f"*Severity:* {meter}"), {"type": "divider"}, |
723 | | - section(f"*Real failures — {len(reals)}, action needed*")] |
724 | | - for r in reals: |
725 | | - link = f" <{run_url()}|logs>" |
726 | | - iss = (f" <https://github.com/{REPO}/issues/{r['issue']}|#{r['issue']}>" |
727 | | - if r["issue"] else "") |
728 | | - lines = [f"*{r['sev']} · `{r['id']}`* ({r['refs']}){link}{iss}", |
729 | | - f" • {r['symptom']}"] |
730 | | - if r["unconfirmed"]: |
731 | | - lines.append(" • _unconfirmed: the retry never completed_") |
732 | | - if r["cause"]: |
733 | | - lines.append(f" • _cause:_ {r['cause']}") |
734 | | - if r["next"]: |
735 | | - lines.append(f" • _next:_ {r['next']}") |
736 | | - if r["upstream"]: |
737 | | - lines.append(" • _fails on master but not stable — likely a wolfSSL " |
738 | | - "regression, not an example bug_") |
739 | | - blocks.append(section("\n".join(lines))) |
740 | | - if flakes: |
741 | | - blocks.append({"type": "divider"}) |
742 | | - for chunk in chunk_lines([f"*Flaked (cleared on retry):* {', '.join(flakes)}"]): |
743 | | - blocks.append(section(chunk)) |
744 | | - blocks.append({"type": "context", "elements": [ |
745 | | - {"type": "mrkdwn", "text": f"<{run_url()}|run {RUN_ID}> · attempt {attempt}"}]}) |
746 | | - |
747 | | - fallback = f"wolfssl-examples nightly: {status} ({n_success}/{total_pf})" |
748 | | - post_slack(color, fallback, blocks) |
749 | | - |
750 | | - |
751 | 525 | def apply(eid, observation, issue, ai, state, rows, close_allowed, target): |
752 | 526 | """Returns the issue number now tracking this dir, or None.""" |
753 | 527 | now = datetime.now(timezone.utc).strftime("%Y-%m-%d") |
@@ -919,14 +693,6 @@ def report(attempt, retry_note): |
919 | 693 | filed[eid] = apply(eid, observation, issue, verdicts.get(eid), state, rows, |
920 | 694 | close_allowed, targets.get(eid, "examples")) |
921 | 695 |
|
922 | | - # Last, so the card can link the issues opened above. Never let a reporting |
923 | | - # failure lose the triage that already succeeded. |
924 | | - try: |
925 | | - post_report(obs, fail_refs, verdicts, filed, results, attempt, retry_note) |
926 | | - except Exception: |
927 | | - traceback.print_exc() |
928 | | - print("slack report failed; triage itself succeeded", file=sys.stderr) |
929 | | - |
930 | 696 |
|
931 | 697 | def main(): |
932 | 698 | run = gh(f"/repos/{REPO}/actions/runs/{RUN_ID}") |
|
0 commit comments