FN-8261: add GitHub issue planning import option

Add a Planning Mode path alongside direct GitHub issue imports.

- Pass selected issue context into Planning Mode after closing the import surface
- Preserve direct-import tracking while labeling its action clearly
- Cover issue planning behavior and document desktop and mobile flows

Files changed:
 docs/dashboard-guide.md                            |  9 ++-
 packages/dashboard/app/components/AppModals.tsx    |  1 +
 packages/dashboard/app/components/GitHubImportModal.tsx | 46 ++++++++++-
 packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx | 91 ++++++++++++++++++++--
 packages/dashboard/app/components/dashboard/MainContent.tsx |  1 +
 5 files changed, 137 insertions(+), 11 deletions(-)

Fusion-Task-Id: FN-8261

Fusion-Task-Lineage: 6b121563-7cfa-4ba1-82c9-9ad574738a20

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-18 01:58:31 -07:00
parent c0cce18cfb
commit 44e20274d7
5 changed files with 137 additions and 11 deletions

View File

@@ -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.
<!-- FNXC:GitHubImportDocs 2026-07-30-00:00: GitHub issue detail now gives operators a deliberate direct-import versus Planning Mode choice; Planning preserves textual source context but must not imply direct-import sourceIssue tracking. -->
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.

View File

@@ -393,6 +393,7 @@ export function AppModals({
isOpen={modalManager.githubImportOpen}
onClose={closeGitHubImportWithNav}
onImport={taskHandlers.handleGitHubImport}
onPlanningMode={onPlanningMode}
tasks={tasks}
projectId={projectId}
/>

View File

@@ -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 ? <Loader2 size={14} className="spin" /> : t("git.closeIssue", "Close issue")}
</button>
)}
{activeTab === "issues" && selectedIssue && onPlanningMode && (
<button
type="button"
className="btn github-import-action"
data-testid="github-import-action-plan"
onClick={handlePlanIssue}
disabled={importing || isUrlImported(selectedIssue.html_url)}
>
{t("git.planIssue", "Plan")}
</button>
)}
<button
className="btn btn-primary github-import-action"
data-testid="github-import-action-top"
@@ -2288,7 +2330,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
(activeTab === "issues" ? selectedIssueNumber === null || isUrlImported(selectedIssue?.html_url) : selectedPullNumber === null || isUrlImported(selectedPull?.html_url)) || importing
}
>
{importing ? <Loader2 size={14} className="spin" /> : activeTab === "pulls" ? t("git.resolveFeedback", "Resolve feedback") : t("git.import", "Import")}
{importing ? <Loader2 size={14} className="spin" /> : activeTab === "pulls" ? t("git.resolveFeedback", "Resolve feedback") : t("git.importAsTask", "Import as task")}
</button>
</div>
</div>

View File

@@ -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", () => {
</MobileNavigationHarness>,
);
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(<GitHubImportModal isOpen onClose={closeToBoard} onImport={onImport} onPlanningMode={onPlanningMode} tasks={[]} presentation="embedded" />);
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(<GitHubImportModal isOpen onClose={onClose} onImport={onImport} tasks={[]} onPlanningMode={vi.fn()} />);
fireEvent.click(await screen.findByRole("button", { name: /Select issue #43/i }));
expect(screen.getByTestId("github-import-action-plan")).toBeEnabled();
view.rerender(<GitHubImportModal isOpen onClose={onClose} onImport={onImport} onPlanningMode={vi.fn()} tasks={[{ ...mockTask, description: `Source: ${issue.html_url}` }]} />);
expect(screen.getByTestId("github-import-action-plan")).toBeDisabled();
view.unmount();
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([issue]);
render(<GitHubImportModal isOpen onClose={onClose} onImport={onImport} tasks={[]} />);
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(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} onPlanningMode={vi.fn()} tasks={[]} />);
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(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} presentation="embedded" />);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} onPlanningMode={vi.fn()} tasks={[]} presentation="embedded" />);
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();
});

View File

@@ -662,6 +662,7 @@ export function MainContent({
isOpen={true}
onClose={() => handleChangeTaskView("board")}
onImport={handleGitHubImport}
onPlanningMode={openPlanningWithInitialPlanWithNav}
tasks={tasks}
projectId={currentProject?.id}
presentation="embedded"