AI Implementation feature(1084): Blog Repository Clone and Pull Workflow (#3)
This commit was merged in pull request #3.
This commit is contained in:
+165
-13
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user