diff --git a/.changeset/github-import-pr-checks-comments.md b/.changeset/github-import-pr-checks-comments.md new file mode 100644 index 0000000000..9932ef3c94 --- /dev/null +++ b/.changeset/github-import-pr-checks-comments.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Import Tasks PR preview now shows the full comment thread and per-check status (with success/failure/pending indicators) for the selected pull request, fetched on selection and cached per PR. The body still renders immediately while checks and comments stream in. diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 3fb5bc89db..2a4134e139 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -2428,6 +2428,24 @@ export function apiFetchGitHubPulls( }); } +/* +FNXC:GitHubImport 2026-06-23-01:00: +Per-PR detail for the Import Tasks PR preview pane. `gh pr list` (apiFetchGitHubPulls) returns only comment COUNT + no per-check status, so the preview fetches the FULL comment thread + per-check status ON SELECTION via this client fn (never for the whole list — too expensive). +`status` is the gh CheckRun status (queued/in_progress/completed) or StatusContext state; `conclusion` (success/failure/neutral/...) is present once a check completes. +*/ +export interface GitHubPullDetail { + comments: Array<{ author: string; body: string; createdAt: string }>; + checks: Array<{ name: string; status: string; conclusion?: string; detailsUrl?: string }>; +} + +/** Fetch the full comment thread + per-check status for a single GitHub PR (called on selection in the import preview). */ +export function apiFetchGitHubPullDetail(repo: string, number: number): Promise { + return api("/github/pulls/detail", { + 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 87a5336227..3177e0b5aa 100644 --- a/packages/dashboard/app/components/GitHubImportModal.css +++ b/packages/dashboard/app/components/GitHubImportModal.css @@ -752,6 +752,133 @@ The markdown variant must NOT pre-wrap/clamp — MailboxMessageContent emits rea margin-bottom: 0; } +/* +FNXC:GitHubImport 2026-06-23-01:00: +Checks + Comments sections live below the PR body in the scrollable preview pane. Theme tokens only — check pills color via --success/--danger/--warning/--text-muted, mirroring the preview-state-badge token fallbacks. +*/ +.github-import-pr-checks, +.github-import-pr-comments { + margin-top: var(--space-md); + padding-top: var(--space-md); + border-top: 1px solid var(--border); +} + +.preview-section-heading { + margin: 0 0 var(--space-sm); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); +} + +.preview-detail-loading { + display: flex; + align-items: center; + gap: var(--space-xs); + color: var(--text-muted); + font-size: 12px; +} + +.preview-detail-error { + color: var(--danger, var(--text-muted)); + font-size: 12px; +} + +.preview-detail-empty { + color: var(--text-dim); + font-size: 12px; +} + +.github-import-pr-checks__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.github-import-pr-check-row { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.github-import-pr-check-pill { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + min-width: 64px; + justify-content: center; + padding: 2px 8px; + border-radius: var(--radius-sm); + font-size: 10px; + font-weight: 600; + text-transform: capitalize; + background: var(--surface); + border: 1px solid var(--border); + color: var(--text); +} + +.github-import-pr-check-pill--success { + color: var(--success, var(--accent)); + border-color: color-mix(in srgb, var(--success, var(--accent)) 40%, transparent); +} + +.github-import-pr-check-pill--failure { + color: var(--danger, var(--text-muted)); + border-color: color-mix(in srgb, var(--danger, var(--text-muted)) 40%, transparent); +} + +.github-import-pr-check-pill--pending { + color: var(--warning, var(--accent)); + border-color: color-mix(in srgb, var(--warning, var(--accent)) 40%, transparent); +} + +.github-import-pr-check-pill--neutral { + color: var(--text-muted); + border-color: color-mix(in srgb, var(--text-muted) 40%, transparent); +} + +.github-import-pr-check-name { + color: var(--text); + text-decoration: none; + word-break: break-word; +} + +a.github-import-pr-check-name:hover { + color: var(--accent); + text-decoration: underline; +} + +.github-import-pr-comments__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.github-import-pr-comment { + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--space-sm) var(--space-md); + background: var(--card); +} + +.github-import-pr-comment__author { + font-size: 12px; + font-weight: 600; + color: var(--text); + margin-bottom: var(--space-xs); +} + +.github-import-pr-comment__body { + color: var(--text); +} + /* Back button - hidden on desktop by default */ .github-import-back-button { display: none; diff --git a/packages/dashboard/app/components/GitHubImportModal.tsx b/packages/dashboard/app/components/GitHubImportModal.tsx index d69e190d5a..628c4c3002 100644 --- a/packages/dashboard/app/components/GitHubImportModal.tsx +++ b/packages/dashboard/app/components/GitHubImportModal.tsx @@ -7,10 +7,12 @@ import { apiFetchGitHubIssues, apiImportGitHubIssue, apiFetchGitHubPulls, + apiFetchGitHubPullDetail, apiImportGitHubPull, fetchGitRemotes, type GitHubIssue, type GitHubPull, + type GitHubPullDetail, type GitRemote, } from "../api"; import { Loader2, RefreshCw, ArrowLeft, GitPullRequest, CircleDot } from "lucide-react"; @@ -94,6 +96,19 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, const [pulls, setPulls] = useState([]); const [selectedPullNumber, setSelectedPullNumber] = useState(null); + /* + FNXC:GitHubImport 2026-06-23-01:00: + The PR preview pane shows the full comment thread + per-check status for the SELECTED PR only. + `gh pr list` returns just comment COUNT + no per-check detail, so the full thread/checks are fetched ON SELECTION via apiFetchGitHubPullDetail — never for the whole list (too expensive). + Detail is cached by PR number in a ref so re-selecting a PR does not refetch; the body renders immediately while checks/comments stream in (loading/error tracked separately, never blocking the body). + */ + const pullDetailCacheRef = useRef>(new Map()); + const [pullDetail, setPullDetail] = useState(null); + const [pullDetailLoading, setPullDetailLoading] = useState(false); + const [pullDetailError, setPullDetailError] = useState(null); + // Guards against a stale in-flight detail response overwriting a newer selection. + const pullDetailRequestRef = useRef(0); + const [error, setError] = useState(null); const [isIssuesEmptyState, setIsIssuesEmptyState] = useState(false); const [isPullsEmptyState, setIsPullsEmptyState] = useState(false); @@ -547,6 +562,46 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, } }, [activeTab, selectedIssueNumber, selectedPullNumber, owner, repo, projectId, onImport, isMobile, mobileView]); + /* + FNXC:GitHubImport 2026-06-23-01:00: + Fetch the selected PR's detail (comments + checks) 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 checks/comments populate when this resolves. + */ + useEffect(() => { + if (activeTab !== "pulls" || selectedPullNumber === null || !owner.trim() || !repo.trim()) { + setPullDetail(null); + setPullDetailLoading(false); + setPullDetailError(null); + return; + } + + const cached = pullDetailCacheRef.current.get(selectedPullNumber); + if (cached) { + setPullDetail(cached); + setPullDetailLoading(false); + setPullDetailError(null); + return; + } + + const requestId = ++pullDetailRequestRef.current; + setPullDetail(null); + setPullDetailLoading(true); + setPullDetailError(null); + + apiFetchGitHubPullDetail(`${owner.trim()}/${repo.trim()}`, selectedPullNumber) + .then((detail) => { + pullDetailCacheRef.current.set(selectedPullNumber, detail); + if (pullDetailRequestRef.current !== requestId) return; + setPullDetail(detail); + setPullDetailLoading(false); + }) + .catch((err: unknown) => { + if (pullDetailRequestRef.current !== requestId) return; + setPullDetailError(getErrorMessage(err)); + setPullDetailLoading(false); + }); + }, [activeTab, selectedPullNumber, owner, repo]); + const selectedIssue = issues.find((i) => i.number === selectedIssueNumber); const selectedPull = pulls.find((p) => p.number === selectedPullNumber); @@ -1012,6 +1067,74 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, {t("git.noDescription", "(no description)")} )} + {/* + FNXC:GitHubImport 2026-06-23-01:00: + Checks + Comments render BELOW the PR body inside the already-scrollable preview pane. They stream in after the per-PR detail fetch resolves and never block the body above. + Check status maps to a theme-token pill class (success/failure/pending/neutral); the rollup conclusion is preferred over the in-progress status for color. + */} +
+
{t("git.checksHeading", "Checks")}
+ {pullDetailLoading ? ( +
+
+ ) : pullDetailError ? ( +
{pullDetailError}
+ ) : pullDetail && pullDetail.checks.length > 0 ? ( +
    + {pullDetail.checks.map((check, idx) => { + const indicator = check.conclusion ?? check.status; + const variant = + indicator === "success" + ? "success" + : indicator === "failure" || indicator === "error" || indicator === "cancelled" || indicator === "timed_out" + ? "failure" + : indicator === "neutral" || indicator === "skipped" + ? "neutral" + : "pending"; + return ( +
  • + {indicator || "pending"} + {check.detailsUrl ? ( + {check.name} + ) : ( + {check.name} + )} +
  • + ); + })} +
+ ) : ( +
{t("git.noChecks", "No checks")}
+ )} +
+
+
{t("git.commentsHeading", "Comments")}
+ {pullDetailLoading ? ( +
+
+ ) : pullDetailError ? ( +
{pullDetailError}
+ ) : pullDetail && pullDetail.comments.length > 0 ? ( +
    + {pullDetail.comments.map((comment, idx) => ( +
  • +
    {comment.author}
    + +
  • + ))} +
+ ) : ( +
{t("git.noComments", "No comments")}
+ )} +
) : activeTab === "pulls" ? (
diff --git a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx index 3ee64a04a1..7c048f7406 100644 --- a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx @@ -5,6 +5,7 @@ import { apiFetchGitHubIssues, apiImportGitHubIssue, apiFetchGitHubPulls, + apiFetchGitHubPullDetail, apiImportGitHubPull, fetchGitRemotes, } from "../../api"; @@ -21,6 +22,7 @@ vi.mock("../../api", async (importOriginal) => { apiFetchGitHubIssues: vi.fn(), apiImportGitHubIssue: vi.fn(), apiFetchGitHubPulls: vi.fn(), + apiFetchGitHubPullDetail: vi.fn(), apiImportGitHubPull: vi.fn(), fetchGitRemotes: vi.fn(), }; @@ -95,10 +97,12 @@ describe("GitHubImportModal", () => { vi.mocked(apiFetchGitHubIssues).mockReset(); vi.mocked(apiImportGitHubIssue).mockReset(); vi.mocked(apiFetchGitHubPulls).mockReset(); + vi.mocked(apiFetchGitHubPullDetail).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: [] }); onClose.mockReset(); onImport.mockReset(); }); @@ -868,6 +872,88 @@ describe("GitHubImportModal", () => { expect(previewCard.textContent).not.toContain(`${"P".repeat(200)}…`); }); + // FNXC:GitHubImport 2026-06-23-01:00: Selecting a PR fetches its detail and renders the full comment thread + per-check status below the body, scoped to PRs (issues unchanged). + it("renders the selected PR's checks and comments from the detail fetch", async () => { + Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1200 }); + + const pulls = [ + { number: 7, title: "Detail PR", body: "PR body text", html_url: "https://github.com/owner/repo/pull/7", headBranch: "feature", baseBranch: "main" }, + ]; + vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote); + vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(pulls); + vi.mocked(apiFetchGitHubPullDetail).mockResolvedValueOnce({ + comments: [ + { author: "alice", body: "First comment from alice", createdAt: "2024-01-01T00:00:00Z" }, + { author: "bob", body: "Second comment from bob", createdAt: "2024-01-02T00:00:00Z" }, + ], + checks: [ + { name: "build", status: "completed", conclusion: "success" }, + { name: "lint", status: "completed", conclusion: "failure" }, + ], + }); + + render(); + + fireEvent.click(await screen.findByRole("tab", { name: /Pull Requests/i })); + await waitFor(() => { + expect(screen.getByText("Detail PR")).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole("radio", { name: /Select pull request #7/i })); + + // Detail fetch is scoped to the selected PR by "owner/repo" + number. + await waitFor(() => { + expect(vi.mocked(apiFetchGitHubPullDetail)).toHaveBeenCalledWith("dustinbyrne/kb", 7); + }); + + const checks = await screen.findByTestId("github-import-pr-checks"); + const comments = await screen.findByTestId("github-import-pr-comments"); + + // Body still renders immediately, independent of detail. + expect(screen.getByTestId("github-import-preview-body").textContent).toContain("PR body text"); + + // Per-check status surfaces both name and conclusion. + await waitFor(() => { + expect(checks.textContent).toContain("build"); + expect(checks.textContent).toContain("success"); + expect(checks.textContent).toContain("lint"); + expect(checks.textContent).toContain("failure"); + }); + // Failed check gets the failure pill variant. + expect(checks.querySelector(".github-import-pr-check-pill--failure")).toBeTruthy(); + expect(checks.querySelector(".github-import-pr-check-pill--success")).toBeTruthy(); + + // Full comment thread renders, chronological, with authors + bodies. + expect(comments.textContent).toContain("alice"); + expect(comments.textContent).toContain("First comment from alice"); + expect(comments.textContent).toContain("bob"); + expect(comments.textContent).toContain("Second comment from bob"); + }); + + // FNXC:GitHubImport 2026-06-23-01:00: Empty detail shows the "No checks"/"No comments" empty states. + it("shows empty states when the selected PR has no checks or comments", async () => { + Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1200 }); + + const pulls = [ + { number: 9, title: "Bare PR", body: "Bare body", html_url: "https://github.com/owner/repo/pull/9", headBranch: "feature", baseBranch: "main" }, + ]; + vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote); + vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(pulls); + vi.mocked(apiFetchGitHubPullDetail).mockResolvedValueOnce({ comments: [], checks: [] }); + + render(); + + fireEvent.click(await screen.findByRole("tab", { name: /Pull Requests/i })); + await waitFor(() => { + expect(screen.getByText("Bare PR")).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole("radio", { name: /Select pull request #9/i })); + + expect(await screen.findByTestId("github-import-pr-checks-empty")).toBeTruthy(); + expect(await screen.findByTestId("github-import-pr-comments-empty")).toBeTruthy(); + }); + // 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 1e42c78afb..d5261177e1 100644 --- a/packages/dashboard/src/github.ts +++ b/packages/dashboard/src/github.ts @@ -3572,6 +3572,143 @@ export class GitHubClient { })); } + /* + FNXC:GitHubImport 2026-06-23-01:00: + The Import Tasks PR preview needs the FULL comment thread plus per-check status for the SELECTED PR only. + `gh pr list` (listPullRequests) returns just comment COUNT + no per-check detail, so this per-PR detail fetch is intentionally separate and called on selection — never for the whole list (too expensive). + Returns the issue-level comment thread (author/body/createdAt, chronological) and the status-check rollup mapped to { name, status, conclusion?, detailsUrl? }. + Falls back to REST when gh CLI auth is unavailable; check failures degrade to an empty checks array rather than failing the whole detail. + */ + async getPullRequestDetail( + owner: string, + repo: string, + number: number + ): Promise<{ + comments: Array<{ author: string; body: string; createdAt: string }>; + checks: Array<{ name: string; status: string; conclusion?: string; detailsUrl?: string }>; + }> { + if (this.hasGhAuth()) { + try { + return await this.getPullRequestDetailWithGh(owner, repo, number); + } catch (err) { + if (this.token) { + return this.getPullRequestDetailWithApi(owner, repo, number); + } + throw new Error(getGhErrorMessage(err)); + } + } + if (this.token) { + return this.getPullRequestDetailWithApi(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 getPullRequestDetailWithGh( + owner: string, + repo: string, + number: number + ): Promise<{ + comments: Array<{ author: string; body: string; createdAt: string }>; + checks: Array<{ name: string; status: string; conclusion?: string; detailsUrl?: string }>; + }> { + const pr = await runGhJsonAsync<{ + comments?: Array<{ author?: { login?: string } | null; body?: string; createdAt?: string }>; + // `gh pr view --json statusCheckRollup` returns a flat array of mixed CheckRun/StatusContext shapes. + statusCheckRollup?: Array<{ + name?: string; + context?: string; + status?: string; + state?: string; + conclusion?: string; + detailsUrl?: string; + targetUrl?: string; + link?: string; + }> | null; + }>([ + "pr", "view", String(number), + "--repo", `${owner}/${repo}`, + "--json", "comments,statusCheckRollup", + ]); + + const comments = (pr.comments ?? []).map((c) => ({ + author: c.author?.login ?? "unknown", + body: c.body ?? "", + createdAt: c.createdAt ?? "", + })); + + const checks = (pr.statusCheckRollup ?? []).map((c) => ({ + name: c.name ?? c.context ?? "check", + // CheckRun uses `status`; StatusContext uses `state`. Surface whichever is present. + status: (c.status ?? c.state ?? "").toLowerCase(), + conclusion: c.conclusion ? c.conclusion.toLowerCase() : undefined, + detailsUrl: c.detailsUrl ?? c.targetUrl ?? c.link ?? undefined, + })); + + return { comments, checks }; + } + + private async getPullRequestDetailWithApi( + owner: string, + repo: string, + number: number + ): Promise<{ + comments: Array<{ author: string; body: string; createdAt: string }>; + checks: Array<{ name: string; status: string; conclusion?: string; detailsUrl?: string }>; + }> { + const headers = this.buildHeaders(); + + // Issue comments thread (the PR conversation tab), chronological. + 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(`PR #${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 ?? "", + })); + + // Per-check status via the combined check-runs endpoint on the PR head sha. + // Check failures degrade to an empty checks array rather than failing the whole detail. + let checks: Array<{ name: string; status: string; conclusion?: string; detailsUrl?: string }> = []; + try { + const prUrl = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}`; + const prRes = await fetch(prUrl, { headers }); + if (prRes.ok) { + const prJson = (await prRes.json()) as { head?: { sha?: string } }; + const sha = prJson.head?.sha; + if (sha) { + const checksUrl = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits/${sha}/check-runs?per_page=100`; + const checksRes = await fetch(checksUrl, { headers }); + if (checksRes.ok) { + const checksJson = (await checksRes.json()) as { + check_runs?: Array<{ name?: string; status?: string; conclusion?: string | null; details_url?: string | null }>; + }; + checks = (checksJson.check_runs ?? []).map((c) => ({ + name: c.name ?? "check", + status: (c.status ?? "").toLowerCase(), + conclusion: c.conclusion ? c.conclusion.toLowerCase() : undefined, + detailsUrl: c.details_url ?? undefined, + })); + } + } + } + } catch { + checks = []; + } + + return { comments, checks }; + } + /** * 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 abfa30ac39..86869bde9e 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -4175,6 +4175,58 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { } }); + /* + FNXC:GitHubImport 2026-06-23-01:00: + POST /api/github/pulls/detail — per-PR detail fetch for the Import Tasks PR preview pane. + `gh pr list` only yields comment COUNT + no per-check status, so the preview fetches the FULL comment thread + per-check status ON SELECTION via this route (never for the whole list — too expensive). + Body: { repo: string ("owner/name"), number: number }. Returns { comments, checks }. + */ + router.post("/github/pulls/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.getPullRequestDetail(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(`Pull request 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.