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 ? (
<div className="flex w-full flex-col gap-4">
<BoardForgejoIssuesPanel boardId={boardId} />
<BoardForgejoIssuesPanel boardId={boardId} canClose={canWrite} />
<BoardForgejoRepositoryLinks boardId={boardId} canWrite={canWrite} />
</div>
) : null}

View File

@ -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}
/>

View File

@ -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<ForgejoIssue[]>([]);
@ -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<ForgejoIssueListResponse>,
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}
/>
)}

View File

@ -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({
<DialogHeader>
<DialogTitle>Close Git Project issue</DialogTitle>
<DialogDescription>
Pipeline will mark issue{" "}
Confirm closing{" "}
<span className="font-mono font-semibold text-strong">
#{issue.forgejo_issue_number}
</span>{" "}
as closed in the connected Git provider and refresh the local issue
cache.
{repositoryName}#{issue.forgejo_issue_number}
</span>
. Pipeline will close it in the connected Git provider and refresh
the local issue cache.
</DialogDescription>
</DialogHeader>
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3">

View File

@ -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<ForgejoIssue | null>(null);
const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false);
const repositoryNameById = useMemo(() => {
const map = new Map<string, string>();
for (const repository of repositories) {
@ -74,7 +81,6 @@ export function ForgejoIssuesTable({
}
return map;
}, [repositories]);
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({
data: issues,
@ -214,6 +249,23 @@ export function ForgejoIssuesTable({
}}
/>
</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}
/>
</>
);
}