diff --git a/machine/health.py b/machine/health.py index 754fb0c..2601125 100644 --- a/machine/health.py +++ b/machine/health.py @@ -126,7 +126,23 @@ def render(findings: list[Finding], config: dict[str, Any]) -> str: return "\n".join(lines) + "\n" -def publish(body: str, token: str, repo: str) -> None: +def fingerprint(findings: list[Finding]) -> str: + """Stable identity of a problem set: what is broken, not when it was checked.""" + return ",".join(sorted(f"{f.where}={f.what}" for f in findings)) + + +def alert_needed(previous_body: str, current: str) -> bool: + """Alert only when the set of problems changed — and never for all clear. + + A daily ping about the same known failure trains people to ignore the + ping; the point is to hear about the *new* one. + """ + if not current: + return False + return f"" not in previous_body + + +def publish(body: str, token: str, repo: str, findings: list[Finding], mention: str) -> None: """Keep one open issue up to date instead of opening one per run.""" def call(method: str, path: str, payload: dict[str, Any] | None = None) -> Any: data = json.dumps(payload).encode("utf-8") if payload is not None else None @@ -138,12 +154,24 @@ def call(method: str, path: str, payload: dict[str, Any] | None = None) -> Any: with urllib.request.urlopen(request, timeout=30) as response: return json.loads(response.read().decode("utf-8") or "null") + current = fingerprint(findings) + stamped = f"{body}\n\n" + issues = call("GET", f"/repos/{repo}/issues?state=open&per_page=100") existing = next((i for i in issues if i.get("title") == ISSUE_TITLE), None) if existing: - call("PATCH", f"/repos/{repo}/issues/{existing['number']}", {"body": body}) + number = existing["number"] + previous = existing.get("body") or "" + call("PATCH", f"/repos/{repo}/issues/{number}", {"body": stamped}) else: - call("POST", f"/repos/{repo}/issues", {"title": ISSUE_TITLE, "body": body}) + number = call("POST", f"/repos/{repo}/issues", {"title": ISSUE_TITLE, "body": stamped})["number"] + previous = "" + + # A comment, not an edit: editing an issue body notifies nobody. + if mention and alert_needed(previous, current): + lines = "\n".join(f"- {f.where}: **{f.what}**" for f in findings) + call("POST", f"/repos/{repo}/issues/{number}/comments", + {"body": f"@{mention} the org health report changed:\n\n{lines}"}) def main() -> int: @@ -161,7 +189,8 @@ def main() -> int: if summary: Path(summary).write_text(report, encoding="utf-8") if args.issue and token: - publish(report, token, os.environ.get("GITHUB_REPOSITORY", "GetTechAPI/TechMachine")) + publish(report, token, os.environ.get("GITHUB_REPOSITORY", "GetTechAPI/TechMachine"), + findings, config.get("notify", "")) # The report is the output; a red run would only add a second alert. return 0 diff --git a/machine/repos.json b/machine/repos.json index 7ff93da..659e22b 100644 --- a/machine/repos.json +++ b/machine/repos.json @@ -1,13 +1,49 @@ { + "notify": "Seungpyo1007", "repos": [ - {"name": "GetTechAPI/TechAPI", "branches": ["develop", "main"]}, - {"name": "GetTechAPI/TechEngine", "branches": ["main"]}, - {"name": "GetTechAPI/game-catalog", "branches": ["develop", "main"]}, - {"name": "GetTechAPI/cpu-engineering-samples", "branches": ["develop", "main"]} + { + "name": "GetTechAPI/TechAPI", + "branches": [ + "develop", + "main" + ] + }, + { + "name": "GetTechAPI/TechEngine", + "branches": [ + "main" + ] + }, + { + "name": "GetTechAPI/game-catalog", + "branches": [ + "develop", + "main" + ] + }, + { + "name": "GetTechAPI/cpu-engineering-samples", + "branches": [ + "develop", + "main" + ] + } ], "endpoints": [ - {"name": "TechAPI manifest", "url": "https://gettechapi.github.io/TechAPI/v1/index.json", "expect": "collections"}, - {"name": "game-catalog summary", "url": "https://gettechapi.github.io/game-catalog/summary.json", "expect": "count"}, - {"name": "cpu-engineering-samples summary", "url": "https://gettechapi.github.io/cpu-engineering-samples/summary.json", "expect": "count"} + { + "name": "TechAPI manifest", + "url": "https://gettechapi.github.io/TechAPI/v1/index.json", + "expect": "collections" + }, + { + "name": "game-catalog summary", + "url": "https://gettechapi.github.io/game-catalog/summary.json", + "expect": "count" + }, + { + "name": "cpu-engineering-samples summary", + "url": "https://gettechapi.github.io/cpu-engineering-samples/summary.json", + "expect": "count" + } ] } diff --git a/tests/test_health.py b/tests/test_health.py index 943d225..053d825 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -51,3 +51,36 @@ def test_render_all_clear_and_problem_table(): assert "all clear" in render([], config) problems = render(run_findings("org/repo", "main", [_run("deploy", "cancelled")]), config) assert "1 problem" in problems and "| cancelled |" in problems + + +# --- alerts ----------------------------------------------------------------- + +from machine.health import Finding, alert_needed, fingerprint # noqa: E402 + +BROKEN = [Finding("TechEngine@main · weekly-refresh", "cancelled")] + + +def test_new_problem_alerts(): + assert alert_needed("", fingerprint(BROKEN)) + + +def test_same_problem_twice_does_not_alert_again(): + body = f"report\n\n" + assert not alert_needed(body, fingerprint(BROKEN)) + + +def test_a_different_problem_alerts(): + body = f"report\n\n" + worse = BROKEN + [Finding("TechAPI@main · deploy-pages", "failure")] + assert alert_needed(body, fingerprint(worse)) + + +def test_all_clear_never_alerts(): + body = f"report\n\n" + assert not alert_needed(body, fingerprint([])) + + +def test_fingerprint_ignores_order_and_links(): + a = [Finding("x", "failure", "https://run/1"), Finding("y", "cancelled", "https://run/2")] + b = [Finding("y", "cancelled", "https://run/9"), Finding("x", "failure", "https://run/8")] + assert fingerprint(a) == fingerprint(b)