diff --git a/.gitignore b/.gitignore index 8434635..0bb5bb6 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,5 @@ backend/coverage.* backend/.coverage frontend/coverage backend/app/services/openclaw/.device-keys +FUTURE.md +FUTURE.md diff --git a/FUTURE.md b/FUTURE.md index 51ec6c0..27b8513 100644 --- a/FUTURE.md +++ b/FUTURE.md @@ -1,26 +1,27 @@ # Pipeline — Up Next ## Phase 1: Backend — Forgejo API Integration -- [ ] Forgejo API client service (`backend/app/services/forgejo/`) -- [ ] DB models: `ForgejoRepo`, `ForgejoIssue`, `ForgejoLabel` -- [ ] Alembic migration for new models -- [ ] API endpoints: `/api/v1/forgejo/repos`, `/api/v1/forgejo/issues` -- [ ] Config: `FORGEJO_URL`, `FORGEJO_TOKEN`, `FORGEJO_REPOS` env vars -- [ ] On-demand sync endpoint: `POST /api/v1/forgejo/sync` -- [ ] Scheduled sync (background task or cron) +- [x] Forgejo API client service (`backend/app/services/forgejo_client.py`) +- [x] DB models: `ForgejoConnection`, `ForgejoRepository`, `ForgejoIssue` (labels cached on issues) +- [x] Alembic migration for new models +- [x] API endpoints: `/api/v1/forgejo/repositories`, `/api/v1/forgejo/issues` +- [x] Config via Forgejo connection/repository CRUD +- [x] On-demand repository sync endpoint: `POST /api/v1/forgejo/repositories/{repository_id}/sync` +- [x] Scheduled sync (background task or cron) ## Phase 2: Frontend — Issues Panel -- [ ] New "Issues" sidebar link and route -- [ ] Issue list page with repo/label/priority filters +- [x] New "Issues" sidebar link and route (`/git-projects/issues`) +- [x] Issue list page with repo/state/search filters +- [ ] Label/priority filters - [ ] Issue detail page - [ ] "Create Task from Issue" button ## Phase 3: Task Integration - [ ] Link tasks to Forgejo issues (foreign key on task) - [ ] Task detail view shows linked issue info -- [ ] Dashboard issue count widgets +- [x] Dashboard issue count widgets ## Phase 4: Polish -- [ ] Forgejo webhook for real-time updates -- [ ] Issue status sync (open/closed) -- [ ] Auto-archive closed issues \ No newline at end of file +- [x] Forgejo webhook for cached issue updates +- [x] Issue status sync (open/closed) +- [ ] Auto-archive closed issues diff --git a/backend/.env.example b/backend/.env.example index 422ad9c..355afc4 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -31,4 +31,8 @@ RQ_REDIS_URL=redis://localhost:6379/0 RQ_QUEUE_NAME=default RQ_DISPATCH_THROTTLE_SECONDS=15.0 RQ_DISPATCH_MAX_RETRIES=3 +# Forgejo issue cache scheduled sync +FORGEJO_SYNC_ENABLED=true +FORGEJO_SYNC_INTERVAL_SECONDS=3600 +FORGEJO_SYNC_STARTUP_DELAY_SECONDS=60 GATEWAY_MIN_VERSION=2026.02.9 diff --git a/backend/README.md b/backend/README.md index 099153b..267b8cb 100644 --- a/backend/README.md +++ b/backend/README.md @@ -65,6 +65,15 @@ A starter file exists at `backend/.env.example`. - If `true`: on startup, the backend attempts to run Alembic migrations (`alembic upgrade head`). - If there are **no** Alembic revision files yet, it falls back to `SQLModel.metadata.create_all`. +### Forgejo scheduled sync + +- `FORGEJO_SYNC_ENABLED` (default: `true`) + - When enabled, the API seeds a periodic Forgejo issue-cache sync task into the RQ queue on startup. +- `FORGEJO_SYNC_INTERVAL_SECONDS` (default: `3600`) + - How often the worker syncs all active Forgejo repositories. +- `FORGEJO_SYNC_STARTUP_DELAY_SECONDS` (default: `60`) + - Initial delay before the first scheduled sync after API startup. + ### Security headers Security response headers added to every API response. Set any variable to blank to disable the corresponding header. diff --git a/backend/app/api/forgejo_connections.py b/backend/app/api/forgejo_connections.py index b43345c..5dd131d 100644 --- a/backend/app/api/forgejo_connections.py +++ b/backend/app/api/forgejo_connections.py @@ -172,6 +172,56 @@ async def delete_connection( return OkResponse() +@router.get( + "/{connection_id}/repos", + summary="List Available Repositories", + description="Return repositories the connection token can access on the Forgejo instance.", +) +async def list_connection_repos( + connection_id: UUID, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, +) -> list[dict[str, object]]: + """List repositories accessible via this connection's token.""" + connection = await crud.get_by_id(session, ForgejoConnection, connection_id) + if connection is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if connection.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if not connection.token: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="Connection has no token configured.", + ) + + from app.services.forgejo_client import ForgejoClientError, get_forgejo_client + + try: + async with get_forgejo_client(connection) as client: + repos = await client.list_user_repos() + except ForgejoClientError as e: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Failed to fetch repositories: {e}", + ) + + return [ + { + "full_name": r.get("full_name", ""), + "name": r.get("name", ""), + "owner": (r.get("owner") or {}).get("login", ""), + "default_branch": r.get("default_branch", "main") or "main", + "description": r.get("description") or None, + "private": bool(r.get("private", False)), + "html_url": r.get("html_url", ""), + } + for r in repos + if r.get("full_name") and r.get("name") + ] + + @router.post( "/{connection_id}/validate", response_model=ForgejoConnectionValidationResponse, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index a5b7f1f..184dfe8 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -81,6 +81,11 @@ class Settings(BaseSettings): rq_dispatch_retry_base_seconds: float = 10.0 rq_dispatch_retry_max_seconds: float = 120.0 + # Forgejo issue cache scheduled sync. + forgejo_sync_enabled: bool = True + forgejo_sync_interval_seconds: float = 60 * 60 + forgejo_sync_startup_delay_seconds: float = 60.0 + # OpenClaw gateway runtime compatibility gateway_min_version: str = "2026.02.9" diff --git a/backend/app/main.py b/backend/app/main.py index f8cd2fd..90e8db7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -46,6 +46,7 @@ from app.core.rate_limit_backend import RateLimitBackend from app.core.security_headers import SecurityHeadersMiddleware from app.db.session import init_db from app.schemas.health import HealthStatusResponse +from app.services.forgejo_sync_queue import seed_scheduled_forgejo_sync if TYPE_CHECKING: from collections.abc import AsyncIterator @@ -468,6 +469,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: logger.info("app.lifecycle.rate_limit backend=redis") else: logger.info("app.lifecycle.rate_limit backend=memory") + seed_scheduled_forgejo_sync() logger.info("app.lifecycle.started") try: yield diff --git a/backend/app/services/forgejo_client.py b/backend/app/services/forgejo_client.py index c0be5bb..9862ad9 100644 --- a/backend/app/services/forgejo_client.py +++ b/backend/app/services/forgejo_client.py @@ -157,6 +157,30 @@ class ForgejoAPIClient: response.raise_for_status() return response.json() + async def list_user_repos( + self, + limit: int = 50, + page: int = 1, + ) -> list[dict[str, object]]: + """ + List repositories accessible to the authenticated token. + + Uses /api/v1/repos/search which returns repos the token can access, + including private repos and org repos. + + Returns: + List of repository dicts with name, full_name, owner, default_branch, etc. + """ + client = await self._get_client() + params = {"limit": limit, "page": page, "token": ""} + response = await client.get("/api/v1/repos/search", params={"limit": limit, "page": page}) + response.raise_for_status() + data = response.json() + # Forgejo /repos/search returns {"data": [...], "ok": true} + if isinstance(data, dict): + return list(data.get("data", [])) + return list(data) + def get_forgejo_client( connection: object, diff --git a/backend/app/services/forgejo_sync_queue.py b/backend/app/services/forgejo_sync_queue.py new file mode 100644 index 0000000..e631a68 --- /dev/null +++ b/backend/app/services/forgejo_sync_queue.py @@ -0,0 +1,147 @@ +"""Queue task helpers for scheduled Forgejo issue cache sync.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.core.config import settings +from app.core.logging import get_logger +from app.core.time import utcnow +from app.db import crud +from app.db.session import async_session_maker +from app.models.forgejo_repositories import ForgejoRepository +from app.services.forgejo_issue_sync import IssueSyncService +from app.services.queue import QueuedTask, enqueue_task_with_delay +from app.services.queue import requeue_if_failed as generic_requeue_if_failed + +logger = get_logger(__name__) +TASK_TYPE = "forgejo_issue_sync_all" +_SINGLETON_CREATED_AT = datetime(1970, 1, 1, tzinfo=UTC) + + +def _task() -> QueuedTask: + return QueuedTask( + task_type=TASK_TYPE, + payload={"scope": "active_repositories"}, + created_at=_SINGLETON_CREATED_AT, + ) + + +def _sync_enabled() -> bool: + return settings.forgejo_sync_enabled and settings.forgejo_sync_interval_seconds > 0 + + +def enqueue_forgejo_issue_sync(*, delay_seconds: float) -> bool: + """Schedule the singleton Forgejo issue sync task.""" + if not _sync_enabled(): + logger.info("forgejo.sync.queue.disabled") + return False + delay = max(0.0, float(delay_seconds)) + ok = enqueue_task_with_delay( + _task(), + settings.rq_queue_name, + delay_seconds=delay, + redis_url=settings.rq_redis_url, + ) + if ok: + logger.info( + "forgejo.sync.queue.enqueued", + extra={"delay_seconds": delay}, + ) + return ok + + +def seed_scheduled_forgejo_sync() -> bool: + """Seed periodic Forgejo sync when the API process starts.""" + return enqueue_forgejo_issue_sync( + delay_seconds=settings.forgejo_sync_startup_delay_seconds, + ) + + +def schedule_next_forgejo_issue_sync() -> bool: + """Schedule the next periodic Forgejo issue sync run.""" + return enqueue_forgejo_issue_sync( + delay_seconds=settings.forgejo_sync_interval_seconds, + ) + + +async def sync_active_forgejo_repositories(session: AsyncSession) -> dict[str, int]: + """Sync all active Forgejo repositories and return aggregate counts.""" + repositories = ( + await session.exec( + select(ForgejoRepository) + .where(ForgejoRepository.active.is_(True)) + .order_by(ForgejoRepository.created_at.asc()) + ) + ).all() + totals = { + "repositories": len(repositories), + "succeeded": 0, + "failed": 0, + "created": 0, + "updated": 0, + "open": 0, + "closed": 0, + "total": 0, + } + for repository in repositories: + try: + result = await IssueSyncService( + session=session, + organization_id=repository.organization_id, + ).sync_repository_issues(repository_id=repository.id) + except Exception as exc: + repository.last_sync_error = str(exc) + repository.updated_at = utcnow() + await crud.save(session, repository) + totals["failed"] += 1 + logger.warning( + "forgejo.sync.repository_failed", + extra={ + "repository_id": str(repository.id), + "owner": repository.owner, + "repo": repository.repo, + "error": str(exc), + }, + ) + continue + + totals["succeeded"] += 1 + for key in ("created", "updated", "open", "closed", "total"): + totals[key] += int(result.get(key, 0)) + return totals + + +async def process_forgejo_sync_queue_task(task: QueuedTask) -> None: + """Worker entrypoint for scheduled Forgejo issue sync.""" + if task.task_type not in {TASK_TYPE, "legacy"}: + raise ValueError(f"Unexpected task_type={task.task_type!r}; expected {TASK_TYPE!r}") + if not _sync_enabled(): + logger.info("forgejo.sync.skipped_disabled") + return + + try: + async with async_session_maker() as session: + totals = await sync_active_forgejo_repositories(session) + logger.info( + "forgejo.sync.completed", + extra={f"sync_{key}": value for key, value in totals.items()}, + ) + except Exception as exc: + logger.exception("forgejo.sync.failed", extra={"error": str(exc)}) + finally: + schedule_next_forgejo_issue_sync() + + +def requeue_forgejo_sync_queue_task(task: QueuedTask, *, delay_seconds: float = 0) -> bool: + """Requeue a failed Forgejo sync task with capped retries.""" + return generic_requeue_if_failed( + task, + settings.rq_queue_name, + max_retries=settings.rq_dispatch_max_retries, + redis_url=settings.rq_redis_url, + delay_seconds=max(0.0, delay_seconds), + ) diff --git a/backend/app/services/queue_worker.py b/backend/app/services/queue_worker.py index 61eb033..c529cbf 100644 --- a/backend/app/services/queue_worker.py +++ b/backend/app/services/queue_worker.py @@ -9,6 +9,11 @@ from dataclasses import dataclass from app.core.config import settings from app.core.logging import get_logger +from app.services.forgejo_sync_queue import TASK_TYPE as FORGEJO_SYNC_TASK_TYPE +from app.services.forgejo_sync_queue import ( + process_forgejo_sync_queue_task, + requeue_forgejo_sync_queue_task, +) from app.services.openclaw.lifecycle_queue import TASK_TYPE as LIFECYCLE_RECONCILE_TASK_TYPE from app.services.openclaw.lifecycle_queue import ( requeue_lifecycle_queue_task, @@ -49,6 +54,17 @@ _TASK_HANDLERS: dict[str, _TaskHandler] = { ), requeue=lambda task, delay: requeue_webhook_queue_task(task, delay_seconds=delay), ), + FORGEJO_SYNC_TASK_TYPE: _TaskHandler( + handler=process_forgejo_sync_queue_task, + attempts_to_delay=lambda attempts: min( + settings.rq_dispatch_retry_base_seconds * (2 ** max(0, attempts)), + settings.rq_dispatch_retry_max_seconds, + ), + requeue=lambda task, delay: requeue_forgejo_sync_queue_task( + task, + delay_seconds=delay, + ), + ), } diff --git a/backend/tests/test_forgejo_client.py b/backend/tests/test_forgejo_client.py index 1a0fe88..f9dfe14 100644 --- a/backend/tests/test_forgejo_client.py +++ b/backend/tests/test_forgejo_client.py @@ -39,14 +39,9 @@ def test_get_forgejo_client_factory() -> None: base_url = "https://forgejo.example.com" token = "ghp_testtoken123" - import asyncio - - async def test(): - client = await get_forgejo_client(MockConnection()) - assert client.base_url == "https://forgejo.example.com" - assert client.token == "ghp_testtoken123" - - asyncio.run(test()) + client = get_forgejo_client(MockConnection()) + assert client.base_url == "https://forgejo.example.com" + assert client.token == "ghp_testtoken123" def test_get_forgejo_client_with_api_path() -> None: @@ -54,10 +49,5 @@ def test_get_forgejo_client_with_api_path() -> None: class MockConnection: base_url = "https://forgejo.example.com/api/v1" - import asyncio - - async def test(): - client = await get_forgejo_client(MockConnection()) - assert client.base_url == "https://forgejo.example.com" - - asyncio.run(test()) + client = get_forgejo_client(MockConnection()) + assert client.base_url == "https://forgejo.example.com" diff --git a/backend/tests/test_forgejo_sync_queue.py b/backend/tests/test_forgejo_sync_queue.py new file mode 100644 index 0000000..54fdf23 --- /dev/null +++ b/backend/tests/test_forgejo_sync_queue.py @@ -0,0 +1,230 @@ +# ruff: noqa: INP001 +"""Scheduled Forgejo issue sync queue tests.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine +from sqlmodel import SQLModel, col, select +from sqlmodel.ext.asyncio.session import AsyncSession + +from app import models as _models +from app.models.forgejo_connections import ForgejoConnection +from app.models.forgejo_repositories import ForgejoRepository +from app.models.organizations import Organization +from app.services import forgejo_sync_queue +from app.services.forgejo_sync_queue import ( + enqueue_forgejo_issue_sync, + process_forgejo_sync_queue_task, + sync_active_forgejo_repositories, +) +from app.services.queue import QueuedTask + +_MODEL_REGISTRY = _models + + +async def _make_engine() -> AsyncEngine: + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + return engine + + +async def _seed_repositories( + session: AsyncSession, +) -> tuple[ForgejoRepository, ForgejoRepository, ForgejoRepository]: + organization_id = uuid4() + connection_id = uuid4() + session.add(Organization(id=organization_id, name=f"org-{organization_id}")) + session.add( + ForgejoConnection( + id=connection_id, + organization_id=organization_id, + name="Forgejo", + base_url="https://forgejo.example.local", + ) + ) + active_repo = ForgejoRepository( + id=uuid4(), + organization_id=organization_id, + connection_id=connection_id, + owner="openclaw", + repo="pipeline", + active=True, + ) + failing_repo = ForgejoRepository( + id=uuid4(), + organization_id=organization_id, + connection_id=connection_id, + owner="openclaw", + repo="broken", + active=True, + ) + inactive_repo = ForgejoRepository( + id=uuid4(), + organization_id=organization_id, + connection_id=connection_id, + owner="openclaw", + repo="inactive", + active=False, + ) + session.add(active_repo) + session.add(failing_repo) + session.add(inactive_repo) + await session.commit() + return active_repo, failing_repo, inactive_repo + + +def test_enqueue_forgejo_issue_sync_uses_singleton_delayed_task( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + def _fake_enqueue_with_delay( + task: QueuedTask, + queue_name: str, + *, + delay_seconds: float, + redis_url: str | None = None, + ) -> bool: + captured["task"] = task + captured["queue_name"] = queue_name + captured["delay_seconds"] = delay_seconds + captured["redis_url"] = redis_url + return True + + monkeypatch.setattr(forgejo_sync_queue.settings, "forgejo_sync_enabled", True) + monkeypatch.setattr(forgejo_sync_queue.settings, "forgejo_sync_interval_seconds", 3600) + monkeypatch.setattr( + "app.services.forgejo_sync_queue.enqueue_task_with_delay", + _fake_enqueue_with_delay, + ) + + assert enqueue_forgejo_issue_sync(delay_seconds=30) is True + task = captured["task"] + assert isinstance(task, QueuedTask) + assert task.task_type == "forgejo_issue_sync_all" + assert task.payload == {"scope": "active_repositories"} + assert task.created_at.isoformat() == "1970-01-01T00:00:00+00:00" + assert captured["delay_seconds"] == 30 + + +def test_enqueue_forgejo_issue_sync_respects_disabled_setting( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(forgejo_sync_queue.settings, "forgejo_sync_enabled", False) + monkeypatch.setattr( + "app.services.forgejo_sync_queue.enqueue_task_with_delay", + lambda *args, **kwargs: pytest.fail("disabled sync should not enqueue"), + ) + + assert enqueue_forgejo_issue_sync(delay_seconds=30) is False + + +@pytest.mark.asyncio +async def test_sync_active_forgejo_repositories_syncs_active_and_records_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[str] = [] + + class _FakeIssueSyncService: + def __init__(self, *, session: AsyncSession, organization_id: object) -> None: + del session, organization_id + + async def sync_repository_issues(self, *, repository_id: object) -> dict[str, int]: + calls.append(str(repository_id)) + if repository_id == failing_repo.id: + raise RuntimeError("Forgejo unavailable") + return {"created": 2, "updated": 3, "open": 4, "closed": 1, "total": 5} + + engine = await _make_engine() + session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + try: + async with session_maker() as session: + active_repo, failing_repo, inactive_repo = await _seed_repositories(session) + monkeypatch.setattr( + "app.services.forgejo_sync_queue.IssueSyncService", + _FakeIssueSyncService, + ) + + totals = await sync_active_forgejo_repositories(session) + + assert totals == { + "repositories": 2, + "succeeded": 1, + "failed": 1, + "created": 2, + "updated": 3, + "open": 4, + "closed": 1, + "total": 5, + } + assert calls == [str(active_repo.id), str(failing_repo.id)] + + async with session_maker() as session: + stored_failing_repo = ( + await session.exec( + select(ForgejoRepository).where(col(ForgejoRepository.id) == failing_repo.id) + ) + ).one() + stored_inactive_repo = ( + await session.exec( + select(ForgejoRepository).where(col(ForgejoRepository.id) == inactive_repo.id) + ) + ).one() + assert stored_failing_repo.last_sync_error == "Forgejo unavailable" + assert stored_inactive_repo.last_sync_error is None + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_process_forgejo_sync_queue_task_reschedules_after_run( + monkeypatch: pytest.MonkeyPatch, +) -> None: + called: list[str] = [] + + async def _fake_sync_active_repositories(session: AsyncSession) -> dict[str, int]: + del session + called.append("sync") + return { + "repositories": 0, + "succeeded": 0, + "failed": 0, + "created": 0, + "updated": 0, + "open": 0, + "closed": 0, + "total": 0, + } + + class _FakeSessionMaker: + async def __aenter__(self) -> AsyncSession: + return None # type: ignore[return-value] + + async def __aexit__(self, *args: object) -> None: + return None + + monkeypatch.setattr(forgejo_sync_queue.settings, "forgejo_sync_enabled", True) + monkeypatch.setattr(forgejo_sync_queue.settings, "forgejo_sync_interval_seconds", 12) + monkeypatch.setattr("app.services.forgejo_sync_queue.async_session_maker", _FakeSessionMaker) + monkeypatch.setattr( + "app.services.forgejo_sync_queue.sync_active_forgejo_repositories", + _fake_sync_active_repositories, + ) + monkeypatch.setattr( + "app.services.forgejo_sync_queue.schedule_next_forgejo_issue_sync", + lambda: called.append("reschedule") or True, + ) + + await process_forgejo_sync_queue_task( + QueuedTask( + task_type="forgejo_issue_sync_all", + payload={"scope": "active_repositories"}, + created_at=forgejo_sync_queue._SINGLETON_CREATED_AT, + ) + ) + + assert called == ["sync", "reschedule"] diff --git a/compose.yml b/compose.yml index 42a6336..a458c7f 100644 --- a/compose.yml +++ b/compose.yml @@ -92,6 +92,9 @@ services: RQ_QUEUE_NAME: ${RQ_QUEUE_NAME:-default} RQ_DISPATCH_THROTTLE_SECONDS: ${RQ_DISPATCH_THROTTLE_SECONDS:-2.0} RQ_DISPATCH_MAX_RETRIES: ${RQ_DISPATCH_MAX_RETRIES:-3} + FORGEJO_SYNC_ENABLED: ${FORGEJO_SYNC_ENABLED:-true} + FORGEJO_SYNC_INTERVAL_SECONDS: ${FORGEJO_SYNC_INTERVAL_SECONDS:-3600} + FORGEJO_SYNC_STARTUP_DELAY_SECONDS: ${FORGEJO_SYNC_STARTUP_DELAY_SECONDS:-60} restart: unless-stopped volumes: diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index eb2ffba..6e957e8 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -42,6 +42,21 @@ See `.env.example` for defaults and required values. - **Purpose:** Comma-separated list of trusted reverse-proxy IPs or CIDRs used to honor `Forwarded` / `X-Forwarded-For` client IP headers. - **Gotcha:** Leave this blank unless the direct peer is a proxy you control. +### `FORGEJO_SYNC_ENABLED` + +- **Default:** `true` +- **Purpose:** Enables the scheduled RQ task that syncs all active Forgejo repository issue caches. + +### `FORGEJO_SYNC_INTERVAL_SECONDS` + +- **Default:** `3600` +- **Purpose:** Interval between scheduled Forgejo issue-cache sync runs. + +### `FORGEJO_SYNC_STARTUP_DELAY_SECONDS` + +- **Default:** `60` +- **Purpose:** Delay before the first scheduled Forgejo sync task is seeded after API startup. + ## Security response headers These environment variables control security headers added to every API response. Set any variable to blank (`""`) to disable the corresponding header. diff --git a/frontend/src/app/git-projects/repositories/[repositoryId]/edit/page.tsx b/frontend/src/app/git-projects/repositories/[repositoryId]/edit/page.tsx index 3823852..3c492a8 100644 --- a/frontend/src/app/git-projects/repositories/[repositoryId]/edit/page.tsx +++ b/frontend/src/app/git-projects/repositories/[repositoryId]/edit/page.tsx @@ -130,6 +130,7 @@ export default function ForgejoRepositoriesEditPage({ repo: repository.repo, display_name: repository.display_name, default_branch: repository.default_branch, + showWebhookSecret: true, }; return ( diff --git a/frontend/src/app/settings/git-projects/page.tsx b/frontend/src/app/settings/git-projects/page.tsx new file mode 100644 index 0000000..a7dc285 --- /dev/null +++ b/frontend/src/app/settings/git-projects/page.tsx @@ -0,0 +1,688 @@ +"use client"; + +export const dynamic = "force-dynamic"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; +import Link from "next/link"; +import { + AlertCircle, + CheckCircle2, + CircleDot, + Clock, + Copy, + ExternalLink, + GitBranch, + KeyRound, + Link2, + RefreshCw, + Server, + Webhook, +} from "lucide-react"; + +import { useAuth } from "@/auth/clerk"; +import { ForgejoConnectionsTable } from "@/components/git/ForgejoConnectionsTable"; +import { ForgejoRepositoriesTable } from "@/components/git/ForgejoRepositoriesTable"; +import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; +import { Button } from "@/components/ui/button"; +import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog"; +import { getApiBaseUrl } from "@/lib/api-base"; +import { + deleteForgejoConnection, + deleteForgejoRepository, + getForgejoConnections, + getForgejoRepositories, + syncRepository, + validateConnection, + validateRepository, + type ForgejoConnection, + type ForgejoRepository, +} from "@/lib/api-forgejo"; + +type Notice = { + tone: "success" | "error"; + message: string; +}; + +type DeleteTarget = + | { type: "connection"; item: ForgejoConnection } + | { type: "repository"; item: ForgejoRepository }; + +const repositoryName = (repository: ForgejoRepository) => + repository.display_name || `${repository.owner}/${repository.repo}`; + +const formatTimestamp = (value: string | null) => { + if (!value) return "Never"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(); +}; + +function NoticeBanner({ notice }: { notice: Notice }) { + return ( +
+ {label} +
++ {value} +
+{caption}
++ URL and token records used by tracked repositories. +
++ Repositories whose issues are cached and shown in Pipeline. +
++ Configure webhooks in Forgejo to push issue updates to + Pipeline in real time, without waiting for the scheduled sync. +
++ No repositories tracked yet. Add a repository to see webhook + URLs. +
+ ) : ( +
+ In each Forgejo repository, go to{" "}
+
+ Settings → Webhooks → Add Webhook
+
+ . Set the target URL below, content type{" "}
+
+ application/json
+
+ , and paste a secret. Store the same secret on the repository
+ record in Pipeline via{" "}
+
+ Edit Repository
+
+ .
+
| + Repository + | ++ Webhook URL + | ++ Secret + | +
|---|---|---|
| + + {repositoryName(repo)} + + + {repo.active ? "" : "(inactive)"} + + | +
+
+
+
+ {webhookUrl}
+
+ |
+
+ {repo.has_webhook_secret ? (
+
+ |
+
+ Pipeline runs a background sync for all active repositories + every 60 minutes. + This keeps issues current without manual syncing or webhooks. + The interval is configured via environment variable and cannot + be changed from the UI. +
+
+ FORGEJO_SYNC_ENABLED
+
+
+
+ Env:{" "}
+
+ FORGEJO_SYNC_INTERVAL_SECONDS
+
+
+ + Manage Forgejo connections, tracked repositories, and issue + sync. +
+- The owner of the repository (username or organization). -
-- A friendly name for this repository. If not provided, the owner/repo - will be used. + Friendly name shown in Pipeline. Defaults to owner/repo if blank.
- The default branch for this repository (e.g., main, dev, master). + Auto-filled from Forgejo when a repository is selected.
+ Secret used to verify incoming Forgejo webhook payloads. Set the + same value in your Forgejo repository webhook settings. Leave + blank to keep the current secret, or clear and save to remove it. +
+Username or organization name.
+Repository name.
+