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.
This commit is contained in:
Goffi 2026-03-17 17:55:43 +01:00
commit bc95840d46

View file

@ -16,9 +16,12 @@
# 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 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