plugin (forge, forge_git): forge plugin first draft:
This plugin implements the base for the forge feature in Libervia. The "forge" plugin introduces configuration to set repositories to expose with the forge (per profile), and the default repository to show. In the future, this may be done dynamically from frontends, so users can create/import repositories themselves. The "forge_git" plugin implements methods to handle Git repositories. In the future, other types of repositories may be managed (e.g.; Mercurial), but for now the focus is on Git.
This commit is contained in:
parent
c46f0716dc
commit
c297da7ec9
2 changed files with 585 additions and 0 deletions
123
libervia/backend/plugins/plugin_forge.py
Normal file
123
libervia/backend/plugins/plugin_forge.py
Normal file
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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())
|
||||
462
libervia/backend/plugins/plugin_forge_git.py
Normal file
462
libervia/backend/plugins/plugin_forge_git.py
Normal file
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
Loading…
Reference in a new issue