-
Notifications
You must be signed in to change notification settings - Fork 1
T5-P5-A8-WP1 Provide the Reusable Canary State Machine and GitHub Issue Updater #4107
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
00dc5a6
2e2682c
a9d4113
e157289
4a4aae7
74ced69
5bb2142
50cdfdd
4aa41ca
69612a7
bb50956
1ccdf65
46fed76
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| """Reusable canary state machine and GitHub issue updater for live probes. | ||
|
|
||
| IL-1 module: imports only stdlib and `autoskillit.core`. Provides the | ||
|
Trecek marked this conversation as resolved.
|
||
| persistence + flake-guard primitives that live probe classes build on. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import os | ||
| import subprocess | ||
| import tempfile | ||
| from dataclasses import asdict, dataclass | ||
| from enum import StrEnum, unique | ||
| from pathlib import Path | ||
|
|
||
| from autoskillit.core import atomic_write, get_logger, run_gh | ||
|
|
||
| logger = get_logger(__name__) | ||
|
|
||
| N_CONSECUTIVE_FLAKE_GUARD: int = 3 | ||
|
|
||
|
|
||
| @unique | ||
| class ErrorKind(StrEnum): | ||
| NETWORK = "network" | ||
| SCHEMA = "schema" | ||
|
|
||
|
|
||
| @dataclass | ||
| class CanaryState: | ||
| network_streak: int = 0 | ||
| schema_streak: int = 0 | ||
| last_issue_number: int | None = None | ||
|
|
||
| @classmethod | ||
| def load(cls, path: Path) -> CanaryState: | ||
| try: | ||
| raw = json.loads(path.read_text(encoding="utf-8")) | ||
| except (FileNotFoundError, json.JSONDecodeError, OSError): | ||
| return cls() | ||
| if not isinstance(raw, dict): | ||
| return cls() | ||
| return cls( | ||
| network_streak=raw.get("network_streak", 0), | ||
| schema_streak=raw.get("schema_streak", 0), | ||
| last_issue_number=raw.get("last_issue_number"), | ||
| ) | ||
|
|
||
| def save(self, path: Path) -> None: | ||
| atomic_write(path, json.dumps(asdict(self), indent=2)) | ||
|
|
||
| def record_failure(self, kind: ErrorKind) -> None: | ||
| if kind is ErrorKind.NETWORK: | ||
| self.network_streak += 1 | ||
| elif kind is ErrorKind.SCHEMA: | ||
| self.schema_streak += 1 | ||
| else: | ||
| raise ValueError(f"Unhandled ErrorKind: {kind!r}") | ||
|
|
||
| def record_success(self) -> None: | ||
| self.network_streak = 0 | ||
| self.schema_streak = 0 | ||
|
|
||
| def should_report(self, flake_guard: int = N_CONSECUTIVE_FLAKE_GUARD) -> bool: | ||
| return self.network_streak >= flake_guard or self.schema_streak >= flake_guard | ||
|
|
||
|
|
||
| def _run_gh_with_body_file(args: list[str], body: str) -> subprocess.CompletedProcess[str]: | ||
|
Trecek marked this conversation as resolved.
|
||
| with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: | ||
| f.write(body) | ||
| body_path = f.name | ||
| try: | ||
| return run_gh([*args, "--body-file", body_path]) | ||
| finally: | ||
| try: | ||
| os.unlink(body_path) | ||
| except OSError: | ||
| logger.debug("canary_body_file_unlink_failed", path=body_path) | ||
|
|
||
|
|
||
| class CanaryIssueUpdater: | ||
| def __init__(self, *, owner: str, repo: str) -> None: | ||
| self._owner = owner | ||
| self._repo = repo | ||
|
|
||
| def ensure_issue(self, state: CanaryState, title: str, body: str) -> int: | ||
| existing = self._find_existing(title) | ||
| if existing is not None: | ||
| result = _run_gh_with_body_file( | ||
| [ | ||
| "issue", | ||
| "edit", | ||
| str(existing), | ||
| "--repo", | ||
| f"{self._owner}/{self._repo}", | ||
| ], | ||
| body, | ||
| ) | ||
| if result.returncode != 0: | ||
| logger.warning( | ||
| "canary_issue_edit_failed", | ||
| issue=existing, | ||
| stderr=result.stderr, | ||
| ) | ||
| state.last_issue_number = existing | ||
| return existing | ||
| result = _run_gh_with_body_file( | ||
| [ | ||
| "issue", | ||
| "create", | ||
| "--repo", | ||
| f"{self._owner}/{self._repo}", | ||
| "--title", | ||
| title, | ||
| "--json", | ||
| "number", | ||
| ], | ||
| body, | ||
| ) | ||
| if result.returncode != 0: | ||
| msg = f"gh issue create failed: {result.stderr}" | ||
| raise RuntimeError(msg) | ||
| try: | ||
| issue_number = json.loads(result.stdout)["number"] | ||
| except (json.JSONDecodeError, KeyError) as exc: | ||
| msg = f"gh issue create returned unexpected output: {result.stdout!r}" | ||
| raise RuntimeError(msg) from exc | ||
| state.last_issue_number = issue_number | ||
| return issue_number | ||
|
|
||
| def _find_existing(self, title: str) -> int | None: | ||
| result = run_gh( | ||
| [ | ||
| "issue", | ||
| "list", | ||
| "--repo", | ||
| f"{self._owner}/{self._repo}", | ||
| "--search", | ||
| title, | ||
| "--state", | ||
| "open", | ||
| "--json", | ||
| "number,title", | ||
| "--limit", | ||
| "10", | ||
| ], | ||
| ) | ||
| if result.returncode != 0: | ||
|
Trecek marked this conversation as resolved.
|
||
| logger.warning( | ||
| "canary_find_existing_failed", | ||
| returncode=result.returncode, | ||
| stderr=result.stderr, | ||
| ) | ||
| return None | ||
| try: | ||
| issues = json.loads(result.stdout) | ||
| except json.JSONDecodeError: | ||
| return None | ||
| if not isinstance(issues, list): | ||
| return None | ||
| for issue in issues: | ||
| if issue.get("title") == title: | ||
| number = issue.get("number") | ||
| if isinstance(number, int): | ||
| return number | ||
| return None | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Observation from local review round 0: [warning] bugs: GitHub Evidence: Code already filters by exact title match at L158 (issue.get('title') == title). The --limit 10 truncation risk is real but adding in:title changes search semantics. Design decision required on acceptable precision vs. current behavior.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Valid observation — flagged for design decision. Code already filters by exact title match at L155 (issue.get('title') == title). The --limit 10 truncation risk is real but adding 'in:title' changes search semantics. Design decision required on acceptable search precision vs. current behavior. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| """Versioned probe result cache with 24-hour TTL. | ||
|
|
||
| IL-1 module: imports only stdlib and `autoskillit.core`. Stores a per-cli-version | ||
| `ProbeResult` keyed by `cli_version`, surviving across runs while staying | ||
| under `PROBE_CACHE_TTL`. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass | ||
| from datetime import UTC, datetime, timedelta | ||
| from pathlib import Path | ||
|
|
||
| from autoskillit.core import get_logger, read_versioned_json, write_versioned_json | ||
|
|
||
| logger = get_logger(__name__) | ||
|
|
||
| PROBE_CACHE_TTL: timedelta = timedelta(hours=24) | ||
| _SCHEMA_VERSION: int = 1 | ||
|
|
||
|
|
||
| @dataclass(frozen=True, slots=True) | ||
| class ProbeResult: | ||
| cli_version: str | ||
| passed: bool | ||
| failure_detail: str | None | ||
| probe_timestamp: str | ||
|
|
||
|
|
||
| def read_probe_cache(cache_path: Path, cli_version: str) -> ProbeResult | None: | ||
| raw = read_versioned_json(cache_path, _SCHEMA_VERSION, logger=logger) | ||
| if raw is None: | ||
| return None | ||
| entries: dict[str, dict] = raw.get("entries", {}) | ||
| entry = entries.get(cli_version) | ||
| if entry is None: | ||
| return None | ||
| try: | ||
| ts = datetime.fromisoformat(entry["probe_timestamp"]) | ||
|
Trecek marked this conversation as resolved.
|
||
| if (datetime.now(UTC) - ts) > PROBE_CACHE_TTL: | ||
| return None | ||
| except (KeyError, ValueError, TypeError): | ||
| return None | ||
| return ProbeResult( | ||
| cli_version=cli_version, | ||
| passed=entry.get("passed", False), | ||
| failure_detail=entry.get("failure_detail"), | ||
| probe_timestamp=entry["probe_timestamp"], | ||
| ) | ||
|
|
||
|
|
||
| def write_probe_cache(cache_path: Path, result: ProbeResult) -> None: | ||
| try: | ||
| existing = read_versioned_json(cache_path, _SCHEMA_VERSION, logger=logger) | ||
| entries: dict[str, dict] = (existing or {}).get("entries", {}) | ||
| entries[result.cli_version] = { | ||
| "passed": result.passed, | ||
| "failure_detail": result.failure_detail, | ||
| "probe_timestamp": result.probe_timestamp, | ||
| } | ||
| write_versioned_json(cache_path, {"entries": entries}, _SCHEMA_VERSION) | ||
| except OSError: | ||
| logger.debug("probe_cache_write_failed", path=str(cache_path), exc_info=True) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Observation from local review round 0:
[warning] cohesion: File bundles two unrelated responsibilities: (a) pure state machine (CanaryState, ErrorKind, N_CONSECUTIVE_FLAKE_GUARD) with no GitHub dependency, and (b) GitHub API caller (CanaryIssueUpdater with run_gh). Split into separate modules.
Evidence: The WP1 task description explicitly named 'canary state machine AND GitHub issue updater' as the deliverable — they were intentionally co-located. Splitting would affect existing importers and is a design decision requiring human judgment about whether the coupling is acceptable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Valid observation — flagged for design decision. The WP1 task description explicitly named 'canary state machine AND GitHub issue updater' as the deliverable — they were intentionally co-located. Splitting would affect existing importers and requires a human decision about whether the coupling is acceptable.