FN-8137: add PR checks refresh control

Let GitHub pull request previews refresh their current check status and comments.

- Add an accessible refresh action that bypasses the selected PR detail cache
- Prevent stale refresh responses from overwriting cached or visible PR details
- Cover refresh behavior across modal and embedded views, and document the control
- Add a minor changeset for the new GitHub import capability

Files changed:
 .changeset/fn-8137-refresh-pr-checks.md            |   7 ++
 docs/dashboard-guide.md                            |   2 +-
 .../dashboard/app/components/GitHubImportModal.css |  29 ++++++
 .../dashboard/app/components/GitHubImportModal.tsx |  48 +++++++--
 .../__tests__/GitHubImportModal.test.tsx           | 115 +++++++++++++++++++++
 5 files changed, 191 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-8137

Fusion-Task-Lineage: 1d4ffa9d-635c-4d59-b17c-63075f6d8c5e

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 13:34:54 -07:00
parent 255e9c19b9
commit 6f99fdb18b
5 changed files with 191 additions and 10 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add a Refresh checks button to GitHub import PR previews for fresh CI status.
category: feature
dev: Refresh evicts the selected pull-detail cache entry and guards stale cache writes (FN-8137).

View File

@@ -312,7 +312,7 @@ Use Import Tasks on desktop/tablet:
3. Stay on **Issues** or switch to **Pull Requests**, then optionally enter issue label filters before loading results.
Expected outcome: the list pane shows matching open issues or pull requests and marks entries that already exist on the board. Use **Hide imported** beside the imported count to remove those unavailable rows from the current Issues, Pull Requests, or GitLab list; turning it off restores the greyed **Imported** rows. After a successful GitHub or GitLab import, the source row is marked **Imported** and made unavailable immediately, without waiting for the board list to refresh.
4. Select an issue or pull request row.
Expected outcome: the full-width candidate list stays visible while its title, source link, body, labels or PR metadata, and import controls open in a draggable and resizable detail window. On mobile, that detail is a full-screen sheet. When selected title/body content is in another language, the detail offers **Translate**, **Show original** / **Show translation**, and **Dismiss**; translation is display-only. A pull request preview also shows its checks; each failed check has a **Create fix task** action that creates a new task prefilled with the repository, PR, branches, check status, and check-details link.
Expected outcome: the full-width candidate list stays visible while its title, source link, body, labels or PR metadata, and import controls open in a draggable and resizable detail window. On mobile, that detail is a full-screen sheet. When selected title/body content is in another language, the detail offers **Translate**, **Show original** / **Show translation**, and **Dismiss**; translation is display-only. A pull request preview also shows its checks; use **Refresh checks** to fetch current GitHub check status and comments without reopening the detail. Each failed check has a **Create fix task** action that creates a new task prefilled with the repository, PR, branches, check status, and check-details link.
5. Select the import action in the detail window. Pull requests use **Resolve feedback**, which creates a task to resolve reviewer feedback and address failed CI checks; issues keep **Import**. Each GitHub issue and pull-request comment also has **Import as task**, which creates a separate resolve-feedback task quoting that comment and linking its source without closing the detail window.
Expected outcome: Fusion creates the requested task, preserves GitHub provenance/tracking metadata, and returns the completed PR/issue import to the list while leaving comment imports available for further feedback.

View File

