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 0000000..8d5fdfa Binary files /dev/null and b/frontend/public/fonts/georgia-bold-italic.woff2 differ diff --git a/frontend/public/fonts/georgia-bold.woff2 b/frontend/public/fonts/georgia-bold.woff2 new file mode 100644 index 0000000..7008a0e Binary files /dev/null and b/frontend/public/fonts/georgia-bold.woff2 differ diff --git a/frontend/public/fonts/georgia-italic.woff2 b/frontend/public/fonts/georgia-italic.woff2 new file mode 100644 index 0000000..845339a Binary files /dev/null and b/frontend/public/fonts/georgia-italic.woff2 differ diff --git a/frontend/public/fonts/georgia-regular.woff2 b/frontend/public/fonts/georgia-regular.woff2 new file mode 100644 index 0000000..9c54dd7 Binary files /dev/null and b/frontend/public/fonts/georgia-regular.woff2 differ 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"], }, }, },