FN-8110: add failed-check fix task action

Add an in-context recovery path for failed GitHub PR checks.

- Add a per-failed-check Create fix task action with progress and result feedback.
- Compose fix-task context from the selected PR, repository, branches, check status, and details URL.
- Cover the GitHub import behavior and document the feature release.

Files changed: .changeset/fn-8110-pr-failed-check-fix-task.md     |   7 +
 docs/dashboard-guide.md                            |   2 +-
 .../dashboard/app/components/GitHubImportModal.css |  23 ++++
 .../dashboard/app/components/GitHubImportModal.tsx |  91 ++++++++++++-
 .../__tests__/GitHubImportModal.test.tsx           | 148 +++++++++++++++++++++
 5 files changed, 269 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-8110

Fusion-Task-Lineage: 0f69a4e2-d950-4a9f-925e-bd56fb6bf45d

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 11:16:06 -07:00
parent 504c702262
commit 68583b5c68
5 changed files with 269 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add a Create fix task button on failed PR checks in the GitHub import preview.
category: feature
dev: Reuses createTask with an auto-composed PR and check context prompt (FN-8110).

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.
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.
5. Select the import action in the detail window.
Expected outcome: Fusion creates a task (or review task for a pull request), preserves GitHub provenance/tracking metadata, closes the detail window, and returns to the list.

View File

