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:
@@ -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.
|
||||
|
||||
|
||||
@@ -393,6 +393,7 @@ export function AppModals({
|
||||
isOpen={modalManager.githubImportOpen}
|
||||
onClose={closeGitHubImportWithNav}
|
||||
onImport={taskHandlers.handleGitHubImport}
|
||||
onPlanningMode={onPlanningMode}
|
||||
tasks={tasks}
|
||||
projectId={projectId}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -662,6 +662,7 @@ export function MainContent({
|
||||
isOpen={true}
|
||||
onClose={() => handleChangeTaskView("board")}
|
||||
onImport={handleGitHubImport}
|
||||
onPlanningMode={openPlanningWithInitialPlanWithNav}
|
||||
tasks={tasks}
|
||||
projectId={currentProject?.id}
|
||||
presentation="embedded"
|
||||
|
||||
Reference in New Issue
Block a user