feat: add Gitea as webhook-based service provider

First webhook-based provider integration (Immich uses polling).
Gitea pushes events via POST /api/webhooks/gitea/{provider_id} with
HMAC-SHA256 signature validation.

- 9 event types: push, issue opened/closed/commented, PR opened/closed/merged/commented, release published
- Generic filters system on NotificationTracker (collections, senders, exclude_senders)
- Provider capabilities include supported_filters and webhook_based flag
- Gitea API client for connection testing and repository listing
- 18 default Jinja2 notification templates (EN + RU)
- Frontend: conditional provider forms, Gitea event toggles in tracking config
- Auto-migration for filters column and Gitea tracking flags
This commit is contained in:
2026-03-22 12:58:35 +03:00
parent 1167d138a3
commit 6d28cfb8d8
39 changed files with 1588 additions and 25 deletions
@@ -14,12 +14,24 @@ from notify_bridge_core.providers.base import ServiceProviderType
class EventType(str, Enum):
"""Types of events a service provider can emit."""
# Immich events
ASSETS_ADDED = "assets_added"
ASSETS_REMOVED = "assets_removed"
COLLECTION_RENAMED = "collection_renamed"
COLLECTION_DELETED = "collection_deleted"
SHARING_CHANGED = "sharing_changed"
# Gitea events
PUSH = "push"
ISSUE_OPENED = "issue_opened"
ISSUE_CLOSED = "issue_closed"
ISSUE_COMMENTED = "issue_commented"
PR_OPENED = "pr_opened"
PR_CLOSED = "pr_closed"
PR_MERGED = "pr_merged"
PR_COMMENTED = "pr_commented"
RELEASE_PUBLISHED = "release_published"
@dataclass
class ServiceEvent:
@@ -15,6 +15,7 @@ class ServiceProviderType(str, Enum):
"""Supported service provider types."""
IMMICH = "immich"
GITEA = "gitea"
class ServiceProvider(ABC):
@@ -29,6 +29,12 @@ class ProviderCapabilities:
# Commands the provider supports
commands: list[dict[str, str]] = field(default_factory=list)
# Filter definitions for tracker UI (rendered dynamically by frontend)
supported_filters: list[dict[str, str]] = field(default_factory=list)
# Whether this provider receives webhooks (vs polling)
webhook_based: bool = False
# ---------------------------------------------------------------------------
# Immich provider capabilities
@@ -37,6 +43,9 @@ class ProviderCapabilities:
IMMICH_CAPABILITIES = ProviderCapabilities(
provider_type="immich",
display_name="Immich",
supported_filters=[
{"key": "collections", "label": "Albums", "type": "select", "source": "api"},
],
notification_slots=[
{"name": "message_assets_added", "description": "New assets added to album"},
{"name": "message_assets_removed", "description": "Assets removed from album"},
@@ -102,12 +111,67 @@ IMMICH_CAPABILITIES = ProviderCapabilities(
],
)
# ---------------------------------------------------------------------------
# Gitea provider capabilities
# ---------------------------------------------------------------------------
GITEA_CAPABILITIES = ProviderCapabilities(
provider_type="gitea",
display_name="Gitea",
webhook_based=True,
supported_filters=[
{
"key": "collections",
"label": "Repositories",
"type": "tags",
"placeholder": "owner/repo",
},
{
"key": "senders",
"label": "Only from users",
"type": "tags",
"placeholder": "username",
},
{
"key": "exclude_senders",
"label": "Exclude users",
"type": "tags",
"placeholder": "bot-name",
},
],
notification_slots=[
{"name": "message_push", "description": "Code pushed to repository"},
{"name": "message_issue_opened", "description": "Issue opened"},
{"name": "message_issue_closed", "description": "Issue closed"},
{"name": "message_issue_commented", "description": "Comment on issue"},
{"name": "message_pr_opened", "description": "Pull request opened"},
{"name": "message_pr_closed", "description": "Pull request closed"},
{"name": "message_pr_merged", "description": "Pull request merged"},
{"name": "message_pr_commented", "description": "Comment on pull request"},
{"name": "message_release_published", "description": "Release published"},
],
command_slots=[],
events=[
{"name": "push", "description": "Code pushed to repository"},
{"name": "issue_opened", "description": "Issue opened"},
{"name": "issue_closed", "description": "Issue closed"},
{"name": "issue_commented", "description": "Comment on issue"},
{"name": "pr_opened", "description": "Pull request opened"},
{"name": "pr_closed", "description": "Pull request closed"},
{"name": "pr_merged", "description": "Pull request merged"},
{"name": "pr_commented", "description": "Comment on pull request"},
{"name": "release_published", "description": "Release published"},
],
commands=[],
)
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
_REGISTRY: dict[str, ProviderCapabilities] = {
"immich": IMMICH_CAPABILITIES,
"gitea": GITEA_CAPABILITIES,
}
@@ -0,0 +1,19 @@
"""Gitea service provider implementation."""
from notify_bridge_core.providers.base import ServiceProviderType
from notify_bridge_core.templates.variables import registry
from .client import GiteaClient, GiteaApiError
from .event_parser import parse_webhook
from .provider import GiteaServiceProvider, GITEA_VARIABLES
# Register Gitea variables in the global registry
registry.register_provider_variables(ServiceProviderType.GITEA, GITEA_VARIABLES)
__all__ = [
"GiteaClient",
"GiteaApiError",
"GiteaServiceProvider",
"GITEA_VARIABLES",
"parse_webhook",
]
@@ -0,0 +1,89 @@
"""Async Gitea API client for connection testing and repository listing."""
from __future__ import annotations
import logging
from typing import Any
import aiohttp
_LOGGER = logging.getLogger(__name__)
class GiteaClient:
"""Async client for the Gitea REST API."""
def __init__(
self,
session: aiohttp.ClientSession,
url: str,
api_token: str,
) -> None:
self._session = session
self._url = url.rstrip("/")
self._api_token = api_token
@property
def url(self) -> str:
return self._url
@property
def _headers(self) -> dict[str, str]:
return {"Authorization": f"token {self._api_token}"}
async def ping(self) -> bool:
"""Check connectivity via GET /api/v1/version."""
try:
async with self._session.get(
f"{self._url}/api/v1/version",
headers=self._headers,
) as response:
return response.status == 200
except aiohttp.ClientError:
return False
async def get_server_version(self) -> str | None:
"""Return Gitea version string, or None on failure."""
try:
async with self._session.get(
f"{self._url}/api/v1/version",
headers=self._headers,
) as response:
if response.status == 200:
data = await response.json()
return data.get("version")
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to fetch Gitea version: %s", err)
return None
async def get_repos(self, limit: int = 50) -> list[dict[str, Any]]:
"""List repositories accessible to the authenticated user."""
repos: list[dict[str, Any]] = []
page = 1
while True:
try:
async with self._session.get(
f"{self._url}/api/v1/repos/search",
headers=self._headers,
params={"page": str(page), "limit": str(limit), "sort": "updated"},
) as response:
if response.status != 200:
_LOGGER.warning("Failed to fetch repos: HTTP %s", response.status)
break
data = await response.json()
# Gitea wraps search results in {"data": [...], "ok": true}
items = data.get("data", data) if isinstance(data, dict) else data
if not items:
break
repos.extend(items)
if len(items) < limit:
break
page += 1
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to fetch repos: %s", err)
break
return repos
class GiteaApiError(Exception):
"""Raised when a Gitea API call fails."""
@@ -0,0 +1,221 @@
"""Parse Gitea webhook payloads into ServiceEvent objects."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
from notify_bridge_core.models.events import EventType, ServiceEvent
from notify_bridge_core.providers.base import ServiceProviderType
from .models import (
GiteaComment,
GiteaCommit,
GiteaIssue,
GiteaPullRequest,
GiteaRelease,
GiteaRepository,
GiteaUser,
)
_LOGGER = logging.getLogger(__name__)
# Map Gitea X-Gitea-Event header values to our event types.
# For issues/PRs, the action field refines the mapping further.
_GITEA_EVENT_MAP: dict[str, EventType | None] = {
"push": EventType.PUSH,
"issues": None, # refined by action
"issue_comment": None, # refined by action + is_pull
"pull_request": None, # refined by action
"release": EventType.RELEASE_PUBLISHED,
}
_ISSUE_ACTION_MAP: dict[str, EventType] = {
"opened": EventType.ISSUE_OPENED,
"closed": EventType.ISSUE_CLOSED,
}
_PR_ACTION_MAP: dict[str, EventType] = {
"opened": EventType.PR_OPENED,
"closed": EventType.PR_CLOSED,
# "closed" + merged flag → PR_MERGED, handled specially
}
def parse_webhook(
event_header: str,
payload: dict[str, Any],
provider_name: str,
) -> ServiceEvent | None:
"""Parse a Gitea webhook payload into a ServiceEvent.
Args:
event_header: Value of the X-Gitea-Event header.
payload: Parsed JSON body of the webhook.
provider_name: Display name of the ServiceProvider instance.
Returns:
A ServiceEvent, or None if the event/action is not tracked.
"""
if event_header not in _GITEA_EVENT_MAP:
_LOGGER.debug("Ignoring untracked Gitea event header: %s", event_header)
return None
repo_data = payload.get("repository", {})
repo = GiteaRepository.from_payload(repo_data)
sender = GiteaUser.from_payload(payload.get("sender", {}))
action = payload.get("action", "")
event_type = _resolve_event_type(event_header, action, payload)
if event_type is None:
_LOGGER.debug(
"Ignoring Gitea event %s with action=%s (not mapped)", event_header, action
)
return None
extra = _build_extra(event_header, event_type, payload, repo, sender)
return ServiceEvent(
event_type=event_type,
provider_type=ServiceProviderType.GITEA,
provider_name=provider_name,
collection_id=repo.full_name,
collection_name=repo.full_name,
timestamp=datetime.now(timezone.utc),
extra=extra,
)
def _resolve_event_type(
event_header: str, action: str, payload: dict[str, Any]
) -> EventType | None:
"""Determine the EventType from header + action."""
direct = _GITEA_EVENT_MAP.get(event_header)
if direct is not None:
# Release: only "published" action
if event_header == "release" and action != "published":
return None
return direct
if event_header == "issues":
return _ISSUE_ACTION_MAP.get(action)
if event_header == "pull_request":
pr_data = payload.get("pull_request", {})
if action == "closed" and pr_data.get("merged", False):
return EventType.PR_MERGED
return _PR_ACTION_MAP.get(action)
if event_header == "issue_comment":
# Gitea sends issue_comment for both issue and PR comments.
is_pull = payload.get("is_pull", False)
if action == "created":
return EventType.PR_COMMENTED if is_pull else EventType.ISSUE_COMMENTED
return None
return None
def _build_extra(
event_header: str,
event_type: EventType,
payload: dict[str, Any],
repo: GiteaRepository,
sender: GiteaUser,
) -> dict[str, Any]:
"""Build the provider-specific extra dict for template rendering."""
extra: dict[str, Any] = {
"sender": sender.login,
"sender_name": sender.full_name or sender.login,
"sender_avatar": sender.avatar_url,
"repo_name": repo.name,
"repo_full_name": repo.full_name,
"repo_url": repo.html_url,
"repo_description": repo.description,
}
if event_type == EventType.PUSH:
_enrich_push(extra, payload)
elif event_type in (EventType.ISSUE_OPENED, EventType.ISSUE_CLOSED):
_enrich_issue(extra, payload)
elif event_type == EventType.ISSUE_COMMENTED:
_enrich_issue(extra, payload)
_enrich_comment(extra, payload)
elif event_type in (EventType.PR_OPENED, EventType.PR_CLOSED, EventType.PR_MERGED):
_enrich_pr(extra, payload)
elif event_type == EventType.PR_COMMENTED:
_enrich_pr(extra, payload)
_enrich_comment(extra, payload)
elif event_type == EventType.RELEASE_PUBLISHED:
_enrich_release(extra, payload)
return extra
def _enrich_push(extra: dict[str, Any], payload: dict[str, Any]) -> None:
ref = payload.get("ref", "")
extra["ref"] = ref
extra["branch"] = ref.removeprefix("refs/heads/")
extra["before"] = payload.get("before", "")
extra["after"] = payload.get("after", "")
extra["compare_url"] = payload.get("compare_url", "")
raw_commits = payload.get("commits", [])
commits = [GiteaCommit.from_payload(c) for c in raw_commits]
extra["commits"] = [
{
"id": c.id,
"short_id": c.id[:7],
"message": c.message,
"url": c.url,
"author": c.author_name,
}
for c in commits
]
extra["commit_count"] = len(commits)
def _enrich_issue(extra: dict[str, Any], payload: dict[str, Any]) -> None:
issue_data = payload.get("issue", {})
issue = GiteaIssue.from_payload(issue_data)
extra["issue_number"] = issue.number
extra["issue_title"] = issue.title
extra["issue_url"] = issue.html_url
extra["issue_state"] = issue.state
extra["issue_body"] = issue.body
extra["issue_labels"] = issue.labels
def _enrich_pr(extra: dict[str, Any], payload: dict[str, Any]) -> None:
pr_data = payload.get("pull_request", {})
pr = GiteaPullRequest.from_payload(pr_data)
extra["pr_number"] = pr.number
extra["pr_title"] = pr.title
extra["pr_url"] = pr.html_url
extra["pr_state"] = pr.state
extra["pr_body"] = pr.body
extra["pr_merged"] = pr.merged
extra["pr_base"] = pr.base_branch
extra["pr_head"] = pr.head_branch
extra["pr_labels"] = pr.labels
def _enrich_comment(extra: dict[str, Any], payload: dict[str, Any]) -> None:
comment_data = payload.get("comment", {})
comment = GiteaComment.from_payload(comment_data)
extra["comment_body"] = comment.body
extra["comment_url"] = comment.html_url
if comment.user:
extra["comment_author"] = comment.user.login
def _enrich_release(extra: dict[str, Any], payload: dict[str, Any]) -> None:
release_data = payload.get("release", {})
release = GiteaRelease.from_payload(release_data)
extra["release_tag"] = release.tag_name
extra["release_name"] = release.name
extra["release_url"] = release.html_url
extra["release_body"] = release.body
extra["release_draft"] = release.draft
extra["release_prerelease"] = release.prerelease
@@ -0,0 +1,186 @@
"""Gitea webhook payload data models."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class GiteaUser:
"""Gitea user from webhook payload."""
id: int
login: str
full_name: str = ""
email: str = ""
avatar_url: str = ""
@classmethod
def from_payload(cls, data: dict[str, Any]) -> GiteaUser:
return cls(
id=data.get("id", 0),
login=data.get("login", data.get("username", "")),
full_name=data.get("full_name", ""),
email=data.get("email", ""),
avatar_url=data.get("avatar_url", ""),
)
@dataclass
class GiteaRepository:
"""Gitea repository from webhook payload."""
id: int
name: str
full_name: str
html_url: str
description: str = ""
private: bool = False
owner: GiteaUser | None = None
@classmethod
def from_payload(cls, data: dict[str, Any]) -> GiteaRepository:
owner = None
if data.get("owner"):
owner = GiteaUser.from_payload(data["owner"])
return cls(
id=data.get("id", 0),
name=data.get("name", ""),
full_name=data.get("full_name", ""),
html_url=data.get("html_url", ""),
description=data.get("description", ""),
private=data.get("private", False),
owner=owner,
)
@dataclass
class GiteaCommit:
"""Gitea commit from push payload."""
id: str
message: str
url: str
author_name: str = ""
author_email: str = ""
timestamp: str = ""
@classmethod
def from_payload(cls, data: dict[str, Any]) -> GiteaCommit:
author = data.get("author", {})
return cls(
id=data.get("id", ""),
message=data.get("message", "").strip(),
url=data.get("url", ""),
author_name=author.get("name", ""),
author_email=author.get("email", ""),
timestamp=data.get("timestamp", ""),
)
@dataclass
class GiteaIssue:
"""Gitea issue from webhook payload."""
id: int
number: int
title: str
html_url: str
state: str = ""
body: str = ""
labels: list[str] = field(default_factory=list)
@classmethod
def from_payload(cls, data: dict[str, Any]) -> GiteaIssue:
labels = [lbl.get("name", "") for lbl in data.get("labels", []) if lbl.get("name")]
return cls(
id=data.get("id", 0),
number=data.get("number", 0),
title=data.get("title", ""),
html_url=data.get("html_url", ""),
state=data.get("state", ""),
body=data.get("body", ""),
labels=labels,
)
@dataclass
class GiteaPullRequest:
"""Gitea pull request from webhook payload."""
id: int
number: int
title: str
html_url: str
state: str = ""
body: str = ""
merged: bool = False
base_branch: str = ""
head_branch: str = ""
labels: list[str] = field(default_factory=list)
@classmethod
def from_payload(cls, data: dict[str, Any]) -> GiteaPullRequest:
labels = [lbl.get("name", "") for lbl in data.get("labels", []) if lbl.get("name")]
base = data.get("base", {})
head = data.get("head", {})
return cls(
id=data.get("id", 0),
number=data.get("number", 0),
title=data.get("title", ""),
html_url=data.get("html_url", ""),
state=data.get("state", ""),
body=data.get("body", ""),
merged=data.get("merged", False),
base_branch=base.get("label", base.get("ref", "")),
head_branch=head.get("label", head.get("ref", "")),
labels=labels,
)
@dataclass
class GiteaRelease:
"""Gitea release from webhook payload."""
id: int
tag_name: str
name: str
html_url: str
body: str = ""
draft: bool = False
prerelease: bool = False
@classmethod
def from_payload(cls, data: dict[str, Any]) -> GiteaRelease:
return cls(
id=data.get("id", 0),
tag_name=data.get("tag_name", ""),
name=data.get("name", ""),
html_url=data.get("html_url", ""),
body=data.get("body", ""),
draft=data.get("draft", False),
prerelease=data.get("prerelease", False),
)
@dataclass
class GiteaComment:
"""Gitea comment from webhook payload."""
id: int
body: str
html_url: str
user: GiteaUser | None = None
@classmethod
def from_payload(cls, data: dict[str, Any]) -> GiteaComment:
user = None
if data.get("user"):
user = GiteaUser.from_payload(data["user"])
return cls(
id=data.get("id", 0),
body=data.get("body", ""),
html_url=data.get("html_url", ""),
user=user,
)
@@ -0,0 +1,270 @@
"""Gitea service provider — webhook-based implementation of ServiceProvider."""
from __future__ import annotations
import logging
from typing import Any
import aiohttp
from notify_bridge_core.models.events import ServiceEvent
from notify_bridge_core.providers.base import ServiceProvider, ServiceProviderType
from notify_bridge_core.templates.variables import TemplateVariableDefinition
from .client import GiteaClient
_LOGGER = logging.getLogger(__name__)
# Gitea-specific template variables
GITEA_VARIABLES: list[TemplateVariableDefinition] = [
TemplateVariableDefinition(
name="sender",
type="string",
description="Username of the user who triggered the event",
example="alexei",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="sender_name",
type="string",
description="Display name of the sender",
example="Alexei",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="repo_name",
type="string",
description="Repository name (without owner)",
example="my-project",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="repo_full_name",
type="string",
description="Full repository name (owner/repo)",
example="alexei/my-project",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="repo_url",
type="string",
description="URL to the repository",
example="https://gitea.example.com/alexei/my-project",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="branch",
type="string",
description="Branch name (push events)",
example="main",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="commits",
type="list",
description="List of commits (push events); each has id, short_id, message, url, author",
example='[{"short_id": "abc1234", "message": "fix bug", ...}]',
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="commit_count",
type="int",
description="Number of commits in push",
example="3",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="compare_url",
type="string",
description="URL to compare changes (push events)",
example="https://gitea.example.com/alexei/my-project/compare/abc...def",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="issue_number",
type="int",
description="Issue number",
example="42",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="issue_title",
type="string",
description="Issue title",
example="Bug in login page",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="issue_url",
type="string",
description="URL to the issue",
example="https://gitea.example.com/alexei/my-project/issues/42",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="pr_number",
type="int",
description="Pull request number",
example="17",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="pr_title",
type="string",
description="Pull request title",
example="Add dark mode support",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="pr_url",
type="string",
description="URL to the pull request",
example="https://gitea.example.com/alexei/my-project/pulls/17",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="pr_merged",
type="bool",
description="Whether the PR was merged (vs closed without merge)",
example="true",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="pr_base",
type="string",
description="Base branch of the PR",
example="main",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="pr_head",
type="string",
description="Head branch of the PR",
example="feature/dark-mode",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="release_tag",
type="string",
description="Release tag name",
example="v1.2.0",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="release_name",
type="string",
description="Release title",
example="Version 1.2.0",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="release_url",
type="string",
description="URL to the release page",
example="https://gitea.example.com/alexei/my-project/releases/tag/v1.2.0",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="comment_body",
type="string",
description="Comment text (comment events)",
example="Looks good to me!",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="comment_url",
type="string",
description="URL to the comment",
example="https://gitea.example.com/.../issues/42#issuecomment-123",
provider_type=ServiceProviderType.GITEA,
),
]
class GiteaServiceProvider(ServiceProvider):
"""Gitea webhook-based provider.
Unlike Immich (polling), Gitea pushes events to us via webhooks.
The poll() method is a no-op — events are parsed from incoming
webhook payloads by event_parser.parse_webhook().
"""
provider_type = ServiceProviderType.GITEA
def __init__(
self,
session: aiohttp.ClientSession,
url: str,
api_token: str,
name: str = "Gitea",
) -> None:
self._client = GiteaClient(session, url, api_token)
self._name = name
@property
def client(self) -> GiteaClient:
return self._client
async def connect(self) -> bool:
return await self._client.ping()
async def disconnect(self) -> None:
pass # session lifecycle managed by caller
async def poll(
self,
collection_ids: list[str],
tracker_state: dict[str, Any],
) -> tuple[list[ServiceEvent], dict[str, Any]]:
# Gitea is webhook-based — poll() is not used.
# Events arrive via the /api/webhooks/gitea route.
return [], tracker_state
def get_available_variables(self) -> list[TemplateVariableDefinition]:
return list(GITEA_VARIABLES)
def get_provider_config_schema(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "Gitea server URL",
"example": "https://gitea.example.com",
},
"api_token": {
"type": "string",
"description": "Gitea API token (for connection testing and repo listing)",
"secret": True,
},
"webhook_secret": {
"type": "string",
"description": "Shared secret for HMAC-SHA256 webhook signature verification",
"secret": True,
},
},
"required": ["url", "webhook_secret"],
}
async def list_collections(self) -> list[dict[str, Any]]:
repos = await self._client.get_repos()
return [
{
"id": r.get("full_name", ""),
"name": r.get("full_name", ""),
"description": r.get("description", ""),
"updated_at": r.get("updated", ""),
}
for r in repos
]
async def test_connection(self) -> dict[str, Any]:
ok = await self._client.ping()
if ok:
version = await self._client.get_server_version()
return {
"ok": True,
"message": f"Connected to Gitea{f' v{version}' if version else ''}",
}
return {"ok": False, "message": "Failed to connect to Gitea"}
@@ -0,0 +1,2 @@
✅ <b>{{ sender_name }}</b> closed issue <a href="{{ issue_url }}">#{{ issue_number }}</a> in <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ issue_title }}</b>
@@ -0,0 +1,3 @@
💬 <b>{{ comment_author | default(sender_name) }}</b> commented on issue <a href="{{ issue_url }}">#{{ issue_number }}</a> in <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ issue_title }}</b>
{{ comment_body | truncate(200) }}
@@ -0,0 +1,5 @@
🐛 <b>{{ sender_name }}</b> opened issue <a href="{{ issue_url }}">#{{ issue_number }}</a> in <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ issue_title }}</b>
{%- if issue_labels %}
🏷 {{ issue_labels | join(", ") }}
{%- endif %}
@@ -0,0 +1,2 @@
❌ <b>{{ sender_name }}</b> closed PR <a href="{{ pr_url }}">#{{ pr_number }}</a> in <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ pr_title }}</b>
@@ -0,0 +1,3 @@
💬 <b>{{ comment_author | default(sender_name) }}</b> commented on PR <a href="{{ pr_url }}">#{{ pr_number }}</a> in <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ pr_title }}</b>
{{ comment_body | truncate(200) }}
@@ -0,0 +1,3 @@
🎉 <b>{{ sender_name }}</b> merged PR <a href="{{ pr_url }}">#{{ pr_number }}</a> in <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ pr_title }}</b>
{{ pr_head }} → {{ pr_base }}
@@ -0,0 +1,6 @@
🔃 <b>{{ sender_name }}</b> opened PR <a href="{{ pr_url }}">#{{ pr_number }}</a> in <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ pr_title }}</b>
{{ pr_head }} → {{ pr_base }}
{%- if pr_labels %}
🏷 {{ pr_labels | join(", ") }}
{%- endif %}
@@ -0,0 +1,9 @@
🔀 <b>{{ sender_name }}</b> pushed {{ commit_count }} commit(s) to <a href="{{ repo_url }}">{{ repo_full_name }}</a>/<b>{{ branch }}</b>
{%- if commits %}
{%- for c in commits %}
• <code>{{ c.short_id }}</code> {{ c.message | truncate(72) }}
{%- endfor %}
{%- endif %}
{%- if compare_url %}
<a href="{{ compare_url }}">View changes</a>
{%- endif %}
@@ -0,0 +1,7 @@
🚀 <b>{{ sender_name }}</b> published release <a href="{{ release_url }}">{{ release_tag }}</a> in <a href="{{ repo_url }}">{{ repo_full_name }}</a>
{%- if release_name and release_name != release_tag %}
<b>{{ release_name }}</b>
{%- endif %}
{%- if release_body %}
{{ release_body | truncate(300) }}
{%- endif %}
@@ -10,24 +10,44 @@ _LOGGER = logging.getLogger(__name__)
_DEFAULTS_DIR = Path(__file__).parent
# Mapping of template slot names to file names
SLOT_FILE_MAP: dict[str, str] = {
"message_assets_added": "assets_added.jinja2",
"message_assets_removed": "assets_removed.jinja2",
"message_collection_renamed": "collection_renamed.jinja2",
"message_collection_deleted": "collection_deleted.jinja2",
"message_sharing_changed": "sharing_changed.jinja2",
"periodic_summary_message": "periodic_summary.jinja2",
"scheduled_assets_message": "scheduled_assets.jinja2",
"memory_mode_message": "memory_mode.jinja2",
# Per-provider mapping of template slot names to file names
PROVIDER_SLOT_FILE_MAP: dict[str, dict[str, str]] = {
"immich": {
"message_assets_added": "assets_added.jinja2",
"message_assets_removed": "assets_removed.jinja2",
"message_collection_renamed": "collection_renamed.jinja2",
"message_collection_deleted": "collection_deleted.jinja2",
"message_sharing_changed": "sharing_changed.jinja2",
"periodic_summary_message": "periodic_summary.jinja2",
"scheduled_assets_message": "scheduled_assets.jinja2",
"memory_mode_message": "memory_mode.jinja2",
},
"gitea": {
"message_push": "gitea_push.jinja2",
"message_issue_opened": "gitea_issue_opened.jinja2",
"message_issue_closed": "gitea_issue_closed.jinja2",
"message_issue_commented": "gitea_issue_commented.jinja2",
"message_pr_opened": "gitea_pr_opened.jinja2",
"message_pr_closed": "gitea_pr_closed.jinja2",
"message_pr_merged": "gitea_pr_merged.jinja2",
"message_pr_commented": "gitea_pr_commented.jinja2",
"message_release_published": "gitea_release_published.jinja2",
},
}
# Backward-compatible alias
SLOT_FILE_MAP: dict[str, str] = PROVIDER_SLOT_FILE_MAP["immich"]
def load_default_templates(locale: str = "en") -> dict[str, str]:
"""Load default template strings for a locale.
def load_default_templates(
locale: str = "en",
provider_type: str = "immich",
) -> dict[str, str]:
"""Load default template strings for a locale and provider type.
Args:
locale: "en" or "ru"
provider_type: "immich" or "gitea"
Returns:
Dict mapping slot name -> template string content.
@@ -37,8 +57,9 @@ def load_default_templates(locale: str = "en") -> dict[str, str]:
_LOGGER.warning("No default templates for locale '%s'", locale)
return {}
slot_map = PROVIDER_SLOT_FILE_MAP.get(provider_type, {})
templates: dict[str, str] = {}
for slot_name, filename in SLOT_FILE_MAP.items():
for slot_name, filename in slot_map.items():
filepath = locale_dir / filename
if filepath.exists():
templates[slot_name] = filepath.read_text(encoding="utf-8").strip()
@@ -0,0 +1,2 @@
✅ <b>{{ sender_name }}</b> закрыл(а) задачу <a href="{{ issue_url }}">#{{ issue_number }}</a> в <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ issue_title }}</b>
@@ -0,0 +1,3 @@
💬 <b>{{ comment_author | default(sender_name) }}</b> прокомментировал(а) задачу <a href="{{ issue_url }}">#{{ issue_number }}</a> в <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ issue_title }}</b>
{{ comment_body | truncate(200) }}
@@ -0,0 +1,5 @@
🐛 <b>{{ sender_name }}</b> создал(а) задачу <a href="{{ issue_url }}">#{{ issue_number }}</a> в <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ issue_title }}</b>
{%- if issue_labels %}
🏷 {{ issue_labels | join(", ") }}
{%- endif %}
@@ -0,0 +1,2 @@
❌ <b>{{ sender_name }}</b> закрыл(а) PR <a href="{{ pr_url }}">#{{ pr_number }}</a> в <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ pr_title }}</b>
@@ -0,0 +1,3 @@
💬 <b>{{ comment_author | default(sender_name) }}</b> прокомментировал(а) PR <a href="{{ pr_url }}">#{{ pr_number }}</a> в <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ pr_title }}</b>
{{ comment_body | truncate(200) }}
@@ -0,0 +1,3 @@
🎉 <b>{{ sender_name }}</b> влил(а) PR <a href="{{ pr_url }}">#{{ pr_number }}</a> в <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ pr_title }}</b>
{{ pr_head }} → {{ pr_base }}
@@ -0,0 +1,6 @@
🔃 <b>{{ sender_name }}</b> создал(а) PR <a href="{{ pr_url }}">#{{ pr_number }}</a> в <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ pr_title }}</b>
{{ pr_head }} → {{ pr_base }}
{%- if pr_labels %}
🏷 {{ pr_labels | join(", ") }}
{%- endif %}
@@ -0,0 +1,9 @@
🔀 <b>{{ sender_name }}</b> отправил(а) {{ commit_count }} коммит(ов) в <a href="{{ repo_url }}">{{ repo_full_name }}</a>/<b>{{ branch }}</b>
{%- if commits %}
{%- for c in commits %}
• <code>{{ c.short_id }}</code> {{ c.message | truncate(72) }}
{%- endfor %}
{%- endif %}
{%- if compare_url %}
<a href="{{ compare_url }}">Просмотреть изменения</a>
{%- endif %}
@@ -0,0 +1,7 @@
🚀 <b>{{ sender_name }}</b> опубликовал(а) релиз <a href="{{ release_url }}">{{ release_tag }}</a> в <a href="{{ repo_url }}">{{ repo_full_name }}</a>
{%- if release_name and release_name != release_tag %}
<b>{{ release_name }}</b>
{%- endif %}
{%- if release_body %}
{{ release_body | truncate(300) }}
{%- endif %}