AI Implementation feature(1084): Blog Repository Clone and Pull Workflow (#3)

This commit was merged in pull request #3.
This commit is contained in:
2026-08-04 18:00:55 +00:00
parent 207e2e6bbb
commit 3fe2b90f51
9 changed files with 477 additions and 23 deletions
+165 -13
View File
@@ -1,22 +1,174 @@
from __future__ import annotations
import logging
import os
import time
from pathlib import Path
from urllib.parse import urlparse
from typing import Callable
import git
from git import GitCommandError, InvalidGitRepositoryError, NoSuchPathError, Repo
def clone_or_update(site_url: str, dest: Path) -> Path:
"""Clone or update the source blog git repository under `dest`.
DEFAULT_REPO_URL = "https://git.chaospott.de/Chaospott/site"
DEFAULT_BLOG_SUBDIR = "blog"
MAX_RETRIES_DEFAULT = 5
BACKOFF_BASE_SECONDS = 2
REMOTE_NAME = "origin"
BRANCH_NAME = "master"
Stubbed for the scaffold (Job 1082). Future jobs will implement this
using GitPython over HTTPS.
"""
parsed = urlparse(site_url)
if parsed.scheme not in {"http", "https"}:
raise ValueError(f"unsupported SITE_URL scheme: {parsed.scheme!r}")
raise NotImplementedError(
"blog clone/update lands in a follow-up job (relies on GitPython over HTTPS)"
)
_LOCAL_MODIFICATION_MARKERS = (
"Your local changes",
"would be overwritten",
"Please commit your changes",
"Please move or remove them",
)
class BlogRepoError(RuntimeError):
"""Raised when the local blog working tree cannot be ensured."""
def blog_dir(data_dir: Path) -> Path:
return Path(data_dir) / "blog"
return Path(data_dir) / DEFAULT_BLOG_SUBDIR
def blog_repo_url() -> str:
return os.environ.get("BLOG_REPO_URL") or DEFAULT_REPO_URL
def _is_dir_empty(path: Path) -> bool:
if not path.exists():
return True
try:
next(path.iterdir())
except StopIteration:
return True
return False
def _is_local_modification_error(exc: GitCommandError) -> bool:
message = str(exc)
return any(marker in message for marker in _LOCAL_MODIFICATION_MARKERS)
def _fetch_and_pull(
repo: Repo,
*,
fetch: Callable[[], object],
pull: Callable[[], object],
sleep: Callable[[float], None],
max_retries: int,
logger: logging.Logger | None,
) -> None:
"""Fetch + fast-forward pull with retry/backoff for transient network errors.
Local-modification errors raise ``BlogRepoError`` immediately, without retry.
"""
attempts = max(1, max_retries + 1)
last_exc: GitCommandError | None = None
for attempt in range(1, attempts + 1):
try:
fetch()
pull()
return
except GitCommandError as exc:
last_exc = exc
if _is_local_modification_error(exc):
if logger is not None:
logger.error(
"blog_local_modifications",
extra={"event": "blog_local_modifications", "error": str(exc)},
)
raise BlogRepoError(
"local modifications detected in blog working tree; aborting run"
) from exc
if logger is not None:
logger.error(
"blog_fetch_error",
extra={
"event": "blog_fetch_error",
"attempt": attempt,
"max_attempts": attempts,
"error": str(exc),
},
)
if attempt < attempts:
sleep(BACKOFF_BASE_SECONDS ** attempt)
assert last_exc is not None
raise BlogRepoError(
f"blog fetch/pull failed after {attempts} attempt(s): {last_exc}"
) from last_exc
def ensure_repo(
data_dir: Path,
*,
repo_url: str | None = None,
blog_path: Path | None = None,
max_retries: int | None = None,
logger: logging.Logger | None = None,
sleep: Callable[[float], None] = time.sleep,
fetch_impl: Callable[[Repo], Callable[[], object]] | None = None,
pull_impl: Callable[[Repo], Callable[[], object]] | None = None,
) -> Repo:
"""Ensure a local working clone of the blog repository exists and is up to date.
Returns the :class:`git.Repo` pointing at an up-to-date working tree.
Raises :class:`BlogRepoError` on irrecoverable failures.
"""
url = repo_url or blog_repo_url()
target = blog_path or blog_dir(data_dir)
retries = MAX_RETRIES_DEFAULT if max_retries is None else max_retries
target.parent.mkdir(parents=True, exist_ok=True)
needs_clone = _is_dir_empty(target)
if needs_clone:
if logger is not None:
logger.info(
"blog_clone_start",
extra={"event": "blog_clone_start", "url": url, "path": str(target)},
)
cloned = Repo.clone_from(url, str(target))
if logger is not None:
logger.info(
"blog_clone_complete",
extra={"event": "blog_clone_complete", "path": str(target)},
)
return cloned
try:
repo = Repo(str(target))
except (InvalidGitRepositoryError, NoSuchPathError) as exc:
raise BlogRepoError(
f"{target} exists but is not a valid git repository: {exc}"
) from exc
fetch = (fetch_impl or _default_fetch)(repo)
pull = (pull_impl or _default_pull)(repo)
_fetch_and_pull(
repo,
fetch=fetch,
pull=pull,
sleep=sleep,
max_retries=retries,
logger=logger,
)
return repo
def _default_fetch(repo: Repo) -> Callable[[], object]:
def _do() -> object:
return repo.remotes[REMOTE_NAME].fetch()
return _do
def _default_pull(repo: Repo) -> Callable[[], object]:
def _do() -> object:
return repo.git.pull("--ff-only")
return _do
+20 -1
View File
@@ -22,7 +22,7 @@ REQUIRED_KEYS = (
"TZ",
)
OPTIONAL_KEYS: tuple[str, ...] = ()
OPTIONAL_KEYS: tuple[str, ...] = ("BLOG_REPO_URL", "BLOG_DIR")
DEFAULTS = {
"VISIBILITY": "public",
@@ -30,6 +30,8 @@ DEFAULTS = {
"MAX_RETRIES": "3",
"TZ": "Europe/Berlin",
"RUN_AT": "09:00",
"BLOG_REPO_URL": "https://git.chaospott.de/Chaospott/site",
"BLOG_DIR": "blog",
}
@@ -52,6 +54,8 @@ class Config:
run_at: str
tz: str
data_dir: Path = field(default_factory=lambda: Path("/app/data"))
blog_repo_url: str = "https://git.chaospott.de/Chaospott/site"
blog_dir: Path = field(default_factory=lambda: Path("/app/data/blog"))
extra: dict = field(default_factory=dict)
@property
@@ -103,6 +107,13 @@ def load_config(dotenv_path: Optional[Path] = None) -> Config:
data_dir = Path(os.environ.get("DATA_DIR", "/app/data")).resolve()
blog_dir_raw = Path(merged["BLOG_DIR"]).expanduser()
blog_dir = (
blog_dir_raw
if blog_dir_raw.is_absolute()
else (data_dir / blog_dir_raw).resolve()
)
extra = {k: v for k, v in merged.items() if k not in REQUIRED_KEYS + OPTIONAL_KEYS}
return Config(
@@ -116,6 +127,8 @@ def load_config(dotenv_path: Optional[Path] = None) -> Config:
run_at=merged["RUN_AT"],
tz=merged["TZ"],
data_dir=data_dir,
blog_repo_url=merged["BLOG_REPO_URL"],
blog_dir=blog_dir,
extra=extra,
)
@@ -151,6 +164,12 @@ def validate_config(values: dict[str, str]) -> None:
f"VISIBILITY={visibility!r} must be one of: {sorted(ALLOWED_VISIBILITY)}"
)
blog_repo_url = values.get("BLOG_REPO_URL", "")
if blog_repo_url and not _is_valid_url(blog_repo_url):
errors.append(
f"BLOG_REPO_URL={blog_repo_url!r} must be a valid http(s) URL"
)
tz = values.get("TZ", "")
if tz and not _is_valid_tz(tz):
errors.append(f"TZ={tz!r} must be a valid IANA timezone")
+19
View File
@@ -5,6 +5,7 @@ import time
from typing import Iterable
from . import __version__
from .blog import BlogRepoError, ensure_repo
from .config import Config, ConfigError, load_config
from .logging_setup import (
configure_json_logging,
@@ -30,6 +31,13 @@ def _run_once(config: Config) -> tuple[int, int, int, int, list[str]]:
Returns ``(scanned, matched, posted, skipped, posted_ids)``.
"""
ensure_repo(
config.data_dir,
repo_url=config.blog_repo_url,
blog_path=config.blog_dir,
max_retries=config.max_retries,
)
already_posted = set(load_posted(config.data_dir).keys())
scanned = 0
@@ -96,6 +104,17 @@ def main() -> int:
config.data_dir.mkdir(parents=True, exist_ok=True)
try:
ensure_repo(
config.data_dir,
repo_url=config.blog_repo_url,
blog_path=config.blog_dir,
max_retries=config.max_retries,
)
except BlogRepoError as exc:
log_error("blog_repo_error", exc=exc)
return 1
log_startup(
version=__version__,
site_url=config.site_url,