Add project management and GitHub source support
Deploy production / deploy (push) Successful in 5s

This commit is contained in:
2026-08-13 17:47:26 -07:00
parent 14640faa15
commit ddf7234c9d
14 changed files with 7310 additions and 39 deletions
+37
View File
@@ -291,6 +291,24 @@ test -f build/index.html
The project currently has no third-party Lektor packages or plugins.
### Local management launcher
To check the checkout's Git synchronization state and open the focused desktop
editors from one small window, run:
```bash
python scripts/management_launcher.py
```
The launcher locates the repository containing its script, fetches metadata
from the configured `origin`, and reports the current branch, upstream,
working-tree changes, and ahead/behind state in plain language. It offers only
fast-forward pulls for a clean, remote-ahead checkout and normal, non-force
pushes for a local-ahead checkout. Diverged histories, missing tracking
configuration, and dirty pulls require manual review. Refresh rechecks the
state without restarting the window. Each editor is started as a separate
Python process and remains directly runnable with its documented command.
### Local blog editor
For a focused desktop form that creates and edits records under `content/blog/`,
@@ -355,6 +373,25 @@ from `PATH` or `support/ffmpeg.exe`, with the fixed 44.1 kHz mono, 96 kbps MP3
preset. Replacement and removal require confirmation; failed conversion leaves
any existing narration unchanged.
### Local project editor
To add, edit, or remove a site-approved remote project source, run:
```bash
python scripts/project_editor.py
```
The project editor asks for one public GitHub or Gitea repository web URL and
derives the anonymous Git and provider API URLs stored in
`configs/project-sources.ini`. Project prose, technology labels, images, and
devlog records remain owned by the external project repository under
`.labyricorn/`. Its explicit **Check Devlog Status** action performs the
importer's public-provider and remote-snapshot validation against a disposable
read-only Git mirror, then removes that mirror. When setup is missing, the
editor directs the user to copy the standalone `devlog_editor.py` into the
project checkout, initialize and author the devlog, publish the changes, and
recheck. It never edits, commits, or pushes an external project repository.
## Production filesystem and permissions
```text
@@ -1,18 +0,0 @@
_model: entry
---
title: Boring Deployments Are a Feature
---
date: 2026-08-10
---
kicker: Article
---
summary: Why a small, transparent deployment chain is often the most resilient one.
---
body:
A static site does not need a sprawling production stack. A canonical Git
repository, an isolated build tool, immutable release directories, an atomic
symlink, and nginx are enough to make deployment understandable and reliable.
The important property is not novelty. It is that a failed build cannot replace
the working site.
-5
View File
@@ -1,5 +0,0 @@
_model: tag
---
title: v0.4.0
---
summary: Work associated with the version 0.4.0 contract and milestone.
+1769
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+433
View File
@@ -0,0 +1,433 @@
#!/usr/bin/env python3
"""Small, conservative Git helper for the Labyricorn management launcher."""
from __future__ import annotations
from dataclasses import dataclass, replace
import os
from pathlib import Path
import re
import subprocess
from urllib.parse import urlsplit, urlunsplit
SYNCED = "synchronized"
REMOTE_AHEAD = "remote-ahead"
LOCAL_AHEAD = "local-ahead"
DIVERGED = "diverged"
UNAVAILABLE = "unavailable"
class GitRepositoryError(Exception):
"""An expected Git problem that can be displayed directly to a user."""
@dataclass(frozen=True)
class RepositoryState:
root: Path
repository_name: str
branch: str
origin_url: str | None
upstream: str | None
upstream_branch: str | None
dirty_count: int
ahead: int | None
behind: int | None
sync_state: str
problem: str | None = None
origin_push_url: str | None = None
@property
def status_text(self) -> str:
if self.sync_state == SYNCED:
return "Up to date"
if self.sync_state == REMOTE_AHEAD:
noun = "commit" if self.behind == 1 else "commits"
return f"Remote has {self.behind} newer {noun}"
if self.sync_state == LOCAL_AHEAD:
noun = "commit" if self.ahead == 1 else "commits"
return f"Local has {self.ahead} unpushed {noun}"
if self.sync_state == DIVERGED:
return "Local and remote have diverged"
return "Unavailable"
@property
def working_tree_text(self) -> str:
if not self.dirty_count:
return "Clean"
noun = "path" if self.dirty_count == 1 else "paths"
return f"{self.dirty_count} changed {noun}"
@property
def can_pull(self) -> bool:
return self.sync_state == REMOTE_AHEAD and self.dirty_count == 0
@property
def can_push(self) -> bool:
return self.sync_state == LOCAL_AHEAD
class GitRepository:
"""Narrow wrapper around the installed Git CLI for one working tree."""
def __init__(self, root: Path):
self.root = root.resolve()
@classmethod
def locate(cls, start: Path) -> "GitRepository":
start = start.resolve()
directory = start if start.is_dir() else start.parent
result = _run_git(directory, "rev-parse", "--show-toplevel", check=False)
if result.returncode != 0:
detail = _error_detail(result)
raise GitRepositoryError(
f"The launcher is not inside a Git repository. {detail}"
)
root = Path(result.stdout.strip()).resolve()
try:
start.relative_to(root)
except ValueError as exc:
raise GitRepositoryError(
"Git returned a repository that does not contain the launcher."
) from exc
return cls(root)
def refresh(self) -> RepositoryState:
"""Read local state, safely fetch origin metadata, and compare commits."""
before_fetch = self._read_state(compare_remote=False)
if before_fetch.problem:
return before_fetch
result = self._git(
"fetch", "--no-tags", "--prune", "origin", check=False, timeout=60
)
if result.returncode != 0:
return replace(
before_fetch,
sync_state=UNAVAILABLE,
ahead=None,
behind=None,
problem=f"Fetch failed: {self._safe_detail(result)}",
)
return self._read_state(compare_remote=True)
def pull(self, expected: RepositoryState) -> RepositoryState:
current = self.refresh()
self._verify_action_state(expected, current)
if current.dirty_count:
raise GitRepositoryError(
"Pull is disabled because the working tree has uncommitted changes. "
"Review or commit them before pulling."
)
if current.sync_state != REMOTE_AHEAD:
raise GitRepositoryError(self._not_actionable_message(current, "pull"))
assert current.upstream_branch is not None
result = self._git(
"pull",
"--ff-only",
"origin",
current.upstream_branch,
check=False,
timeout=60,
)
if result.returncode != 0:
raise GitRepositoryError(f"Pull failed: {self._safe_detail(result)}")
return self.refresh()
def push(self, expected: RepositoryState) -> RepositoryState:
current = self.refresh()
self._verify_action_state(expected, current)
if current.sync_state != LOCAL_AHEAD:
raise GitRepositoryError(self._not_actionable_message(current, "push"))
assert current.upstream_branch is not None
result = self._git(
"push",
"origin",
f"HEAD:refs/heads/{current.upstream_branch}",
check=False,
timeout=60,
)
if result.returncode != 0:
refreshed = self.refresh()
if refreshed.sync_state == DIVERGED:
raise GitRepositoryError(
"Push failed because the remote changed and the histories now "
"diverge. Resolve this manually."
)
raise GitRepositoryError(f"Push failed: {self._safe_detail(result)}")
return self.refresh()
def _read_state(self, *, compare_remote: bool) -> RepositoryState:
self._verify_root()
branch_result = self._git(
"symbolic-ref", "--quiet", "--short", "HEAD", check=False
)
branch = branch_result.stdout.strip()
detached = branch_result.returncode != 0 or not branch
if detached:
branch = "(detached HEAD)"
origin_result = self._git("remote", "get-url", "origin", check=False)
raw_origin = origin_result.stdout.strip() if origin_result.returncode == 0 else ""
origin_url = _display_url(raw_origin) if raw_origin else None
push_result = self._git(
"remote", "get-url", "--push", "origin", check=False
)
raw_push_url = push_result.stdout.strip() if push_result.returncode == 0 else ""
origin_push_url = _display_url(raw_push_url) if raw_push_url else origin_url
repository_name = _repository_name(raw_origin) if raw_origin else self.root.name
status_result = self._git(
"status", "--porcelain=v1", "--untracked-files=normal", check=True
)
dirty_count = len(status_result.stdout.splitlines())
if detached:
return RepositoryState(
self.root,
repository_name,
branch,
origin_url,
None,
None,
dirty_count,
None,
None,
UNAVAILABLE,
"The checkout has a detached HEAD. Switch branches manually before syncing.",
origin_push_url,
)
if not raw_origin:
return RepositoryState(
self.root,
repository_name,
branch,
None,
None,
None,
dirty_count,
None,
None,
UNAVAILABLE,
"No usable origin remote is configured. Configure it manually before syncing.",
origin_push_url,
)
upstream_result = self._git(
"rev-parse",
"--abbrev-ref",
"--symbolic-full-name",
"@{upstream}",
check=False,
)
upstream = upstream_result.stdout.strip()
remote_result = self._git(
"config", "--get", f"branch.{branch}.remote", check=False
)
merge_result = self._git(
"config", "--get", f"branch.{branch}.merge", check=False
)
upstream_remote = remote_result.stdout.strip()
merge_ref = merge_result.stdout.strip()
if (
not upstream
or upstream_remote != "origin"
or not merge_ref.startswith("refs/heads/")
):
return RepositoryState(
self.root,
repository_name,
branch,
origin_url,
upstream or None,
None,
dirty_count,
None,
None,
UNAVAILABLE,
"The current branch has no usable upstream on origin. Configure its "
"tracking branch manually before syncing.",
origin_push_url,
)
upstream_branch = merge_ref.removeprefix("refs/heads/")
expected_upstream = f"origin/{upstream_branch}"
if upstream != expected_upstream:
return RepositoryState(
self.root,
repository_name,
branch,
origin_url,
upstream,
upstream_branch,
dirty_count,
None,
None,
UNAVAILABLE,
f"The upstream relationship is ambiguous ({upstream}). Resolve it manually.",
origin_push_url,
)
if not compare_remote:
return RepositoryState(
self.root,
repository_name,
branch,
origin_url,
upstream,
upstream_branch,
dirty_count,
None,
None,
UNAVAILABLE,
origin_push_url=origin_push_url,
)
counts_result = self._git(
"rev-list", "--left-right", "--count", f"HEAD...{upstream}", check=False
)
if counts_result.returncode != 0:
return RepositoryState(
self.root,
repository_name,
branch,
origin_url,
upstream,
upstream_branch,
dirty_count,
None,
None,
UNAVAILABLE,
f"Cannot compare local and remote commits: {self._safe_detail(counts_result)}",
origin_push_url,
)
try:
ahead, behind = (int(value) for value in counts_result.stdout.split())
except (TypeError, ValueError) as exc:
raise GitRepositoryError("Git returned an invalid ahead/behind result.") from exc
if ahead and behind:
sync_state = DIVERGED
elif ahead:
sync_state = LOCAL_AHEAD
elif behind:
sync_state = REMOTE_AHEAD
else:
sync_state = SYNCED
return RepositoryState(
self.root,
repository_name,
branch,
origin_url,
upstream,
upstream_branch,
dirty_count,
ahead,
behind,
sync_state,
origin_push_url=origin_push_url,
)
def _verify_root(self) -> None:
result = self._git("rev-parse", "--show-toplevel", check=False)
if result.returncode != 0 or Path(result.stdout.strip()).resolve() != self.root:
raise GitRepositoryError("The repository containing the launcher has changed.")
def _verify_action_state(
self, expected: RepositoryState, current: RepositoryState
) -> None:
if current.root != expected.root:
raise GitRepositoryError("The repository changed; refresh before syncing.")
if current.branch != expected.branch:
raise GitRepositoryError(
f"The current branch changed from {expected.branch} to {current.branch}. "
"Review the refreshed status before syncing."
)
if (
current.origin_url != expected.origin_url
or current.origin_push_url != expected.origin_push_url
or current.upstream != expected.upstream
):
raise GitRepositoryError(
"The remote or upstream configuration changed. Review the refreshed "
"status before syncing."
)
if current.problem:
raise GitRepositoryError(current.problem)
@staticmethod
def _not_actionable_message(state: RepositoryState, action: str) -> str:
if state.sync_state == DIVERGED:
return (
"Local and remote histories have diverged. The launcher will not choose "
f"a {action} strategy; resolve the histories manually."
)
if state.sync_state == SYNCED:
return "The repository is already up to date."
return f"A safe {action} is not available for the current repository state."
def _git(
self,
*args: str,
check: bool,
timeout: int = 15,
) -> subprocess.CompletedProcess[str]:
result = _run_git(self.root, *args, check=False, timeout=timeout)
if check and result.returncode != 0:
raise GitRepositoryError(f"Git failed: {self._safe_detail(result)}")
return result
def _safe_detail(self, result: subprocess.CompletedProcess[str]) -> str:
return re.sub(r"(https?://)[^/\s@]+@", r"\1", _error_detail(result))
def _run_git(
cwd: Path,
*args: str,
check: bool,
timeout: int = 15,
) -> subprocess.CompletedProcess[str]:
command = ["git", *args]
options: dict[str, object] = {
"cwd": cwd,
"capture_output": True,
"text": True,
"encoding": "utf-8",
"errors": "replace",
"check": False,
"timeout": timeout,
"env": {**os.environ, "GIT_TERMINAL_PROMPT": "0"},
}
if os.name == "nt":
options["creationflags"] = subprocess.CREATE_NO_WINDOW
try:
result = subprocess.run(command, **options)
except FileNotFoundError as exc:
raise GitRepositoryError(
"Git is not installed or is not available on PATH."
) from exc
except subprocess.TimeoutExpired as exc:
raise GitRepositoryError("Git did not finish within the safety timeout.") from exc
if check and result.returncode != 0:
raise GitRepositoryError(f"Git failed: {_error_detail(result)}")
return result
def _error_detail(result: subprocess.CompletedProcess[str]) -> str:
return (result.stderr.strip() or result.stdout.strip() or "unknown Git error").replace(
"\n", " "
)
def _display_url(value: str) -> str:
"""Remove HTTP credentials before a remote URL reaches the GUI."""
parsed = urlsplit(value)
if parsed.scheme in {"http", "https"} and parsed.hostname:
host = parsed.hostname
if parsed.port:
host = f"{host}:{parsed.port}"
return urlunsplit((parsed.scheme, host, parsed.path, parsed.query, parsed.fragment))
return value
def _repository_name(value: str) -> str:
path = urlsplit(value).path if "://" in value else re.split(r"[\\/:]", value)[-1]
name = path.rstrip("/").rsplit("/", 1)[-1]
return name.removesuffix(".git") or "repository"
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""Small desktop front door for Labyricorn repository sync and editors."""
from __future__ import annotations
from pathlib import Path
import subprocess
import sys
import threading
from typing import Callable
from git_repo import GitRepository, GitRepositoryError, RepositoryState
SCRIPT_DIR = Path(__file__).resolve().parent
SITE_ROOT = SCRIPT_DIR.parent
EDITOR_SCRIPTS = (
("Blog Editor", "blog_editor.py", True),
("Article Editor", "article_editor.py", True),
("Tag Editor", "tag_editor.py", True),
("Project Editor", "project_editor.py", True),
)
def run_gui() -> None:
import tkinter as tk
from tkinter import messagebox, ttk
class ManagementLauncher:
def __init__(self) -> None:
self.root = tk.Tk()
self.root.title("Labyricorn Management")
self.root.minsize(620, 430)
self.repository: GitRepository | None = None
self.state: RepositoryState | None = None
self.busy = False
outer = ttk.Frame(self.root, padding=14)
outer.grid(row=0, column=0, sticky="nsew")
self.root.columnconfigure(0, weight=1)
self.root.rowconfigure(0, weight=1)
outer.columnconfigure(0, weight=1)
ttk.Label(
outer,
text="Labyricorn Management",
font=("TkDefaultFont", 15, "bold"),
).grid(row=0, column=0, sticky="w", pady=(0, 12))
repository_frame = ttk.LabelFrame(outer, text="Repository", padding=10)
repository_frame.grid(row=1, column=0, sticky="ew")
repository_frame.columnconfigure(1, weight=1)
self.values: dict[str, tk.StringVar] = {}
fields = (
("Repository root", "root"),
("Repository", "repository"),
("Branch", "branch"),
("Remote", "remote"),
("Upstream", "upstream"),
("Status", "status"),
("Working tree", "working_tree"),
)
for row, (label, key) in enumerate(fields):
ttk.Label(repository_frame, text=f"{label}:").grid(
row=row, column=0, sticky="nw", padx=(0, 10), pady=2
)
value = tk.StringVar(value="Checking…" if key == "status" else "")
self.values[key] = value
ttk.Label(repository_frame, textvariable=value, wraplength=460).grid(
row=row, column=1, sticky="w", pady=2
)
self.details_var = tk.StringVar(value="Fetching remote metadata…")
ttk.Label(
repository_frame,
textvariable=self.details_var,
wraplength=560,
foreground="#7a5200",
).grid(row=len(fields), column=0, columnspan=2, sticky="w", pady=(8, 2))
controls = ttk.Frame(repository_frame)
controls.grid(
row=len(fields) + 1,
column=0,
columnspan=2,
sticky="w",
pady=(10, 0),
)
self.refresh_button = ttk.Button(controls, text="Refresh", command=self.refresh)
self.refresh_button.grid(row=0, column=0)
self.pull_button = ttk.Button(
controls, text="Pull", command=self.pull, state="disabled"
)
self.pull_button.grid(row=0, column=1, padx=(8, 0))
self.push_button = ttk.Button(
controls, text="Push", command=self.push, state="disabled"
)
self.push_button.grid(row=0, column=2, padx=(8, 0))
editors_frame = ttk.LabelFrame(outer, text="Editors", padding=10)
editors_frame.grid(row=2, column=0, sticky="ew", pady=(14, 0))
editors_frame.columnconfigure(0, weight=1)
editor_row = 0
for label, filename, required in EDITOR_SCRIPTS:
path = SCRIPT_DIR / filename
if not required and not path.is_file():
continue
button = ttk.Button(
editors_frame,
text=label,
command=lambda selected=path, name=label: self.launch_editor(
selected, name
),
)
button.grid(row=editor_row, column=0, sticky="ew", pady=3)
if not path.is_file():
button.configure(state="disabled")
ttk.Label(editors_frame, text=f"{filename} is missing").grid(
row=editor_row, column=1, sticky="w", padx=(10, 0)
)
editor_row += 1
self.root.after(50, self.refresh)
def refresh(self) -> None:
def operation() -> RepositoryState:
repository = GitRepository.locate(SCRIPT_DIR)
state = repository.refresh()
self.repository = repository
return state
self._start_git_task("Refreshing repository status", operation)
def pull(self) -> None:
if self.repository is None or self.state is None:
return
repository = self.repository
expected = self.state
self._start_git_task(
"Pulling with fast-forward only", lambda: repository.pull(expected)
)
def push(self) -> None:
if self.repository is None or self.state is None:
return
repository = self.repository
expected = self.state
self._start_git_task(
"Pushing local commits", lambda: repository.push(expected)
)
def _start_git_task(
self, label: str, operation: Callable[[], RepositoryState]
) -> None:
if self.busy:
return
self.busy = True
self.details_var.set(f"{label}")
self._update_buttons()
def worker() -> None:
try:
state = operation()
except (GitRepositoryError, OSError) as exc:
self.root.after(0, self._finish_error, label, str(exc))
else:
self.root.after(0, self._finish_success, state)
threading.Thread(target=worker, daemon=True).start()
def _finish_success(self, state: RepositoryState) -> None:
self.busy = False
self.state = state
self.values["root"].set(str(state.root))
self.values["repository"].set(state.repository_name)
self.values["branch"].set(state.branch)
if not state.origin_url:
remote_text = "Not configured"
elif state.origin_push_url and state.origin_push_url != state.origin_url:
remote_text = (
f"origin — {state.origin_url}\nPush target — {state.origin_push_url}"
)
else:
remote_text = f"origin — {state.origin_url}"
self.values["remote"].set(remote_text)
self.values["upstream"].set(state.upstream or "Not configured")
self.values["status"].set(state.status_text)
self.values["working_tree"].set(state.working_tree_text)
if state.problem:
self.details_var.set(state.problem)
elif state.sync_state == "diverged":
self.details_var.set(
f"Local is {state.ahead} ahead and {state.behind} behind. "
"Resolve the histories manually; no automatic action is offered."
)
elif state.dirty_count and state.sync_state == "remote-ahead":
self.details_var.set(
"Pull is unavailable until the working-tree changes are "
"reviewed or committed."
)
else:
self.details_var.set("Remote metadata fetched successfully.")
self._update_buttons()
def _finish_error(self, label: str, detail: str) -> None:
self.busy = False
self.state = None
self.details_var.set(detail)
self.values["status"].set("Unavailable")
self._update_buttons()
messagebox.showerror(label, detail, parent=self.root)
def _update_buttons(self) -> None:
self.refresh_button.configure(state="disabled" if self.busy else "normal")
pull_enabled = (
not self.busy and self.state is not None and self.state.can_pull
)
push_enabled = (
not self.busy and self.state is not None and self.state.can_push
)
self.pull_button.configure(state="normal" if pull_enabled else "disabled")
self.push_button.configure(state="normal" if push_enabled else "disabled")
def launch_editor(self, path: Path, label: str) -> None:
if not path.is_file():
messagebox.showerror(
f"Cannot launch {label}",
f"The editor script is missing: {path}",
parent=self.root,
)
return
try:
subprocess.Popen(
[sys.executable, str(path), "--site-root", str(SITE_ROOT)],
cwd=SITE_ROOT,
)
except OSError as exc:
messagebox.showerror(
f"Cannot launch {label}",
f"Could not start {path.name}: {exc}",
parent=self.root,
)
try:
app = ManagementLauncher()
except tk.TclError as exc:
raise GitRepositoryError(f"Cannot open the Tkinter window: {exc}") from exc
app.root.mainloop()
def main() -> int:
try:
run_gui()
except GitRepositoryError as exc:
print(f"management-launcher: ERROR: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+702
View File
@@ -0,0 +1,702 @@
#!/usr/bin/env python3
"""Manage Labyricorn's allowlisted project sources and check devlog readiness."""
from __future__ import annotations
import argparse
import configparser
from dataclasses import dataclass
import importlib
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
import threading
from typing import Any
from project_providers import (
ProjectProviderError,
derive_repository_urls,
validate_public_https_url,
)
SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
BRANCH_RE = re.compile(r"^[A-Za-z0-9._/-]+$")
SECTION_RE = re.compile(r"(?m)^\[([^\]\r\n]+)\][^\r\n]*(?:\r?\n|$)")
KNOWN_OPTIONS = ("repository", "web_url", "api_url", "branch", "featured_order")
class ProjectEditorError(Exception):
"""An expected project-editor problem suitable for display to a user."""
def _load_project_importer() -> Any:
try:
return importlib.import_module("project_sources")
except ModuleNotFoundError as exc:
if exc.name != "lektor":
raise
raise ProjectEditorError(
"The Lektor Python package required by the project importer is unavailable. "
"Run Project Editor with the same Python environment used for Lektor builds."
) from exc
def _validate_url(value: str, field: str) -> str:
"""Mirror the importer's credential-free public HTTPS URL policy."""
try:
return validate_public_https_url(value, field)
except ProjectProviderError as exc:
raise ProjectEditorError(f"{exc}.") from exc
@dataclass(frozen=True)
class ProjectSource:
project_id: str
repository: str
web_url: str
api_url: str
branch: str
featured_order: int | None
unknown_options: tuple[tuple[str, str], ...]
registry_bytes: bytes
def importer_source(self) -> dict[str, str | int]:
source: dict[str, str | int] = {
"project_id": self.project_id,
"repository": self.repository,
"web_url": self.web_url,
"api_url": self.api_url,
"branch": self.branch,
}
if self.featured_order is not None:
source["featured_order"] = self.featured_order
return source
@dataclass(frozen=True)
class DevlogReadiness:
status: str
summary: str
details: str
ready: bool = False
def _clean_stored_values(
project_id: str,
repository: str,
web_url: str,
api_url: str,
branch: str,
featured_order: str | int | None,
) -> tuple[str, str, str, str, str, int | None]:
project_id = project_id.strip()
if not SLUG_RE.fullmatch(project_id):
raise ProjectEditorError(
"Project ID must use lowercase letters, numbers, and single hyphens only."
)
repository = _validate_url(repository.strip(), "Repository URL")
web_url = _validate_url(web_url.strip(), "Repository web URL")
api_url = _validate_url(api_url.strip(), "Repository API URL")
branch = branch.strip()
if not branch or not BRANCH_RE.fullmatch(branch) or ".." in branch:
raise ProjectEditorError("Branch contains unsupported characters.")
if featured_order is None or str(featured_order).strip() == "":
parsed_order = None
else:
try:
parsed_order = int(featured_order)
except (TypeError, ValueError) as exc:
raise ProjectEditorError("Featured order must be an integer or left blank.") from exc
return project_id, repository, web_url, api_url, branch, parsed_order
def _clean_editor_values(
project_id: str,
web_url: str,
branch: str,
featured_order: str | int | None,
) -> tuple[str, str, str, str, str, int | None]:
try:
urls = derive_repository_urls(web_url)
except ProjectProviderError as exc:
raise ProjectEditorError(str(exc)) from exc
return _clean_stored_values(
project_id,
urls.repository,
urls.web_url,
urls.api_url,
branch,
featured_order,
)
def _parse_registry(raw: bytes) -> tuple[str, configparser.ConfigParser]:
try:
text = raw.decode("utf-8")
except UnicodeDecodeError as exc:
raise ProjectEditorError("Project source registry is not valid UTF-8.") from exc
parser = configparser.ConfigParser(interpolation=None)
try:
parser.read_string(text)
except configparser.Error as exc:
raise ProjectEditorError(f"Cannot parse project source registry: {exc}") from exc
return text, parser
def _source_from_section(
project_id: str, section: configparser.SectionProxy, raw: bytes
) -> ProjectSource:
required = {"repository", "web_url", "api_url", "branch"}
missing = required - set(section)
if missing:
raise ProjectEditorError(
f"{project_id}: missing registry fields {', '.join(sorted(missing))}."
)
cleaned = _clean_stored_values(
project_id,
section["repository"],
section["web_url"],
section["api_url"],
section["branch"],
section.get("featured_order"),
)
unknown = tuple((key, value) for key, value in section.items() if key not in KNOWN_OPTIONS)
return ProjectSource(*cleaned, unknown, raw)
def _render_option(name: str, value: str) -> str:
lines = value.replace("\r\n", "\n").replace("\r", "\n").split("\n")
return f"{name} = {lines[0]}" + "".join(f"\n\t{line}" for line in lines[1:])
def _render_section(source: ProjectSource) -> str:
rows = [
f"[{source.project_id}]",
_render_option("repository", source.repository),
_render_option("web_url", source.web_url),
_render_option("api_url", source.api_url),
_render_option("branch", source.branch),
]
if source.featured_order is not None:
rows.append(_render_option("featured_order", str(source.featured_order)))
rows.extend(_render_option(key, value) for key, value in source.unknown_options)
return "\n".join(rows) + "\n"
def _section_bounds(text: str, project_id: str) -> tuple[int, int]:
matches = list(SECTION_RE.finditer(text))
for index, match in enumerate(matches):
if match.group(1).strip() == project_id:
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
return match.start(), end
raise ProjectEditorError(f"Project source no longer exists: {project_id}.")
class ProjectSourceRegistry:
"""Conservative editor for configs/project-sources.ini."""
def __init__(self, site_root: Path):
self.site_root = site_root.resolve()
registry_path = self.site_root / "configs" / "project-sources.ini"
if registry_path.is_symlink():
raise ProjectEditorError("Refusing to manage a linked project source registry.")
self.path = registry_path.resolve()
projects_root = (self.site_root / "content" / "projects").resolve()
if len(list(self.site_root.glob("*.lektorproject"))) != 1:
raise ProjectEditorError(
f"{self.site_root}: expected exactly one .lektorproject file."
)
if not self.path.is_file():
raise ProjectEditorError(f"Project source registry is missing: {self.path}.")
if not self.path.is_relative_to(self.site_root):
raise ProjectEditorError("Refusing to manage an out-of-tree registry.")
if not projects_root.is_dir() or not (projects_root / "contents.lr").is_file():
raise ProjectEditorError(f"Lektor project section is missing: {projects_root}.")
self.list_sources()
def _read(self) -> bytes:
try:
return self.path.read_bytes()
except OSError as exc:
raise ProjectEditorError(f"Cannot read {self.path}: {exc}") from exc
def list_sources(self) -> list[ProjectSource]:
raw = self._read()
_text, parser = _parse_registry(raw)
if not parser.sections():
raise ProjectEditorError("Project source registry must contain at least one project.")
return [_source_from_section(project_id, parser[project_id], raw) for project_id in parser.sections()]
def load_source(self, project_id: str) -> ProjectSource:
for source in self.list_sources():
if source.project_id == project_id:
return source
raise ProjectEditorError(f"Project source does not exist: {project_id}.")
@staticmethod
def _write_atomic(path: Path, payload: bytes) -> None:
temporary: Path | None = None
try:
descriptor, name = tempfile.mkstemp(prefix=".project-sources-", suffix=".ini", dir=path.parent)
os.close(descriptor)
temporary = Path(name)
temporary.write_bytes(payload)
os.replace(temporary, path)
except OSError as exc:
if temporary is not None:
temporary.unlink(missing_ok=True)
raise ProjectEditorError(f"Cannot write {path}: {exc}") from exc
def _validate_proposed(self, payload: bytes) -> None:
_text, parser = _parse_registry(payload)
if not parser.sections():
raise ProjectEditorError("The importer requires at least one configured project source.")
for project_id in parser.sections():
_source_from_section(project_id, parser[project_id], payload)
def create_source(
self,
project_id: str,
web_url: str,
branch: str,
featured_order: str | int | None,
) -> ProjectSource:
cleaned = _clean_editor_values(project_id, web_url, branch, featured_order)
raw = self._read()
text, parser = _parse_registry(raw)
if cleaned[0] in parser:
raise ProjectEditorError(f"Project source already exists: {cleaned[0]}.")
source = ProjectSource(*cleaned, (), raw)
proposed = (text.rstrip() + "\n\n" + _render_section(source)).encode("utf-8")
self._validate_proposed(proposed)
self._write_atomic(self.path, proposed)
return self.load_source(source.project_id)
def save_source(
self,
original: ProjectSource,
web_url: str,
branch: str,
featured_order: str | int | None,
) -> ProjectSource:
raw = self._read()
if raw != original.registry_bytes:
raise ProjectEditorError("The project registry changed on disk; reload before saving.")
cleaned = _clean_editor_values(
original.project_id, web_url, branch, featured_order
)
updated = ProjectSource(*cleaned, original.unknown_options, raw)
text, _parser = _parse_registry(raw)
start, end = _section_bounds(text, original.project_id)
proposed = (text[:start] + _render_section(updated) + text[end:].lstrip("\r\n")).encode("utf-8")
self._validate_proposed(proposed)
self._write_atomic(self.path, proposed)
return self.load_source(updated.project_id)
def delete_source(self, source: ProjectSource) -> None:
raw = self._read()
if raw != source.registry_bytes:
raise ProjectEditorError("The project registry changed on disk; reload before deleting.")
text, parser = _parse_registry(raw)
if len(parser.sections()) == 1:
raise ProjectEditorError(
"The importer requires at least one configured source; the last project cannot be removed."
)
start, end = _section_bounds(text, source.project_id)
proposed = (text[:start].rstrip() + "\n\n" + text[end:].lstrip("\r\n")).encode("utf-8")
self._validate_proposed(proposed)
self._write_atomic(self.path, proposed)
def corrective_guidance(source: ProjectSource) -> str:
return (
"This project is configured to publish a devlog, but its required remote "
"Labyricorn structure is missing or incomplete.\n\n"
"To correct this:\n\n"
f"1. Clone the project repository:\n git clone {source.repository}\n\n"
"2. Copy the standalone devlog_editor.py into the cloned repository root.\n\n"
"3. From that repository root, run:\n python devlog_editor.py\n\n"
"4. Choose “Initialize Labyricorn Project & Devlog” if no publishing structure "
"exists, or “Initialize Devlog” if the project record already exists.\n\n"
"5. Create and save one or more devlog entries when appropriate. An empty "
"validated devlog index is accepted by the current importer.\n\n"
"6. Prefer the editor's “Publish Labyricorn Changes” action. It safely creates "
"the publishing commit and pushes it; alternatively, commit and push with Git.\n\n"
"7. Confirm the .labyricorn changes are visible in the remote repository.\n\n"
"8. Return to Project Editor and click “Check Devlog Status” again.\n\n"
"Project Editor only diagnoses the remote repository. It never clones it "
"persistently, edits it, commits, or pushes."
)
def _incomplete(source: ProjectSource, diagnostic: str, *, not_initialized: bool = False) -> DevlogReadiness:
status = "Not initialized" if not_initialized else "Required devlog structure is incomplete"
return DevlogReadiness(status, diagnostic, diagnostic + "\n\n" + corrective_guidance(source))
def check_devlog_readiness(source: ProjectSource, importer: Any | None = None) -> DevlogReadiness:
"""Inspect a configured source using the importer's exact read-only validation."""
if shutil.which("git") is None:
return DevlogReadiness(
"Repository inaccessible",
"Git is not installed or is not available on PATH.",
"Install Git, restart Project Editor, and run the check again. No remote repository was changed.",
)
try:
importer = importer or _load_project_importer()
except ProjectEditorError as exc:
return DevlogReadiness(
"Unable to check",
"The site's authoritative project validator is unavailable in this Python environment.",
str(exc),
)
importer_source = source.importer_source()
try:
repository_metadata = importer.fetch_json(source.api_url, require_public=True)
except importer.RepositoryNotPublicError:
return DevlogReadiness(
"Repository inaccessible",
"The repository API is not anonymously accessible.",
"The importer accepts only public repositories. Confirm that the configured repository is public and that the API URL is correct.",
)
except importer.ProjectSourceError as exc:
return DevlogReadiness(
"Repository inaccessible",
"Unable to verify the remote repository because its public API could not be reached.",
f"Network or provider API error: {exc}\n\nCheck the network and configured API URL, then try again.",
)
if not isinstance(repository_metadata, dict) or repository_metadata.get("private") is not False:
return DevlogReadiness(
"Repository inaccessible",
"The provider did not confirm that this repository is public.",
"The site importer requires an anonymous provider API response with private=false.",
)
if repository_metadata.get("default_branch") != source.branch:
return _incomplete(
source,
f"The provider reports default branch {repository_metadata.get('default_branch')!r}, but the site is configured for {source.branch!r}.",
)
try:
with tempfile.TemporaryDirectory(prefix="labyricorn-project-check-") as temporary:
mirror, fetched = importer.ensure_mirror(importer_source, Path(temporary))
if not fetched:
raise importer.ProjectSourceError("anonymous Git fetch failed")
commit = importer.resolve_branch(mirror, source.branch)
_files, records = importer.validate_snapshot(importer_source, mirror, commit)
except FileNotFoundError:
return DevlogReadiness(
"Repository inaccessible",
"Git is not installed or is not available on PATH.",
"Install Git, restart Project Editor, and run the check again.",
)
except (OSError, subprocess.SubprocessError) as exc:
return DevlogReadiness(
"Repository inaccessible",
"Unable to fetch the repository anonymously.",
f"Network, authentication, or Git error: {exc}\n\nCheck repository access and try again.",
)
except (importer.ProjectSourceError, ValueError) as exc:
diagnostic = str(exc)
if "initial repository fetch failed" in diagnostic:
return DevlogReadiness(
"Repository inaccessible",
"Unable to fetch the repository anonymously.",
"The repository may be unavailable, private, or blocked by a network/authentication problem.",
)
missing_devlog = (
"publishing tree is missing" in diagnostic
and ".labyricorn/devlog/contents.lr" in diagnostic
)
return _incomplete(source, diagnostic, not_initialized=missing_devlog)
project = records[".labyricorn/project/contents.lr"]
entry_count = sum(
1
for path in records
if path.startswith(".labyricorn/devlog/")
and path != ".labyricorn/devlog/contents.lr"
)
details = (
f"Repository: {source.web_url}\n"
f"Branch: {source.branch}\n"
f"Validated commit: {commit[:10]}\n"
f"Remote project: {project['title']} ({project['project_id']})\n"
f"Valid devlog entries: {entry_count}\n\n"
"The remote snapshot passed the same schema, path, attachment, and commit checks used by the site importer. The check was read-only and its temporary mirror was removed."
)
return DevlogReadiness(
"Ready",
f"{project['title']} is ready for devlog import at {commit[:10]}.",
details,
True,
)
def run_gui(registry: ProjectSourceRegistry) -> None:
import tkinter as tk
from tkinter import messagebox, ttk
from tkinter.scrolledtext import ScrolledText
class ProjectEditorApp:
def __init__(self) -> None:
self.root = tk.Tk()
self.root.title("Labyricorn Project Editor")
self.root.minsize(900, 680)
self.current: ProjectSource | None = None
self.mode = "new"
self.snapshot: tuple[str, ...] | None = None
self.checking = False
outer = ttk.Frame(self.root, padding=12)
outer.grid(row=0, column=0, sticky="nsew")
self.root.columnconfigure(0, weight=1)
self.root.rowconfigure(0, weight=1)
outer.columnconfigure(1, weight=1)
outer.rowconfigure(0, weight=1)
browser = ttk.LabelFrame(outer, text="Configured projects", padding=8)
browser.grid(row=0, column=0, sticky="ns", padx=(0, 10))
browser.rowconfigure(0, weight=1)
self.tree = ttk.Treeview(browser, columns=("branch",), show="tree headings", height=24)
self.tree.heading("#0", text="Project ID")
self.tree.heading("branch", text="Branch")
self.tree.column("#0", width=165)
self.tree.column("branch", width=95)
self.tree.grid(row=0, column=0, columnspan=3, sticky="nsew")
self.tree.bind("<Double-1>", lambda _event: self.edit_selected())
ttk.Button(browser, text="New", command=self.new_source).grid(row=1, column=0, sticky="ew", pady=(8, 0))
ttk.Button(browser, text="Edit", command=self.edit_selected).grid(row=1, column=1, sticky="ew", padx=5, pady=(8, 0))
self.delete_button = ttk.Button(browser, text="Delete", command=self.delete_current, state="disabled")
self.delete_button.grid(row=1, column=2, sticky="ew", pady=(8, 0))
form = ttk.LabelFrame(outer, text="Site-side project source", padding=10)
form.grid(row=0, column=1, sticky="nsew")
form.columnconfigure(1, weight=1)
form.rowconfigure(9, weight=1)
self.vars = {
name: tk.StringVar()
for name in ("project_id", "web_url", "branch", "featured_order")
}
labels = (
("Project ID", "project_id"),
("Public repository URL", "web_url"),
("Branch", "branch"),
("Featured order (optional)", "featured_order"),
)
for row, (label, name) in enumerate(labels):
ttk.Label(form, text=label).grid(row=row, column=0, sticky="w", padx=(0, 10), pady=3)
entry = ttk.Entry(form, textvariable=self.vars[name])
entry.grid(row=row, column=1, sticky="ew", pady=3)
if name == "project_id":
self.id_entry = entry
ttk.Label(
form,
text=(
"Project prose, technologies/tags, and images are owned by the external repository under "
".labyricorn/project/. This editor does not copy or modify them. Every configured source is "
"expected by the current importer to provide a .labyricorn/devlog/ index. Git and API URLs "
"are derived for public GitHub and Gitea repositories."
),
wraplength=590,
foreground="#6b5a32",
).grid(row=6, column=0, columnspan=2, sticky="ew", pady=(8, 10))
actions = ttk.Frame(form)
actions.grid(row=7, column=0, columnspan=2, sticky="ew")
self.check_button = ttk.Button(actions, text="Check Devlog Status", command=self.check_status, state="disabled")
self.check_button.grid(row=0, column=0)
ttk.Button(actions, text="Save Project", command=self.save).grid(row=0, column=1, padx=(8, 0))
self.status_var = tk.StringVar(value="Devlog Status: Configuration does not require devlogs")
ttk.Label(form, textvariable=self.status_var, font=("TkDefaultFont", 10, "bold")).grid(row=8, column=0, columnspan=2, sticky="w", pady=(12, 5))
self.details = ScrolledText(form, wrap=tk.WORD, height=14, state="disabled")
self.details.grid(row=9, column=0, columnspan=2, sticky="nsew")
self.footer_var = tk.StringVar()
ttk.Label(outer, textvariable=self.footer_var).grid(row=1, column=0, columnspan=2, sticky="w", pady=(8, 0))
self.root.protocol("WM_DELETE_WINDOW", self.close)
self.refresh()
self.new_source(False)
def values(self) -> tuple[str, ...]:
return tuple(self.vars[name].get() for name in self.vars)
def set_details(self, value: str) -> None:
self.details.configure(state="normal")
self.details.delete("1.0", tk.END)
self.details.insert("1.0", value)
self.details.configure(state="disabled")
def may_discard(self) -> bool:
return self.snapshot is None or self.values() == self.snapshot or messagebox.askyesno(
"Discard unsaved changes?", "Discard the unsaved project-source changes?", icon="warning", parent=self.root
)
def refresh(self, preserve: bool = True) -> None:
selected = self.tree.selection()[0] if preserve and self.tree.selection() else None
self.tree.delete(*self.tree.get_children())
try:
sources = registry.list_sources()
except ProjectEditorError as exc:
messagebox.showerror("Cannot load projects", str(exc), parent=self.root)
return
for source in sources:
self.tree.insert("", tk.END, iid=source.project_id, text=source.project_id, values=(source.branch,))
if selected and self.tree.exists(selected):
self.tree.selection_set(selected)
self.footer_var.set(f"{len(sources)} configured project source{'s' if len(sources) != 1 else ''}")
def new_source(self, confirm: bool = True) -> None:
if confirm and not self.may_discard():
return
self.mode = "new"
self.current = None
self.id_entry.configure(state="normal")
for variable in self.vars.values():
variable.set("")
self.vars["branch"].set("main")
self.delete_button.configure(state="disabled")
self.check_button.configure(state="disabled")
self.status_var.set("Devlog Status: Configuration does not require devlogs")
self.set_details("Save this source to make it part of the site's project importer configuration.")
self.snapshot = self.values()
def edit_selected(self) -> None:
selection = self.tree.selection()
if not selection:
messagebox.showinfo("Select a project", "Select a configured project to edit.", parent=self.root)
return
if not self.may_discard():
return
try:
source = registry.load_source(selection[0])
except ProjectEditorError as exc:
messagebox.showerror("Cannot load project", str(exc), parent=self.root)
return
self.mode = "edit"
self.current = source
self.vars["project_id"].set(source.project_id)
self.vars["web_url"].set(source.web_url)
self.vars["branch"].set(source.branch)
self.vars["featured_order"].set("" if source.featured_order is None else str(source.featured_order))
self.id_entry.configure(state="readonly")
self.delete_button.configure(state="normal")
self.check_button.configure(state="normal")
self.status_var.set("Devlog Status: Not checked")
self.set_details(f"Configured repository: {source.web_url}\nClick “Check Devlog Status” for a read-only remote validation.")
self.snapshot = self.values()
def save(self) -> None:
values = self.values()
try:
if self.mode == "new":
source = registry.create_source(*values)
else:
if self.current is None:
raise ProjectEditorError("No configured project is loaded.")
source = registry.save_source(self.current, *values[1:])
except ProjectEditorError as exc:
messagebox.showerror("Cannot save project", str(exc), parent=self.root)
return
self.current = source
self.mode = "edit"
self.vars["project_id"].set(source.project_id)
self.vars["web_url"].set(source.web_url)
self.vars["branch"].set(source.branch)
self.vars["featured_order"].set(
"" if source.featured_order is None else str(source.featured_order)
)
self.id_entry.configure(state="readonly")
self.delete_button.configure(state="normal")
self.check_button.configure(state="normal")
self.snapshot = self.values()
self.refresh(False)
self.tree.selection_set(source.project_id)
self.tree.see(source.project_id)
self.status_var.set("Devlog Status: Not checked")
self.set_details("Project source saved. Run the manual readiness check to inspect the remote repository.")
self.footer_var.set(f"Saved configs/project-sources.ini [{source.project_id}]")
def delete_current(self) -> None:
if self.current is None:
return
source = self.current
if not messagebox.askyesno(
"Delete configured project?",
f"Remove {source.project_id!r} from the site project-source registry?\n\nRepository: {source.web_url}\n\nThis removes only the site-side configuration. It does not delete or modify the external repository.",
icon="warning",
parent=self.root,
):
return
try:
registry.delete_source(source)
except ProjectEditorError as exc:
messagebox.showerror("Cannot delete project", str(exc), parent=self.root)
return
project_id = source.project_id
self.snapshot = None
self.refresh(False)
self.new_source(False)
self.footer_var.set(f"Removed [{project_id}] from configs/project-sources.ini; external repository unchanged")
def check_status(self) -> None:
if self.current is None or self.checking:
return
if self.values() != self.snapshot:
messagebox.showinfo("Save before checking", "Save or discard the project-source changes before checking the configured remote.", parent=self.root)
return
source = self.current
self.checking = True
self.check_button.configure(state="disabled")
self.status_var.set("Devlog Status: Checking…")
self.set_details("Checking public provider metadata and validating a temporary read-only Git mirror…")
def worker() -> None:
try:
result = check_devlog_readiness(source)
except Exception as exc: # Keep unexpected operational failures out of Tk's worker thread.
result = DevlogReadiness(
"Unable to check",
"An unexpected error stopped the read-only validation.",
str(exc),
)
self.root.after(0, self.finish_check, result)
threading.Thread(target=worker, daemon=True).start()
def finish_check(self, result: DevlogReadiness) -> None:
self.checking = False
self.check_button.configure(state="normal" if self.current else "disabled")
self.status_var.set(f"Devlog Status: {result.status}")
self.set_details(result.summary + "\n\n" + result.details)
def close(self) -> None:
if self.may_discard():
self.root.destroy()
try:
app = ProjectEditorApp()
except tk.TclError as exc:
raise ProjectEditorError(f"Cannot open the Tkinter window: {exc}") from exc
app.root.mainloop()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--site-root", type=Path, default=Path(__file__).resolve().parents[1])
args = parser.parse_args()
try:
run_gui(ProjectSourceRegistry(args.site_root))
except ProjectEditorError as exc:
parser.exit(1, f"project-editor: ERROR: {exc}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+104
View File
@@ -0,0 +1,104 @@
"""Provider-specific URL derivation for public Labyricorn project repositories."""
from __future__ import annotations
from dataclasses import dataclass
import ipaddress
import re
from urllib.parse import quote, urlsplit, urlunsplit
class ProjectProviderError(ValueError):
"""Raised when a public repository URL cannot be derived safely."""
@dataclass(frozen=True)
class RepositoryUrls:
provider: str
repository: str
web_url: str
api_url: str
def validate_public_https_url(value: str, field: str) -> str:
value = value.strip().rstrip("/")
parsed = urlsplit(value)
if (
parsed.scheme != "https"
or not parsed.hostname
or parsed.username
or parsed.password
):
raise ProjectProviderError(f"{field} must be a credential-free HTTPS URL")
if parsed.hostname.lower() == "localhost":
raise ProjectProviderError(f"{field} must not target localhost")
try:
address = ipaddress.ip_address(parsed.hostname)
except ValueError:
pass
else:
if not address.is_global:
raise ProjectProviderError(f"{field} must not target a private or local address")
return value
def repository_provider(web_url: str) -> str:
hostname = (urlsplit(web_url).hostname or "").lower()
return "github" if hostname == "github.com" else "gitea"
def derive_repository_urls(value: str) -> RepositoryUrls:
"""Derive Git and API URLs from one GitHub or Gitea repository web URL."""
web_url = validate_public_https_url(value, "Repository URL")
parsed = urlsplit(web_url)
hostname = (parsed.hostname or "").lower()
if hostname == "gitlab.com":
raise ProjectProviderError(
"GitLab repositories are not supported; use a public GitHub or Gitea URL"
)
if parsed.query or parsed.fragment:
raise ProjectProviderError("Repository URL must not contain a query or fragment")
path = parsed.path.rstrip("/")
if path.lower().endswith(".git"):
path = path[:-4]
parts = [part for part in path.split("/") if part]
if len(parts) != 2:
raise ProjectProviderError(
"Repository URL must identify one owner and repository, such as "
"https://github.com/owner/repository"
)
owner, repository_name = parts
if any(
part in {".", ".."} or not re.fullmatch(r"[A-Za-z0-9_.-]+", part)
for part in (owner, repository_name)
):
raise ProjectProviderError(
"Repository owner and name contain unsupported URL characters"
)
normalized_path = f"/{owner}/{repository_name}"
normalized_web = urlunsplit((parsed.scheme, parsed.netloc, normalized_path, "", ""))
provider = repository_provider(normalized_web)
if provider == "github":
api_url = (
"https://api.github.com/repos/"
f"{quote(owner, safe='')}/{quote(repository_name, safe='')}"
)
else:
api_url = urlunsplit(
(
parsed.scheme,
parsed.netloc,
f"/api/v1/repos/{quote(owner, safe='')}/{quote(repository_name, safe='')}",
"",
"",
)
)
return RepositoryUrls(provider, f"{normalized_web}.git", normalized_web, api_url)
def repository_readme_url(web_url: str, branch: str) -> str:
encoded_branch = quote(branch, safe="")
if repository_provider(web_url) == "github":
return f"{web_url}/blob/{encoded_branch}/README.md"
return f"{web_url}/src/branch/{encoded_branch}/README.md"
+25 -16
View File
@@ -6,7 +6,6 @@ from __future__ import annotations
import argparse
import configparser
import hashlib
import ipaddress
import io
import json
import os
@@ -19,11 +18,18 @@ import tempfile
from datetime import date, datetime, timezone
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urlparse
from urllib.parse import quote
from urllib.request import Request, urlopen
from lektor.metaformat import tokenize
from project_providers import (
ProjectProviderError,
repository_provider,
repository_readme_url,
validate_public_https_url,
)
SCHEMA_VERSION = "1"
MAX_FILES = 100
@@ -108,19 +114,10 @@ def run_git(git_dir: Path, *args: str, text: bool = True) -> str | bytes:
def validate_url(value: str, field: str) -> str:
parsed = urlparse(value)
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
raise ProjectSourceError(f"{field} must be a credential-free HTTPS URL")
if parsed.hostname.lower() == "localhost":
raise ProjectSourceError(f"{field} must not target localhost")
try:
address = ipaddress.ip_address(parsed.hostname)
except ValueError:
pass
else:
if not address.is_global:
raise ProjectSourceError(f"{field} must not target a private or local address")
return value.rstrip("/")
return validate_public_https_url(value, field)
except ProjectProviderError as exc:
raise ProjectSourceError(str(exc)) from exc
def load_registry(site_root: Path) -> list[dict[str, str | int]]:
@@ -454,6 +451,14 @@ def fetch_json(
except HTTPError as exc:
if allow_not_found and exc.code == 404:
return None
if (
require_public
and exc.code == 403
and exc.headers.get("X-RateLimit-Remaining") == "0"
):
raise ProjectSourceError(
"anonymous provider API rate limit was reached"
) from exc
if require_public and exc.code in {401, 403, 404}:
raise RepositoryNotPublicError(
"repository API is not anonymously accessible"
@@ -490,11 +495,14 @@ def detect_license(mirror: Path, commit: str) -> str:
def collect_metadata(
source: dict[str, str], mirror: Path, commit: str, previous: dict[str, Any] | None
) -> tuple[dict[str, Any], bool]:
provider = repository_provider(str(source["web_url"]))
metadata: dict[str, Any] = commit_metadata(mirror, commit)
metadata.update(
{
"repository_url": source["web_url"],
"readme_url": f"{source['web_url']}/src/branch/{quote(source['branch'], safe='')}/README.md",
"readme_url": repository_readme_url(
str(source["web_url"]), str(source["branch"])
),
"commit_url": f"{source['web_url']}/commit/{commit}",
"default_branch": source["branch"],
"license": detect_license(mirror, commit),
@@ -516,7 +524,8 @@ def collect_metadata(
if repository.get("default_branch") != source["branch"]:
raise ProjectSourceError(f"{source['project_id']}: API default branch differs from registry")
metadata["open_issues"] = int(repository.get("open_issues_count") or 0)
metadata["stars"] = int(repository.get("stars_count") or 0)
stars_field = "stargazers_count" if provider == "github" else "stars_count"
metadata["stars"] = int(repository.get(stars_field) or 0)
metadata["forks"] = int(repository.get("forks_count") or 0)
languages = fetch_json(f"{source['api_url']}/languages")
if isinstance(languages, dict):
File diff suppressed because it is too large Load Diff
+162
View File
@@ -0,0 +1,162 @@
from __future__ import annotations
from pathlib import Path
import os
import shutil
import stat
import subprocess
import sys
import unittest
import uuid
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from git_repo import ( # noqa: E402
DIVERGED,
LOCAL_AHEAD,
REMOTE_AHEAD,
SYNCED,
UNAVAILABLE,
GitRepository,
GitRepositoryError,
_display_url,
)
class GitRepositoryTests(unittest.TestCase):
def setUp(self) -> None:
cache_root = Path(__file__).resolve().parents[1] / ".cache"
cache_root.mkdir(exist_ok=True)
self.base = cache_root / f"git-repo-test-{uuid.uuid4().hex}"
self.base.mkdir()
self.remote = self.base / "labyricorn-site.git"
self.seed = self.base / "seed"
self.work = self.base / "work"
self.other = self.base / "other"
self.git(self.base, "init", "--bare", "--initial-branch=main", str(self.remote))
self.git(self.base, "init", "--initial-branch=main", str(self.seed))
self.configure_identity(self.seed)
(self.seed / "README.md").write_text("initial\n", encoding="utf-8")
self.git(self.seed, "add", "README.md")
self.git(self.seed, "commit", "-m", "Initial")
self.git(self.seed, "remote", "add", "origin", str(self.remote))
self.git(self.seed, "push", "-u", "origin", "main")
self.git(self.base, "clone", str(self.remote), str(self.work))
self.git(self.base, "clone", str(self.remote), str(self.other))
self.configure_identity(self.work)
self.configure_identity(self.other)
self.repository = GitRepository.locate(self.work / "README.md")
def tearDown(self) -> None:
shutil.rmtree(self.base, onexc=self.make_writable)
@staticmethod
def make_writable(function: object, path: str, _error: BaseException) -> None:
os.chmod(path, stat.S_IWRITE)
function(path)
@staticmethod
def git(cwd: Path, *args: str) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
["git", *args],
cwd=cwd,
check=False,
capture_output=True,
text=True,
encoding="utf-8",
)
if result.returncode != 0:
raise AssertionError(result.stderr.strip() or result.stdout.strip())
return result
def configure_identity(self, repository: Path) -> None:
self.git(repository, "config", "user.name", "Launcher Test")
self.git(repository, "config", "user.email", "[email protected]")
def commit(self, repository: Path, filename: str, content: str) -> None:
(repository / filename).write_text(content, encoding="utf-8")
self.git(repository, "add", filename)
self.git(repository, "commit", "-m", f"Update {filename}")
def push_other_commit(self) -> None:
self.commit(self.other, "remote.txt", "remote\n")
self.git(self.other, "push", "origin", "main")
def test_synchronized_and_dirty_states(self) -> None:
state = self.repository.refresh()
self.assertEqual(SYNCED, state.sync_state)
self.assertEqual("labyricorn-site", state.repository_name)
self.assertEqual("main", state.branch)
self.assertEqual("origin/main", state.upstream)
self.assertEqual(0, state.dirty_count)
(self.work / "local.txt").write_text("uncommitted\n", encoding="utf-8")
dirty = self.repository.refresh()
self.assertEqual(SYNCED, dirty.sync_state)
self.assertEqual(1, dirty.dirty_count)
def test_local_ahead_and_push(self) -> None:
self.commit(self.work, "local.txt", "local\n")
state = self.repository.refresh()
self.assertEqual(LOCAL_AHEAD, state.sync_state)
self.assertEqual(1, state.ahead)
self.assertTrue(state.can_push)
result = self.repository.push(state)
self.assertEqual(SYNCED, result.sync_state)
def test_remote_ahead_and_fast_forward_pull(self) -> None:
self.push_other_commit()
state = self.repository.refresh()
self.assertEqual(REMOTE_AHEAD, state.sync_state)
self.assertEqual(1, state.behind)
self.assertTrue(state.can_pull)
result = self.repository.pull(state)
self.assertEqual(SYNCED, result.sync_state)
self.assertTrue((self.work / "remote.txt").is_file())
def test_dirty_worktree_blocks_pull(self) -> None:
self.push_other_commit()
(self.work / "local.txt").write_text("uncommitted\n", encoding="utf-8")
state = self.repository.refresh()
self.assertEqual(REMOTE_AHEAD, state.sync_state)
self.assertFalse(state.can_pull)
with self.assertRaisesRegex(GitRepositoryError, "uncommitted changes"):
self.repository.pull(state)
def test_diverged_history_has_no_sync_action(self) -> None:
self.commit(self.work, "local.txt", "local\n")
self.push_other_commit()
state = self.repository.refresh()
self.assertEqual(DIVERGED, state.sync_state)
self.assertEqual(1, state.ahead)
self.assertEqual(1, state.behind)
self.assertFalse(state.can_pull)
self.assertFalse(state.can_push)
with self.assertRaisesRegex(GitRepositoryError, "diverged"):
self.repository.push(state)
def test_missing_origin_and_upstream_are_reported(self) -> None:
self.git(self.work, "remote", "remove", "origin")
missing_origin = self.repository.refresh()
self.assertEqual(UNAVAILABLE, missing_origin.sync_state)
self.assertIn("No usable origin", missing_origin.problem or "")
self.git(self.work, "remote", "add", "origin", str(self.remote))
missing_upstream = self.repository.refresh()
self.assertEqual(UNAVAILABLE, missing_upstream.sync_state)
self.assertIn("no usable upstream", missing_upstream.problem or "")
def test_http_credentials_are_not_displayed(self) -> None:
self.assertEqual(
"https://git.example.test/owner/repository.git",
_display_url("https://user:[email protected]/owner/repository.git"),
)
if __name__ == "__main__":
unittest.main()
+228
View File
@@ -0,0 +1,228 @@
from __future__ import annotations
from pathlib import Path
import shutil
import sys
import unittest
from unittest.mock import patch
import uuid
from types import SimpleNamespace
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from project_editor import ( # noqa: E402
ProjectEditorError,
ProjectSourceRegistry,
check_devlog_readiness,
corrective_guidance,
)
from management_launcher import EDITOR_SCRIPTS # noqa: E402
from project_providers import ProjectProviderError, derive_repository_urls # noqa: E402
class FakeProjectSourceError(RuntimeError):
pass
class FakeRepositoryNotPublicError(FakeProjectSourceError):
pass
def fake_importer(**overrides: object) -> SimpleNamespace:
values: dict[str, object] = {
"ProjectSourceError": FakeProjectSourceError,
"RepositoryNotPublicError": FakeRepositoryNotPublicError,
"fetch_json": lambda *_args, **_kwargs: {"private": False, "default_branch": "main"},
"ensure_mirror": lambda *_args, **_kwargs: (Path("mirror.git"), True),
"resolve_branch": lambda *_args, **_kwargs: "a" * 40,
"validate_snapshot": lambda *_args, **_kwargs: ({}, {}),
}
values.update(overrides)
return SimpleNamespace(**values)
class ProjectEditorTests(unittest.TestCase):
def setUp(self) -> None:
cache_root = Path(__file__).resolve().parents[1] / ".cache"
cache_root.mkdir(exist_ok=True)
self.site_root = cache_root / f"project-editor-test-{uuid.uuid4().hex}"
(self.site_root / "configs").mkdir(parents=True)
(self.site_root / "content" / "projects").mkdir(parents=True)
(self.site_root / "Test.lektorproject").write_text("[project]\nname = Test\n", encoding="utf-8")
(self.site_root / "content" / "projects" / "contents.lr").write_text(
"_model: section\n---\ntitle: Projects\n", encoding="utf-8"
)
(self.site_root / "configs" / "project-sources.ini").write_text(
"# Preserve this header.\n\n"
"[alpha]\n"
"repository = https://git.example.test/team/alpha.git\n"
"web_url = https://git.example.test/team/alpha\n"
"api_url = https://git.example.test/api/v1/repos/team/alpha\n"
"branch = main\n"
"featured_order = 10\n"
"future_option = preserve me\n\n"
"[beta]\n"
"repository = https://git.example.test/team/beta.git\n"
"web_url = https://git.example.test/team/beta\n"
"api_url = https://git.example.test/api/v1/repos/team/beta\n"
"branch = stable\n",
encoding="utf-8",
)
self.registry = ProjectSourceRegistry(self.site_root)
def tearDown(self) -> None:
shutil.rmtree(self.site_root)
def test_current_registry_loads(self) -> None:
registry = ProjectSourceRegistry(Path(__file__).resolve().parents[1])
sources = registry.list_sources()
self.assertGreaterEqual(len(sources), 1)
self.assertTrue(all(source.repository.startswith("https://") for source in sources))
def test_management_launcher_exposes_project_editor_as_required_script(self) -> None:
self.assertIn(("Project Editor", "project_editor.py", True), EDITOR_SCRIPTS)
def test_create_edit_and_delete_preserve_other_configuration(self) -> None:
created = self.registry.create_source(
"gamma",
"https://git.example.test/team/gamma",
"main",
"",
)
alpha_before = self.registry.load_source("alpha")
saved = self.registry.save_source(
created,
created.web_url,
"release",
"30",
)
self.registry.delete_source(saved)
text = (self.site_root / "configs" / "project-sources.ini").read_text(encoding="utf-8")
self.assertIn("# Preserve this header.", text)
self.assertIn("future_option = preserve me", text)
self.assertIn("[beta]", text)
self.assertNotIn("[gamma]", text)
self.assertEqual(self.registry.load_source("alpha").repository, alpha_before.repository)
def test_edit_preserves_unknown_options_and_detects_external_change(self) -> None:
source = self.registry.load_source("alpha")
saved = self.registry.save_source(
source, source.web_url, "release", "11"
)
self.assertEqual(dict(saved.unknown_options)["future_option"], "preserve me")
with (self.site_root / "configs" / "project-sources.ini").open("a", encoding="utf-8") as output:
output.write("\n# external change\n")
with self.assertRaisesRegex(ProjectEditorError, "changed on disk"):
self.registry.save_source(
saved, saved.web_url, saved.branch, "11"
)
def test_delete_never_touches_external_repository_and_refuses_last_source(self) -> None:
beta = self.registry.load_source("beta")
self.registry.delete_source(beta)
alpha = self.registry.load_source("alpha")
with self.assertRaisesRegex(ProjectEditorError, "at least one"):
self.registry.delete_source(alpha)
def test_invalid_urls_and_ids_are_rejected(self) -> None:
with self.assertRaises(ProjectEditorError):
self.registry.create_source(
"Not Valid", "http://example.test/team/repo", "main", ""
)
def test_one_public_url_derives_github_and_gitea_configuration(self) -> None:
github = derive_repository_urls("https://github.com/example/public-project.git")
self.assertEqual(github.provider, "github")
self.assertEqual(github.web_url, "https://github.com/example/public-project")
self.assertEqual(github.repository, f"{github.web_url}.git")
self.assertEqual(
github.api_url, "https://api.github.com/repos/example/public-project"
)
gitea = derive_repository_urls("https://git.example.test/team/project")
self.assertEqual(gitea.provider, "gitea")
self.assertEqual(gitea.repository, "https://git.example.test/team/project.git")
self.assertEqual(
gitea.api_url, "https://git.example.test/api/v1/repos/team/project"
)
saved = self.registry.create_source(
"github-project",
"https://github.com/example/public-project",
"main",
"20",
)
self.assertEqual(saved.repository, github.repository)
self.assertEqual(saved.web_url, github.web_url)
self.assertEqual(saved.api_url, github.api_url)
def test_gitlab_url_is_rejected_clearly(self) -> None:
with self.assertRaisesRegex(ProjectProviderError, "GitLab.*not supported"):
derive_repository_urls("https://gitlab.com/example/project")
@patch("project_editor.shutil.which", return_value="git")
@patch("project_editor.tempfile.TemporaryDirectory")
def test_inaccessible_repository_is_not_reported_as_missing_devlog(
self, temporary: object, _which: object
) -> None:
temporary.return_value.__enter__.return_value = str(self.site_root / "temporary")
def fail_fetch(*_args: object, **_kwargs: object) -> object:
raise FakeProjectSourceError("initial repository fetch failed")
importer = fake_importer(ensure_mirror=fail_fetch)
result = check_devlog_readiness(self.registry.load_source("alpha"), importer)
self.assertEqual(result.status, "Repository inaccessible")
self.assertNotIn("Initialize Devlog", result.details)
@patch("project_editor.shutil.which", return_value="git")
@patch("project_editor.tempfile.TemporaryDirectory")
def test_missing_devlog_has_corrective_standalone_editor_guidance(
self, temporary: object, _which: object
) -> None:
temporary.return_value.__enter__.return_value = str(self.site_root / "temporary")
def fail_validation(*_args: object, **_kwargs: object) -> object:
raise FakeProjectSourceError(
"publishing tree is missing ['.labyricorn/devlog/contents.lr']"
)
importer = fake_importer(validate_snapshot=fail_validation)
source = self.registry.load_source("alpha")
result = check_devlog_readiness(source, importer)
self.assertEqual(result.status, "Not initialized")
guidance = corrective_guidance(source)
for phrase in (
"git clone",
"Copy the standalone devlog_editor.py",
"python devlog_editor.py",
"Initialize Devlog",
"Create and save",
"Publish Labyricorn Changes",
"commit and push",
"Check Devlog Status",
):
self.assertIn(phrase, guidance)
@patch("project_editor.shutil.which", return_value="git")
@patch("project_editor.tempfile.TemporaryDirectory")
def test_valid_remote_snapshot_reports_ready(
self, temporary: object, _which: object
) -> None:
temporary.return_value.__enter__.return_value = str(self.site_root / "temporary")
records = {
".labyricorn/project/contents.lr": {"title": "Alpha", "project_id": "alpha"},
".labyricorn/devlog/contents.lr": {"title": "Alpha devlog"},
}
importer = fake_importer(
resolve_branch=lambda *_args: "b" * 40,
validate_snapshot=lambda *_args: ({}, records),
)
result = check_devlog_readiness(self.registry.load_source("alpha"), importer)
self.assertTrue(result.ready)
self.assertEqual(result.status, "Ready")
self.assertIn("Valid devlog entries: 0", result.details)
if __name__ == "__main__":
unittest.main()
+51
View File
@@ -13,6 +13,7 @@ from project_sources import ( # noqa: E402
ProjectSourceError,
RepositoryNotPublicError,
append_fields,
collect_metadata,
fetch_json,
load_registry,
metadata_digest,
@@ -68,6 +69,21 @@ class ProjectSourceTests(unittest.TestCase):
with self.assertRaises(RepositoryNotPublicError):
fetch_json("https://git.example.test/api/repo", require_public=True)
def test_github_anonymous_rate_limit_is_not_reported_as_private(self) -> None:
error = HTTPError(
"https://api.github.com/repos/example/project",
403,
"Forbidden",
{"X-RateLimit-Remaining": "0"},
None,
)
with patch("project_sources.urlopen", side_effect=error):
with self.assertRaisesRegex(ProjectSourceError, "rate limit"):
fetch_json(
"https://api.github.com/repos/example/project",
require_public=True,
)
def test_unapproved_labels_remain_visible_without_becoming_tags(self) -> None:
items = taxonomy_label_items(
["TypeScript", "Unlisted Tool"], ["typescript"]
@@ -78,6 +94,41 @@ class ProjectSourceTests(unittest.TestCase):
self.assertEqual(normalize_tag_value("v0.4.0"), "v0-4-0")
self.assertEqual(normalize_tag_value("OpenAI Build Week"), "openai-build-week")
def test_github_public_metadata_is_normalized_for_project_templates(self) -> None:
source = {
"project_id": "example",
"repository": "https://github.com/example/project.git",
"web_url": "https://github.com/example/project",
"api_url": "https://api.github.com/repos/example/project",
"branch": "main",
}
responses = (
{
"private": False,
"default_branch": "main",
"open_issues_count": 7,
"stargazers_count": 42,
"forks_count": 5,
},
{"Python": 100, "JavaScript": 25},
{"tag_name": "v1.0.0", "html_url": "https://github.com/example/project/releases/tag/v1.0.0"},
)
with (
patch("project_sources.commit_metadata", return_value={"commit": "a" * 40}),
patch("project_sources.detect_license", return_value="MIT"),
patch("project_sources.fetch_json", side_effect=responses),
):
metadata, current = collect_metadata(source, Path("unused.git"), "a" * 40, None)
self.assertTrue(current)
self.assertEqual(metadata["stars"], 42)
self.assertEqual(metadata["forks"], 5)
self.assertEqual(metadata["languages"], ["Python", "JavaScript"])
self.assertEqual(
metadata["readme_url"],
"https://github.com/example/project/blob/main/README.md",
)
if __name__ == "__main__":
unittest.main()