feat: Add Close Issue Action

This commit is contained in:
null 2026-05-20 03:35:26 -05:00
parent d85912c4c9
commit 7b20d2c26d
5 changed files with 98 additions and 14 deletions

View File

@ -3247,7 +3247,7 @@ export default function BoardDetailPage() {
{canRead && boardId ? ( {canRead && boardId ? (
<div className="flex w-full flex-col gap-4"> <div className="flex w-full flex-col gap-4">
<BoardForgejoIssuesPanel boardId={boardId} /> <BoardForgejoIssuesPanel boardId={boardId} canClose={canWrite} />
<BoardForgejoRepositoryLinks boardId={boardId} canWrite={canWrite} /> <BoardForgejoRepositoryLinks boardId={boardId} canWrite={canWrite} />
</div> </div>
) : null} ) : null}

View File

@ -8,6 +8,11 @@ import { AlertCircle } from "lucide-react";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ApiError } from "@/api/mutator";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import { import {
getForgejoIssues, getForgejoIssues,
getForgejoRepositories, getForgejoRepositories,
@ -79,6 +84,15 @@ export default function GitIssuesPage() {
parsePositiveInteger(searchParams.get("page")), parsePositiveInteger(searchParams.get("page")),
); );
const limit = 30; const limit = 30;
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
ApiError
>({
query: {
enabled: true,
refetchOnMount: "always",
},
});
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@ -158,6 +172,19 @@ export default function GitIssuesPage() {
const staleCutoffMs = useMemo(() => Date.now() - STALE_ISSUE_MS, []); const staleCutoffMs = useMemo(() => Date.now() - STALE_ISSUE_MS, []);
const recentClosedCutoffMs = useMemo(() => Date.now() - RECENT_CLOSED_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(() => { const visibleIssues = useMemo(() => {
if (staleOnly) { if (staleOnly) {
return issues.filter((issue) => isStaleOpenIssue(issue, staleCutoffMs)); return issues.filter((issue) => isStaleOpenIssue(issue, staleCutoffMs));
@ -263,6 +290,7 @@ export default function GitIssuesPage() {
issues={visibleIssues} issues={visibleIssues}
repositories={repos} repositories={repos}
isLoading={isLoadingIssues} isLoading={isLoadingIssues}
canClose={canCloseIssues}
onRefresh={handleRefresh} onRefresh={handleRefresh}
/> />

View File

@ -8,17 +8,18 @@ import {
getForgejoIssues, getForgejoIssues,
type ForgejoIssue, type ForgejoIssue,
type ForgejoRepository, type ForgejoRepository,
type ForgejoIssueListResponse,
} from "@/lib/api-forgejo"; } from "@/lib/api-forgejo";
import { ForgejoIssuesTable } from "@/components/git/ForgejoIssuesTable"; import { ForgejoIssuesTable } from "@/components/git/ForgejoIssuesTable";
interface BoardForgejoIssuesPanelProps { interface BoardForgejoIssuesPanelProps {
boardId: string; boardId: string;
canClose?: boolean;
repositories?: ForgejoRepository[]; repositories?: ForgejoRepository[];
} }
export function BoardForgejoIssuesPanel({ export function BoardForgejoIssuesPanel({
boardId, boardId,
canClose = false,
repositories = [], repositories = [],
}: BoardForgejoIssuesPanelProps) { }: BoardForgejoIssuesPanelProps) {
const [issues, setIssues] = useState<ForgejoIssue[]>([]); const [issues, setIssues] = useState<ForgejoIssue[]>([]);
@ -36,7 +37,7 @@ export function BoardForgejoIssuesPanel({
try { try {
const [linksResult, issuesResult] = await Promise.all([ const [linksResult, issuesResult] = await Promise.all([
getBoardForgejoRepositories(boardId), getBoardForgejoRepositories(boardId),
getForgejoIssues({ board_id: boardId, state: "open", limit: 50 }) as Promise<ForgejoIssueListResponse>, getForgejoIssues({ board_id: boardId, state: "open", limit: 50 }),
]); ]);
if (cancelled) return; if (cancelled) return;
const links = Array.isArray(linksResult) const links = Array.isArray(linksResult)
@ -70,7 +71,7 @@ export function BoardForgejoIssuesPanel({
board_id: boardId, board_id: boardId,
state: "open", state: "open",
limit: 50, limit: 50,
}) as ForgejoIssueListResponse; });
setIssues(issuesResult.items); setIssues(issuesResult.items);
} catch (err) { } catch (err) {
setError( setError(
@ -124,6 +125,7 @@ export function BoardForgejoIssuesPanel({
issues={issues} issues={issues}
repositories={repositories} repositories={repositories}
isLoading={isLoading} isLoading={isLoading}
canClose={canClose}
onRefresh={handleRefresh} onRefresh={handleRefresh}
/> />
)} )}

View File

@ -16,6 +16,7 @@ import { closeForgejoIssue } from "@/lib/api-forgejo";
type CloseForgejoIssueDialogProps = { type CloseForgejoIssueDialogProps = {
issue: ForgejoIssue | null; issue: ForgejoIssue | null;
repositoryName: string;
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onCloseSuccess: () => void; onCloseSuccess: () => void;
@ -23,6 +24,7 @@ type CloseForgejoIssueDialogProps = {
export function CloseForgejoIssueDialog({ export function CloseForgejoIssueDialog({
issue, issue,
repositoryName,
open, open,
onOpenChange, onOpenChange,
onCloseSuccess, onCloseSuccess,
@ -54,12 +56,12 @@ export function CloseForgejoIssueDialog({
<DialogHeader> <DialogHeader>
<DialogTitle>Close Git Project issue</DialogTitle> <DialogTitle>Close Git Project issue</DialogTitle>
<DialogDescription> <DialogDescription>
Pipeline will mark issue{" "} Confirm closing{" "}
<span className="font-mono font-semibold text-strong"> <span className="font-mono font-semibold text-strong">
#{issue.forgejo_issue_number} {repositoryName}#{issue.forgejo_issue_number}
</span>{" "} </span>
as closed in the connected Git provider and refresh the local issue . Pipeline will close it in the connected Git provider and refresh
cache. the local issue cache.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3"> <div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3">

View File

@ -1,18 +1,20 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { import {
type ColumnDef, type ColumnDef,
getCoreRowModel, getCoreRowModel,
useReactTable, useReactTable,
} from "@tanstack/react-table"; } 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 { Badge } from "@/components/ui/badge";
import { DataTable } from "@/components/tables/DataTable"; import { DataTable } from "@/components/tables/DataTable";
import type { ForgejoIssue, ForgejoIssueLabel } from "@/lib/api-forgejo"; import type { ForgejoIssue, ForgejoIssueLabel } from "@/lib/api-forgejo";
import type { ForgejoRepository } 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. */ /** Normalize a Forgejo label color to a valid 6-char hex string or null. */
function normalizeLabelColor(raw: string | null | undefined): string | null { function normalizeLabelColor(raw: string | null | undefined): string | null {
@ -55,6 +57,7 @@ export type ForgejoIssuesTableProps = {
issues: ForgejoIssue[]; issues: ForgejoIssue[];
repositories: ForgejoRepository[]; repositories: ForgejoRepository[];
isLoading?: boolean; isLoading?: boolean;
canClose?: boolean;
onRefresh: () => void; onRefresh: () => void;
}; };
@ -62,8 +65,12 @@ export function ForgejoIssuesTable({
issues, issues,
repositories, repositories,
isLoading = false, isLoading = false,
onRefresh: _onRefresh, canClose = false,
onRefresh,
}: ForgejoIssuesTableProps) { }: ForgejoIssuesTableProps) {
const [issueToClose, setIssueToClose] = useState<ForgejoIssue | null>(null);
const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false);
const repositoryNameById = useMemo(() => { const repositoryNameById = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
for (const repository of repositories) { for (const repository of repositories) {
@ -74,7 +81,6 @@ export function ForgejoIssuesTable({
} }
return map; return map;
}, [repositories]); }, [repositories]);
const columns: ColumnDef<ForgejoIssue>[] = useMemo( const columns: ColumnDef<ForgejoIssue>[] = 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 (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-[color:var(--danger)] hover:bg-[color:rgba(248,113,113,0.08)]"
title={`Close ${repositoryName}#${issue.forgejo_issue_number}`}
onClick={() => {
setIssueToClose(issue);
setIsCloseDialogOpen(true);
}}
>
<XCircle className="h-4 w-4" />
</Button>
);
},
},
], ],
[repositoryNameById], [canClose, repositoryNameById],
); );
const table = useReactTable({ const table = useReactTable({
data: issues, data: issues,
@ -214,6 +249,23 @@ export function ForgejoIssuesTable({
}} }}
/> />
</div> </div>
<CloseForgejoIssueDialog
issue={issueToClose}
repositoryName={
issueToClose
? (repositoryNameById.get(issueToClose.repository_id) ??
issueToClose.repository_id)
: "Repository"
}
open={isCloseDialogOpen}
onOpenChange={(nextOpen) => {
setIsCloseDialogOpen(nextOpen);
if (!nextOpen) {
setIssueToClose(null);
}
}}
onCloseSuccess={onRefresh}
/>
</> </>
); );
} }