diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx index 2ea3c85..f239136 100644 --- a/frontend/src/app/boards/[boardId]/page.tsx +++ b/frontend/src/app/boards/[boardId]/page.tsx @@ -3247,7 +3247,7 @@ export default function BoardDetailPage() { {canRead && boardId ? (
- +
) : null} diff --git a/frontend/src/app/git-projects/issues/page.tsx b/frontend/src/app/git-projects/issues/page.tsx index 25a4a2a..e53c26f 100644 --- a/frontend/src/app/git-projects/issues/page.tsx +++ b/frontend/src/app/git-projects/issues/page.tsx @@ -8,6 +8,11 @@ import { AlertCircle } from "lucide-react"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { Button } from "@/components/ui/button"; +import { ApiError } from "@/api/mutator"; +import { + type getMyMembershipApiV1OrganizationsMeMemberGetResponse, + useGetMyMembershipApiV1OrganizationsMeMemberGet, +} from "@/api/generated/organizations/organizations"; import { getForgejoIssues, getForgejoRepositories, @@ -79,6 +84,15 @@ export default function GitIssuesPage() { parsePositiveInteger(searchParams.get("page")), ); const limit = 30; + const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet< + getMyMembershipApiV1OrganizationsMeMemberGetResponse, + ApiError + >({ + query: { + enabled: true, + refetchOnMount: "always", + }, + }); useEffect(() => { (async () => { @@ -158,6 +172,19 @@ export default function GitIssuesPage() { const staleCutoffMs = useMemo(() => Date.now() - STALE_ISSUE_MS, []); const recentClosedCutoffMs = useMemo(() => Date.now() - RECENT_CLOSED_MS, []); + const canCloseIssues = useMemo(() => { + if (membershipQuery.data?.status !== 200) { + return false; + } + const member = membershipQuery.data.data; + if (["owner", "admin"].includes(member.role)) { + return true; + } + if (member.all_boards_write) { + return true; + } + return (member.board_access ?? []).some((entry) => entry.can_write); + }, [membershipQuery.data]); const visibleIssues = useMemo(() => { if (staleOnly) { return issues.filter((issue) => isStaleOpenIssue(issue, staleCutoffMs)); @@ -263,6 +290,7 @@ export default function GitIssuesPage() { issues={visibleIssues} repositories={repos} isLoading={isLoadingIssues} + canClose={canCloseIssues} onRefresh={handleRefresh} /> diff --git a/frontend/src/components/git/BoardForgejoIssuesPanel.tsx b/frontend/src/components/git/BoardForgejoIssuesPanel.tsx index 712017e..161acad 100644 --- a/frontend/src/components/git/BoardForgejoIssuesPanel.tsx +++ b/frontend/src/components/git/BoardForgejoIssuesPanel.tsx @@ -8,17 +8,18 @@ import { getForgejoIssues, type ForgejoIssue, type ForgejoRepository, - type ForgejoIssueListResponse, } from "@/lib/api-forgejo"; import { ForgejoIssuesTable } from "@/components/git/ForgejoIssuesTable"; interface BoardForgejoIssuesPanelProps { boardId: string; + canClose?: boolean; repositories?: ForgejoRepository[]; } export function BoardForgejoIssuesPanel({ boardId, + canClose = false, repositories = [], }: BoardForgejoIssuesPanelProps) { const [issues, setIssues] = useState([]); @@ -36,7 +37,7 @@ export function BoardForgejoIssuesPanel({ try { const [linksResult, issuesResult] = await Promise.all([ getBoardForgejoRepositories(boardId), - getForgejoIssues({ board_id: boardId, state: "open", limit: 50 }) as Promise, + getForgejoIssues({ board_id: boardId, state: "open", limit: 50 }), ]); if (cancelled) return; const links = Array.isArray(linksResult) @@ -70,7 +71,7 @@ export function BoardForgejoIssuesPanel({ board_id: boardId, state: "open", limit: 50, - }) as ForgejoIssueListResponse; + }); setIssues(issuesResult.items); } catch (err) { setError( @@ -124,6 +125,7 @@ export function BoardForgejoIssuesPanel({ issues={issues} repositories={repositories} isLoading={isLoading} + canClose={canClose} onRefresh={handleRefresh} /> )} diff --git a/frontend/src/components/git/CloseForgejoIssueDialog.tsx b/frontend/src/components/git/CloseForgejoIssueDialog.tsx index ac77264..ffea045 100644 --- a/frontend/src/components/git/CloseForgejoIssueDialog.tsx +++ b/frontend/src/components/git/CloseForgejoIssueDialog.tsx @@ -16,6 +16,7 @@ import { closeForgejoIssue } from "@/lib/api-forgejo"; type CloseForgejoIssueDialogProps = { issue: ForgejoIssue | null; + repositoryName: string; open: boolean; onOpenChange: (open: boolean) => void; onCloseSuccess: () => void; @@ -23,6 +24,7 @@ type CloseForgejoIssueDialogProps = { export function CloseForgejoIssueDialog({ issue, + repositoryName, open, onOpenChange, onCloseSuccess, @@ -54,12 +56,12 @@ export function CloseForgejoIssueDialog({ Close Git Project issue - Pipeline will mark issue{" "} + Confirm closing{" "} - #{issue.forgejo_issue_number} - {" "} - as closed in the connected Git provider and refresh the local issue - cache. + {repositoryName}#{issue.forgejo_issue_number} + + . Pipeline will close it in the connected Git provider and refresh + the local issue cache.
diff --git a/frontend/src/components/git/ForgejoIssuesTable.tsx b/frontend/src/components/git/ForgejoIssuesTable.tsx index 96c2357..5fdd22c 100644 --- a/frontend/src/components/git/ForgejoIssuesTable.tsx +++ b/frontend/src/components/git/ForgejoIssuesTable.tsx @@ -1,18 +1,20 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { type ColumnDef, getCoreRowModel, useReactTable, } from "@tanstack/react-table"; -import { CircleDot, ExternalLink } from "lucide-react"; +import { CircleDot, ExternalLink, XCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { DataTable } from "@/components/tables/DataTable"; import type { ForgejoIssue, ForgejoIssueLabel } from "@/lib/api-forgejo"; import type { ForgejoRepository } from "@/lib/api-forgejo"; +import { CloseForgejoIssueDialog } from "@/components/git/CloseForgejoIssueDialog"; /** Normalize a Forgejo label color to a valid 6-char hex string or null. */ function normalizeLabelColor(raw: string | null | undefined): string | null { @@ -55,6 +57,7 @@ export type ForgejoIssuesTableProps = { issues: ForgejoIssue[]; repositories: ForgejoRepository[]; isLoading?: boolean; + canClose?: boolean; onRefresh: () => void; }; @@ -62,8 +65,12 @@ export function ForgejoIssuesTable({ issues, repositories, isLoading = false, - onRefresh: _onRefresh, + canClose = false, + onRefresh, }: ForgejoIssuesTableProps) { + const [issueToClose, setIssueToClose] = useState(null); + const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false); + const repositoryNameById = useMemo(() => { const map = new Map(); for (const repository of repositories) { @@ -74,7 +81,6 @@ export function ForgejoIssuesTable({ } return map; }, [repositories]); - const columns: ColumnDef[] = useMemo( () => [ { @@ -189,8 +195,37 @@ export function ForgejoIssuesTable({ } }, }, + { + id: "actions", + header: "Actions", + cell: ({ row }) => { + const issue = row.original; + const canShowClose = + canClose && issue.state === "open" && !issue.is_pull_request; + if (!canShowClose) { + return null; + } + const repositoryName = + repositoryNameById.get(issue.repository_id) ?? issue.repository_id; + return ( + + ); + }, + }, ], - [repositoryNameById], + [canClose, repositoryNameById], ); const table = useReactTable({ data: issues, @@ -214,6 +249,23 @@ export function ForgejoIssuesTable({ }} />
+ { + setIsCloseDialogOpen(nextOpen); + if (!nextOpen) { + setIssueToClose(null); + } + }} + onCloseSuccess={onRefresh} + /> ); }