Skip to content

Commit 865a9b7

Browse files
committed
Triage every nightly target, label it, and comment the history each night
1 parent f59efca commit 865a9b7

2 files changed

Lines changed: 129 additions & 24 deletions

File tree

.github/scripts/ci_triage.py

Lines changed: 96 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import json
1414
import os
15+
import re
1516
import sys
1617
import time
1718
import traceback
@@ -240,7 +241,62 @@ def classify(run_id, attempt, retry_note):
240241
obs[eid] = store.FLAKE_ONLY
241242
else:
242243
obs[eid] = store.PASS
243-
return obs, {k: sorted(v) for k, v in fail_refs.items()}
244+
# every host dir came from examples.yml, which nightly.yml calls as `host`
245+
targets = {eid: "examples" for eid in obs}
246+
247+
jobs_obs, jobs_refs, jobs_targets = classify_jobs(run_id, attempt, retry_note)
248+
obs.update(jobs_obs)
249+
fail_refs.update(jobs_refs)
250+
targets.update(jobs_targets)
251+
return obs, {k: sorted(v) for k, v in fail_refs.items()}, targets
252+
253+
254+
# Jobs the nightly fans out to that emit no results-*.json. Without this they
255+
# ran every night, went red, and told nobody -- 12 of the 13 targets.
256+
HOST_JOBS = {"host", "refs", "gate"}
257+
REF_RE = re.compile(r"wolfSSL (\S+)|\((master|v[\d.]+-stable)\)")
258+
259+
260+
def job_unit(name):
261+
"""`esp32 / Build / ESP32 (...) wolfSSL master` -> ('esp32', 'master').
262+
263+
The prefix is nightly.yml's job name, which is deliberately the target name
264+
and therefore the ci:<target> label.
265+
"""
266+
if " / " not in name:
267+
return None, None
268+
target, rest = name.split(" / ", 1)
269+
if target in HOST_JOBS:
270+
return None, None
271+
m = REF_RE.search(rest)
272+
return target, (m.group(1) or m.group(2)) if m else "unknown"
273+
274+
275+
def classify_jobs(run_id, attempt, retry_note):
276+
def by_unit(jobs):
277+
out = defaultdict(list)
278+
for j in jobs:
279+
t, ref = job_unit(j.get("name", ""))
280+
if t:
281+
out[t].append((j.get("conclusion"), ref))
282+
return out
283+
284+
a1 = by_unit(all_jobs(run_id, attempt=1))
285+
final = by_unit(all_jobs(run_id)) if attempt >= 2 else a1
286+
287+
obs, refs, targets = {}, defaultdict(set), {}
288+
for t in set(a1) | set(final):
289+
failed_now = any(c == "failure" for c, _ in final.get(t, []))
290+
failed_before = any(c == "failure" for c, _ in a1.get(t, []))
291+
if failed_now:
292+
obs[t] = store.UNCONFIRMED if retry_note else store.REAL
293+
refs[t] = {r for c, r in final[t] if c == "failure"}
294+
elif failed_before and attempt >= 2:
295+
obs[t] = store.FLAKE_ONLY
296+
else:
297+
obs[t] = store.PASS
298+
targets[t] = t
299+
return obs, refs, targets
244300

245301

246302
# --- AI: advisory only -----------------------------------------------------
@@ -401,10 +457,21 @@ def open_issues():
401457
page += 1
402458

403459

460+
NIGHTLY_TARGETS = ("examples", "esp32", "android", "rt1060", "uefi", "puf",
461+
"rpi-pico", "ebpf", "fullstack", "java", "cmake", "csharp",
462+
"emulated")
463+
464+
404465
def ensure_labels():
466+
for t in NIGHTLY_TARGETS:
467+
try:
468+
gh(f"/repos/{REPO}/labels", method="POST", retry=False,
469+
body={"name": store.target_label(t), "color": "1d76db",
470+
"description": f"Nightly {t} target"})
471+
except urllib.error.HTTPError:
472+
pass # 422 = already exists
405473
for name, color, desc in [
406474
(store.LABEL, "b60205", "Filed by the nightly examples triage"),
407-
(store.LABEL_FLAKY, "fbca04", "Flaps between pass and fail"),
408475
(store.LABEL_MUTE, "ededed", "Triage will not touch this issue"),
409476
(store.LABEL_UNCONFIRMED, "d4c5f9", "Retry did not run; not double-confirmed"),
410477
]:
@@ -658,7 +725,7 @@ def post_report(obs, fail_refs, verdicts, filed, results, attempt, retry_note):
658725
post_slack(color, fallback, blocks)
659726

660727

661-
def apply(eid, observation, issue, ai, state, rows, close_allowed):
728+
def apply(eid, observation, issue, ai, state, rows, close_allowed, target):
662729
"""Returns the issue number now tracking this dir, or None."""
663730
now = datetime.now(timezone.utc).strftime("%Y-%m-%d")
664731
action, why = store.decide(observation, issue, close_allowed)
@@ -674,16 +741,21 @@ def apply(eid, observation, issue, ai, state, rows, close_allowed):
674741
body = store.render_body(eid, state, ai, rows, run_url(), now)
675742

