From 8e012a2197ff3adec32e98f67880223239ceae5c Mon Sep 17 00:00:00 2001 From: null Date: Tue, 19 May 2026 20:14:16 -0500 Subject: [PATCH] feat(forgejo): batch 3 UI + Georgia numbers font (#28) --- backend/app/api/forgejo_connections.py | 23 +- backend/app/api/forgejo_issues.py | 23 +- backend/app/api/forgejo_repositories.py | 25 +- .../public/fonts/georgia-bold-italic.woff2 | Bin 0 -> 7012 bytes frontend/public/fonts/georgia-bold.woff2 | Bin 0 -> 6476 bytes frontend/public/fonts/georgia-italic.woff2 | Bin 0 -> 6872 bytes frontend/public/fonts/georgia-regular.woff2 | Bin 0 -> 6476 bytes .../connections/[connectionId]/edit/page.tsx | 96 ++-- .../app/git-projects/connections/new/page.tsx | 22 +- .../src/app/git-projects/connections/page.tsx | 178 ++++-- frontend/src/app/git-projects/issues/page.tsx | 183 +++---- frontend/src/app/git-projects/page.tsx | 123 +++-- .../repositories/[repositoryId]/edit/page.tsx | 86 +-- .../git-projects/repositories/new/page.tsx | 22 +- .../app/git-projects/repositories/page.tsx | 199 ++++--- frontend/src/app/globals.css | 98 ++++ .../git/BoardForgejoRepositoryLinks.tsx | 516 +++++++++++------- .../git/CloseForgejoIssueDialog.tsx | 50 +- .../components/git/ForgejoConnectionForm.tsx | 81 ++- .../git/ForgejoConnectionsTable.tsx | 111 ++-- .../components/git/ForgejoIssueFilters.tsx | 24 +- .../src/components/git/ForgejoIssuesTable.tsx | 144 +++-- .../git/ForgejoRepositoriesTable.tsx | 184 +++---- .../components/git/ForgejoRepositoryForm.tsx | 150 +++-- frontend/src/components/tables/DataTable.tsx | 17 +- .../src/components/tables/cell-formatters.tsx | 8 +- .../components/ui/confirm-action-dialog.tsx | 20 +- frontend/src/components/ui/table-state.tsx | 14 +- frontend/src/lib/api-forgejo.ts | 256 ++++----- frontend/tailwind.config.cjs | 1 + 30 files changed, 1551 insertions(+), 1103 deletions(-) create mode 100644 frontend/public/fonts/georgia-bold-italic.woff2 create mode 100644 frontend/public/fonts/georgia-bold.woff2 create mode 100644 frontend/public/fonts/georgia-italic.woff2 create mode 100644 frontend/public/fonts/georgia-regular.woff2 diff --git a/backend/app/api/forgejo_connections.py b/backend/app/api/forgejo_connections.py index a881d20..b43345c 100644 --- a/backend/app/api/forgejo_connections.py +++ b/backend/app/api/forgejo_connections.py @@ -2,16 +2,13 @@ from __future__ import annotations -import time from typing import TYPE_CHECKING from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status from sqlmodel import select -from sqlalchemy.orm import selectinload -from app.api.deps import require_org_admin -from app.core.auth import AuthContext, get_auth_context +from app.api.deps import require_org_member from app.db import crud from app.db.session import get_session from app.models.forgejo_connections import ForgejoConnection @@ -22,7 +19,6 @@ from app.schemas.forgejo_connections import ( ForgejoConnectionUpdate, ) from app.schemas.forgejo_validation import ForgejoConnectionValidationResponse -from app.services.forgejo_client import get_forgejo_client from app.services.organizations import OrganizationContext if TYPE_CHECKING: @@ -31,8 +27,7 @@ if TYPE_CHECKING: router = APIRouter(prefix="/forgejo/connections", tags=["forgejo-connections"]) SESSION_DEP = Depends(get_session) -AUTH_DEP = Depends(get_auth_context) -ORG_ADMIN_DEP = Depends(require_org_admin) +ORG_MEMBER_DEP = Depends(require_org_member) 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]) async def list_connections( session: AsyncSession = SESSION_DEP, - ctx: OrganizationContext = ORG_ADMIN_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> list[ForgejoConnectionRead]: """List Forgejo connections for the caller's organization.""" statement = ( @@ -76,8 +71,7 @@ async def list_connections( async def create_connection( payload: ForgejoConnectionCreate, session: AsyncSession = SESSION_DEP, - auth: AuthContext = AUTH_DEP, - ctx: OrganizationContext = ORG_ADMIN_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> ForgejoConnectionRead: """Create a Forgejo connection for the caller's organization.""" data = payload.model_dump() @@ -96,7 +90,7 @@ async def create_connection( async def get_connection( connection_id: UUID, session: AsyncSession = SESSION_DEP, - ctx: OrganizationContext = ORG_ADMIN_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> ForgejoConnectionRead: """Return one Forgejo connection by id for the caller's organization.""" connection = await crud.get_by_id(session, ForgejoConnection, connection_id) @@ -112,8 +106,7 @@ async def update_connection( connection_id: UUID, payload: ForgejoConnectionUpdate, session: AsyncSession = SESSION_DEP, - auth: AuthContext = AUTH_DEP, - ctx: OrganizationContext = ORG_ADMIN_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> ForgejoConnectionRead: """Patch a Forgejo connection for the caller's organization.""" connection = await crud.get_by_id(session, ForgejoConnection, connection_id) @@ -165,7 +158,7 @@ async def update_connection( async def delete_connection( connection_id: UUID, session: AsyncSession = SESSION_DEP, - ctx: OrganizationContext = ORG_ADMIN_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> OkResponse: """Delete a Forgejo connection for the caller's organization.""" connection = await crud.get_by_id(session, ForgejoConnection, connection_id) @@ -188,7 +181,7 @@ async def delete_connection( async def validate_connection( connection_id: UUID, session: AsyncSession = SESSION_DEP, - ctx: OrganizationContext = ORG_ADMIN_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> ForgejoConnectionValidationResponse: """Validate a Forgejo connection by testing authenticated API access.""" connection = await crud.get_by_id(session, ForgejoConnection, connection_id) diff --git a/backend/app/api/forgejo_issues.py b/backend/app/api/forgejo_issues.py index bb57609..eeb95c0 100644 --- a/backend/app/api/forgejo_issues.py +++ b/backend/app/api/forgejo_issues.py @@ -8,9 +8,8 @@ from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlmodel import select, func -from app.api.deps import get_board_for_user_write, require_org_admin -from app.core.agent_auth import get_agent_auth_context -from app.core.auth import get_auth_context +from app.api.deps import get_board_for_user_write, require_org_member +from app.core.auth import AuthContext, get_auth_context from app.db import crud from app.db.session import get_session from app.models.board_repository_links import BoardRepositoryLink @@ -26,14 +25,13 @@ if TYPE_CHECKING: router = APIRouter(prefix="/forgejo/issues", tags=["forgejo-issues"]) SESSION_DEP = Depends(get_session) AUTH_DEP = Depends(get_auth_context) -ORG_ADMIN_DEP = Depends(require_org_admin) -BOARD_WRITE_DEP = Depends(get_board_for_user_write) +ORG_MEMBER_DEP = Depends(require_org_member) @router.get("", response_model=ForgejoIssueListResponse) async def list_issues( 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"), state: str | None = Query(None, description="Filter by state (open, closed)"), label: str | None = Query(None, description="Filter by label name"), @@ -99,7 +97,7 @@ async def list_issues( async def get_issue( issue_id: str, session: AsyncSession = SESSION_DEP, - ctx: OrganizationContext = ORG_ADMIN_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> ForgejoIssueRead: """Get one cached issue by ID.""" try: @@ -157,7 +155,8 @@ async def get_issue( async def close_issue( issue_id: str, session: AsyncSession = SESSION_DEP, - ctx: OrganizationContext = ORG_ADMIN_DEP, + auth: AuthContext = AUTH_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> CloseIssueResponse: """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 - board = await get_board_for_user_write( + await get_board_for_user_write( board_id=str(link.board_id), 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 try: result = await close_issue_by_id( session=session, issue_id=uuid, - actor_user_id=ctx.user.id, + actor_user_id=auth.user.id, ) except CloseIssueNotFoundError as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) diff --git a/backend/app/api/forgejo_repositories.py b/backend/app/api/forgejo_repositories.py index 26c3b7d..ea9a127 100644 --- a/backend/app/api/forgejo_repositories.py +++ b/backend/app/api/forgejo_repositories.py @@ -2,15 +2,13 @@ from __future__ import annotations -import time from typing import TYPE_CHECKING from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status from sqlmodel import select -from app.api.deps import require_org_admin -from app.core.auth import AuthContext, get_auth_context +from app.api.deps import require_org_member from app.db import crud from app.db.session import get_session from app.models.forgejo_connections import ForgejoConnection @@ -31,8 +29,7 @@ if TYPE_CHECKING: router = APIRouter(prefix="/forgejo/repositories", tags=["forgejo-repositories"]) SESSION_DEP = Depends(get_session) -AUTH_DEP = Depends(get_auth_context) -ORG_ADMIN_DEP = Depends(require_org_admin) +ORG_MEMBER_DEP = Depends(require_org_member) 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]) async def list_repositories( session: AsyncSession = SESSION_DEP, - ctx: OrganizationContext = ORG_ADMIN_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> list[ForgejoRepositoryRead]: """List Forgejo repositories for the caller's organization.""" statement = ( @@ -77,8 +74,7 @@ async def list_repositories( async def create_repository( payload: ForgejoRepositoryCreate, session: AsyncSession = SESSION_DEP, - auth: AuthContext = AUTH_DEP, - ctx: OrganizationContext = ORG_ADMIN_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> ForgejoRepositoryRead: """Create a Forgejo repository tracked for the caller's organization.""" # Validate connection belongs to caller's org @@ -123,7 +119,7 @@ async def create_repository( async def get_repository( repository_id: UUID, session: AsyncSession = SESSION_DEP, - ctx: OrganizationContext = ORG_ADMIN_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> ForgejoRepositoryRead: """Return one Forgejo repository by id for the caller's organization.""" statement = ( @@ -143,8 +139,7 @@ async def update_repository( repository_id: UUID, payload: ForgejoRepositoryUpdate, session: AsyncSession = SESSION_DEP, - auth: AuthContext = AUTH_DEP, - ctx: OrganizationContext = ORG_ADMIN_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> ForgejoRepositoryRead: """Patch a Forgejo repository for the caller's organization.""" # Get repository @@ -218,7 +213,7 @@ async def update_repository( async def delete_repository( repository_id: UUID, session: AsyncSession = SESSION_DEP, - ctx: OrganizationContext = ORG_ADMIN_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> OkResponse: """Delete a Forgejo repository for the caller's organization.""" repository = await crud.get_by_id(session, ForgejoRepository, repository_id) @@ -241,7 +236,7 @@ async def delete_repository( async def validate_repository( repository_id: UUID, session: AsyncSession = SESSION_DEP, - ctx: OrganizationContext = ORG_ADMIN_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> ForgejoRepositoryValidationResponse: """Validate a Forgejo repository by testing API access.""" repository = await crud.get_by_id(session, ForgejoRepository, repository_id) @@ -296,12 +291,12 @@ async def validate_repository( @router.post( "/{repository_id}/sync", 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( repository_id: UUID, session: AsyncSession = SESSION_DEP, - ctx: OrganizationContext = ORG_ADMIN_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> dict[str, int]: """Sync issues from a Forgejo repository.""" repository = await crud.get_by_id(session, ForgejoRepository, repository_id) diff --git a/frontend/public/fonts/georgia-bold-italic.woff2 b/frontend/public/fonts/georgia-bold-italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..8d5fdfac0d679b296168eb527a44fa3eed023b55 GIT binary patch literal 7012 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 W?26=O<$p=%Jv8c`i6@5jruh#8;Bbrp literal 0 HcmV?d00001 diff --git a/frontend/public/fonts/georgia-bold.woff2 b/frontend/public/fonts/georgia-bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..7008a0e06f00ebdf36c1dd8f154d245ffdca6351 GIT binary patch literal 6476 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 Z^O%hJhc;RNKZA1~CL@k;LU7-h{tv7+(dz&J literal 0 HcmV?d00001 diff --git a/frontend/public/fonts/georgia-italic.woff2 b/frontend/public/fonts/georgia-italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..845339a8230d343c2e5763de9c6ed83d081a4ea7 GIT binary patch literal 6872 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 MM$F)V!1b5@4@A(?^Z)<= literal 0 HcmV?d00001 diff --git a/frontend/src/app/git-projects/connections/[connectionId]/edit/page.tsx b/frontend/src/app/git-projects/connections/[connectionId]/edit/page.tsx index 398b85a..4d8759c 100644 --- a/frontend/src/app/git-projects/connections/[connectionId]/edit/page.tsx +++ b/frontend/src/app/git-projects/connections/[connectionId]/edit/page.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { useAuth } from "@/auth/clerk"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { ForgejoConnectionForm } from "@/components/git/ForgejoConnectionForm"; +import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog"; import { getForgejoConnection, updateForgejoConnection, @@ -27,13 +28,20 @@ interface ConnectionData { id: string; } -export default function ForgejoConnectionsEditPage({ params }: { params: RouteParams }) { +export default function ForgejoConnectionsEditPage({ + params, +}: { + params: RouteParams; +}) { const router = useRouter(); const auth = useAuth(); const [connection, setConnection] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [deleteOpen, setDeleteOpen] = useState(false); + const [deleteError, setDeleteError] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); useEffect(() => { const fetchConnection = async () => { @@ -43,7 +51,9 @@ export default function ForgejoConnectionsEditPage({ params }: { params: RoutePa setConnection(data); setError(null); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load connection"); + setError( + err instanceof Error ? err.message : "Failed to load connection", + ); } finally { setIsLoading(false); } @@ -55,26 +65,22 @@ export default function ForgejoConnectionsEditPage({ params }: { params: RoutePa }, [params.connectionId, auth.isSignedIn]); const handleSubmit = async (values: ForgejoConnectionUpdate) => { - try { - const connection = await updateForgejoConnection(params.connectionId, values); - console.log("Connection updated:", connection); - router.push("/git-projects/connections"); - } catch (err) { - alert(err instanceof Error ? err.message : "Failed to update connection"); - } + await updateForgejoConnection(params.connectionId, values); + router.push("/git-projects/connections"); }; const handleDelete = async () => { - if ( - confirm(`Are you sure you want to delete "${connection?.name}"? This action cannot be undone.`) - ) { - try { - await deleteForgejoConnection(params.connectionId); - console.log("Connection deleted"); - router.push("/git-projects/connections"); - } catch (err) { - alert(err instanceof Error ? err.message : "Failed to delete connection"); - } + setIsDeleting(true); + setDeleteError(null); + try { + await deleteForgejoConnection(params.connectionId); + router.push("/git-projects/connections"); + } catch (err) { + 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 ( -

Loading connection...

+

Loading connection…

); } @@ -98,14 +104,16 @@ export default function ForgejoConnectionsEditPage({ params }: { params: RoutePa return ( -

{error || "Connection not found"}

+

+ {error || "Connection not found"} +

); } @@ -113,40 +121,58 @@ export default function ForgejoConnectionsEditPage({ params }: { params: RoutePa const defaultValues = { name: connection.name, base_url: connection.base_url, - token: connection.has_token ? "••••" + (connection.token_last_eight || "") : "", + token: "", }; return ( -
+
-
-

Danger Zone

-

- Deleting a connection will remove all associated repositories and data. +

+

+ Delete Connection +

+

+ Remove this connection from Pipeline. Repositories that use it will + stop syncing.

+ ); } diff --git a/frontend/src/app/git-projects/connections/new/page.tsx b/frontend/src/app/git-projects/connections/new/page.tsx index b852730..ced7618 100644 --- a/frontend/src/app/git-projects/connections/new/page.tsx +++ b/frontend/src/app/git-projects/connections/new/page.tsx @@ -4,33 +4,31 @@ import { useRouter } from "next/navigation"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; 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() { const router = useRouter(); const handleSubmit = async (values: ForgejoConnectionCreate) => { - try { - const connection = await createForgejoConnection(values); - alert(`Connection "${connection.name}" created successfully`); - router.push("/git-projects/connections"); - } catch (err) { - alert(err instanceof Error ? err.message : "Failed to create connection"); - } + await createForgejoConnection(values); + router.push("/git-projects/connections"); }; return ( -
+
diff --git a/frontend/src/app/git-projects/connections/page.tsx b/frontend/src/app/git-projects/connections/page.tsx index 8369bba..3767fb6 100644 --- a/frontend/src/app/git-projects/connections/page.tsx +++ b/frontend/src/app/git-projects/connections/page.tsx @@ -2,11 +2,13 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; +import { AlertCircle, CheckCircle2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/auth/clerk"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { ForgejoConnectionsTable } from "@/components/git/ForgejoConnectionsTable"; +import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog"; import { getForgejoConnections, deleteForgejoConnection, @@ -21,6 +23,15 @@ export default function ForgejoConnectionsPage() { const [connections, setConnections] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [notice, setNotice] = useState<{ + tone: "success" | "error"; + message: string; + } | null>(null); + const [deleteTarget, setDeleteTarget] = useState( + null, + ); + const [deleteError, setDeleteError] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); useEffect(() => { const fetchConnections = async () => { @@ -30,7 +41,9 @@ export default function ForgejoConnectionsPage() { setConnections(data); setError(null); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load connections"); + setError( + err instanceof Error ? err.message : "Failed to load connections", + ); } finally { setIsLoading(false); } @@ -41,77 +54,130 @@ export default function ForgejoConnectionsPage() { } }, [auth.isSignedIn, auth.getToken]); - const handleDelete = async (connection: ForgejoConnection) => { - if ( - confirm(`Are you sure you want to delete "${connection.name}"? This action cannot be undone.`) - ) { - try { - await deleteForgejoConnection(connection.id); - setConnections((prev) => prev.filter((c) => c.id !== connection.id)); - alert("Connection deleted successfully"); - } catch (err) { - alert(err instanceof Error ? err.message : "Failed to delete connection"); - } + const handleDelete = (connection: ForgejoConnection) => { + setDeleteError(null); + setDeleteTarget(connection); + }; + + const confirmDelete = async () => { + if (!deleteTarget) return; + setIsDeleting(true); + setDeleteError(null); + try { + await deleteForgejoConnection(deleteTarget.id); + setConnections((prev) => prev.filter((c) => c.id !== deleteTarget.id)); + setNotice({ + tone: "success", + message: `Deleted "${deleteTarget.name}".`, + }); + setDeleteTarget(null); + } catch (err) { + setDeleteError( + err instanceof Error ? err.message : "Failed to delete connection", + ); + } finally { + setIsDeleting(false); } }; const handleValidateConnection = async (connection: ForgejoConnection) => { try { const result = await validateConnection(connection.id); - if (result.ok) { - alert( - `Connection validated successfully!\n\n` + - `Response time: ${result.response_time_ms}ms` - ); + if (result.status.ok) { + setNotice({ + tone: "success", + message: `"${connection.name}" validated in ${Math.round(result.response_time_ms)}ms.`, + }); } else { - alert( - `Connection validation failed: ${result.error_message || "Unknown error"}` - ); + setNotice({ + tone: "error", + message: `Connection validation failed: ${result.status.error_message || "Unknown error"}`, + }); } return result; } 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; } }; return ( - -
-
-

Connections

- -
-
- {error ? ( -
-

{error}

+ <> + +
+ {notice ? ( +
+ {notice.tone === "success" ? ( + + ) : ( + + )} + {notice.message}
- ) : ( - - )} + ) : null} + +
+

Connections

+ +
+
+ {error ? ( +
+

{error}

+
+ ) : ( + + )} +
-
- + + { + 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" + /> + ); } diff --git a/frontend/src/app/git-projects/issues/page.tsx b/frontend/src/app/git-projects/issues/page.tsx index e34117b..965a703 100644 --- a/frontend/src/app/git-projects/issues/page.tsx +++ b/frontend/src/app/git-projects/issues/page.tsx @@ -2,14 +2,11 @@ export const dynamic = "force-dynamic"; -import { useMemo, useState, useEffect } from "react"; - -import { - type ColumnDef, -} from "@tanstack/react-table"; +import { useState, useEffect } from "react"; +import { AlertCircle } from "lucide-react"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; -import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { getForgejoIssues, getForgejoRepositories, @@ -23,6 +20,8 @@ export default function GitIssuesPage() { const [issues, setIssues] = useState([]); const [repos, setRepos] = useState([]); const [total, setTotal] = useState(0); + const [isLoadingIssues, setIsLoadingIssues] = useState(true); + const [error, setError] = useState(null); const [stateFilter, setStateFilter] = useState("open"); const [repoFilter, setRepoFilter] = useState("all"); const [search, setSearch] = useState(""); @@ -44,8 +43,9 @@ export default function GitIssuesPage() { const controller = new AbortController(); (async () => { try { + setIsLoadingIssues(true); const result = await getForgejoIssues({ - state: stateFilter || undefined, + state: stateFilter !== "all" ? stateFilter : undefined, repository_id: repoFilter !== "all" ? repoFilter : undefined, search: search || undefined, page, @@ -53,9 +53,16 @@ export default function GitIssuesPage() { }); setIssues(result.items); setTotal(result.total); + setError(null); } catch (err) { 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(); @@ -63,8 +70,9 @@ export default function GitIssuesPage() { const handleRefresh = async () => { try { + setIsLoadingIssues(true); const result = await getForgejoIssues({ - state: stateFilter || undefined, + state: stateFilter !== "all" ? stateFilter : undefined, repository_id: repoFilter !== "all" ? repoFilter : undefined, search: search || undefined, page, @@ -72,152 +80,85 @@ export default function GitIssuesPage() { }); setIssues(result.items); setTotal(result.total); + setError(null); } 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[] = useMemo( - () => [ - { - accessorKey: "forgejo_issue_number", - header: "#", - cell: ({ row }) => ( - - #{row.original.forgejo_issue_number} - - ), - }, - { - accessorKey: "title", - header: "Title", - cell: ({ row }) => ( -
{row.original.title}
- ), - }, - { - 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
{truncated}
; - }, - }, - { - accessorKey: "state", - header: "State", - cell: ({ row }) => { - const state = row.original.state; - return ( - - {state} - - ); - }, - }, - { - accessorKey: "author", - header: "Author", - }, - { - accessorKey: "labels", - header: "Labels", - cell: ({ row }) => { - const labels = row.original.labels; - if (!labels || labels.length === 0) return null; - return ( -
- {labels.slice(0, 3).map((label: Record, i: number) => ( - - {String(label.name || "")} - - ))} - {labels.length > 3 && ( - +{labels.length - 3} - )} -
- ); - }, - }, - { - 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); return ( { setStateFilter(v); setPage(1); }} + onStateChange={(v) => { + setStateFilter(v); + setPage(1); + }} repoFilter={repoFilter} - onRepoChange={(v) => { setRepoFilter(v); setPage(1); }} + onRepoChange={(v) => { + setRepoFilter(v); + setPage(1); + }} search={search} - onSearchChange={(v) => { setSearch(v); setPage(1); }} + onSearchChange={(v) => { + setSearch(v); + setPage(1); + }} repos={repos} /> - + {error ? ( +
+ + {error} +
+ ) : null} + + {totalPages > 1 && ( -
- +
+ Page {page} of {totalPages} ({total} total)
- - +
)} diff --git a/frontend/src/app/git-projects/page.tsx b/frontend/src/app/git-projects/page.tsx index 7428a63..c731487 100644 --- a/frontend/src/app/git-projects/page.tsx +++ b/frontend/src/app/git-projects/page.tsx @@ -9,12 +9,13 @@ import { getCoreRowModel, useReactTable, } from "@tanstack/react-table"; +import { AlertCircle, CheckCircle2, GitBranch, RefreshCw } from "lucide-react"; import Link from "next/link"; -import { useAuth } from "@/auth/clerk"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { DataTable } from "@/components/tables/DataTable"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { getForgejoRepositories, @@ -23,9 +24,9 @@ import { } from "@/lib/api-forgejo"; export default function GitProjectsPage() { - const _useAuth = useAuth(); const [repositories, setRepositories] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); const [syncingId, setSyncingId] = useState(null); const [syncResult, setSyncResult] = useState<{ repoName: string; @@ -33,14 +34,20 @@ export default function GitProjectsPage() { updated: number; open: number; closed: number; + error?: string; } | null>(null); const fetchRepos = useCallback(async () => { try { const repos = await getForgejoRepositories(); setRepositories(repos); + setError(null); } catch (err) { - console.error("Failed to fetch repositories:", err); + setError( + err instanceof Error + ? err.message + : "Pipeline could not load Git Projects.", + ); } finally { setLoading(false); } @@ -65,7 +72,14 @@ export default function GitProjectsPage() { }); await fetchRepos(); } 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 { setSyncingId(null); } @@ -80,14 +94,13 @@ export default function GitProjectsPage() { header: "Repository", cell: ({ row }) => { const repo = row.original; - const name = - repo.display_name || `${repo.owner}/${repo.repo}`; + const name = repo.display_name || `${repo.owner}/${repo.repo}`; return ( -
-
+
+
{name}
-
+
{repo.owner}/{repo.repo}
@@ -99,22 +112,20 @@ export default function GitProjectsPage() { header: "Connection", cell: ({ row }) => { const conn = row.original.connection; - return conn ? conn.name : "—"; + return ( + + {conn ? conn.name : "Unassigned"} + + ); }, }, { accessorKey: "active", header: "Status", cell: ({ row }) => ( - + {row.original.active ? "Active" : "Inactive"} - + ), }, { @@ -122,11 +133,15 @@ export default function GitProjectsPage() { header: "Last Synced", cell: ({ row }) => { const val = row.original.last_sync_at; - if (!val) return "—"; + if (!val) return Never; try { - return new Date(val).toLocaleString(); + return ( + + {new Date(val).toLocaleString()} + + ); } catch { - return val; + return {val}; } }, }, @@ -138,12 +153,16 @@ export default function GitProjectsPage() { const isSyncing = syncingId === repo.id; return ( ); }, @@ -161,7 +180,7 @@ export default function GitProjectsPage() { return ( {syncResult && ( -
- {syncResult.repoName} synced:{" "} - {syncResult.created} created, {syncResult.updated} updated,{" "} - {syncResult.open} open, {syncResult.closed} closed +
+ {syncResult.error ? ( + + ) : ( + + )} +
+ {syncResult.repoName}{" "} + {syncResult.error ? ( + {syncResult.error} + ) : ( + + synced: {syncResult.created} created, {syncResult.updated}{" "} + updated, {syncResult.open} open, {syncResult.closed} closed + + )} +
)} -
+
-
+ {error ? ( +
+ {error} +
+ ) : null} + +
- - - - ), + icon: , title: "No repositories tracked yet", 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", actionLabel: "Set up connection", }} @@ -219,4 +250,4 @@ export default function GitProjectsPage() {
); -} \ No newline at end of file +} diff --git a/frontend/src/app/git-projects/repositories/[repositoryId]/edit/page.tsx b/frontend/src/app/git-projects/repositories/[repositoryId]/edit/page.tsx index 7a405a1..3823852 100644 --- a/frontend/src/app/git-projects/repositories/[repositoryId]/edit/page.tsx +++ b/frontend/src/app/git-projects/repositories/[repositoryId]/edit/page.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { useAuth } from "@/auth/clerk"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { ForgejoRepositoryForm } from "@/components/git/ForgejoRepositoryForm"; +import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog"; import { getForgejoRepository, 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 auth = useAuth(); const [repository, setRepository] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [deleteOpen, setDeleteOpen] = useState(false); + const [deleteError, setDeleteError] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); useEffect(() => { const fetchRepository = async () => { @@ -49,7 +57,9 @@ export default function ForgejoRepositoriesEditPage({ params }: { params: RouteP setRepository(data); setError(null); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load repository"); + setError( + err instanceof Error ? err.message : "Failed to load repository", + ); } finally { setIsLoading(false); } @@ -61,26 +71,22 @@ export default function ForgejoRepositoriesEditPage({ params }: { params: RouteP }, [params.repositoryId, auth.isSignedIn]); const handleSubmit = async (values: ForgejoRepositoryUpdate) => { - try { - const repository = await updateForgejoRepository(params.repositoryId, values); - console.log("Repository updated:", repository); - router.push("/git-projects/repositories"); - } catch (err) { - alert(err instanceof Error ? err.message : "Failed to update repository"); - } + await updateForgejoRepository(params.repositoryId, values); + router.push("/git-projects/repositories"); }; const handleDelete = async () => { - if ( - confirm(`Are you sure you want to delete "${repository?.display_name || repository?.repo}"? This action cannot be undone.`) - ) { - try { - await deleteForgejoRepository(params.repositoryId); - console.log("Repository deleted"); - router.push("/git-projects/repositories"); - } catch (err) { - alert(err instanceof Error ? err.message : "Failed to delete repository"); - } + setIsDeleting(true); + setDeleteError(null); + try { + await deleteForgejoRepository(params.repositoryId); + router.push("/git-projects/repositories"); + } catch (err) { + 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", signUpForceRedirectUrl: "/git-projects/repositories", }} - title="Loading..." + title="Loading…" stickyHeader > -

Loading repository...

+

Loading repository…

); } @@ -111,7 +117,9 @@ export default function ForgejoRepositoriesEditPage({ params }: { params: RouteP title="Error" stickyHeader > -

{error || "Repository not found"}

+

+ {error || "Repository not found"} +

); } @@ -131,30 +139,46 @@ export default function ForgejoRepositoriesEditPage({ params }: { params: RouteP forceRedirectUrl: "/git-projects/repositories", signUpForceRedirectUrl: "/git-projects/repositories", }} - title={`Edit Repository: ${repository.display_name || repository.repo}`} - description="Update repository settings and tracking options." + title={`Edit Git Project Repository: ${repository.display_name || repository.repo}`} + description="Update the repository settings Pipeline uses for Git Projects." stickyHeader > -
+
-
-

Danger Zone

-

- Deleting a repository will remove all associated data including issues and pull requests. +

+

+ Delete Repository +

+

+ Remove this repository from Pipeline. Synced issue records for this + repository will be removed.

+ ); } diff --git a/frontend/src/app/git-projects/repositories/new/page.tsx b/frontend/src/app/git-projects/repositories/new/page.tsx index 9218125..fd2ba45 100644 --- a/frontend/src/app/git-projects/repositories/new/page.tsx +++ b/frontend/src/app/git-projects/repositories/new/page.tsx @@ -4,33 +4,31 @@ import { useRouter } from "next/navigation"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; 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() { const router = useRouter(); const handleSubmit = async (values: ForgejoRepositoryCreate) => { - try { - const repository = await createForgejoRepository(values); - alert(`Repository "${repository.display_name || repository.repo}" added successfully`); - router.push("/git-projects/repositories"); - } catch (err) { - alert(err instanceof Error ? err.message : "Failed to add repository"); - } + await createForgejoRepository(values); + router.push("/git-projects/repositories"); }; return ( -
+
diff --git a/frontend/src/app/git-projects/repositories/page.tsx b/frontend/src/app/git-projects/repositories/page.tsx index f5baac7..67b7c6a 100644 --- a/frontend/src/app/git-projects/repositories/page.tsx +++ b/frontend/src/app/git-projects/repositories/page.tsx @@ -2,11 +2,13 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; +import { AlertCircle, CheckCircle2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/auth/clerk"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { ForgejoRepositoriesTable } from "@/components/git/ForgejoRepositoriesTable"; +import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog"; import { getForgejoRepositories, deleteForgejoRepository, @@ -22,6 +24,15 @@ export default function ForgejoRepositoriesPage() { const [repositories, setRepositories] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [notice, setNotice] = useState<{ + tone: "success" | "error"; + message: string; + } | null>(null); + const [deleteTarget, setDeleteTarget] = useState( + null, + ); + const [deleteError, setDeleteError] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); useEffect(() => { const fetchRepositories = async () => { @@ -31,7 +42,9 @@ export default function ForgejoRepositoriesPage() { setRepositories(data); setError(null); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load repositories"); + setError( + err instanceof Error ? err.message : "Failed to load repositories", + ); } finally { setIsLoading(false); } @@ -42,37 +55,52 @@ export default function ForgejoRepositoriesPage() { } }, [auth.isSignedIn, auth.getToken]); - const handleDelete = async (repository: ForgejoRepository) => { - if ( - confirm(`Are you sure you want to delete "${repository.display_name || repository.repo}"? This action cannot be undone.`) - ) { - try { - await deleteForgejoRepository(repository.id); - setRepositories((prev) => prev.filter((r) => r.id !== repository.id)); - alert("Repository deleted successfully"); - } catch (err) { - alert(err instanceof Error ? err.message : "Failed to delete repository"); - } + const repositoryName = (repository: ForgejoRepository) => + repository.display_name || `${repository.owner}/${repository.repo}`; + + const handleDelete = (repository: ForgejoRepository) => { + setDeleteError(null); + setDeleteTarget(repository); + }; + + const confirmDelete = async () => { + if (!deleteTarget) return; + setIsDeleting(true); + setDeleteError(null); + try { + await deleteForgejoRepository(deleteTarget.id); + setRepositories((prev) => prev.filter((r) => r.id !== deleteTarget.id)); + setNotice({ + tone: "success", + message: `Deleted "${repositoryName(deleteTarget)}".`, + }); + setDeleteTarget(null); + } catch (err) { + setDeleteError( + err instanceof Error ? err.message : "Failed to delete repository", + ); + } finally { + setIsDeleting(false); } }; const handleSync = async (repository: ForgejoRepository) => { try { const result = await syncRepository(repository.id); - alert( - `Sync completed!\n\n` + - `Created: ${result.created}\n` + - `Updated: ${result.updated}\n` + - `Open: ${result.open}\n` + - `Closed: ${result.closed}\n` + - `Total: ${result.total}` - ); + setNotice({ + tone: "success", + message: `${repositoryName(repository)} synced: ${result.created} created, ${result.updated} updated, ${result.open} open, ${result.closed} closed.`, + }); // Refetch to update last_sync_at const data = await getForgejoRepositories(); setRepositories(data); return result; } 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; } }; @@ -80,59 +108,102 @@ export default function ForgejoRepositoriesPage() { const handleValidateRepository = async (repository: ForgejoRepository) => { try { const result = await validateRepository(repository.id); - if (result.ok) { - alert( - `Repository is valid!\n\n` + - `Repository exists: ${result.repo_exists ? "Yes" : "No"}` - ); + if (result.status.ok) { + setNotice({ + tone: "success", + message: `${repositoryName(repository)} is reachable from Pipeline.`, + }); } else { - alert( - `Repository validation failed: ${result.error_message || "Unknown error"}` - ); + setNotice({ + tone: "error", + message: `Repository validation failed: ${result.status.error_message || "Unknown error"}`, + }); } return result; } 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; } }; return ( - -
-
-

Repositories

- -
-
- {error ? ( -
-

{error}

+ <> + +
+ {notice ? ( +
+ {notice.tone === "success" ? ( + + ) : ( + + )} + {notice.message}
- ) : ( - - )} + ) : null} + +
+

Repositories

+ +
+
+ {error ? ( +
+

{error}

+
+ ) : ( + + )} +
-
- + + { + 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" + /> + ); } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 1bbfd89..629d049 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -2,6 +2,100 @@ @tailwind components; @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 { color-scheme: dark; --bg: #070b12; @@ -160,6 +254,10 @@ body { ); background-size: 120px 120px; } + /* Numbers-only Georgia font utility */ + .font-numeric { + font-family: "Georgia Numbers", "Georgia", "Times New Roman", serif; + } } .landing-page { diff --git a/frontend/src/components/git/BoardForgejoRepositoryLinks.tsx b/frontend/src/components/git/BoardForgejoRepositoryLinks.tsx index d05bc21..b147f28 100644 --- a/frontend/src/components/git/BoardForgejoRepositoryLinks.tsx +++ b/frontend/src/components/git/BoardForgejoRepositoryLinks.tsx @@ -1,261 +1,393 @@ "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 { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; 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 = { - id: string; - board_id: string; - repository_id: string; - organization_id: string; - created_at: string; +interface BoardForgejoRepositoryLinksProps { + boardId: string; + canWrite?: boolean; +} + +type LinkedRepository = BoardForgejoRepositoryLink & { repository: ForgejoRepository; }; -type BoardForgejoRepositoryLinksProps = { - boardId: string; - canWrite: boolean; -}; +const normalizeBoardLinks = ( + result: BoardForgejoRepositoriesResponse, +): BoardForgejoRepositoryLink[] => + Array.isArray(result) ? result : (result.repositories ?? []); + +const repositoryDisplayName = (repository: ForgejoRepository): string => + repository.display_name || `${repository.owner}/${repository.repo}`; export function BoardForgejoRepositoryLinks({ boardId, - canWrite, + canWrite = false, }: BoardForgejoRepositoryLinksProps) { - const [linkedRepos, setLinkedRepos] = useState([]); + const [linkedLinks, setLinkedLinks] = useState( + [], + ); const [allRepos, setAllRepos] = useState([]); - const [searchQuery, setSearchQuery] = useState(""); - const [loading, setLoading] = useState(true); - const [isLinking, setIsLinking] = useState(false); - const [unlinkRepo, setUnlinkRepo] = useState(null); + const [searchTerm, setSearchTerm] = useState(""); + const [isLoading, setIsLoading] = useState(true); + const [linkError, setLinkError] = useState(null); const [unlinkError, setUnlinkError] = useState(null); - const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isLinking, setIsLinking] = useState(false); + const [isUnlinking, setIsUnlinking] = useState(false); + const [unlinkTarget, setUnlinkTarget] = useState( + null, + ); const fetchLinkedRepos = useCallback(async () => { try { const result = await getBoardForgejoRepositories(boardId); - setLinkedRepos(result.repositories || []); + setLinkedLinks(normalizeBoardLinks(result)); + setLinkError(null); } 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]); + const fetchAllRepositories = useCallback(async () => { try { const repos = await getForgejoRepositories(); setAllRepos(repos); + setLinkError(null); } 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(() => { - setLoading(true); - Promise.all([fetchLinkedRepos(), fetchAllRepositories()]).finally(() => { - setLoading(false); - }); - }, [boardId, fetchLinkedRepos, fetchAllRepositories]); + let isMounted = true; + const loadRepositories = async () => { + setIsLoading(true); + await Promise.all([fetchLinkedRepos(), fetchAllRepositories()]); + if (isMounted) { + setIsLoading(false); + } + }; - const filteredRepos = useMemo(() => { - if (!searchQuery) return allRepos; - const query = searchQuery.toLowerCase(); - return allRepos.filter( - (r) => - (r.display_name && r.display_name.toLowerCase().includes(query)) || - r.owner.toLowerCase().includes(query) || - r.repo.toLowerCase().includes(query), - ); - }, [allRepos, searchQuery]); + loadRepositories(); + + return () => { + isMounted = false; + }; + }, [fetchAllRepositories, fetchLinkedRepos]); + + const linkedRepoIds = useMemo( + () => new Set(linkedLinks.map((link) => link.repository_id)), + [linkedLinks], + ); + + 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) => { - if (!canWrite) return; + if (!canWrite) { + return; + } + setIsLinking(true); + setLinkError(null); + try { await linkBoardForgejoRepository(boardId, repositoryId); await fetchLinkedRepos(); - setSearchQuery(""); } 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 { setIsLinking(false); } }; const handleUnlinkRepo = async () => { - if (!unlinkRepo) return; - setIsDialogOpen(false); + if (!unlinkTarget || !canWrite) { + return; + } + + setIsUnlinking(true); setUnlinkError(null); + try { - await unlinkBoardForgejoRepository(boardId, unlinkRepo); + await unlinkBoardForgejoRepository(boardId, unlinkTarget.repository_id); await fetchLinkedRepos(); + setUnlinkTarget(null); } 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); + } finally { + setIsUnlinking(false); } }; - const linkedRepoIds = useMemo(() => new Set(linkedRepos.map((l) => l.repository_id)), [linkedRepos]); - return ( -
-
-

- Linked Repositories -

-

- {linkedRepos.length} repository{linkedRepos.length === 1 ? "" : "s"} linked to this board -

-
+ <> +
+
+
+

+ Linked Git Project Repositories +

+

+ Choose which synced repositories appear on this Pipeline board. +

+
+ + {linkedRepos.length} linked + +
-
- setSearchQuery(e.target.value)} - className="w-[240px]" - /> - {canWrite && ( - + {linkError && ( +
+ + {linkError} +
)} -
- {loading ? ( -
Loading…
- ) : linkedRepos.length === 0 && allRepos.length === 0 ? ( -
-

- No repositories found. Configure Forgejo connections in Git Projects to start tracking repositories. -

-
- ) : linkedRepos.length === 0 ? ( -
-

- No repositories linked to this board. Link a repository to track its issues. -

-
- ) : ( -
- {linkedRepos.map((link) => ( -
-
-
-
- {link.repository.display_name || `${link.repository.owner}/${link.repository.repo}`} -
-
- Last sync: {link.repository.last_sync_at ? new Date(link.repository.last_sync_at).toLocaleDateString() : "Never"} + {isLoading ? ( +
+ + Loading Git Project repositories... +
+ ) : ( +
+
+
+ On This Board +
+ {linkedRepos.length === 0 ? ( +
+
+
+

+ No repositories linked yet +

+

+ {canWrite + ? "Link a Git Project repository below to bring its issues onto this board." + : "No Git Project repositories are linked to this board yet."} +

- {canWrite && ( - + ) : ( +
+ {linkedRepos.map((link) => { + const repository = link.repository; + + return ( +
+
+

+ {repositoryDisplayName(repository)} +

+

+ {repository.owner}/{repository.repo} +

+
+ {canWrite ? ( + + ) : null} +
+ ); + })} +
+ )} +
+ + {canWrite ? ( +
+
+
+
+ Available Repositories +
+

+ Link repositories that are already configured in Git + Projects. +

+
+ setSearchTerm(event.target.value)} + placeholder="Search repositories..." + className="w-full sm:w-72" + /> +
+ + {availableRepos.length === 0 ? ( +
+

+ {allRepos.length === 0 + ? "No Git Project repositories configured" + : "No matching repositories"} +

+

+ {allRepos.length === 0 + ? "Add repositories in Git Projects before linking them to boards." + : "Adjust the search or unlink a repository from this board."} +

+
+ ) : ( +
+ {availableRepos.slice(0, 9).map((repository) => ( +
+
+
+

+ {repositoryDisplayName(repository)} +

+ + {repository.active ? "Active" : "Paused"} + +
+

+ {repository.owner}/{repository.repo} +

+
+ +
+ ))} +
)}
-
- ))} -
- )} - - {canWrite && ( - <> -
-

- Available Repositories -

-
- {filteredRepos - .filter((r) => !linkedRepoIds.has(r.id)) - .slice(0, 9) - .map((repo) => ( -
-
- {repo.display_name || `${repo.owner}/${repo.repo}`} -
-
- - {repo.active ? "Active" : "Inactive"} - - {repo.last_sync_at && ( - - Synced {new Date(repo.last_sync_at).toLocaleDateString()} - - )} -
- -
- ))} -
+ ) : null}
- - )} + )} +
{ + if (!open && !isUnlinking) { + setUnlinkTarget(null); + setUnlinkError(null); + } + }} + title="Unlink Git Project repository" description={ - unlinkRepo - ? unlinkError - ? `Error: ${unlinkError}` - : "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." + unlinkTarget + ? `Remove "${repositoryDisplayName(unlinkTarget.repository)}" from this board? Issues from this repository will no longer appear on the board.` + : "Remove this repository from the board?" } - onConfirm={ - unlinkRepo ? handleUnlinkRepo : () => setIsDialogOpen(false) - } - isConfirming={isLinking || (!!unlinkRepo && unlinkError !== null)} - cancelLabel={unlinkRepo ? "Keep Linked" : "Cancel"} - confirmLabel={unlinkRepo ? "Unlink" : undefined} - errorStyle="panel" + onConfirm={handleUnlinkRepo} + isConfirming={isUnlinking} + errorMessage={unlinkError} + confirmLabel="Unlink Repository" + confirmingLabel="Unlinking..." + confirmClassName="bg-[color:var(--danger)] text-white hover:bg-[color:var(--danger)]/90" + cancelLabel="Keep Linked" /> -
+ ); } diff --git a/frontend/src/components/git/CloseForgejoIssueDialog.tsx b/frontend/src/components/git/CloseForgejoIssueDialog.tsx index d992bc1..ac77264 100644 --- a/frontend/src/components/git/CloseForgejoIssueDialog.tsx +++ b/frontend/src/components/git/CloseForgejoIssueDialog.tsx @@ -2,7 +2,14 @@ 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 type { ForgejoIssue } from "@/lib/api-forgejo"; import { closeForgejoIssue } from "@/lib/api-forgejo"; @@ -33,7 +40,8 @@ export function CloseForgejoIssueDialog({ onCloseSuccess(); onOpenChange(false); } 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); } finally { setIsClosing(false); @@ -42,25 +50,47 @@ export function CloseForgejoIssueDialog({ return ( - + - Close Issue + Close Git Project issue - Are you sure you want to close issue{" "} - #{issue.forgejo_issue_number} in{" "} - {issue.repository_id}? + Pipeline will mark issue{" "} + + #{issue.forgejo_issue_number} + {" "} + as closed in the connected Git provider and refresh the local issue + cache. +
+

+ {issue.title} +

+ {issue.body_preview ? ( +

+ {issue.body_preview} +

+ ) : null} +
{error && ( -
+
{error}
)} - - diff --git a/frontend/src/components/git/ForgejoConnectionForm.tsx b/frontend/src/components/git/ForgejoConnectionForm.tsx index 7ec6cf2..0a43505 100644 --- a/frontend/src/components/git/ForgejoConnectionForm.tsx +++ b/frontend/src/components/git/ForgejoConnectionForm.tsx @@ -12,6 +12,8 @@ interface ForgejoConnectionFormProps { defaultValues?: Partial; onSubmit: (values: ForgejoConnectionCreate) => Promise; isSubmitting?: boolean; + isTokenRequired?: boolean; + existingTokenLastEight?: string | null; title?: string; description?: string; submitLabel?: string; @@ -27,20 +29,30 @@ export function ForgejoConnectionForm({ defaultValues = {}, onSubmit, isSubmitting = false, - title = "Forgejo Connection", - description = "Connect a Forgejo instance to track issues and pull requests.", + isTokenRequired = true, + existingTokenLastEight, + title = "Git Project Connection", + description = "Connect a Git provider so Pipeline can track issues.", submitLabel = "Save Connection", }: ForgejoConnectionFormProps) { const [error, setError] = useState(null); + const [isSaving, setIsSaving] = useState(false); const [name, setName] = useState(defaultValues.name || ""); const [baseUrl, setBaseUrl] = useState(defaultValues.base_url || ""); 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) { e.preventDefault(); setError(null); try { + setIsSaving(true); await onSubmit({ name, base_url: baseUrl, @@ -48,17 +60,26 @@ export function ForgejoConnectionForm({ }); } catch (err) { setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setIsSaving(false); } } return ( -
+
-

{title}

- {description &&

{description}

} +
+

{title}

+ {description && ( +

{description}

+ )} +
{error && ( -
+

Configuration Error

{error}

@@ -72,12 +93,12 @@ export function ForgejoConnectionForm({ id="name" value={name} onChange={(e) => setName(e.target.value)} - placeholder="e.g., Dream Forgejo" - disabled={isSubmitting} + placeholder="Team Git" + disabled={isBusy} required /> -

- A memorable name for this Forgejo connection. +

+ A memorable name for this Git Projects connection.

@@ -89,12 +110,13 @@ export function ForgejoConnectionForm({ id="base_url" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} - placeholder="https://dream.scheller.ltd" - disabled={isSubmitting} + placeholder="https://git.example.com" + disabled={isBusy} required /> -

- The base URL of your Forgejo instance (without trailing slash). +

+ The base URL of your Git provider instance, without a trailing + slash.

@@ -107,25 +129,34 @@ export function ForgejoConnectionForm({ type="password" value={token} onChange={(e) => setToken(e.target.value)} - placeholder="••••••••" - disabled={isSubmitting} - required + placeholder={ + isTokenRequired + ? "Paste token" + : existingTokenLastEight + ? `Current token ends in ${existingTokenLastEight}` + : "Paste token" + } + disabled={isBusy} + required={isTokenRequired} /> -

- Forgejo personal access token with repo permissions. Token is stored securely and never displayed. -

+

{tokenHelpText}

-
- - )} -
); } @@ -246,7 +219,7 @@ export function ConnectionsTableToggle({ return (
- Show: + Show: ); }, }, ], - [], + [handleCloseClick], ); + const table = useReactTable({ + data: issues, + columns, + getCoreRowModel: getCoreRowModel(), + }); return ( <> - - - - - - ), - title: "No issues found", - description: "Sync a repository to pull in issues, or adjust your filters.", - }} - /> +
+ , + title: "No Git Project issues found", + description: + "Sync a repository to pull issues into Pipeline, or adjust your filters.", + }} + /> +
void; onDelete?: (repository: ForgejoRepository) => void; - onSync?: (repository: ForgejoRepository) => void; - onValidate?: (repository: ForgejoRepository) => void; + onSync?: (repository: ForgejoRepository) => Promise; + onValidate?: ( + repository: ForgejoRepository, + ) => Promise; } export function ForgejoRepositoriesTable({ @@ -48,23 +60,10 @@ export function ForgejoRepositoriesTable({ table={table} isLoading={isLoading} emptyState={{ - icon: ( - - - - - ), - title: "No repositories tracked yet", + icon: , + title: "No Git Project repositories yet", 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", actionLabel: "Add repository", }} @@ -72,13 +71,16 @@ export function ForgejoRepositoriesTable({ getEditHref: (row) => `/git-projects/repositories/${row.id}/edit`, onDelete: onDelete ?? undefined, }} + tableClassName="min-w-[860px] w-full text-left text-sm" /> ); } const columns = ( - onSync?: (repository: ForgejoRepository) => void, - onValidate?: (repository: ForgejoRepository) => void + onSync?: (repository: ForgejoRepository) => Promise, + onValidate?: ( + repository: ForgejoRepository, + ) => Promise, ): ColumnDef[] => [ { accessorKey: "displayName", @@ -87,7 +89,7 @@ const columns = ( )} @@ -296,18 +264,12 @@ function ActionsCell({ {isValidateLoading ? ( ) : validateResult?.ok ? ( - + ) : ( )} )} -
); } @@ -323,10 +285,10 @@ export function RepositoriesTableFilter({ return ( 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 (
- Show: + Show:
-
- - ) : ( @@ -204,10 +206,7 @@ export function DataTable({ /> ) : ( - + {emptyMessage} diff --git a/frontend/src/components/tables/cell-formatters.tsx b/frontend/src/components/tables/cell-formatters.tsx index 5107c63..318775e 100644 --- a/frontend/src/components/tables/cell-formatters.tsx +++ b/frontend/src/components/tables/cell-formatters.tsx @@ -40,14 +40,14 @@ export function linkifyCell({

{label}

{subtitle != null ? ( -

+

{subtitle}

) : null} @@ -60,7 +60,7 @@ export function linkifyCell({ href={href} title={title} 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, )} > @@ -82,7 +82,7 @@ export function dateCell( ) { const display = relative ? formatRelative(value) : formatTimestamp(value); return ( - + {display ?? fallback} ); diff --git a/frontend/src/components/ui/confirm-action-dialog.tsx b/frontend/src/components/ui/confirm-action-dialog.tsx index 3814dda..ee54e87 100644 --- a/frontend/src/components/ui/confirm-action-dialog.tsx +++ b/frontend/src/components/ui/confirm-action-dialog.tsx @@ -9,6 +9,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; type ConfirmActionDialogProps = { open: boolean; @@ -20,6 +21,8 @@ type ConfirmActionDialogProps = { errorMessage?: string | null; confirmLabel?: string; confirmingLabel?: string; + confirmVariant?: NonNullable; + confirmClassName?: string; cancelLabel?: string; cancelVariant?: NonNullable; errorStyle?: "text" | "panel"; @@ -36,6 +39,8 @@ export function ConfirmActionDialog({ errorMessage, confirmLabel = "Delete", confirmingLabel = "Deleting…", + confirmVariant = "primary", + confirmClassName, cancelLabel = "Cancel", cancelVariant = "outline", errorStyle = "panel", @@ -50,7 +55,7 @@ export function ConfirmActionDialog({ {errorMessage ? ( errorStyle === "text" ? ( -

{errorMessage}

+

{errorMessage}

) : (
{errorMessage} @@ -58,10 +63,19 @@ export function ConfirmActionDialog({ ) ) : null} - - diff --git a/frontend/src/components/ui/table-state.tsx b/frontend/src/components/ui/table-state.tsx index eb762fa..e27c1f2 100644 --- a/frontend/src/components/ui/table-state.tsx +++ b/frontend/src/components/ui/table-state.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import Link from "next/link"; +import { Loader2 } from "lucide-react"; import { buttonVariants } from "@/components/ui/button"; @@ -15,7 +16,10 @@ export function TableLoadingRow({ return ( - {label} +
+ + {label} +
); @@ -42,9 +46,11 @@ export function TableEmptyStateRow({
-
{icon}
-

{title}

-

{description}

+
+ {icon} +
+

{title}

+

{description}

{actionHref && actionLabel ? ( = { + data: T; + status: number; + headers: Headers; +}; -async function fetchJson(url: string, init?: RequestInit): Promise { - const response = await fetch(url, { - ...init, - 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(); +async function fetchJson(path: string, init?: RequestInit): Promise { + const response = await customFetch>(path, init ?? {}); + return response.data; } // Forgejo Connection API export async function getForgejoConnections(): Promise { - return fetchJson(`${API_BASE_URL}/api/v1/forgejo/connections`); + return fetchJson("/api/v1/forgejo/connections"); } export async function createForgejoConnection( data: ForgejoConnectionCreate, ): Promise { - return fetchJson( - `${API_BASE_URL}/api/v1/forgejo/connections`, - { - method: "POST", - body: JSON.stringify(data), - }, - ); + return fetchJson("/api/v1/forgejo/connections", { + method: "POST", + body: JSON.stringify(data), + }); } export async function getForgejoConnection( connectionId: string, ): Promise { return fetchJson( - `${API_BASE_URL}/api/v1/forgejo/connections/${connectionId}`, + `/api/v1/forgejo/connections/${connectionId}`, ); } @@ -106,7 +94,7 @@ export async function updateForgejoConnection( data: ForgejoConnectionUpdate, ): Promise { return fetchJson( - `${API_BASE_URL}/api/v1/forgejo/connections/${connectionId}`, + `/api/v1/forgejo/connections/${connectionId}`, { method: "PATCH", body: JSON.stringify(data), @@ -114,36 +102,36 @@ export async function updateForgejoConnection( ); } -export async function deleteForgejoConnection(connectionId: string): Promise { - await fetch(`${API_BASE_URL}/api/v1/forgejo/connections/${connectionId}`, { - method: "DELETE", - }); +export async function deleteForgejoConnection( + connectionId: string, +): Promise { + await customFetch>( + `/api/v1/forgejo/connections/${connectionId}`, + { + method: "DELETE", + }, + ); } // Forgejo Repository API export async function getForgejoRepositories(): Promise { - return fetchJson( - `${API_BASE_URL}/api/v1/forgejo/repositories`, - ); + return fetchJson("/api/v1/forgejo/repositories"); } export async function createForgejoRepository( data: ForgejoRepositoryCreate, ): Promise { - return fetchJson( - `${API_BASE_URL}/api/v1/forgejo/repositories`, - { - method: "POST", - body: JSON.stringify(data), - }, - ); + return fetchJson("/api/v1/forgejo/repositories", { + method: "POST", + body: JSON.stringify(data), + }); } export async function getForgejoRepository( repositoryId: string, ): Promise { return fetchJson( - `${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}`, + `/api/v1/forgejo/repositories/${repositoryId}`, ); } @@ -152,7 +140,7 @@ export async function updateForgejoRepository( data: ForgejoRepositoryUpdate, ): Promise { return fetchJson( - `${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}`, + `/api/v1/forgejo/repositories/${repositoryId}`, { method: "PATCH", body: JSON.stringify(data), @@ -160,43 +148,62 @@ export async function updateForgejoRepository( ); } -export async function deleteForgejoRepository(repositoryId: string): Promise { - await fetch(`${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}`, { - method: "DELETE", - }); +export async function deleteForgejoRepository( + repositoryId: string, +): Promise { + await customFetch>( + `/api/v1/forgejo/repositories/${repositoryId}`, + { + method: "DELETE", + }, + ); } // Forgejo Sync & Validation API -export async function syncRepository( - repositoryId: string, -): Promise<{ +export async function syncRepository(repositoryId: string): Promise<{ created: number; updated: number; open: number; closed: number; total: number; }> { - return fetchJson<{ created: number; updated: number; open: number; closed: number; total: number }>( - `${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}/sync`, - { - method: "POST", - }, - ); + return fetchJson<{ + created: number; + updated: number; + open: number; + closed: number; + total: number; + }>(`/api/v1/forgejo/repositories/${repositoryId}/sync`, { + 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( connectionId: string, -): Promise<{ - ok: boolean; - error_message?: string; - response_time_ms: number; -}> { - return fetchJson<{ - ok: boolean; - error_message?: string; - response_time_ms: number; - }>( - `${API_BASE_URL}/api/v1/forgejo/connections/${connectionId}/validate`, +): Promise { + return fetchJson( + `/api/v1/forgejo/connections/${connectionId}/validate`, { method: "POST", }, @@ -205,17 +212,9 @@ export async function validateConnection( export async function validateRepository( repositoryId: string, -): Promise<{ - ok: boolean; - repo_exists: boolean; - error_message?: string; -}> { - return fetchJson<{ - ok: boolean; - repo_exists: boolean; - error_message?: string; - }>( - `${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}/validate`, +): Promise { + return fetchJson( + `/api/v1/forgejo/repositories/${repositoryId}/validate`, { method: "POST", }, @@ -260,7 +259,8 @@ export async function getForgejoIssues(params?: { limit?: number; }): Promise { 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?.search) searchParams.set("search", params.search); if (params?.page) searchParams.set("page", params.page.toString()); @@ -268,83 +268,62 @@ export async function getForgejoIssues(params?: { const qs = searchParams.toString(); return fetchJson( - `${API_BASE_URL}/api/v1/forgejo/issues${qs ? `?${qs}` : ""}`, + `/api/v1/forgejo/issues${qs ? `?${qs}` : ""}`, ); } export async function getForgejoIssue(issueId: string): Promise { - return fetchJson( - `${API_BASE_URL}/api/v1/forgejo/issues/${issueId}`, - ); + return fetchJson(`/api/v1/forgejo/issues/${issueId}`); } -export async function closeForgejoIssue(issueId: string): Promise { - return fetchJson( - `${API_BASE_URL}/api/v1/forgejo/issues/${issueId}/close`, - { - method: "POST", - }, - ); +export async function closeForgejoIssue( + issueId: string, +): Promise { + return fetchJson(`/api/v1/forgejo/issues/${issueId}/close`, { + method: "POST", + }); } // Board Repository Linking API -export async function getBoardForgejoRepositories(boardId: string): Promise<{ - repositories: Array<{ - id: string; - board_id: string; - repository_id: string; - organization_id: string; - created_at: string; - repository: ForgejoRepository; - }>; -}> { - return fetchJson<{ - repositories: Array<{ - 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`, +export interface BoardForgejoRepositoryLink { + id: string; + board_id: string; + repository_id: string; + organization_id: string; + created_at: string; + repository?: ForgejoRepository; +} + +export type BoardForgejoRepositoriesResponse = + | BoardForgejoRepositoryLink[] + | { repositories: BoardForgejoRepositoryLink[] }; + +export async function getBoardForgejoRepositories( + boardId: string, +): Promise { + return fetchJson( + `/api/v1/boards/${boardId}/forgejo/repositories`, ); } export async function linkBoardForgejoRepository( boardId: string, repositoryId: string, -): Promise<{ - id: string; - board_id: string; - repository_id: string; - 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", - body: JSON.stringify({ repository_id: repositoryId }), - }, - ); +): Promise { + return fetchJson< + BoardForgejoRepositoryLink | { link?: BoardForgejoRepositoryLink } + >(`/api/v1/boards/${boardId}/forgejo/repositories`, { + method: "POST", + body: JSON.stringify({ repository_id: repositoryId }), + }); } export async function unlinkBoardForgejoRepository( boardId: string, repositoryId: string, ): Promise { - await fetch( - `${API_BASE_URL}/api/v1/boards/${boardId}/forgejo/repositories/${repositoryId}`, + await customFetch>( + `/api/v1/boards/${boardId}/forgejo/repositories/${repositoryId}`, { method: "DELETE", }, @@ -378,9 +357,10 @@ export async function getForgejoMetrics(params?: { }): Promise { const searchParams = new URLSearchParams(); 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(); return fetchJson( - `${API_BASE_URL}/api/v1/forgejo/metrics${qs ? `?${qs}` : ""}`, + `/api/v1/forgejo/metrics${qs ? `?${qs}` : ""}`, ); } diff --git a/frontend/tailwind.config.cjs b/frontend/tailwind.config.cjs index f46b047..da4ed85 100644 --- a/frontend/tailwind.config.cjs +++ b/frontend/tailwind.config.cjs @@ -8,6 +8,7 @@ module.exports = { heading: ["var(--font-heading)", "sans-serif"], body: ["var(--font-body)", "sans-serif"], display: ["var(--font-display)", "serif"], + numeric: ["Georgia Numbers", "Georgia", "Times New Roman", "serif"], }, }, },