Feat: Settings git

This commit is contained in:
null 2026-05-19 21:18:18 -05:00
parent 21dadc8724
commit d6d094a67d
20 changed files with 1541 additions and 77 deletions

2
.gitignore vendored
View File

@ -28,3 +28,5 @@ backend/coverage.*
backend/.coverage
frontend/coverage
backend/app/services/openclaw/.device-keys
FUTURE.md
FUTURE.md

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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,

View File

@ -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"

View File

@ -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

View File

@ -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,

View File

@ -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),
)

View File

@ -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,
),
),
}

View File

@ -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"

View File

@ -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"]

View File

@ -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:

View File

@ -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.

View File

@ -130,6 +130,7 @@ export default function ForgejoRepositoriesEditPage({
repo: repository.repo,
display_name: repository.display_name,
default_branch: repository.default_branch,
showWebhookSecret: true,
};
return (

View File

@ -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"
/>
</>
);
}

View File

@ -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

View File

@ -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]"
/>
);
}

View File

@ -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"

View File

@ -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;