676743
if action == "open":
677-
created = gh(
678-
f"/repos/{REPO}/issues",
679-
method="POST",
680-
body={
744+
body_kw = {
681745
"title": f"CI: `{eid}` failing in the nightly",
682746
"body": body,
683-
"labels": [store.LABEL]
747+
"labels": [store.LABEL, store.target_label(target)]
684748
+ ([store.LABEL_UNCONFIRMED] if observation == store.UNCONFIRMED else []),
685-
},
686-
)
749+
# wolfHSM assigns its nightly issues to the bot; retried
750+
# without it below if the account is not assignable here.
751+
"assignees": ["wolfSSL-Bot"],
752+
}
753+
try:
754+
created = gh(f"/repos/{REPO}/issues", method="POST", body=body_kw)
755+
except urllib.error.HTTPError:
756+
# not assignable in this repo; file it anyway rather than lose it
757+
body_kw.pop("assignees", None)
758+
created = gh(f"/repos/{REPO}/issues", method="POST", body=body_kw)
687759
num = created["number"]
688760
print(f" {eid}: opened #{num} ({why})")
689761
elif action == "reopen":
@@ -699,19 +771,20 @@ def apply(eid, observation, issue, ai, state, rows, close_allowed):
699771
)
700772
print(f" {eid}: reopened #{issue['number']} ({why})")
701773
elif action == "update":
702-
# Silent: issues.update sends no notification. A comment every night on a
703-
# long-standing break is how a bot gets muted.
704774
gh(f"/repos/{REPO}/issues/{issue['number']}", method="PATCH", body={"body": body})
775+
# A comment every night, so the thread IS the history. wolfHSM #412 does
776+
# this with a bare "Still failing as of <url>" x15; carry the verdict,
777+
# the retry outcome and what changed instead, so a reader can skim the
778+
# thread and see how the failure evolved.
705779
reason = store.material_change(store.parse_state(issue.get("body", "")), state)
706-
if reason:
707-
gh(
708-
f"/repos/{REPO}/issues/{issue['number']}/comments",
709-
method="POST",
710-
body={"body": f"{reason}. [Run]({run_url()})"},
711-
)
712-
print(f" {eid}: updated #{issue['number']} + comment ({reason})")
713-
else:
714-
print(f" {eid}: updated #{issue['number']} silently ({why})")
780+
gh(
781+
f"/repos/{REPO}/issues/{issue['number']}/comments",
782+
method="POST",
783+
body={"body": store.render_comment(state, ai, observation, reason,
784+
run_url(), now)},
785+
)
786+
print(f" {eid}: updated #{issue['number']} + comment"
787+
+ (f" ({reason})" if reason else ""))
715788
elif action == "close":
716789
gh(
717790
f"/repos/{REPO}/issues/{issue['number']}/comments",
@@ -731,7 +804,7 @@ def apply(eid, observation, issue, ai, state, rows, close_allowed):
731804

732805
def report(attempt, retry_note):
733806
results = merged_results(RUN_ID, attempt)
734-
obs, fail_refs = classify(RUN_ID, attempt, retry_note)
807+
obs, fail_refs, targets = classify(RUN_ID, attempt, retry_note)
735808

736809
logs = defaultdict(str)
737810
for r in results:
@@ -804,7 +877,7 @@ def report(attempt, retry_note):
804877
# collected, not read back from `issues`: a dir filed for the first time
805878
# tonight is not in that map, and its card entry would have no link
806879
filed[eid] = apply(eid, observation, issue, verdicts.get(eid), state, rows,
807-
close_allowed)
880+
close_allowed, targets.get(eid, "examples"))
808881

809882
# Last, so the card can link the issues opened above. Never let a reporting
810883
# failure lose the triage that already succeeded.

.github/scripts/issue_store.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
import re
1313

1414
LABEL = "ci:nightly"
15-
LABEL_FLAKY = "ci:flaky"
15+
# ci:<target> -- the nightly job that failed (examples, esp32, android, ...).
16+
# Added only when that target fails, so a green night adds no labels.
17+
def target_label(target):
18+
return f"ci:{target}"
19+
1620
LABEL_MUTE = "ci:mute"
1721
LABEL_UNCONFIRMED = "ci:unconfirmed"
1822

@@ -200,6 +204,34 @@ def decide(observation, issue, close_allowed=True):
200204
return "update", "still failing"
201205

202206

207+
def render_comment(state, ai, observation, reason, run_url, now):
208+
"""One comment per night, so the thread carries the history.
209+
210+
wolfHSM's nightly posts a bare "Still failing as of <url>" -- 15 of them on
211+
one issue, none of which say what happened. Carry the verdict and the retry
212+
outcome, so the thread is readable rather than just long.
213+
"""
214+
n = state.get("streak", 1)
215+
verdict = ("**Real** — retried once, failed again." if observation == REAL
216+
else "**Unconfirmed** — the retry did not complete, so this is "
217+
"not double-checked.")
218+
lines = [
219+
f"**Night {n}** · [run]({run_url}) · {now}",
220+
"",
221+
verdict,
222+
]
223+
if reason:
224+
lines += ["", f"Changed since last night: **{reason}**."]
225+
if ai:
226+
lines += ["", f"`{ai.get('severity', '?')}` · {scrub(ai.get('symptom', ''))}"]
227+
if ai.get("next"):
228+
lines.append(f"_next:_ {scrub(ai.get('next', ''))}")
229+
refs = state.get("failing") or []
230+
if refs:
231+
lines += ["", f"Failing on: {', '.join(f'`{r}`' for r in refs)}"]
232+
return "\n".join(lines)
233+
234+
203235
def material_change(old_state, new_state):
204236
"""Comment only on a material change. Body edits are silent; comments notify,
205237
and a comment every night on a long-standing break gets the bot muted."""

0 commit comments

Comments
 (0)