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:
5
.changeset/github-import-pr-checks-comments.md
Normal file
5
.changeset/github-import-pr-checks-comments.md
Normal 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.
|
||||
@@ -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), {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user