diff --git a/.changeset/fn-8110-pr-failed-check-fix-task.md b/.changeset/fn-8110-pr-failed-check-fix-task.md new file mode 100644 index 0000000000..7e5adc3449 --- /dev/null +++ b/.changeset/fn-8110-pr-failed-check-fix-task.md @@ -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). diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index abc5b27dca..008ed82649 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. diff --git a/packages/dashboard/app/components/GitHubImportModal.css b/packages/dashboard/app/components/GitHubImportModal.css index 44f30cc723..13a4dea36f 100644 --- a/packages/dashboard/app/components/GitHubImportModal.css +++ b/packages/dashboard/app/components/GitHubImportModal.css @@ -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. diff --git a/packages/dashboard/app/components/GitHubImportModal.tsx b/packages/dashboard/app/components/GitHubImportModal.tsx index bfdae4e03b..da3686812d 100644 --- a/packages/dashboard/app/components/GitHubImportModal.tsx +++ b/packages/dashboard/app/components/GitHubImportModal.tsx @@ -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 | 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>(new Set()); + const [checkFixTaskToast, setCheckFixTaskToast] = useState<{ type: "success" | "error"; message: string } | null>(null); + const checkFixTaskToastTimerRef = useRef | null>(null); const [error, setError] = useState(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 ( -
  • +
  • {indicator || "pending"} {check.detailsUrl ? ( {check.name} ) : ( {check.name} )} + {variant === "failure" && ( + + )}
  • ); })} @@ -1897,6 +1977,15 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, ) : (
    {t("git.noChecks", "No checks")}
    )} + {checkFixTaskToast && ( +
    + {checkFixTaskToast.message} +
    + )} { 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(); + 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((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(); + 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();