libervia-backend/libervia/backend/plugins/plugin_forge.py

333 lines
11 KiB
Python

#!/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 datetime import date as dt_date
from typing import Iterator, cast
from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator
from sqlalchemy import literal_column, select
from sqlalchemy.dialects.sqlite import insert
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.memory.sqla_mapping import PrivateInd
from libervia.backend.models.types import JIDType
from libervia.backend.tools.config import ConfigOptions, DirectoryPath, PluginSettings
from libervia.backend.tools.utils import aio
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 TicketsNode(ConfigOptions):
service: JIDType
node: str
class Repository(ConfigOptions):
name: str
path: DirectoryPath
tickets: TicketsNode | None = None
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 CloneCounterRequest(BaseModel):
"""Counter statitics request.
Counters are stored daily. Query can be filtered on specific date or date range. Don't
specify any date to get all counters of the repository.
"""
model_config = ConfigDict(extra="forbid")
date: dt_date | None = Field(
default=None,
description="Specific date to retrieve (YYYY-MM-DD format)",
)
start_date: dt_date | None = Field(
default=None,
description="Start date for range query (inclusive, YYYY-MM-DD format)",
)
end_date: dt_date | None = Field(
default=None,
description="End date for range query (inclusive, YYYY-MM-DD format)",
)
@field_validator("date")
@classmethod
def validate_date_exclusive(cls, v: dt_date | None, info):
"""date cannot be used together with start_date/end_date."""
if v is not None:
data = info.data
if data.get("start_date") is not None or data.get("end_date") is not None:
raise ValueError("date cannot be used together with start_date/end_date")
return v
class CloneCounter(BaseModel):
"""Clone counter statistics for a repository on a specific date."""
repository: str = Field(description="Repository name")
date: str = Field(description="Date in YYYY-MM-DD format")
count: int = Field(description="Number of clones")
class CloneCounters(RootModel):
root: list[CloneCounter]
def __iter__(self) -> Iterator[CloneCounter]: # type: ignore
return iter(self.root)
def append(self, item: CloneCounter) -> None:
self.root.append(item)
def __len__(self) -> int:
return len(self.root)
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,
)
host.bridge.add_method(
"forge_clone_counter_update",
".plugin",
in_sign="ss",
out_sign="",
method=self._update_clone_counter,
async_=True,
)
host.bridge.add_method(
"forge_clone_counter_get",
".plugin",
in_sign="ssss",
out_sign="s",
method=self._get_clone_counter,
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
) -> Repositories:
"""Retrieve repositories of a profile.
@param client: Client instance.
@param target_profile: Profile to retrieve repositories from.
@return: List of repositories.
@raise exceptions.NotFound: No repository found.
"""
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())
@aio
async def _update_clone_counter(
self, target_profile: str, repository_name: str
) -> None:
"""Update clone counter for a repository
Counters are incremented per-day. Local timezone is used to determine the day.
@param target_profile: name of the profile owning the repository
@param repository_name: name of the repository
"""
key = f"counter:{repository_name}:{dt_date.today().isoformat()}"
namespace = PLUGIN_INFO[C.PI_NAME]
async with G.storage.session() as session:
stmt = (
insert(PrivateInd)
.values(
namespace=namespace,
key=key,
profile_id=G.storage.profiles[target_profile],
value="1",
)
.on_conflict_do_update(
index_elements=[
PrivateInd.namespace,
PrivateInd.key,
PrivateInd.profile_id,
],
set_={"value": literal_column("CAST(value AS INTEGER) + 1")},
)
)
await session.execute(stmt)
await session.commit()
def _get_clone_counter(
self,
target_profile: str,
repository_name: str,
request_s: str,
profile: str,
) -> defer.Deferred[str]:
client = self.host.get_client(profile)
request = CloneCounterRequest.model_validate_json(request_s)
d = defer.ensureDeferred(
self.get_clone_counter(client, target_profile, repository_name, request)
)
d.addCallback(lambda stats_list: stats_list.model_dump_json())
d = cast(defer.Deferred[str], d)
return d
@aio
async def get_clone_counter(
self,
client: SatXMPPEntity,
target_profile: str,
repository_name: str,
request: CloneCounterRequest,
) -> CloneCounters:
"""Retrieve clone counter.
@param client: Client instance.
@param target_profile: Profile to get counters for.
@param repository_name: Name of the repository.
@param request: Which counters to retrieve.
@return: List of clone counters.
@raise exceptions.NotFound: The requested repository can't be found.
"""
repositories = self.repositories_get(client, target_profile)
try:
next(r for r in repositories if r.name == repository_name)
except StopIteration:
raise exceptions.NotFound(
f"{target_profile} has not repository named {repository_name}."
)
namespace = PLUGIN_INFO[C.PI_NAME]
profile_id = G.storage.profiles[target_profile]
key_prefix = f"counter:{repository_name}:"
# FIXME: We use PrivateInd which has text values, and we use keys to store the
# date. This is not ideal. In future, dedicated models/DB tables should probably
# be used.
stmt = (
select(PrivateInd)
.where(
PrivateInd.namespace == namespace,
PrivateInd.profile_id == profile_id,
)
.order_by(PrivateInd.key)
)
if request.date is not None:
key = f"{key_prefix}{request.date.isoformat()}"
stmt = stmt.where(PrivateInd.key == key)
else:
if request.start_date is not None:
start_key = f"{key_prefix}{request.start_date.isoformat()}"
stmt = stmt.where(PrivateInd.key >= start_key)
if request.end_date is not None:
end_key = f"{key_prefix}{request.end_date.isoformat()}"
stmt = stmt.where(PrivateInd.key <= end_key)
stmt = stmt.where(PrivateInd.key.like(f"{key_prefix}%"))
async with G.storage.session() as session:
result = await session.execute(stmt)
rows = result.scalars().all()
counters = CloneCounters([])
for row in rows:
date_str = row.key[len(key_prefix) :]
counters.append(
CloneCounter(
repository=repository_name,
date=date_str,
count=int(row.value),
)
)
return counters