@@ -1154,6 +1154,18 @@ Checks + Comments sections live below the PR body in the scrollable preview pane
color: var(--text);
text-decoration: none;
word-break: break-word;
min-width: 0;
flex: 1 1 auto;
}
/*
FNXC:GitHubImport 2026-07-16-17:00:
FN-8110 keeps a failed check's repair action attached to its own row without introducing a new button visual system.
The shared btn primitives own colors and control treatment; this rule only reserves row layout and mobile-safe wrapping.
*/
.github-import-pr-check-fix-task {
flex: 0 0 auto;
margin-inline-start: auto;
}
a.github-import-pr-check-name:hover {
@@ -1628,6 +1640,17 @@ Import Tasks embedded header now adopts the canonical ViewHeader chrome — edge
grid-template-columns: 1fr;
}
.github-import-pr-check-row {
flex-wrap: wrap;
align-items: center;
}
.github-import-pr-check-fix-task {
width: 100%;
justify-content: center;
margin-inline-start: 0;
}
/*
FNXC:GitHubImport 2026-07-15-18:20:
This is the breakpoint where the detail FloatingWindow becomes a full-screen sheet. The panel's var(--space-lg) inset already reads as a conventional mobile gutter, so only the actions change: they grow to a 40px touch target because the desktop 30px pair is a mouse-sized hit area and Import is the sheet's primary action.

View File

@@ -18,6 +18,7 @@ import {
apiImportGitLabMergeRequest,
fetchSettings,
fetchGitRemotes,
createTask,
type GitHubIssue,
type GitHubPull,
type GitHubPullDetail,
@@ -302,6 +303,34 @@ notice tells the operator to refine by label rather than pretending the list is
const ISSUES_FETCH_CAP = 300;
const ISSUES_PAGE_SIZE = 30;
/**
* FNXC:GitHubImport 2026-07-16-17:00:
* FN-8110 lets an operator create a repair task from a failed PR check without retyping its context.
* Keep this prompt composition pure so every check row carries its repository, PR, branch, status, and details-link evidence.
*/
export function buildCheckFixTaskPrompt(
pull: GitHubPull,
check: GitHubPullDetail["checks"][number],
repoSlug: string,
): { title: string; description: string } {
const indicator = check.conclusion ?? check.status ?? "unknown";
const details = check.detailsUrl ? `\nCheck details: ${check.detailsUrl}` : "";
return {
title: `Fix failing check "${check.name}" on PR #${pull.number}`,
description: [
"Fix the failing GitHub pull request check described below.",
"",
`Repository: ${repoSlug}`,
`Pull request: #${pull.number} — ${pull.title}`,
`PR URL: ${pull.html_url}`,
`Branches: ${pull.headBranch} → ${pull.baseBranch}`,
`Failing check: ${check.name}`,
`Check status: ${indicator}${details}`,
].join("\n"),
};
}
export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, presentation = "modal" }: GitHubImportModalProps) {
const { isEmbedded, scrollLockEnabled, resizePersistEnabled, escapeEnabled } = useEmbeddedPresentation(presentation);
useMobileScrollLock(isOpen && scrollLockEnabled);
@@ -438,6 +467,10 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
const [closingIssue, setClosingIssue] = useState(false);
const [closeToast, setCloseToast] = useState<{ type: "success" | "error"; message: string } | null>(null);
const closeToastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// FNXC:GitHubImport 2026-07-16-17:00: Track check-task submission by name + row index so duplicate GitHub check names never disable each other's controls.
const [creatingCheckFixTaskRows, setCreatingCheckFixTaskRows] = useState<Set<string>>(new Set());
const [checkFixTaskToast, setCheckFixTaskToast] = useState<{ type: "success" | "error"; message: string } | null>(null);
const checkFixTaskToastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [error, setError] = useState<string | null>(null);
const [isIssuesEmptyState, setIsIssuesEmptyState] = useState(false);
@@ -1120,6 +1153,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
// FNXC:GitHubImport 2026-06-23-03:15: Clear the transient close toast timer on unmount.
useEffect(() => () => {
if (closeToastTimerRef.current) clearTimeout(closeToastTimerRef.current);
if (checkFixTaskToastTimerRef.current) clearTimeout(checkFixTaskToastTimerRef.current);
}, []);
/*
@@ -1150,6 +1184,36 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
const selectedIssue = issues.find((i) => i.number === selectedIssueNumber);
const selectedPull = pulls.find((p) => p.number === selectedPullNumber);
const handleCreateCheckFixTask = useCallback(async (
check: GitHubPullDetail["checks"][number],
rowKey: string,
) => {
if (!selectedPull || !owner.trim() || !repo.trim()) return;
setCreatingCheckFixTaskRows((current) => new Set(current).add(rowKey));
if (checkFixTaskToastTimerRef.current) clearTimeout(checkFixTaskToastTimerRef.current);
setCheckFixTaskToast(null);
try {
const task = await createTask(buildCheckFixTaskPrompt(selectedPull, check, `${owner.trim()}/${repo.trim()}`), projectId);
onImport(task);
setCheckFixTaskToast({ type: "success", message: t("git.checkFixTaskCreated", "Fix task created") });
} catch (err: unknown) {
setCheckFixTaskToast({
type: "error",
message: getErrorMessage(err) || t("git.failedToCreateCheckFixTask", "Failed to create fix task"),
});
} finally {
setCreatingCheckFixTaskRows((current) => {
const next = new Set(current);
next.delete(rowKey);
return next;
});
checkFixTaskToastTimerRef.current = setTimeout(() => setCheckFixTaskToast(null), 4000);
}
}, [onImport, owner, projectId, repo, selectedPull, t]);
/*
FNXC:GitHubImport 2026-06-23-03:15:
An issue counts as closed if the upstream state is closed OR we closed it locally this session. Only OPEN issues show the Close button.
@@ -1882,14 +1946,30 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
: indicator === "neutral" || indicator === "skipped"
? "neutral"
: "pending";
// FNXC:GitHubImport 2026-07-16-18:00: GitHub can return duplicate check names, and a user can switch PRs before a create finishes; include the PR number so only the originating row is disabled.
const rowKey = `${selectedPull.number}:${check.name}-${idx}`;
const creatingFixTask = creatingCheckFixTaskRows.has(rowKey);
return (
<li key={`${check.name}-${idx}`} className="github-import-pr-check-row">
<li key={rowKey} 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>
)}
{variant === "failure" && (
<button
type="button"
className="btn btn-secondary btn-sm github-import-pr-check-fix-task"
data-testid="github-import-pr-check-fix-task"
aria-label={t("git.createCheckFixTaskAria", "Create fix task for {{checkName}}", { checkName: check.name })}
disabled={creatingFixTask}
onClick={() => handleCreateCheckFixTask(check, rowKey)}
>
{creatingFixTask && <Loader2 size={14} className="spin" aria-hidden="true" />}
{t("git.createCheckFixTask", "Create fix task")}
</button>
)}
</li>
);
})}
@@ -1897,6 +1977,15 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
) : (
<div className="preview-detail-empty" data-testid="github-import-pr-checks-empty">{t("git.noChecks", "No checks")}</div>
)}
{checkFixTaskToast && (
<div
className={`github-import-close-toast github-import-close-toast--${checkFixTaskToast.type}`}
role="status"
data-testid="github-import-pr-check-fix-task-toast"
>
{checkFixTaskToast.message}
</div>
)}
</div>
<CommentsThread
comments={pullDetail?.comments ?? []}

View File

@@ -17,6 +17,7 @@ import {
apiImportGitLabMergeRequest,
fetchSettings,
fetchGitRemotes,
createTask,
translateImportContent,
} from "../../api";
import type { Task } from "@fusion/core";
@@ -44,6 +45,7 @@ vi.mock("../../api", async (importOriginal) => {
apiImportGitLabMergeRequest: vi.fn(),
fetchSettings: vi.fn(),
fetchGitRemotes: vi.fn(),
createTask: vi.fn(),
translateImportContent: vi.fn(),
};
});
@@ -175,6 +177,8 @@ describe("GitHubImportModal", () => {
vi.mocked(apiImportGitLabGroupIssue).mockReset();
vi.mocked(apiImportGitLabMergeRequest).mockReset();
vi.mocked(fetchSettings).mockReset();
vi.mocked(createTask).mockReset();
vi.mocked(createTask).mockResolvedValue(mockTask);
vi.mocked(fetchSettings).mockResolvedValue({ gitlabEnabled: true } as never);
// Set default mock for apiFetchGitHubIssues to return empty array (prevents undefined issues state)
vi.mocked(apiFetchGitHubIssues).mockResolvedValue([]);
@@ -192,6 +196,150 @@ describe("GitHubImportModal", () => {
onImport.mockReset();
});
/*
* FNXC:GitHubImport 2026-07-16-17:00:
* FN-8110's per-check repair action is exercised through the same selected-PR detail path in both modal and embedded presentations.
* The helper deliberately controls only the API seam so the tests cover the real row rendering and task callback behavior.
*/
const renderSelectedPullChecks = async (
checks: Array<{ name: string; status: string; conclusion?: string; detailsUrl?: string }>,
presentation: "modal" | "embedded" = "modal",
detailRequest: Promise<{ comments: []; checks: Array<{ name: string; status: string; conclusion?: string; detailsUrl?: string }> }> = Promise.resolve({ comments: [], checks }),
) => {
const pull = {
number: 81,
title: "Fixable PR",
body: "PR body",
html_url: "https://github.com/owner/repo/pull/81",
headBranch: "broken-build",
baseBranch: "main",
};
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([{ name: "origin", owner: "owner", repo: "repo", url: "" }]);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce([pull]);
vi.mocked(apiFetchGitHubPullDetail).mockReturnValueOnce(detailRequest as never);
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("Fixable PR");
fireEvent.click(screen.getByRole("button", { name: /Select pull request #81/i }));
};
describe("failed PR check fix tasks", () => {
const checkVariants = [
{ name: "failure", status: "completed", conclusion: "failure", detailsUrl: "https://github.com/owner/repo/runs/1" },
{ name: "success", status: "completed", conclusion: "success" },
{ name: "pending", status: "in_progress" },
{ name: "neutral", status: "completed", conclusion: "skipped" },
];
it.each(["modal", "embedded"] as const)("creates a task from the failed row in %s presentation", async (presentation) => {
const createdTask = { ...mockTask, id: `FN-${presentation}` };
vi.mocked(createTask).mockResolvedValueOnce(createdTask);
await renderSelectedPullChecks(checkVariants, presentation);
const buttons = await screen.findAllByTestId("github-import-pr-check-fix-task");
expect(buttons).toHaveLength(1);
expect(buttons[0]).toHaveAccessibleName("Create fix task for failure");
fireEvent.click(buttons[0]);
await waitFor(() => {
expect(createTask).toHaveBeenCalledWith(expect.objectContaining({
title: 'Fix failing check "failure" on PR #81',
description: expect.stringContaining("Repository: owner/repo"),
}), "project-1");
});
const [{ description }] = vi.mocked(createTask).mock.calls[0];
expect(description).toContain("https://github.com/owner/repo/pull/81");
expect(description).toContain("broken-build → main");
expect(description).toContain("Failing check: failure");
expect(description).toContain("https://github.com/owner/repo/runs/1");
expect(await screen.findByTestId("github-import-pr-check-fix-task-toast")).toHaveTextContent("Fix task created");
expect(onImport).toHaveBeenCalledWith(createdTask);
});
it("renders the affordance only for failed variants and keeps mobile wrapping source coverage", async () => {
await renderSelectedPullChecks(checkVariants);
expect(await screen.findAllByTestId("github-import-pr-check-fix-task")).toHaveLength(1);
expect(screen.getByRole("button", { name: "Create fix task for failure" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Create fix task for success" })).toBeNull();
expect(screen.queryByRole("button", { name: "Create fix task for pending" })).toBeNull();
expect(screen.queryByRole("button", { name: "Create fix task for neutral" })).toBeNull();
const source = readFileSync(resolve(__dirname, "../GitHubImportModal.css"), "utf8");
expect(source).toMatch(/@media \(max-width: 768px\)[\s\S]*\.github-import-pr-check-row\s*\{[\s\S]*flex-wrap: wrap;/);
expect(source).toMatch(/@media \(max-width: 768px\)[\s\S]*\.github-import-pr-check-fix-task\s*\{[\s\S]*width: 100%;/);
});
it("keeps duplicate failed check rows independently actionable while creation is in flight", async () => {
let resolveFirstCreate!: (task: Task) => void;
vi.mocked(createTask)
.mockImplementationOnce(() => new Promise<Task>((resolve) => { resolveFirstCreate = resolve; }))
.mockResolvedValueOnce({ ...mockTask, id: "FN-second" });
await renderSelectedPullChecks([
{ name: "duplicate", status: "completed", conclusion: "failure" },
{ name: "duplicate", status: "completed", conclusion: "failure" },
]);
const [first, second] = await screen.findAllByTestId("github-import-pr-check-fix-task");
fireEvent.click(first);
await waitFor(() => expect(first).toBeDisabled());
expect(first.querySelector(".spin")).toBeTruthy();
expect(second).not.toBeDisabled();
fireEvent.click(second);
expect(createTask).toHaveBeenCalledTimes(2);
await act(async () => { resolveFirstCreate(mockTask); });
});
it("surfaces create errors inline and permits retry", async () => {
vi.mocked(createTask)
.mockRejectedValueOnce(new Error("Task service unavailable"))
.mockResolvedValueOnce({ ...mockTask, id: "FN-retry" });
await renderSelectedPullChecks([{ name: "lint", status: "completed", conclusion: "failure" }]);
const button = await screen.findByTestId("github-import-pr-check-fix-task");
fireEvent.click(button);
expect(await screen.findByTestId("github-import-pr-check-fix-task-toast")).toHaveTextContent("Task service unavailable");
expect(button).not.toBeDisabled();
expect(onImport).not.toHaveBeenCalled();
fireEvent.click(button);
await waitFor(() => expect(onImport).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-retry" })));
expect(createTask).toHaveBeenCalledTimes(2);
});
it("emits no fix action while PR detail is loading, errored, empty, or has no failures", async () => {
let resolveDetail!: (detail: { comments: []; checks: [] }) => void;
const loadingDetail = new Promise<{ comments: []; checks: [] }>((resolve) => { resolveDetail = resolve; });
await renderSelectedPullChecks([], "modal", loadingDetail);
expect(await screen.findByTestId("github-import-pr-checks-loading")).toBeTruthy();
expect(screen.queryByTestId("github-import-pr-check-fix-task")).toBeNull();
await act(async () => { resolveDetail({ comments: [], checks: [] }); });
expect(await screen.findByTestId("github-import-pr-checks-empty")).toBeTruthy();
expect(screen.queryByTestId("github-import-pr-check-fix-task")).toBeNull();
});
it("emits no fix action for PR detail errors or populated non-failing checks", async () => {
vi.mocked(apiFetchGitHubPullDetail).mockRejectedValueOnce(new Error("Checks unavailable"));
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([{ name: "origin", owner: "owner", repo: "repo", url: "" }]);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce([{
number: 82, title: "No failures", body: "", html_url: "https://github.com/owner/repo/pull/82", headBranch: "feature", baseBranch: "main",
}]);
render(<GitHubImportModal isOpen onClose={onClose} onImport={onImport} tasks={[]} />);
fireEvent.click(await screen.findByRole("tab", { name: /Pull Requests/i }));
await screen.findByText("No failures");
fireEvent.click(screen.getByRole("button", { name: /Select pull request #82/i }));
expect(await screen.findByTestId("github-import-pr-checks-error")).toHaveTextContent("Checks unavailable");
expect(screen.queryByTestId("github-import-pr-check-fix-task")).toBeNull();
});
it("emits no fix action for populated successful, pending, and neutral checks", async () => {
await renderSelectedPullChecks(checkVariants.slice(1));
expect(await screen.findByTestId("github-import-pr-checks")).toBeTruthy();
expect(screen.queryByTestId("github-import-pr-check-fix-task")).toBeNull();
});
});
it("renders when isOpen is true", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);