#!/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 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