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:
@@ -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), {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user