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

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:
kert
2026-04-23 19:03:08 -04:00
parent f6e418e400
commit 759d8d515e
3 changed files with 155 additions and 0 deletions

78
src/bib/iom_alert.py Normal file
View 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)

View File

@@ -579,6 +579,12 @@ def refresh_oig() -> None:
@app.command(name="refresh-iom") @app.command(name="refresh-iom")
def refresh_iom( def refresh_iom(
pubs: list[str] = typer.Option(None, "--pub", "-p"), 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: ) -> None:
"""One-shot: ingest-iom → watch-iom → attach-iom → sync-zotero. """One-shot: ingest-iom → watch-iom → attach-iom → sync-zotero.
@@ -594,6 +600,7 @@ def refresh_iom(
from bib import connect from bib import connect
from bib.iom import check_future_updates, download_attachments, ingest_all 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 from bib.sync import push_to_zotero
store = connect() store = connect()
@@ -611,6 +618,10 @@ def refresh_iom(
f" futurepdf.pdf: {'CHANGED' if changed else 'unchanged'} " f" futurepdf.pdf: {'CHANGED' if changed else 'unchanged'} "
f"(sha256={digest[:12]})" 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") typer.echo("==> attach-iom")
attach_summary = download_attachments(store, client, pubs=pubs) attach_summary = download_attachments(store, client, pubs=pubs)

View 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