Feat: Settings git
This commit is contained in:
parent
21dadc8724
commit
d6d094a67d
|
|
@ -28,3 +28,5 @@ backend/coverage.*
|
|||
backend/.coverage
|
||||
frontend/coverage
|
||||
backend/app/services/openclaw/.device-keys
|
||||
FUTURE.md
|
||||
FUTURE.md
|
||||
|
|
|
|||
25
FUTURE.md
25
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)
|
||||
- [x] Forgejo webhook for cached issue updates
|
||||
- [x] Issue status sync (open/closed)
|
||||
- [ ] Auto-archive closed issues
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ export default function ForgejoRepositoriesEditPage({
|
|||
repo: repository.repo,
|
||||
display_name: repository.display_name,
|
||||
default_branch: repository.default_branch,
|
||||
showWebhookSecret: true,
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
className={`flex items-start gap-3 rounded-xl border p-3 text-sm ${
|
||||
notice.tone === "success"
|
||||
? "border-[color:rgba(52,211,153,0.35)] bg-[color:rgba(52,211,153,0.08)] text-[color:var(--success)]"
|
||||
: "border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] text-[color:var(--danger)]"
|
||||
}`}
|
||||
>
|
||||
{notice.tone === "success" ? (
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span>{notice.message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
caption,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
caption: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-3 break-words font-heading text-2xl font-semibold text-strong">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-2 text-[color:var(--accent)]">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-muted">{caption}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({ value, label }: { value: string; label?: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard API unavailable — silent fail
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="h-7 gap-1.5 px-2 text-xs"
|
||||
title={label ?? "Copy to clipboard"}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-[color:var(--success)]" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GitProjectSettingsPage() {
|
||||
const auth = useAuth();
|
||||
const [connections, setConnections] = useState<ForgejoConnection[]>([]);
|
||||
const [repositories, setRepositories] = useState<ForgejoRepository[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSyncingAll, setIsSyncingAll] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<Notice | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Auto-dismiss notices after 8s
|
||||
useEffect(() => {
|
||||
if (!notice) return;
|
||||
const id = setTimeout(() => setNotice(null), 8000);
|
||||
return () => clearTimeout(id);
|
||||
}, [notice]);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const [connectionsData, repositoriesData] = await Promise.all([
|
||||
getForgejoConnections(),
|
||||
getForgejoRepositories(),
|
||||
]);
|
||||
setConnections(connectionsData);
|
||||
setRepositories(repositoriesData);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Pipeline could not load Git Project settings.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.isSignedIn) {
|
||||
loadSettings();
|
||||
}
|
||||
}, [auth.isSignedIn, loadSettings]);
|
||||
|
||||
const activeConnections = useMemo(
|
||||
() => connections.filter((c) => c.active).length,
|
||||
[connections],
|
||||
);
|
||||
const activeRepositories = useMemo(
|
||||
() => repositories.filter((r) => r.active).length,
|
||||
[repositories],
|
||||
);
|
||||
const latestSync = useMemo(() => {
|
||||
const synced = repositories
|
||||
.map((r) => r.last_sync_at)
|
||||
.filter((v): v is string => Boolean(v))
|
||||
.map((v) => new Date(v))
|
||||
.filter((d) => !Number.isNaN(d.getTime()));
|
||||
if (synced.length === 0) return null;
|
||||
return synced.reduce((latest, d) =>
|
||||
d.getTime() > latest.getTime() ? d : latest,
|
||||
);
|
||||
}, [repositories]);
|
||||
const syncErrorCount = useMemo(
|
||||
() => repositories.filter((r) => r.last_sync_error).length,
|
||||
[repositories],
|
||||
);
|
||||
|
||||
const webhookBaseUrl = useMemo(() => {
|
||||
try {
|
||||
return getApiBaseUrl();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleValidateConnection = async (connection: ForgejoConnection) => {
|
||||
try {
|
||||
const result = await validateConnection(connection.id);
|
||||
setNotice(
|
||||
result.status.ok
|
||||
? {
|
||||
tone: "success",
|
||||
message: `"${connection.name}" validated in ${Math.round(result.response_time_ms)}ms.`,
|
||||
}
|
||||
: {
|
||||
tone: "error",
|
||||
message: `Connection validation failed: ${result.status.error_message ?? "Unknown error"}`,
|
||||
},
|
||||
);
|
||||
return result;
|
||||
} catch (err) {
|
||||
setNotice({
|
||||
tone: "error",
|
||||
message:
|
||||
err instanceof Error ? err.message : "Failed to validate connection.",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidateRepository = async (repository: ForgejoRepository) => {
|
||||
try {
|
||||
const result = await validateRepository(repository.id);
|
||||
setNotice(
|
||||
result.status.ok
|
||||
? {
|
||||
tone: "success",
|
||||
message: `${repositoryName(repository)} is reachable from Pipeline.`,
|
||||
}
|
||||
: {
|
||||
tone: "error",
|
||||
message: `Repository validation failed: ${result.status.error_message ?? "Unknown error"}`,
|
||||
},
|
||||
);
|
||||
return result;
|
||||
} catch (err) {
|
||||
setNotice({
|
||||
tone: "error",
|
||||
message:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to validate repository.",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncRepository = async (repository: ForgejoRepository) => {
|
||||
try {
|
||||
const result = await syncRepository(repository.id);
|
||||
setNotice({
|
||||
tone: "success",
|
||||
message: `${repositoryName(repository)} synced: ${result.created} created, ${result.updated} updated, ${result.open} open, ${result.closed} closed.`,
|
||||
});
|
||||
const data = await getForgejoRepositories();
|
||||
setRepositories(data);
|
||||
return result;
|
||||
} catch (err) {
|
||||
setNotice({
|
||||
tone: "error",
|
||||
message:
|
||||
err instanceof Error ? err.message : "Failed to sync repository.",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncAll = async () => {
|
||||
const active = repositories.filter((r) => r.active);
|
||||
if (active.length === 0) return;
|
||||
setIsSyncingAll(true);
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
await Promise.allSettled(
|
||||
active.map(async (repo) => {
|
||||
try {
|
||||
await syncRepository(repo.id);
|
||||
succeeded++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const data = await getForgejoRepositories();
|
||||
setRepositories(data);
|
||||
setIsSyncingAll(false);
|
||||
setNotice(
|
||||
failed === 0
|
||||
? {
|
||||
tone: "success",
|
||||
message: `All ${succeeded} repositories synced successfully.`,
|
||||
}
|
||||
: {
|
||||
tone: "error",
|
||||
message: `Sync completed: ${succeeded} succeeded, ${failed} failed.`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setIsDeleting(true);
|
||||
setDeleteError(null);
|
||||
try {
|
||||
if (deleteTarget.type === "connection") {
|
||||
await deleteForgejoConnection(deleteTarget.item.id);
|
||||
setConnections((prev) =>
|
||||
prev.filter((c) => c.id !== deleteTarget.item.id),
|
||||
);
|
||||
setNotice({
|
||||
tone: "success",
|
||||
message: `Deleted "${deleteTarget.item.name}".`,
|
||||
});
|
||||
} else {
|
||||
await deleteForgejoRepository(deleteTarget.item.id);
|
||||
setRepositories((prev) =>
|
||||
prev.filter((r) => r.id !== deleteTarget.item.id),
|
||||
);
|
||||
setNotice({
|
||||
tone: "success",
|
||||
message: `Deleted "${repositoryName(deleteTarget.item)}".`,
|
||||
});
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
} catch (err) {
|
||||
setDeleteError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Pipeline could not delete this item.",
|
||||
);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardPageLayout
|
||||
signedOut={{
|
||||
message: "Sign in to manage Git Project settings.",
|
||||
forceRedirectUrl: "/settings/git-projects",
|
||||
signUpForceRedirectUrl: "/settings/git-projects",
|
||||
}}
|
||||
title="Git Project Settings"
|
||||
description="Manage Forgejo connections, tracked repositories, and issue sync."
|
||||
stickyHeader
|
||||
headerActions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/git-projects/issues">
|
||||
<Button variant="outline" size="sm">
|
||||
<CircleDot className="h-4 w-4" />
|
||||
Issues
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSyncAll}
|
||||
disabled={isSyncingAll || isLoading || activeRepositories === 0}
|
||||
title="Sync all active repositories"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isSyncingAll ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{isSyncingAll ? "Syncing…" : "Sync All"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadSettings}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{notice ? <NoticeBanner notice={notice} /> : null}
|
||||
{error ? (
|
||||
<div className="flex items-start gap-3 rounded-xl border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-3 text-sm text-[color:var(--danger)]">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Stats */}
|
||||
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
icon={<Server className="h-4 w-4" />}
|
||||
label="Connections"
|
||||
value={`${activeConnections}/${connections.length}`}
|
||||
caption="Active Forgejo connection records."
|
||||
/>
|
||||
<StatCard
|
||||
icon={<GitBranch className="h-4 w-4" />}
|
||||
label="Repositories"
|
||||
value={`${activeRepositories}/${repositories.length}`}
|
||||
caption="Active tracked repositories."
|
||||
/>
|
||||
<StatCard
|
||||
icon={<RefreshCw className="h-4 w-4" />}
|
||||
label="Latest Sync"
|
||||
value={formatTimestamp(latestSync?.toISOString() ?? null)}
|
||||
caption="Most recent repository sync timestamp."
|
||||
/>
|
||||
<StatCard
|
||||
icon={<AlertCircle className="h-4 w-4" />}
|
||||
label="Sync Errors"
|
||||
value={String(syncErrorCount)}
|
||||
caption="Repositories with a recorded sync error."
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Connections */}
|
||||
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold text-strong">
|
||||
Forgejo Connections
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
URL and token records used by tracked repositories.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/git-projects/connections/new">
|
||||
<Button size="sm">
|
||||
<Link2 className="h-4 w-4" />
|
||||
Add Connection
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-4 overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)]">
|
||||
<ForgejoConnectionsTable
|
||||
connections={connections}
|
||||
isLoading={isLoading}
|
||||
onDelete={(connection) =>
|
||||
setDeleteTarget({ type: "connection", item: connection })
|
||||
}
|
||||
onValidate={handleValidateConnection}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Repositories */}
|
||||
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold text-strong">
|
||||
Tracked Repositories
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
Repositories whose issues are cached and shown in Pipeline.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/git-projects/repositories/new">
|
||||
<Button size="sm">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Add Repository
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/git-projects">
|
||||
<Button variant="outline" size="sm">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Git Projects
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)]">
|
||||
<ForgejoRepositoriesTable
|
||||
repositories={repositories}
|
||||
isLoading={isLoading}
|
||||
onDelete={(repository) =>
|
||||
setDeleteTarget({ type: "repository", item: repository })
|
||||
}
|
||||
onSync={handleSyncRepository}
|
||||
onValidate={handleValidateRepository}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Webhook Setup */}
|
||||
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-2 text-[color:var(--accent)] shrink-0">
|
||||
<Webhook className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold text-strong">
|
||||
Webhook Setup
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
Configure webhooks in Forgejo to push issue updates to
|
||||
Pipeline in real time, without waiting for the scheduled sync.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{repositories.length === 0 ? (
|
||||
<p className="text-sm text-muted">
|
||||
No repositories tracked yet. Add a repository to see webhook
|
||||
URLs.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted">
|
||||
In each Forgejo repository, go to{" "}
|
||||
<strong className="text-strong">
|
||||
Settings → Webhooks → Add Webhook
|
||||
</strong>
|
||||
. Set the target URL below, content type{" "}
|
||||
<code className="rounded bg-[color:var(--surface-muted)] px-1 py-0.5 font-mono text-xs">
|
||||
application/json
|
||||
</code>
|
||||
, and paste a secret. Store the same secret on the repository
|
||||
record in Pipeline via{" "}
|
||||
<Link
|
||||
href="/git-projects/repositories"
|
||||
className="text-[color:var(--accent)] underline underline-offset-2 hover:opacity-80"
|
||||
>
|
||||
Edit Repository
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<div className="overflow-x-auto rounded-xl border border-[color:var(--border)]">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[color:var(--border)] bg-[color:var(--surface-muted)]">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Repository
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Webhook URL
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Secret
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[color:var(--border)]">
|
||||
{repositories.map((repo) => {
|
||||
const webhookUrl = webhookBaseUrl
|
||||
? `${webhookBaseUrl}/api/v1/forgejo/webhooks/${repo.id}`
|
||||
: `…/api/v1/forgejo/webhooks/${repo.id}`;
|
||||
return (
|
||||
<tr
|
||||
key={repo.id}
|
||||
className="bg-[color:var(--surface)] hover:bg-[color:var(--surface-muted)]"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-medium text-strong">
|
||||
{repositoryName(repo)}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted">
|
||||
{repo.active ? "" : "(inactive)"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="max-w-[340px] truncate rounded bg-[color:var(--surface-muted)] px-2 py-0.5 font-mono text-xs text-strong">
|
||||
{webhookUrl}
|
||||
</code>
|
||||
<CopyButton
|
||||
value={webhookUrl}
|
||||
label="Copy webhook URL"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{repo.has_webhook_secret ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-[color:var(--success)]">
|
||||
<KeyRound className="h-3.5 w-3.5" />
|
||||
Configured
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted">
|
||||
<KeyRound className="h-3.5 w-3.5" />
|
||||
Not set
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Scheduled Sync */}
|
||||
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-2 text-[color:var(--accent)] shrink-0">
|
||||
<Clock className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold text-strong">
|
||||
Scheduled Sync
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
Pipeline runs a background sync for all active repositories
|
||||
every <strong className="text-strong">60 minutes</strong>.
|
||||
This keeps issues current without manual syncing or webhooks.
|
||||
The interval is configured via environment variable and cannot
|
||||
be changed from the UI.
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-4 text-xs text-muted">
|
||||
<span>
|
||||
Env:{" "}
|
||||
<code className="rounded bg-[color:var(--surface-muted)] px-1 py-0.5 font-mono text-strong">
|
||||
FORGEJO_SYNC_ENABLED
|
||||
</code>
|
||||
</span>
|
||||
<span>
|
||||
Env:{" "}
|
||||
<code className="rounded bg-[color:var(--surface-muted)] px-1 py-0.5 font-mono text-strong">
|
||||
FORGEJO_SYNC_INTERVAL_SECONDS
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DashboardPageLayout>
|
||||
|
||||
<ConfirmActionDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTarget(null);
|
||||
}}
|
||||
title={
|
||||
deleteTarget?.type === "connection"
|
||||
? "Delete Git Project connection"
|
||||
: "Delete Git Project repository"
|
||||
}
|
||||
description={
|
||||
deleteTarget?.type === "connection"
|
||||
? `Delete "${deleteTarget.item.name}" from Pipeline? Repositories that use this connection will stop syncing.`
|
||||
: deleteTarget?.type === "repository"
|
||||
? `Delete "${repositoryName(deleteTarget.item)}" from Pipeline? Synced issue records for this repository will be removed.`
|
||||
: ""
|
||||
}
|
||||
onConfirm={confirmDelete}
|
||||
isConfirming={isDeleting}
|
||||
errorMessage={deleteError}
|
||||
confirmLabel="Delete"
|
||||
confirmingLabel="Deleting…"
|
||||
confirmClassName="bg-[color:var(--danger)] text-white hover:bg-[color:var(--danger)]/90"
|
||||
cancelLabel="Keep"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,11 +3,21 @@
|
|||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useAuth, useUser } from "@/auth/clerk";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Globe, Mail, RotateCcw, Save, Trash2, User } from "lucide-react";
|
||||
import {
|
||||
ArrowRight,
|
||||
GitBranch,
|
||||
Globe,
|
||||
Mail,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Trash2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
useDeleteMeApiV1UsersMeDelete,
|
||||
|
|
@ -241,6 +251,27 @@ export default function SettingsPage() {
|
|||
</form>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="flex items-center gap-2 text-base font-semibold text-slate-900">
|
||||
<GitBranch className="h-4 w-4 text-slate-500" />
|
||||
Git Projects
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Manage Forgejo connections, tracked repositories, and issue
|
||||
sync.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/settings/git-projects">
|
||||
<Button type="button" variant="outline">
|
||||
Open settings
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-rose-200 bg-rose-50/70 p-6 shadow-sm">
|
||||
<h2 className="text-base font-semibold text-rose-900">
|
||||
Delete account
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
|
|||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { KeyRound, Loader2, Lock, Unlock } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -14,8 +14,10 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
|
||||
import {
|
||||
getConnectionRepos,
|
||||
getForgejoConnections,
|
||||
type ForgejoConnection,
|
||||
type ForgejoRemoteRepo,
|
||||
type ForgejoRepositoryCreate,
|
||||
} from "@/lib/api-forgejo";
|
||||
|
||||
|
|
@ -34,6 +36,9 @@ export interface ForgejoRepositoryFormValues {
|
|||
repo: string;
|
||||
display_name?: string;
|
||||
default_branch?: string;
|
||||
webhook_secret?: string;
|
||||
/** When true, shows the webhook secret field (edit mode only) */
|
||||
showWebhookSecret?: boolean;
|
||||
}
|
||||
|
||||
export function ForgejoRepositoryForm({
|
||||
|
|
@ -57,21 +62,35 @@ export function ForgejoRepositoryForm({
|
|||
const [defaultBranch, setDefaultBranch] = useState(
|
||||
defaultValues.default_branch || "main",
|
||||
);
|
||||
const [webhookSecret, setWebhookSecret] = useState(
|
||||
defaultValues.webhook_secret || "",
|
||||
);
|
||||
const showWebhookSecret = Boolean(defaultValues.showWebhookSecret);
|
||||
|
||||
// Get connections for dropdown
|
||||
const [connections, setConnections] = useState<ForgejoConnection[]>([]);
|
||||
|
||||
const [isLoadingConnections, setIsLoadingConnections] = useState(false);
|
||||
|
||||
// Remote repo discovery
|
||||
const [remoteRepos, setRemoteRepos] = useState<ForgejoRemoteRepo[]>([]);
|
||||
const [isLoadingRepos, setIsLoadingRepos] = useState(false);
|
||||
const [repoLoadError, setRepoLoadError] = useState<string | null>(null);
|
||||
const [selectedFullName, setSelectedFullName] = useState(
|
||||
defaultValues.owner && defaultValues.repo
|
||||
? `${defaultValues.owner}/${defaultValues.repo}`
|
||||
: "",
|
||||
);
|
||||
|
||||
const isBusy = isSubmitting || isSaving;
|
||||
const isEditMode = Boolean(defaultValues.connection_id);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchConnections = async () => {
|
||||
try {
|
||||
setIsLoadingConnections(true);
|
||||
const data = await getForgejoConnections();
|
||||
setConnections(data.filter((connection) => connection.active));
|
||||
setConnections(data.filter((c) => c.active));
|
||||
} catch {
|
||||
setError("Failed to load connections");
|
||||
setError("Failed to load connections.");
|
||||
} finally {
|
||||
setIsLoadingConnections(false);
|
||||
}
|
||||
|
|
@ -79,16 +98,55 @@ export function ForgejoRepositoryForm({
|
|||
fetchConnections();
|
||||
}, []);
|
||||
|
||||
// When a connection is chosen, fetch accessible repos from that Forgejo instance
|
||||
useEffect(() => {
|
||||
if (!connectionId || isEditMode) return;
|
||||
setRemoteRepos([]);
|
||||
setSelectedFullName("");
|
||||
setOwner("");
|
||||
setRepo("");
|
||||
setDefaultBranch("main");
|
||||
setRepoLoadError(null);
|
||||
|
||||
const fetchRepos = async () => {
|
||||
setIsLoadingRepos(true);
|
||||
try {
|
||||
const data = await getConnectionRepos(connectionId);
|
||||
setRemoteRepos(data);
|
||||
} catch (err) {
|
||||
setRepoLoadError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Could not fetch repositories from this connection.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoadingRepos(false);
|
||||
}
|
||||
};
|
||||
fetchRepos();
|
||||
}, [connectionId, isEditMode]);
|
||||
|
||||
const handleRepoSelect = (fullName: string) => {
|
||||
setSelectedFullName(fullName);
|
||||
const found = remoteRepos.find((r) => r.full_name === fullName);
|
||||
if (!found) return;
|
||||
setOwner(found.owner);
|
||||
setRepo(found.name);
|
||||
setDefaultBranch(found.default_branch || "main");
|
||||
if (!displayName) setDisplayName(found.name);
|
||||
};
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!connectionId) {
|
||||
setError(
|
||||
"Choose a Git Projects connection before saving this repository.",
|
||||
);
|
||||
setError("Choose a connection before saving.");
|
||||
return;
|
||||
}
|
||||
if (!owner || !repo) {
|
||||
setError("Select a repository or enter an owner and repository name.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await onSubmit({
|
||||
|
|
@ -97,9 +155,12 @@ export function ForgejoRepositoryForm({
|
|||
repo,
|
||||
display_name: displayName || undefined,
|
||||
default_branch: defaultBranch,
|
||||
...(showWebhookSecret
|
||||
? { webhook_secret: webhookSecret || null }
|
||||
: {}),
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
setError(err instanceof Error ? err.message : "An error occurred.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
|
|
@ -125,6 +186,7 @@ export function ForgejoRepositoryForm({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="connection_id" className="text-sm font-medium">
|
||||
Connection
|
||||
|
|
@ -133,7 +195,10 @@ export function ForgejoRepositoryForm({
|
|||
value={connectionId}
|
||||
onValueChange={setConnectionId}
|
||||
disabled={
|
||||
isBusy || isLoadingConnections || connections.length === 0
|
||||
isBusy ||
|
||||
isLoadingConnections ||
|
||||
connections.length === 0 ||
|
||||
isEditMode
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="connection_id">
|
||||
|
|
@ -141,61 +206,121 @@ export function ForgejoRepositoryForm({
|
|||
placeholder={
|
||||
isLoadingConnections
|
||||
? "Loading connections…"
|
||||
: "Select a connection"
|
||||
: connections.length === 0
|
||||
? "No active connections"
|
||||
: "Select a connection"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id}>
|
||||
{conn.name} - {conn.base_url}
|
||||
{conn.name} — {conn.base_url}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted">
|
||||
The Git Projects connection to use for this repository. Add a
|
||||
connection first if none are available.
|
||||
The Forgejo connection to use.{" "}
|
||||
{connections.length === 0 && !isLoadingConnections && (
|
||||
<span className="text-[color:var(--danger)]">
|
||||
Add a connection first.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{/* Repo picker — shown when connection is selected in create mode */}
|
||||
{!isEditMode && connectionId && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="owner" className="text-sm font-medium">
|
||||
Owner
|
||||
</label>
|
||||
<Input
|
||||
id="owner"
|
||||
value={owner}
|
||||
onChange={(e) => setOwner(e.target.value)}
|
||||
placeholder="openclaw"
|
||||
disabled={isBusy}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted">
|
||||
The owner of the repository (username or organization).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="repo" className="text-sm font-medium">
|
||||
<label htmlFor="repo_select" className="text-sm font-medium">
|
||||
Repository
|
||||
</label>
|
||||
<Input
|
||||
id="repo"
|
||||
value={repo}
|
||||
onChange={(e) => setRepo(e.target.value)}
|
||||
placeholder="pipeline"
|
||||
disabled={isBusy}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted">The name of the repository.</p>
|
||||
{isLoadingRepos ? (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-[color:var(--border)] px-3 py-2.5 text-sm text-muted">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Fetching repositories from Forgejo…
|
||||
</div>
|
||||
) : repoLoadError ? (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-xl border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] px-3 py-2 text-xs text-[color:var(--danger)]">
|
||||
{repoLoadError} — enter owner and repository manually below.
|
||||
</div>
|
||||
<ManualOwnerRepoFields
|
||||
owner={owner}
|
||||
repo={repo}
|
||||
onOwnerChange={setOwner}
|
||||
onRepoChange={setRepo}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
</div>
|
||||
) : remoteRepos.length > 0 ? (
|
||||
<>
|
||||
<Select
|
||||
value={selectedFullName}
|
||||
onValueChange={handleRepoSelect}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<SelectTrigger id="repo_select">
|
||||
<SelectValue placeholder="Select a repository…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{remoteRepos.map((r) => (
|
||||
<SelectItem key={r.full_name} value={r.full_name}>
|
||||
<span className="flex items-center gap-2">
|
||||
{r.private ? (
|
||||
<Lock className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
) : (
|
||||
<Unlock className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
)}
|
||||
{r.full_name}
|
||||
{r.description && (
|
||||
<span className="ml-1 truncate text-xs text-muted">
|
||||
— {r.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted">
|
||||
{remoteRepos.length} repositories accessible with this token.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted">
|
||||
No repositories found via this token. Enter manually:
|
||||
</p>
|
||||
<ManualOwnerRepoFields
|
||||
owner={owner}
|
||||
repo={repo}
|
||||
onOwnerChange={setOwner}
|
||||
onRepoChange={setRepo}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual owner/repo fields — always shown in edit mode */}
|
||||
{isEditMode && (
|
||||
<ManualOwnerRepoFields
|
||||
owner={owner}
|
||||
repo={repo}
|
||||
onOwnerChange={setOwner}
|
||||
onRepoChange={setRepo}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Display Name */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="display_name" className="text-sm font-medium">
|
||||
Display Name (Optional)
|
||||
Display Name{" "}
|
||||
<span className="font-normal text-muted">(optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
id="display_name"
|
||||
|
|
@ -205,11 +330,11 @@ export function ForgejoRepositoryForm({
|
|||
disabled={isBusy}
|
||||
/>
|
||||
<p className="text-xs text-muted">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Default Branch */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="default_branch" className="text-sm font-medium">
|
||||
Default Branch
|
||||
|
|
@ -223,9 +348,36 @@ export function ForgejoRepositoryForm({
|
|||
required
|
||||
/>
|
||||
<p className="text-xs text-muted">
|
||||
The default branch for this repository (e.g., main, dev, master).
|
||||
Auto-filled from Forgejo when a repository is selected.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Webhook Secret — edit mode only */}
|
||||
{showWebhookSecret && (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="webhook_secret"
|
||||
className="flex items-center gap-2 text-sm font-medium"
|
||||
>
|
||||
<KeyRound className="h-4 w-4 text-muted" />
|
||||
Webhook Secret
|
||||
</label>
|
||||
<Input
|
||||
id="webhook_secret"
|
||||
type="password"
|
||||
value={webhookSecret}
|
||||
onChange={(e) => setWebhookSecret(e.target.value)}
|
||||
placeholder="Leave blank to keep existing secret"
|
||||
disabled={isBusy}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<p className="text-xs text-muted">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 border-t border-[color:var(--border)] pt-4 sm:flex-row sm:justify-end">
|
||||
|
|
@ -251,3 +403,69 @@ export function ForgejoRepositoryForm({
|
|||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ManualOwnerRepoFields({
|
||||
owner,
|
||||
repo,
|
||||
onOwnerChange,
|
||||
onRepoChange,
|
||||
disabled,
|
||||
}: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
onOwnerChange: (v: string) => void;
|
||||
onRepoChange: (v: string) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="owner" className="text-sm font-medium">
|
||||
Owner
|
||||
</label>
|
||||
<Input
|
||||
id="owner"
|
||||
value={owner}
|
||||
onChange={(e) => onOwnerChange(e.target.value)}
|
||||
placeholder="myorg"
|
||||
disabled={disabled}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted">Username or organization name.</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="repo" className="text-sm font-medium">
|
||||
Repository
|
||||
</label>
|
||||
<Input
|
||||
id="repo"
|
||||
value={repo}
|
||||
onChange={(e) => onRepoChange(e.target.value)}
|
||||
placeholder="my-project"
|
||||
disabled={disabled}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted">Repository name.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Filter/toggle helpers re-exported for table usage
|
||||
export function RepositoriesTableFilter({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter repositories…"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-9 w-[150px] rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1 text-sm text-strong focus:border-[color:var(--accent)] focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)] lg:w-[250px]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -199,6 +199,13 @@ export function DashboardSidebar() {
|
|||
<Building2 className="h-4 w-4" />
|
||||
Organization
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings"
|
||||
className={navItemClass(pathname.startsWith("/settings"))}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</Link>
|
||||
{isAdmin ? (
|
||||
<Link
|
||||
href="/gateways"
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export interface ForgejoRepository {
|
|||
display_name: string;
|
||||
default_branch: string;
|
||||
active: boolean;
|
||||
has_webhook_secret: boolean;
|
||||
connection: ForgejoConnection;
|
||||
last_sync_at: string | null;
|
||||
last_sync_error: string | null;
|
||||
|
|
@ -54,6 +55,7 @@ export interface ForgejoRepositoryCreate {
|
|||
export interface ForgejoRepositoryUpdate {
|
||||
display_name?: string;
|
||||
default_branch?: string;
|
||||
webhook_secret?: string | null;
|
||||
}
|
||||
|
||||
type ApiResponse<T> = {
|
||||
|
|
@ -159,6 +161,25 @@ export async function deleteForgejoRepository(
|
|||
);
|
||||
}
|
||||
|
||||
// Remote repo discovery
|
||||
export interface ForgejoRemoteRepo {
|
||||
full_name: string;
|
||||
name: string;
|
||||
owner: string;
|
||||
default_branch: string;
|
||||
description: string | null;
|
||||
private: boolean;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
export async function getConnectionRepos(
|
||||
connectionId: string,
|
||||
): Promise<ForgejoRemoteRepo[]> {
|
||||
return fetchJson<ForgejoRemoteRepo[]>(
|
||||
`/api/v1/forgejo/connections/${connectionId}/repos`,
|
||||
);
|
||||
}
|
||||
|
||||
// Forgejo Sync & Validation API
|
||||
export async function syncRepository(repositoryId: string): Promise<{
|
||||
created: number;
|
||||
|
|
|
|||
Loading…
Reference in New Issue