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:
7
.changeset/fn-8137-refresh-pr-checks.md
Normal file
7
.changeset/fn-8137-refresh-pr-checks.md
Normal 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).
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user