This commit is contained in:
@@ -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,
|
||||
RepositoryNotPublicError,
|
||||
append_fields,
|
||||
collect_metadata,
|
||||
fetch_json,
|
||||
load_registry,
|
||||
metadata_digest,
|
||||
@@ -68,6 +69,21 @@ class ProjectSourceTests(unittest.TestCase):
|
||||
with self.assertRaises(RepositoryNotPublicError):
|
||||
fetch_json("https://git.example.test/api/repo", require_public=True)
|
||||
|
||||
def test_github_anonymous_rate_limit_is_not_reported_as_private(self) -> None:
|
||||
error = HTTPError(
|
||||
"https://api.github.com/repos/example/project",
|
||||
403,
|
||||
"Forbidden",
|
||||
{"X-RateLimit-Remaining": "0"},
|
||||
None,
|
||||
)
|
||||
with patch("project_sources.urlopen", side_effect=error):
|
||||
with self.assertRaisesRegex(ProjectSourceError, "rate limit"):
|
||||
fetch_json(
|
||||
"https://api.github.com/repos/example/project",
|
||||
require_public=True,
|
||||
)
|
||||
|
||||
def test_unapproved_labels_remain_visible_without_becoming_tags(self) -> None:
|
||||
items = taxonomy_label_items(
|
||||
["TypeScript", "Unlisted Tool"], ["typescript"]
|
||||
@@ -78,6 +94,41 @@ class ProjectSourceTests(unittest.TestCase):
|
||||
self.assertEqual(normalize_tag_value("v0.4.0"), "v0-4-0")
|
||||
self.assertEqual(normalize_tag_value("OpenAI Build Week"), "openai-build-week")
|
||||
|
||||
def test_github_public_metadata_is_normalized_for_project_templates(self) -> None:
|
||||
source = {
|
||||
"project_id": "example",
|
||||
"repository": "https://github.com/example/project.git",
|
||||
"web_url": "https://github.com/example/project",
|
||||
"api_url": "https://api.github.com/repos/example/project",
|
||||
"branch": "main",
|
||||
}
|
||||
responses = (
|
||||
{
|
||||
"private": False,
|
||||
"default_branch": "main",
|
||||
"open_issues_count": 7,
|
||||
"stargazers_count": 42,
|
||||
"forks_count": 5,
|
||||
},
|
||||
{"Python": 100, "JavaScript": 25},
|
||||
{"tag_name": "v1.0.0", "html_url": "https://github.com/example/project/releases/tag/v1.0.0"},
|
||||
)
|
||||
with (
|
||||
patch("project_sources.commit_metadata", return_value={"commit": "a" * 40}),
|
||||
patch("project_sources.detect_license", return_value="MIT"),
|
||||
patch("project_sources.fetch_json", side_effect=responses),
|
||||
):
|
||||
metadata, current = collect_metadata(source, Path("unused.git"), "a" * 40, None)
|
||||
|
||||
self.assertTrue(current)
|
||||
self.assertEqual(metadata["stars"], 42)
|
||||
self.assertEqual(metadata["forks"], 5)
|
||||
self.assertEqual(metadata["languages"], ["Python", "JavaScript"])
|
||||
self.assertEqual(
|
||||
metadata["readme_url"],
|
||||
"https://github.com/example/project/blob/main/README.md",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user