@@ -1081,6 +1081,27 @@ Checks + Comments sections live below the PR body in the scrollable preview pane
color: var(--text-muted);
}
/*
FNXC:GitHubImport 2026-07-16-19:00:
FN-8137 keeps the PR checks refresh control in the section heading so changing CI state is discoverable without competing with individual check actions.
*/
.github-import-pr-checks__heading-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
}
.github-import-pr-checks__heading-row .preview-section-heading {
margin-bottom: 0;
}
.github-import-pr-checks-refresh {
flex: 0 0 auto;
color: var(--text-muted);
}
.preview-detail-loading {
display: flex;
align-items: center;
@@ -1675,6 +1696,14 @@ Import Tasks embedded header now adopts the canonical ViewHeader chrome — edge
grid-template-columns: 1fr;
}
.github-import-pr-checks__heading-row {
flex-wrap: wrap;
}
.github-import-pr-checks__heading-row .preview-section-heading {
flex: 1 1 auto;
}
.github-import-pr-check-row {
flex-wrap: wrap;
align-items: center;

View File

@@ -497,6 +497,9 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
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).
FNXC:GitHubImport 2026-07-16-19:00:
FN-8137 adds an explicit force-refresh path for changing GitHub CI and comments. It evicts only the selected PR cache entry before refetching while normal selection remains cache-first.
*/
const pullDetailCacheRef = useRef<Map<number, GitHubPullDetail>>(new Map());
const [pullDetail, setPullDetail] = useState<GitHubPullDetail | null>(null);
@@ -1129,12 +1132,8 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
}
}, [activeTab, selectedIssueNumber, selectedPullNumber, issues, pulls, owner, repo, projectId, onImport, returnToIssueListAfterSuccess]);
/*
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(() => {
const fetchPullDetail = useCallback((force: boolean) => {
const requestId = ++pullDetailRequestRef.current;
if (activeTab !== "pulls" || selectedPullNumber === null || !owner.trim() || !repo.trim()) {
setPullDetail(null);
setPullDetailLoading(false);
@@ -1142,6 +1141,10 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
return;
}
if (force) {
pullDetailCacheRef.current.delete(selectedPullNumber);
}
const cached = pullDetailCacheRef.current.get(selectedPullNumber);
if (cached) {
setPullDetail(cached);
@@ -1150,15 +1153,15 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
return;
}
const requestId = ++pullDetailRequestRef.current;
setPullDetail(null);
setPullDetailLoading(true);
setPullDetailError(null);
apiFetchGitHubPullDetail(`${owner.trim()}/${repo.trim()}`, selectedPullNumber)
.then((detail) => {
pullDetailCacheRef.current.set(selectedPullNumber, detail);
// FNXC:GitHubImport 2026-07-16-19:00: FN-8137 requires stale detail requests to be unable to poison the cache as well as the currently visible checks and comments.
if (pullDetailRequestRef.current !== requestId) return;
pullDetailCacheRef.current.set(selectedPullNumber, detail);
setPullDetail(detail);
setPullDetailLoading(false);
})
@@ -1169,6 +1172,20 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
});
}, [activeTab, selectedPullNumber, owner, repo]);
/*
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(() => {
fetchPullDetail(false);
}, [fetchPullDetail]);
const handleRefreshChecks = useCallback(() => {
if (selectedPullNumber === null || pullDetailLoading) return;
fetchPullDetail(true);
}, [fetchPullDetail, pullDetailLoading, selectedPullNumber]);
/*
FNXC:GitHubImport 2026-06-23-03:15:
Fetch the selected issue's comments on selection. Serves from the per-number cache on re-select; otherwise fetches and caches.
@@ -2001,7 +2018,20 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
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>
<div className="github-import-pr-checks__heading-row">
<h5 className="preview-section-heading">{t("git.checksHeading", "Checks")}</h5>
<button
type="button"
className="btn btn-icon github-import-pr-checks-refresh"
data-testid="github-import-pr-checks-refresh"
aria-label={t("git.refreshChecksAria", "Refresh checks")}
title={t("git.refreshChecks", "Refresh checks")}
disabled={pullDetailLoading}
onClick={handleRefreshChecks}
>
{pullDetailLoading ? <Loader2 size={14} className="spin" aria-hidden="true" /> : <RefreshCw size={14} aria-hidden="true" />}
</button>
</div>
{pullDetailLoading ? (
<div className="preview-detail-loading" data-testid="github-import-pr-checks-loading">
<Loader2 size={14} className="spin" aria-hidden="true" />

View File

@@ -229,6 +229,121 @@ describe("GitHubImportModal", () => {
fireEvent.click(screen.getByRole("button", { name: /Select pull request #81/i }));
};
describe("PR checks refresh", () => {
const pullOne = mockPulls[0];
const pullTwo = mockPulls[1];
const initialDetail = {
checks: [{ name: "build", status: "in_progress" }],
comments: [{ author: "octocat", body: "Pending build comment", createdAt: "2026-07-16T00:00:00Z", authorIsBot: false }],
};
const refreshedDetail = {
checks: [{ name: "build", status: "completed", conclusion: "success" }],
comments: [{ author: "octocat", body: "Build passed comment", createdAt: "2026-07-16T00:01:00Z", authorIsBot: false }],
};
const renderPullPreview = async (presentation: "modal" | "embedded" = "modal") => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([{ name: "origin", owner: "owner", repo: "repo", url: "" }]);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce([pullOne, pullTwo]);
render(<GitHubImportModal isOpen onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation={presentation} />);
fireEvent.click(await screen.findByRole("tab", { name: /Pull Requests/i }));
await screen.findByText("Test PR");
};
it.each(["modal", "embedded"] as const)("renders a reachable refresh button in %s presentation", async (presentation) => {
await renderPullPreview(presentation);
fireEvent.click(screen.getByRole("button", { name: /Select pull request #1/i }));
expect(await screen.findByTestId("github-import-pr-checks-refresh")).toHaveAccessibleName("Refresh checks");
});
it("keeps the checks heading responsive at the mobile breakpoint", () => {
const source = readFileSync(resolve(__dirname, "../GitHubImportModal.css"), "utf8");
expect(source).toMatch(/@media \(max-width: 768px\)[\s\S]*\.github-import-pr-checks__heading-row\s*\{[\s\S]*flex-wrap: wrap;/);
});
it("bypasses cached detail on refresh and caches the refreshed checks and comments", async () => {
vi.mocked(apiFetchGitHubPullDetail)
.mockResolvedValueOnce(initialDetail)
.mockResolvedValueOnce({ comments: [], checks: [] })
.mockResolvedValueOnce(refreshedDetail);
await renderPullPreview();
fireEvent.click(screen.getByRole("button", { name: /Select pull request #1/i }));
expect(await screen.findByText("Pending build comment")).toBeTruthy();
expect(apiFetchGitHubPullDetail).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: /Select pull request #2/i }));
await screen.findByTestId("github-import-pr-checks-empty");
fireEvent.click(screen.getByRole("button", { name: /Select pull request #1/i }));
expect(await screen.findByText("Pending build comment")).toBeTruthy();
expect(apiFetchGitHubPullDetail).toHaveBeenCalledTimes(2);
fireEvent.click(screen.getByTestId("github-import-pr-checks-refresh"));
expect(await screen.findByText("Build passed comment")).toBeTruthy();
expect(screen.getByText("success")).toBeTruthy();
expect(apiFetchGitHubPullDetail).toHaveBeenCalledTimes(3);
fireEvent.click(screen.getByRole("button", { name: /Select pull request #2/i }));
fireEvent.click(screen.getByRole("button", { name: /Select pull request #1/i }));
expect(await screen.findByText("Build passed comment")).toBeTruthy();
expect(apiFetchGitHubPullDetail).toHaveBeenCalledTimes(3);
});
it("disables refresh with a spinner while loading, then surfaces errors and permits retry", async () => {
let resolveRefresh!: (detail: typeof refreshedDetail) => void;
vi.mocked(apiFetchGitHubPullDetail)
.mockResolvedValueOnce(initialDetail)
.mockImplementationOnce(() => new Promise((resolve) => { resolveRefresh = resolve; }))
.mockRejectedValueOnce(new Error("GitHub checks unavailable"))
.mockResolvedValueOnce(refreshedDetail);
await renderPullPreview();
fireEvent.click(screen.getByRole("button", { name: /Select pull request #1/i }));
await screen.findByText("Pending build comment");
const refresh = screen.getByTestId("github-import-pr-checks-refresh");
fireEvent.click(refresh);
expect(refresh).toBeDisabled();
expect(refresh.querySelector(".spin")).toBeTruthy();
await act(async () => { resolveRefresh(refreshedDetail); });
await screen.findByText("Build passed comment");
expect(refresh).not.toBeDisabled();
fireEvent.click(refresh);
expect(await screen.findByTestId("github-import-pr-checks-error")).toHaveTextContent("GitHub checks unavailable");
expect(refresh).not.toBeDisabled();
fireEvent.click(refresh);
expect(await screen.findByText("Build passed comment")).toBeTruthy();
});
it("drops a stale refresh response from state and cache", async () => {
let resolveStaleRefresh!: (detail: typeof initialDetail) => void;
vi.mocked(apiFetchGitHubPullDetail)
.mockResolvedValueOnce(initialDetail)
.mockImplementationOnce(() => new Promise((resolve) => { resolveStaleRefresh = resolve; }))
.mockResolvedValueOnce(refreshedDetail)
.mockResolvedValueOnce({ checks: [{ name: "fresh", status: "completed", conclusion: "success" }], comments: [{ author: "octocat", body: "Fresh reselected comment", createdAt: "2026-07-16T00:02:00Z", authorIsBot: false }] });
await renderPullPreview();
fireEvent.click(screen.getByRole("button", { name: /Select pull request #1/i }));
await screen.findByText("Pending build comment");
fireEvent.click(screen.getByTestId("github-import-pr-checks-refresh"));
fireEvent.click(screen.getByRole("button", { name: /Select pull request #2/i }));
expect(await screen.findByText("Build passed comment")).toBeTruthy();
await act(async () => { resolveStaleRefresh(initialDetail); });
expect(screen.getByText("Build passed comment")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /Select pull request #1/i }));
expect(await screen.findByText("Fresh reselected comment")).toBeTruthy();
expect(apiFetchGitHubPullDetail).toHaveBeenCalledTimes(4);
});
it("does not render a refresh shell without a selected PR or on the issues tab", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([{ name: "origin", owner: "owner", repo: "repo", url: "" }]);
render(<GitHubImportModal isOpen onClose={onClose} onImport={onImport} tasks={[]} />);
expect(screen.queryByTestId("github-import-pr-checks-refresh")).toBeNull();
await screen.findByRole("tab", { name: "Issues" });
expect(screen.queryByTestId("github-import-pr-checks-refresh")).toBeNull();
});
});
describe("failed PR check fix tasks", () => {
const checkVariants = [
{ name: "failure", status: "completed", conclusion: "failure", detailsUrl: "https://github.com/owner/repo/runs/1" },