This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user