diff --git a/.mailmap b/.mailmap index 6e5f61fb31..425d6fafa5 100644 --- a/.mailmap +++ b/.mailmap @@ -1 +1,7 @@ -Kevin Veen-Birkenbach Environment Test +Alejandro Roman Ibanez +Evangelos Tsakoudis +Evangelos Tsakoudis +Kevin Veen-Birkenbach +Kevin Veen-Birkenbach +Kevin Veen-Birkenbach Environment Test +Prageeth Panicker diff --git a/cli/build/docs/readme/generate.py b/cli/build/docs/readme/generate.py index 901f05ad66..5c4ba6ab95 100644 --- a/cli/build/docs/readme/generate.py +++ b/cli/build/docs/readme/generate.py @@ -28,7 +28,7 @@ from cli.build.docs.readme.cosmos import derive_cosmos_mermaid from cli.build.docs.readme.sections import Readme, parse_readme from utils.cache.files import PROJECT_ROOT, read_text from utils.cache.yaml import load_yaml -from utils.roles.credits import author_urls +from utils.meta.identity import by_name, profile_url from utils.roles.deploy import role_has_stack from utils.roles.entity.name import get_entity_name from utils.roles.mapping import ROLE_FILE_META_MAIN, ROLE_FILE_README @@ -97,7 +97,7 @@ def _base_context(role_dir, role_name: str, app_name: str, *, invokable: bool) - {"name": "Feature", "description": "Describe a capability."} ], "application_author": _role_author(role_dir), - "application_author_url": author_urls().get(_role_author(role_dir)), + "application_author_url": profile_url(by_name().get(_role_author(role_dir), "")), "application_is_host": not role_has_stack(role_dir), "application_needs_clearnet_resolver": _needs_clearnet_resolver(role_dir), "application_deploy_ids": ",".join( diff --git a/cli/build/docs/readme/schema.py b/cli/build/docs/readme/schema.py index 32a95dbcee..5b054ed237 100644 --- a/cli/build/docs/readme/schema.py +++ b/cli/build/docs/readme/schema.py @@ -35,8 +35,8 @@ _REQUIRED_CTX: dict = { _OPTIONAL_CTX: dict = { "application_invokable": True, + "application_author_url": "https://social.infinito.nexus/profile/example/profile", "cosmos_intro": "Example cosmos intro.", - "application_author_url": "https://www.veen.world", "application_use_cases": "Example use case.", "application_developer_notes": [{"file": "Administration.md", "description": "notes."}], "application_further_resources": [{"label": "Example", "url": "https://example.com/"}], diff --git a/compose.yml b/compose.yml index 7dbf8d11c5..d651d51fef 100644 --- a/compose.yml +++ b/compose.yml @@ -80,6 +80,9 @@ services: INFINITO_DNS_IP: ${INFINITO_DNS_IP} INFINITO_DOMAIN: ${INFINITO_DOMAIN} INFINITO_GHCR_MIRROR_PREFIX: ${INFINITO_GHCR_MIRROR_PREFIX} + INFINITO_GIT_AUTHOR_EMAIL: ${INFINITO_GIT_AUTHOR_EMAIL} + INFINITO_GIT_AUTHOR_NAME: ${INFINITO_GIT_AUTHOR_NAME} + INFINITO_GPU_COUNT: ${INFINITO_GPU_COUNT} INFINITO_IMAGE_TAG: ${INFINITO_IMAGE_TAG} INFINITO_IP4: ${INFINITO_IP4} INFINITO_IP6: ${INFINITO_IP6} diff --git a/templates/roles/README.md.j2.tmpl b/templates/roles/README.md.j2.tmpl index d74e423b90..b7e46e43d2 100644 --- a/templates/roles/README.md.j2.tmpl +++ b/templates/roles/README.md.j2.tmpl @@ -21,8 +21,9 @@ Optional context: application_invokable When true, emit the Quick Setup section (invokable roles only; forbidden otherwise). + application_author_url The author's social profile, derived from the + address .mailmap folds their commits into. cosmos_intro Sentence above the Cosmos diagram. - application_author_url Wraps the Credits author in a Markdown link. application_use_cases Paragraph for the optional Use Cases section. application_developer_notes List of {file, description} local-doc links. application_further_resources List of {label, url} external links. diff --git a/tests/external/repository/test_author_profile.py b/tests/external/repository/test_author_profile.py new file mode 100644 index 0000000000..977f4e550b --- /dev/null +++ b/tests/external/repository/test_author_profile.py @@ -0,0 +1,73 @@ +"""The profile this contributor's address names is public and answers. + +``.mailmap`` folds a commit address into an ``@infinito.nexus`` one, whose +local part is the slug of a profile on the social instance. That profile is +the single point of truth for an author's details, so it has to exist and be +readable without logging in. + +Only the address this checkout commits under is probed. Probing every declared +author would fail for people who have not created their profile yet, and a +suite that is red for someone else's omission stops being read. + +This is an external test because it performs a live HTTP request. A request +that never gets an answer is reported as unverified rather than as a missing +profile: the run could not reach the instance, so it learned nothing. The +canary below separates the two. +""" + +from __future__ import annotations + +import unittest + +import requests + +from utils.annotations.message import warning +from utils.meta.identity import DOMAIN, canonical, committing_as, profile_url + +CANARY = f"https://social.{DOMAIN}/" +TIMEOUT = 20 +READABLE = (200, 301, 302) + + +def _probe(url: str) -> tuple[int | None, str]: + try: + response = requests.get(url, timeout=TIMEOUT, allow_redirects=True) + except requests.RequestException as exc: + return None, str(exc) + return response.status_code, response.reason or "" + + +class TestAuthorProfile(unittest.TestCase): + def setUp(self) -> None: + name, email = committing_as() + if not email: + raise unittest.SkipTest("git config user.email is unset here") + _name, self.email = canonical(name, email) + self.url = profile_url(self.email) + if not self.url: + raise unittest.SkipTest( + f"{email!r} is not mapped to an @{DOMAIN} address yet; " + "tests/integration/meta/test_commit_identity.py reports that" + ) + + def test_the_profile_is_publicly_readable(self) -> None: + status, detail = _probe(CANARY) + if status is None: + raise unittest.SkipTest(f"social.{DOMAIN} is unreachable here: {detail}") + + status, detail = _probe(self.url) + if status is None: + warning(f"{self.url} was not answered ({detail}); profile unverified") + raise unittest.SkipTest("the instance answered the canary but not the probe") + + self.assertIn( + status, + READABLE, + f"{self.url} answered {status} {detail}. Your author details are read " + f"from that profile, so create it and make it public. It is derived " + f"from {self.email!r}, the address .mailmap folds your commits into.", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integration/meta/test_commit_identity.py b/tests/integration/meta/test_commit_identity.py new file mode 100644 index 0000000000..7fb977ec2a --- /dev/null +++ b/tests/integration/meta/test_commit_identity.py @@ -0,0 +1,50 @@ +"""The identity this checkout commits under resolves to an Infinito.Nexus one. + +A contributor may commit under any address, but ``.mailmap`` has to fold it +into their ``@infinito.nexus`` one, because that address is what names their +public profile and the profile is where an author's details live. + +Only the address configured here is checked. A historic address nobody commits +under any more is not this contributor's problem to fix, and failing on it +would make the suite red for everyone. +""" + +from __future__ import annotations + +import unittest + +from utils.meta.identity import DOMAIN, MAILMAP, canonical, committing_as, profile_url + + +class TestCommitIdentity(unittest.TestCase): + def setUp(self) -> None: + self.name, self.email = committing_as() + if not self.email: + raise unittest.SkipTest("git config user.email is unset here") + + def test_the_configured_address_maps_to_an_infinito_nexus_one(self) -> None: + name, email = canonical(self.name, self.email) + + self.assertTrue( + email.endswith(f"@{DOMAIN}"), + f"commits from this checkout carry {self.email!r}, which {MAILMAP} " + f"does not fold into an @{DOMAIN} address. Add a line to {MAILMAP}:\n\n" + f" {name} <{self.email}>\n\n" + f"That address names your profile at " + f"{profile_url(f'yourname@{DOMAIN}')}, which is where your author " + f"details are read from.", + ) + + def test_the_mapped_address_names_a_profile(self) -> None: + _name, email = canonical(self.name, self.email) + if not email.endswith(f"@{DOMAIN}"): + raise unittest.SkipTest("address is not mapped yet") + + self.assertTrue( + profile_url(email), + f"{email!r} yields no profile slug; the local part must not be empty", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/utils/meta/test_authors.py b/tests/unit/python/utils/meta/test_authors.py index 0c79f65d2c..8a459270e3 100644 --- a/tests/unit/python/utils/meta/test_authors.py +++ b/tests/unit/python/utils/meta/test_authors.py @@ -2,8 +2,8 @@ from __future__ import annotations import unittest -from utils.meta.authors import author_names -from utils.roles.credits import author_urls +from utils.cache.files import PROJECT_ROOT +from utils.meta.authors import author_names, role_authors from utils.software import SOFTWARE_AUTHOR @@ -17,14 +17,30 @@ class TestAuthorNames(unittest.TestCase): self.assertEqual(list(names), sorted(names)) self.assertEqual(len(names), len(set(names))) - def test_it_carries_exactly_the_keys_of_the_url_map(self) -> None: + def test_every_name_is_declared_by_at_least_one_role(self) -> None: + declared = set() + for role_dir in sorted((PROJECT_ROOT / "roles").iterdir()): + if role_dir.is_dir(): + declared.update(role_authors(role_dir)) + self.assertEqual( set(author_names()), - set(author_urls()), - "the names are the map's keys, not a second list that could drift " - "from the Credits sections they are derived from", + declared, + "the names come from galaxy_info.author, not from prose that could " + "drift from the role metadata", ) + def test_a_role_crediting_several_people_yields_each_of_them(self) -> None: + multi = [ + names + for role_dir in sorted((PROJECT_ROOT / "roles").iterdir()) + if role_dir.is_dir() and len(names := role_authors(role_dir)) > 1 + ] + + self.assertTrue(multi, "no role credits more than one author any more") + for names in multi: + self.assertTrue(all(name and not name.startswith(" ") for name in names)) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/python/utils/meta/test_identity.py b/tests/unit/python/utils/meta/test_identity.py new file mode 100644 index 0000000000..1cbc437395 --- /dev/null +++ b/tests/unit/python/utils/meta/test_identity.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from utils.meta.identity import ( + DOMAIN, + MAILMAP, + PROFILE_BASE, + by_name, + canonical, + committing_as, + profile_url, +) + +MAILMAP_BODY = """\ +# a comment line +Alejandro Roman Ibanez +Kevin Veen-Birkenbach +Kevin Veen-Birkenbach Old Name + +""" + + +class TestProfileUrl(unittest.TestCase): + def test_the_local_part_names_the_profile(self) -> None: + self.assertEqual( + profile_url(f"kevinveenbirkenbach@{DOMAIN}"), + f"{PROFILE_BASE}/kevinveenbirkenbach/profile", + ) + + def test_a_foreign_domain_names_no_profile(self) -> None: + self.assertEqual(profile_url("someone@example.com"), "") + + def test_the_domain_is_matched_case_insensitively(self) -> None: + self.assertTrue(profile_url(f"someone@{DOMAIN.upper()}")) + + def test_an_empty_local_part_names_no_profile(self) -> None: + self.assertEqual(profile_url(f"@{DOMAIN}"), "") + + +class TestMailmap(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory(prefix="infinito-identity-") + self.root = Path(self._tmp.name) + (self.root / MAILMAP).write_text(MAILMAP_BODY, encoding="utf-8") + self.addCleanup(self._tmp.cleanup) + by_name.cache_clear() + self.addCleanup(by_name.cache_clear) + + def test_an_alias_folds_into_the_canonical_address(self) -> None: + self.assertEqual( + canonical("Alejandro Roman", "romanibanez.alex@gmail.com", self.root), + ("Alejandro Roman Ibanez", "alexromanibanez@infinito.nexus"), + ) + + def test_an_alias_written_with_a_name_folds_too(self) -> None: + self.assertEqual( + canonical("Old Name", "old@example.com", self.root)[1], + "kevinveenbirkenbach@infinito.nexus", + ) + + def test_an_entry_without_a_canonical_name_keeps_the_commit_name(self) -> None: + self.assertEqual( + canonical("Prageeth Panicker", "prage.pani@gmail.com", self.root), + ("Prageeth Panicker", "prageethpanicker@infinito.nexus"), + ) + + def test_an_unknown_address_is_returned_unchanged(self) -> None: + self.assertEqual( + canonical("Nobody", "nobody@example.com", self.root), + ("Nobody", "nobody@example.com"), + ) + + def test_the_address_is_matched_case_insensitively(self) -> None: + self.assertEqual( + canonical("Kevin Veen-Birkenbach", "Kevin@Veen.World", self.root)[1], + "kevinveenbirkenbach@infinito.nexus", + ) + + def test_by_name_reads_the_canonical_column(self) -> None: + self.assertEqual( + by_name(str(self.root))["Kevin Veen-Birkenbach"], + "kevinveenbirkenbach@infinito.nexus", + ) + + def test_a_missing_mailmap_folds_nothing(self) -> None: + empty = Path(self._tmp.name) / "empty" + empty.mkdir() + + self.assertEqual( + canonical("Nobody", "nobody@example.com", empty), + ("Nobody", "nobody@example.com"), + ) + + +class TestCommittingAs(unittest.TestCase): + def test_the_carried_environment_wins_over_git(self) -> None: + carried = { + "INFINITO_GIT_AUTHOR_NAME": "Carried Name", + "INFINITO_GIT_AUTHOR_EMAIL": "carried@example.com", + } + with mock.patch.dict("os.environ", carried): + self.assertEqual(committing_as(), ("Carried Name", "carried@example.com")) + + def test_an_empty_carried_address_falls_through_to_git(self) -> None: + with mock.patch.dict("os.environ", {"INFINITO_GIT_AUTHOR_EMAIL": " "}): + with mock.patch("subprocess.run") as run: + run.return_value = mock.Mock(stdout="from-git\n") + self.assertEqual(committing_as(), ("from-git", "from-git")) + + +if __name__ == "__main__": + unittest.main() diff --git a/utils/env/handlers/__init__.py b/utils/env/handlers/__init__.py index 2dbaa55bd3..6486c719ef 100644 --- a/utils/env/handlers/__init__.py +++ b/utils/env/handlers/__init__.py @@ -20,6 +20,7 @@ from .infinito import ( distros, docker_volume, domain, + git_identity, gpu_count, image, image_repository, @@ -98,6 +99,7 @@ ORDERED_HANDLERS = [ fork_repository_url, nix_config, registry_cache_max_size, + git_identity, gpu_count, i18n_libretranslate, i18n_tuning, diff --git a/utils/env/handlers/infinito/git_identity.py b/utils/env/handlers/infinito/git_identity.py new file mode 100644 index 0000000000..a247b97b32 --- /dev/null +++ b/utils/env/handlers/infinito/git_identity.py @@ -0,0 +1,35 @@ +"""INFINITO_GIT_AUTHOR_NAME / INFINITO_GIT_AUTHOR_EMAIL: the identity this +checkout commits under. The test container mounts the checkout but not the +contributor's git configuration, so the suite cannot ask git who is about to +commit; the ``.env`` carries the answer across.""" + +from __future__ import annotations + +import subprocess +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from utils.env.builder import BuildContext, EnvBuilder + +NAME_KEY = "INFINITO_GIT_AUTHOR_NAME" +EMAIL_KEY = "INFINITO_GIT_AUTHOR_EMAIL" +NAME_COMMENT = "Name this checkout commits under; empty when git has none." +EMAIL_COMMENT = "Address this checkout commits under; empty when git has none." + + +def _configured(repo_root, key: str) -> str: + try: + result = subprocess.run( + ["git", "-C", str(repo_root), "config", "--get", key], + capture_output=True, + text=True, + check=False, + ) + except OSError: + return "" + return result.stdout.strip() + + +def apply(eb: EnvBuilder, ctx: BuildContext) -> None: + eb.set(NAME_KEY, _configured(ctx.repo_root, "user.name"), comment=NAME_COMMENT) + eb.set(EMAIL_KEY, _configured(ctx.repo_root, "user.email"), comment=EMAIL_COMMENT) diff --git a/utils/meta/authors.py b/utils/meta/authors.py index db8ac966be..201266705c 100644 --- a/utils/meta/authors.py +++ b/utils/meta/authors.py @@ -1,19 +1,45 @@ -"""The people the repository credits, as one source for every reader. +"""The people the roles name as their authors. -The names are derived from the ``## Credits`` sections themselves rather than -kept as a list, so crediting a contributor needs no code change. The derivation -lives in :mod:`utils.roles.credits`; this module is the entry point for readers -that want the names alone. +``galaxy_info.author`` is where a role records who implemented it, so that is +where the names are read. A role may credit more than one person, comma +separated. """ from __future__ import annotations from functools import lru_cache +from pathlib import Path -from utils.roles.credits import author_urls +from utils.cache.files import PROJECT_ROOT +from utils.cache.yaml import load_yaml +from utils.meta.role.names import ROLES_DIR +from utils.roles.mapping import ROLE_FILE_META_MAIN + + +def role_authors(role_dir: Path) -> tuple[str, ...]: + """Return the people ``role_dir`` names as its authors. + + Args: + role_dir: the role's directory. + """ + meta = role_dir / ROLE_FILE_META_MAIN + if not meta.is_file(): + return () + declared = ((load_yaml(str(meta)) or {}).get("galaxy_info") or {}).get("author") + if not declared: + return () + return tuple(part.strip() for part in str(declared).split(",") if part.strip()) @lru_cache(maxsize=1) -def author_names() -> tuple[str, ...]: - """Return every credited person's name, sorted.""" - return tuple(sorted(author_urls())) +def author_names(root: str = str(PROJECT_ROOT)) -> tuple[str, ...]: + """Return every declared role author, sorted and without duplicates. + + Args: + root: repository root, as a string so the cache key stays hashable. + """ + found = set() + for path in sorted(Path(root, ROLES_DIR).iterdir()): + if path.is_dir(): + found.update(role_authors(path)) + return tuple(sorted(found)) diff --git a/utils/meta/identity.py b/utils/meta/identity.py new file mode 100644 index 0000000000..03b0202c97 --- /dev/null +++ b/utils/meta/identity.py @@ -0,0 +1,129 @@ +"""Who an author is, and where their public profile lives. + +``.mailmap`` maps every address a person has committed under to their +``@infinito.nexus`` address, and that address names their profile: the local +part is the profile slug. One social profile is therefore the single point of +truth for an author, and nothing here keeps a second copy of their name, URL +or avatar. +""" + +from __future__ import annotations + +import os +import re +import subprocess +from functools import lru_cache +from pathlib import Path + +from utils.cache.files import PROJECT_ROOT, read_text + +DOMAIN = "infinito.nexus" +PROFILE_BASE = f"https://social.{DOMAIN}/profile" +MAILMAP = ".mailmap" + +_CANONICAL = re.compile(r"^(?P[^<#]+?)\s*<(?P[^>]+)>") +_ENTRY = re.compile( + r"^(?P[^<#]+?)?\s*<(?P[^>]+)>" + r"(?:\s*(?P[^<#]+?)?\s*<(?P[^>]+)>)?\s*$" +) + + +def profile_url(email: str) -> str: + """Return the public profile the address names, empty for a foreign one. + + Args: + email: an author's address. + """ + local, _, domain = email.strip().partition("@") + if domain.lower() != DOMAIN or not local: + return "" + return f"{PROFILE_BASE}/{local}/profile" + + +@lru_cache(maxsize=1) +def _aliases(root: str = str(PROJECT_ROOT)) -> dict[str, tuple[str, str]]: + """Return ``{commit address: canonical (name, address)}``. + + Exception: ``.mailmap`` is parsed here rather than handed to + ``git check-mailmap``, because the test container mounts the checkout + without the worktree's git directory and git then refuses to read it. + + Args: + root: repository root, as a string so the cache key stays hashable. + """ + path = Path(root, MAILMAP) + if not path.is_file(): + return {} + found: dict[str, tuple[str, str]] = {} + for line in read_text(str(path)).splitlines(): + match = _ENTRY.match(line.strip()) + if not match: + continue + name = (match.group("name") or "").strip() + email = match.group("email") + alias = match.group("alias_email") or email + found[alias.lower()] = (name, email) + return found + + +def canonical(name: str, email: str, root: Path | None = None) -> tuple[str, str]: + """Return the identity ``.mailmap`` folds ``name``/``email`` into. + + Args: + name: the name the commit carries. + email: the address the commit carries. + root: repository root; defaults to this checkout. + + Returns: + ``(name, email)``, unchanged when the mailmap knows the address not. + """ + mapped = _aliases(str(root or PROJECT_ROOT)).get(email.strip().lower()) + if not mapped: + return name, email + return mapped[0] or name, mapped[1] + + +@lru_cache(maxsize=1) +def by_name(root: str = str(PROJECT_ROOT)) -> dict[str, str]: + """Return ``{author name: canonical address}`` as ``.mailmap`` declares it. + + Args: + root: repository root, as a string so the cache key stays hashable. + """ + path = Path(root, MAILMAP) + if not path.is_file(): + return {} + found: dict[str, str] = {} + for line in read_text(str(path)).splitlines(): + match = _CANONICAL.match(line.strip()) + if match: + found[match.group("name")] = match.group("email") + return found + + +def committing_as(root: Path | None = None) -> tuple[str, str]: + """Return the identity a commit made here right now would carry. + + Exception: the env pair wins over git. The test container mounts the + checkout but not the contributor's git configuration, so asking git there + yields nothing; ``utils/env/handlers/infinito/git_identity.py`` records the + host's answer in the ``.env`` the container reads. + + Args: + root: repository root; defaults to this checkout. + + Returns: + ``(name, email)``; either is empty when neither source knows it. + """ + carried = ( + os.environ.get("INFINITO_GIT_AUTHOR_NAME", "").strip(), + os.environ.get("INFINITO_GIT_AUTHOR_EMAIL", "").strip(), + ) + if carried[1]: + return carried + base = ["git", "-C", str(root or PROJECT_ROOT), "config", "--get"] + values = [ + subprocess.run([*base, key], capture_output=True, text=True, check=False) + for key in ("user.name", "user.email") + ] + return tuple(value.stdout.strip() for value in values) # type: ignore[return-value] diff --git a/utils/roles/credits.py b/utils/roles/credits.py index 170509078e..0e2757a712 100644 --- a/utils/roles/credits.py +++ b/utils/roles/credits.py @@ -1,15 +1,9 @@ """Attribution recorded in markdown ``## Credits`` sections. -The link that belongs to a credited person lives nowhere but the Credits -lines themselves: ``galaxy_info.author`` carries the name without a URL, and -``galaxy_info.company`` names the project owner rather than the role's -author, so neither can supply it. This module derives the name -> URL map -from the linked mentions already present in the repository, which keeps the -README generator and the ``credits-attribution`` lint reading the same -source instead of a hand-maintained list. - -Adding a contributor therefore needs no code change: one linked credit -anywhere makes the generator emit that link for every role they author. +A role's author is declared in ``galaxy_info.author`` and rendered without a +link, so nothing here feeds the README generator any more. What remains +backs the ``credits-attribution`` lint: it reads the linked mentions already +present in prose and holds them to one link per person. Patterns exported here: @@ -28,7 +22,6 @@ Patterns exported here: from __future__ import annotations import re -from functools import lru_cache from typing import TYPE_CHECKING from utils.cache.files import iter_non_ignored_files, read_text @@ -101,18 +94,3 @@ def iter_markdown_documents() -> Iterator[tuple[str, list[str]]]: yield path, read_text(path).splitlines() except OSError: continue - - -@lru_cache(maxsize=1) -def author_urls() -> dict[str, str]: - """Name -> URL for every person credited with exactly one URL repo-wide. - - Names linked to conflicting URLs are omitted, so an ambiguous mapping - never silently picks a winner. - """ - documents = [lines for _path, lines in iter_markdown_documents()] - return { - name: next(iter(seen)) - for name, seen in collect_author_urls(documents).items() - if len(seen) == 1 - }