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")
|
@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)
|
||||||
|
|||||||
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