diff --git a/libervia/backend/plugins/plugin_forge.py b/libervia/backend/plugins/plugin_forge.py new file mode 100644 index 00000000..6c3428ae --- /dev/null +++ b/libervia/backend/plugins/plugin_forge.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 + +# Libervia plugin to handle forges. +# Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from typing import Iterator + +from pydantic import BaseModel, Field, RootModel +from twisted.internet import defer + +from libervia.backend import G +from libervia.backend.core import exceptions +from libervia.backend.core.constants import Const as C +from libervia.backend.core.core_types import SatXMPPEntity +from libervia.backend.core.i18n import _ +from libervia.backend.core.log import getLogger +from libervia.backend.tools.config import DirectoryPath, PluginSettings + +log = getLogger(__name__) + + +PLUGIN_INFO = { + C.PI_NAME: "Forge", + C.PI_IMPORT_NAME: "FORGE", + C.PI_TYPE: C.PLUG_TYPE_MISC, + C.PI_DEPENDENCIES: [], + C.PI_MAIN: "Forge", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Forge for developping projects."""), +} + + +class Repository(BaseModel): + name: str + path: DirectoryPath + + +class Repositories(RootModel): + root: list[Repository] + + def __iter__(self) -> Iterator[Repository]: # type: ignore + return iter(self.root) + + def __len__(self) -> int: + return len(self.root) + + +class ProfileRepositories(RootModel): + root: dict[str, Repositories] + + def __getitem__(self, item: str) -> Repositories: + return self.root[item] + + +class DefaultRepository(BaseModel): + profile: str + repository: str + + +class PluginForgeSettings(PluginSettings, plugin_info=PLUGIN_INFO): + repositories: ProfileRepositories = Field( + default_factory=lambda: ProfileRepositories({}) + ) + default: DefaultRepository | None = None + + +class Forge: + def __init__(self, host): + log.info(f"Plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization.") + self.settings = G.config.get_settings(PluginForgeSettings) + self.host = host + + host.bridge.add_method( + "forge_repositories_get", + ".plugin", + in_sign="sss", + out_sign="s", + method=self._repositories_get, + async_=True, + ) + host.bridge.add_method( + "forge_default_repository_get", + ".plugin", + in_sign="", + out_sign="s", + method=self._default_repository_get, + async_=True, + ) + + def _repositories_get( + self, target_profile: str, data_s: str, profile: str + ) -> defer.Deferred[str]: + client = self.host.get_client(profile) + repositories = self.repositories_get(client, target_profile) + return defer.succeed(repositories.model_dump_json()) + + def repositories_get( + self, client: SatXMPPEntity, target_profile: str, data: dict | None = None + ) -> Repositories: + try: + return self.settings.repositories[target_profile] + except KeyError: + raise exceptions.NotFound(f"No repository found for {target_profile!r}.") + + def _default_repository_get(self) -> defer.Deferred[str]: + default = self.settings.default + if default is None: + raise exceptions.NotFound("No default repository set.") + else: + return defer.succeed(default.model_dump_json()) diff --git a/libervia/backend/plugins/plugin_forge_git.py b/libervia/backend/plugins/plugin_forge_git.py new file mode 100644 index 00000000..adc8d2c7 --- /dev/null +++ b/libervia/backend/plugins/plugin_forge_git.py @@ -0,0 +1,462 @@ +#!/usr/bin/env python3 + +# Libervia plugin to handle Git repositories +# Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from pathlib import Path +from typing import Iterator, Literal, TYPE_CHECKING, cast + +from pydantic import BaseModel, ConfigDict, Field, RootModel +from twisted.internet import defer +from twisted.python.procutils import which + +from libervia.backend.core import exceptions +from libervia.backend.core.constants import Const as C +from libervia.backend.core.core_types import SatXMPPEntity +from libervia.backend.core.i18n import _ +from libervia.backend.core.log import getLogger +from libervia.backend.plugins.plugin_forge import Forge +from libervia.backend.tools import utils +from libervia.backend.tools.common import async_process + +if TYPE_CHECKING: + from libervia.backend.core.main import LiberviaBackend + +log = getLogger(__name__) + +# Git formatting, cf.: https://git-scm.com/docs/git-log#_pretty_formats +GIT_LOG_SEPARATOR = "%x00" +GIT_LOG_FORMAT = GIT_LOG_SEPARATOR.join( + [ + # Full commit hash + "%H", + # Short commit hash + "%h", + # Author name + "%an", + # Author email + "%ae", + # Committer date in UNIX timestamp + "%ct", + # Subject (first line of commit message) + "%s", + # Body (rest of commit message) + "%b", + ] +) +GIT_LOG_FORMAT += "%x1E" + + +class CommitRequest(BaseModel): + """Model to specify the commits to retrieve.""" + + model_config = ConfigDict(extra="forbid") + + after: str | None = Field( + default=None, description="Get commits after this reference." + ) + before: str | None = Field( + default=None, description="Get commits before this reference." + ) + limit: int = Field(default=20, description="Maximum number of commits to retrieve.") + + +class Commit(BaseModel): + """Information about a Git commit for history display""" + + id: str = Field(description="Commit hash (SHA)") + short_id: str = Field(description="Short commit hash (7 characters)") + author_name: str = Field(description="Name of the author") + author_email: str = Field(description="Email of the author") + commit_ts: int = Field(description="UNIX timestamp of the commit") + message_title: str = Field(description="First line of the commit message") + message: str = Field(description="Commit message") + + +class Commits(RootModel): + root: list[Commit] + + def __iter__(self) -> Iterator[Commit]: # type: ignore + return iter(self.root) + + def __getitem__(self, item) -> Commit: + return self.root[item] + + def __len__(self) -> int: + return len(self.root) + + def append(self, item: Commit) -> None: + self.root.append(item) + + def sort(self, key=None, reverse=False) -> None: + self.root.sort(key=key, reverse=reverse) # type: ignore + + +class File(BaseModel): + """Metadata for a file or directory in a Git repository""" + + name: str + type: Literal["file", "directory"] + hash: str + mode: int + size: int | None = None + + +class Files(RootModel): + root: list[File] + + def __iter__(self) -> Iterator[File]: # type: ignore + return iter(self.root) + + def __getitem__(self, item) -> str: + return self.root[item] + + def __len__(self) -> int: + return len(self.root) + + def append(self, item: File) -> None: + self.root.append(item) + + def sort(self, key=None, reverse=False) -> None: + self.root.sort(key=key, reverse=reverse) # type: ignore + + +class InfoRequest(BaseModel): + """Model to indicate which infos a requested in an info_get request.""" + + model_config = ConfigDict(extra="forbid") + + branches: bool = True + last_commit: bool = False + + +class InfoResult(BaseModel): + + branches: list[str] = Field(default_factory=list) + last_commit: Commit | None = None + + +PLUGIN_INFO = { + C.PI_NAME: "Git Repository Handler", + C.PI_IMPORT_NAME: "GIT_REPOS", + C.PI_TYPE: C.PLUG_TYPE_MISC, + C.PI_DEPENDENCIES: ["FORGE"], + C.PI_MAIN: "GitHandler", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Handle Git Repositories"""), +} + + +class GitProtocol(async_process.CommandProtocol): + """handle git commands""" + + name = "Git" + command = None + + @classmethod + def run(cls, path: Path, command: str, *args, **kwargs) -> defer.Deferred[bytes]: + """Create a new MercurialRegisterProtocol and execute the given mercurial command. + + @param path: path to the repository + @param command: git command to run + @return: stdout of the command + """ + assert "path" not in kwargs + kwargs["path"] = path + d = super().run(command, *args, **kwargs) + d.addErrback(utils.logError) + return d + + +class GitHandler: + def __init__(self, host: "LiberviaBackend") -> None: + log.info(f"Plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization.") + try: + GitProtocol.command = which("git")[0] + except IndexError: + raise exceptions.NotFound( + _("Git executable not found, can't use Git handler.") + ) + self.host = host + self._f = cast(Forge, host.plugins["FORGE"]) + host.bridge.add_method( + "git_files_get", + ".plugin", + in_sign="sssss", + out_sign="s", + method=self._files_get, + async_=True, + ) + host.bridge.add_method( + "git_infos_get", + ".plugin", + in_sign="ssss", + out_sign="s", + method=self._infos_get, + async_=True, + ) + host.bridge.add_method( + "git_file_content_get", + ".plugin", + in_sign="sssss", + out_sign="s", + method=self._git_file_content_get, + async_=True, + ) + host.bridge.add_method( + "git_history_get", + ".plugin", + in_sign="ssss", + out_sign="s", + method=self._git_history_get, + async_=True, + ) + + def get_path_from_repository(self, target_profile: str, repo: str) -> Path: + try: + profile_repositories = self._f.settings.repositories[target_profile] + repo_data = next( + r for r in profile_repositories if r.name.lower() == repo.lower() + ) + except (KeyError, StopIteration): + raise exceptions.NotFound(f"Repository Unknown: {repo!r}") + else: + return repo_data.path + + def _files_get( + self, + target_profile: str, + repository: str, + branch: str, + path: str, + profile_key: str, + ) -> defer.Deferred[str]: + client = self.host.get_client(profile_key) + repository_path = self.get_path_from_repository(target_profile, repository) + d = defer.ensureDeferred( + self.files_get( + client, + repository_path, + branch, + path, + ) + ) + d.addCallback(lambda m: m.model_dump_json()) + return d + + async def files_get( + self, + client: SatXMPPEntity, + repository: Path, + branch: str = "main", + path: str = "", + ) -> Files: + """List files from a repository branch and path + + @param client: Client instance. + @param repository: Repository to list files from. + @param branch: Branch of the repository to use. + @param path: Path to list. + If not set, list root files. + @return: files listing. + """ + output = await GitProtocol.run(repository, "ls-tree", "-l", f"{branch}:{path}") + files = Files([]) + for line in output.splitlines(): + mode, type_, hash_, size, name = line.split(maxsplit=4) + size_int = int(size) if size != b"-" else None + file = File( + name=name.decode(), + type="file" if type_ == b"blob" else "directory", + hash=hash_.decode(), + mode=int(mode), + size=size_int, + ) + files.append(file) + + files.sort(key=lambda f: (f.type != "directory", f.name.lower())) + + return files + + def _infos_get( + self, target_profile: str, repository: str, info_request_s: str, profile_key: str + ) -> defer.Deferred[str]: + client = self.host.get_client(profile_key) + repository_path = self.get_path_from_repository(target_profile, repository) + info_request = InfoRequest.model_validate_json(info_request_s) + d = defer.ensureDeferred(self.infos_get(client, repository_path, info_request)) + d.addCallback(lambda info_result: info_result.model_dump_json()) + d = cast(defer.Deferred[str], d) + return d + + async def infos_get( + self, client: SatXMPPEntity, repository: Path, info_request: InfoRequest + ) -> InfoResult: + """Get information about a Git repository. + + @param client: Client instance. + @param repository: Path to the repository. + @param info_request: Which information to retrieve. + @return: Informations about the repository. + """ + branches = [] + last_commit = None + if info_request.branches: + try: + output = await GitProtocol.run(repository, "branch") + output_str = output.decode() + lines = output_str.splitlines() + branches = [line.strip().lstrip("* ").strip() for line in lines] + except Exception as e: + log.warning(f"Could not retrieve branches for {repository}: {e}") + if info_request.last_commit: + try: + output = await GitProtocol.run( + repository, "log", "-1", f"--format={GIT_LOG_FORMAT}" + ) + commits = self._parse_git_log_output(output) + if commits: + last_commit = commits[0] + except Exception as e: + log.warning(f"Could not retrieve last commit data for {repository}: {e}") + return InfoResult(branches=branches, last_commit=last_commit) + + def _git_file_content_get( + self, + target_profile: str, + repository: str, + branch: str, + file_path: str, + profile_key: str, + ) -> defer.Deferred[str]: + client = self.host.get_client(profile_key) + repository_path = self.get_path_from_repository(target_profile, repository) + d = defer.ensureDeferred( + self.git_file_content_get(client, repository_path, branch, file_path) + ) + d.addCallback(lambda content: content) + return d + + async def git_file_content_get( + self, + client: SatXMPPEntity, + repository: Path, + branch: str, + file_path: str, + ) -> str: + """Get the content of a file from a Git repository. + + @param client: Client instance. + @param repository: Path to the repository. + @param branch: Branch of the repository to use. + @param file_path: Path to the file within the repository. + @return: Content of the file as a string. + @raise exceptions.NotFound: If the file does not exist. + """ + output = await GitProtocol.run(repository, "show", f"{branch}:{file_path}") + return output.decode() + + def _git_history_get( + self, + target_profile: str, + repository: str, + commit_request_s: str, + profile_key: str, + ) -> defer.Deferred[str]: + client = self.host.get_client(profile_key) + repository_path = self.get_path_from_repository(target_profile, repository) + commit_request = CommitRequest.model_validate_json(commit_request_s) + d = defer.ensureDeferred( + self.git_history_get(client, repository_path, commit_request) + ) + d.addCallback(lambda commits: commits.model_dump_json()) + d = cast(defer.Deferred[str], d) + return d + + @staticmethod + def _parse_git_log_output(output: bytes) -> Commits: + """Parse git log output into Commit objects. + + @param output: Raw output from git log command. + @return: Commits object containing parsed commit data. + """ + output_str = output.decode().strip() + if not output_str: + return Commits([]) + + commits = Commits([]) + commit_blocks = output_str.split("\x1e") + + for block in commit_blocks: + commit_data = block.strip().split("\x00") + if len(commit_data) != 7: + log.warning(f"Error while parsing commit, ignoring:\n{block!r}") + continue + else: + commit = Commit( + id=commit_data[0], + short_id=commit_data[1], + author_name=commit_data[2], + author_email=commit_data[3], + commit_ts=int(commit_data[4]), + message_title=commit_data[5], + message=commit_data[6], + ) + commits.append(commit) + + return commits + + async def git_history_get( + self, client: SatXMPPEntity, repository: Path, commit_request: CommitRequest + ) -> Commits: + """Get the commit history of a repository. + + @param client: Client instance. + @param repository: Path to the repository. + @param commit_request: Requested commit and limit. + @return: List of commits in reverse chronological order. + """ + extra = [] + match (commit_request.after, commit_request.before): + case (after, before) if after is not None and before is not None: + extra.append(f"{after}..{before}^") + case (_, before) if before is not None: + extra.append(f"{before}^") + case (after, _) if after is not None: + # Git can't retrieve immediate children, so we first need to get commits + # IDs until the limit. + commit_ids = ( + (await GitProtocol.run(repository, "rev-list", f"{after}..")) + .decode() + .split() + ) + if not commit_ids: + return Commits([]) + elif len(commit_ids) >= commit_request.limit: + upper_limit = commit_ids[-commit_request.limit] + else: + upper_limit = commit_ids[0] + + extra.append(f"{after}..{upper_limit}") + + output = await GitProtocol.run( + repository, + "log", + f"-{commit_request.limit}", + f"--format={GIT_LOG_FORMAT}", + *extra, + ) + return self._parse_git_log_output(output)