feat(forgejo): batch 3 UI + Georgia numbers font (#28)
This commit is contained in:
parent
2de481460f
commit
8e012a2197
|
|
@ -2,16 +2,13 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from app.api.deps import require_org_admin
|
from app.api.deps import require_org_member
|
||||||
from app.core.auth import AuthContext, get_auth_context
|
|
||||||
from app.db import crud
|
from app.db import crud
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models.forgejo_connections import ForgejoConnection
|
from app.models.forgejo_connections import ForgejoConnection
|
||||||
|
|
@ -22,7 +19,6 @@ from app.schemas.forgejo_connections import (
|
||||||
ForgejoConnectionUpdate,
|
ForgejoConnectionUpdate,
|
||||||
)
|
)
|
||||||
from app.schemas.forgejo_validation import ForgejoConnectionValidationResponse
|
from app.schemas.forgejo_validation import ForgejoConnectionValidationResponse
|
||||||
from app.services.forgejo_client import get_forgejo_client
|
|
||||||
from app.services.organizations import OrganizationContext
|
from app.services.organizations import OrganizationContext
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -31,8 +27,7 @@ if TYPE_CHECKING:
|
||||||
|
|
||||||
router = APIRouter(prefix="/forgejo/connections", tags=["forgejo-connections"])
|
router = APIRouter(prefix="/forgejo/connections", tags=["forgejo-connections"])
|
||||||
SESSION_DEP = Depends(get_session)
|
SESSION_DEP = Depends(get_session)
|
||||||
AUTH_DEP = Depends(get_auth_context)
|
ORG_MEMBER_DEP = Depends(require_org_member)
|
||||||
ORG_ADMIN_DEP = Depends(require_org_admin)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_token_last_eight(token: str | None) -> str | None:
|
def _extract_token_last_eight(token: str | None) -> str | None:
|
||||||
|
|
@ -60,7 +55,7 @@ def _mask_connection(connection: ForgejoConnection) -> dict[str, object]:
|
||||||
@router.get("", response_model=list[ForgejoConnectionRead])
|
@router.get("", response_model=list[ForgejoConnectionRead])
|
||||||
async def list_connections(
|
async def list_connections(
|
||||||
session: AsyncSession = SESSION_DEP,
|
session: AsyncSession = SESSION_DEP,
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
) -> list[ForgejoConnectionRead]:
|
) -> list[ForgejoConnectionRead]:
|
||||||
"""List Forgejo connections for the caller's organization."""
|
"""List Forgejo connections for the caller's organization."""
|
||||||
statement = (
|
statement = (
|
||||||
|
|
@ -76,8 +71,7 @@ async def list_connections(
|
||||||
async def create_connection(
|
async def create_connection(
|
||||||
payload: ForgejoConnectionCreate,
|
payload: ForgejoConnectionCreate,
|
||||||
session: AsyncSession = SESSION_DEP,
|
session: AsyncSession = SESSION_DEP,
|
||||||
auth: AuthContext = AUTH_DEP,
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> ForgejoConnectionRead:
|
) -> ForgejoConnectionRead:
|
||||||
"""Create a Forgejo connection for the caller's organization."""
|
"""Create a Forgejo connection for the caller's organization."""
|
||||||
data = payload.model_dump()
|
data = payload.model_dump()
|
||||||
|
|
@ -96,7 +90,7 @@ async def create_connection(
|
||||||
async def get_connection(
|
async def get_connection(
|
||||||
connection_id: UUID,
|
connection_id: UUID,
|
||||||
session: AsyncSession = SESSION_DEP,
|
session: AsyncSession = SESSION_DEP,
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
) -> ForgejoConnectionRead:
|
) -> ForgejoConnectionRead:
|
||||||
"""Return one Forgejo connection by id for the caller's organization."""
|
"""Return one Forgejo connection by id for the caller's organization."""
|
||||||
connection = await crud.get_by_id(session, ForgejoConnection, connection_id)
|
connection = await crud.get_by_id(session, ForgejoConnection, connection_id)
|
||||||
|
|
@ -112,8 +106,7 @@ async def update_connection(
|
||||||
connection_id: UUID,
|
connection_id: UUID,
|
||||||
payload: ForgejoConnectionUpdate,
|
payload: ForgejoConnectionUpdate,
|
||||||
session: AsyncSession = SESSION_DEP,
|
session: AsyncSession = SESSION_DEP,
|
||||||
auth: AuthContext = AUTH_DEP,
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> ForgejoConnectionRead:
|
) -> ForgejoConnectionRead:
|
||||||
"""Patch a Forgejo connection for the caller's organization."""
|
"""Patch a Forgejo connection for the caller's organization."""
|
||||||
connection = await crud.get_by_id(session, ForgejoConnection, connection_id)
|
connection = await crud.get_by_id(session, ForgejoConnection, connection_id)
|
||||||
|
|
@ -165,7 +158,7 @@ async def update_connection(
|
||||||
async def delete_connection(
|
async def delete_connection(
|
||||||
connection_id: UUID,
|
connection_id: UUID,
|
||||||
session: AsyncSession = SESSION_DEP,
|
session: AsyncSession = SESSION_DEP,
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
) -> OkResponse:
|
) -> OkResponse:
|
||||||
"""Delete a Forgejo connection for the caller's organization."""
|
"""Delete a Forgejo connection for the caller's organization."""
|
||||||
connection = await crud.get_by_id(session, ForgejoConnection, connection_id)
|
connection = await crud.get_by_id(session, ForgejoConnection, connection_id)
|
||||||
|
|
@ -188,7 +181,7 @@ async def delete_connection(
|
||||||
async def validate_connection(
|
async def validate_connection(
|
||||||
connection_id: UUID,
|
connection_id: UUID,
|
||||||
session: AsyncSession = SESSION_DEP,
|
session: AsyncSession = SESSION_DEP,
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
) -> ForgejoConnectionValidationResponse:
|
) -> ForgejoConnectionValidationResponse:
|
||||||
"""Validate a Forgejo connection by testing authenticated API access."""
|
"""Validate a Forgejo connection by testing authenticated API access."""
|
||||||
connection = await crud.get_by_id(session, ForgejoConnection, connection_id)
|
connection = await crud.get_by_id(session, ForgejoConnection, connection_id)
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,8 @@ from uuid import UUID
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlmodel import select, func
|
from sqlmodel import select, func
|
||||||
|
|
||||||
from app.api.deps import get_board_for_user_write, require_org_admin
|
from app.api.deps import get_board_for_user_write, require_org_member
|
||||||
from app.core.agent_auth import get_agent_auth_context
|
from app.core.auth import AuthContext, get_auth_context
|
||||||
from app.core.auth import get_auth_context
|
|
||||||
from app.db import crud
|
from app.db import crud
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models.board_repository_links import BoardRepositoryLink
|
from app.models.board_repository_links import BoardRepositoryLink
|
||||||
|
|
@ -26,14 +25,13 @@ if TYPE_CHECKING:
|
||||||
router = APIRouter(prefix="/forgejo/issues", tags=["forgejo-issues"])
|
router = APIRouter(prefix="/forgejo/issues", tags=["forgejo-issues"])
|
||||||
SESSION_DEP = Depends(get_session)
|
SESSION_DEP = Depends(get_session)
|
||||||
AUTH_DEP = Depends(get_auth_context)
|
AUTH_DEP = Depends(get_auth_context)
|
||||||
ORG_ADMIN_DEP = Depends(require_org_admin)
|
ORG_MEMBER_DEP = Depends(require_org_member)
|
||||||
BOARD_WRITE_DEP = Depends(get_board_for_user_write)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=ForgejoIssueListResponse)
|
@router.get("", response_model=ForgejoIssueListResponse)
|
||||||
async def list_issues(
|
async def list_issues(
|
||||||
session: AsyncSession = SESSION_DEP,
|
session: AsyncSession = SESSION_DEP,
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
repository_id: str | None = Query(None, description="Filter by repository ID"),
|
repository_id: str | None = Query(None, description="Filter by repository ID"),
|
||||||
state: str | None = Query(None, description="Filter by state (open, closed)"),
|
state: str | None = Query(None, description="Filter by state (open, closed)"),
|
||||||
label: str | None = Query(None, description="Filter by label name"),
|
label: str | None = Query(None, description="Filter by label name"),
|
||||||
|
|
@ -99,7 +97,7 @@ async def list_issues(
|
||||||
async def get_issue(
|
async def get_issue(
|
||||||
issue_id: str,
|
issue_id: str,
|
||||||
session: AsyncSession = SESSION_DEP,
|
session: AsyncSession = SESSION_DEP,
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
) -> ForgejoIssueRead:
|
) -> ForgejoIssueRead:
|
||||||
"""Get one cached issue by ID."""
|
"""Get one cached issue by ID."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -157,7 +155,8 @@ async def get_issue(
|
||||||
async def close_issue(
|
async def close_issue(
|
||||||
issue_id: str,
|
issue_id: str,
|
||||||
session: AsyncSession = SESSION_DEP,
|
session: AsyncSession = SESSION_DEP,
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
auth: AuthContext = AUTH_DEP,
|
||||||
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
) -> CloseIssueResponse:
|
) -> CloseIssueResponse:
|
||||||
"""Close a Forgejo issue as an authenticated user.
|
"""Close a Forgejo issue as an authenticated user.
|
||||||
|
|
||||||
|
|
@ -185,18 +184,20 @@ async def close_issue(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify the user has write access to the board
|
# Verify the user has write access to the board
|
||||||
board = await get_board_for_user_write(
|
await get_board_for_user_write(
|
||||||
board_id=str(link.board_id),
|
board_id=str(link.board_id),
|
||||||
session=session,
|
session=session,
|
||||||
auth=ctx,
|
auth=auth,
|
||||||
)
|
)
|
||||||
|
if auth.user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
# Close the issue using the service
|
# Close the issue using the service
|
||||||
try:
|
try:
|
||||||
result = await close_issue_by_id(
|
result = await close_issue_by_id(
|
||||||
session=session,
|
session=session,
|
||||||
issue_id=uuid,
|
issue_id=uuid,
|
||||||
actor_user_id=ctx.user.id,
|
actor_user_id=auth.user.id,
|
||||||
)
|
)
|
||||||
except CloseIssueNotFoundError as e:
|
except CloseIssueNotFoundError as e:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,13 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
||||||
from app.api.deps import require_org_admin
|
from app.api.deps import require_org_member
|
||||||
from app.core.auth import AuthContext, get_auth_context
|
|
||||||
from app.db import crud
|
from app.db import crud
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models.forgejo_connections import ForgejoConnection
|
from app.models.forgejo_connections import ForgejoConnection
|
||||||
|
|
@ -31,8 +29,7 @@ if TYPE_CHECKING:
|
||||||
|
|
||||||
router = APIRouter(prefix="/forgejo/repositories", tags=["forgejo-repositories"])
|
router = APIRouter(prefix="/forgejo/repositories", tags=["forgejo-repositories"])
|
||||||
SESSION_DEP = Depends(get_session)
|
SESSION_DEP = Depends(get_session)
|
||||||
AUTH_DEP = Depends(get_auth_context)
|
ORG_MEMBER_DEP = Depends(require_org_member)
|
||||||
ORG_ADMIN_DEP = Depends(require_org_admin)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_connection_info(connection: ForgejoConnection) -> dict[str, object]:
|
def _create_connection_info(connection: ForgejoConnection) -> dict[str, object]:
|
||||||
|
|
@ -51,7 +48,7 @@ def _create_connection_info(connection: ForgejoConnection) -> dict[str, object]:
|
||||||
@router.get("", response_model=list[ForgejoRepositoryRead])
|
@router.get("", response_model=list[ForgejoRepositoryRead])
|
||||||
async def list_repositories(
|
async def list_repositories(
|
||||||
session: AsyncSession = SESSION_DEP,
|
session: AsyncSession = SESSION_DEP,
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
) -> list[ForgejoRepositoryRead]:
|
) -> list[ForgejoRepositoryRead]:
|
||||||
"""List Forgejo repositories for the caller's organization."""
|
"""List Forgejo repositories for the caller's organization."""
|
||||||
statement = (
|
statement = (
|
||||||
|
|
@ -77,8 +74,7 @@ async def list_repositories(
|
||||||
async def create_repository(
|
async def create_repository(
|
||||||
payload: ForgejoRepositoryCreate,
|
payload: ForgejoRepositoryCreate,
|
||||||
session: AsyncSession = SESSION_DEP,
|
session: AsyncSession = SESSION_DEP,
|
||||||
auth: AuthContext = AUTH_DEP,
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> ForgejoRepositoryRead:
|
) -> ForgejoRepositoryRead:
|
||||||
"""Create a Forgejo repository tracked for the caller's organization."""
|
"""Create a Forgejo repository tracked for the caller's organization."""
|
||||||
# Validate connection belongs to caller's org
|
# Validate connection belongs to caller's org
|
||||||
|
|
@ -123,7 +119,7 @@ async def create_repository(
|
||||||
async def get_repository(
|
async def get_repository(
|
||||||
repository_id: UUID,
|
repository_id: UUID,
|
||||||
session: AsyncSession = SESSION_DEP,
|
session: AsyncSession = SESSION_DEP,
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
) -> ForgejoRepositoryRead:
|
) -> ForgejoRepositoryRead:
|
||||||
"""Return one Forgejo repository by id for the caller's organization."""
|
"""Return one Forgejo repository by id for the caller's organization."""
|
||||||
statement = (
|
statement = (
|
||||||
|
|
@ -143,8 +139,7 @@ async def update_repository(
|
||||||
repository_id: UUID,
|
repository_id: UUID,
|
||||||
payload: ForgejoRepositoryUpdate,
|
payload: ForgejoRepositoryUpdate,
|
||||||
session: AsyncSession = SESSION_DEP,
|
session: AsyncSession = SESSION_DEP,
|
||||||
auth: AuthContext = AUTH_DEP,
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> ForgejoRepositoryRead:
|
) -> ForgejoRepositoryRead:
|
||||||
"""Patch a Forgejo repository for the caller's organization."""
|
"""Patch a Forgejo repository for the caller's organization."""
|
||||||
# Get repository
|
# Get repository
|
||||||
|
|
@ -218,7 +213,7 @@ async def update_repository(
|
||||||
async def delete_repository(
|
async def delete_repository(
|
||||||
repository_id: UUID,
|
repository_id: UUID,
|
||||||
session: AsyncSession = SESSION_DEP,
|
session: AsyncSession = SESSION_DEP,
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
) -> OkResponse:
|
) -> OkResponse:
|
||||||
"""Delete a Forgejo repository for the caller's organization."""
|
"""Delete a Forgejo repository for the caller's organization."""
|
||||||
repository = await crud.get_by_id(session, ForgejoRepository, repository_id)
|
repository = await crud.get_by_id(session, ForgejoRepository, repository_id)
|
||||||
|
|
@ -241,7 +236,7 @@ async def delete_repository(
|
||||||
async def validate_repository(
|
async def validate_repository(
|
||||||
repository_id: UUID,
|
repository_id: UUID,
|
||||||
session: AsyncSession = SESSION_DEP,
|
session: AsyncSession = SESSION_DEP,
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
) -> ForgejoRepositoryValidationResponse:
|
) -> ForgejoRepositoryValidationResponse:
|
||||||
"""Validate a Forgejo repository by testing API access."""
|
"""Validate a Forgejo repository by testing API access."""
|
||||||
repository = await crud.get_by_id(session, ForgejoRepository, repository_id)
|
repository = await crud.get_by_id(session, ForgejoRepository, repository_id)
|
||||||
|
|
@ -296,12 +291,12 @@ async def validate_repository(
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{repository_id}/sync",
|
"/{repository_id}/sync",
|
||||||
summary="Sync Issues from Repository",
|
summary="Sync Issues from Repository",
|
||||||
description="Sync issues from a Forgejo repository. Admin-only endpoint.",
|
description="Sync issues from a Forgejo repository.",
|
||||||
)
|
)
|
||||||
async def sync_repository_issues(
|
async def sync_repository_issues(
|
||||||
repository_id: UUID,
|
repository_id: UUID,
|
||||||
session: AsyncSession = SESSION_DEP,
|
session: AsyncSession = SESSION_DEP,
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
"""Sync issues from a Forgejo repository."""
|
"""Sync issues from a Forgejo repository."""
|
||||||
repository = await crud.get_by_id(session, ForgejoRepository, repository_id)
|
repository = await crud.get_by_id(session, ForgejoRepository, repository_id)
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { useAuth } from "@/auth/clerk";
|
import { useAuth } from "@/auth/clerk";
|
||||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||||
import { ForgejoConnectionForm } from "@/components/git/ForgejoConnectionForm";
|
import { ForgejoConnectionForm } from "@/components/git/ForgejoConnectionForm";
|
||||||
|
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
||||||
import {
|
import {
|
||||||
getForgejoConnection,
|
getForgejoConnection,
|
||||||
updateForgejoConnection,
|
updateForgejoConnection,
|
||||||
|
|
@ -27,13 +28,20 @@ interface ConnectionData {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ForgejoConnectionsEditPage({ params }: { params: RouteParams }) {
|
export default function ForgejoConnectionsEditPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: RouteParams;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
|
||||||
const [connection, setConnection] = useState<ConnectionData | null>(null);
|
const [connection, setConnection] = useState<ConnectionData | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchConnection = async () => {
|
const fetchConnection = async () => {
|
||||||
|
|
@ -43,7 +51,9 @@ export default function ForgejoConnectionsEditPage({ params }: { params: RoutePa
|
||||||
setConnection(data);
|
setConnection(data);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to load connection");
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Failed to load connection",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -55,26 +65,22 @@ export default function ForgejoConnectionsEditPage({ params }: { params: RoutePa
|
||||||
}, [params.connectionId, auth.isSignedIn]);
|
}, [params.connectionId, auth.isSignedIn]);
|
||||||
|
|
||||||
const handleSubmit = async (values: ForgejoConnectionUpdate) => {
|
const handleSubmit = async (values: ForgejoConnectionUpdate) => {
|
||||||
try {
|
await updateForgejoConnection(params.connectionId, values);
|
||||||
const connection = await updateForgejoConnection(params.connectionId, values);
|
|
||||||
console.log("Connection updated:", connection);
|
|
||||||
router.push("/git-projects/connections");
|
router.push("/git-projects/connections");
|
||||||
} catch (err) {
|
|
||||||
alert(err instanceof Error ? err.message : "Failed to update connection");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (
|
setIsDeleting(true);
|
||||||
confirm(`Are you sure you want to delete "${connection?.name}"? This action cannot be undone.`)
|
setDeleteError(null);
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
await deleteForgejoConnection(params.connectionId);
|
await deleteForgejoConnection(params.connectionId);
|
||||||
console.log("Connection deleted");
|
|
||||||
router.push("/git-projects/connections");
|
router.push("/git-projects/connections");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err instanceof Error ? err.message : "Failed to delete connection");
|
setDeleteError(
|
||||||
}
|
err instanceof Error ? err.message : "Failed to delete connection",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -82,14 +88,14 @@ export default function ForgejoConnectionsEditPage({ params }: { params: RoutePa
|
||||||
return (
|
return (
|
||||||
<DashboardPageLayout
|
<DashboardPageLayout
|
||||||
signedOut={{
|
signedOut={{
|
||||||
message: "Sign in to edit a Forgejo connection.",
|
message: "Sign in to edit a Git Project connection.",
|
||||||
forceRedirectUrl: "/git-projects/connections",
|
forceRedirectUrl: "/git-projects/connections",
|
||||||
signUpForceRedirectUrl: "/git-projects/connections",
|
signUpForceRedirectUrl: "/git-projects/connections",
|
||||||
}}
|
}}
|
||||||
title="Loading..."
|
title="Loading…"
|
||||||
stickyHeader
|
stickyHeader
|
||||||
>
|
>
|
||||||
<p className="text-slate-500">Loading connection...</p>
|
<p className="text-muted">Loading connection…</p>
|
||||||
</DashboardPageLayout>
|
</DashboardPageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -98,14 +104,16 @@ export default function ForgejoConnectionsEditPage({ params }: { params: RoutePa
|
||||||
return (
|
return (
|
||||||
<DashboardPageLayout
|
<DashboardPageLayout
|
||||||
signedOut={{
|
signedOut={{
|
||||||
message: "Sign in to edit a Forgejo connection.",
|
message: "Sign in to edit a Git Project connection.",
|
||||||
forceRedirectUrl: "/git-projects/connections",
|
forceRedirectUrl: "/git-projects/connections",
|
||||||
signUpForceRedirectUrl: "/git-projects/connections",
|
signUpForceRedirectUrl: "/git-projects/connections",
|
||||||
}}
|
}}
|
||||||
title="Error"
|
title="Error"
|
||||||
stickyHeader
|
stickyHeader
|
||||||
>
|
>
|
||||||
<p className="text-red-600">{error || "Connection not found"}</p>
|
<p className="text-[color:var(--danger)]">
|
||||||
|
{error || "Connection not found"}
|
||||||
|
</p>
|
||||||
</DashboardPageLayout>
|
</DashboardPageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -113,40 +121,58 @@ export default function ForgejoConnectionsEditPage({ params }: { params: RoutePa
|
||||||
const defaultValues = {
|
const defaultValues = {
|
||||||
name: connection.name,
|
name: connection.name,
|
||||||
base_url: connection.base_url,
|
base_url: connection.base_url,
|
||||||
token: connection.has_token ? "••••" + (connection.token_last_eight || "") : "",
|
token: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardPageLayout
|
<DashboardPageLayout
|
||||||
signedOut={{
|
signedOut={{
|
||||||
message: "Sign in to edit a Forgejo connection.",
|
message: "Sign in to edit a Git Project connection.",
|
||||||
forceRedirectUrl: "/git-projects/connections",
|
forceRedirectUrl: "/git-projects/connections",
|
||||||
signUpForceRedirectUrl: "/git-projects/connections",
|
signUpForceRedirectUrl: "/git-projects/connections",
|
||||||
}}
|
}}
|
||||||
title={`Edit Connection: ${connection.name}`}
|
title={`Edit Git Project Connection: ${connection.name}`}
|
||||||
description="Update connection settings and credentials."
|
description="Update the Git provider settings Pipeline uses for Git Projects."
|
||||||
stickyHeader
|
stickyHeader
|
||||||
>
|
>
|
||||||
<div className="max-w-2xl">
|
<div className="w-full max-w-2xl">
|
||||||
<ForgejoConnectionForm
|
<ForgejoConnectionForm
|
||||||
defaultValues={defaultValues}
|
defaultValues={defaultValues}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
isTokenRequired={!connection.has_token}
|
||||||
|
existingTokenLastEight={connection.token_last_eight}
|
||||||
submitLabel="Save Changes"
|
submitLabel="Save Changes"
|
||||||
/>
|
/>
|
||||||
<div className="mt-8 border-t pt-6">
|
<div className="surface-muted mt-6 rounded-2xl p-4 sm:p-5">
|
||||||
<h3 className="text-lg font-semibold text-red-900 dark:text-red-100">Danger Zone</h3>
|
<h3 className="text-base font-semibold text-[color:var(--danger)]">
|
||||||
<p className="mt-2 text-sm text-slate-500">
|
Delete Connection
|
||||||
Deleting a connection will remove all associated repositories and data.
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-muted">
|
||||||
|
Remove this connection from Pipeline. Repositories that use it will
|
||||||
|
stop syncing.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-950 dark:hover:text-red-300"
|
className="mt-4 border-[color:rgba(248,113,113,0.45)] text-[color:var(--danger)] hover:bg-[color:rgba(248,113,113,0.08)]"
|
||||||
onClick={handleDelete}
|
onClick={() => setDeleteOpen(true)}
|
||||||
>
|
>
|
||||||
Delete Connection
|
Delete Connection
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={setDeleteOpen}
|
||||||
|
title="Delete Git Project connection"
|
||||||
|
description={`Delete "${connection.name}" from Pipeline? Repositories that use this connection will stop syncing.`}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
isConfirming={isDeleting}
|
||||||
|
errorMessage={deleteError}
|
||||||
|
confirmLabel="Delete Connection"
|
||||||
|
confirmingLabel="Deleting…"
|
||||||
|
confirmClassName="bg-[color:var(--danger)] text-white hover:bg-[color:var(--danger)]/90"
|
||||||
|
cancelLabel="Keep Connection"
|
||||||
|
/>
|
||||||
</DashboardPageLayout>
|
</DashboardPageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,33 +4,31 @@ import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||||
import { ForgejoConnectionForm } from "@/components/git/ForgejoConnectionForm";
|
import { ForgejoConnectionForm } from "@/components/git/ForgejoConnectionForm";
|
||||||
import { createForgejoConnection, type ForgejoConnectionCreate } from "@/lib/api-forgejo";
|
import {
|
||||||
|
createForgejoConnection,
|
||||||
|
type ForgejoConnectionCreate,
|
||||||
|
} from "@/lib/api-forgejo";
|
||||||
|
|
||||||
export default function ForgejoConnectionsNewPage() {
|
export default function ForgejoConnectionsNewPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const handleSubmit = async (values: ForgejoConnectionCreate) => {
|
const handleSubmit = async (values: ForgejoConnectionCreate) => {
|
||||||
try {
|
await createForgejoConnection(values);
|
||||||
const connection = await createForgejoConnection(values);
|
|
||||||
alert(`Connection "${connection.name}" created successfully`);
|
|
||||||
router.push("/git-projects/connections");
|
router.push("/git-projects/connections");
|
||||||
} catch (err) {
|
|
||||||
alert(err instanceof Error ? err.message : "Failed to create connection");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardPageLayout
|
<DashboardPageLayout
|
||||||
signedOut={{
|
signedOut={{
|
||||||
message: "Sign in to create a Forgejo connection.",
|
message: "Sign in to create a Git Project connection.",
|
||||||
forceRedirectUrl: "/git-projects/connections/new",
|
forceRedirectUrl: "/git-projects/connections/new",
|
||||||
signUpForceRedirectUrl: "/git-projects/connections/new",
|
signUpForceRedirectUrl: "/git-projects/connections/new",
|
||||||
}}
|
}}
|
||||||
title="New Forgejo Connection"
|
title="New Git Project Connection"
|
||||||
description="Add a new Forgejo instance to track issues and pull requests."
|
description="Connect a Git provider so Pipeline can track repository issues."
|
||||||
stickyHeader
|
stickyHeader
|
||||||
>
|
>
|
||||||
<div className="max-w-2xl">
|
<div className="w-full max-w-2xl">
|
||||||
<ForgejoConnectionForm onSubmit={handleSubmit} />
|
<ForgejoConnectionForm onSubmit={handleSubmit} />
|
||||||
</div>
|
</div>
|
||||||
</DashboardPageLayout>
|
</DashboardPageLayout>
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useAuth } from "@/auth/clerk";
|
import { useAuth } from "@/auth/clerk";
|
||||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||||
import { ForgejoConnectionsTable } from "@/components/git/ForgejoConnectionsTable";
|
import { ForgejoConnectionsTable } from "@/components/git/ForgejoConnectionsTable";
|
||||||
|
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
||||||
import {
|
import {
|
||||||
getForgejoConnections,
|
getForgejoConnections,
|
||||||
deleteForgejoConnection,
|
deleteForgejoConnection,
|
||||||
|
|
@ -21,6 +23,15 @@ export default function ForgejoConnectionsPage() {
|
||||||
const [connections, setConnections] = useState<ForgejoConnection[]>([]);
|
const [connections, setConnections] = useState<ForgejoConnection[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<{
|
||||||
|
tone: "success" | "error";
|
||||||
|
message: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ForgejoConnection | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchConnections = async () => {
|
const fetchConnections = async () => {
|
||||||
|
|
@ -30,7 +41,9 @@ export default function ForgejoConnectionsPage() {
|
||||||
setConnections(data);
|
setConnections(data);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to load connections");
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Failed to load connections",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -41,66 +54,99 @@ export default function ForgejoConnectionsPage() {
|
||||||
}
|
}
|
||||||
}, [auth.isSignedIn, auth.getToken]);
|
}, [auth.isSignedIn, auth.getToken]);
|
||||||
|
|
||||||
const handleDelete = async (connection: ForgejoConnection) => {
|
const handleDelete = (connection: ForgejoConnection) => {
|
||||||
if (
|
setDeleteError(null);
|
||||||
confirm(`Are you sure you want to delete "${connection.name}"? This action cannot be undone.`)
|
setDeleteTarget(connection);
|
||||||
) {
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
setDeleteError(null);
|
||||||
try {
|
try {
|
||||||
await deleteForgejoConnection(connection.id);
|
await deleteForgejoConnection(deleteTarget.id);
|
||||||
setConnections((prev) => prev.filter((c) => c.id !== connection.id));
|
setConnections((prev) => prev.filter((c) => c.id !== deleteTarget.id));
|
||||||
alert("Connection deleted successfully");
|
setNotice({
|
||||||
|
tone: "success",
|
||||||
|
message: `Deleted "${deleteTarget.name}".`,
|
||||||
|
});
|
||||||
|
setDeleteTarget(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err instanceof Error ? err.message : "Failed to delete connection");
|
setDeleteError(
|
||||||
}
|
err instanceof Error ? err.message : "Failed to delete connection",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleValidateConnection = async (connection: ForgejoConnection) => {
|
const handleValidateConnection = async (connection: ForgejoConnection) => {
|
||||||
try {
|
try {
|
||||||
const result = await validateConnection(connection.id);
|
const result = await validateConnection(connection.id);
|
||||||
if (result.ok) {
|
if (result.status.ok) {
|
||||||
alert(
|
setNotice({
|
||||||
`Connection validated successfully!\n\n` +
|
tone: "success",
|
||||||
`Response time: ${result.response_time_ms}ms`
|
message: `"${connection.name}" validated in ${Math.round(result.response_time_ms)}ms.`,
|
||||||
);
|
});
|
||||||
} else {
|
} else {
|
||||||
alert(
|
setNotice({
|
||||||
`Connection validation failed: ${result.error_message || "Unknown error"}`
|
tone: "error",
|
||||||
);
|
message: `Connection validation failed: ${result.status.error_message || "Unknown error"}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err instanceof Error ? err.message : "Failed to validate connection");
|
setNotice({
|
||||||
|
tone: "error",
|
||||||
|
message:
|
||||||
|
err instanceof Error ? err.message : "Failed to validate connection",
|
||||||
|
});
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<DashboardPageLayout
|
<DashboardPageLayout
|
||||||
signedOut={{
|
signedOut={{
|
||||||
message: "Sign in to manage Forgejo connections.",
|
message: "Sign in to manage Git Project connections.",
|
||||||
forceRedirectUrl: "/git-projects/connections",
|
forceRedirectUrl: "/git-projects/connections",
|
||||||
signUpForceRedirectUrl: "/git-projects/connections",
|
signUpForceRedirectUrl: "/git-projects/connections",
|
||||||
}}
|
}}
|
||||||
title="Forgejo Connections"
|
title="Git Project Connections"
|
||||||
description={`${connections.length} connection${connections.length === 1 ? "" : "s"} configured.`}
|
description={`${connections.length} connection${connections.length === 1 ? "" : "s"} configured for Pipeline.`}
|
||||||
stickyHeader
|
stickyHeader
|
||||||
isAdmin={false}
|
|
||||||
adminOnlyMessage="Admin access required to manage Forgejo connections."
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
{notice ? (
|
||||||
<h2 className="text-sm font-medium text-slate-500">Connections</h2>
|
<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>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 className="text-sm font-medium text-muted">Connections</h2>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => router.push("/git-projects/connections/new")}
|
onClick={() => router.push("/git-projects/connections/new")}
|
||||||
>
|
>
|
||||||
Add Connection
|
Add Connection
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-lush">
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
<p className="text-red-600">{error}</p>
|
<p className="text-sm text-[color:var(--danger)]">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ForgejoConnectionsTable
|
<ForgejoConnectionsTable
|
||||||
|
|
@ -113,5 +159,25 @@ export default function ForgejoConnectionsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DashboardPageLayout>
|
</DashboardPageLayout>
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={Boolean(deleteTarget)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDeleteTarget(null);
|
||||||
|
}}
|
||||||
|
title="Delete Git Project connection"
|
||||||
|
description={
|
||||||
|
deleteTarget
|
||||||
|
? `Delete "${deleteTarget.name}" from Pipeline? Repositories that use this connection will stop syncing.`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
isConfirming={isDeleting}
|
||||||
|
errorMessage={deleteError}
|
||||||
|
confirmLabel="Delete Connection"
|
||||||
|
confirmingLabel="Deleting…"
|
||||||
|
confirmClassName="bg-[color:var(--danger)] text-white hover:bg-[color:var(--danger)]/90"
|
||||||
|
cancelLabel="Keep Connection"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,11 @@
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { useMemo, useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { AlertCircle } from "lucide-react";
|
||||||
import {
|
|
||||||
type ColumnDef,
|
|
||||||
} from "@tanstack/react-table";
|
|
||||||
|
|
||||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
getForgejoIssues,
|
getForgejoIssues,
|
||||||
getForgejoRepositories,
|
getForgejoRepositories,
|
||||||
|
|
@ -23,6 +20,8 @@ export default function GitIssuesPage() {
|
||||||
const [issues, setIssues] = useState<ForgejoIssue[]>([]);
|
const [issues, setIssues] = useState<ForgejoIssue[]>([]);
|
||||||
const [repos, setRepos] = useState<ForgejoRepository[]>([]);
|
const [repos, setRepos] = useState<ForgejoRepository[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
|
const [isLoadingIssues, setIsLoadingIssues] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [stateFilter, setStateFilter] = useState<string>("open");
|
const [stateFilter, setStateFilter] = useState<string>("open");
|
||||||
const [repoFilter, setRepoFilter] = useState<string>("all");
|
const [repoFilter, setRepoFilter] = useState<string>("all");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
@ -44,8 +43,9 @@ export default function GitIssuesPage() {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
setIsLoadingIssues(true);
|
||||||
const result = await getForgejoIssues({
|
const result = await getForgejoIssues({
|
||||||
state: stateFilter || undefined,
|
state: stateFilter !== "all" ? stateFilter : undefined,
|
||||||
repository_id: repoFilter !== "all" ? repoFilter : undefined,
|
repository_id: repoFilter !== "all" ? repoFilter : undefined,
|
||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
page,
|
page,
|
||||||
|
|
@ -53,9 +53,16 @@ export default function GitIssuesPage() {
|
||||||
});
|
});
|
||||||
setIssues(result.items);
|
setIssues(result.items);
|
||||||
setTotal(result.total);
|
setTotal(result.total);
|
||||||
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error && err.name === "AbortError") return;
|
if (err instanceof Error && err.name === "AbortError") return;
|
||||||
console.error("Failed to fetch issues:", err);
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Pipeline could not load Git Project issues.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingIssues(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
|
|
@ -63,8 +70,9 @@ export default function GitIssuesPage() {
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
try {
|
try {
|
||||||
|
setIsLoadingIssues(true);
|
||||||
const result = await getForgejoIssues({
|
const result = await getForgejoIssues({
|
||||||
state: stateFilter || undefined,
|
state: stateFilter !== "all" ? stateFilter : undefined,
|
||||||
repository_id: repoFilter !== "all" ? repoFilter : undefined,
|
repository_id: repoFilter !== "all" ? repoFilter : undefined,
|
||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
page,
|
page,
|
||||||
|
|
@ -72,152 +80,85 @@ export default function GitIssuesPage() {
|
||||||
});
|
});
|
||||||
setIssues(result.items);
|
setIssues(result.items);
|
||||||
setTotal(result.total);
|
setTotal(result.total);
|
||||||
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch issues:", err);
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Pipeline could not refresh Git Project issues.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingIssues(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: ColumnDef<ForgejoIssue>[] = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
accessorKey: "forgejo_issue_number",
|
|
||||||
header: "#",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<a
|
|
||||||
href={row.original.html_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="font-mono text-sm text-blue-600 hover:underline dark:text-blue-400"
|
|
||||||
>
|
|
||||||
#{row.original.forgejo_issue_number}
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "title",
|
|
||||||
header: "Title",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="max-w-md truncate">{row.original.title}</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "body_preview",
|
|
||||||
header: "Description",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const body = row.original.body_preview;
|
|
||||||
if (!body) return null;
|
|
||||||
const truncated = body.length > 120 ? body.slice(0, 120) + "…" : body;
|
|
||||||
return <div className="max-w-xs truncate text-sm text-slate-500">{truncated}</div>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "state",
|
|
||||||
header: "State",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const state = row.original.state;
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
variant={state === "open" ? "success" : "default"}
|
|
||||||
className={state === "open" ? "" : ""}
|
|
||||||
>
|
|
||||||
{state}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "author",
|
|
||||||
header: "Author",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "labels",
|
|
||||||
header: "Labels",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const labels = row.original.labels;
|
|
||||||
if (!labels || labels.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{labels.slice(0, 3).map((label: Record<string, unknown>, i: number) => (
|
|
||||||
<Badge
|
|
||||||
key={i}
|
|
||||||
variant="outline"
|
|
||||||
className="text-xs"
|
|
||||||
style={
|
|
||||||
label.color
|
|
||||||
? { backgroundColor: `#${label.color}`, color: "#fff" }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{String(label.name || "")}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{labels.length > 3 && (
|
|
||||||
<span className="text-xs text-slate-500">+{labels.length - 3}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "forgejo_updated_at",
|
|
||||||
header: "Updated",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
try {
|
|
||||||
return new Date(row.original.forgejo_updated_at).toLocaleDateString();
|
|
||||||
} catch {
|
|
||||||
return row.original.forgejo_updated_at;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / limit);
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardPageLayout
|
<DashboardPageLayout
|
||||||
signedOut={{
|
signedOut={{
|
||||||
message: "Sign in to view issues.",
|
message: "Sign in to view Git Project issues.",
|
||||||
forceRedirectUrl: "/git-projects/issues",
|
forceRedirectUrl: "/git-projects/issues",
|
||||||
signUpForceRedirectUrl: "/git-projects/issues",
|
signUpForceRedirectUrl: "/git-projects/issues",
|
||||||
}}
|
}}
|
||||||
title="Issues"
|
title="Git Project Issues"
|
||||||
description={`${total} issue${total === 1 ? "" : "s"} from tracked repositories.`}
|
description={`${total} issue${total === 1 ? "" : "s"} from repositories tracked by Pipeline.`}
|
||||||
stickyHeader
|
stickyHeader
|
||||||
>
|
>
|
||||||
<ForgejoIssueFilters
|
<ForgejoIssueFilters
|
||||||
stateFilter={stateFilter}
|
stateFilter={stateFilter}
|
||||||
onStateChange={(v) => { setStateFilter(v); setPage(1); }}
|
onStateChange={(v) => {
|
||||||
|
setStateFilter(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
repoFilter={repoFilter}
|
repoFilter={repoFilter}
|
||||||
onRepoChange={(v) => { setRepoFilter(v); setPage(1); }}
|
onRepoChange={(v) => {
|
||||||
|
setRepoFilter(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={(v) => { setSearch(v); setPage(1); }}
|
onSearchChange={(v) => {
|
||||||
|
setSearch(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
repos={repos}
|
repos={repos}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ForgejoIssuesTable issues={issues} onRefresh={handleRefresh} />
|
{error ? (
|
||||||
|
<div className="mb-4 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}
|
||||||
|
|
||||||
|
<ForgejoIssuesTable
|
||||||
|
issues={issues}
|
||||||
|
isLoading={isLoadingIssues}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
/>
|
||||||
|
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="mt-4 flex items-center justify-between text-sm text-slate-600 dark:text-slate-400">
|
<div className="mt-4 flex flex-col gap-3 text-sm text-muted sm:flex-row sm:items-center sm:justify-between">
|
||||||
<span>
|
<span className="break-words">
|
||||||
Page {page} of {totalPages} ({total} total)
|
Page {page} of {totalPages} ({total} total)
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<Button
|
||||||
className="rounded border px-3 py-1 disabled:opacity-50"
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
disabled={page <= 1}
|
disabled={page <= 1}
|
||||||
>
|
>
|
||||||
Previous
|
Previous
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
className="rounded border px-3 py-1 disabled:opacity-50"
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
disabled={page >= totalPages}
|
disabled={page >= totalPages}
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,13 @@ import {
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
|
import { AlertCircle, CheckCircle2, GitBranch, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { useAuth } from "@/auth/clerk";
|
|
||||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||||
import { DataTable } from "@/components/tables/DataTable";
|
import { DataTable } from "@/components/tables/DataTable";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
getForgejoRepositories,
|
getForgejoRepositories,
|
||||||
|
|
@ -23,9 +24,9 @@ import {
|
||||||
} from "@/lib/api-forgejo";
|
} from "@/lib/api-forgejo";
|
||||||
|
|
||||||
export default function GitProjectsPage() {
|
export default function GitProjectsPage() {
|
||||||
const _useAuth = useAuth();
|
|
||||||
const [repositories, setRepositories] = useState<ForgejoRepository[]>([]);
|
const [repositories, setRepositories] = useState<ForgejoRepository[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [syncingId, setSyncingId] = useState<string | null>(null);
|
const [syncingId, setSyncingId] = useState<string | null>(null);
|
||||||
const [syncResult, setSyncResult] = useState<{
|
const [syncResult, setSyncResult] = useState<{
|
||||||
repoName: string;
|
repoName: string;
|
||||||
|
|
@ -33,14 +34,20 @@ export default function GitProjectsPage() {
|
||||||
updated: number;
|
updated: number;
|
||||||
open: number;
|
open: number;
|
||||||
closed: number;
|
closed: number;
|
||||||
|
error?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const fetchRepos = useCallback(async () => {
|
const fetchRepos = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const repos = await getForgejoRepositories();
|
const repos = await getForgejoRepositories();
|
||||||
setRepositories(repos);
|
setRepositories(repos);
|
||||||
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch repositories:", err);
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Pipeline could not load Git Projects.",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -65,7 +72,14 @@ export default function GitProjectsPage() {
|
||||||
});
|
});
|
||||||
await fetchRepos();
|
await fetchRepos();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Sync failed:", err);
|
setSyncResult({
|
||||||
|
repoName: `${repo.owner}/${repo.repo}`,
|
||||||
|
created: 0,
|
||||||
|
updated: 0,
|
||||||
|
open: 0,
|
||||||
|
closed: 0,
|
||||||
|
error: err instanceof Error ? err.message : "Sync failed.",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSyncingId(null);
|
setSyncingId(null);
|
||||||
}
|
}
|
||||||
|
|
@ -80,14 +94,13 @@ export default function GitProjectsPage() {
|
||||||
header: "Repository",
|
header: "Repository",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const repo = row.original;
|
const repo = row.original;
|
||||||
const name =
|
const name = repo.display_name || `${repo.owner}/${repo.repo}`;
|
||||||
repo.display_name || `${repo.owner}/${repo.repo}`;
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="min-w-[180px]">
|
||||||
<div className="font-medium text-slate-900 dark:text-slate-100">
|
<div className="truncate font-medium text-strong" title={name}>
|
||||||
{name}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
<div className="truncate font-mono text-xs text-muted">
|
||||||
{repo.owner}/{repo.repo}
|
{repo.owner}/{repo.repo}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -99,22 +112,20 @@ export default function GitProjectsPage() {
|
||||||
header: "Connection",
|
header: "Connection",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const conn = row.original.connection;
|
const conn = row.original.connection;
|
||||||
return conn ? conn.name : "—";
|
return (
|
||||||
|
<span className="block max-w-[180px] truncate text-muted">
|
||||||
|
{conn ? conn.name : "Unassigned"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "active",
|
accessorKey: "active",
|
||||||
header: "Status",
|
header: "Status",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span
|
<Badge variant={row.original.active ? "success" : "outline"}>
|
||||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
|
||||||
row.original.active
|
|
||||||
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
|
|
||||||
: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{row.original.active ? "Active" : "Inactive"}
|
{row.original.active ? "Active" : "Inactive"}
|
||||||
</span>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -122,11 +133,15 @@ export default function GitProjectsPage() {
|
||||||
header: "Last Synced",
|
header: "Last Synced",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const val = row.original.last_sync_at;
|
const val = row.original.last_sync_at;
|
||||||
if (!val) return "—";
|
if (!val) return <span className="text-muted">Never</span>;
|
||||||
try {
|
try {
|
||||||
return new Date(val).toLocaleString();
|
return (
|
||||||
|
<span className="whitespace-nowrap text-muted">
|
||||||
|
{new Date(val).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return val;
|
return <span className="text-muted">{val}</span>;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -138,12 +153,16 @@ export default function GitProjectsPage() {
|
||||||
const isSyncing = syncingId === repo.id;
|
const isSyncing = syncingId === repo.id;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="whitespace-nowrap"
|
||||||
onClick={() => handleSync(repo)}
|
onClick={() => handleSync(repo)}
|
||||||
disabled={!!syncingId}
|
disabled={!!syncingId}
|
||||||
>
|
>
|
||||||
{isSyncing ? "Syncing…" : "Sync Issues"}
|
<RefreshCw
|
||||||
|
className={`h-4 w-4 ${isSyncing ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
{isSyncing ? "Syncing" : "Sync"}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -161,7 +180,7 @@ export default function GitProjectsPage() {
|
||||||
return (
|
return (
|
||||||
<DashboardPageLayout
|
<DashboardPageLayout
|
||||||
signedOut={{
|
signedOut={{
|
||||||
message: "Sign in to view Git projects.",
|
message: "Sign in to view Git Projects.",
|
||||||
forceRedirectUrl: "/git-projects",
|
forceRedirectUrl: "/git-projects",
|
||||||
signUpForceRedirectUrl: "/git-projects",
|
signUpForceRedirectUrl: "/git-projects",
|
||||||
}}
|
}}
|
||||||
|
|
@ -170,14 +189,33 @@ export default function GitProjectsPage() {
|
||||||
stickyHeader
|
stickyHeader
|
||||||
>
|
>
|
||||||
{syncResult && (
|
{syncResult && (
|
||||||
<div className="mb-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 dark:border-emerald-800 dark:bg-emerald-900/20 dark:text-emerald-300">
|
<div
|
||||||
<strong>{syncResult.repoName}</strong> synced:{" "}
|
className={`mb-4 flex items-start gap-3 rounded-xl border p-3 text-sm ${
|
||||||
{syncResult.created} created, {syncResult.updated} updated,{" "}
|
syncResult.error
|
||||||
{syncResult.open} open, {syncResult.closed} closed
|
? "border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] text-[color:var(--danger)]"
|
||||||
|
: "border-[color:rgba(52,211,153,0.35)] bg-[color:rgba(52,211,153,0.08)] text-[color:var(--success)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{syncResult.error ? (
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<strong className="break-words">{syncResult.repoName}</strong>{" "}
|
||||||
|
{syncResult.error ? (
|
||||||
|
<span>{syncResult.error}</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
synced: {syncResult.created} created, {syncResult.updated}{" "}
|
||||||
|
updated, {syncResult.open} open, {syncResult.closed} closed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mb-4 flex gap-3">
|
<div className="mb-4 flex flex-wrap gap-3">
|
||||||
<Link href="/git-projects/connections">
|
<Link href="/git-projects/connections">
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
Connections
|
Connections
|
||||||
|
|
@ -190,28 +228,21 @@ export default function GitProjectsPage() {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-900">
|
{error ? (
|
||||||
|
<div className="surface-muted mb-4 rounded-xl p-4 text-sm text-[color:var(--danger)]">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-lush">
|
||||||
<DataTable
|
<DataTable
|
||||||
table={table}
|
table={table}
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
emptyState={{
|
emptyState={{
|
||||||
icon: (
|
icon: <GitBranch className="h-12 w-12" />,
|
||||||
<svg
|
|
||||||
className="h-16 w-16 text-slate-300"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M9 11l3 3L22 4" />
|
|
||||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
title: "No repositories tracked yet",
|
title: "No repositories tracked yet",
|
||||||
description:
|
description:
|
||||||
"Connect a Forgejo instance and add repositories to start tracking issues.",
|
"Connect a Git provider and add repositories so Pipeline can track issues for Git Projects.",
|
||||||
actionHref: "/git-projects/connections",
|
actionHref: "/git-projects/connections",
|
||||||
actionLabel: "Set up connection",
|
actionLabel: "Set up connection",
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { useAuth } from "@/auth/clerk";
|
import { useAuth } from "@/auth/clerk";
|
||||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||||
import { ForgejoRepositoryForm } from "@/components/git/ForgejoRepositoryForm";
|
import { ForgejoRepositoryForm } from "@/components/git/ForgejoRepositoryForm";
|
||||||
|
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
||||||
import {
|
import {
|
||||||
getForgejoRepository,
|
getForgejoRepository,
|
||||||
updateForgejoRepository,
|
updateForgejoRepository,
|
||||||
|
|
@ -33,13 +34,20 @@ interface RepositoryData {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ForgejoRepositoriesEditPage({ params }: { params: RouteParams }) {
|
export default function ForgejoRepositoriesEditPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: RouteParams;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
|
||||||
const [repository, setRepository] = useState<RepositoryData | null>(null);
|
const [repository, setRepository] = useState<RepositoryData | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchRepository = async () => {
|
const fetchRepository = async () => {
|
||||||
|
|
@ -49,7 +57,9 @@ export default function ForgejoRepositoriesEditPage({ params }: { params: RouteP
|
||||||
setRepository(data);
|
setRepository(data);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to load repository");
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Failed to load repository",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -61,26 +71,22 @@ export default function ForgejoRepositoriesEditPage({ params }: { params: RouteP
|
||||||
}, [params.repositoryId, auth.isSignedIn]);
|
}, [params.repositoryId, auth.isSignedIn]);
|
||||||
|
|
||||||
const handleSubmit = async (values: ForgejoRepositoryUpdate) => {
|
const handleSubmit = async (values: ForgejoRepositoryUpdate) => {
|
||||||
try {
|
await updateForgejoRepository(params.repositoryId, values);
|
||||||
const repository = await updateForgejoRepository(params.repositoryId, values);
|
|
||||||
console.log("Repository updated:", repository);
|
|
||||||
router.push("/git-projects/repositories");
|
router.push("/git-projects/repositories");
|
||||||
} catch (err) {
|
|
||||||
alert(err instanceof Error ? err.message : "Failed to update repository");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (
|
setIsDeleting(true);
|
||||||
confirm(`Are you sure you want to delete "${repository?.display_name || repository?.repo}"? This action cannot be undone.`)
|
setDeleteError(null);
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
await deleteForgejoRepository(params.repositoryId);
|
await deleteForgejoRepository(params.repositoryId);
|
||||||
console.log("Repository deleted");
|
|
||||||
router.push("/git-projects/repositories");
|
router.push("/git-projects/repositories");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err instanceof Error ? err.message : "Failed to delete repository");
|
setDeleteError(
|
||||||
}
|
err instanceof Error ? err.message : "Failed to delete repository",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -92,10 +98,10 @@ export default function ForgejoRepositoriesEditPage({ params }: { params: RouteP
|
||||||
forceRedirectUrl: "/git-projects/repositories",
|
forceRedirectUrl: "/git-projects/repositories",
|
||||||
signUpForceRedirectUrl: "/git-projects/repositories",
|
signUpForceRedirectUrl: "/git-projects/repositories",
|
||||||
}}
|
}}
|
||||||
title="Loading..."
|
title="Loading…"
|
||||||
stickyHeader
|
stickyHeader
|
||||||
>
|
>
|
||||||
<p className="text-slate-500">Loading repository...</p>
|
<p className="text-muted">Loading repository…</p>
|
||||||
</DashboardPageLayout>
|
</DashboardPageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -111,7 +117,9 @@ export default function ForgejoRepositoriesEditPage({ params }: { params: RouteP
|
||||||
title="Error"
|
title="Error"
|
||||||
stickyHeader
|
stickyHeader
|
||||||
>
|
>
|
||||||
<p className="text-red-600">{error || "Repository not found"}</p>
|
<p className="text-[color:var(--danger)]">
|
||||||
|
{error || "Repository not found"}
|
||||||
|
</p>
|
||||||
</DashboardPageLayout>
|
</DashboardPageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -131,30 +139,46 @@ export default function ForgejoRepositoriesEditPage({ params }: { params: RouteP
|
||||||
forceRedirectUrl: "/git-projects/repositories",
|
forceRedirectUrl: "/git-projects/repositories",
|
||||||
signUpForceRedirectUrl: "/git-projects/repositories",
|
signUpForceRedirectUrl: "/git-projects/repositories",
|
||||||
}}
|
}}
|
||||||
title={`Edit Repository: ${repository.display_name || repository.repo}`}
|
title={`Edit Git Project Repository: ${repository.display_name || repository.repo}`}
|
||||||
description="Update repository settings and tracking options."
|
description="Update the repository settings Pipeline uses for Git Projects."
|
||||||
stickyHeader
|
stickyHeader
|
||||||
>
|
>
|
||||||
<div className="max-w-2xl">
|
<div className="w-full max-w-2xl">
|
||||||
<ForgejoRepositoryForm
|
<ForgejoRepositoryForm
|
||||||
defaultValues={defaultValues}
|
defaultValues={defaultValues}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
submitLabel="Save Changes"
|
submitLabel="Save Changes"
|
||||||
/>
|
/>
|
||||||
<div className="mt-8 border-t pt-6">
|
<div className="surface-muted mt-6 rounded-2xl p-4 sm:p-5">
|
||||||
<h3 className="text-lg font-semibold text-red-900 dark:text-red-100">Danger Zone</h3>
|
<h3 className="text-base font-semibold text-[color:var(--danger)]">
|
||||||
<p className="mt-2 text-sm text-slate-500">
|
Delete Repository
|
||||||
Deleting a repository will remove all associated data including issues and pull requests.
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-muted">
|
||||||
|
Remove this repository from Pipeline. Synced issue records for this
|
||||||
|
repository will be removed.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-950 dark:hover:text-red-300"
|
className="mt-4 border-[color:rgba(248,113,113,0.45)] text-[color:var(--danger)] hover:bg-[color:rgba(248,113,113,0.08)]"
|
||||||
onClick={handleDelete}
|
onClick={() => setDeleteOpen(true)}
|
||||||
>
|
>
|
||||||
Delete Repository
|
Delete Repository
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={setDeleteOpen}
|
||||||
|
title="Delete Git Project repository"
|
||||||
|
description={`Delete "${repository.display_name || repository.repo}" from Pipeline? Synced issue records for this repository will be removed.`}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
isConfirming={isDeleting}
|
||||||
|
errorMessage={deleteError}
|
||||||
|
confirmLabel="Delete Repository"
|
||||||
|
confirmingLabel="Deleting…"
|
||||||
|
confirmClassName="bg-[color:var(--danger)] text-white hover:bg-[color:var(--danger)]/90"
|
||||||
|
cancelLabel="Keep Repository"
|
||||||
|
/>
|
||||||
</DashboardPageLayout>
|
</DashboardPageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,33 +4,31 @@ import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||||
import { ForgejoRepositoryForm } from "@/components/git/ForgejoRepositoryForm";
|
import { ForgejoRepositoryForm } from "@/components/git/ForgejoRepositoryForm";
|
||||||
import { createForgejoRepository, type ForgejoRepositoryCreate } from "@/lib/api-forgejo";
|
import {
|
||||||
|
createForgejoRepository,
|
||||||
|
type ForgejoRepositoryCreate,
|
||||||
|
} from "@/lib/api-forgejo";
|
||||||
|
|
||||||
export default function ForgejoRepositoriesNewPage() {
|
export default function ForgejoRepositoriesNewPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const handleSubmit = async (values: ForgejoRepositoryCreate) => {
|
const handleSubmit = async (values: ForgejoRepositoryCreate) => {
|
||||||
try {
|
await createForgejoRepository(values);
|
||||||
const repository = await createForgejoRepository(values);
|
|
||||||
alert(`Repository "${repository.display_name || repository.repo}" added successfully`);
|
|
||||||
router.push("/git-projects/repositories");
|
router.push("/git-projects/repositories");
|
||||||
} catch (err) {
|
|
||||||
alert(err instanceof Error ? err.message : "Failed to add repository");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardPageLayout
|
<DashboardPageLayout
|
||||||
signedOut={{
|
signedOut={{
|
||||||
message: "Sign in to add a tracked repository.",
|
message: "Sign in to add a Git Project repository.",
|
||||||
forceRedirectUrl: "/git-projects/repositories/new",
|
forceRedirectUrl: "/git-projects/repositories/new",
|
||||||
signUpForceRedirectUrl: "/git-projects/repositories/new",
|
signUpForceRedirectUrl: "/git-projects/repositories/new",
|
||||||
}}
|
}}
|
||||||
title="Add Tracked Repository"
|
title="Add Git Project Repository"
|
||||||
description="Add a repository to track issues and pull requests from your Forgejo instance."
|
description="Add a repository for Pipeline to sync and display in Git Projects."
|
||||||
stickyHeader
|
stickyHeader
|
||||||
>
|
>
|
||||||
<div className="max-w-2xl">
|
<div className="w-full max-w-2xl">
|
||||||
<ForgejoRepositoryForm onSubmit={handleSubmit} />
|
<ForgejoRepositoryForm onSubmit={handleSubmit} />
|
||||||
</div>
|
</div>
|
||||||
</DashboardPageLayout>
|
</DashboardPageLayout>
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useAuth } from "@/auth/clerk";
|
import { useAuth } from "@/auth/clerk";
|
||||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||||
import { ForgejoRepositoriesTable } from "@/components/git/ForgejoRepositoriesTable";
|
import { ForgejoRepositoriesTable } from "@/components/git/ForgejoRepositoriesTable";
|
||||||
|
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
||||||
import {
|
import {
|
||||||
getForgejoRepositories,
|
getForgejoRepositories,
|
||||||
deleteForgejoRepository,
|
deleteForgejoRepository,
|
||||||
|
|
@ -22,6 +24,15 @@ export default function ForgejoRepositoriesPage() {
|
||||||
const [repositories, setRepositories] = useState<ForgejoRepository[]>([]);
|
const [repositories, setRepositories] = useState<ForgejoRepository[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<{
|
||||||
|
tone: "success" | "error";
|
||||||
|
message: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ForgejoRepository | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchRepositories = async () => {
|
const fetchRepositories = async () => {
|
||||||
|
|
@ -31,7 +42,9 @@ export default function ForgejoRepositoriesPage() {
|
||||||
setRepositories(data);
|
setRepositories(data);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to load repositories");
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Failed to load repositories",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -42,37 +55,52 @@ export default function ForgejoRepositoriesPage() {
|
||||||
}
|
}
|
||||||
}, [auth.isSignedIn, auth.getToken]);
|
}, [auth.isSignedIn, auth.getToken]);
|
||||||
|
|
||||||
const handleDelete = async (repository: ForgejoRepository) => {
|
const repositoryName = (repository: ForgejoRepository) =>
|
||||||
if (
|
repository.display_name || `${repository.owner}/${repository.repo}`;
|
||||||
confirm(`Are you sure you want to delete "${repository.display_name || repository.repo}"? This action cannot be undone.`)
|
|
||||||
) {
|
const handleDelete = (repository: ForgejoRepository) => {
|
||||||
|
setDeleteError(null);
|
||||||
|
setDeleteTarget(repository);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
setDeleteError(null);
|
||||||
try {
|
try {
|
||||||
await deleteForgejoRepository(repository.id);
|
await deleteForgejoRepository(deleteTarget.id);
|
||||||
setRepositories((prev) => prev.filter((r) => r.id !== repository.id));
|
setRepositories((prev) => prev.filter((r) => r.id !== deleteTarget.id));
|
||||||
alert("Repository deleted successfully");
|
setNotice({
|
||||||
|
tone: "success",
|
||||||
|
message: `Deleted "${repositoryName(deleteTarget)}".`,
|
||||||
|
});
|
||||||
|
setDeleteTarget(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err instanceof Error ? err.message : "Failed to delete repository");
|
setDeleteError(
|
||||||
}
|
err instanceof Error ? err.message : "Failed to delete repository",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSync = async (repository: ForgejoRepository) => {
|
const handleSync = async (repository: ForgejoRepository) => {
|
||||||
try {
|
try {
|
||||||
const result = await syncRepository(repository.id);
|
const result = await syncRepository(repository.id);
|
||||||
alert(
|
setNotice({
|
||||||
`Sync completed!\n\n` +
|
tone: "success",
|
||||||
`Created: ${result.created}\n` +
|
message: `${repositoryName(repository)} synced: ${result.created} created, ${result.updated} updated, ${result.open} open, ${result.closed} closed.`,
|
||||||
`Updated: ${result.updated}\n` +
|
});
|
||||||
`Open: ${result.open}\n` +
|
|
||||||
`Closed: ${result.closed}\n` +
|
|
||||||
`Total: ${result.total}`
|
|
||||||
);
|
|
||||||
// Refetch to update last_sync_at
|
// Refetch to update last_sync_at
|
||||||
const data = await getForgejoRepositories();
|
const data = await getForgejoRepositories();
|
||||||
setRepositories(data);
|
setRepositories(data);
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err instanceof Error ? err.message : "Failed to sync repository");
|
setNotice({
|
||||||
|
tone: "error",
|
||||||
|
message:
|
||||||
|
err instanceof Error ? err.message : "Failed to sync repository",
|
||||||
|
});
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -80,47 +108,70 @@ export default function ForgejoRepositoriesPage() {
|
||||||
const handleValidateRepository = async (repository: ForgejoRepository) => {
|
const handleValidateRepository = async (repository: ForgejoRepository) => {
|
||||||
try {
|
try {
|
||||||
const result = await validateRepository(repository.id);
|
const result = await validateRepository(repository.id);
|
||||||
if (result.ok) {
|
if (result.status.ok) {
|
||||||
alert(
|
setNotice({
|
||||||
`Repository is valid!\n\n` +
|
tone: "success",
|
||||||
`Repository exists: ${result.repo_exists ? "Yes" : "No"}`
|
message: `${repositoryName(repository)} is reachable from Pipeline.`,
|
||||||
);
|
});
|
||||||
} else {
|
} else {
|
||||||
alert(
|
setNotice({
|
||||||
`Repository validation failed: ${result.error_message || "Unknown error"}`
|
tone: "error",
|
||||||
);
|
message: `Repository validation failed: ${result.status.error_message || "Unknown error"}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err instanceof Error ? err.message : "Failed to validate repository");
|
setNotice({
|
||||||
|
tone: "error",
|
||||||
|
message:
|
||||||
|
err instanceof Error ? err.message : "Failed to validate repository",
|
||||||
|
});
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<DashboardPageLayout
|
<DashboardPageLayout
|
||||||
signedOut={{
|
signedOut={{
|
||||||
message: "Sign in to manage tracked repositories.",
|
message: "Sign in to manage Git Project repositories.",
|
||||||
forceRedirectUrl: "/git-projects/repositories",
|
forceRedirectUrl: "/git-projects/repositories",
|
||||||
signUpForceRedirectUrl: "/git-projects/repositories",
|
signUpForceRedirectUrl: "/git-projects/repositories",
|
||||||
}}
|
}}
|
||||||
title="Tracked Repositories"
|
title="Git Project Repositories"
|
||||||
description={`${repositories.length} repository${repositories.length === 1 ? "" : "s"} being tracked.`}
|
description={`${repositories.length} repositor${repositories.length === 1 ? "y" : "ies"} tracked by Pipeline.`}
|
||||||
stickyHeader
|
stickyHeader
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
{notice ? (
|
||||||
<h2 className="text-sm font-medium text-slate-500">Repositories</h2>
|
<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>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 className="text-sm font-medium text-muted">Repositories</h2>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => router.push("/git-projects/repositories/new")}
|
onClick={() => router.push("/git-projects/repositories/new")}
|
||||||
>
|
>
|
||||||
Add Repository
|
Add Repository
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-lush">
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
<p className="text-red-600">{error}</p>
|
<p className="text-sm text-[color:var(--danger)]">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ForgejoRepositoriesTable
|
<ForgejoRepositoriesTable
|
||||||
|
|
@ -134,5 +185,25 @@ export default function ForgejoRepositoriesPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DashboardPageLayout>
|
</DashboardPageLayout>
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={Boolean(deleteTarget)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDeleteTarget(null);
|
||||||
|
}}
|
||||||
|
title="Delete Git Project repository"
|
||||||
|
description={
|
||||||
|
deleteTarget
|
||||||
|
? `Delete "${repositoryName(deleteTarget)}" from Pipeline? Synced issue records for this repository will be removed.`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
isConfirming={isDeleting}
|
||||||
|
errorMessage={deleteError}
|
||||||
|
confirmLabel="Delete Repository"
|
||||||
|
confirmingLabel="Deleting…"
|
||||||
|
confirmClassName="bg-[color:var(--danger)] text-white hover:bg-[color:var(--danger)]/90"
|
||||||
|
cancelLabel="Keep Repository"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,100 @@
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* Georgia font for numbers only - subset to digits and common number symbols */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Georgia Numbers";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src:
|
||||||
|
local("Georgia"),
|
||||||
|
local("Georgia-Regular"),
|
||||||
|
url("/fonts/georgia-regular.woff2") format("woff2");
|
||||||
|
unicode-range:
|
||||||
|
U+0030-0039,
|
||||||
|
U+00B9,
|
||||||
|
U+00B2,
|
||||||
|
U+00B3,
|
||||||
|
U+2030,
|
||||||
|
U+2070,
|
||||||
|
U+2074-2079,
|
||||||
|
U+2080-2089,
|
||||||
|
U+2150-215F,
|
||||||
|
U+2160-2188,
|
||||||
|
U+2189-2189;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Georgia Numbers";
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src:
|
||||||
|
local("Georgia Italic"),
|
||||||
|
local("Georgia-Italic"),
|
||||||
|
url("/fonts/georgia-italic.woff2") format("woff2");
|
||||||
|
unicode-range:
|
||||||
|
U+0030-0039,
|
||||||
|
U+00B9,
|
||||||
|
U+00B2,
|
||||||
|
U+00B3,
|
||||||
|
U+2030,
|
||||||
|
U+2070,
|
||||||
|
U+2074-2079,
|
||||||
|
U+2080-2089,
|
||||||
|
U+2150-215F,
|
||||||
|
U+2160-2188,
|
||||||
|
U+2189-2189;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Georgia Numbers";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src:
|
||||||
|
local("Georgia Bold"),
|
||||||
|
local("Georgia-Bold"),
|
||||||
|
url("/fonts/georgia-bold.woff2") format("woff2");
|
||||||
|
unicode-range:
|
||||||
|
U+0030-0039,
|
||||||
|
U+00B9,
|
||||||
|
U+00B2,
|
||||||
|
U+00B3,
|
||||||
|
U+2030,
|
||||||
|
U+2070,
|
||||||
|
U+2074-2079,
|
||||||
|
U+2080-2089,
|
||||||
|
U+2150-215F,
|
||||||
|
U+2160-2188,
|
||||||
|
U+2189-2189;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Georgia Numbers";
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src:
|
||||||
|
local("Georgia Bold Italic"),
|
||||||
|
local("Georgia-Bold-Italic"),
|
||||||
|
url("/fonts/georgia-bold-italic.woff2") format("woff2");
|
||||||
|
unicode-range:
|
||||||
|
U+0030-0039,
|
||||||
|
U+00B9,
|
||||||
|
U+00B2,
|
||||||
|
U+00B3,
|
||||||
|
U+2030,
|
||||||
|
U+2070,
|
||||||
|
U+2074-2079,
|
||||||
|
U+2080-2089,
|
||||||
|
U+2150-215F,
|
||||||
|
U+2160-2188,
|
||||||
|
U+2189-2189;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--bg: #070b12;
|
--bg: #070b12;
|
||||||
|
|
@ -160,6 +254,10 @@ body {
|
||||||
);
|
);
|
||||||
background-size: 120px 120px;
|
background-size: 120px 120px;
|
||||||
}
|
}
|
||||||
|
/* Numbers-only Georgia font utility */
|
||||||
|
.font-numeric {
|
||||||
|
font-family: "Georgia Numbers", "Georgia", "Times New Roman", serif;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-page {
|
.landing-page {
|
||||||
|
|
|
||||||
|
|
@ -1,227 +1,357 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState, useEffect, useCallback } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { AlertCircle, GitBranch, Loader2, X } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
||||||
import { type ForgejoRepository, getForgejoRepositories, linkBoardForgejoRepository, unlinkBoardForgejoRepository, getBoardForgejoRepositories } from "@/lib/api-forgejo";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
type BoardForgejoRepositoriesResponse,
|
||||||
|
type BoardForgejoRepositoryLink,
|
||||||
|
type ForgejoRepository,
|
||||||
|
getBoardForgejoRepositories,
|
||||||
|
getForgejoRepositories,
|
||||||
|
linkBoardForgejoRepository,
|
||||||
|
unlinkBoardForgejoRepository,
|
||||||
|
} from "@/lib/api-forgejo";
|
||||||
|
|
||||||
type BoardForgejoRepositoryLink = {
|
interface BoardForgejoRepositoryLinksProps {
|
||||||
id: string;
|
boardId: string;
|
||||||
board_id: string;
|
canWrite?: boolean;
|
||||||
repository_id: string;
|
}
|
||||||
organization_id: string;
|
|
||||||
created_at: string;
|
type LinkedRepository = BoardForgejoRepositoryLink & {
|
||||||
repository: ForgejoRepository;
|
repository: ForgejoRepository;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BoardForgejoRepositoryLinksProps = {
|
const normalizeBoardLinks = (
|
||||||
boardId: string;
|
result: BoardForgejoRepositoriesResponse,
|
||||||
canWrite: boolean;
|
): BoardForgejoRepositoryLink[] =>
|
||||||
};
|
Array.isArray(result) ? result : (result.repositories ?? []);
|
||||||
|
|
||||||
|
const repositoryDisplayName = (repository: ForgejoRepository): string =>
|
||||||
|
repository.display_name || `${repository.owner}/${repository.repo}`;
|
||||||
|
|
||||||
export function BoardForgejoRepositoryLinks({
|
export function BoardForgejoRepositoryLinks({
|
||||||
boardId,
|
boardId,
|
||||||
canWrite,
|
canWrite = false,
|
||||||
}: BoardForgejoRepositoryLinksProps) {
|
}: BoardForgejoRepositoryLinksProps) {
|
||||||
const [linkedRepos, setLinkedRepos] = useState<BoardForgejoRepositoryLink[]>([]);
|
const [linkedLinks, setLinkedLinks] = useState<BoardForgejoRepositoryLink[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
const [allRepos, setAllRepos] = useState<ForgejoRepository[]>([]);
|
const [allRepos, setAllRepos] = useState<ForgejoRepository[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isLinking, setIsLinking] = useState(false);
|
const [linkError, setLinkError] = useState<string | null>(null);
|
||||||
const [unlinkRepo, setUnlinkRepo] = useState<string | null>(null);
|
|
||||||
const [unlinkError, setUnlinkError] = useState<string | null>(null);
|
const [unlinkError, setUnlinkError] = useState<string | null>(null);
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isLinking, setIsLinking] = useState(false);
|
||||||
|
const [isUnlinking, setIsUnlinking] = useState(false);
|
||||||
|
const [unlinkTarget, setUnlinkTarget] = useState<LinkedRepository | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const fetchLinkedRepos = useCallback(async () => {
|
const fetchLinkedRepos = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await getBoardForgejoRepositories(boardId);
|
const result = await getBoardForgejoRepositories(boardId);
|
||||||
setLinkedRepos(result.repositories || []);
|
setLinkedLinks(normalizeBoardLinks(result));
|
||||||
|
setLinkError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch linked repositories:", err);
|
const message =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Unable to load linked Git Project repositories.";
|
||||||
|
setLinkError(message);
|
||||||
}
|
}
|
||||||
}, [boardId]);
|
}, [boardId]);
|
||||||
|
|
||||||
const fetchAllRepositories = useCallback(async () => {
|
const fetchAllRepositories = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const repos = await getForgejoRepositories();
|
const repos = await getForgejoRepositories();
|
||||||
setAllRepos(repos);
|
setAllRepos(repos);
|
||||||
|
setLinkError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch repositories:", err);
|
const message =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Unable to load available Git Project repositories.";
|
||||||
|
setLinkError(message);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
let isMounted = true;
|
||||||
Promise.all([fetchLinkedRepos(), fetchAllRepositories()]).finally(() => {
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}, [boardId, fetchLinkedRepos, fetchAllRepositories]);
|
|
||||||
|
|
||||||
|
const loadRepositories = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
await Promise.all([fetchLinkedRepos(), fetchAllRepositories()]);
|
||||||
|
if (isMounted) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filteredRepos = useMemo(() => {
|
loadRepositories();
|
||||||
if (!searchQuery) return allRepos;
|
|
||||||
const query = searchQuery.toLowerCase();
|
return () => {
|
||||||
return allRepos.filter(
|
isMounted = false;
|
||||||
(r) =>
|
};
|
||||||
(r.display_name && r.display_name.toLowerCase().includes(query)) ||
|
}, [fetchAllRepositories, fetchLinkedRepos]);
|
||||||
r.owner.toLowerCase().includes(query) ||
|
|
||||||
r.repo.toLowerCase().includes(query),
|
const linkedRepoIds = useMemo(
|
||||||
|
() => new Set(linkedLinks.map((link) => link.repository_id)),
|
||||||
|
[linkedLinks],
|
||||||
);
|
);
|
||||||
}, [allRepos, searchQuery]);
|
|
||||||
|
const repoById = useMemo(
|
||||||
|
() => new Map(allRepos.map((repository) => [repository.id, repository])),
|
||||||
|
[allRepos],
|
||||||
|
);
|
||||||
|
|
||||||
|
const linkedRepos = useMemo(
|
||||||
|
() =>
|
||||||
|
linkedLinks
|
||||||
|
.map((link) => ({
|
||||||
|
...link,
|
||||||
|
repository: link.repository ?? repoById.get(link.repository_id),
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
(link): link is LinkedRepository => link.repository !== undefined,
|
||||||
|
),
|
||||||
|
[linkedLinks, repoById],
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableRepos = useMemo(() => {
|
||||||
|
const query = searchTerm.toLowerCase().trim();
|
||||||
|
|
||||||
|
return allRepos
|
||||||
|
.filter((repository) => !linkedRepoIds.has(repository.id))
|
||||||
|
.filter((repository) => {
|
||||||
|
if (!query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const haystack = [
|
||||||
|
repositoryDisplayName(repository),
|
||||||
|
repository.owner,
|
||||||
|
repository.repo,
|
||||||
|
]
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
return haystack.includes(query);
|
||||||
|
});
|
||||||
|
}, [allRepos, linkedRepoIds, searchTerm]);
|
||||||
|
|
||||||
const handleLinkRepo = async (repositoryId: string) => {
|
const handleLinkRepo = async (repositoryId: string) => {
|
||||||
if (!canWrite) return;
|
if (!canWrite) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLinking(true);
|
setIsLinking(true);
|
||||||
|
setLinkError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await linkBoardForgejoRepository(boardId, repositoryId);
|
await linkBoardForgejoRepository(boardId, repositoryId);
|
||||||
await fetchLinkedRepos();
|
await fetchLinkedRepos();
|
||||||
setSearchQuery("");
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to link repository:", err);
|
const message =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Unable to link this repository to the board.";
|
||||||
|
setLinkError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLinking(false);
|
setIsLinking(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUnlinkRepo = async () => {
|
const handleUnlinkRepo = async () => {
|
||||||
if (!unlinkRepo) return;
|
if (!unlinkTarget || !canWrite) {
|
||||||
setIsDialogOpen(false);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUnlinking(true);
|
||||||
setUnlinkError(null);
|
setUnlinkError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await unlinkBoardForgejoRepository(boardId, unlinkRepo);
|
await unlinkBoardForgejoRepository(boardId, unlinkTarget.repository_id);
|
||||||
await fetchLinkedRepos();
|
await fetchLinkedRepos();
|
||||||
|
setUnlinkTarget(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Failed to unlink repository";
|
const message =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Unable to unlink this repository from the board.";
|
||||||
setUnlinkError(message);
|
setUnlinkError(message);
|
||||||
|
} finally {
|
||||||
|
setIsUnlinking(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const linkedRepoIds = useMemo(() => new Set(linkedRepos.map((l) => l.repository_id)), [linkedRepos]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white p-6 dark:border-slate-700 dark:bg-slate-900">
|
<>
|
||||||
<div className="mb-6">
|
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush sm:p-5">
|
||||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
Linked Repositories
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-sm font-semibold text-strong">
|
||||||
|
Linked Git Project Repositories
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
<p className="mt-1 text-sm text-muted">
|
||||||
{linkedRepos.length} repository{linkedRepos.length === 1 ? "" : "s"} linked to this board
|
Choose which synced repositories appear on this Pipeline board.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Badge variant="outline" className="w-fit">
|
||||||
|
{linkedRepos.length} linked
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-6 flex flex-wrap items-center gap-3">
|
{linkError && (
|
||||||
<Input
|
<div className="mb-4 flex items-start gap-2 rounded-lg border border-[color:var(--danger)]/35 bg-[color:var(--danger-soft)] px-3 py-2 text-sm text-[color:var(--danger)]">
|
||||||
placeholder="Search repositories…"
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
value={searchQuery}
|
<span>{linkError}</span>
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
</div>
|
||||||
className="w-[240px]"
|
|
||||||
/>
|
|
||||||
{canWrite && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsDialogOpen(true)}
|
|
||||||
disabled={isLinking}
|
|
||||||
>
|
|
||||||
Link Repository
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
{isLoading ? (
|
||||||
<div className="py-8 text-center text-sm text-slate-500">Loading…</div>
|
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-4 py-6 text-center text-sm text-muted">
|
||||||
) : linkedRepos.length === 0 && allRepos.length === 0 ? (
|
<Loader2 className="mx-auto mb-2 h-4 w-4 animate-spin" />
|
||||||
<div className="py-8 text-center">
|
Loading Git Project repositories...
|
||||||
<p className="text-sm text-slate-500">
|
|
||||||
No repositories found. Configure Forgejo connections in Git Projects to start tracking repositories.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : linkedRepos.length === 0 ? (
|
) : (
|
||||||
<div className="py-8 text-center">
|
<div className="space-y-5">
|
||||||
<p className="text-sm text-slate-500">
|
<div>
|
||||||
No repositories linked to this board. Link a repository to track its issues.
|
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-muted">
|
||||||
|
On This Board
|
||||||
|
</div>
|
||||||
|
{linkedRepos.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] px-4 py-5 text-center">
|
||||||
|
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full border border-[color:var(--border)] bg-[color:var(--surface)] text-muted">
|
||||||
|
<GitBranch className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-strong">
|
||||||
|
No repositories linked yet
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-muted">
|
||||||
|
{canWrite
|
||||||
|
? "Link a Git Project repository below to bring its issues onto this board."
|
||||||
|
: "No Git Project repositories are linked to this board yet."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
{linkedRepos.map((link) => (
|
{linkedRepos.map((link) => {
|
||||||
|
const repository = link.repository;
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={link.id}
|
key={link.id}
|
||||||
className="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800/50"
|
className="flex min-w-0 items-center justify-between gap-3 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="min-w-0">
|
||||||
<div>
|
<p
|
||||||
<div className="font-medium text-slate-900 dark:text-slate-100">
|
className="truncate text-sm font-medium text-strong"
|
||||||
{link.repository.display_name || `${link.repository.owner}/${link.repository.repo}`}
|
title={repositoryDisplayName(repository)}
|
||||||
|
>
|
||||||
|
{repositoryDisplayName(repository)}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="truncate text-xs text-muted"
|
||||||
|
title={`${repository.owner}/${repository.repo}`}
|
||||||
|
>
|
||||||
|
{repository.owner}/{repository.repo}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-slate-500">
|
{canWrite ? (
|
||||||
Last sync: {link.repository.last_sync_at ? new Date(link.repository.last_sync_at).toLocaleDateString() : "Never"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{canWrite && (
|
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="h-8 w-8 shrink-0 p-0 text-[color:var(--danger)] hover:bg-[color:var(--danger-soft)]"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUnlinkRepo(link.repository_id);
|
setUnlinkError(null);
|
||||||
setIsDialogOpen(true);
|
setUnlinkTarget(link);
|
||||||
}}
|
}}
|
||||||
className="h-6 w-6 p-0 text-rose-500 hover:bg-rose-50 hover:text-rose-600"
|
aria-label={`Unlink ${repositoryDisplayName(repository)}`}
|
||||||
title="Unlink repository"
|
|
||||||
>
|
>
|
||||||
<svg
|
<X className="h-4 w-4" />
|
||||||
className="h-4 w-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M18 6L6 18M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{canWrite && (
|
{canWrite ? (
|
||||||
<>
|
<div>
|
||||||
<div className="mt-6 border-t border-slate-200 pt-6 dark:border-slate-700">
|
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h4 className="mb-3 text-sm font-medium text-slate-900 dark:text-slate-100">
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-muted">
|
||||||
Available Repositories
|
Available Repositories
|
||||||
</h4>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{filteredRepos
|
|
||||||
.filter((r) => !linkedRepoIds.has(r.id))
|
|
||||||
.slice(0, 9)
|
|
||||||
.map((repo) => (
|
|
||||||
<div
|
|
||||||
key={repo.id}
|
|
||||||
className="rounded-lg border border-slate-200 bg-slate-50 p-4 hover:border-blue-300 hover:bg-blue-50/50 dark:border-slate-700 dark:bg-slate-800/50 dark:hover:border-blue-700 dark:hover:bg-blue-900/20"
|
|
||||||
>
|
|
||||||
<div className="font-medium text-slate-900 dark:text-slate-100">
|
|
||||||
{repo.display_name || `${repo.owner}/${repo.repo}`}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<p className="mt-1 text-sm text-muted">
|
||||||
<Badge variant="outline" className="text-xs">
|
Link repositories that are already configured in Git
|
||||||
{repo.active ? "Active" : "Inactive"}
|
Projects.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(event) => setSearchTerm(event.target.value)}
|
||||||
|
placeholder="Search repositories..."
|
||||||
|
className="w-full sm:w-72"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{availableRepos.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] px-4 py-5 text-center">
|
||||||
|
<p className="text-sm font-medium text-strong">
|
||||||
|
{allRepos.length === 0
|
||||||
|
? "No Git Project repositories configured"
|
||||||
|
: "No matching repositories"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-muted">
|
||||||
|
{allRepos.length === 0
|
||||||
|
? "Add repositories in Git Projects before linking them to boards."
|
||||||
|
: "Adjust the search or unlink a repository from this board."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2 lg:grid-cols-2">
|
||||||
|
{availableRepos.slice(0, 9).map((repository) => (
|
||||||
|
<div
|
||||||
|
key={repository.id}
|
||||||
|
className="flex min-w-0 flex-col gap-3 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-3 transition-colors hover:bg-[color:var(--accent-soft)] sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<p
|
||||||
|
className="truncate text-sm font-medium text-strong"
|
||||||
|
title={repositoryDisplayName(repository)}
|
||||||
|
>
|
||||||
|
{repositoryDisplayName(repository)}
|
||||||
|
</p>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
repository.active ? "success" : "outline"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{repository.active ? "Active" : "Paused"}
|
||||||
</Badge>
|
</Badge>
|
||||||
{repo.last_sync_at && (
|
</div>
|
||||||
<span className="text-xs text-slate-500">
|
<p
|
||||||
Synced {new Date(repo.last_sync_at).toLocaleDateString()}
|
className="mt-1 truncate text-xs text-muted"
|
||||||
</span>
|
title={`${repository.owner}/${repository.repo}`}
|
||||||
)}
|
>
|
||||||
|
{repository.owner}/{repository.repo}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="mt-3 w-full text-xs"
|
className="w-full shrink-0 sm:w-auto"
|
||||||
onClick={() => handleLinkRepo(repo.id)}
|
onClick={() => handleLinkRepo(repository.id)}
|
||||||
disabled={isLinking}
|
disabled={isLinking}
|
||||||
>
|
>
|
||||||
Link
|
Link
|
||||||
|
|
@ -229,33 +359,35 @@ export function BoardForgejoRepositoryLinks({
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<ConfirmActionDialog
|
<ConfirmActionDialog
|
||||||
open={isDialogOpen}
|
open={unlinkTarget !== null}
|
||||||
onOpenChange={setIsDialogOpen}
|
onOpenChange={(open) => {
|
||||||
title={
|
if (!open && !isUnlinking) {
|
||||||
unlinkRepo
|
setUnlinkTarget(null);
|
||||||
? "Unlink Repository"
|
setUnlinkError(null);
|
||||||
: "Link Repository"
|
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
|
title="Unlink Git Project repository"
|
||||||
description={
|
description={
|
||||||
unlinkRepo
|
unlinkTarget
|
||||||
? unlinkError
|
? `Remove "${repositoryDisplayName(unlinkTarget.repository)}" from this board? Issues from this repository will no longer appear on the board.`
|
||||||
? `Error: ${unlinkError}`
|
: "Remove this repository from the board?"
|
||||||
: "Are you sure you want to unlink this repository from the board? Issues from this repository will no longer appear on this board."
|
|
||||||
: "Select a repository to link to this board."
|
|
||||||
}
|
}
|
||||||
onConfirm={
|
onConfirm={handleUnlinkRepo}
|
||||||
unlinkRepo ? handleUnlinkRepo : () => setIsDialogOpen(false)
|
isConfirming={isUnlinking}
|
||||||
}
|
errorMessage={unlinkError}
|
||||||
isConfirming={isLinking || (!!unlinkRepo && unlinkError !== null)}
|
confirmLabel="Unlink Repository"
|
||||||
cancelLabel={unlinkRepo ? "Keep Linked" : "Cancel"}
|
confirmingLabel="Unlinking..."
|
||||||
confirmLabel={unlinkRepo ? "Unlink" : undefined}
|
confirmClassName="bg-[color:var(--danger)] text-white hover:bg-[color:var(--danger)]/90"
|
||||||
errorStyle="panel"
|
cancelLabel="Keep Linked"
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,14 @@
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { ForgejoIssue } from "@/lib/api-forgejo";
|
import type { ForgejoIssue } from "@/lib/api-forgejo";
|
||||||
import { closeForgejoIssue } from "@/lib/api-forgejo";
|
import { closeForgejoIssue } from "@/lib/api-forgejo";
|
||||||
|
|
@ -33,7 +40,8 @@ export function CloseForgejoIssueDialog({
|
||||||
onCloseSuccess();
|
onCloseSuccess();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Failed to close issue";
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Failed to close issue";
|
||||||
setError(message);
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsClosing(false);
|
setIsClosing(false);
|
||||||
|
|
@ -42,25 +50,47 @@ export function CloseForgejoIssueDialog({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent className="max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Close Issue</DialogTitle>
|
<DialogTitle>Close Git Project issue</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Are you sure you want to close issue{" "}
|
Pipeline will mark issue{" "}
|
||||||
<span className="font-mono font-semibold">#{issue.forgejo_issue_number}</span> in{" "}
|
<span className="font-mono font-semibold text-strong">
|
||||||
<span className="font-mono font-semibold">{issue.repository_id}</span>?
|
#{issue.forgejo_issue_number}
|
||||||
|
</span>{" "}
|
||||||
|
as closed in the connected Git provider and refresh the local issue
|
||||||
|
cache.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3">
|
||||||
|
<p className="break-words text-sm font-medium text-strong">
|
||||||
|
{issue.title}
|
||||||
|
</p>
|
||||||
|
{issue.body_preview ? (
|
||||||
|
<p className="mt-1 line-clamp-3 break-words text-xs text-muted">
|
||||||
|
{issue.body_preview}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
|
<div className="rounded-lg border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-3 text-xs text-[color:var(--danger)]">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isClosing}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleClose} disabled={isClosing}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-[color:rgba(248,113,113,0.45)] text-[color:var(--danger)] hover:bg-[color:rgba(248,113,113,0.08)]"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={isClosing}
|
||||||
|
>
|
||||||
{isClosing ? "Closing…" : "Close Issue"}
|
{isClosing ? "Closing…" : "Close Issue"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ interface ForgejoConnectionFormProps {
|
||||||
defaultValues?: Partial<ForgejoConnectionFormValues>;
|
defaultValues?: Partial<ForgejoConnectionFormValues>;
|
||||||
onSubmit: (values: ForgejoConnectionCreate) => Promise<void>;
|
onSubmit: (values: ForgejoConnectionCreate) => Promise<void>;
|
||||||
isSubmitting?: boolean;
|
isSubmitting?: boolean;
|
||||||
|
isTokenRequired?: boolean;
|
||||||
|
existingTokenLastEight?: string | null;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
submitLabel?: string;
|
submitLabel?: string;
|
||||||
|
|
@ -27,20 +29,30 @@ export function ForgejoConnectionForm({
|
||||||
defaultValues = {},
|
defaultValues = {},
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isSubmitting = false,
|
isSubmitting = false,
|
||||||
title = "Forgejo Connection",
|
isTokenRequired = true,
|
||||||
description = "Connect a Forgejo instance to track issues and pull requests.",
|
existingTokenLastEight,
|
||||||
|
title = "Git Project Connection",
|
||||||
|
description = "Connect a Git provider so Pipeline can track issues.",
|
||||||
submitLabel = "Save Connection",
|
submitLabel = "Save Connection",
|
||||||
}: ForgejoConnectionFormProps) {
|
}: ForgejoConnectionFormProps) {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [name, setName] = useState(defaultValues.name || "");
|
const [name, setName] = useState(defaultValues.name || "");
|
||||||
const [baseUrl, setBaseUrl] = useState(defaultValues.base_url || "");
|
const [baseUrl, setBaseUrl] = useState(defaultValues.base_url || "");
|
||||||
const [token, setToken] = useState(defaultValues.token || "");
|
const [token, setToken] = useState(defaultValues.token || "");
|
||||||
|
const isBusy = isSubmitting || isSaving;
|
||||||
|
const tokenHelpText = isTokenRequired
|
||||||
|
? "Paste your Git provider personal access token. Use read:issue; add write:issue if Pipeline should close issues. The token is stored server-side and never displayed."
|
||||||
|
: existingTokenLastEight
|
||||||
|
? `Leave blank to keep the saved token ending in ${existingTokenLastEight}. Paste a new token to replace it.`
|
||||||
|
: "Paste a new Git provider personal access token to replace the saved token.";
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
await onSubmit({
|
await onSubmit({
|
||||||
name,
|
name,
|
||||||
base_url: baseUrl,
|
base_url: baseUrl,
|
||||||
|
|
@ -48,17 +60,26 @@ export function ForgejoConnectionForm({
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "An error occurred");
|
setError(err instanceof Error ? err.message : "An error occurred");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-8">
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="surface-panel w-full max-w-2xl space-y-6 rounded-2xl p-4 sm:p-6"
|
||||||
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-semibold">{title}</h3>
|
<div>
|
||||||
{description && <p className="text-sm text-slate-500">{description}</p>}
|
<h3 className="text-lg font-semibold text-strong">{title}</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-1 text-sm text-muted">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg bg-red-50 p-4 text-red-700">
|
<div className="rounded-xl border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-4 text-[color:var(--danger)]">
|
||||||
<p className="font-medium">Configuration Error</p>
|
<p className="font-medium">Configuration Error</p>
|
||||||
<p className="text-sm">{error}</p>
|
<p className="text-sm">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -72,12 +93,12 @@ export function ForgejoConnectionForm({
|
||||||
id="name"
|
id="name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="e.g., Dream Forgejo"
|
placeholder="Team Git"
|
||||||
disabled={isSubmitting}
|
disabled={isBusy}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-muted">
|
||||||
A memorable name for this Forgejo connection.
|
A memorable name for this Git Projects connection.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -89,12 +110,13 @@ export function ForgejoConnectionForm({
|
||||||
id="base_url"
|
id="base_url"
|
||||||
value={baseUrl}
|
value={baseUrl}
|
||||||
onChange={(e) => setBaseUrl(e.target.value)}
|
onChange={(e) => setBaseUrl(e.target.value)}
|
||||||
placeholder="https://dream.scheller.ltd"
|
placeholder="https://git.example.com"
|
||||||
disabled={isSubmitting}
|
disabled={isBusy}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-muted">
|
||||||
The base URL of your Forgejo instance (without trailing slash).
|
The base URL of your Git provider instance, without a trailing
|
||||||
|
slash.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -107,25 +129,34 @@ export function ForgejoConnectionForm({
|
||||||
type="password"
|
type="password"
|
||||||
value={token}
|
value={token}
|
||||||
onChange={(e) => setToken(e.target.value)}
|
onChange={(e) => setToken(e.target.value)}
|
||||||
placeholder="••••••••"
|
placeholder={
|
||||||
disabled={isSubmitting}
|
isTokenRequired
|
||||||
required
|
? "Paste token"
|
||||||
|
: existingTokenLastEight
|
||||||
|
? `Current token ends in ${existingTokenLastEight}`
|
||||||
|
: "Paste token"
|
||||||
|
}
|
||||||
|
disabled={isBusy}
|
||||||
|
required={isTokenRequired}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-muted">{tokenHelpText}</p>
|
||||||
Forgejo personal access token with repo permissions. Token is stored securely and never displayed.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-4 pt-4 border-t">
|
<div className="flex flex-col-reverse gap-3 border-t border-[color:var(--border)] pt-4 sm:flex-row sm:justify-end">
|
||||||
<Button type="button" variant="outline" onClick={() => window.history.back()} disabled={isSubmitting}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => window.history.back()}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
<Button type="submit" disabled={isBusy}>
|
||||||
{isSubmitting ? (
|
{isBusy ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Saving...
|
Saving…
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
submitLabel
|
submitLabel
|
||||||
|
|
|
||||||
|
|
@ -11,20 +11,24 @@ import {
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Loader2, CheckCircle2 } from "lucide-react";
|
import { CheckCircle2, GitBranch, Loader2 } from "lucide-react";
|
||||||
import { DataTable } from "@/components/tables/DataTable";
|
import { DataTable } from "@/components/tables/DataTable";
|
||||||
import DropdownSelect from "@/components/ui/dropdown-select";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import type { ForgejoConnection } from "@/lib/api-forgejo";
|
import type {
|
||||||
|
ForgejoConnection,
|
||||||
|
ForgejoConnectionValidationResponse,
|
||||||
|
} from "@/lib/api-forgejo";
|
||||||
|
|
||||||
interface ConnectionsTableProps {
|
interface ConnectionsTableProps {
|
||||||
connections: ForgejoConnection[];
|
connections: ForgejoConnection[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onEdit?: (connection: ForgejoConnection) => void;
|
onEdit?: (connection: ForgejoConnection) => void;
|
||||||
onDelete?: (connection: ForgejoConnection) => void;
|
onDelete?: (connection: ForgejoConnection) => void;
|
||||||
onValidate?: (connection: ForgejoConnection) => void;
|
onValidate?: (
|
||||||
|
connection: ForgejoConnection,
|
||||||
|
) => Promise<ForgejoConnectionValidationResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ForgejoConnectionsTable({
|
export function ForgejoConnectionsTable({
|
||||||
|
|
@ -47,23 +51,10 @@ export function ForgejoConnectionsTable({
|
||||||
table={table}
|
table={table}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyState={{
|
emptyState={{
|
||||||
icon: (
|
icon: <GitBranch className="h-12 w-12" />,
|
||||||
<svg
|
title: "No Git Project connections yet",
|
||||||
className="h-16 w-16 text-slate-300"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M9 11l3 3L22 4" />
|
|
||||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
title: "No Forgejo connections yet",
|
|
||||||
description:
|
description:
|
||||||
"Connect a Forgejo instance to start tracking issues and pull requests from your Git projects.",
|
"Connect a Git provider so Pipeline can track issues for Git Projects.",
|
||||||
actionHref: "/git-projects/connections/new",
|
actionHref: "/git-projects/connections/new",
|
||||||
actionLabel: "Add connection",
|
actionLabel: "Add connection",
|
||||||
}}
|
}}
|
||||||
|
|
@ -71,12 +62,15 @@ export function ForgejoConnectionsTable({
|
||||||
getEditHref: (row) => `/git-projects/connections/${row.id}/edit`,
|
getEditHref: (row) => `/git-projects/connections/${row.id}/edit`,
|
||||||
onDelete: onDelete ?? undefined,
|
onDelete: onDelete ?? undefined,
|
||||||
}}
|
}}
|
||||||
|
tableClassName="min-w-[680px] w-full text-left text-sm"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = (
|
const columns = (
|
||||||
onValidate?: (connection: ForgejoConnection) => void
|
onValidate?: (
|
||||||
|
connection: ForgejoConnection,
|
||||||
|
) => Promise<ForgejoConnectionValidationResponse>,
|
||||||
): ColumnDef<ForgejoConnection>[] => [
|
): ColumnDef<ForgejoConnection>[] => [
|
||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
|
|
@ -85,7 +79,7 @@ const columns = (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => column.toggleSorting(false)}
|
onClick={() => column.toggleSorting(false)}
|
||||||
className="px-0 hover:bg-transparent hover:text-slate-900"
|
className="h-auto px-0 py-0 hover:bg-transparent hover:text-[color:var(--accent)]"
|
||||||
>
|
>
|
||||||
Name
|
Name
|
||||||
{column.getIsSorted() === "asc" && "↑"}
|
{column.getIsSorted() === "asc" && "↑"}
|
||||||
|
|
@ -94,9 +88,13 @@ const columns = (
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex flex-col">
|
<div className="min-w-0">
|
||||||
<span className="font-medium text-slate-900">{row.original.name}</span>
|
<span className="block truncate font-medium text-strong">
|
||||||
<span className="text-xs text-slate-500">{row.original.base_url}</span>
|
{row.original.name}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs text-muted">
|
||||||
|
{row.original.base_url}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
@ -124,7 +122,9 @@ const columns = (
|
||||||
{hasToken ? "Configured" : "Missing"}
|
{hasToken ? "Configured" : "Missing"}
|
||||||
</Badge>
|
</Badge>
|
||||||
{tokenLastEight && hasToken && (
|
{tokenLastEight && hasToken && (
|
||||||
<span className="text-xs text-slate-500 font-mono">••••{tokenLastEight}</span>
|
<span className="font-mono text-xs text-muted">
|
||||||
|
••••{tokenLastEight}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -132,7 +132,9 @@ const columns = (
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({ row }) => <ActionsCell connection={row.original} onValidate={onValidate} />,
|
cell: ({ row }) => (
|
||||||
|
<ActionsCell connection={row.original} onValidate={onValidate} />
|
||||||
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -141,10 +143,11 @@ function ActionsCell({
|
||||||
onValidate,
|
onValidate,
|
||||||
}: {
|
}: {
|
||||||
connection: ForgejoConnection;
|
connection: ForgejoConnection;
|
||||||
onValidate?: (connection: ForgejoConnection) => void;
|
onValidate?: (
|
||||||
|
connection: ForgejoConnection,
|
||||||
|
) => Promise<ForgejoConnectionValidationResponse>;
|
||||||
}) {
|
}) {
|
||||||
const [isValidateLoading, setIsValidateLoading] = useState(false);
|
const [isValidateLoading, setIsValidateLoading] = useState(false);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const [validateResult, setValidateResult] = useState<{
|
const [validateResult, setValidateResult] = useState<{
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
error_message?: string;
|
error_message?: string;
|
||||||
|
|
@ -155,41 +158,17 @@ function ActionsCell({
|
||||||
if (!onValidate) return;
|
if (!onValidate) return;
|
||||||
setIsValidateLoading(true);
|
setIsValidateLoading(true);
|
||||||
try {
|
try {
|
||||||
await onValidate(connection);
|
const result = await onValidate(connection);
|
||||||
|
setValidateResult({
|
||||||
|
ok: result.status.ok,
|
||||||
|
error_message: result.status.error_message ?? undefined,
|
||||||
|
response_time_ms: result.response_time_ms,
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsValidateLoading(false);
|
setIsValidateLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = [
|
|
||||||
{ value: "edit", label: "Edit" },
|
|
||||||
{ value: "delete", label: "Delete", icon: (props: { className?: string }) => (
|
|
||||||
<svg
|
|
||||||
className={cn("h-4 w-4", props.className)}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M3 6h18" />
|
|
||||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
|
||||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
|
||||||
</svg>
|
|
||||||
)},
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleSelect = (value: string) => {
|
|
||||||
if (value === "edit") {
|
|
||||||
// Navigate to edit page
|
|
||||||
} else if (value === "delete") {
|
|
||||||
if (confirm(`Are you sure you want to delete "${connection.name}"?`)) {
|
|
||||||
// Trigger delete via parent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{onValidate && (
|
{onValidate && (
|
||||||
|
|
@ -203,18 +182,12 @@ function ActionsCell({
|
||||||
{isValidateLoading ? (
|
{isValidateLoading ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : validateResult?.ok ? (
|
) : validateResult?.ok ? (
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
<CheckCircle2 className="h-4 w-4 text-[color:var(--success)]" />
|
||||||
) : (
|
) : (
|
||||||
<CheckCircle2 className="h-4 w-4" />
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<DropdownSelect
|
|
||||||
ariaLabel="Connection actions"
|
|
||||||
options={options}
|
|
||||||
onValueChange={handleSelect}
|
|
||||||
triggerClassName="h-8 w-8 p-0"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -246,7 +219,7 @@ export function ConnectionsTableToggle({
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-slate-500">Show:</span>
|
<span className="text-xs text-muted">Show:</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -277,7 +250,9 @@ export function ConnectionsTableToggle({
|
||||||
onClick={() => column.toggleVisibility(!column.getIsVisible())}
|
onClick={() => column.toggleVisibility(!column.getIsVisible())}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 px-2 py-1 text-xs",
|
"h-8 px-2 py-1 text-xs",
|
||||||
column.getIsVisible() ? "bg-slate-100 text-slate-900" : "text-slate-500",
|
column.getIsVisible()
|
||||||
|
? "bg-[color:var(--surface-strong)] text-strong"
|
||||||
|
: "text-muted",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{column.id}
|
{column.id}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import {
|
||||||
|
Select,
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import type { ForgejoRepository } from "@/lib/api-forgejo";
|
import type { ForgejoRepository } from "@/lib/api-forgejo";
|
||||||
|
|
||||||
|
|
@ -26,9 +30,9 @@ export function ForgejoIssueFilters({
|
||||||
repos,
|
repos,
|
||||||
}: ForgejoIssueFiltersProps) {
|
}: ForgejoIssueFiltersProps) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 flex flex-wrap gap-3">
|
<div className="mb-4 grid gap-3 rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-3 shadow-lush sm:grid-cols-[140px_minmax(200px,280px)_minmax(220px,1fr)]">
|
||||||
<Select value={stateFilter} onValueChange={(v) => { onStateChange(v); }}>
|
<Select value={stateFilter} onValueChange={onStateChange}>
|
||||||
<SelectTrigger className="w-[120px]">
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="State" />
|
<SelectValue placeholder="State" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -38,8 +42,8 @@ export function ForgejoIssueFilters({
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={repoFilter} onValueChange={(v) => { onRepoChange(v); }}>
|
<Select value={repoFilter} onValueChange={onRepoChange}>
|
||||||
<SelectTrigger className="w-[200px]">
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Repository" />
|
<SelectValue placeholder="Repository" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -53,10 +57,10 @@ export function ForgejoIssueFilters({
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search issues…"
|
placeholder="Search Git Project issues…"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
className="w-[240px]"
|
className="min-w-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
import {
|
||||||
import { XCircle } from "lucide-react";
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import { CircleDot, ExternalLink, XCircle } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
@ -13,17 +17,22 @@ import type { ForgejoIssue } from "@/lib/api-forgejo";
|
||||||
|
|
||||||
export type ForgejoIssuesTableProps = {
|
export type ForgejoIssuesTableProps = {
|
||||||
issues: ForgejoIssue[];
|
issues: ForgejoIssue[];
|
||||||
|
isLoading?: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProps) {
|
export function ForgejoIssuesTable({
|
||||||
|
issues,
|
||||||
|
isLoading = false,
|
||||||
|
onRefresh,
|
||||||
|
}: ForgejoIssuesTableProps) {
|
||||||
const [closeIssueDialogOpen, setCloseIssueDialogOpen] = useState(false);
|
const [closeIssueDialogOpen, setCloseIssueDialogOpen] = useState(false);
|
||||||
const [issueToClose, setIssueToClose] = useState<ForgejoIssue | null>(null);
|
const [issueToClose, setIssueToClose] = useState<ForgejoIssue | null>(null);
|
||||||
|
|
||||||
const handleCloseClick = (issue: ForgejoIssue) => {
|
const handleCloseClick = useCallback((issue: ForgejoIssue) => {
|
||||||
setIssueToClose(issue);
|
setIssueToClose(issue);
|
||||||
setCloseIssueDialogOpen(true);
|
setCloseIssueDialogOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleCloseSuccess = () => {
|
const handleCloseSuccess = () => {
|
||||||
onRefresh();
|
onRefresh();
|
||||||
|
|
@ -39,9 +48,10 @@ export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProp
|
||||||
href={row.original.html_url}
|
href={row.original.html_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-mono text-sm text-blue-600 hover:underline dark:text-blue-400"
|
className="inline-flex items-center gap-1 font-mono text-sm text-[color:var(--accent)] hover:underline"
|
||||||
>
|
>
|
||||||
#{row.original.forgejo_issue_number}
|
#{row.original.forgejo_issue_number}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
</a>
|
</a>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
@ -49,7 +59,12 @@ export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProp
|
||||||
accessorKey: "title",
|
accessorKey: "title",
|
||||||
header: "Title",
|
header: "Title",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="max-w-md truncate">{row.original.title}</div>
|
<div
|
||||||
|
className="max-w-[28rem] truncate font-medium text-strong"
|
||||||
|
title={row.original.title}
|
||||||
|
>
|
||||||
|
{row.original.title}
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -59,7 +74,11 @@ export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProp
|
||||||
const body = row.original.body_preview;
|
const body = row.original.body_preview;
|
||||||
if (!body) return null;
|
if (!body) return null;
|
||||||
const truncated = body.length > 120 ? body.slice(0, 120) + "…" : body;
|
const truncated = body.length > 120 ? body.slice(0, 120) + "…" : body;
|
||||||
return <div className="max-w-xs truncate text-sm text-slate-500">{truncated}</div>;
|
return (
|
||||||
|
<div className="max-w-xs truncate text-sm text-muted" title={body}>
|
||||||
|
{truncated}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -68,10 +87,7 @@ export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProp
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const state = row.original.state;
|
const state = row.original.state;
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge variant={state === "open" ? "success" : "default"}>
|
||||||
variant={state === "open" ? "success" : "default"}
|
|
||||||
className={state === "open" ? "" : ""}
|
|
||||||
>
|
|
||||||
{state}
|
{state}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
|
|
@ -80,6 +96,11 @@ export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProp
|
||||||
{
|
{
|
||||||
accessorKey: "author",
|
accessorKey: "author",
|
||||||
header: "Author",
|
header: "Author",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="block max-w-[160px] truncate text-muted">
|
||||||
|
{row.original.author || "Unknown"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "labels",
|
accessorKey: "labels",
|
||||||
|
|
@ -88,8 +109,10 @@ export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProp
|
||||||
const labels = row.original.labels;
|
const labels = row.original.labels;
|
||||||
if (!labels || labels.length === 0) return null;
|
if (!labels || labels.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex max-w-[220px] flex-wrap gap-1">
|
||||||
{labels.slice(0, 3).map((label: Record<string, unknown>, i: number) => (
|
{labels
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((label: Record<string, unknown>, i: number) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={i}
|
key={i}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -104,7 +127,7 @@ export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProp
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
{labels.length > 3 && (
|
{labels.length > 3 && (
|
||||||
<span className="text-xs text-slate-500">+{labels.length - 3}</span>
|
<span className="text-xs text-muted">+{labels.length - 3}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -115,9 +138,17 @@ export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProp
|
||||||
header: "Updated",
|
header: "Updated",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
try {
|
try {
|
||||||
return new Date(row.original.forgejo_updated_at).toLocaleDateString();
|
return (
|
||||||
|
<span className="whitespace-nowrap text-muted">
|
||||||
|
{new Date(row.original.forgejo_updated_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return row.original.forgejo_updated_at;
|
return (
|
||||||
|
<span className="text-muted">
|
||||||
|
{row.original.forgejo_updated_at}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -131,47 +162,40 @@ export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProp
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0 text-[color:var(--danger)] hover:bg-[color:rgba(248,113,113,0.08)]"
|
||||||
onClick={() => handleCloseClick(issue)}
|
onClick={() => handleCloseClick(issue)}
|
||||||
title="Close issue"
|
title="Close issue"
|
||||||
>
|
>
|
||||||
<XCircle className="h-4 w-4 text-rose-500" />
|
<XCircle className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[handleCloseClick],
|
||||||
);
|
);
|
||||||
|
const table = useReactTable({
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DataTable
|
|
||||||
table={useReactTable({
|
|
||||||
data: issues,
|
data: issues,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
})}
|
});
|
||||||
isLoading={false}
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-lush">
|
||||||
|
<DataTable
|
||||||
|
table={table}
|
||||||
|
isLoading={isLoading}
|
||||||
|
loadingLabel="Loading Git Project issues…"
|
||||||
|
tableClassName="min-w-[960px] w-full text-left text-sm"
|
||||||
emptyState={{
|
emptyState={{
|
||||||
icon: (
|
icon: <CircleDot className="h-12 w-12" />,
|
||||||
<svg
|
title: "No Git Project issues found",
|
||||||
className="h-16 w-16 text-slate-300"
|
description:
|
||||||
viewBox="0 0 24 24"
|
"Sync a repository to pull issues into Pipeline, or adjust your filters.",
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<path d="M12 8v4" />
|
|
||||||
<path d="M12 16h.01" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
title: "No issues found",
|
|
||||||
description: "Sync a repository to pull in issues, or adjust your filters.",
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<CloseForgejoIssueDialog
|
<CloseForgejoIssueDialog
|
||||||
issue={issueToClose}
|
issue={issueToClose}
|
||||||
open={closeIssueDialogOpen}
|
open={closeIssueDialogOpen}
|
||||||
|
|
|
||||||
|
|
@ -11,20 +11,32 @@ import {
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Loader2, CheckCircle2 } from "lucide-react";
|
import { CheckCircle2, GitBranch, Loader2, RefreshCw } from "lucide-react";
|
||||||
import { DataTable } from "@/components/tables/DataTable";
|
import { DataTable } from "@/components/tables/DataTable";
|
||||||
import DropdownSelect from "@/components/ui/dropdown-select";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import type { ForgejoRepository } from "@/lib/api-forgejo";
|
import type {
|
||||||
|
ForgejoRepository,
|
||||||
|
ForgejoRepositoryValidationResponse,
|
||||||
|
} from "@/lib/api-forgejo";
|
||||||
|
|
||||||
|
type RepositorySyncResult = {
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
open: number;
|
||||||
|
closed: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
interface RepositoriesTableProps {
|
interface RepositoriesTableProps {
|
||||||
repositories: ForgejoRepository[];
|
repositories: ForgejoRepository[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onEdit?: (repository: ForgejoRepository) => void;
|
onEdit?: (repository: ForgejoRepository) => void;
|
||||||
onDelete?: (repository: ForgejoRepository) => void;
|
onDelete?: (repository: ForgejoRepository) => void;
|
||||||
onSync?: (repository: ForgejoRepository) => void;
|
onSync?: (repository: ForgejoRepository) => Promise<RepositorySyncResult>;
|
||||||
onValidate?: (repository: ForgejoRepository) => void;
|
onValidate?: (
|
||||||
|
repository: ForgejoRepository,
|
||||||
|
) => Promise<ForgejoRepositoryValidationResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ForgejoRepositoriesTable({
|
export function ForgejoRepositoriesTable({
|
||||||
|
|
@ -48,23 +60,10 @@ export function ForgejoRepositoriesTable({
|
||||||
table={table}
|
table={table}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyState={{
|
emptyState={{
|
||||||
icon: (
|
icon: <GitBranch className="h-12 w-12" />,
|
||||||
<svg
|
title: "No Git Project repositories yet",
|
||||||
className="h-16 w-16 text-slate-300"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M9 11l3 3L22 4" />
|
|
||||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
title: "No repositories tracked yet",
|
|
||||||
description:
|
description:
|
||||||
"Add repositories to start tracking issues and pull requests from your Git projects.",
|
"Add repositories so Pipeline can sync issues into Git Projects.",
|
||||||
actionHref: "/git-projects/repositories/new",
|
actionHref: "/git-projects/repositories/new",
|
||||||
actionLabel: "Add repository",
|
actionLabel: "Add repository",
|
||||||
}}
|
}}
|
||||||
|
|
@ -72,13 +71,16 @@ export function ForgejoRepositoriesTable({
|
||||||
getEditHref: (row) => `/git-projects/repositories/${row.id}/edit`,
|
getEditHref: (row) => `/git-projects/repositories/${row.id}/edit`,
|
||||||
onDelete: onDelete ?? undefined,
|
onDelete: onDelete ?? undefined,
|
||||||
}}
|
}}
|
||||||
|
tableClassName="min-w-[860px] w-full text-left text-sm"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = (
|
const columns = (
|
||||||
onSync?: (repository: ForgejoRepository) => void,
|
onSync?: (repository: ForgejoRepository) => Promise<RepositorySyncResult>,
|
||||||
onValidate?: (repository: ForgejoRepository) => void
|
onValidate?: (
|
||||||
|
repository: ForgejoRepository,
|
||||||
|
) => Promise<ForgejoRepositoryValidationResponse>,
|
||||||
): ColumnDef<ForgejoRepository>[] => [
|
): ColumnDef<ForgejoRepository>[] => [
|
||||||
{
|
{
|
||||||
accessorKey: "displayName",
|
accessorKey: "displayName",
|
||||||
|
|
@ -87,7 +89,7 @@ const columns = (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => column.toggleSorting(false)}
|
onClick={() => column.toggleSorting(false)}
|
||||||
className="px-0 hover:bg-transparent hover:text-slate-900"
|
className="h-auto px-0 py-0 hover:bg-transparent hover:text-[color:var(--accent)]"
|
||||||
>
|
>
|
||||||
Repository
|
Repository
|
||||||
{column.getIsSorted() === "asc" && "↑"}
|
{column.getIsSorted() === "asc" && "↑"}
|
||||||
|
|
@ -98,9 +100,11 @@ const columns = (
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const repo = row.original;
|
const repo = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="min-w-0">
|
||||||
<span className="font-medium text-slate-900">{repo.display_name || `${repo.owner}/${repo.repo}`}</span>
|
<span className="block truncate font-medium text-strong">
|
||||||
<span className="text-xs text-slate-500">
|
{repo.display_name || `${repo.owner}/${repo.repo}`}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate font-mono text-xs text-muted">
|
||||||
{repo.owner}/{repo.repo} • {repo.connection?.name}
|
{repo.owner}/{repo.repo} • {repo.connection?.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -113,9 +117,13 @@ const columns = (
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const connection = row.original.connection;
|
const connection = row.original.connection;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="min-w-0">
|
||||||
<span className="text-sm text-slate-700">{connection?.name}</span>
|
<span className="block truncate text-sm text-strong">
|
||||||
<span className="text-xs text-slate-500">{connection?.base_url}</span>
|
{connection?.name}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs text-muted">
|
||||||
|
{connection?.base_url}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -140,20 +148,23 @@ const columns = (
|
||||||
const lastSyncError = row.original.last_sync_error;
|
const lastSyncError = row.original.last_sync_error;
|
||||||
|
|
||||||
if (!lastSyncAt) {
|
if (!lastSyncAt) {
|
||||||
return <span className="text-sm text-slate-400">Never</span>;
|
return <span className="text-sm text-muted">Never</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const date = new Date(lastSyncAt);
|
const date = new Date(lastSyncAt);
|
||||||
const isRecent = new Date().getTime() - date.getTime() < 24 * 60 * 60 * 1000; // Within 24 hours
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className={`text-sm ${isRecent ? "text-green-600" : "text-slate-700"}`}>
|
<span className="text-sm text-strong">
|
||||||
{date.toLocaleDateString()}
|
{date.toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-slate-500">{date.toLocaleTimeString()}</span>
|
<span className="text-xs text-muted">
|
||||||
|
{date.toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
{lastSyncError && (
|
{lastSyncError && (
|
||||||
<span className="text-xs text-red-500">Error: {lastSyncError.substring(0, 50)}...</span>
|
<span className="max-w-[220px] truncate text-xs text-[color:var(--danger)]">
|
||||||
|
Error: {lastSyncError.substring(0, 50)}...
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -161,7 +172,13 @@ const columns = (
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({ row }) => <ActionsCell repository={row.original} onSync={onSync} onValidate={onValidate} />,
|
cell: ({ row }) => (
|
||||||
|
<ActionsCell
|
||||||
|
repository={row.original}
|
||||||
|
onSync={onSync}
|
||||||
|
onValidate={onValidate}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -171,12 +188,13 @@ function ActionsCell({
|
||||||
onValidate,
|
onValidate,
|
||||||
}: {
|
}: {
|
||||||
repository: ForgejoRepository;
|
repository: ForgejoRepository;
|
||||||
onSync?: (repository: ForgejoRepository) => void;
|
onSync?: (repository: ForgejoRepository) => Promise<RepositorySyncResult>;
|
||||||
onValidate?: (repository: ForgejoRepository) => void;
|
onValidate?: (
|
||||||
|
repository: ForgejoRepository,
|
||||||
|
) => Promise<ForgejoRepositoryValidationResponse>;
|
||||||
}) {
|
}) {
|
||||||
const [isSyncLoading, setIsSyncLoading] = useState(false);
|
const [isSyncLoading, setIsSyncLoading] = useState(false);
|
||||||
const [isValidateLoading, setIsValidateLoading] = useState(false);
|
const [isValidateLoading, setIsValidateLoading] = useState(false);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const [syncResult, setSyncResult] = useState<{
|
const [syncResult, setSyncResult] = useState<{
|
||||||
created: number;
|
created: number;
|
||||||
updated: number;
|
updated: number;
|
||||||
|
|
@ -184,7 +202,6 @@ function ActionsCell({
|
||||||
closed: number;
|
closed: number;
|
||||||
total: number;
|
total: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const [validateResult, setValidateResult] = useState<{
|
const [validateResult, setValidateResult] = useState<{
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
repo_exists?: boolean;
|
repo_exists?: boolean;
|
||||||
|
|
@ -195,7 +212,8 @@ function ActionsCell({
|
||||||
if (!onSync) return;
|
if (!onSync) return;
|
||||||
setIsSyncLoading(true);
|
setIsSyncLoading(true);
|
||||||
try {
|
try {
|
||||||
await onSync(repository);
|
const result = await onSync(repository);
|
||||||
|
setSyncResult(result);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSyncLoading(false);
|
setIsSyncLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -205,41 +223,17 @@ function ActionsCell({
|
||||||
if (!onValidate) return;
|
if (!onValidate) return;
|
||||||
setIsValidateLoading(true);
|
setIsValidateLoading(true);
|
||||||
try {
|
try {
|
||||||
await onValidate(repository);
|
const result = await onValidate(repository);
|
||||||
|
setValidateResult({
|
||||||
|
ok: result.status.ok,
|
||||||
|
repo_exists: result.repo_exists ?? undefined,
|
||||||
|
error_message: result.status.error_message ?? undefined,
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsValidateLoading(false);
|
setIsValidateLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = [
|
|
||||||
{ value: "edit", label: "Edit" },
|
|
||||||
{ value: "delete", label: "Delete", icon: (props: { className?: string }) => (
|
|
||||||
<svg
|
|
||||||
className={cn("h-4 w-4", props.className)}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M3 6h18" />
|
|
||||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
|
||||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
|
||||||
</svg>
|
|
||||||
)},
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleSelect = (value: string) => {
|
|
||||||
if (value === "edit") {
|
|
||||||
// Navigate to edit page
|
|
||||||
} else if (value === "delete") {
|
|
||||||
if (confirm(`Are you sure you want to delete "${repository.display_name || repository.repo}"?`)) {
|
|
||||||
// Trigger delete via parent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{onSync && (
|
{onSync && (
|
||||||
|
|
@ -253,35 +247,9 @@ function ActionsCell({
|
||||||
{isSyncLoading ? (
|
{isSyncLoading ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : syncResult ? (
|
) : syncResult ? (
|
||||||
<svg
|
<CheckCircle2 className="h-4 w-4 text-[color:var(--success)]" />
|
||||||
className="h-4 w-4 text-green-600"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
|
||||||
<path d="M3 3v5h5" />
|
|
||||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
|
||||||
<path d="M16 21h5v-5" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
) : (
|
||||||
<svg
|
<RefreshCw className="h-4 w-4" />
|
||||||
className="h-4 w-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
|
||||||
<path d="M3 3v5h5" />
|
|
||||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
|
||||||
<path d="M16 21h5v-5" />
|
|
||||||
</svg>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -296,18 +264,12 @@ function ActionsCell({
|
||||||
{isValidateLoading ? (
|
{isValidateLoading ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : validateResult?.ok ? (
|
) : validateResult?.ok ? (
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
<CheckCircle2 className="h-4 w-4 text-[color:var(--success)]" />
|
||||||
) : (
|
) : (
|
||||||
<CheckCircle2 className="h-4 w-4" />
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<DropdownSelect
|
|
||||||
ariaLabel="Repository actions"
|
|
||||||
options={options}
|
|
||||||
onValueChange={handleSelect}
|
|
||||||
triggerClassName="h-8 w-8 p-0"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -323,10 +285,10 @@ export function RepositoriesTableFilter({
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Filter repositories..."
|
placeholder="Filter repositories…"
|
||||||
value={value ?? ""}
|
value={value ?? ""}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
className="h-8 w-[150px] rounded-md border border-slate-200 px-3 py-1 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 lg:w-[250px]"
|
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]"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -340,7 +302,7 @@ export function RepositoriesTableToggle({
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-slate-500">Show:</span>
|
<span className="text-xs text-muted">Show:</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -371,7 +333,9 @@ export function RepositoriesTableToggle({
|
||||||
onClick={() => column.toggleVisibility(!column.getIsVisible())}
|
onClick={() => column.toggleVisibility(!column.getIsVisible())}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 px-2 py-1 text-xs",
|
"h-8 px-2 py-1 text-xs",
|
||||||
column.getIsVisible() ? "bg-slate-100 text-slate-900" : "text-slate-500",
|
column.getIsVisible()
|
||||||
|
? "bg-[color:var(--surface-strong)] text-strong"
|
||||||
|
: "text-muted",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{column.id}
|
{column.id}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,19 @@ import { useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
import type { ForgejoRepositoryCreate } from "@/lib/api-forgejo";
|
import {
|
||||||
|
getForgejoConnections,
|
||||||
|
type ForgejoConnection,
|
||||||
|
type ForgejoRepositoryCreate,
|
||||||
|
} from "@/lib/api-forgejo";
|
||||||
|
|
||||||
interface ForgejoRepositoryFormProps {
|
interface ForgejoRepositoryFormProps {
|
||||||
defaultValues?: Partial<ForgejoRepositoryFormValues>;
|
defaultValues?: Partial<ForgejoRepositoryFormValues>;
|
||||||
|
|
@ -34,27 +45,32 @@ export function ForgejoRepositoryForm({
|
||||||
submitLabel = "Save Repository",
|
submitLabel = "Save Repository",
|
||||||
}: ForgejoRepositoryFormProps) {
|
}: ForgejoRepositoryFormProps) {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [connectionId, setConnectionId] = useState(defaultValues.connection_id || "");
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [connectionId, setConnectionId] = useState(
|
||||||
|
defaultValues.connection_id || "",
|
||||||
|
);
|
||||||
const [owner, setOwner] = useState(defaultValues.owner || "");
|
const [owner, setOwner] = useState(defaultValues.owner || "");
|
||||||
const [repo, setRepo] = useState(defaultValues.repo || "");
|
const [repo, setRepo] = useState(defaultValues.repo || "");
|
||||||
const [displayName, setDisplayName] = useState(defaultValues.display_name || "");
|
const [displayName, setDisplayName] = useState(
|
||||||
const [defaultBranch, setDefaultBranch] = useState(defaultValues.default_branch || "main");
|
defaultValues.display_name || "",
|
||||||
|
);
|
||||||
|
const [defaultBranch, setDefaultBranch] = useState(
|
||||||
|
defaultValues.default_branch || "main",
|
||||||
|
);
|
||||||
|
|
||||||
// Get connections for dropdown
|
// Get connections for dropdown
|
||||||
const [connections, setConnections] = useState<{id: string; name: string; base_url: string; active: boolean}[]>([]);
|
const [connections, setConnections] = useState<ForgejoConnection[]>([]);
|
||||||
|
|
||||||
const [isLoadingConnections, setIsLoadingConnections] = useState(false);
|
const [isLoadingConnections, setIsLoadingConnections] = useState(false);
|
||||||
|
const isBusy = isSubmitting || isSaving;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchConnections = async () => {
|
const fetchConnections = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoadingConnections(true);
|
setIsLoadingConnections(true);
|
||||||
const response = await fetch("/api/v1/forgejo/connections");
|
const data = await getForgejoConnections();
|
||||||
if (response.ok) {
|
setConnections(data.filter((connection) => connection.active));
|
||||||
const data = await response.json();
|
} catch {
|
||||||
setConnections(data.filter((c: { active: boolean }) => c.active));
|
|
||||||
}
|
|
||||||
} catch (_: unknown) {
|
|
||||||
setError("Failed to load connections");
|
setError("Failed to load connections");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingConnections(false);
|
setIsLoadingConnections(false);
|
||||||
|
|
@ -66,8 +82,15 @@ export function ForgejoRepositoryForm({
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
if (!connectionId) {
|
||||||
|
setError(
|
||||||
|
"Choose a Git Projects connection before saving this repository.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
await onSubmit({
|
await onSubmit({
|
||||||
connection_id: connectionId,
|
connection_id: connectionId,
|
||||||
owner,
|
owner,
|
||||||
|
|
@ -77,17 +100,26 @@ export function ForgejoRepositoryForm({
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "An error occurred");
|
setError(err instanceof Error ? err.message : "An error occurred");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-8">
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="surface-panel w-full max-w-2xl space-y-6 rounded-2xl p-4 sm:p-6"
|
||||||
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-semibold">{title}</h3>
|
<div>
|
||||||
{description && <p className="text-sm text-slate-500">{description}</p>}
|
<h3 className="text-lg font-semibold text-strong">{title}</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-1 text-sm text-muted">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg bg-red-50 p-4 text-red-700">
|
<div className="rounded-xl border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-4 text-[color:var(--danger)]">
|
||||||
<p className="font-medium">Configuration Error</p>
|
<p className="font-medium">Configuration Error</p>
|
||||||
<p className="text-sm">{error}</p>
|
<p className="text-sm">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -97,27 +129,37 @@ export function ForgejoRepositoryForm({
|
||||||
<label htmlFor="connection_id" className="text-sm font-medium">
|
<label htmlFor="connection_id" className="text-sm font-medium">
|
||||||
Connection
|
Connection
|
||||||
</label>
|
</label>
|
||||||
<select
|
<Select
|
||||||
id="connection_id"
|
|
||||||
value={connectionId}
|
value={connectionId}
|
||||||
onChange={(e) => setConnectionId(e.target.value)}
|
onValueChange={setConnectionId}
|
||||||
disabled={isSubmitting || isLoadingConnections}
|
disabled={
|
||||||
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50"
|
isBusy || isLoadingConnections || connections.length === 0
|
||||||
required
|
}
|
||||||
>
|
>
|
||||||
<option value="">Select a connection</option>
|
<SelectTrigger id="connection_id">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
isLoadingConnections
|
||||||
|
? "Loading connections…"
|
||||||
|
: "Select a connection"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
{connections.map((conn) => (
|
{connections.map((conn) => (
|
||||||
<option key={conn.id} value={conn.id}>
|
<SelectItem key={conn.id} value={conn.id}>
|
||||||
{conn.name} - {conn.base_url}
|
{conn.name} - {conn.base_url}
|
||||||
</option>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</select>
|
</SelectContent>
|
||||||
<p className="text-xs text-slate-500">
|
</Select>
|
||||||
The Forgejo connection to use for this repository.
|
<p className="text-xs text-muted">
|
||||||
|
The Git Projects connection to use for this repository. Add a
|
||||||
|
connection first if none are available.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="owner" className="text-sm font-medium">
|
<label htmlFor="owner" className="text-sm font-medium">
|
||||||
Owner
|
Owner
|
||||||
|
|
@ -126,11 +168,11 @@ export function ForgejoRepositoryForm({
|
||||||
id="owner"
|
id="owner"
|
||||||
value={owner}
|
value={owner}
|
||||||
onChange={(e) => setOwner(e.target.value)}
|
onChange={(e) => setOwner(e.target.value)}
|
||||||
placeholder="e.g., null"
|
placeholder="openclaw"
|
||||||
disabled={isSubmitting}
|
disabled={isBusy}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-muted">
|
||||||
The owner of the repository (username or organization).
|
The owner of the repository (username or organization).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -143,13 +185,11 @@ export function ForgejoRepositoryForm({
|
||||||
id="repo"
|
id="repo"
|
||||||
value={repo}
|
value={repo}
|
||||||
onChange={(e) => setRepo(e.target.value)}
|
onChange={(e) => setRepo(e.target.value)}
|
||||||
placeholder="e.g., Pipeline"
|
placeholder="pipeline"
|
||||||
disabled={isSubmitting}
|
disabled={isBusy}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-muted">The name of the repository.</p>
|
||||||
The name of the repository.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -161,11 +201,12 @@ export function ForgejoRepositoryForm({
|
||||||
id="display_name"
|
id="display_name"
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
placeholder="e.g., Pipeline"
|
placeholder="Pipeline"
|
||||||
disabled={isSubmitting}
|
disabled={isBusy}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-muted">
|
||||||
A friendly name for this repository. If not provided, the owner/repo will be used.
|
A friendly name for this repository. If not provided, the owner/repo
|
||||||
|
will be used.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -178,24 +219,29 @@ export function ForgejoRepositoryForm({
|
||||||
value={defaultBranch}
|
value={defaultBranch}
|
||||||
onChange={(e) => setDefaultBranch(e.target.value)}
|
onChange={(e) => setDefaultBranch(e.target.value)}
|
||||||
placeholder="main"
|
placeholder="main"
|
||||||
disabled={isSubmitting}
|
disabled={isBusy}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-muted">
|
||||||
The default branch for this repository (e.g., main, dev, master).
|
The default branch for this repository (e.g., main, dev, master).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-4 pt-4 border-t">
|
<div className="flex flex-col-reverse gap-3 border-t border-[color:var(--border)] pt-4 sm:flex-row sm:justify-end">
|
||||||
<Button type="button" variant="outline" onClick={() => window.history.back()} disabled={isSubmitting}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => window.history.back()}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
<Button type="submit" disabled={isBusy}>
|
||||||
{isSubmitting ? (
|
{isBusy ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Saving...
|
Saving…
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
submitLabel
|
submitLabel
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,9 @@ export function DataTable<TData>({
|
||||||
tableClassName = "w-full text-left text-sm",
|
tableClassName = "w-full text-left text-sm",
|
||||||
headerClassName,
|
headerClassName,
|
||||||
headerCellClassName = "px-3 py-2 md:px-6 md:py-3",
|
headerCellClassName = "px-3 py-2 md:px-6 md:py-3",
|
||||||
bodyClassName = "divide-y divide-slate-100",
|
bodyClassName = "divide-y divide-[color:var(--border)]",
|
||||||
rowClassName = "hover:bg-slate-50",
|
rowClassName = "transition-colors hover:bg-[color:var(--surface-muted)]",
|
||||||
cellClassName = "px-3 py-3 md:px-6 md:py-4",
|
cellClassName = "px-3 py-3 align-middle md:px-6",
|
||||||
}: DataTableProps<TData>) {
|
}: DataTableProps<TData>) {
|
||||||
const resolvedRowActions = rowActions
|
const resolvedRowActions = rowActions
|
||||||
? (rowActions.actions ??
|
? (rowActions.actions ??
|
||||||
|
|
@ -93,7 +93,7 @@ export function DataTable<TData>({
|
||||||
<thead
|
<thead
|
||||||
className={
|
className={
|
||||||
headerClassName ??
|
headerClassName ??
|
||||||
`${stickyHeader ? "sticky top-0 z-10 " : ""}bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500`
|
`${stickyHeader ? "sticky top-0 z-10 " : ""}bg-[color:var(--surface-muted)] text-xs font-semibold uppercase tracking-wider text-[color:var(--text-muted)]`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
|
@ -117,7 +117,9 @@ export function DataTable<TData>({
|
||||||
) : header.column.getIsSorted() === "desc" ? (
|
) : header.column.getIsSorted() === "desc" ? (
|
||||||
"↓"
|
"↓"
|
||||||
) : (
|
) : (
|
||||||
<span className="text-slate-300">↕</span>
|
<span className="text-[color:var(--text-quiet)]">
|
||||||
|
↕
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -204,10 +206,7 @@ export function DataTable<TData>({
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td colSpan={colSpan} className="px-6 py-8 text-sm text-muted">
|
||||||
colSpan={colSpan}
|
|
||||||
className="px-6 py-8 text-sm text-slate-500"
|
|
||||||
>
|
|
||||||
{emptyMessage}
|
{emptyMessage}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -40,14 +40,14 @@ export function linkifyCell({
|
||||||
<Link href={href} title={title} className={cn("group block", className)}>
|
<Link href={href} title={title} className={cn("group block", className)}>
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm font-medium text-slate-900 group-hover:text-blue-600",
|
"text-sm font-medium text-strong group-hover:text-[color:var(--accent)]",
|
||||||
labelClassName,
|
labelClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
{subtitle != null ? (
|
{subtitle != null ? (
|
||||||
<p className={cn("text-xs text-slate-500", subtitleClassName)}>
|
<p className={cn("text-xs text-muted", subtitleClassName)}>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -60,7 +60,7 @@ export function linkifyCell({
|
||||||
href={href}
|
href={href}
|
||||||
title={title}
|
title={title}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm font-medium text-slate-700 hover:text-blue-600",
|
"text-sm font-medium text-strong hover:text-[color:var(--accent)]",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -82,7 +82,7 @@ export function dateCell(
|
||||||
) {
|
) {
|
||||||
const display = relative ? formatRelative(value) : formatTimestamp(value);
|
const display = relative ? formatRelative(value) : formatTimestamp(value);
|
||||||
return (
|
return (
|
||||||
<span className={cn("text-sm text-slate-700", className)}>
|
<span className={cn("text-sm text-muted", className)}>
|
||||||
{display ?? fallback}
|
{display ?? fallback}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type ConfirmActionDialogProps = {
|
type ConfirmActionDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -20,6 +21,8 @@ type ConfirmActionDialogProps = {
|
||||||
errorMessage?: string | null;
|
errorMessage?: string | null;
|
||||||
confirmLabel?: string;
|
confirmLabel?: string;
|
||||||
confirmingLabel?: string;
|
confirmingLabel?: string;
|
||||||
|
confirmVariant?: NonNullable<ButtonProps["variant"]>;
|
||||||
|
confirmClassName?: string;
|
||||||
cancelLabel?: string;
|
cancelLabel?: string;
|
||||||
cancelVariant?: NonNullable<ButtonProps["variant"]>;
|
cancelVariant?: NonNullable<ButtonProps["variant"]>;
|
||||||
errorStyle?: "text" | "panel";
|
errorStyle?: "text" | "panel";
|
||||||
|
|
@ -36,6 +39,8 @@ export function ConfirmActionDialog({
|
||||||
errorMessage,
|
errorMessage,
|
||||||
confirmLabel = "Delete",
|
confirmLabel = "Delete",
|
||||||
confirmingLabel = "Deleting…",
|
confirmingLabel = "Deleting…",
|
||||||
|
confirmVariant = "primary",
|
||||||
|
confirmClassName,
|
||||||
cancelLabel = "Cancel",
|
cancelLabel = "Cancel",
|
||||||
cancelVariant = "outline",
|
cancelVariant = "outline",
|
||||||
errorStyle = "panel",
|
errorStyle = "panel",
|
||||||
|
|
@ -50,7 +55,7 @@ export function ConfirmActionDialog({
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
errorStyle === "text" ? (
|
errorStyle === "text" ? (
|
||||||
<p className="text-sm text-red-500">{errorMessage}</p>
|
<p className="text-sm text-[color:var(--danger)]">{errorMessage}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
|
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
|
|
@ -58,10 +63,19 @@ export function ConfirmActionDialog({
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant={cancelVariant} onClick={() => onOpenChange(false)}>
|
<Button
|
||||||
|
variant={cancelVariant}
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isConfirming}
|
||||||
|
>
|
||||||
{cancelLabel}
|
{cancelLabel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onConfirm} disabled={isConfirming}>
|
<Button
|
||||||
|
variant={confirmVariant}
|
||||||
|
className={cn(confirmClassName)}
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isConfirming}
|
||||||
|
>
|
||||||
{isConfirming ? confirmingLabel : confirmLabel}
|
{isConfirming ? confirmingLabel : confirmLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
|
|
@ -15,7 +16,10 @@ export function TableLoadingRow({
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={colSpan} className="px-6 py-8">
|
<td colSpan={colSpan} className="px-6 py-8">
|
||||||
<span className="text-sm text-slate-500">{label}</span>
|
<div className="flex items-center gap-2 text-sm text-muted">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-[color:var(--accent)]" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|
@ -42,9 +46,11 @@ export function TableEmptyStateRow({
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={colSpan} className="px-6 py-16">
|
<td colSpan={colSpan} className="px-6 py-16">
|
||||||
<div className="flex flex-col items-center justify-center text-center">
|
<div className="flex flex-col items-center justify-center text-center">
|
||||||
<div className="mb-4 rounded-full bg-slate-50 p-4">{icon}</div>
|
<div className="mb-4 rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-[color:var(--text-muted)]">
|
||||||
<h3 className="mb-2 text-lg font-semibold text-slate-900">{title}</h3>
|
{icon}
|
||||||
<p className="mb-6 max-w-md text-sm text-slate-500">{description}</p>
|
</div>
|
||||||
|
<h3 className="mb-2 text-lg font-semibold text-strong">{title}</h3>
|
||||||
|
<p className="mb-6 max-w-md text-sm text-muted">{description}</p>
|
||||||
{actionHref && actionLabel ? (
|
{actionHref && actionLabel ? (
|
||||||
<Link
|
<Link
|
||||||
href={actionHref}
|
href={actionHref}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getApiBaseUrl } from "./api-base";
|
import { customFetch } from "@/api/mutator";
|
||||||
|
|
||||||
// Forgejo Connection types
|
// Forgejo Connection types
|
||||||
export interface ForgejoConnection {
|
export interface ForgejoConnection {
|
||||||
|
|
@ -56,48 +56,36 @@ export interface ForgejoRepositoryUpdate {
|
||||||
default_branch?: string;
|
default_branch?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// API client
|
type ApiResponse<T> = {
|
||||||
const API_BASE_URL = getApiBaseUrl();
|
data: T;
|
||||||
|
status: number;
|
||||||
|
headers: Headers;
|
||||||
|
};
|
||||||
|
|
||||||
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(url, {
|
const response = await customFetch<ApiResponse<T>>(path, init ?? {});
|
||||||
...init,
|
return response.data;
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(init?.headers || {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.detail || `API error: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forgejo Connection API
|
// Forgejo Connection API
|
||||||
export async function getForgejoConnections(): Promise<ForgejoConnection[]> {
|
export async function getForgejoConnections(): Promise<ForgejoConnection[]> {
|
||||||
return fetchJson<ForgejoConnection[]>(`${API_BASE_URL}/api/v1/forgejo/connections`);
|
return fetchJson<ForgejoConnection[]>("/api/v1/forgejo/connections");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createForgejoConnection(
|
export async function createForgejoConnection(
|
||||||
data: ForgejoConnectionCreate,
|
data: ForgejoConnectionCreate,
|
||||||
): Promise<ForgejoConnection> {
|
): Promise<ForgejoConnection> {
|
||||||
return fetchJson<ForgejoConnection>(
|
return fetchJson<ForgejoConnection>("/api/v1/forgejo/connections", {
|
||||||
`${API_BASE_URL}/api/v1/forgejo/connections`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getForgejoConnection(
|
export async function getForgejoConnection(
|
||||||
connectionId: string,
|
connectionId: string,
|
||||||
): Promise<ForgejoConnection> {
|
): Promise<ForgejoConnection> {
|
||||||
return fetchJson<ForgejoConnection>(
|
return fetchJson<ForgejoConnection>(
|
||||||
`${API_BASE_URL}/api/v1/forgejo/connections/${connectionId}`,
|
`/api/v1/forgejo/connections/${connectionId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,7 +94,7 @@ export async function updateForgejoConnection(
|
||||||
data: ForgejoConnectionUpdate,
|
data: ForgejoConnectionUpdate,
|
||||||
): Promise<ForgejoConnection> {
|
): Promise<ForgejoConnection> {
|
||||||
return fetchJson<ForgejoConnection>(
|
return fetchJson<ForgejoConnection>(
|
||||||
`${API_BASE_URL}/api/v1/forgejo/connections/${connectionId}`,
|
`/api/v1/forgejo/connections/${connectionId}`,
|
||||||
{
|
{
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|
@ -114,36 +102,36 @@ export async function updateForgejoConnection(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteForgejoConnection(connectionId: string): Promise<void> {
|
export async function deleteForgejoConnection(
|
||||||
await fetch(`${API_BASE_URL}/api/v1/forgejo/connections/${connectionId}`, {
|
connectionId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await customFetch<ApiResponse<unknown>>(
|
||||||
|
`/api/v1/forgejo/connections/${connectionId}`,
|
||||||
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forgejo Repository API
|
// Forgejo Repository API
|
||||||
export async function getForgejoRepositories(): Promise<ForgejoRepository[]> {
|
export async function getForgejoRepositories(): Promise<ForgejoRepository[]> {
|
||||||
return fetchJson<ForgejoRepository[]>(
|
return fetchJson<ForgejoRepository[]>("/api/v1/forgejo/repositories");
|
||||||
`${API_BASE_URL}/api/v1/forgejo/repositories`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createForgejoRepository(
|
export async function createForgejoRepository(
|
||||||
data: ForgejoRepositoryCreate,
|
data: ForgejoRepositoryCreate,
|
||||||
): Promise<ForgejoRepository> {
|
): Promise<ForgejoRepository> {
|
||||||
return fetchJson<ForgejoRepository>(
|
return fetchJson<ForgejoRepository>("/api/v1/forgejo/repositories", {
|
||||||
`${API_BASE_URL}/api/v1/forgejo/repositories`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getForgejoRepository(
|
export async function getForgejoRepository(
|
||||||
repositoryId: string,
|
repositoryId: string,
|
||||||
): Promise<ForgejoRepository> {
|
): Promise<ForgejoRepository> {
|
||||||
return fetchJson<ForgejoRepository>(
|
return fetchJson<ForgejoRepository>(
|
||||||
`${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}`,
|
`/api/v1/forgejo/repositories/${repositoryId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,7 +140,7 @@ export async function updateForgejoRepository(
|
||||||
data: ForgejoRepositoryUpdate,
|
data: ForgejoRepositoryUpdate,
|
||||||
): Promise<ForgejoRepository> {
|
): Promise<ForgejoRepository> {
|
||||||
return fetchJson<ForgejoRepository>(
|
return fetchJson<ForgejoRepository>(
|
||||||
`${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}`,
|
`/api/v1/forgejo/repositories/${repositoryId}`,
|
||||||
{
|
{
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|
@ -160,43 +148,62 @@ export async function updateForgejoRepository(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteForgejoRepository(repositoryId: string): Promise<void> {
|
export async function deleteForgejoRepository(
|
||||||
await fetch(`${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}`, {
|
repositoryId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await customFetch<ApiResponse<unknown>>(
|
||||||
|
`/api/v1/forgejo/repositories/${repositoryId}`,
|
||||||
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forgejo Sync & Validation API
|
// Forgejo Sync & Validation API
|
||||||
export async function syncRepository(
|
export async function syncRepository(repositoryId: string): Promise<{
|
||||||
repositoryId: string,
|
|
||||||
): Promise<{
|
|
||||||
created: number;
|
created: number;
|
||||||
updated: number;
|
updated: number;
|
||||||
open: number;
|
open: number;
|
||||||
closed: number;
|
closed: number;
|
||||||
total: number;
|
total: number;
|
||||||
}> {
|
}> {
|
||||||
return fetchJson<{ created: number; updated: number; open: number; closed: number; total: number }>(
|
return fetchJson<{
|
||||||
`${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}/sync`,
|
created: number;
|
||||||
{
|
updated: number;
|
||||||
|
open: number;
|
||||||
|
closed: number;
|
||||||
|
total: number;
|
||||||
|
}>(`/api/v1/forgejo/repositories/${repositoryId}/sync`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
},
|
});
|
||||||
);
|
}
|
||||||
|
|
||||||
|
export interface ForgejoValidationStatus {
|
||||||
|
ok: boolean;
|
||||||
|
status: string;
|
||||||
|
error_message?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForgejoConnectionValidationResponse {
|
||||||
|
connection_id: string;
|
||||||
|
status: ForgejoValidationStatus;
|
||||||
|
response_time_ms: number;
|
||||||
|
validated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForgejoRepositoryValidationResponse {
|
||||||
|
repository_id: string;
|
||||||
|
status: ForgejoValidationStatus;
|
||||||
|
response_time_ms: number;
|
||||||
|
validated_at: string;
|
||||||
|
repo_exists?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateConnection(
|
export async function validateConnection(
|
||||||
connectionId: string,
|
connectionId: string,
|
||||||
): Promise<{
|
): Promise<ForgejoConnectionValidationResponse> {
|
||||||
ok: boolean;
|
return fetchJson<ForgejoConnectionValidationResponse>(
|
||||||
error_message?: string;
|
`/api/v1/forgejo/connections/${connectionId}/validate`,
|
||||||
response_time_ms: number;
|
|
||||||
}> {
|
|
||||||
return fetchJson<{
|
|
||||||
ok: boolean;
|
|
||||||
error_message?: string;
|
|
||||||
response_time_ms: number;
|
|
||||||
}>(
|
|
||||||
`${API_BASE_URL}/api/v1/forgejo/connections/${connectionId}/validate`,
|
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
},
|
},
|
||||||
|
|
@ -205,17 +212,9 @@ export async function validateConnection(
|
||||||
|
|
||||||
export async function validateRepository(
|
export async function validateRepository(
|
||||||
repositoryId: string,
|
repositoryId: string,
|
||||||
): Promise<{
|
): Promise<ForgejoRepositoryValidationResponse> {
|
||||||
ok: boolean;
|
return fetchJson<ForgejoRepositoryValidationResponse>(
|
||||||
repo_exists: boolean;
|
`/api/v1/forgejo/repositories/${repositoryId}/validate`,
|
||||||
error_message?: string;
|
|
||||||
}> {
|
|
||||||
return fetchJson<{
|
|
||||||
ok: boolean;
|
|
||||||
repo_exists: boolean;
|
|
||||||
error_message?: string;
|
|
||||||
}>(
|
|
||||||
`${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}/validate`,
|
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
},
|
},
|
||||||
|
|
@ -260,7 +259,8 @@ export async function getForgejoIssues(params?: {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}): Promise<ForgejoIssueListResponse> {
|
}): Promise<ForgejoIssueListResponse> {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (params?.repository_id) searchParams.set("repository_id", params.repository_id);
|
if (params?.repository_id)
|
||||||
|
searchParams.set("repository_id", params.repository_id);
|
||||||
if (params?.state) searchParams.set("state", params.state);
|
if (params?.state) searchParams.set("state", params.state);
|
||||||
if (params?.search) searchParams.set("search", params.search);
|
if (params?.search) searchParams.set("search", params.search);
|
||||||
if (params?.page) searchParams.set("page", params.page.toString());
|
if (params?.page) searchParams.set("page", params.page.toString());
|
||||||
|
|
@ -268,83 +268,62 @@ export async function getForgejoIssues(params?: {
|
||||||
|
|
||||||
const qs = searchParams.toString();
|
const qs = searchParams.toString();
|
||||||
return fetchJson<ForgejoIssueListResponse>(
|
return fetchJson<ForgejoIssueListResponse>(
|
||||||
`${API_BASE_URL}/api/v1/forgejo/issues${qs ? `?${qs}` : ""}`,
|
`/api/v1/forgejo/issues${qs ? `?${qs}` : ""}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getForgejoIssue(issueId: string): Promise<ForgejoIssue> {
|
export async function getForgejoIssue(issueId: string): Promise<ForgejoIssue> {
|
||||||
return fetchJson<ForgejoIssue>(
|
return fetchJson<ForgejoIssue>(`/api/v1/forgejo/issues/${issueId}`);
|
||||||
`${API_BASE_URL}/api/v1/forgejo/issues/${issueId}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function closeForgejoIssue(issueId: string): Promise<ForgejoIssue> {
|
export async function closeForgejoIssue(
|
||||||
return fetchJson<ForgejoIssue>(
|
issueId: string,
|
||||||
`${API_BASE_URL}/api/v1/forgejo/issues/${issueId}/close`,
|
): Promise<ForgejoIssue> {
|
||||||
{
|
return fetchJson<ForgejoIssue>(`/api/v1/forgejo/issues/${issueId}/close`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Board Repository Linking API
|
// Board Repository Linking API
|
||||||
export async function getBoardForgejoRepositories(boardId: string): Promise<{
|
export interface BoardForgejoRepositoryLink {
|
||||||
repositories: Array<{
|
|
||||||
id: string;
|
id: string;
|
||||||
board_id: string;
|
board_id: string;
|
||||||
repository_id: string;
|
repository_id: string;
|
||||||
organization_id: string;
|
organization_id: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
repository: ForgejoRepository;
|
repository?: ForgejoRepository;
|
||||||
}>;
|
}
|
||||||
}> {
|
|
||||||
return fetchJson<{
|
export type BoardForgejoRepositoriesResponse =
|
||||||
repositories: Array<{
|
| BoardForgejoRepositoryLink[]
|
||||||
id: string;
|
| { repositories: BoardForgejoRepositoryLink[] };
|
||||||
board_id: string;
|
|
||||||
repository_id: string;
|
export async function getBoardForgejoRepositories(
|
||||||
organization_id: string;
|
boardId: string,
|
||||||
created_at: string;
|
): Promise<BoardForgejoRepositoriesResponse> {
|
||||||
repository: ForgejoRepository;
|
return fetchJson<BoardForgejoRepositoriesResponse>(
|
||||||
}>;
|
`/api/v1/boards/${boardId}/forgejo/repositories`,
|
||||||
}>(
|
|
||||||
`${API_BASE_URL}/api/v1/boards/${boardId}/forgejo/repositories`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function linkBoardForgejoRepository(
|
export async function linkBoardForgejoRepository(
|
||||||
boardId: string,
|
boardId: string,
|
||||||
repositoryId: string,
|
repositoryId: string,
|
||||||
): Promise<{
|
): Promise<BoardForgejoRepositoryLink | { link?: BoardForgejoRepositoryLink }> {
|
||||||
id: string;
|
return fetchJson<
|
||||||
board_id: string;
|
BoardForgejoRepositoryLink | { link?: BoardForgejoRepositoryLink }
|
||||||
repository_id: string;
|
>(`/api/v1/boards/${boardId}/forgejo/repositories`, {
|
||||||
organization_id: string;
|
|
||||||
created_at: string;
|
|
||||||
repository: ForgejoRepository;
|
|
||||||
}> {
|
|
||||||
return fetchJson<{
|
|
||||||
id: string;
|
|
||||||
board_id: string;
|
|
||||||
repository_id: string;
|
|
||||||
organization_id: string;
|
|
||||||
created_at: string;
|
|
||||||
repository: ForgejoRepository;
|
|
||||||
}>(
|
|
||||||
`${API_BASE_URL}/api/v1/boards/${boardId}/forgejo/repositories`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ repository_id: repositoryId }),
|
body: JSON.stringify({ repository_id: repositoryId }),
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unlinkBoardForgejoRepository(
|
export async function unlinkBoardForgejoRepository(
|
||||||
boardId: string,
|
boardId: string,
|
||||||
repositoryId: string,
|
repositoryId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await fetch(
|
await customFetch<ApiResponse<unknown>>(
|
||||||
`${API_BASE_URL}/api/v1/boards/${boardId}/forgejo/repositories/${repositoryId}`,
|
`/api/v1/boards/${boardId}/forgejo/repositories/${repositoryId}`,
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
},
|
},
|
||||||
|
|
@ -378,9 +357,10 @@ export async function getForgejoMetrics(params?: {
|
||||||
}): Promise<ForgejoIssueMetrics> {
|
}): Promise<ForgejoIssueMetrics> {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (params?.board_id) searchParams.set("board_id", params.board_id);
|
if (params?.board_id) searchParams.set("board_id", params.board_id);
|
||||||
if (params?.repository_id) searchParams.set("repository_id", params.repository_id);
|
if (params?.repository_id)
|
||||||
|
searchParams.set("repository_id", params.repository_id);
|
||||||
const qs = searchParams.toString();
|
const qs = searchParams.toString();
|
||||||
return fetchJson<ForgejoIssueMetrics>(
|
return fetchJson<ForgejoIssueMetrics>(
|
||||||
`${API_BASE_URL}/api/v1/forgejo/metrics${qs ? `?${qs}` : ""}`,
|
`/api/v1/forgejo/metrics${qs ? `?${qs}` : ""}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ module.exports = {
|
||||||
heading: ["var(--font-heading)", "sans-serif"],
|
heading: ["var(--font-heading)", "sans-serif"],
|
||||||
body: ["var(--font-body)", "sans-serif"],
|
body: ["var(--font-body)", "sans-serif"],
|
||||||
display: ["var(--font-display)", "serif"],
|
display: ["var(--font-display)", "serif"],
|
||||||
|
numeric: ["Georgia Numbers", "Georgia", "Times New Roman", "serif"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue