This commit is contained in:
@@ -291,6 +291,24 @@ test -f build/index.html
|
|||||||
|
|
||||||
The project currently has no third-party Lektor packages or plugins.
|
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
|
### Local blog editor
|
||||||
|
|
||||||
For a focused desktop form that creates and edits records under `content/blog/`,
|
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
|
preset. Replacement and removal require confirmation; failed conversion leaves
|
||||||
any existing narration unchanged.
|
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
|
## Production filesystem and permissions
|
||||||
|
|
||||||
```text
|
```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.
|
|
||||||
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
_model: tag
|
|
||||||
---
|
|
||||||
title: v0.4.0
|
|
||||||
---
|
|
||||||
summary: Work associated with the version 0.4.0 contract and milestone.
|
|
||||||
+1769
File diff suppressed because it is too large
Load Diff
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 argparse
|
||||||
import configparser
|
import configparser
|
||||||
import hashlib
|
import hashlib
|
||||||
import ipaddress
|
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -19,11 +18,18 @@ import tempfile
|
|||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.parse import quote, urlparse
|
from urllib.parse import quote
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
from lektor.metaformat import tokenize
|
from lektor.metaformat import tokenize
|
||||||
|
|
||||||
|
from project_providers import (
|
||||||
|
ProjectProviderError,
|
||||||
|
repository_provider,
|
||||||
|
repository_readme_url,
|
||||||
|
validate_public_https_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
SCHEMA_VERSION = "1"
|
SCHEMA_VERSION = "1"
|
||||||
MAX_FILES = 100
|
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:
|
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:
|
try:
|
||||||
address = ipaddress.ip_address(parsed.hostname)
|
return validate_public_https_url(value, field)
|
||||||
except ValueError:
|
except ProjectProviderError as exc:
|
||||||
pass
|
raise ProjectSourceError(str(exc)) from exc
|
||||||
else:
|
|
||||||
if not address.is_global:
|
|
||||||
raise ProjectSourceError(f"{field} must not target a private or local address")
|
|
||||||
return value.rstrip("/")
|
|
||||||
|
|
||||||
|
|
||||||
def load_registry(site_root: Path) -> list[dict[str, str | int]]:
|
def load_registry(site_root: Path) -> list[dict[str, str | int]]:
|
||||||
@@ -454,6 +451,14 @@ def fetch_json(
|
|||||||
except HTTPError as exc:
|
except HTTPError as exc:
|
||||||
if allow_not_found and exc.code == 404:
|
if allow_not_found and exc.code == 404:
|
||||||
return None
|
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}:
|
if require_public and exc.code in {401, 403, 404}:
|
||||||
raise RepositoryNotPublicError(
|
raise RepositoryNotPublicError(
|
||||||
"repository API is not anonymously accessible"
|
"repository API is not anonymously accessible"
|
||||||
@@ -490,11 +495,14 @@ def detect_license(mirror: Path, commit: str) -> str:
|
|||||||
def collect_metadata(
|
def collect_metadata(
|
||||||
source: dict[str, str], mirror: Path, commit: str, previous: dict[str, Any] | None
|
source: dict[str, str], mirror: Path, commit: str, previous: dict[str, Any] | None
|
||||||
) -> tuple[dict[str, Any], bool]:
|
) -> tuple[dict[str, Any], bool]:
|
||||||
|
provider = repository_provider(str(source["web_url"]))
|
||||||
metadata: dict[str, Any] = commit_metadata(mirror, commit)
|
metadata: dict[str, Any] = commit_metadata(mirror, commit)
|
||||||
metadata.update(
|
metadata.update(
|
||||||
{
|
{
|
||||||
"repository_url": source["web_url"],
|
"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}",
|
"commit_url": f"{source['web_url']}/commit/{commit}",
|
||||||
"default_branch": source["branch"],
|
"default_branch": source["branch"],
|
||||||
"license": detect_license(mirror, commit),
|
"license": detect_license(mirror, commit),
|
||||||
@@ -516,7 +524,8 @@ def collect_metadata(
|
|||||||
if repository.get("default_branch") != source["branch"]:
|
if repository.get("default_branch") != source["branch"]:
|
||||||
raise ProjectSourceError(f"{source['project_id']}: API default branch differs from registry")
|
raise ProjectSourceError(f"{source['project_id']}: API default branch differs from registry")
|
||||||
metadata["open_issues"] = int(repository.get("open_issues_count") or 0)
|
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)
|
metadata["forks"] = int(repository.get("forks_count") or 0)
|
||||||
languages = fetch_json(f"{source['api_url']}/languages")
|
languages = fetch_json(f"{source['api_url']}/languages")
|
||||||
if isinstance(languages, dict):
|
if isinstance(languages, dict):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -13,6 +13,7 @@ from project_sources import ( # noqa: E402
|
|||||||
ProjectSourceError,
|
ProjectSourceError,
|
||||||
RepositoryNotPublicError,
|
RepositoryNotPublicError,
|
||||||
append_fields,
|
append_fields,
|
||||||
|
collect_metadata,
|
||||||
fetch_json,
|
fetch_json,
|
||||||
load_registry,
|
load_registry,
|
||||||
metadata_digest,
|
metadata_digest,
|
||||||
@@ -68,6 +69,21 @@ class ProjectSourceTests(unittest.TestCase):
|
|||||||
with self.assertRaises(RepositoryNotPublicError):
|
with self.assertRaises(RepositoryNotPublicError):
|
||||||
fetch_json("https://git.example.test/api/repo", require_public=True)
|
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:
|
def test_unapproved_labels_remain_visible_without_becoming_tags(self) -> None:
|
||||||
items = taxonomy_label_items(
|
items = taxonomy_label_items(
|
||||||
["TypeScript", "Unlisted Tool"], ["typescript"]
|
["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("v0.4.0"), "v0-4-0")
|
||||||
self.assertEqual(normalize_tag_value("OpenAI Build Week"), "openai-build-week")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user