feat(workflow): Gitea-Wiki als generierter Spiegel von docs/ (#192)
Konverter .gitea/scripts/wiki_mirror.py, Push-Skript wiki-push.sh, Job wiki-mirror in release.yml, Dispatch-Workflow wiki-mirror.yml und --check-Schritt in ci.yml. Version 9.0.0-beta.13. Files changed: - .gitea/scripts/wiki-push.sh - .gitea/scripts/wiki_mirror.py - .gitea/workflows/ci.yml - .gitea/workflows/release.yml - .gitea/workflows/wiki-mirror.yml - CHANGES.md - README.md - VERSION - docs/dev/development.md - tools/chemenu/tests/test_wiki_mirror.py Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
1 parent
45ae04cfee
commit
90ea0eea1b
10 files changed
+869
-3
No files matched your search
@@ -0,0 +1,313 @@
|
||||
"""Tests for the Gitea wiki mirror: the converter `.gitea/scripts/wiki_mirror.py` and the push
|
||||
script `.gitea/scripts/wiki-push.sh`. Neither is part of `tools/chemenu`; the converter is loaded
|
||||
by path and the push script is run against a bare repository on disk."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parents[3]
|
||||
SCRIPTS = REPO / ".gitea" / "scripts"
|
||||
TAG_URL = "https://gitea.nehmer.net/torben/chemenu/src/tag/v1.2.3"
|
||||
|
||||
|
||||
def _load():
|
||||
spec = importlib.util.spec_from_file_location("wiki_mirror", SCRIPTS / "wiki_mirror.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
wm = _load()
|
||||
|
||||
|
||||
def _write(root: Path, files: dict[str, str]) -> Path:
|
||||
for rel, text in files.items():
|
||||
path = root / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
def _run(source: Path, out: Path | None, *extra: str) -> int:
|
||||
argv = ["--source", str(source), "--version", "1.2.3", *extra]
|
||||
if out is not None:
|
||||
argv += ["--out", str(out)]
|
||||
return wm.main(argv)
|
||||
|
||||
|
||||
def _read_all(out: Path) -> dict[str, bytes]:
|
||||
return {p.name: p.read_bytes() for p in sorted(out.iterdir())}
|
||||
|
||||
|
||||
MINI = {
|
||||
"README.md": "# Home\n\n[guide](docs/how-to/ingest.md) [agents](AGENTS.md)\n",
|
||||
"INSTALL.md": "# Install\n",
|
||||
"AGENTS.md": "# Agents\n",
|
||||
"tools/CONTRACT.md": "# Contract\n",
|
||||
"docs/how-to/README.md": "# How-to\n",
|
||||
"docs/how-to/ingest.md": "# Ingest\n\n[up](README.md) [ref](../reference/commands/pages.md#foo)\n",
|
||||
"docs/reference/commands/README.md": "# Commands\n",
|
||||
"docs/reference/commands/pages.md": "# Pages\n\n## Foo\n",
|
||||
"docs/dev/development.md": "# Developing\n",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path, name",
|
||||
[
|
||||
("docs/how-to/ingest-a-source.md", "How-to-Ingest-a-source"),
|
||||
("docs/how-to/README.md", "How-to"),
|
||||
("docs/reference/commands/pages.md", "Reference-Commands-Pages"),
|
||||
("docs/reference/commands/README.md", "Reference-Commands"),
|
||||
("docs/dev/development.md", "Dev-Development"),
|
||||
("docs/why-gates-are-code.md", "Why-gates-are-code"),
|
||||
("README.md", "Home"),
|
||||
("INSTALL.md", "Install"),
|
||||
],
|
||||
)
|
||||
def test_name_rule(tmp_path, path, name):
|
||||
_write(tmp_path, {"README.md": "x\n", "INSTALL.md": "x\n", path: "x\n"})
|
||||
pages, errors = wm.collect_pages(str(tmp_path))
|
||||
assert errors == []
|
||||
assert {p.path: p.name for p in pages}[path] == name
|
||||
|
||||
|
||||
def test_every_file_under_docs_gets_a_unique_name_and_output_is_reproducible(tmp_path):
|
||||
pages, errors = wm.collect_pages(str(REPO))
|
||||
assert errors == []
|
||||
mirrored = {p.path for p in pages}
|
||||
on_disk = {p.relative_to(REPO).as_posix() for p in (REPO / "docs").rglob("*.md")}
|
||||
assert on_disk <= mirrored
|
||||
names = [p.name.lower() for p in pages]
|
||||
assert len(names) == len(set(names))
|
||||
|
||||
first, second = tmp_path / "a", tmp_path / "b"
|
||||
assert _run(REPO, first) == 0
|
||||
assert _run(REPO, second) == 0
|
||||
assert _read_all(first) == _read_all(second)
|
||||
|
||||
|
||||
def test_every_relative_link_in_the_output_is_a_page_or_a_tag_url(tmp_path):
|
||||
out = tmp_path / "out"
|
||||
assert _run(REPO, out) == 0
|
||||
names = {p.stem for p in out.iterdir()}
|
||||
for page in out.iterdir():
|
||||
for line, in_code in wm.code_flags(page.read_text(encoding="utf-8")):
|
||||
if in_code:
|
||||
continue
|
||||
for match in wm._LINK.finditer(wm.mask_code_spans(line)):
|
||||
target = match.group(1)
|
||||
if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", target) or target.startswith("#"):
|
||||
continue
|
||||
assert target.partition("#")[0] in names, (page.name, target)
|
||||
sidebar = (out / "_Sidebar.md").read_text(encoding="utf-8")
|
||||
linked = set(re.findall(r"\]\(([^)#]+)\)", sidebar))
|
||||
assert linked <= names
|
||||
|
||||
|
||||
def test_links_are_rewritten_and_everything_else_is_left_alone(tmp_path):
|
||||
out = tmp_path / "out"
|
||||
assert _run(_write(tmp_path / "src", MINI), out) == 0
|
||||
ingest = (out / "How-to-Ingest.md").read_text(encoding="utf-8")
|
||||
assert "[up](How-to)" in ingest
|
||||
assert "[ref](Reference-Commands-Pages#foo)" in ingest
|
||||
home = (out / "Home.md").read_text(encoding="utf-8")
|
||||
assert "[guide](How-to-Ingest)" in home
|
||||
assert f"[agents]({TAG_URL}/AGENTS.md)" in home
|
||||
assert home.startswith(
|
||||
f"*Generated from [`README.md`]({TAG_URL}/README.md) at v1.2.3 – edit the repository, not this wiki.*\n\n"
|
||||
)
|
||||
assert sorted(_read_all(out)) == sorted(
|
||||
[
|
||||
"Home.md", "Install.md", "How-to.md", "How-to-Ingest.md", "Reference-Commands.md",
|
||||
"Reference-Commands-Pages.md", "Dev-Development.md", "_Sidebar.md",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_external_links_anchors_and_code_are_not_touched(tmp_path):
|
||||
body = (
|
||||
"[ext](https://example.org/a.md) [mail](mailto:a@b.c) [here](#foo)\n"
|
||||
"`[inline](missing.md)` and [`code text`](AGENTS.md)\n"
|
||||
"```\n[fenced](missing.md)\n```\n"
|
||||
"~~~sh\n[tilde](missing.md)\n~~~\n"
|
||||
" ````\n```\n[nested](missing.md)\n````\n"
|
||||
)
|
||||
src = _write(tmp_path / "src", {"README.md": body, "AGENTS.md": "x\n"})
|
||||
out = tmp_path / "out"
|
||||
assert _run(src, out) == 0
|
||||
home = (out / "Home.md").read_text(encoding="utf-8").split("\n\n", 1)[1]
|
||||
assert home == body.replace("[`code text`](AGENTS.md)", f"[`code text`]({TAG_URL}/AGENTS.md)")
|
||||
|
||||
|
||||
def test_link_to_a_directory_without_a_readme_points_at_the_tag(tmp_path):
|
||||
src = _write(tmp_path / "src", {"README.md": "[d](docs/) [t](tools/)\n", "tools/x.txt": "x\n", "docs/a.md": "x\n"})
|
||||
out = tmp_path / "out"
|
||||
assert _run(src, out) == 0
|
||||
home = (out / "Home.md").read_text(encoding="utf-8")
|
||||
assert f"[d]({TAG_URL}/docs)" in home and f"[t]({TAG_URL}/tools)" in home
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"files, expect",
|
||||
[
|
||||
({"docs/a/b.md": "x\n", "docs/a-b.md": "x\n"}, ["docs/a/b.md", "docs/a-b.md"]),
|
||||
({"docs/how-to/x.md": "x\n", "docs/How-to/x.md": "x\n"}, ["docs/how-to/x.md", "docs/How-to/x.md"]),
|
||||
({"docs/install.md": "x\n"}, ["docs/install.md", "reserved"]),
|
||||
({"docs/_sidebar.md": "x\n"}, ["docs/_sidebar.md", "reserved"]),
|
||||
],
|
||||
)
|
||||
def test_a_name_collision_fails_and_names_the_paths(tmp_path, capsys, files, expect):
|
||||
src = _write(tmp_path / "src", {"README.md": "x\n", "INSTALL.md": "x\n", **files})
|
||||
assert _run(src, tmp_path / "out") == 1
|
||||
err = capsys.readouterr().err
|
||||
for fragment in expect:
|
||||
assert fragment in err
|
||||
assert not (tmp_path / "out").exists()
|
||||
|
||||
|
||||
def test_a_link_to_a_missing_file_fails_and_names_both_paths(tmp_path, capsys):
|
||||
src = _write(tmp_path / "src", {"README.md": "x\n", "docs/a.md": "[gone](../nowhere/x.md)\n"})
|
||||
assert _run(src, tmp_path / "out") == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "docs/a.md" in err and "nowhere/x.md" in err
|
||||
|
||||
|
||||
def test_a_link_out_of_the_tree_fails(tmp_path, capsys):
|
||||
src = _write(tmp_path / "src", {"README.md": "[x](../../etc/passwd)\n"})
|
||||
assert _run(src, tmp_path / "out") == 1
|
||||
assert "leaves the tree" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_check_writes_nothing_and_fails_on_a_problem(tmp_path):
|
||||
src = _write(tmp_path / "src", MINI)
|
||||
assert _run(src, None, "--check") == 0
|
||||
assert not (tmp_path / "out").exists()
|
||||
_write(src, {"docs/bad.md": "[x](missing.md)\n"})
|
||||
assert _run(src, None, "--check") == 1
|
||||
|
||||
|
||||
def test_a_flat_docs_directory_without_areas_is_mirrored(tmp_path):
|
||||
src = _write(
|
||||
tmp_path / "src",
|
||||
{
|
||||
"README.md": "[why](docs/why.md) [dev](DEVELOPMENT.md)\n",
|
||||
"DEVELOPMENT.md": "x\n",
|
||||
"docs/why.md": "# Why\n\n[home](../README.md)\n",
|
||||
},
|
||||
)
|
||||
out = tmp_path / "out"
|
||||
assert _run(src, out) == 0
|
||||
assert "[why](Why)" in (out / "Home.md").read_text(encoding="utf-8")
|
||||
assert f"[dev]({TAG_URL}/DEVELOPMENT.md)" in (out / "Home.md").read_text(encoding="utf-8")
|
||||
assert "[home](Home)" in (out / "Why.md").read_text(encoding="utf-8")
|
||||
sidebar = (out / "_Sidebar.md").read_text(encoding="utf-8")
|
||||
assert "* Documentation\n * [Why](Why)\n" in sidebar
|
||||
|
||||
|
||||
def test_sidebar_order_and_nesting(tmp_path):
|
||||
out = tmp_path / "out"
|
||||
assert _run(_write(tmp_path / "src", MINI), out) == 0
|
||||
assert (out / "_Sidebar.md").read_text(encoding="utf-8") == (
|
||||
"* [Home](Home)\n"
|
||||
"* [Install](Install)\n"
|
||||
"* [How-to](How-to)\n"
|
||||
" * [Ingest](How-to-Ingest)\n"
|
||||
"* Reference\n"
|
||||
" * [Commands](Reference-Commands)\n"
|
||||
" * [Pages](Reference-Commands-Pages)\n"
|
||||
"* Dev\n"
|
||||
" * [Developing](Dev-Development)\n"
|
||||
)
|
||||
|
||||
|
||||
def test_a_non_empty_output_directory_is_refused(tmp_path, capsys):
|
||||
out = tmp_path / "out"
|
||||
_write(out, {"stale.md": "x\n"})
|
||||
assert _run(_write(tmp_path / "src", MINI), out) == 1
|
||||
assert "not empty" in capsys.readouterr().err
|
||||
|
||||
|
||||
# --- wiki-push.sh -----------------------------------------------------------------------------
|
||||
|
||||
needs_tools = pytest.mark.skipif(
|
||||
not all(shutil.which(t) for t in ("git", "sh", "base64", "diff", "find")),
|
||||
reason="needs git and POSIX utilities",
|
||||
)
|
||||
|
||||
|
||||
def _git(*args: str, cwd: Path | None = None) -> str:
|
||||
return subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True).stdout
|
||||
|
||||
|
||||
class Wiki:
|
||||
def __init__(self, root: Path):
|
||||
self.bare = root / "wiki.git"
|
||||
self.out = root / "out"
|
||||
subprocess.run(["git", "init", "-q", "--bare", str(self.bare)], check=True)
|
||||
|
||||
def mirror(self, files: dict[str, str], version: str = "1.0.0", token: str = "t"):
|
||||
shutil.rmtree(self.out, ignore_errors=True)
|
||||
_write(self.out, files)
|
||||
env = {**os.environ, "WIKI_REMOTE": str(self.bare), "WIKI_TOKEN": token}
|
||||
if not token:
|
||||
env.pop("WIKI_TOKEN")
|
||||
return subprocess.run(
|
||||
["sh", str(SCRIPTS / "wiki-push.sh"), str(self.out), version],
|
||||
env=env, capture_output=True, text=True,
|
||||
)
|
||||
|
||||
def commits(self) -> int:
|
||||
refs = _git("--git-dir", str(self.bare), "for-each-ref")
|
||||
return int(_git("--git-dir", str(self.bare), "rev-list", "--all", "--count")) if refs else 0
|
||||
|
||||
def files(self) -> dict[str, str]:
|
||||
branch = _git("--git-dir", str(self.bare), "for-each-ref", "--format=%(refname)").split()[0]
|
||||
names = _git("--git-dir", str(self.bare), "ls-tree", "-r", "--name-only", branch).split()
|
||||
return {n: _git("--git-dir", str(self.bare), "show", f"{branch}:{n}") for n in names}
|
||||
|
||||
|
||||
@needs_tools
|
||||
def test_push_fills_an_empty_wiki_with_exactly_the_output(tmp_path):
|
||||
wiki = Wiki(tmp_path)
|
||||
result = wiki.mirror({"Home.md": "home\n", "A.md": "a\n", "_Sidebar.md": "s\n"})
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert wiki.files() == {"Home.md": "home\n", "A.md": "a\n", "_Sidebar.md": "s\n"}
|
||||
assert wiki.commits() == 1
|
||||
|
||||
|
||||
@needs_tools
|
||||
def test_push_of_the_same_output_makes_no_commit(tmp_path):
|
||||
wiki = Wiki(tmp_path)
|
||||
files = {"Home.md": "home\n", "A.md": "a\n"}
|
||||
assert wiki.mirror(files).returncode == 0
|
||||
again = wiki.mirror(files)
|
||||
assert again.returncode == 0
|
||||
assert "Wiki already matches v1.0.0" in again.stdout
|
||||
assert wiki.commits() == 1
|
||||
|
||||
|
||||
@needs_tools
|
||||
def test_push_removes_a_page_the_output_no_longer_has_and_updates_the_rest(tmp_path):
|
||||
wiki = Wiki(tmp_path)
|
||||
assert wiki.mirror({"Home.md": "one\n", "Gone.md": "x\n", "Keep.md": "k\n"}).returncode == 0
|
||||
assert wiki.mirror({"Home.md": "two\n", "Keep.md": "k\n"}, version="2.0.0").returncode == 0
|
||||
assert wiki.files() == {"Home.md": "two\n", "Keep.md": "k\n"}
|
||||
assert wiki.commits() == 2
|
||||
assert "Mirror docs at v2.0.0" in _git("--git-dir", str(wiki.bare), "log", "-1", "--format=%s", "--all")
|
||||
|
||||
|
||||
@needs_tools
|
||||
def test_push_without_a_token_fails_before_touching_the_wiki(tmp_path):
|
||||
wiki = Wiki(tmp_path)
|
||||
result = wiki.mirror({"Home.md": "home\n"}, token="")
|
||||
assert result.returncode != 0
|
||||
assert "WIKI_TOKEN" in result.stderr
|
||||
assert wiki.commits() == 0
|
||||
Reference in new issue
Block a user