feat(bib): nightly refresh-iom + auto cms-update issue (#343)
Some checks failed
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Has been skipped
Infra CI / mc (push) Successful in 11s
Deploy / report (push) Successful in 12s
CI / test (push) Has been cancelled
CI / lint (push) Failing after 31s
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / mc (push) Has been skipped
Infra CI / notebooks (push) Failing after 12s
Infra CI / zotero (push) Successful in 12s
Infra CI / docs (push) Successful in 1m12s
Infra CI / api (push) Successful in 21s
Some checks failed
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Has been skipped
Infra CI / mc (push) Successful in 11s
Deploy / report (push) Successful in 12s
CI / test (push) Has been cancelled
CI / lint (push) Failing after 31s
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / mc (push) Has been skipped
Infra CI / notebooks (push) Failing after 12s
Infra CI / zotero (push) Successful in 12s
Infra CI / docs (push) Successful in 1m12s
Infra CI / api (push) Successful in 21s
Wires the existing idempotent `stack bib refresh-iom` command behind a systemd user timer (daily 03:30) and auto-files a Gitea issue when watch-iom detects a futurepdf.pdf SHA-256 change. - src/bib/iom_alert.py — file_iom_change_alert() opens an issue tagged 'cms-update', creating the label first if needed. - src/cli/bib.py — refresh-iom gains --file-issue/--no-file-issue (default True). Calls the alert helper when changed=True. - tests/bib/test_iom_alert.py — 4 unit tests. Systemd user units (host-specific, not in repo): - ~/.config/systemd/user/bib-refresh-iom.service - ~/.config/systemd/user/bib-refresh-iom.timer (OnCalendar=03:30) Out of scope: adaptive 6h-after-change scheduling; Slack alternative. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
78
src/bib/iom_alert.py
Normal file
78
src/bib/iom_alert.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""File a Gitea issue when CMS publishes a new IOM transmittal schedule.
|
||||
|
||||
Wired into ``stack bib refresh-iom``: when ``check_future_updates``
|
||||
reports the futurepdf.pdf SHA-256 has changed, we open an issue
|
||||
tagged ``cms-update`` so the change surfaces in the tracker without
|
||||
manual log-watching. Tracks #343.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import date
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_LABEL = "cms-update"
|
||||
_LABEL_COLOR = "fbca04" # CMS yellow
|
||||
|
||||
|
||||
def file_iom_change_alert(
|
||||
*,
|
||||
sha256: str,
|
||||
token: str | None = None,
|
||||
owner: str = "homelab",
|
||||
repo: str = "stack",
|
||||
) -> dict | None:
|
||||
"""Create a ``cms-update`` Gitea issue for a futurepdf.pdf change.
|
||||
|
||||
Returns the created issue dict, or ``None`` if no token is
|
||||
available (skip silently — useful in dev/test).
|
||||
"""
|
||||
token = token or os.environ.get("GITEA_TOKEN", "")
|
||||
if not token:
|
||||
log.warning("no GITEA_TOKEN — skipping IOM change alert")
|
||||
return None
|
||||
|
||||
from api.clients.gitea import GiteaClient
|
||||
|
||||
title = f"cms-update: futurepdf.pdf changed {date.today().isoformat()}"
|
||||
body = (
|
||||
f"CMS posted a new IOM transmittal schedule.\n\n"
|
||||
f"**Detected by:** `stack bib refresh-iom` (watch-iom step)\n"
|
||||
f"**New SHA-256:** `{sha256}`\n"
|
||||
f"**Date:** {date.today().isoformat()}\n\n"
|
||||
f"Review futurepdf.pdf for new transmittals or schedule changes."
|
||||
)
|
||||
|
||||
client = GiteaClient(token)
|
||||
try:
|
||||
_ensure_label(client, owner, repo)
|
||||
label_ids = client.resolve_labels(owner, repo, [_LABEL])
|
||||
payload: dict = {"title": title, "body": body}
|
||||
if label_ids:
|
||||
payload["labels"] = label_ids
|
||||
result = client.create_issue(owner, repo, payload)
|
||||
log.info("filed cms-update issue #%s", result.get("number"))
|
||||
return result
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def _ensure_label(client, owner: str, repo: str) -> None:
|
||||
"""Create ``cms-update`` label if it doesn't exist yet."""
|
||||
existing = {lbl["name"] for lbl in client.list_labels(owner, repo)}
|
||||
if _LABEL in existing:
|
||||
return
|
||||
try:
|
||||
client.post(
|
||||
f"/repos/{owner}/{repo}/labels",
|
||||
json={"name": _LABEL, "color": _LABEL_COLOR},
|
||||
)
|
||||
# Bust the GiteaClient label cache so resolve_labels sees it.
|
||||
if hasattr(client, "_label_cache"):
|
||||
del client._label_cache
|
||||
log.info("created %r label in %s/%s", _LABEL, owner, repo)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("could not create %r label: %s", _LABEL, e)
|
||||
@@ -579,6 +579,12 @@ def refresh_oig() -> None:
|
||||
@app.command(name="refresh-iom")
|
||||
def refresh_iom(
|
||||
pubs: list[str] = typer.Option(None, "--pub", "-p"),
|
||||
file_issue: bool = typer.Option(
|
||||
True,
|
||||
"--file-issue/--no-file-issue",
|
||||
help="When watch-iom detects a futurepdf.pdf change, auto-file a "
|
||||
"Gitea issue tagged 'cms-update'. Disable for local/dev runs.",
|
||||
),
|
||||
) -> None:
|
||||
"""One-shot: ingest-iom → watch-iom → attach-iom → sync-zotero.
|
||||
|
||||
@@ -594,6 +600,7 @@ def refresh_iom(
|
||||
|
||||
from bib import connect
|
||||
from bib.iom import check_future_updates, download_attachments, ingest_all
|
||||
from bib.iom_alert import file_iom_change_alert
|
||||
from bib.sync import push_to_zotero
|
||||
|
||||
store = connect()
|
||||
@@ -611,6 +618,10 @@ def refresh_iom(
|
||||
f" futurepdf.pdf: {'CHANGED' if changed else 'unchanged'} "
|
||||
f"(sha256={digest[:12]})"
|
||||
)
|
||||
if changed and file_issue:
|
||||
issue = file_iom_change_alert(sha256=digest)
|
||||
if issue:
|
||||
typer.echo(f" filed Gitea issue #{issue['number']}")
|
||||
|
||||
typer.echo("==> attach-iom")
|
||||
attach_summary = download_attachments(store, client, pubs=pubs)
|
||||
|
||||
66
tests/bib/test_iom_alert.py
Normal file
66
tests/bib/test_iom_alert.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Tests for bib.iom_alert — Gitea issue filing on futurepdf.pdf changes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from bib.iom_alert import _LABEL, file_iom_change_alert
|
||||
|
||||
|
||||
def test_no_token_returns_none(monkeypatch):
|
||||
monkeypatch.delenv("GITEA_TOKEN", raising=False)
|
||||
assert file_iom_change_alert(sha256="deadbeef" * 8) is None
|
||||
|
||||
|
||||
@patch("api.clients.gitea.GiteaClient")
|
||||
def test_creates_label_then_files_issue(mock_client_cls):
|
||||
client = MagicMock()
|
||||
# Pretend label doesn't exist on first call
|
||||
client.list_labels.return_value = []
|
||||
client.resolve_labels.return_value = [42]
|
||||
client.create_issue.return_value = {"number": 999, "html_url": "https://x"}
|
||||
mock_client_cls.return_value = client
|
||||
|
||||
result = file_iom_change_alert(sha256="abc123", token="fake-token")
|
||||
|
||||
assert result == {"number": 999, "html_url": "https://x"}
|
||||
# Label create attempted because list_labels returned empty
|
||||
client.post.assert_called_once()
|
||||
call_kwargs = client.post.call_args
|
||||
assert call_kwargs[0][0] == "/repos/homelab/stack/labels"
|
||||
assert call_kwargs[1]["json"]["name"] == _LABEL
|
||||
# Issue created with title containing 'cms-update' + body with sha256
|
||||
issue_payload = client.create_issue.call_args[0][2]
|
||||
assert issue_payload["title"].startswith("cms-update:")
|
||||
assert "abc123" in issue_payload["body"]
|
||||
assert issue_payload["labels"] == [42]
|
||||
|
||||
|
||||
@patch("api.clients.gitea.GiteaClient")
|
||||
def test_skips_label_create_when_existing(mock_client_cls):
|
||||
client = MagicMock()
|
||||
client.list_labels.return_value = [{"name": _LABEL, "id": 7}]
|
||||
client.resolve_labels.return_value = [7]
|
||||
client.create_issue.return_value = {"number": 5}
|
||||
mock_client_cls.return_value = client
|
||||
|
||||
file_iom_change_alert(sha256="x", token="t")
|
||||
|
||||
# Did NOT call POST to create the label
|
||||
client.post.assert_not_called()
|
||||
|
||||
|
||||
@patch("api.clients.gitea.GiteaClient")
|
||||
def test_files_without_label_when_resolve_returns_empty(mock_client_cls):
|
||||
"""resolve_labels silently skips unknown — issue should still file."""
|
||||
client = MagicMock()
|
||||
client.list_labels.return_value = [{"name": _LABEL, "id": 1}]
|
||||
# Simulate resolve_labels returning [] (e.g. cache stale)
|
||||
client.resolve_labels.return_value = []
|
||||
client.create_issue.return_value = {"number": 10}
|
||||
mock_client_cls.return_value = client
|
||||
|
||||
file_iom_change_alert(sha256="x", token="t")
|
||||
|
||||
issue_payload = client.create_issue.call_args[0][2]
|
||||
assert "labels" not in issue_payload
|
||||
Reference in New Issue
Block a user