feat(dashboard): GitHub import issues show comments + a Close-issue action

Mirrors the PR detail pattern for issues: GitHubClient.getIssueDetail (gh issue view --json comments, REST fallback) + closeIssue (gh issue close, REST PATCH fallback); new POST /api/github/issues/detail + /issues/close routes. The Issues preview fetches comments on selection (cached, non-blocking) and renders them below the body; a Close-issue button in the preview header (open issues only) closes via the API, toasts, and reflects the closed state without dismissing the view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-22 11:10:43 -07:00
parent 8640a747aa
commit a9b0e6c0d1
6 changed files with 551 additions and 3 deletions

View File

@@ -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<GitHubIssueDetail> {
return api<GitHubIssueDetail>("/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<void> {
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<Task> {
return api<Task>(withProjectId("/github/pulls/import", projectId), {

View File

@@ -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;

View File

@@ -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<Map<number, GitHubIssueDetail>>(new Map());
const [issueDetail, setIssueDetail] = useState<GitHubIssueDetail | null>(null);
const [issueDetailLoading, setIssueDetailLoading] = useState(false);
const [issueDetailError, setIssueDetailError] = useState<string | null>(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<Set<number>>(new Set());
const [closingIssue, setClosingIssue] = useState(false);
const [closeToast, setCloseToast] = useState<{ type: "success" | "error"; message: string } | null>(null);
const closeToastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [error, setError] = useState<string | null>(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,
</button>
)}
<h4 id="github-import-preview-heading">{t("git.previewHeading", "Preview")}</h4>
{/*
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 && (
<button
className="btn github-import-issue-close-top"
data-testid="github-import-issue-close"
onClick={handleCloseIssue}
disabled={closingIssue}
title={t("git.closeIssueTitle", "Close issue #{{number}}", { number: selectedIssue.number })}
>
{closingIssue ? <Loader2 size={14} className="spin" /> : t("git.closeIssue", "Close issue")}
</button>
)}
<button
className="btn btn-primary github-import-action-top"
data-testid="github-import-action-top"
@@ -999,6 +1117,19 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
{importing ? <Loader2 size={14} className="spin" /> : t("git.import", "Import")}
</button>
</div>
{/*
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 && (
<div
className={`github-import-close-toast github-import-close-toast--${closeToast.type}`}
role="status"
data-testid="github-import-issue-close-toast"
>
{closeToast.message}
</div>
)}
<div className="github-import-pane-content">
{/* Issue preview */}
@@ -1011,9 +1142,13 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
<div className="preview-meta">{t("git.previewIssueMeta", "Issue #{{number}}", { number: selectedIssue.number })}</div>
<div className="preview-title">{selectedIssue.title}</div>
<div className="preview-metadata">
{selectedIssue.state && (
<span className={`preview-state-badge preview-state-badge--${selectedIssue.state}`}>{selectedIssue.state}</span>
)}
{/* 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 (
<span className={`preview-state-badge preview-state-badge--${displayState}`}>{displayState}</span>
);
})()}
{selectedIssue.author && (
<span className="preview-author">{t("git.previewAuthor", "by {{author}}", { author: selectedIssue.author })}</span>
)}
@@ -1039,6 +1174,37 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
{t("git.noDescription", "(no description)")}
</div>
)}
{/*
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.
*/}
<div className="github-import-pr-comments github-import-issue-comments" data-testid="github-import-issue-comments">
<h5 className="preview-section-heading">{t("git.commentsHeading", "Comments")}</h5>
{issueDetailLoading ? (
<div className="preview-detail-loading" data-testid="github-import-issue-comments-loading">
<Loader2 size={14} className="spin" aria-hidden="true" />
<span>{t("git.loadingComments", "Loading comments…")}</span>
</div>
) : issueDetailError ? (
<div className="preview-detail-error" data-testid="github-import-issue-comments-error">{issueDetailError}</div>
) : issueDetail && issueDetail.comments.length > 0 ? (
<ul className="github-import-pr-comments__list">
{issueDetail.comments.map((comment, idx) => (
<li key={idx} className="github-import-pr-comment">
<div className="github-import-pr-comment__author">{comment.author}</div>
<MailboxMessageContent
className="github-import-pr-comment__body preview-body--markdown"
content={comment.body || t("git.noCommentBody", "(empty comment)")}
testId="github-import-issue-comment-body"
/>
</li>
))}
</ul>
) : (
<div className="preview-detail-empty" data-testid="github-import-issue-comments-empty">{t("git.noComments", "No comments")}</div>
)}
</div>
</div>
) : activeTab === "issues" ? (
<div className="github-import-state github-import-state--idle" data-testid="github-import-preview-empty">

View File

@@ -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(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
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(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
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", {

View File

@@ -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 <n>` 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<void> {
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<void> {
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.

View File

@@ -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.