diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 049097d9f6..1775102e9e 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -339,8 +339,9 @@ Import Tasks documentation distinguishes Add comment (an upstream GitHub mutatio --> 5. In an issue detail, use **Add comment** to write a new upstream GitHub comment. The composer remains available for open and closed issues; posting adds the comment to the preview thread immediately. This is separate from **Import as task**, which creates a Fusion resolve-feedback task from an existing GitHub comment. Expected outcome: Fusion posts the new comment to GitHub, keeps its inline composer available for another response, and shows a success or retryable error message without leaving Import Tasks. -6. 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. + +6. In a GitHub **issue** detail, choose **Import as task** to create the board task directly with GitHub provenance/tracking metadata, or choose **Plan** to close Import Tasks and open Planning Mode with the issue title, body, and source URL as the initial plan. The Planning path does not establish GitHub source-issue tracking or direct-import deduplication. Pull requests continue to use **Resolve feedback**, which creates a task to resolve reviewer feedback and address failed CI checks. 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: direct import creates the requested tracked task and returns the completed PR/issue import to the list; Plan opens the docked Planning Mode interview with the issue context; comment imports remain available for further feedback. Leaving and returning to **Import Tasks** (for example switching to Board and back) restores the prior context for the current project — provider (GitHub/GitLab), active Issues/PRs tab, label filter, selected repository/remote, GitLab project/group inputs, the **Hide imported** preference, and the previously selected issue/PR — instead of resetting to defaults. When GitLab integration is disabled in Settings, the GitLab provider tab is hidden and any restored GitLab provider preference opens on GitHub instead; saved GitLab URLs and tokens remain configured. The restored selection re-validates against the freshly reloaded list; a selection that no longer exists (e.g. the issue was closed upstream) clears gracefully rather than showing a stuck or empty preview. First-time opens with no prior state keep the existing default-remote auto-detect behavior. State is scoped per project and does not leak across projects. @@ -348,8 +349,8 @@ Use GitHub import on mobile: 1. Open the compact Header actions or bottom **More** sheet and select **Import from GitHub**. Expected outcome: the same import workflow opens in the mobile modal layout. -2. Choose the repository, issue/PR tab, candidate row, and import action. - Expected outcome: Fusion creates the board task with the same GitHub provenance/tracking metadata as the desktop/tablet **Import Tasks** view. +2. Choose the repository, issue/PR tab, candidate row, and detail action. For GitHub issues, choose **Import as task** for direct tracked creation or **Plan** to start Planning Mode with the issue context. + Expected outcome: direct import creates the board task with the same GitHub provenance/tracking metadata as the desktop/tablet **Import Tasks** view; Plan opens the Planning Mode interview without source-issue tracking. 3. While a candidate detail sheet is open, use the platform Back gesture or control. Expected outcome: the first Back dismisses only the issue, pull request, or GitLab detail and returns to the import candidate list; a second Back dismisses the import form. diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index 38bdc3e3a9..3d000797f0 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -393,6 +393,7 @@ export function AppModals({ isOpen={modalManager.githubImportOpen} onClose={closeGitHubImportWithNav} onImport={taskHandlers.handleGitHubImport} + onPlanningMode={onPlanningMode} tasks={tasks} projectId={projectId} /> diff --git a/packages/dashboard/app/components/GitHubImportModal.tsx b/packages/dashboard/app/components/GitHubImportModal.tsx index 4b0d4089d6..52d19be2b1 100644 --- a/packages/dashboard/app/components/GitHubImportModal.tsx +++ b/packages/dashboard/app/components/GitHubImportModal.tsx @@ -50,6 +50,8 @@ interface GitHubImportModalProps { isOpen: boolean; onClose: () => void; onImport: (task: Task) => void; + /** Optional because callers without Planning Mode retain the direct-import-only surface. */ + onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void; tasks: Task[]; projectId?: string; /* @@ -368,6 +370,23 @@ const ISSUES_PAGE_SIZE = 30; * 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. */ +/* +FNXC:GitHubImport 2026-07-30-00:00: +Operators can choose direct task import or Planning Mode for GitHub issues. Planning receives a +self-contained issue seed, including the source URL, but intentionally does not establish GitHub +sourceIssue tracking or deduplication; those remain exclusive to direct import. +*/ +export function buildIssuePlanningSeed(issue: GitHubIssue): string { + return [ + `Plan work for GitHub issue: ${issue.title}`, + "", + "Issue description:", + issue.body?.trim() || "(no description)", + "", + `Source: ${issue.html_url}`, + ].join("\n"); +} + export function buildCheckFixTaskPrompt( pull: GitHubPull, check: GitHubPullDetail["checks"][number], @@ -391,7 +410,7 @@ export function buildCheckFixTaskPrompt( }; } -export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, presentation = "modal" }: GitHubImportModalProps) { +export function GitHubImportModal({ isOpen, onClose, onImport, onPlanningMode, tasks, projectId, presentation = "modal" }: GitHubImportModalProps) { const { isEmbedded, scrollLockEnabled, resizePersistEnabled, escapeEnabled } = useEmbeddedPresentation(presentation); useMobileScrollLock(isOpen && scrollLockEnabled); const { t, i18n } = useTranslation("app"); @@ -1160,6 +1179,18 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, } }, [activeTab, selectedIssueNumber, selectedPullNumber, issues, pulls, owner, repo, projectId, onImport, returnToIssueListAfterSuccess, clearDetailSelection]); + const handlePlanIssue = useCallback(() => { + const selectedIssue = activeTab === "issues" + ? issues.find((issue) => issue.number === selectedIssueNumber) + : undefined; + if (!selectedIssue || !onPlanningMode || importing || isUrlImported(selectedIssue.html_url)) return; + + const seed = buildIssuePlanningSeed(selectedIssue); + // FNXC:GitHubImport 2026-07-30-00:00: Embedded close navigates to Board, so close first and open Planning last to preserve Planning as the final destination. + onClose(); + onPlanningMode(seed); + }, [activeTab, importing, isUrlImported, issues, onClose, onPlanningMode, selectedIssueNumber]); + const fetchPullDetail = useCallback((force: boolean) => { const requestId = ++pullDetailRequestRef.current; if (activeTab !== "pulls" || selectedPullNumber === null || !owner.trim() || !repo.trim()) { @@ -2280,6 +2311,17 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, {closingIssue ? : t("git.closeIssue", "Close issue")} )} + {activeTab === "issues" && selectedIssue && onPlanningMode && ( + + )} diff --git a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx index 05001ad5bc..85f83f07c8 100644 --- a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { useEffect, type ReactNode } from "react"; import { act, render, screen, fireEvent, waitFor, within } from "@testing-library/react"; -import { GitHubImportModal } from "../GitHubImportModal"; +import { buildIssuePlanningSeed, GitHubImportModal } from "../GitHubImportModal"; import { ConfirmDialogProvider } from "../../hooks/useConfirm"; import { NavigationHistoryProvider, useNavigationHistory } from "../../hooks/useNavigationHistory"; import { @@ -282,6 +282,85 @@ describe("GitHubImportModal", () => { , ); + it("builds a Planning Mode seed with the GitHub issue context", () => { + expect(buildIssuePlanningSeed({ + number: 42, + title: "Plan import", + body: "Capture the original issue context.", + html_url: "https://github.com/owner/repo/issues/42", + labels: [], + state: "open", + })).toContain("Plan import"); + expect(buildIssuePlanningSeed({ + number: 42, + title: "Plan import", + body: "Capture the original issue context.", + html_url: "https://github.com/owner/repo/issues/42", + labels: [], + state: "open", + })).toContain("Capture the original issue context."); + expect(buildIssuePlanningSeed({ + number: 42, + title: "Plan import", + body: "Capture the original issue context.", + html_url: "https://github.com/owner/repo/issues/42", + labels: [], + state: "open", + })).toContain("https://github.com/owner/repo/issues/42"); + }); + + it("plans a selected issue after closing the embedded import surface", async () => { + const issue = { number: 42, title: "Plan import", body: "Capture the original issue context.", html_url: "https://github.com/owner/repo/issues/42", labels: [], state: "open" }; + const sequence: string[] = []; + let destination = "import"; + const onPlanningMode = vi.fn((seed: string) => { + sequence.push("planning"); + destination = "planning"; + expect(seed).toContain(issue.title); + expect(seed).toContain(issue.body); + expect(seed).toContain(issue.html_url); + }); + const closeToBoard = vi.fn(() => { + sequence.push("board"); + destination = "board"; + }); + vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote); + vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([issue]); + + render(); + fireEvent.click(await screen.findByRole("button", { name: /Select issue #42/i })); + expect(screen.getByTestId("github-import-action-plan")).toBeEnabled(); + expect(screen.getByTestId("github-import-action-top")).toHaveTextContent("Import as task"); + + fireEvent.click(screen.getByTestId("github-import-action-plan")); + + // FNXC:GitHubImport 2026-07-30-00:00: The embedded close routes to Board; Planning must run second so it remains the final destination. + expect(sequence).toEqual(["board", "planning"]); + expect(destination).toBe("planning"); + expect(onPlanningMode).toHaveBeenCalledTimes(1); + expect(apiImportGitHubIssue).not.toHaveBeenCalled(); + }); + + it("renders Plan only for selectable GitHub issues with Planning Mode", async () => { + const issue = { number: 43, title: "Optional plan", body: "Issue body", html_url: "https://github.com/owner/repo/issues/43", labels: [], state: "open" }; + vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote); + vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([issue]); + + const view = render(); + fireEvent.click(await screen.findByRole("button", { name: /Select issue #43/i })); + expect(screen.getByTestId("github-import-action-plan")).toBeEnabled(); + + view.rerender(); + expect(screen.getByTestId("github-import-action-plan")).toBeDisabled(); + + view.unmount(); + vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote); + vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([issue]); + render(); + fireEvent.click(await screen.findByRole("button", { name: /Select issue #43/i })); + expect(screen.queryByTestId("github-import-action-plan")).toBeNull(); + }); + const dispatchDetailBack = (delivery: "popstate" | "native") => { if (delivery === "native") { const event = new CustomEvent("fusion:native-back", { cancelable: true, detail: { source: "android-back" } }); @@ -728,7 +807,7 @@ describe("GitHubImportModal", () => { vi.mocked(apiImportGitLabGroupIssue).mockResolvedValueOnce({ ...mockTask, id: "FN-100", title: "Group issue" }); vi.mocked(apiImportGitLabMergeRequest).mockResolvedValueOnce({ ...mockTask, id: "FN-101", title: "Review MR !5: Review me" }); - render(); + render(); fireEvent.click(await screen.findByRole("button", { name: "GitLab" })); fireEvent.click(screen.getByRole("tab", { name: "Group issues" })); @@ -737,6 +816,7 @@ describe("GitHubImportModal", () => { expect(await screen.findByText(/#7 Group issue/)).toBeInTheDocument(); fireEvent.click(screen.getByText(/#7 Group issue/)); expect(screen.getByTestId("gitlab-import-preview-body")).toHaveTextContent("(no description)"); + expect(screen.queryByTestId("github-import-action-plan")).toBeNull(); fireEvent.click(screen.getAllByRole("button", { name: "Import" })[0]); await waitFor(() => expect(apiImportGitLabGroupIssue).toHaveBeenCalledWith(expect.objectContaining({ iid: 7 }), "group", undefined)); @@ -1737,13 +1817,14 @@ describe("GitHubImportModal", () => { vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote); vi.mocked(apiFetchGitHubPulls).mockResolvedValue([{ number: 23, title: "Action PR", body: "body", html_url: "https://github.com/owner/repo/pull/23", headBranch: "feature", baseBranch: "main" }]); vi.mocked(apiFetchGitHubIssues).mockResolvedValue([{ number: 24, title: "Action issue", body: "body", html_url: "https://github.com/owner/repo/issues/24", labels: [], state: "open", author: "owner" }]); - render(); + render(); fireEvent.click(await screen.findByRole("tab", { name: /Pull Requests/i })); fireEvent.click(await screen.findByRole("button", { name: /Select pull request #23/i })); expect(await screen.findByRole("button", { name: "Resolve feedback" })).toBeTruthy(); + expect(screen.queryByTestId("github-import-action-plan")).toBeNull(); fireEvent.click(screen.getByRole("tab", { name: "Issues" })); fireEvent.click(await screen.findByRole("button", { name: /Select issue #24/i })); - expect(await screen.findByRole("button", { name: "Import" })).toBeTruthy(); + expect(await screen.findByRole("button", { name: "Import as task" })).toBeTruthy(); }); // FNXC:GitHubImport 2026-06-23-03:30: The Human filter hides bot comments; All (default) shows both. @@ -2947,7 +3028,7 @@ describe("GitHubImportModal — detail actions sit at the bottom (operator repor bottom of the flex panel. Geometry itself was verified in a real browser at 412px. */ expect(content!.compareDocumentPosition(bar) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - expect(within(bar).getByRole("button", { name: /^Import$/i })).toBeTruthy(); + expect(within(bar).getByRole("button", { name: /^Import as task$/i })).toBeTruthy(); expect(within(bar).getByTestId("github-import-issue-close")).toBeTruthy(); }); diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index 282c0b4109..af03ed2a04 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -662,6 +662,7 @@ export function MainContent({ isOpen={true} onClose={() => handleChangeTaskView("board")} onImport={handleGitHubImport} + onPlanningMode={openPlanningWithInitialPlanWithNav} tasks={tasks} projectId={currentProject?.id} presentation="embedded"