diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 2a4134e139..8ea4b64f91 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -2446,6 +2446,31 @@ export function apiFetchGitHubPullDetail(repo: string, number: number): Promise< }); } +/* +FNXC:GitHubImport 2026-06-23-03:15: +Per-issue detail for the Import Tasks issue preview pane. Mirrors apiFetchGitHubPullDetail: `gh issue list` has no comment thread, so the preview fetches the FULL comment thread ON SELECTION (never for the whole list). +Issues have no checks rollup, so only `comments` is returned. +*/ +export interface GitHubIssueDetail { + comments: Array<{ author: string; body: string; createdAt: string }>; +} + +/** Fetch the full comment thread for a single GitHub issue (called on selection in the import preview). */ +export function apiFetchGitHubIssueDetail(repo: string, number: number): Promise { + return api("/github/issues/detail", { + method: "POST", + body: JSON.stringify({ repo, number }), + }); +} + +/** Close a GitHub issue (Close issue button in the import preview). */ +export async function apiCloseGitHubIssue(repo: string, number: number): Promise { + await api<{ ok: boolean }>("/github/issues/close", { + method: "POST", + body: JSON.stringify({ repo, number }), + }); +} + /** Import a specific GitHub pull request as a fn review task */ export function apiImportGitHubPull(owner: string, repo: string, prNumber: number, projectId?: string): Promise { return api(withProjectId("/github/pulls/import", projectId), { diff --git a/packages/dashboard/app/components/GitHubImportModal.css b/packages/dashboard/app/components/GitHubImportModal.css index faadeacefb..0c02259b2f 100644 --- a/packages/dashboard/app/components/GitHubImportModal.css +++ b/packages/dashboard/app/components/GitHubImportModal.css @@ -237,6 +237,40 @@ margin-left:auto keeps it pinned right even when the mobile Back button is absen gap: var(--space-xs); } +/* +FNXC:GitHubImport 2026-06-23-03:15: +Close-issue action sits just left of the top Import action in the preview header. It is the lighter (non-primary) button; flex-shrink:0 keeps it on one line next to Import even on a narrow preview pane. +*/ +.github-import-issue-close-top { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: var(--space-xs); +} + +/* +FNXC:GitHubImport 2026-06-23-03:15: +Transient inline toast confirming issue close. Sits directly under the preview header; success/error use theme tokens only. Auto-dismisses via component timer. +*/ +.github-import-close-toast { + margin-bottom: var(--space-sm); + padding: var(--space-xs) var(--space-sm); + border-radius: var(--radius-sm); + font-size: 12px; + border: 1px solid var(--border); + color: var(--text); +} + +.github-import-close-toast--success { + border-color: var(--color-success); + color: var(--color-success); +} + +.github-import-close-toast--error { + border-color: var(--color-error); + color: var(--color-error); +} + .github-import-pane-content { flex: 1; min-height: 0; diff --git a/packages/dashboard/app/components/GitHubImportModal.tsx b/packages/dashboard/app/components/GitHubImportModal.tsx index deacc4fa21..b1b988a3e4 100644 --- a/packages/dashboard/app/components/GitHubImportModal.tsx +++ b/packages/dashboard/app/components/GitHubImportModal.tsx @@ -8,11 +8,14 @@ import { apiImportGitHubIssue, apiFetchGitHubPulls, apiFetchGitHubPullDetail, + apiFetchGitHubIssueDetail, + apiCloseGitHubIssue, apiImportGitHubPull, fetchGitRemotes, type GitHubIssue, type GitHubPull, type GitHubPullDetail, + type GitHubIssueDetail, type GitRemote, } from "../api"; import { Loader2, RefreshCw, ArrowLeft, GitPullRequest, CircleDot } from "lucide-react"; @@ -109,6 +112,28 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, // Guards against a stale in-flight detail response overwriting a newer selection. const pullDetailRequestRef = useRef(0); + /* + FNXC:GitHubImport 2026-06-23-03:15: + The issue preview pane mirrors the PR preview: the SELECTED issue's full comment thread is fetched ON SELECTION (issues have no checks rollup, so comments only). + Cached by issue number in a ref so re-selecting does not refetch; the body renders immediately while comments stream in (loading/error tracked separately, never blocking the body). + */ + const issueDetailCacheRef = useRef>(new Map()); + const [issueDetail, setIssueDetail] = useState(null); + const [issueDetailLoading, setIssueDetailLoading] = useState(false); + const [issueDetailError, setIssueDetailError] = useState(null); + // Guards against a stale in-flight issue-detail response overwriting a newer selection. + const issueDetailRequestRef = useRef(0); + + /* + FNXC:GitHubImport 2026-06-23-03:15: + Close-issue UX: clicking "Close issue" calls apiCloseGitHubIssue, then reflects the closed state locally (closedIssueNumbers set) WITHOUT dismissing the view. + A transient inline toast confirms success/failure (the modal has no toast prop). Only OPEN issues show the button; closing disables it and flips the local state badge to closed. + */ + const [closedIssueNumbers, setClosedIssueNumbers] = useState>(new Set()); + const [closingIssue, setClosingIssue] = useState(false); + const [closeToast, setCloseToast] = useState<{ type: "success" | "error"; message: string } | null>(null); + const closeToastTimerRef = useRef | null>(null); + const [error, setError] = useState(null); const [isIssuesEmptyState, setIsIssuesEmptyState] = useState(false); const [isPullsEmptyState, setIsPullsEmptyState] = useState(false); @@ -602,8 +627,85 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, }); }, [activeTab, selectedPullNumber, owner, repo]); + /* + FNXC:GitHubImport 2026-06-23-03:15: + Fetch the selected issue's comments on selection. Serves from the per-number cache on re-select; otherwise fetches and caches. + Body render is never blocked on this — the body shows immediately and comments populate when this resolves. Mirrors the PR detail effect. + */ + useEffect(() => { + if (activeTab !== "issues" || selectedIssueNumber === null || !owner.trim() || !repo.trim()) { + setIssueDetail(null); + setIssueDetailLoading(false); + setIssueDetailError(null); + return; + } + + const cached = issueDetailCacheRef.current.get(selectedIssueNumber); + if (cached) { + setIssueDetail(cached); + setIssueDetailLoading(false); + setIssueDetailError(null); + return; + } + + const requestId = ++issueDetailRequestRef.current; + setIssueDetail(null); + setIssueDetailLoading(true); + setIssueDetailError(null); + + apiFetchGitHubIssueDetail(`${owner.trim()}/${repo.trim()}`, selectedIssueNumber) + .then((detail) => { + issueDetailCacheRef.current.set(selectedIssueNumber, detail); + if (issueDetailRequestRef.current !== requestId) return; + setIssueDetail(detail); + setIssueDetailLoading(false); + }) + .catch((err: unknown) => { + if (issueDetailRequestRef.current !== requestId) return; + setIssueDetailError(getErrorMessage(err)); + setIssueDetailLoading(false); + }); + }, [activeTab, selectedIssueNumber, owner, repo]); + + // FNXC:GitHubImport 2026-06-23-03:15: Clear the transient close toast timer on unmount. + useEffect(() => () => { + if (closeToastTimerRef.current) clearTimeout(closeToastTimerRef.current); + }, []); + + /* + FNXC:GitHubImport 2026-06-23-03:15: + Close the selected issue: calls apiCloseGitHubIssue, marks the number closed locally (so the badge/button reflect it) WITHOUT dismissing the view, and shows a transient inline toast. + */ + const handleCloseIssue = useCallback(async () => { + if (selectedIssueNumber === null || !owner.trim() || !repo.trim()) return; + const issueNumber = selectedIssueNumber; + setClosingIssue(true); + if (closeToastTimerRef.current) clearTimeout(closeToastTimerRef.current); + setCloseToast(null); + try { + await apiCloseGitHubIssue(`${owner.trim()}/${repo.trim()}`, issueNumber); + setClosedIssueNumbers((prev) => { + const next = new Set(prev); + next.add(issueNumber); + return next; + }); + setCloseToast({ type: "success", message: t("git.issueClosedToast", "Issue #{{number}} closed", { number: issueNumber }) }); + } catch (err: unknown) { + setCloseToast({ type: "error", message: getErrorMessage(err) }); + } finally { + setClosingIssue(false); + closeToastTimerRef.current = setTimeout(() => setCloseToast(null), 4000); + } + }, [selectedIssueNumber, owner, repo, t]); + const selectedIssue = issues.find((i) => i.number === selectedIssueNumber); const selectedPull = pulls.find((p) => p.number === selectedPullNumber); + /* + FNXC:GitHubImport 2026-06-23-03:15: + An issue counts as closed if the upstream state is closed OR we closed it locally this session. Only OPEN issues show the Close button. + */ + const selectedIssueClosed = + !!selectedIssue && (selectedIssue.state === "closed" || closedIssueNumbers.has(selectedIssue.number)); if (!isOpen) return null; @@ -988,6 +1090,22 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, )}

{t("git.previewHeading", "Preview")}

+ {/* + FNXC:GitHubImport 2026-06-23-03:15: + Close-issue action sits next to the top Import action and acts on the selected OPEN issue. Hidden for the PR tab and for already-closed issues; disabled while a close request is in flight. + Closing reflects locally (badge flips to closed) without dismissing the preview. + */} + {activeTab === "issues" && selectedIssue && !selectedIssueClosed && ( + + )} + {/* + FNXC:GitHubImport 2026-06-23-03:15: + Transient inline toast confirms issue-close success/failure (the modal has no toast prop). Auto-dismisses; never blocks the preview. + */} + {closeToast && ( +
+ {closeToast.message} +
+ )}
{/* Issue preview */} @@ -1011,9 +1142,13 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
{t("git.previewIssueMeta", "Issue #{{number}}", { number: selectedIssue.number })}
{selectedIssue.title}
- {selectedIssue.state && ( - {selectedIssue.state} - )} + {/* FNXC:GitHubImport 2026-06-23-03:15: Badge reflects the local close (closedIssueNumbers) so closing the issue flips it to "closed" without a refetch. */} + {(() => { + const displayState = selectedIssueClosed ? "closed" : (selectedIssue.state ?? "open"); + return ( + {displayState} + ); + })()} {selectedIssue.author && ( {t("git.previewAuthor", "by {{author}}", { author: selectedIssue.author })} )} @@ -1039,6 +1174,37 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, {t("git.noDescription", "(no description)")}
)} + {/* + FNXC:GitHubImport 2026-06-23-03:15: + Comments render BELOW the issue body inside the already-scrollable preview pane. They stream in after the per-issue detail fetch resolves and never block the body above. + Mirrors the PR comments markup/classes; markdown via MailboxMessageContent with an empty state. + */} +
+
{t("git.commentsHeading", "Comments")}
+ {issueDetailLoading ? ( +
+
+ ) : issueDetailError ? ( +
{issueDetailError}
+ ) : issueDetail && issueDetail.comments.length > 0 ? ( +
    + {issueDetail.comments.map((comment, idx) => ( +
  • +
    {comment.author}
    + +
  • + ))} +
+ ) : ( +
{t("git.noComments", "No comments")}
+ )} +
) : activeTab === "issues" ? (
diff --git a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx index 34dff0ded0..1b8247fcbb 100644 --- a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx @@ -6,6 +6,8 @@ import { apiImportGitHubIssue, apiFetchGitHubPulls, apiFetchGitHubPullDetail, + apiFetchGitHubIssueDetail, + apiCloseGitHubIssue, apiImportGitHubPull, fetchGitRemotes, } from "../../api"; @@ -23,6 +25,8 @@ vi.mock("../../api", async (importOriginal) => { apiImportGitHubIssue: vi.fn(), apiFetchGitHubPulls: vi.fn(), apiFetchGitHubPullDetail: vi.fn(), + apiFetchGitHubIssueDetail: vi.fn(), + apiCloseGitHubIssue: vi.fn(), apiImportGitHubPull: vi.fn(), fetchGitRemotes: vi.fn(), }; @@ -98,11 +102,15 @@ describe("GitHubImportModal", () => { vi.mocked(apiImportGitHubIssue).mockReset(); vi.mocked(apiFetchGitHubPulls).mockReset(); vi.mocked(apiFetchGitHubPullDetail).mockReset(); + vi.mocked(apiFetchGitHubIssueDetail).mockReset(); + vi.mocked(apiCloseGitHubIssue).mockReset(); vi.mocked(apiImportGitHubPull).mockReset(); // Set default mock for apiFetchGitHubIssues to return empty array (prevents undefined issues state) vi.mocked(apiFetchGitHubIssues).mockResolvedValue([]); vi.mocked(apiFetchGitHubPulls).mockResolvedValue([]); vi.mocked(apiFetchGitHubPullDetail).mockResolvedValue({ comments: [], checks: [] }); + vi.mocked(apiFetchGitHubIssueDetail).mockResolvedValue({ comments: [] }); + vi.mocked(apiCloseGitHubIssue).mockResolvedValue(undefined); onClose.mockReset(); onImport.mockReset(); }); @@ -987,6 +995,89 @@ describe("GitHubImportModal", () => { expect(await screen.findByTestId("github-import-pr-comments-empty")).toBeTruthy(); }); + // FNXC:GitHubImport 2026-06-23-03:15: Selecting an issue fetches its detail and renders the full comment thread below the body (mirrors the PR tab; issues have no checks). + it("renders the selected issue's comments from the detail fetch", async () => { + Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1200 }); + + const issues = [ + { number: 7, title: "Detail Issue", body: "Issue body text", html_url: "https://github.com/owner/repo/issues/7", labels: [], state: "open" as const, author: "carol" }, + ]; + vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote); + vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues); + vi.mocked(apiFetchGitHubIssueDetail).mockResolvedValueOnce({ + comments: [ + { author: "alice", body: "First issue comment", createdAt: "2024-01-01T00:00:00Z" }, + { author: "bob", body: "Second issue comment", createdAt: "2024-01-02T00:00:00Z" }, + ], + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("Detail Issue")).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole("radio", { name: /Select issue #7/i })); + + // Detail fetch is scoped to the selected issue by "owner/repo" + number. + await waitFor(() => { + expect(vi.mocked(apiFetchGitHubIssueDetail)).toHaveBeenCalledWith("dustinbyrne/kb", 7); + }); + + const comments = await screen.findByTestId("github-import-issue-comments"); + + // Body still renders immediately, independent of detail. + expect(screen.getByTestId("github-import-preview-body").textContent).toContain("Issue body text"); + + // Full comment thread renders, chronological, with authors + bodies. + await waitFor(() => { + expect(comments.textContent).toContain("alice"); + expect(comments.textContent).toContain("First issue comment"); + expect(comments.textContent).toContain("bob"); + expect(comments.textContent).toContain("Second issue comment"); + }); + }); + + // FNXC:GitHubImport 2026-06-23-03:15: The Close issue button calls the close API and reflects the closed state locally without dismissing the preview. + it("closes the selected issue via the close API and reflects the closed state", async () => { + Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1200 }); + + const issues = [ + { number: 5, title: "Closable Issue", body: "Body", html_url: "https://github.com/owner/repo/issues/5", labels: [], state: "open" as const, author: "dave" }, + ]; + vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote); + vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues); + + render(); + + await waitFor(() => { + expect(screen.getByText("Closable Issue")).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole("radio", { name: /Select issue #5/i })); + + const closeButton = await screen.findByTestId("github-import-issue-close"); + fireEvent.click(closeButton); + + // Calls the close API scoped to "owner/repo" + number. + await waitFor(() => { + expect(vi.mocked(apiCloseGitHubIssue)).toHaveBeenCalledWith("dustinbyrne/kb", 5); + }); + + // Success toast surfaces without dismissing the preview. + expect(await screen.findByTestId("github-import-issue-close-toast")).toBeTruthy(); + + // Closed state reflects locally: badge flips to "closed" and the Close button is gone (only OPEN issues show it). + await waitFor(() => { + const previewCard = screen.getByTestId("github-import-preview-card"); + expect(within(previewCard).getByText("closed")).toBeTruthy(); + expect(screen.queryByTestId("github-import-issue-close")).toBeNull(); + }); + + // Preview is NOT dismissed. + expect(onClose).not.toHaveBeenCalled(); + }); + // FNXC:GitHubImport 2026-06-22-18:30: Desktop preview must show the FULL issue/PR body (no 200-char clamp). The list response already carries the complete body, so no detail fetch is needed. it("renders long selected issue body in full on desktop without a truncation ellipsis", async () => { Object.defineProperty(window, "innerWidth", { diff --git a/packages/dashboard/src/github.ts b/packages/dashboard/src/github.ts index d5261177e1..7621dc6d93 100644 --- a/packages/dashboard/src/github.ts +++ b/packages/dashboard/src/github.ts @@ -3709,6 +3709,135 @@ export class GitHubClient { return { comments, checks }; } + /* + FNXC:GitHubImport 2026-06-23-03:15: + Issues preview pane mirrors the PR preview: on selection it fetches the issue's full comment thread (issues have no checks rollup, so only comments). + `gh issue view --json comments` returns the conversation; REST `issues/{n}/comments` is the token fallback. 404 maps to "not found" upstream of the route. + */ + async getIssueDetail( + owner: string, + repo: string, + number: number + ): Promise<{ + comments: Array<{ author: string; body: string; createdAt: string }>; + }> { + if (this.hasGhAuth()) { + try { + return await this.getIssueDetailWithGh(owner, repo, number); + } catch (err) { + if (this.token) { + return this.getIssueDetailWithApi(owner, repo, number); + } + throw new Error(getGhErrorMessage(err)); + } + } + if (this.token) { + return this.getIssueDetailWithApi(owner, repo, number); + } + throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided. Run 'gh auth login' to authenticate."); + } + + private async getIssueDetailWithGh( + owner: string, + repo: string, + number: number + ): Promise<{ + comments: Array<{ author: string; body: string; createdAt: string }>; + }> { + const issue = await runGhJsonAsync<{ + comments?: Array<{ author?: { login?: string } | null; body?: string; createdAt?: string }>; + }>([ + "issue", "view", String(number), + "--repo", `${owner}/${repo}`, + "--json", "comments", + ]); + + const comments = (issue.comments ?? []).map((c) => ({ + author: c.author?.login ?? "unknown", + body: c.body ?? "", + createdAt: c.createdAt ?? "", + })); + + return { comments }; + } + + private async getIssueDetailWithApi( + owner: string, + repo: string, + number: number + ): Promise<{ + comments: Array<{ author: string; body: string; createdAt: string }>; + }> { + const headers = this.buildHeaders(); + + const commentsUrl = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}/comments?per_page=100`; + const commentsRes = await fetch(commentsUrl, { headers }); + if (!commentsRes.ok) { + if (commentsRes.status === 404) { + throw new Error(`Issue #${number} not found in ${owner}/${repo}`); + } + throw new Error(`GitHub API error: ${commentsRes.status} ${commentsRes.statusText}`); + } + const commentData = (await commentsRes.json()) as Array<{ + user?: { login?: string } | null; + body?: string; + created_at?: string; + }>; + const comments = commentData.map((c) => ({ + author: c.user?.login ?? "unknown", + body: c.body ?? "", + createdAt: c.created_at ?? "", + })); + + return { comments }; + } + + /* + FNXC:GitHubImport 2026-06-23-03:15: + Close-issue action for the Import Tasks issue preview pane. `gh issue close ` closes via CLI; REST PATCH state=closed is the token fallback. + Returns void; the route maps 404/401 like the detail route. The preview reflects the closed state locally without re-fetching. + */ + async closeIssue(owner: string, repo: string, number: number): Promise { + if (this.hasGhAuth()) { + try { + await runGhAsync([ + "issue", "close", String(number), + "--repo", `${owner}/${repo}`, + ]); + return; + } catch (err) { + if (this.token) { + await this.closeIssueWithApi(owner, repo, number); + return; + } + throw new Error(getGhErrorMessage(err)); + } + } + if (this.token) { + await this.closeIssueWithApi(owner, repo, number); + return; + } + throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided. Run 'gh auth login' to authenticate."); + } + + private async closeIssueWithApi(owner: string, repo: string, number: number): Promise { + const response = await fetch( + `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}`, + { + method: "PATCH", + headers: { ...this.buildHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ state: "closed" }), + } + ); + if (!response.ok) { + if (response.status === 404) { + throw new Error(`Issue #${number} not found in ${owner}/${repo}`); + } + const error = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(`GitHub API error: ${response.status} ${error.message || response.statusText}`); + } + } + /** * Fetch a single pull request by number. * Uses gh CLI if available, otherwise falls back to REST API. diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index 86869bde9e..e529fb51e0 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -4227,6 +4227,109 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { } }); + /* + FNXC:GitHubImport 2026-06-23-03:15: + POST /api/github/issues/detail — per-issue detail fetch for the Import Tasks issue preview pane. + `gh issue list` yields no comment thread, so the preview fetches the FULL comment thread ON SELECTION (never for the whole list). + Body: { repo: string ("owner/name"), number: number }. Returns { comments }. Mirrors pulls/detail auth/404/401 handling. + */ + router.post("/github/issues/detail", async (req, res) => { + try { + const { repo, number } = req.body; + + if (!repo || typeof repo !== "string" || !repo.includes("/")) { + throw badRequest("repo is required and must be in 'owner/name' form"); + } + if (!number || typeof number !== "number" || number < 1) { + throw badRequest("number is required and must be a positive number"); + } + + const [owner, repoName] = repo.split("/"); + if (!owner || !repoName) { + throw badRequest("repo must be in 'owner/name' form"); + } + + if (!isGhAuthenticated()) { + throw unauthorized("Not authenticated with GitHub. Run `gh auth login`."); + } + + const client = new GitHubClient(); + + try { + const detail = await client.getIssueDetail(owner, repoName, number); + res.json(detail); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + const errorMessage = err instanceof Error ? err.message : String(err); + if (errorMessage.includes("not found") || errorMessage.includes("404")) { + throw notFound(`Issue not found: ${repo}#${number}`); + } + if (errorMessage.includes("authentication") || errorMessage.includes("401") || errorMessage.includes("403")) { + throw unauthorized("Not authenticated with GitHub. Run `gh auth login`."); + } + throw new ApiError(502, `GitHub CLI error: ${errorMessage}`); + } + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } + }); + + /* + FNXC:GitHubImport 2026-06-23-03:15: + POST /api/github/issues/close — closes the selected issue from the Import Tasks preview pane (Close issue button). + Body: { repo: string ("owner/name"), number: number }. Returns { ok: true }. Mirrors pulls/detail auth/404/401 handling. + */ + router.post("/github/issues/close", async (req, res) => { + try { + const { repo, number } = req.body; + + if (!repo || typeof repo !== "string" || !repo.includes("/")) { + throw badRequest("repo is required and must be in 'owner/name' form"); + } + if (!number || typeof number !== "number" || number < 1) { + throw badRequest("number is required and must be a positive number"); + } + + const [owner, repoName] = repo.split("/"); + if (!owner || !repoName) { + throw badRequest("repo must be in 'owner/name' form"); + } + + if (!isGhAuthenticated()) { + throw unauthorized("Not authenticated with GitHub. Run `gh auth login`."); + } + + const client = new GitHubClient(); + + try { + await client.closeIssue(owner, repoName, number); + res.json({ ok: true }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + const errorMessage = err instanceof Error ? err.message : String(err); + if (errorMessage.includes("not found") || errorMessage.includes("404")) { + throw notFound(`Issue not found: ${repo}#${number}`); + } + if (errorMessage.includes("authentication") || errorMessage.includes("401") || errorMessage.includes("403")) { + throw unauthorized("Not authenticated with GitHub. Run `gh auth login`."); + } + throw new ApiError(502, `GitHub CLI error: ${errorMessage}`); + } + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } + }); + /** * POST /api/github/pulls/import * Import a specific GitHub pull request as a fn review task.