feat(dashboard): Import Tasks PR preview shows full comments + checks with per-check status

New POST /api/github/pulls/detail endpoint + GitHubClient.getPullRequestDetail (gh pr view --json comments,statusCheckRollup, REST fallback). On PR select, the preview fetches detail (cached per PR, body never blocked) and renders below the body: a Checks section (each check name + status pill success/failure/pending/neutral, linked to detailsUrl) and a Comments section (chronological, markdown via MailboxMessageContent). Issues tab unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-22 10:19:12 -07:00
parent ab9db246bb
commit 2dc36d9ef4
7 changed files with 548 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@@ -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<GitHubPull[]>([]);
const [selectedPullNumber, setSelectedPullNumber] = useState<number | null>(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<Map<number, GitHubPullDetail>>(new Map());
const [pullDetail, setPullDetail] = useState<GitHubPullDetail | null>(null);
const [pullDetailLoading, setPullDetailLoading] = useState(false);
const [pullDetailError, setPullDetailError] = useState<string | null>(null);
// Guards against a stale in-flight detail response overwriting a newer selection.
const pullDetailRequestRef = useRef(0);
const [error, setError] = useState<string | null>(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)")}
</div>
)}
{/*
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.
*/}
<div className="github-import-pr-checks" data-testid="github-import-pr-checks">
<h5 className="preview-section-heading">{t("git.checksHeading", "Checks")}</h5>
{pullDetailLoading ? (
<div className="preview-detail-loading" data-testid="github-import-pr-checks-loading">
<Loader2 size={14} className="spin" aria-hidden="true" />
<span>{t("git.loadingChecks", "Loading checks…")}</span>
</div>
) : pullDetailError ? (
<div className="preview-detail-error" data-testid="github-import-pr-checks-error">{pullDetailError}</div>
) : pullDetail && pullDetail.checks.length > 0 ? (
<ul className="github-import-pr-checks__list">
{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 (
<li key={`${check.name}-${idx}`} className="github-import-pr-check-row">
<span className={`github-import-pr-check-pill github-import-pr-check-pill--${variant}`}>{indicator || "pending"}</span>
{check.detailsUrl ? (
<a className="github-import-pr-check-name" href={check.detailsUrl} target="_blank" rel="noopener noreferrer">{check.name}</a>
) : (
<span className="github-import-pr-check-name">{check.name}</span>
)}
</li>
);
})}
</ul>
) : (
<div className="preview-detail-empty" data-testid="github-import-pr-checks-empty">{t("git.noChecks", "No checks")}</div>
)}
</div>
<div className="github-import-pr-comments" data-testid="github-import-pr-comments">
<h5 className="preview-section-heading">{t("git.commentsHeading", "Comments")}</h5>
{pullDetailLoading ? (
<div className="preview-detail-loading" data-testid="github-import-pr-comments-loading">
<Loader2 size={14} className="spin" aria-hidden="true" />
<span>{t("git.loadingComments", "Loading comments…")}</span>
</div>
) : pullDetailError ? (
<div className="preview-detail-error" data-testid="github-import-pr-comments-error">{pullDetailError}</div>
) : pullDetail && pullDetail.comments.length > 0 ? (
<ul className="github-import-pr-comments__list">
{pullDetail.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-pr-comment-body"
/>
</li>
))}
</ul>
) : (
<div className="preview-detail-empty" data-testid="github-import-pr-comments-empty">{t("git.noComments", "No comments")}</div>
)}
</div>
</div>
) : activeTab === "pulls" ? (
<div className="github-import-state github-import-state--idle" data-testid="github-import-preview-empty">

View File

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

View File

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

View File

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