From bc95840d46541b9787acb8b7e4d4b5d9b50b4711 Mon Sep 17 00:00:00 2001 From: Goffi Date: Tue, 17 Mar 2026 17:55:43 +0100 Subject: [PATCH] plugin (forge): add bridge methods to handle clone counters: A counter of clones (i.e.; Git clones as Git is currently the only backend) can be incremented or retrieved. Counters are stored per day. --- libervia/backend/plugins/plugin_forge.py | 193 ++++++++++++++++++++++- 1 file changed, 191 insertions(+), 2 deletions(-) diff --git a/libervia/backend/plugins/plugin_forge.py b/libervia/backend/plugins/plugin_forge.py index 8e0cd660..eefbc9f2 100644 --- a/libervia/backend/plugins/plugin_forge.py +++ b/libervia/backend/plugins/plugin_forge.py @@ -16,9 +16,12 @@ # 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 datetime import date as dt_date +from typing import Iterator, cast -from pydantic import BaseModel, Field, RootModel +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 @@ -27,8 +30,10 @@ 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__) @@ -77,6 +82,60 @@ class DefaultRepository(BaseModel): 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({}) @@ -106,6 +165,22 @@ class Forge: 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 @@ -128,3 +203,117 @@ class Forge: 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. + """ + 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