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 */
|
/** Import a specific GitHub pull request as a fn review task */
|
||||||
export function apiImportGitHubPull(owner: string, repo: string, prNumber: number, projectId?: string): Promise<Task> {
|
export function apiImportGitHubPull(owner: string, repo: string, prNumber: number, projectId?: string): Promise<Task> {
|
||||||
return api<Task>(withProjectId("/github/pulls/import", projectId), {
|
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;
|
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 */
|
/* Back button - hidden on desktop by default */
|
||||||
.github-import-back-button {
|
.github-import-back-button {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import {
|
|||||||
apiFetchGitHubIssues,
|
apiFetchGitHubIssues,
|
||||||
apiImportGitHubIssue,
|
apiImportGitHubIssue,
|
||||||
apiFetchGitHubPulls,
|
apiFetchGitHubPulls,
|
||||||
|
apiFetchGitHubPullDetail,
|
||||||
apiImportGitHubPull,
|
apiImportGitHubPull,
|
||||||
fetchGitRemotes,
|
fetchGitRemotes,
|
||||||
type GitHubIssue,
|
type GitHubIssue,
|
||||||
type GitHubPull,
|
type GitHubPull,
|
||||||
|
type GitHubPullDetail,
|
||||||
type GitRemote,
|
type GitRemote,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { Loader2, RefreshCw, ArrowLeft, GitPullRequest, CircleDot } from "lucide-react";
|
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 [pulls, setPulls] = useState<GitHubPull[]>([]);
|
||||||
const [selectedPullNumber, setSelectedPullNumber] = useState<number | null>(null);
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const [isIssuesEmptyState, setIsIssuesEmptyState] = useState(false);
|
const [isIssuesEmptyState, setIsIssuesEmptyState] = useState(false);
|
||||||
const [isPullsEmptyState, setIsPullsEmptyState] = 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]);
|
}, [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 selectedIssue = issues.find((i) => i.number === selectedIssueNumber);
|
||||||
const selectedPull = pulls.find((p) => p.number === selectedPullNumber);
|
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)")}
|
{t("git.noDescription", "(no description)")}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : activeTab === "pulls" ? (
|
) : activeTab === "pulls" ? (
|
||||||
<div className="github-import-state github-import-state--idle" data-testid="github-import-preview-empty">
|
<div className="github-import-state github-import-state--idle" data-testid="github-import-preview-empty">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
apiFetchGitHubIssues,
|
apiFetchGitHubIssues,
|
||||||
apiImportGitHubIssue,
|
apiImportGitHubIssue,
|
||||||
apiFetchGitHubPulls,
|
apiFetchGitHubPulls,
|
||||||
|
apiFetchGitHubPullDetail,
|
||||||
apiImportGitHubPull,
|
apiImportGitHubPull,
|
||||||
fetchGitRemotes,
|
fetchGitRemotes,
|
||||||
} from "../../api";
|
} from "../../api";
|
||||||
@@ -21,6 +22,7 @@ vi.mock("../../api", async (importOriginal) => {
|
|||||||
apiFetchGitHubIssues: vi.fn(),
|
apiFetchGitHubIssues: vi.fn(),
|
||||||
apiImportGitHubIssue: vi.fn(),
|
apiImportGitHubIssue: vi.fn(),
|
||||||
apiFetchGitHubPulls: vi.fn(),
|
apiFetchGitHubPulls: vi.fn(),
|
||||||
|
apiFetchGitHubPullDetail: vi.fn(),
|
||||||
apiImportGitHubPull: vi.fn(),
|
apiImportGitHubPull: vi.fn(),
|
||||||
fetchGitRemotes: vi.fn(),
|
fetchGitRemotes: vi.fn(),
|
||||||
};
|
};
|
||||||
@@ -95,10 +97,12 @@ describe("GitHubImportModal", () => {
|
|||||||
vi.mocked(apiFetchGitHubIssues).mockReset();
|
vi.mocked(apiFetchGitHubIssues).mockReset();
|
||||||
vi.mocked(apiImportGitHubIssue).mockReset();
|
vi.mocked(apiImportGitHubIssue).mockReset();
|
||||||
vi.mocked(apiFetchGitHubPulls).mockReset();
|
vi.mocked(apiFetchGitHubPulls).mockReset();
|
||||||
|
vi.mocked(apiFetchGitHubPullDetail).mockReset();
|
||||||
vi.mocked(apiImportGitHubPull).mockReset();
|
vi.mocked(apiImportGitHubPull).mockReset();
|
||||||
// Set default mock for apiFetchGitHubIssues to return empty array (prevents undefined issues state)
|
// Set default mock for apiFetchGitHubIssues to return empty array (prevents undefined issues state)
|
||||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValue([]);
|
vi.mocked(apiFetchGitHubIssues).mockResolvedValue([]);
|
||||||
vi.mocked(apiFetchGitHubPulls).mockResolvedValue([]);
|
vi.mocked(apiFetchGitHubPulls).mockResolvedValue([]);
|
||||||
|
vi.mocked(apiFetchGitHubPullDetail).mockResolvedValue({ comments: [], checks: [] });
|
||||||
onClose.mockReset();
|
onClose.mockReset();
|
||||||
onImport.mockReset();
|
onImport.mockReset();
|
||||||
});
|
});
|
||||||
@@ -868,6 +872,88 @@ describe("GitHubImportModal", () => {
|
|||||||
expect(previewCard.textContent).not.toContain(`${"P".repeat(200)}…`);
|
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.
|
// 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 () => {
|
it("renders long selected issue body in full on desktop without a truncation ellipsis", async () => {
|
||||||
Object.defineProperty(window, "innerWidth", {
|
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.
|
* Fetch a single pull request by number.
|
||||||
* Uses gh CLI if available, otherwise falls back to REST API.
|
* 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
|
* POST /api/github/pulls/import
|
||||||
* Import a specific GitHub pull request as a fn review task.
|
* Import a specific GitHub pull request as a fn review task.
|
||||||
|
|||||||
Reference in New Issue
Block a user