333 lines
11 KiB
Python
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
|