Files
labyricorn-site/scripts/management_launcher.py
Labyricorn 034c1b653e
Deploy production / deploy (push) Successful in 46s
blog entry
2026-08-13 19:22:21 -07:00

347 lines
14 KiB
Python

#!/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 (
GitAuthenticationError,
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, simpledialog, 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="Commit & 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)
self.repository = repository
return repository.refresh()
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
try:
changes = repository.publishing_changes()
except GitRepositoryError as exc:
messagebox.showerror(
"Cannot prepare publication", str(exc), parent=self.root
)
return
commit_message: str | None = None
if changes:
commit_message = simpledialog.askstring(
"Commit Labyricorn Changes",
"Enter the Git commit message for these reviewed changes:",
parent=self.root,
)
if commit_message is None:
return
commit_message = " ".join(commit_message.splitlines()).strip()
if not commit_message:
messagebox.showerror(
"Commit message required",
"Enter a non-empty commit message before publishing.",
parent=self.root,
)
return
lines = changes.splitlines()
preview_limit = 40
preview = "\n".join(lines[:preview_limit])
if len(lines) > preview_limit:
preview += f"\n… and {len(lines) - preview_limit} more paths"
if not preview:
preview = "No uncommitted paths; existing local commits will be pushed."
branch_warning = (
"\n\nWARNING: Pushing main automatically deploys production."
if expected.branch == "main"
else ""
)
confirmed = messagebox.askyesno(
"Confirm Commit and Push",
f"Repository changes to publish:\n\n{preview}"
f"\n\nCommit message: {commit_message or '(existing commits only)'}"
f"{branch_warning}\n\nContinue?",
parent=self.root,
)
if not confirmed:
return
self._start_git_task(
"Committing and pushing reviewed changes",
lambda: repository.publish(expected, commit_message, changes),
)
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 GitAuthenticationError as exc:
self.root.after(0, self._finish_authentication_error, label, str(exc))
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_authentication_error(self, label: str, detail: str) -> None:
self.busy = False
self.state = None
self.details_var.set(detail)
self.values["status"].set("Authentication required")
self._update_buttons()
if self.repository is None:
messagebox.showerror(label, detail, parent=self.root)
return
retry = messagebox.askyesno(
"Git authentication failed",
f"{detail}\n\n"
"Clear only the saved credential for this repository's HTTPS host "
"and open the Git Credential Manager sign-in flow?\n\n"
"Working files and local commits will not be changed.",
parent=self.root,
)
if not retry:
return
repository = self.repository
def clear_and_refresh() -> RepositoryState:
repository.clear_https_credential()
return repository.refresh(allow_prompt=True)
self._start_git_task("Clearing rejected credential and signing in", clear_and_refresh)
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."
)
elif state.dirty_count and state.can_publish:
self.details_var.set(
"Working-tree changes are ready for review with Commit & Push."
)
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_publish
)
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())