diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..01451d9 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,45 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + +permissions: {} + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # One entry per tool. Add a directory here when you add a package. + package: [ai-failure-notifier] + python-version: ['3.10', '3.12', '3.14'] + defaults: + run: + working-directory: ${{ matrix.package }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + python-version: ${{ matrix.python-version }} + # --locked, so that a lockfile which no longer matches its pyproject.toml + # fails here rather than silently resolving to something else. + - run: uv sync --locked --group unit + - run: uv run pytest + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + # Ruff is pinned in the root dependency group, and its configuration + # lives there too, so both run once across every package rather than + # per matrix entry. + - run: uv run --group lint ruff check . + - run: uv run --group lint ruff format --check . diff --git a/.gitignore b/.gitignore index 83972fa..e67f161 100644 --- a/.gitignore +++ b/.gitignore @@ -216,3 +216,6 @@ __marimo__/ # Streamlit .streamlit/secrets.toml + +# uv +.venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..91ba797 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# charm-tech-code + +Shared tooling for the Charm Tech repositories (`operator`, `charmlibs`, `jubilant`, `pebble`, `concierge`, and the rest of the estate). + +Each tool is its own package in its own top-level directory, with its own `pyproject.toml`, `src/`, `tests/` and lockfile - the same shape `canonical/charmlibs` uses. Adding a tool means adding a directory, not adding to an existing package, so a tool's dependencies are paid only by the workflows that run that tool. + +| directory | what it does | +|---|---| +| [`ai-failure-notifier`](ai-failure-notifier) | Triages and enriches the issue opened when a scheduled workflow fails. | + +Code here is consumed by workflow YAML in the repository that runs it, pinned by commit SHA: + +```yaml +run: uvx --from "git+https://github.com/canonical/charm-tech-code@<40-char-sha>#subdirectory=ai-failure-notifier" ai-failure-notifier +``` + +The point is that the code lives in one place. A tool used by eleven repositories should be fixed once, not eleven times, and the workflow YAML that differs per repository stays in that repository. + +There is no release process and nothing is published. The SHA in the `uvx` line is the version, which is the same trust decision every pinned `uses: actions/checkout@` line in those repositories already makes. + +## Configuration + +Ruff's configuration lives in the root `pyproject.toml` and is copied from `canonical/operator`, so that a file can move between the two repositories without being reformatted. Packages deliberately do not carry their own `[tool.ruff]` block: ruff uses the closest configuration it finds rather than merging, so a local one would silently override the shared one. + +`preview` is set in configuration rather than passed as `--preview` on the command line, which is how operator's `tox.ini` does it. That way an editor, a hook and CI agree without anyone having to remember the flag. It is load-bearing rather than cosmetic - the preview style hugs brackets inside calls, and without it a good deal of existing code reformats. + +## Developing + +```shell +cd +uv sync --group unit +uv run pytest +``` + +Lint and format run from the root, across every package at once: + +```shell +uv run --group lint ruff check . +uv run --group lint ruff format --check . +``` diff --git a/ai-failure-notifier/README.md b/ai-failure-notifier/README.md new file mode 100644 index 0000000..c1e2d09 --- /dev/null +++ b/ai-failure-notifier/README.md @@ -0,0 +1,22 @@ +# ai-failure-notifier + +Enriches the placeholder issue that a scheduled workflow's failure notifier opens. + +When a scheduled workflow fails, the notifier in the repository opens an issue with a generic title and a link to the failing job. This tool picks that placeholder up, reads the failing run's job logs, reduces them to a deterministic failure signature, searches for issues that look like the same failure, and then either comments on the existing one or rewrites the placeholder with a real title, body and labels. + +Without an API key it falls back to a plain notification. That is deliberate: the notifier has to work when everything else is broken, so nothing here is allowed to be a hard dependency of it. + +## Running it + +```shell +uvx --from "git+https://github.com/canonical/charm-tech-code@<40-char-sha>#subdirectory=ai-failure-notifier" ai-failure-notifier +``` + +It reads its inputs from the environment: `GH_TOKEN`, `REPO`, `RUN_ID`, `WORKFLOW_NAME`, `RUN_URL`, and optionally `OPENROUTER_API_KEY` and `OPENROUTER_MODEL`. See `canonical/operator`'s `.github/workflows/ai-failure-enrich.yaml` for the calling side, including the environment mechanics the key depends on. + +## Developing + +```shell +uv sync --group unit +uv run pytest +``` diff --git a/ai-failure-notifier/pyproject.toml b/ai-failure-notifier/pyproject.toml new file mode 100644 index 0000000..37a3021 --- /dev/null +++ b/ai-failure-notifier/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "charm-tech-code-ai-failure-notifier" +version = "0.1.0" +description = "Triage and enrich the issues opened when a scheduled workflow fails." +readme = "README.md" +requires-python = ">=3.10" +authors = [ + {name = "The Charm Tech team at Canonical Ltd."}, +] +license = "Apache-2.0" +# Deliberately none. The tool is invoked by `uvx --from git+...` on a GitHub +# runner, so every dependency added here is paid on every failed scheduled +# run, in a workflow whose whole point is to still work when things are +# broken. It talks to GitHub through `gh`, which the runner already has, and +# to OpenRouter through urllib. +dependencies = [] + +[project.scripts] +ai-failure-notifier = "charm_tech_code.ai_failure_notifier:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/charm_tech_code"] + +[dependency-groups] +unit = ["pytest"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +# Ruff configuration is at the root of the monorepo, deliberately not repeated +# here: ruff uses the closest config it finds rather than merging, so a +# [tool.ruff] block in this file would silently override the shared one. diff --git a/ai-failure-notifier/src/charm_tech_code/ai_failure_notifier/__init__.py b/ai-failure-notifier/src/charm_tech_code/ai_failure_notifier/__init__.py new file mode 100644 index 0000000..3e395d9 --- /dev/null +++ b/ai-failure-notifier/src/charm_tech_code/ai_failure_notifier/__init__.py @@ -0,0 +1,20 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Triage and enrich the issue opened when a scheduled workflow fails.""" + +from charm_tech_code.ai_failure_notifier.cli import main + +__all__ = ['main'] diff --git a/ai-failure-notifier/src/charm_tech_code/ai_failure_notifier/apply.py b/ai-failure-notifier/src/charm_tech_code/ai_failure_notifier/apply.py new file mode 100644 index 0000000..34720b7 --- /dev/null +++ b/ai-failure-notifier/src/charm_tech_code/ai_failure_notifier/apply.py @@ -0,0 +1,79 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Writing the decision back to GitHub, and the no-LLM fallback.""" + +from __future__ import annotations + +from typing import Any + +from charm_tech_code.ai_failure_notifier import github, summary + + +def plain_fallback_body(workflow_name: str, run_url: str) -> str: + """The plain, generic body text used whenever enrichment is unavailable.""" + return f"Scheduled workflow '{workflow_name}' failed: {run_url}" + + +def render_body(body: str, workflow_name: str, marker: str) -> str: + """Assemble an issue or comment body, footer and marker included. + + The `Workflow: ` footer is what keeps the notifier's coarse search + working after enrichment has rewritten the title and body: the search + matches on the workflow name, and without the footer it would depend on + the model happening to leave the name in the title. + """ + return f'{body.rstrip()}\n\nWorkflow: {workflow_name}\n\n{marker}' + + +def apply_entry( + repo: str, + entry: dict[str, Any], + marker: str, + workflow_name: str, + *, + default_target: int | None = None, +) -> str: + """Create or comment on an issue per one envelope entry, stamping `marker`.""" + body = render_body(entry['body'], workflow_name, marker) + if entry['action'] == 'new': + # The repo's label set is centrally managed, so anything the model + # asked for that doesn't exist is dropped rather than created. + labels = github.filter_labels(entry.get('labels') or [], github.existing_labels(repo)) + dropped = set(entry.get('labels') or []) - set(labels) + if dropped: + summary.write_step_summary( + f'Dropped labels that do not exist in this repo: {", ".join(sorted(dropped))}.' + ) + args = ['issue', 'create', '--repo', repo, '--title', entry['title'], '--body', body] + for label in labels: + args += ['--label', label] + issue_type = entry.get('issue_type') + result = None + if issue_type: + result = github.gh(*args, '--type', issue_type, check=False) + if result.returncode != 0: + summary.write_step_summary( + f'`gh issue create --type {issue_type}` failed ({result.stderr.strip()}); ' + 'retrying without --type.' + ) + result = None + if result is None: + result = github.gh(*args) + return result.stdout.strip() + else: + target = entry.get('target_issue', default_target) + github.gh('issue', 'comment', str(target), '--repo', repo, '--body', body) + return f'commented on #{target}' diff --git a/ai-failure-notifier/src/charm_tech_code/ai_failure_notifier/candidates.py b/ai-failure-notifier/src/charm_tech_code/ai_failure_notifier/candidates.py new file mode 100644 index 0000000..64de7f5 --- /dev/null +++ b/ai-failure-notifier/src/charm_tech_code/ai_failure_notifier/candidates.py @@ -0,0 +1,69 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Building the pool of issues a failure might already have.""" + +from __future__ import annotations + +import datetime + +from charm_tech_code.ai_failure_notifier.constants import ( + CLOSED_CANDIDATE_WINDOW_DAYS, + MAX_CANDIDATES, +) +from charm_tech_code.ai_failure_notifier.models import CandidateIssue + + +def within_window(iso_timestamp: str, now: datetime.datetime, days: int) -> bool: + """Return whether `iso_timestamp` falls within `days` of `now`.""" + ts = datetime.datetime.fromisoformat(iso_timestamp.replace('Z', '+00:00')) + return now - ts <= datetime.timedelta(days=days) + + +def build_candidates_block( + open_issues: list[CandidateIssue], + closed_issues: list[CandidateIssue], + now: datetime.datetime, +) -> str: + """Render the {{CANDIDATES_BLOCK}} the prompt expects. + + Up to MAX_CANDIDATES entries: open issues first, then recently-closed + issues (<=14 days) filling any remaining slots, explicitly labelled as + closed so the LLM never auto-treats one as a strong match. Calibration on + past scheduled failures found a closed issue can corroborate a match but + should never be enough to dedupe against on its own. + """ + entries: list[str] = [] + for issue in open_issues: + if len(entries) >= MAX_CANDIDATES: + break + entries.append(f'- **#{issue.number} — {issue.title}** (open)\n > {issue.excerpt()}') + + recent_closed = [ + i + for i in closed_issues + if i.closed_at and within_window(i.closed_at, now, CLOSED_CANDIDATE_WINDOW_DAYS) + ] + for issue in recent_closed: + if len(entries) >= MAX_CANDIDATES: + break + entries.append( + f'- **#{issue.number} — {issue.title}** (closed {issue.closed_at} -- ' + f'recently closed; treat as at most a medium-confidence match)\n > {issue.excerpt()}' + ) + + if not entries: + return '(no open issues found for this workflow)' + return '\n'.join(entries) diff --git a/ai-failure-notifier/src/charm_tech_code/ai_failure_notifier/cli.py b/ai-failure-notifier/src/charm_tech_code/ai_failure_notifier/cli.py new file mode 100644 index 0000000..0566cee --- /dev/null +++ b/ai-failure-notifier/src/charm_tech_code/ai_failure_notifier/cli.py @@ -0,0 +1,343 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Entry point.""" + +from __future__ import annotations + +import dataclasses +import datetime +import os +import sys +from typing import Any + +from charm_tech_code.ai_failure_notifier import github, openrouter, prompt, summary +from charm_tech_code.ai_failure_notifier.apply import apply_entry, plain_fallback_body, render_body +from charm_tech_code.ai_failure_notifier.candidates import build_candidates_block +from charm_tech_code.ai_failure_notifier.constants import DEFAULT_MODEL, MARKER_PREFIX +from charm_tech_code.ai_failure_notifier.envelope import normalise_envelope, validate_envelope +from charm_tech_code.ai_failure_notifier.markers import render_enriched_marker +from charm_tech_code.ai_failure_notifier.models import RunSignature +from charm_tech_code.ai_failure_notifier.signatures import build_job_signature, build_run_signature + + +@dataclasses.dataclass(frozen=True) +class _RunConfig: + """The environment `main` runs with, read once up front.""" + + repo: str + run_id: str + workflow_name: str + run_url: str + api_key: str + model: str + # What the notifier did, when it tells us. Both are optional: an + # unmigrated caller, or a notifier that failed before it got as far as an + # issue, leaves them empty and we go looking instead. + notify_issue: int | None + notify_origin: str | None + + +def _read_config() -> _RunConfig: + """Read the workflow's environment into a `_RunConfig`.""" + return _RunConfig( + repo=os.environ['REPO'], + run_id=str(os.environ['RUN_ID']), + workflow_name=os.environ['WORKFLOW_NAME'], + run_url=os.environ['RUN_URL'], + api_key=os.environ.get('OPENROUTER_API_KEY', ''), + model=os.environ.get('OPENROUTER_MODEL') or DEFAULT_MODEL, + notify_issue=int(os.environ['NOTIFY_ISSUE']) if os.environ.get('NOTIFY_ISSUE') else None, + notify_origin=os.environ.get('NOTIFY_ORIGIN') or None, + ) + + +def _resolve_origin(config: _RunConfig) -> tuple[int | None, str | None, int | None]: + """Locate the run's marker, degrading to "un-marked" if the lookup fails.""" + try: + return github.resolve_origin( + config.repo, config.run_id, config.notify_issue, config.notify_origin + ) + except Exception as exc: # search API rejection, rate limit, transient 5xx. + # Nothing catches this above us: there is no workflow-level fallback + # job any more, so an uncaught failure here loses the enrichment + # outright rather than degrading through the paths below. When the + # notifier told us its issue we can still carry on with that; without + # it we continue as though the run were un-marked. + summary.write_step_summary( + f'Marker lookup failed ({exc}); treating this run as un-marked.' + ) + return None, config.notify_origin, config.notify_issue + + +def _comment_on_rerun(config: _RunConfig, enriched_issue: int) -> None: + """Rung zero: a re-run of the same failing jobs re-triggered us. + + Comment, don't skip and don't redo the full LLM pass. On a corpus of past + scheduled failures this rung accounted for half the real duplicate pairs, + making it the highest-value one. + """ + github.gh( + 'issue', + 'comment', + str(enriched_issue), + '--repo', + config.repo, + '--body', + f'Re-run attempt still failing: {config.run_url}\n\n' + f'', + ) + summary.write_step_summary( + f'Rung zero: run {config.run_id} already enriched on #{enriched_issue}; ' + 'commented re-run note.' + ) + + +def _create_placeholder_issue(config: _RunConfig) -> tuple[int, str]: + """Open a plain placeholder issue when no origin marker was found. + + Either a caller that has not been migrated to pass the issue through, or a + marker lookup that failed. The first is the normal state of a repo part + way through adopting this, so don't treat it as an anomaly -- just don't + lose the notification. + """ + summary.write_step_summary( + 'No notifier marker found for this run id; falling back to a plain issue.' + ) + result = github.gh( + 'issue', + 'create', + '--repo', + config.repo, + '--title', + f"Scheduled workflow '{config.workflow_name}' failed", + '--body', + plain_fallback_body(config.workflow_name, config.run_url) + + f'\n\n', + ) + origin_issue = int(result.stdout.strip().rstrip('/').rsplit('/', 1)[-1]) + return origin_issue, 'new' + + +def _build_run_signature(config: _RunConfig) -> RunSignature: + """Fetch the run's failed jobs and metadata, and reduce them to a signature.""" + failed_jobs = github.fetch_failed_jobs(config.repo, config.run_id) + jobs_sig = [ + build_job_signature( + job.id, + job.name, + job.failed_step, + github.fetch_job_log(config.repo, config.run_id, job.id), + ) + for job in failed_jobs + ] + meta = github.fetch_run_meta(config.repo, config.run_id) + return build_run_signature( + config.run_id, config.workflow_name, config.run_url, meta.get('createdAt', ''), jobs_sig + ) + + +def _plain_fallback_entry(config: _RunConfig, origin_kind: str | None, origin_issue: int) -> Any: + """Build the envelope-shaped entry `apply_entry` uses when there is no LLM output.""" + if origin_kind == 'comment': + return { + 'action': 'comment', + 'body': plain_fallback_body(config.workflow_name, config.run_url), + 'target_issue': origin_issue, + } + return { + 'action': 'new', + 'body': plain_fallback_body(config.workflow_name, config.run_url), + 'title': f"Scheduled workflow '{config.workflow_name}' failed", + 'labels': [], + 'issue_type': None, + } + + +def _apply_plain_fallback( + config: _RunConfig, origin_kind: str | None, origin_issue: int, enriched_marker: str +) -> None: + """Apply the plain fallback entry against `origin_issue`.""" + apply_entry( + config.repo, + _plain_fallback_entry(config, origin_kind, origin_issue), + enriched_marker, + config.workflow_name, + default_target=origin_issue, + ) + + +def _search_candidates(config: _RunConfig, origin_kind: str | None, origin_issue: int) -> str: + """Build the {{CANDIDATES_BLOCK}} for the prompt, degrading to "none" on search failure.""" + try: + open_candidates, closed_candidates = github.search_candidates( + config.repo, config.workflow_name + ) + except Exception as exc: # as above: degrade to "no candidates", don't crash. + summary.write_step_summary( + f'Candidate search failed ({exc}); proceeding with no candidates.' + ) + open_candidates, closed_candidates = [], [] + if origin_kind == 'new': + # The placeholder this run just created is not a candidate to dedupe + # against. An issue the notifier *commented* on is a different matter: + # it already existed, the coarse search matched it, and it is the most + # likely duplicate -- dropping it left the model blind to the very + # issue it should have been comparing against, so it answered "new" + # and produced the duplicate this whole path exists to avoid. + open_candidates = [c for c in open_candidates if c.number != origin_issue] + return build_candidates_block( + open_candidates, closed_candidates, datetime.datetime.now(datetime.timezone.utc) + ) + + +def _fetch_envelope( + config: _RunConfig, origin_kind: str | None, origin_issue: int, signature: RunSignature +) -> Any: + """Ask the LLM to triage the failure, returning `None` on any failure along the way.""" + candidates_block = _search_candidates(config, origin_kind, origin_issue) + system_prompt, user_prompt = prompt.build_prompt( + config.workflow_name, config.run_url, signature, candidates_block + ) + + try: + envelope = openrouter.call_openrouter( + system_prompt, user_prompt, config.model, config.api_key + ) + except Exception as exc: # network error, non-2xx, bad JSON, and so on. + summary.write_step_summary( + f'OpenRouter call failed ({exc}); using the plain fallback body.' + ) + return None + + envelope, dropped_fields = normalise_envelope(envelope) + if dropped_fields: + summary.write_step_summary( + 'Ignored fields that do not apply to the chosen action: ' + + ', '.join(dropped_fields) + + '.' + ) + + errors = validate_envelope(envelope) + if errors: + summary.write_step_summary( + 'LLM output failed schema validation:\n' + '\n'.join(f'- {e}' for e in errors) + ) + return None + + return envelope + + +def _apply_envelope( + config: _RunConfig, + envelope: Any, + origin_kind: str | None, + origin_issue: int, + enriched_marker: str, +) -> None: + """Act on a validated LLM envelope: upgrade, comment, or open a new issue.""" + if envelope['action'] == 'new' and origin_kind == 'new': + # Upgrade the placeholder in place rather than creating a duplicate. + available = github.existing_labels(config.repo) + labels = github.filter_labels(envelope.get('labels') or [], available) + edit_args = [ + 'issue', + 'edit', + str(origin_issue), + '--repo', + config.repo, + '--title', + envelope['title'], + '--body', + render_body(envelope['body'], config.workflow_name, enriched_marker), + ] + for label in labels: + edit_args += ['--add-label', label] + github.gh(*edit_args) + elif envelope['action'] == 'comment' and envelope.get('target_issue') == origin_issue: + apply_entry( + config.repo, + envelope, + enriched_marker, + config.workflow_name, + default_target=origin_issue, + ) + elif envelope['action'] == 'comment': + # LLM picked a different candidate than the notifier's coarse match. + apply_entry(config.repo, envelope, enriched_marker, config.workflow_name) + if origin_kind == 'comment': + github.gh( + 'issue', + 'comment', + str(origin_issue), + '--repo', + config.repo, + '--body', + f'This looks like a distinct issue -- see #{envelope["target_issue"]}.\n\n' + f'{enriched_marker}', + ) + else: + # action == "new" but origin_kind == "comment": the coarse title + # match landed on an unrelated older issue; this is genuinely new. + apply_entry(config.repo, envelope, enriched_marker, config.workflow_name) + github.gh( + 'issue', + 'comment', + str(origin_issue), + '--repo', + config.repo, + '--body', + f'This looks like a distinct issue from this one -- opened separately.\n\n' + f'{enriched_marker}', + ) + + for also_entry in envelope.get('also') or []: + apply_entry(config.repo, also_entry, enriched_marker, config.workflow_name) + + +def main() -> int: + """Entry point: locate the run's marker, enrich or fall back, apply, and exit.""" + config = _read_config() + + enriched_issue, origin_kind, origin_issue = _resolve_origin(config) + + if enriched_issue is not None: + _comment_on_rerun(config, enriched_issue) + return 0 + + if origin_issue is None: + origin_issue, origin_kind = _create_placeholder_issue(config) + + signature = _build_run_signature(config) + enriched_marker = render_enriched_marker(config.run_id, signature) + + if not config.api_key: + summary.write_step_summary( + 'No OPENROUTER_API_KEY configured -- using the plain fallback body.' + ) + _apply_plain_fallback(config, origin_kind, origin_issue, enriched_marker) + return 0 + + envelope = _fetch_envelope(config, origin_kind, origin_issue, signature) + if envelope is None: + _apply_plain_fallback(config, origin_kind, origin_issue, enriched_marker) + return 0 + + _apply_envelope(config, envelope, origin_kind, origin_issue, enriched_marker) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/ai-failure-notifier/src/charm_tech_code/ai_failure_notifier/constants.py b/ai-failure-notifier/src/charm_tech_code/ai_failure_notifier/constants.py new file mode 100644 index 0000000..51edd54 --- /dev/null +++ b/ai-failure-notifier/src/charm_tech_code/ai_failure_notifier/constants.py @@ -0,0 +1,121 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Regexes, marker shapes and tuning constants.""" + +from __future__ import annotations + +import re + +MARKER_PREFIX = 'ai-failure-notifications' +DEFAULT_MODEL = 'deepseek/deepseek-chat' # DeepSeek V3 on OpenRouter. +CLOSED_CANDIDATE_WINDOW_DAYS = 14 +MAX_CANDIDATES = 3 +# How many recently-updated issues to scan for the notifier's marker. The +# artefact we are looking for was touched minutes ago, so this only has to +# cover issue churn in that window; 50 is far more than `operator` sees. +RECENT_ISSUE_SCAN = 50 + +# Colour escapes, which Actions logs are full of. Two alternatives, because +# the logs contain both the real thing and a mangled form where the ESC byte +# has already been stripped, leaving a bare "[32m". +ANSI = re.compile( + r""" + \x1b\[ [0-9;]* [A-Za-z] # a full escape: ESC [ params letter + | + \[ \d+ (?:;\d+)* m # ESC already stripped: [32m, [1;33m + """, + re.VERBOSE, +) + +# The timestamp Actions prefixes to every log line, for example +# "2026-07-21T16:17:04.8204062Z ". Stripped before anything else is matched. +TS = re.compile( + r""" + ^\d{4}-\d{2}-\d{2} # date: 2026-07-21 + T\d{2}:\d{2}:\d{2} # time: T16:17:04 + \.\d+Z[ ] # fractional seconds, zone, one trailing space + """, + re.VERBOSE, +) + +# Actions' own annotation for a failing step. +ERROR_MARKER = re.compile(r'##\[error\]') + +# The runner opens every step with "##[group]Run