Skip to content

Commit 455e081

Browse files
committed
Report nightly failures as issues only, dropping the Slack card
1 parent 16a499c commit 455e081

2 files changed

Lines changed: 0 additions & 237 deletions

File tree

.github/scripts/ci_triage.py

Lines changed: 0 additions & 234 deletions
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,12 @@
3232
AUTO_RETRY = os.environ.get("AUTO_RETRY", "false") == "true"
3333
DRY_RUN = os.environ.get("DRY_RUN", "true") == "true"
3434
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
3935

4036
API = "https://api.github.com"
4137
POLL_INTERVAL_S = 30
4238
JOBS_PER_PAGE = 50 # the jobs endpoint 502s at per_page=100 on large runs
4339
AI_MAX_CALLS = 15
4440
LOG_TAIL_CHARS = 4000
45-
RETRY_CODES = {429, 500, 502, 503, 504}
46-
SPARK = "▁▂▃▄▅▆▇█"
4741

4842

4943
def gh(path, method="GET", body=None, retry=True):
@@ -528,226 +522,6 @@ def run_url():
528522
return f"https://github.com/{REPO}/actions/runs/{RUN_ID}"
529523

530524

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-
751525
def apply(eid, observation, issue, ai, state, rows, close_allowed, target):
752526
"""Returns the issue number now tracking this dir, or None."""
753527
now = datetime.now(timezone.utc).strftime("%Y-%m-%d")
@@ -919,14 +693,6 @@ def report(attempt, retry_note):
919693
filed[eid] = apply(eid, observation, issue, verdicts.get(eid), state, rows,
920694
close_allowed, targets.get(eid, "examples"))
921695

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-
930696

931697
def main():
932698
run = gh(f"/repos/{REPO}/actions/runs/{RUN_ID}")

.github/workflows/ci-triage.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,6 @@ jobs:
5959
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
6060
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
6161
CLAUDE_MODEL: claude-opus-4-8
62-
# Unset is fine: the reporter prints the card to this log instead of
63-
# posting, so a fork (or this repo before the secret exists) still works.
64-
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
6562
AUTO_RETRY: ${{ github.event_name == 'workflow_run' && 'true' || inputs.auto_retry && 'true' || 'false' }}
6663
# Defaults TRUE on workflow_dispatch: the first thing anyone does with a
6764
# new issue-filing bot is point it at an old run to see what it says.

0 commit comments

Comments
 (0)