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:
@@ -99,4 +99,5 @@ export const previewTargetTypeItems = (): GridItem[] => [
|
||||
|
||||
export const providerTypeItems = (): GridItem[] => [
|
||||
{ value: 'immich', icon: 'mdiCamera', label: t('providers.typeImmich') },
|
||||
{ value: 'gitea', icon: 'mdiGit', label: t('providers.typeGitea') },
|
||||
];
|
||||
|
||||
@@ -106,11 +106,18 @@
|
||||
"offline": "Offline",
|
||||
"checking": "Checking...",
|
||||
"typeImmich": "Immich",
|
||||
"typeGitea": "Gitea",
|
||||
"loadError": "Failed to load providers.",
|
||||
"externalDomain": "External Domain",
|
||||
"optional": "optional",
|
||||
"urlApiKeyRequired": "URL and API Key are required",
|
||||
"externalDomainHint": "Public-facing URL for notification links. Falls back to server URL.",
|
||||
"webhookSecret": "Webhook Secret",
|
||||
"webhookSecretKeep": "Webhook Secret (leave empty to keep current)",
|
||||
"webhookSecretHint": "Shared secret for HMAC-SHA256 signature verification. Set the same secret in Gitea webhook settings.",
|
||||
"webhookSecretRequired": "Webhook secret is required",
|
||||
"apiToken": "API Token",
|
||||
"apiTokenHint": "Optional. Needed for connection testing and repository listing.",
|
||||
"testAndSave": "Test & Save",
|
||||
"saveWithoutTest": "Save without testing"
|
||||
},
|
||||
@@ -248,6 +255,7 @@
|
||||
"matrixRoomId": "Room ID",
|
||||
"receivers": "Receivers",
|
||||
"noReceivers": "No receivers yet",
|
||||
"alreadyAdded": "already added",
|
||||
"addReceiver": "Add Receiver",
|
||||
"receiverAdded": "Receiver added",
|
||||
"receiverDeleted": "Receiver deleted",
|
||||
@@ -348,6 +356,15 @@
|
||||
"albumRenamed": "Album renamed",
|
||||
"albumDeleted": "Album deleted",
|
||||
"sharingChanged": "Sharing changed",
|
||||
"push": "Push",
|
||||
"issueOpened": "Issue opened",
|
||||
"issueClosed": "Issue closed",
|
||||
"issueCommented": "Issue commented",
|
||||
"prOpened": "PR opened",
|
||||
"prClosed": "PR closed",
|
||||
"prMerged": "PR merged",
|
||||
"prCommented": "PR commented",
|
||||
"releasePublished": "Release published",
|
||||
"trackImages": "Track images",
|
||||
"trackVideos": "Track videos",
|
||||
"favoritesOnly": "Favorites only",
|
||||
|
||||
@@ -106,11 +106,18 @@
|
||||
"offline": "Не в сети",
|
||||
"checking": "Проверка...",
|
||||
"typeImmich": "Immich",
|
||||
"typeGitea": "Gitea",
|
||||
"loadError": "Не удалось загрузить провайдеры.",
|
||||
"externalDomain": "Внешний домен",
|
||||
"optional": "необязательно",
|
||||
"urlApiKeyRequired": "URL и API ключ обязательны",
|
||||
"externalDomainHint": "Публичный URL для ссылок в уведомлениях. По умолчанию используется URL сервера.",
|
||||
"webhookSecret": "Секрет вебхука",
|
||||
"webhookSecretKeep": "Секрет вебхука (оставьте пустым для сохранения текущего)",
|
||||
"webhookSecretHint": "Общий секрет для проверки HMAC-SHA256 подписи. Укажите тот же секрет в настройках вебхука Gitea.",
|
||||
"webhookSecretRequired": "Секрет вебхука обязателен",
|
||||
"apiToken": "API токен",
|
||||
"apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.",
|
||||
"testAndSave": "Проверить и сохранить",
|
||||
"saveWithoutTest": "Сохранить без проверки"
|
||||
},
|
||||
@@ -248,6 +255,7 @@
|
||||
"matrixRoomId": "ID комнаты",
|
||||
"receivers": "Получатели",
|
||||
"noReceivers": "Нет получателей",
|
||||
"alreadyAdded": "уже добавлен",
|
||||
"addReceiver": "Добавить получателя",
|
||||
"receiverAdded": "Получатель добавлен",
|
||||
"receiverDeleted": "Получатель удалён",
|
||||
@@ -348,6 +356,15 @@
|
||||
"albumRenamed": "Альбом переименован",
|
||||
"albumDeleted": "Альбом удалён",
|
||||
"sharingChanged": "Изменение доступа",
|
||||
"push": "Push",
|
||||
"issueOpened": "Задача создана",
|
||||
"issueClosed": "Задача закрыта",
|
||||
"issueCommented": "Комментарий к задаче",
|
||||
"prOpened": "PR создан",
|
||||
"prClosed": "PR закрыт",
|
||||
"prMerged": "PR влит",
|
||||
"prCommented": "Комментарий к PR",
|
||||
"releasePublished": "Релиз опубликован",
|
||||
"trackImages": "Фото",
|
||||
"trackVideos": "Видео",
|
||||
"favoritesOnly": "Только избранные",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
let providers = $derived(providersCache.items);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let form = $state({ name: 'Immich', type: 'immich', url: '', api_key: '', external_domain: '', icon: '' });
|
||||
let form = $state({ name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '' });
|
||||
let error = $state('');
|
||||
let loadError = $state('');
|
||||
let submitting = $state(false);
|
||||
@@ -48,12 +48,16 @@
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
form = { name: 'Immich', type: 'immich', url: '', api_key: '', external_domain: '', icon: '' };
|
||||
form = { name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '' };
|
||||
editing = null; showForm = true;
|
||||
}
|
||||
function edit(p: any) {
|
||||
const cfg = p.config || {};
|
||||
form = { name: p.name, type: p.type, url: cfg.url || '', api_key: '', external_domain: cfg.external_domain || '', icon: p.icon || '' };
|
||||
form = {
|
||||
name: p.name, type: p.type, url: cfg.url || '',
|
||||
api_key: '', api_token: '', webhook_secret: '',
|
||||
external_domain: cfg.external_domain || '', icon: p.icon || '',
|
||||
};
|
||||
editing = p.id; showForm = true;
|
||||
}
|
||||
|
||||
@@ -61,12 +65,21 @@
|
||||
e.preventDefault(); error = ''; submitting = true;
|
||||
try {
|
||||
const config: any = { url: form.url };
|
||||
if (form.api_key) config.api_key = form.api_key;
|
||||
if (form.external_domain) config.external_domain = form.external_domain;
|
||||
if (form.type === 'immich') {
|
||||
if (form.api_key) config.api_key = form.api_key;
|
||||
if (form.external_domain) config.external_domain = form.external_domain;
|
||||
if (!editing) config.api_key = form.api_key;
|
||||
} else if (form.type === 'gitea') {
|
||||
if (form.api_token) config.api_token = form.api_token;
|
||||
if (form.webhook_secret) config.webhook_secret = form.webhook_secret;
|
||||
if (!editing && !form.webhook_secret) {
|
||||
error = t('providers.webhookSecretRequired');
|
||||
snackError(error); submitting = false; return;
|
||||
}
|
||||
}
|
||||
if (editing) {
|
||||
await api(`/providers/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon, config }) });
|
||||
} else {
|
||||
config.api_key = form.api_key; // required on create
|
||||
await api('/providers', { method: 'POST', body: JSON.stringify({ type: form.type, name: form.name, icon: form.icon, config }) });
|
||||
}
|
||||
showForm = false; editing = null; providersCache.invalidate(); await load();
|
||||
@@ -129,8 +142,9 @@
|
||||
</div>
|
||||
<div>
|
||||
<label for="prv-url" class="block text-sm font-medium mb-1">{t('providers.url')}</label>
|
||||
<input id="prv-url" bind:value={form.url} required placeholder={t('providers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<input id="prv-url" bind:value={form.url} required placeholder={form.type === 'gitea' ? 'https://gitea.example.com' : t('providers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{#if form.type === 'immich'}
|
||||
<div>
|
||||
<label for="prv-key" class="block text-sm font-medium mb-1">{editing ? t('providers.apiKeyKeep') : t('providers.apiKey')}</label>
|
||||
<input id="prv-key" bind:value={form.api_key} type="password" required={!editing} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
@@ -139,6 +153,18 @@
|
||||
<label for="prv-ext" class="block text-sm font-medium mb-1">{t('providers.externalDomain')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
|
||||
<input id="prv-ext" bind:value={form.external_domain} placeholder="https://photos.example.com" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{:else if form.type === 'gitea'}
|
||||
<div>
|
||||
<label for="prv-secret" class="block text-sm font-medium mb-1">{editing ? t('providers.webhookSecretKeep') : t('providers.webhookSecret')}</label>
|
||||
<input id="prv-secret" bind:value={form.webhook_secret} type="password" required={!editing} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.webhookSecretHint')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="prv-token" class="block text-sm font-medium mb-1">{t('providers.apiToken')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
|
||||
<input id="prv-token" bind:value={form.api_token} type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.apiTokenHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<button type="submit" disabled={submitting}
|
||||
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||
{submitting ? t('providers.connecting') : (editing ? t('common.save') : t('providers.addProvider'))}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
const defaultForm = () => ({
|
||||
provider_type: 'immich', name: '', icon: '',
|
||||
// Immich event tracking
|
||||
track_assets_added: true, track_assets_removed: false,
|
||||
track_collection_renamed: true, track_collection_deleted: true, track_sharing_changed: false,
|
||||
track_images: true, track_videos: true, notify_favorites_only: false,
|
||||
@@ -39,6 +40,10 @@
|
||||
scheduled_min_rating: 0, scheduled_order_by: 'random', scheduled_order: 'descending',
|
||||
memory_enabled: false, memory_source: 'albums', memory_times: '09:00', memory_collection_mode: 'combined',
|
||||
memory_limit: 10, memory_favorite_only: false, memory_asset_type: 'all', memory_min_rating: 0,
|
||||
// Gitea event tracking
|
||||
track_push: true, track_issue_opened: true, track_issue_closed: true, track_issue_commented: false,
|
||||
track_pr_opened: true, track_pr_closed: true, track_pr_merged: true, track_pr_commented: false,
|
||||
track_release_published: true,
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
|
||||
@@ -112,6 +117,19 @@
|
||||
<!-- Event tracking -->
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('trackingConfig.eventTracking')}</legend>
|
||||
{#if form.provider_type === 'gitea'}
|
||||
<div class="grid grid-cols-2 gap-2 mt-2">
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_push} /> {t('trackingConfig.push')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_issue_opened} /> {t('trackingConfig.issueOpened')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_issue_closed} /> {t('trackingConfig.issueClosed')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_issue_commented} /> {t('trackingConfig.issueCommented')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_pr_opened} /> {t('trackingConfig.prOpened')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_pr_closed} /> {t('trackingConfig.prClosed')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_pr_merged} /> {t('trackingConfig.prMerged')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_pr_commented} /> {t('trackingConfig.prCommented')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_release_published} /> {t('trackingConfig.releasePublished')}</label>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-2 mt-2">
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_assets_added} /> {t('trackingConfig.assetsAdded')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_assets_removed} /> {t('trackingConfig.assetsRemoved')}</label>
|
||||
@@ -124,6 +142,8 @@
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.include_tags} /> {t('trackingConfig.includePeople')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.include_asset_details} /> {t('trackingConfig.includeDetails')}</label>
|
||||
</div>
|
||||
{/if}
|
||||
{#if form.provider_type !== 'gitea'}
|
||||
<div class="grid grid-cols-3 gap-3 mt-3">
|
||||
<div>
|
||||
<label for="tc-max" class="block text-xs mb-1">{t('trackingConfig.maxAssets')}<Hint text={t('hints.maxAssets')} /></label>
|
||||
@@ -138,8 +158,10 @@
|
||||
<IconGridSelect items={sortOrderItems()} bind:value={form.assets_order} columns={2} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
{#if form.provider_type !== 'gitea'}
|
||||
<!-- Periodic summary -->
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('trackingConfig.periodicSummary')}<Hint text={t('hints.periodicSummary')} /></legend>
|
||||
@@ -190,6 +212,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{editing ? t('common.save') : t('common.create')}
|
||||
|
||||
@@ -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>
|
||||
+3
@@ -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 %}
|
||||
+7
@@ -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>
|
||||
+3
@@ -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 %}
|
||||
+7
@@ -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 %}
|
||||
@@ -13,7 +13,7 @@ import aiohttp
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import ServiceProvider, User
|
||||
from ..services import make_immich_provider
|
||||
from ..services import make_immich_provider, make_gitea_provider
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -81,6 +81,22 @@ async def create_provider(
|
||||
if test_result.get("external_domain"):
|
||||
config["external_domain"] = test_result["external_domain"]
|
||||
|
||||
elif body.type == "gitea":
|
||||
config = body.config
|
||||
# api_token is optional (webhook_secret is required, but token only for repo listing)
|
||||
if config.get("api_token"):
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
from notify_bridge_core.providers.gitea import GiteaServiceProvider
|
||||
gitea = GiteaServiceProvider(
|
||||
http_session, config.get("url", ""), config.get("api_token", ""), body.name,
|
||||
)
|
||||
test_result = await gitea.test_connection()
|
||||
if not test_result.get("ok"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=test_result.get("message", "Cannot connect to Gitea"),
|
||||
)
|
||||
|
||||
provider = ServiceProvider(
|
||||
user_id=user.id,
|
||||
type=body.type,
|
||||
@@ -107,6 +123,8 @@ async def list_provider_capabilities():
|
||||
"command_slots": caps.command_slots,
|
||||
"events": caps.events,
|
||||
"commands": caps.commands,
|
||||
"supported_filters": caps.supported_filters,
|
||||
"webhook_based": caps.webhook_based,
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -125,6 +143,8 @@ async def get_provider_capabilities(provider_type: str):
|
||||
"command_slots": caps.command_slots,
|
||||
"events": caps.events,
|
||||
"commands": caps.commands,
|
||||
"supported_filters": caps.supported_filters,
|
||||
"webhook_based": caps.webhook_based,
|
||||
}
|
||||
|
||||
|
||||
@@ -175,6 +195,22 @@ async def update_provider(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Connection error: {err}",
|
||||
)
|
||||
elif config_changed and provider.type == "gitea":
|
||||
if provider.config.get("api_token"):
|
||||
try:
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
gitea = make_gitea_provider(http_session, provider)
|
||||
test_result = await gitea.test_connection()
|
||||
if not test_result.get("ok"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=test_result.get("message", "Cannot connect to Gitea"),
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Connection error: {err}",
|
||||
)
|
||||
|
||||
session.add(provider)
|
||||
await session.commit()
|
||||
@@ -210,6 +246,13 @@ async def test_provider(
|
||||
immich = make_immich_provider(http_session, provider)
|
||||
return await immich.test_connection()
|
||||
|
||||
if provider.type == "gitea":
|
||||
if not provider.config.get("api_token"):
|
||||
return {"ok": True, "message": "Gitea webhook-only mode (no API token for testing)"}
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
gitea = make_gitea_provider(http_session, provider)
|
||||
return await gitea.test_connection()
|
||||
|
||||
return {"ok": False, "message": f"Unknown provider type: {provider.type}"}
|
||||
|
||||
|
||||
@@ -227,6 +270,13 @@ async def list_collections(
|
||||
immich = make_immich_provider(http_session, provider)
|
||||
return await immich.list_collections()
|
||||
|
||||
if provider.type == "gitea":
|
||||
if not provider.config.get("api_token"):
|
||||
return []
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
gitea = make_gitea_provider(http_session, provider)
|
||||
return await gitea.list_collections()
|
||||
|
||||
return []
|
||||
|
||||
|
||||
@@ -285,9 +335,10 @@ def _provider_response(p: ServiceProvider) -> dict:
|
||||
"""Build a safe response dict for a provider."""
|
||||
config = dict(p.config)
|
||||
# Mask sensitive fields
|
||||
if "api_key" in config:
|
||||
key = config["api_key"]
|
||||
config["api_key"] = f"{key[:8]}...{key[-4:]}" if len(key) > 12 else "***"
|
||||
for secret_field in ("api_key", "api_token", "webhook_secret"):
|
||||
if secret_field in config:
|
||||
key = config[secret_field]
|
||||
config[secret_field] = f"{key[:8]}...{key[-4:]}" if len(key) > 12 else "***"
|
||||
return {
|
||||
"id": p.id,
|
||||
"type": p.type,
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Incoming webhook handlers for webhook-based providers (Gitea, etc.)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from notify_bridge_core.models.events import ServiceEvent
|
||||
from notify_bridge_core.notifications.dispatcher import NotificationDispatcher, TargetConfig
|
||||
from notify_bridge_core.providers.gitea.event_parser import parse_webhook as parse_gitea_webhook
|
||||
|
||||
from ..database.engine import get_engine
|
||||
from ..database.models import (
|
||||
EmailBot,
|
||||
EventLog,
|
||||
MatrixBot,
|
||||
NotificationTarget,
|
||||
NotificationTracker,
|
||||
NotificationTrackerTarget,
|
||||
ServiceProvider,
|
||||
TargetReceiver,
|
||||
TemplateConfig,
|
||||
TemplateSlot,
|
||||
TrackingConfig,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HMAC-SHA256 validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _verify_gitea_signature(secret: str, body: bytes, signature: str) -> bool:
|
||||
"""Verify Gitea X-Gitea-Signature HMAC-SHA256."""
|
||||
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filter helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _passes_filters(
|
||||
event: ServiceEvent,
|
||||
filters: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Check if an event passes the tracker's filters."""
|
||||
# Collection filter (repo full_name for Gitea)
|
||||
collections = filters.get("collections", [])
|
||||
if collections and event.collection_id not in collections:
|
||||
return False
|
||||
|
||||
sender = event.extra.get("sender", "")
|
||||
# Sender allowlist
|
||||
senders = filters.get("senders", [])
|
||||
if senders and sender not in senders:
|
||||
return False
|
||||
|
||||
# Sender blocklist
|
||||
exclude_senders = filters.get("exclude_senders", [])
|
||||
if exclude_senders and sender in exclude_senders:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gitea webhook endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/gitea/{provider_id}")
|
||||
async def gitea_webhook(provider_id: int, request: Request):
|
||||
"""Receive a Gitea webhook, parse it, filter, and dispatch notifications."""
|
||||
engine = get_engine()
|
||||
|
||||
# --- Load provider and validate signature ---
|
||||
async with AsyncSession(engine) as session:
|
||||
provider = await session.get(ServiceProvider, provider_id)
|
||||
if not provider or provider.type != "gitea":
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
webhook_secret = (provider.config or {}).get("webhook_secret", "")
|
||||
|
||||
# Read raw body for HMAC check
|
||||
raw_body = await request.body()
|
||||
|
||||
if webhook_secret:
|
||||
signature = request.headers.get("X-Gitea-Signature", "")
|
||||
if not signature or not _verify_gitea_signature(webhook_secret, raw_body, signature):
|
||||
raise HTTPException(status_code=403, detail="Invalid signature")
|
||||
|
||||
# Parse event header + payload
|
||||
event_header = request.headers.get("X-Gitea-Event", "")
|
||||
if not event_header:
|
||||
return {"ok": True, "skipped": "no event header"}
|
||||
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON")
|
||||
|
||||
event = parse_gitea_webhook(event_header, payload, provider.name)
|
||||
if event is None:
|
||||
return {"ok": True, "skipped": "unmapped event"}
|
||||
|
||||
# --- Find trackers for this provider and dispatch ---
|
||||
dispatched = 0
|
||||
async with AsyncSession(engine) as session:
|
||||
tracker_result = await session.exec(
|
||||
select(NotificationTracker).where(
|
||||
NotificationTracker.provider_id == provider_id,
|
||||
NotificationTracker.enabled == True,
|
||||
)
|
||||
)
|
||||
trackers = tracker_result.all()
|
||||
|
||||
for tracker in trackers:
|
||||
# Apply filters
|
||||
filters = tracker.filters or {}
|
||||
if not _passes_filters(event, filters):
|
||||
_LOGGER.debug(
|
||||
"Event filtered out for tracker %d (%s)", tracker.id, tracker.name
|
||||
)
|
||||
continue
|
||||
|
||||
# Load tracker-target links
|
||||
link_data = await _load_link_data(session, tracker.id)
|
||||
if not link_data:
|
||||
continue
|
||||
|
||||
# Log event
|
||||
session.add(EventLog(
|
||||
tracker_id=tracker.id,
|
||||
tracker_name=tracker.name,
|
||||
provider_id=provider_id,
|
||||
provider_name=provider.name,
|
||||
event_type=event.event_type.value,
|
||||
collection_id=event.collection_id,
|
||||
collection_name=event.collection_name,
|
||||
assets_count=0,
|
||||
details={
|
||||
"provider_type": event.provider_type.value,
|
||||
**{k: v for k, v in event.extra.items() if k in (
|
||||
"sender", "branch", "commit_count",
|
||||
"issue_number", "issue_title",
|
||||
"pr_number", "pr_title",
|
||||
"release_tag", "release_name",
|
||||
)},
|
||||
},
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
# Dispatch to targets
|
||||
dispatcher = NotificationDispatcher()
|
||||
target_configs = _build_target_configs(event, link_data, provider.config or {})
|
||||
if target_configs:
|
||||
results = await dispatcher.dispatch(event, target_configs)
|
||||
for r in results:
|
||||
if r.get("success"):
|
||||
dispatched += 1
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Notification failed for tracker %d: %s",
|
||||
tracker.id, r.get("error", "unknown"),
|
||||
)
|
||||
|
||||
return {"ok": True, "dispatched": dispatched}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared dispatch helpers (extracted from watcher pattern)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _load_link_data(
|
||||
session: AsyncSession,
|
||||
tracker_id: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Load tracker-target link data for dispatch (same pattern as watcher)."""
|
||||
tt_result = await session.exec(
|
||||
select(NotificationTrackerTarget).where(
|
||||
NotificationTrackerTarget.tracker_id == tracker_id
|
||||
)
|
||||
)
|
||||
tracker_targets = tt_result.all()
|
||||
|
||||
link_data: list[dict[str, Any]] = []
|
||||
for tt in tracker_targets:
|
||||
if not tt.enabled:
|
||||
continue
|
||||
|
||||
target = await session.get(NotificationTarget, tt.target_id)
|
||||
if not target:
|
||||
continue
|
||||
|
||||
# Load receivers
|
||||
recv_result = await session.exec(
|
||||
select(TargetReceiver).where(
|
||||
TargetReceiver.target_id == target.id,
|
||||
TargetReceiver.enabled == True,
|
||||
)
|
||||
)
|
||||
receivers = [dict(r.config) for r in recv_result.all()]
|
||||
|
||||
tracking_config = None
|
||||
if tt.tracking_config_id:
|
||||
tracking_config = await session.get(TrackingConfig, tt.tracking_config_id)
|
||||
|
||||
template_config = None
|
||||
template_slots: dict[str, str] | None = None
|
||||
if tt.template_config_id:
|
||||
template_config = await session.get(TemplateConfig, tt.template_config_id)
|
||||
if template_config:
|
||||
slot_result = await session.exec(
|
||||
select(TemplateSlot).where(TemplateSlot.config_id == template_config.id)
|
||||
)
|
||||
raw_slots = {s.slot_name: s.template for s in slot_result.all()}
|
||||
template_slots = {}
|
||||
for slot_name, tmpl_text in raw_slots.items():
|
||||
event_key = slot_name.removeprefix("message_") if slot_name.startswith("message_") else slot_name
|
||||
template_slots[event_key] = tmpl_text
|
||||
|
||||
target_config = dict(target.config)
|
||||
# Inject bot credentials
|
||||
if target.type == "email":
|
||||
email_bot_id = target.config.get("email_bot_id")
|
||||
if email_bot_id:
|
||||
email_bot = await session.get(EmailBot, email_bot_id)
|
||||
if email_bot:
|
||||
target_config["smtp"] = {
|
||||
"host": email_bot.smtp_host,
|
||||
"port": email_bot.smtp_port,
|
||||
"username": email_bot.smtp_username,
|
||||
"password": email_bot.smtp_password,
|
||||
"from_address": email_bot.email,
|
||||
"from_name": email_bot.name,
|
||||
"use_tls": email_bot.smtp_use_tls,
|
||||
}
|
||||
elif target.type == "matrix":
|
||||
matrix_bot_id = target.config.get("matrix_bot_id")
|
||||
if matrix_bot_id:
|
||||
matrix_bot = await session.get(MatrixBot, matrix_bot_id)
|
||||
if matrix_bot:
|
||||
target_config["homeserver_url"] = matrix_bot.homeserver_url
|
||||
target_config["access_token"] = matrix_bot.access_token
|
||||
|
||||
link_data.append({
|
||||
"target_type": target.type,
|
||||
"target_config": target_config,
|
||||
"receivers": receivers,
|
||||
"tracking_config": tracking_config,
|
||||
"template_config": template_config,
|
||||
"template_slots": template_slots,
|
||||
})
|
||||
|
||||
return link_data
|
||||
|
||||
|
||||
def _event_allowed_by_tracking_config(event: ServiceEvent, tc: TrackingConfig) -> bool:
|
||||
"""Check if an event type is allowed by tracking config flags."""
|
||||
event_type = event.event_type.value
|
||||
flag_map = {
|
||||
"push": tc.track_push,
|
||||
"issue_opened": tc.track_issue_opened,
|
||||
"issue_closed": tc.track_issue_closed,
|
||||
"issue_commented": tc.track_issue_commented,
|
||||
"pr_opened": tc.track_pr_opened,
|
||||
"pr_closed": tc.track_pr_closed,
|
||||
"pr_merged": tc.track_pr_merged,
|
||||
"pr_commented": tc.track_pr_commented,
|
||||
"release_published": tc.track_release_published,
|
||||
# Immich events
|
||||
"assets_added": tc.track_assets_added,
|
||||
"assets_removed": tc.track_assets_removed,
|
||||
"collection_renamed": tc.track_collection_renamed,
|
||||
"collection_deleted": tc.track_collection_deleted,
|
||||
"sharing_changed": tc.track_sharing_changed,
|
||||
}
|
||||
return flag_map.get(event_type, True)
|
||||
|
||||
|
||||
def _build_target_configs(
|
||||
event: ServiceEvent,
|
||||
link_data: list[dict[str, Any]],
|
||||
provider_config: dict[str, Any],
|
||||
) -> list[TargetConfig]:
|
||||
"""Build TargetConfig objects for dispatch, applying tracking config filters."""
|
||||
target_configs: list[TargetConfig] = []
|
||||
for ld in link_data:
|
||||
tc = ld["tracking_config"]
|
||||
if tc and not _event_allowed_by_tracking_config(event, tc):
|
||||
continue
|
||||
|
||||
tmpl = ld["template_config"]
|
||||
target_configs.append(TargetConfig(
|
||||
type=ld["target_type"],
|
||||
config=ld["target_config"],
|
||||
template_slots=ld["template_slots"],
|
||||
date_format=tmpl.date_format if tmpl else "%d.%m.%Y, %H:%M UTC",
|
||||
date_only_format=tmpl.date_only_format if tmpl and tmpl.date_only_format else "%d.%m.%Y",
|
||||
provider_api_key=provider_config.get("api_token"),
|
||||
provider_internal_url=provider_config.get("url", ""),
|
||||
provider_external_url=provider_config.get("url", ""),
|
||||
receivers=ld["receivers"],
|
||||
))
|
||||
return target_configs
|
||||
@@ -130,6 +130,34 @@ async def migrate_schema(engine: AsyncEngine) -> None:
|
||||
)
|
||||
logger.info("Added memory_source column to tracking_config table")
|
||||
|
||||
# Add filters JSON column to notification_tracker if missing
|
||||
if await _has_table(conn, tracker_table):
|
||||
if not await _has_column(conn, tracker_table, "filters"):
|
||||
await conn.execute(
|
||||
text(f"ALTER TABLE {tracker_table} ADD COLUMN filters TEXT DEFAULT '{{}}'")
|
||||
)
|
||||
logger.info("Added filters column to %s table", tracker_table)
|
||||
|
||||
# Add Gitea tracking flags to tracking_config if missing
|
||||
if await _has_table(conn, "tracking_config"):
|
||||
gitea_flags = [
|
||||
("track_push", "INTEGER DEFAULT 1"),
|
||||
("track_issue_opened", "INTEGER DEFAULT 1"),
|
||||
("track_issue_closed", "INTEGER DEFAULT 1"),
|
||||
("track_issue_commented", "INTEGER DEFAULT 0"),
|
||||
("track_pr_opened", "INTEGER DEFAULT 1"),
|
||||
("track_pr_closed", "INTEGER DEFAULT 1"),
|
||||
("track_pr_merged", "INTEGER DEFAULT 1"),
|
||||
("track_pr_commented", "INTEGER DEFAULT 0"),
|
||||
("track_release_published", "INTEGER DEFAULT 1"),
|
||||
]
|
||||
for col_name, col_type in gitea_flags:
|
||||
if not await _has_column(conn, "tracking_config", col_name):
|
||||
await conn.execute(
|
||||
text(f"ALTER TABLE tracking_config ADD COLUMN {col_name} {col_type}")
|
||||
)
|
||||
logger.info("Added %s column to tracking_config table", col_name)
|
||||
|
||||
# Add collection_name and shared to tracker_state if missing
|
||||
state_table = "notification_tracker_state" if await _has_table(conn, "notification_tracker_state") else "tracker_state"
|
||||
if await _has_table(conn, state_table):
|
||||
|
||||
@@ -115,6 +115,19 @@ class TrackingConfig(SQLModel, table=True):
|
||||
track_collection_renamed: bool = Field(default=True)
|
||||
track_collection_deleted: bool = Field(default=True)
|
||||
track_sharing_changed: bool = Field(default=False)
|
||||
|
||||
# Gitea event tracking
|
||||
track_push: bool = Field(default=True)
|
||||
track_issue_opened: bool = Field(default=True)
|
||||
track_issue_closed: bool = Field(default=True)
|
||||
track_issue_commented: bool = Field(default=False)
|
||||
track_pr_opened: bool = Field(default=True)
|
||||
track_pr_closed: bool = Field(default=True)
|
||||
track_pr_merged: bool = Field(default=True)
|
||||
track_pr_commented: bool = Field(default=False)
|
||||
track_release_published: bool = Field(default=True)
|
||||
|
||||
# Immich asset display
|
||||
track_images: bool = Field(default=True)
|
||||
track_videos: bool = Field(default=True)
|
||||
notify_favorites_only: bool = Field(default=False)
|
||||
@@ -247,6 +260,7 @@ class NotificationTracker(SQLModel, table=True):
|
||||
name: str
|
||||
icon: str = Field(default="")
|
||||
collection_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON))
|
||||
filters: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
||||
scan_interval: int = Field(default=60)
|
||||
batch_duration: int = Field(default=0) # seconds to accumulate events before dispatch (0=immediate)
|
||||
enabled: bool = Field(default=True)
|
||||
|
||||
@@ -32,6 +32,7 @@ from .api.command_configs import router as command_configs_router
|
||||
from .api.command_trackers import router as command_trackers_router
|
||||
from .api.command_template_configs import router as command_template_configs_router
|
||||
from .commands.webhook import router as webhook_router, set_webhook_secret
|
||||
from .api.webhooks import router as webhooks_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -88,6 +89,7 @@ app.include_router(command_configs_router)
|
||||
app.include_router(command_trackers_router)
|
||||
app.include_router(command_template_configs_router)
|
||||
app.include_router(webhook_router)
|
||||
app.include_router(webhooks_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
@@ -120,7 +122,7 @@ async def _seed_default_templates():
|
||||
}
|
||||
|
||||
for locale in ("en", "ru"):
|
||||
slots = load_default_templates(locale)
|
||||
slots = load_default_templates(locale, provider_type="immich")
|
||||
if not slots:
|
||||
continue
|
||||
|
||||
@@ -193,6 +195,86 @@ async def _seed_default_templates():
|
||||
template=template_text,
|
||||
))
|
||||
|
||||
# --- Seed Gitea default templates ---
|
||||
gitea_result = await session.exec(
|
||||
select(TemplateConfig).where(
|
||||
TemplateConfig.user_id == 0,
|
||||
TemplateConfig.provider_type == "gitea",
|
||||
)
|
||||
)
|
||||
gitea_configs = gitea_result.all()
|
||||
gitea_existing_locales = {
|
||||
(c.locale if c.locale else "en"): c for c in gitea_configs
|
||||
}
|
||||
|
||||
for locale in ("en", "ru"):
|
||||
gitea_slots = load_default_templates(locale, provider_type="gitea")
|
||||
if not gitea_slots:
|
||||
continue
|
||||
|
||||
if locale not in gitea_existing_locales:
|
||||
from datetime import datetime as _dt, timezone as _tz
|
||||
now = _dt.now(_tz.utc).isoformat()
|
||||
name = f"Default Gitea ({locale.upper()})"
|
||||
desc = f"Default Gitea templates ({locale.upper()})"
|
||||
col_info = (await session.execute(
|
||||
text("PRAGMA table_info(template_config)")
|
||||
)).fetchall()
|
||||
col_names = [c[1] for c in col_info if c[1] != "id"]
|
||||
values = {}
|
||||
for col in col_names:
|
||||
if col == "user_id":
|
||||
values[col] = 0
|
||||
elif col == "provider_type":
|
||||
values[col] = "gitea"
|
||||
elif col == "name":
|
||||
values[col] = name
|
||||
elif col == "description":
|
||||
values[col] = desc
|
||||
elif col == "created_at":
|
||||
values[col] = now
|
||||
elif col == "date_format":
|
||||
values[col] = "%d.%m.%Y, %H:%M UTC"
|
||||
elif col == "date_only_format":
|
||||
values[col] = "%d.%m.%Y"
|
||||
elif col == "locale":
|
||||
values[col] = locale
|
||||
else:
|
||||
values[col] = ""
|
||||
cols_str = ", ".join(values.keys())
|
||||
placeholders = ", ".join(f":{k}" for k in values.keys())
|
||||
await session.execute(
|
||||
text(f"INSERT INTO template_config ({cols_str}) VALUES ({placeholders})"),
|
||||
values,
|
||||
)
|
||||
row = (await session.execute(text("SELECT last_insert_rowid()"))).scalar()
|
||||
gitea_config_id = row
|
||||
for slot_name, template_text in gitea_slots.items():
|
||||
session.add(TemplateSlot(
|
||||
config_id=gitea_config_id,
|
||||
slot_name=slot_name,
|
||||
template=template_text,
|
||||
))
|
||||
else:
|
||||
config = gitea_existing_locales[locale]
|
||||
for slot_name, template_text in gitea_slots.items():
|
||||
slot_result = await session.exec(
|
||||
select(TemplateSlot).where(
|
||||
TemplateSlot.config_id == config.id,
|
||||
TemplateSlot.slot_name == slot_name,
|
||||
)
|
||||
)
|
||||
existing = slot_result.first()
|
||||
if existing:
|
||||
existing.template = template_text
|
||||
session.add(existing)
|
||||
else:
|
||||
session.add(TemplateSlot(
|
||||
config_id=config.id,
|
||||
slot_name=slot_name,
|
||||
template=template_text,
|
||||
))
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Shared service utilities."""
|
||||
|
||||
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
||||
from notify_bridge_core.providers.gitea import GiteaServiceProvider
|
||||
|
||||
from ..database.models import ServiceProvider
|
||||
|
||||
@@ -15,3 +16,14 @@ def make_immich_provider(http_session, provider: ServiceProvider) -> ImmichServi
|
||||
config.get("external_domain"),
|
||||
provider.name,
|
||||
)
|
||||
|
||||
|
||||
def make_gitea_provider(http_session, provider: ServiceProvider) -> GiteaServiceProvider:
|
||||
"""Create a GiteaServiceProvider from a DB provider model."""
|
||||
config = provider.config or {}
|
||||
return GiteaServiceProvider(
|
||||
http_session,
|
||||
config.get("url", ""),
|
||||
config.get("api_token", ""),
|
||||
provider.name,
|
||||
)
|
||||
|
||||
@@ -78,11 +78,22 @@ def _event_allowed_by_config(event: ServiceEvent, tc: TrackingConfig) -> bool:
|
||||
"""Check if an event type is allowed by the tracking config's flags."""
|
||||
event_type = event.event_type.value
|
||||
flag_map = {
|
||||
# Immich events
|
||||
"assets_added": tc.track_assets_added,
|
||||
"assets_removed": tc.track_assets_removed,
|
||||
"collection_renamed": tc.track_collection_renamed,
|
||||
"collection_deleted": tc.track_collection_deleted,
|
||||
"sharing_changed": tc.track_sharing_changed,
|
||||
# Gitea events
|
||||
"push": tc.track_push,
|
||||
"issue_opened": tc.track_issue_opened,
|
||||
"issue_closed": tc.track_issue_closed,
|
||||
"issue_commented": tc.track_issue_commented,
|
||||
"pr_opened": tc.track_pr_opened,
|
||||
"pr_closed": tc.track_pr_closed,
|
||||
"pr_merged": tc.track_pr_merged,
|
||||
"pr_commented": tc.track_pr_commented,
|
||||
"release_published": tc.track_release_published,
|
||||
}
|
||||
return flag_map.get(event_type, True)
|
||||
|
||||
@@ -220,6 +231,10 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
return {"status": "error", "reason": "failed to connect to provider"}
|
||||
|
||||
events, new_state = await immich.poll(collection_ids, state_dict)
|
||||
elif provider_type == "gitea":
|
||||
# Gitea is webhook-based — events arrive via /api/webhooks/gitea endpoint.
|
||||
# The scheduler still calls check_tracker but there's nothing to poll.
|
||||
return {"status": "ok", "events_detected": 0, "collections_checked": 0}
|
||||
else:
|
||||
return {"status": "error", "reason": f"unsupported provider type: {provider_type}"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user