From d09e3e55540e5526ae8605171b2605355ce9767a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 9 Aug 2026 07:06:06 -0700 Subject: [PATCH] FN-8861: preserve GitHub issue context in planning Preserve imported GitHub issue provenance through Planning Mode and source tracking. - Pass structured GitHub issue context from import through planning task creation. - Append source-issue metadata to planned tasks and safely link each source issue once. - Cover provenance persistence, duplicate suppression, and planning UI handoff. Files changed: .changeset/fn-8861-planning-source-issue.md | 7 ++ docs/dashboard-guide.md | 8 +- docs/settings-reference.md | 2 +- packages/dashboard/app/App.tsx | 4 +- packages/dashboard/app/api/planning/planning.ts | 3 +- packages/dashboard/app/components/AppModals.tsx | 2 +- .../dashboard/app/components/GitHubImportModal.tsx | 13 +-- .../dashboard/app/components/PlanningModeModal.tsx | 5 +- .../__tests__/GitHubImportModal.test.tsx | 34 ++++++ .../app/components/dashboard/PlanningKeepAlive.tsx | 1 + .../dashboard/app/components/dashboard/types.ts | 2 +- .../app/hooks/__tests__/useModalManager.test.ts | 26 ++++- packages/dashboard/app/hooks/useModalManager.ts | 17 ++- .../src/__tests__/github-tracking.test.ts | 125 ++++++++++++++++++++- packages/dashboard/src/__tests__/github.test.ts | 78 ++++++++++++- .../__tests__/planning-e2e-plan-creation.test.ts | 49 +++++++- .../src/__tests__/planning-source-issue.test.ts | 61 ++++++++++ .../src/__tests__/routes-planning-tracking.test.ts | 118 ++++++++++++++++++- packages/dashboard/src/github-tracking.ts | 112 ++++++++++++++++-- packages/dashboard/src/github.ts | 50 +++++++++ packages/dashboard/src/planning.ts | 48 +++++++- .../dashboard/src/routes/register-git-github.ts | 31 +---- .../src/routes/register-planning-subtask-routes.ts | 69 +++++++++++- 23 files changed, 785 insertions(+), 80 deletions(-) Fusion-Task-Id: FN-8861 Fusion-Task-Lineage: b2433f70-9c1b-47ef-bafa-0434861cd0a8 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8861-planning-source-issue.md | 7 + docs/dashboard-guide.md | 8 +- docs/settings-reference.md | 2 +- packages/dashboard/app/App.tsx | 4 +- .../dashboard/app/api/planning/planning.ts | 3 +- .../dashboard/app/components/AppModals.tsx | 2 +- .../app/components/GitHubImportModal.tsx | 13 +- .../app/components/PlanningModeModal.tsx | 5 +- .../__tests__/GitHubImportModal.test.tsx | 34 +++++ .../dashboard/PlanningKeepAlive.tsx | 1 + .../app/components/dashboard/types.ts | 2 +- .../hooks/__tests__/useModalManager.test.ts | 26 +++- .../dashboard/app/hooks/useModalManager.ts | 17 ++- .../src/__tests__/github-tracking.test.ts | 125 +++++++++++++++++- .../dashboard/src/__tests__/github.test.ts | 78 ++++++++++- .../planning-e2e-plan-creation.test.ts | 49 ++++++- .../__tests__/planning-source-issue.test.ts | 61 +++++++++ .../routes-planning-tracking.test.ts | 118 ++++++++++++++++- packages/dashboard/src/github-tracking.ts | 112 ++++++++++++++-- packages/dashboard/src/github.ts | 50 +++++++ packages/dashboard/src/planning.ts | 48 ++++++- .../src/routes/register-git-github.ts | 31 +---- .../register-planning-subtask-routes.ts | 69 +++++++++- 23 files changed, 785 insertions(+), 80 deletions(-) create mode 100644 .changeset/fn-8861-planning-source-issue.md create mode 100644 packages/dashboard/src/__tests__/planning-source-issue.test.ts diff --git a/.changeset/fn-8861-planning-source-issue.md b/.changeset/fn-8861-planning-source-issue.md new file mode 100644 index 0000000000..266e0103da --- /dev/null +++ b/.changeset/fn-8861-planning-source-issue.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Planning a GitHub issue now creates a task linked to that issue as a tracked source issue. +category: feature +dev: Planning sessions persist GitHub source context, preserve the issue body, and exclusively adopt concurrent source links. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 1ed979a235..46d34c06f1 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -423,13 +423,13 @@ Import Tasks documentation distinguishes Add comment (an upstream GitHub mutatio 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. In a GitHub **issue** detail, choose **Import as task** to create the board task directly with GitHub provenance/tracking metadata, **Plan** to close Import Tasks and open Planning Mode with the issue title, body, and source URL as the initial plan, or **Chat** to open Chat with the selected issue link prefilled in the composer. The Planning path does not establish GitHub source-issue tracking or direct-import deduplication; Chat does not create a task or send a message. Pull requests continue to use **Resolve feedback** for task creation and also offer **Chat** with their selected PR link prefilled. The Chat action is GitHub-only; GitLab import actions are unchanged. 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; Chat opens a focused composer ready for an operator question; comment imports remain available for further feedback. +6. In a GitHub **issue** detail, choose **Import as task** to create the board task directly, **Plan** to open Planning Mode with the issue title, body, and source URL, or **Chat** to prefill the selected issue link. A planned task records the same GitHub source provenance as direct import and preserves the original body in its task description and document. GitHub tracking is enabled when `githubLinkImportedIssuesToTracking` or the resolved GitHub tracking default is on. If a live task already represents the issue, Planning still records truthful provenance but suppresses the second tracking stream; exclusive adoption makes concurrent plans converge on one linked issue. Planning does not download issue/comment image attachments, and breakdown subtasks preserve the source text but do not each link or track the issue. Chat does not create a task or send a message. Pull requests continue to use **Resolve feedback** for task creation and also offer **Chat** with their selected PR link prefilled. The Chat action is GitHub-only; GitLab import actions are unchanged. 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 and Plan create source-aware tasks; Plan opens the docked Planning Mode interview with retained issue context, while duplicate source tracking remains singular; Chat opens a focused composer ready for an operator question; 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. @@ -444,7 +444,7 @@ on one touch-safe row. Labels may wrap inside their own actions rather than bein an inaccessible second row. --> 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. When all GitHub issue actions are available, their full labels remain on one touch-safe action row. + Expected outcome: direct import creates the board task with GitHub provenance/tracking metadata; Plan creates the same source-aware planned task, subject to the same tracking settings and single-tracker duplicate rule. Planning does not download issue images, and breakdown children retain text only rather than creating multiple issue links. When all GitHub issue actions are available, their full labels remain on one touch-safe action row. 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/docs/settings-reference.md b/docs/settings-reference.md index 98c37174dd..6190b83d23 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -712,7 +712,7 @@ Default notes: | `githubCloseSourceIssueOnDone` | `boolean` | `false` | When enabled, source-imported GitHub issues are automatically closed with `state_reason: completed` when the Fusion task moves to `done`. A startup reconciliation sweep also closes missed open source issues on boot. Separately, a triage split-close comments with the child task IDs immediately before closing its imported issue; the same source/tracking issue is handled once by its owning path. | | `githubTrackingEnabledByDefault` | `boolean` | `false` | Project-level default for enabling issue tracking on ordinary new tasks. When this is false, the Quick Entry GitHub toggle is disabled until tracking is enabled in Settings. Imported GitHub issues still follow this default unless `githubLinkImportedIssuesToTracking` is enabled. | | `sessionAdvisorEnabledByDefault` | `boolean` | `false` | Project-level default for the session advisor (LLM overseer agent that reviews live executor transcripts). Off by default (opt-in). Quick Add exposes an eye toggle next to GitHub that inherits this default; each task can override via `sessionAdvisorEnabled`. Provider and model ids still come from workflow settings (`plannerOverseerAdvisorProvider` / `plannerOverseerAdvisorModelId`). Dashboard location: **Settings → Project → General → Session advisor (overseer agent)**. | -| `githubLinkImportedIssuesToTracking` | `boolean` | `false` | Project-scoped, import-only option. When enabled, GitHub issue imports from the dashboard, CLI, and extension tools persist `githubTracking: { enabled: true }` so Fusion adopts the imported source issue as the tracking issue without turning tracking on for ordinary new tasks. Duplicate/skipped imports do not create tasks or tracking metadata. | +| `githubLinkImportedIssuesToTracking` | `boolean` | `false` | Project-scoped imported-issue option. When enabled, direct GitHub imports and Planning Mode tasks seeded from a GitHub issue persist `githubTracking: { enabled: true }` so Fusion adopts the source issue as the tracking issue without turning tracking on for ordinary new tasks. A duplicate planned task still records source provenance but suppresses its second tracking link. | | `reportMode` | `"draft-review" \| "auto-file"` | `"draft-review"` | Default in-app Bug, Feedback, Idea, and Help report behavior. `"draft-review"` presents the scrubbed, structured draft for approval; `"auto-file"` files a new report or endorses a strong open duplicate automatically. Dashboard location: **Settings → Project → General → In-app report mode**. | | `reportModeByAction` | `Partial>` | `undefined` | Optional per-action override map. An action-specific value takes precedence over `reportMode`; unset actions use the project default. Every mode always scrubs report content before GitHub egress. | | `reportRoadmapDedupeEnabled` | `boolean` | `true` | Enables open public-roadmap GitHub Issue matching before ordinary Issue/Discussion report dedupe. Project value overrides global; unavailable searches fall back safely. Dashboard location: **Settings → Project → General**. | diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 6f9abe552a..57e9eeaafc 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -1262,8 +1262,8 @@ function AppInner() { handleTaskViewChange("planning"); }, [handleTaskViewChange, modalManager]); - const openPlanningWithInitialPlanWithNav = useCallback((initialPlan: string, workflowId?: string | null) => { - modalManager.openPlanningWithInitialPlan(initialPlan, workflowId); + const openPlanningWithInitialPlanWithNav = useCallback((initialPlan: string, workflowId?: string | null, sourceIssue?: { provider: "github"; repository: string; issueNumber: number; url: string; title?: string }) => { + modalManager.openPlanningWithInitialPlan(initialPlan, workflowId, sourceIssue); handleTaskViewChange("planning"); }, [handleTaskViewChange, modalManager]); diff --git a/packages/dashboard/app/api/planning/planning.ts b/packages/dashboard/app/api/planning/planning.ts index cd5a1fbe62..d03492d844 100644 --- a/packages/dashboard/app/api/planning/planning.ts +++ b/packages/dashboard/app/api/planning/planning.ts @@ -125,7 +125,7 @@ export function startPlanningStreaming( initialPlan: string, projectId?: string, modelOverride?: { planningModelProvider?: string; planningModelId?: string; thinkingLevel?: ThinkingLevel }, - planningOptions?: { clarificationEnabled?: boolean; workflowId?: string | null }, + planningOptions?: { clarificationEnabled?: boolean; workflowId?: string | null; sourceIssue?: { provider: "github"; repository: string; issueNumber: number; url: string; title?: string } }, existingSessionId?: string, ): Promise<{ sessionId: string }> { return api<{ sessionId: string }>(withProjectId("/planning/start-streaming", projectId), { @@ -137,6 +137,7 @@ export function startPlanningStreaming( thinkingLevel: modelOverride?.thinkingLevel, clarificationEnabled: planningOptions?.clarificationEnabled, ...(planningOptions?.workflowId ? { workflowId: planningOptions.workflowId } : {}), + ...(planningOptions?.sourceIssue ? { sourceIssue: planningOptions.sourceIssue } : {}), ...(existingSessionId ? { existingSessionId } : {}), }), }); diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index 502877a187..18175036a8 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -55,7 +55,7 @@ interface AppModalsProps { modalManager: ModalManager; projectActions: Pick; taskHandlers: Pick; - onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void; + onPlanningMode?: (initialPlan: string, workflowId?: string | null, sourceIssue?: { provider: "github"; repository: string; issueNumber: number; url: string; title?: string }) => void; onOpenChatWithPrefill?: (prefillText: string) => void; onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void; taskOperations: { diff --git a/packages/dashboard/app/components/GitHubImportModal.tsx b/packages/dashboard/app/components/GitHubImportModal.tsx index 94208e8b2f..22a33817f2 100644 --- a/packages/dashboard/app/components/GitHubImportModal.tsx +++ b/packages/dashboard/app/components/GitHubImportModal.tsx @@ -50,7 +50,7 @@ interface GitHubImportModalProps { 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; + onPlanningMode?: (initialPlan: string, workflowId?: string | null, sourceIssue?: { provider: "github"; repository: string; issueNumber: number; url: string; title?: string }) => void; /* FNXC:GitHubImport 2026-07-30-12:00: Chat is deliberately separate from direct import: it seeds a GitHub issue/PR link in the composer, @@ -376,10 +376,9 @@ const ISSUES_PAGE_SIZE = 30; * 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. +FNXC:GitHubImport 2026-08-09-05:36: +Planning receives both the canonical seed and structured GitHub provenance so the server can preserve +issue context and safely adopt the source issue without treating arbitrary prose URLs as links. */ export function buildIssuePlanningSeed(issue: GitHubIssue): string { return [ @@ -1191,8 +1190,8 @@ export function GitHubImportModal({ isOpen, onClose, onImport, onPlanningMode, o 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]); + onPlanningMode(seed, undefined, { provider: "github", repository: `${owner}/${repo}`, issueNumber: selectedIssue.number, url: selectedIssue.html_url, title: selectedIssue.title }); + }, [activeTab, importing, isUrlImported, issues, onClose, onPlanningMode, owner, repo, selectedIssueNumber]); const fetchPullDetail = useCallback((force: boolean) => { const requestId = ++pullDetailRequestRef.current; diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index b6dd7e02d2..3284d5ae5c 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -156,6 +156,7 @@ interface PlanningModeModalProps { onViewTask?: (task: Task) => void; tasks: Task[]; initialPlan?: string; + sourceIssue?: { provider: "github"; repository: string; issueNumber: number; url: string; title?: string }; /** FNXC:PlanningMode 2026-07-23-00:00: Called exactly once when the auto-start effect actually consumes `initialPlan`, so the owner @@ -483,7 +484,7 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri }; } -export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreated, onViewTask, tasks, initialPlan: initialPlanProp, onInitialPlanConsumed, projectId, workflowId, resumeSessionId, initialSessions, presentation = "modal", active = true }: PlanningModeModalProps) { +export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreated, onViewTask, tasks, initialPlan: initialPlanProp, sourceIssue, onInitialPlanConsumed, projectId, workflowId, resumeSessionId, initialSessions, presentation = "modal", active = true }: PlanningModeModalProps) { const { t } = useTranslation("app"); // FNXC:EmbeddedPresentation 2026-06-22-12:00: shared hook supplies isEmbedded (DOM branching) plus the modal-only gates. // Note: the Escape handler intentionally does NOT gate on embedded here — embedded planning preserves its historical @@ -1900,7 +1901,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat startedPlan, projectId, modelOverride, - { clarificationEnabled: true, ...(workflowId ? { workflowId } : {}) }, + { clarificationEnabled: true, ...(workflowId ? { workflowId } : {}), ...(sourceIssue ? { sourceIssue } : {}) }, draftSessionId, ); draftSessionIdRef.current = null; diff --git a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx index b610480a2b..04f28145de 100644 --- a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx @@ -427,9 +427,43 @@ describe("GitHubImportModal", () => { expect(sequence).toEqual(["board", "planning"]); expect(destination).toBe("planning"); expect(onPlanningMode).toHaveBeenCalledTimes(1); + expect(onPlanningMode).toHaveBeenCalledWith( + buildIssuePlanningSeed(issue), + undefined, + { + provider: "github", + repository: "dustinbyrne/kb", + issueNumber: issue.number, + url: issue.html_url, + title: issue.title, + }, + ); expect(apiImportGitHubIssue).not.toHaveBeenCalled(); }); + it("passes structured GitHub source context from the modal Plan action", async () => { + const issue = { number: 45, title: "Modal plan", body: "Keep this issue context.", html_url: "https://github.com/dustinbyrne/kb/issues/45", labels: [], state: "open" }; + const onPlanningMode = vi.fn(); + vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote); + vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([issue]); + + render(); + fireEvent.click(await screen.findByRole("button", { name: /Select issue #45/i })); + fireEvent.click(screen.getByTestId("github-import-action-plan")); + + expect(onPlanningMode).toHaveBeenCalledWith( + buildIssuePlanningSeed(issue), + undefined, + { + provider: "github", + repository: "dustinbyrne/kb", + issueNumber: issue.number, + url: issue.html_url, + title: issue.title, + }, + ); + }); + 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); diff --git a/packages/dashboard/app/components/dashboard/PlanningKeepAlive.tsx b/packages/dashboard/app/components/dashboard/PlanningKeepAlive.tsx index cdff8cee1d..a59858face 100644 --- a/packages/dashboard/app/components/dashboard/PlanningKeepAlive.tsx +++ b/packages/dashboard/app/components/dashboard/PlanningKeepAlive.tsx @@ -72,6 +72,7 @@ export function PlanningKeepAlive({ tasks={tasks} initialSessions={bgPlanningSessions} initialPlan={modalManager.planningInitialPlan ?? undefined} + sourceIssue={modalManager.planningSourceIssue} onInitialPlanConsumed={modalManager.clearPlanningInitialPlan} projectId={projectId} workflowId={modalManager.planningWorkflowId ?? planningHeaderWorkflowId} diff --git a/packages/dashboard/app/components/dashboard/types.ts b/packages/dashboard/app/components/dashboard/types.ts index bc1fd3049f..910bab58ec 100644 --- a/packages/dashboard/app/components/dashboard/types.ts +++ b/packages/dashboard/app/components/dashboard/types.ts @@ -167,7 +167,7 @@ export interface MainContentProps { memoryEnabled: boolean; goalsEnabled: boolean; handleOpenMission: (missionId: string) => void; - openPlanningWithInitialPlanWithNav: (initialPlan: string, workflowId?: string | null) => void; + openPlanningWithInitialPlanWithNav: (initialPlan: string, workflowId?: string | null, sourceIssue?: { provider: "github"; repository: string; issueNumber: number; url: string; title?: string }) => void; ingestCreatedTasks: (tasks: Task[]) => void; nodesEnabled: boolean; openWorkflowEditorWithNav: (workflowId?: string) => void; diff --git a/packages/dashboard/app/hooks/__tests__/useModalManager.test.ts b/packages/dashboard/app/hooks/__tests__/useModalManager.test.ts index 346a31e453..6aa85fe56d 100644 --- a/packages/dashboard/app/hooks/__tests__/useModalManager.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useModalManager.test.ts @@ -165,12 +165,27 @@ describe("useModalManager", () => { useModalManager({ projectId, planningSessions: [{ id: "plan-1" }] }), ); + const sourceIssue = { + provider: "github" as const, + repository: "owner/repo", + issueNumber: 42, + url: "https://github.com/owner/repo/issues/42", + title: "Seeded issue", + }; act(() => { - result.current.openPlanningWithInitialPlan("Build dashboard"); + result.current.openPlanningWithInitialPlan("Build dashboard", undefined, sourceIssue); }); expect(result.current.isPlanningOpen).toBe(true); expect(result.current.planningInitialPlan).toBe("Build dashboard"); + expect(result.current.planningSourceIssue).toEqual(sourceIssue); + + act(() => { + result.current.clearPlanningInitialPlan(); + }); + + expect(result.current.planningInitialPlan).toBeNull(); + expect(result.current.planningSourceIssue).toBeUndefined(); act(() => { result.current.closePlanning(); @@ -179,6 +194,7 @@ describe("useModalManager", () => { expect(result.current.isPlanningOpen).toBe(false); expect(result.current.planningInitialPlan).toBeNull(); expect(result.current.planningResumeSessionId).toBeUndefined(); + expect(result.current.planningSourceIssue).toBeUndefined(); expect(localStorage.getItem(quickEntryKey)).toBe("quick draft"); expect(localStorage.getItem(inlineCreateKey)).toBe("inline draft"); @@ -188,6 +204,14 @@ describe("useModalManager", () => { expect(result.current.isPlanningOpen).toBe(true); expect(result.current.planningResumeSessionId).toBe("plan-1"); + expect(result.current.planningSourceIssue).toBeUndefined(); + + act(() => { + result.current.openPlanningWithInitialPlan("Seed another issue", undefined, sourceIssue); + result.current.openPlanning(); + }); + + expect(result.current.planningSourceIssue).toBeUndefined(); }); it("clears scoped quick-add drafts after single-task planning completion", () => { diff --git a/packages/dashboard/app/hooks/useModalManager.ts b/packages/dashboard/app/hooks/useModalManager.ts index 2fbdcf39dd..abd416fadb 100644 --- a/packages/dashboard/app/hooks/useModalManager.ts +++ b/packages/dashboard/app/hooks/useModalManager.ts @@ -62,6 +62,7 @@ export interface ModalManager { newTaskInitialWorkflowId: string | null | undefined; isPlanningOpen: boolean; planningInitialPlan: string | null; + planningSourceIssue: { provider: "github"; repository: string; issueNumber: number; url: string; title?: string } | undefined; planningResumeSessionId: string | undefined; planningWorkflowId: string | null | undefined; /* @@ -112,7 +113,7 @@ export interface ModalManager { closeNewTask: () => void; openPlanning: () => void; - openPlanningWithInitialPlan: (initialPlan: string, workflowId?: string | null) => void; + openPlanningWithInitialPlan: (initialPlan: string, workflowId?: string | null, sourceIssue?: { provider: "github"; repository: string; issueNumber: number; url: string; title?: string }) => void; resumePlanning: () => void; openPlanningWithSession: (sessionId: string) => void; /** @@ -219,6 +220,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager { const [planningInitialPlan, setPlanningInitialPlan] = useState(null); const [planningResumeSessionId, setPlanningResumeSessionId] = useState(undefined); const [planningWorkflowId, setPlanningWorkflowId] = useState(undefined); + const [planningSourceIssue, setPlanningSourceIssue] = useState<{ provider: "github"; repository: string; issueNumber: number; url: string; title?: string } | undefined>(undefined); // FNXC:PlanningKeepAlive 2026-07-22-12:20: see ModalManager.planningEntryGeneration. const [planningEntryGeneration, setPlanningEntryGeneration] = useState(0); const [isSubtaskOpen, setIsSubtaskOpen] = useState(false); @@ -314,14 +316,16 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager { setPlanningResumeSessionId(undefined); setPlanningInitialPlan(null); setPlanningWorkflowId(undefined); + setPlanningSourceIssue(undefined); setIsPlanningOpen(true); }, []); - const openPlanningWithInitialPlan = useCallback((initialPlan: string, workflowId?: string | null) => { + const openPlanningWithInitialPlan = useCallback((initialPlan: string, workflowId?: string | null, sourceIssue?: { provider: "github"; repository: string; issueNumber: number; url: string; title?: string }) => { // FNXC:PlanningModals 2026-06-20-20:10: clear a stale resume-session id so the // supplied initial plan is honored rather than being overridden by an old session. setPlanningResumeSessionId(undefined); setPlanningInitialPlan(initialPlan); setPlanningWorkflowId(workflowId); + setPlanningSourceIssue(sourceIssue); // FNXC:PlanningKeepAlive 2026-07-22-12:20: payload-carrying entries bump the generation so the kept-alive instance remounts with fresh-open semantics. setPlanningEntryGeneration((generation) => generation + 1); setIsPlanningOpen(true); @@ -330,24 +334,31 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager { const session = planningSessions[0]; if (!session) return; setPlanningWorkflowId(undefined); + setPlanningSourceIssue(undefined); setPlanningResumeSessionId(session.id); setPlanningEntryGeneration((generation) => generation + 1); setIsPlanningOpen(true); }, [planningSessions]); const openPlanningWithSession = useCallback((sessionId: string) => { setPlanningWorkflowId(undefined); + setPlanningSourceIssue(undefined); setPlanningResumeSessionId(sessionId); setPlanningEntryGeneration((generation) => generation + 1); setIsPlanningOpen(true); }, []); const clearPlanningInitialPlan = useCallback(() => { + // FNXC:GitHubPlanningSourceIssue 2026-08-09-08:09: The seed and its GitHub + // provenance are one atomic handoff. After auto-start consumes the seed, retaining + // sourceIssue would incorrectly attach it to a later plan started in the same view. setPlanningInitialPlan(null); + setPlanningSourceIssue(undefined); }, []); const closePlanning = useCallback(() => { setIsPlanningOpen(false); setPlanningInitialPlan(null); setPlanningResumeSessionId(undefined); setPlanningWorkflowId(undefined); + setPlanningSourceIssue(undefined); }, []); const openSubtaskBreakdown = useCallback((description: string, workflowId?: string | null) => { @@ -534,6 +545,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager { setSubtaskWorkflowId(undefined); setIsPlanningOpen(false); setPlanningInitialPlan(null); + setPlanningSourceIssue(undefined); setPlanningResumeSessionId(undefined); setPlanningWorkflowId(undefined); setGitHubImportOpen(false); @@ -587,6 +599,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager { newTaskInitialWorkflowId, isPlanningOpen, planningInitialPlan, + planningSourceIssue, planningResumeSessionId, planningWorkflowId, planningEntryGeneration, diff --git a/packages/dashboard/src/__tests__/github-tracking.test.ts b/packages/dashboard/src/__tests__/github-tracking.test.ts index 140e2095d8..30cbad6bb0 100644 --- a/packages/dashboard/src/__tests__/github-tracking.test.ts +++ b/packages/dashboard/src/__tests__/github-tracking.test.ts @@ -14,7 +14,8 @@ vi.mock("@fusion/core", async () => { }; }); -vi.mock("../github.js", () => ({ +vi.mock("../github.js", async (importOriginal) => ({ + ...(await importOriginal()), GitHubClient: vi.fn().mockImplementation(function () { return { createIssue: createIssueMock, searchIssues: searchIssuesMock, @@ -30,7 +31,9 @@ import { deriveTitleFromDescription, formatTrackingIssueBody, formatTrackingIssueTitle, + adoptGithubSourceIssueExclusively, maybeCreateTrackingIssue, + resolvePlanningGithubTrackingDecision, } from "../github-tracking.js"; function buildTask(overrides: Partial = {}): Task { @@ -444,6 +447,126 @@ describe("maybeCreateTrackingIssue", () => { expect(createIssueMock).toHaveBeenCalledTimes(1); }); + it("suppresses planning tracking when a live task already owns the source issue", async () => { + const decision = await resolvePlanningGithubTrackingDecision({ + projectId: "project-1", + listTasks: vi.fn().mockResolvedValue([buildTask({ + id: "FN-existing", + sourceIssue: { + provider: "github", + repository: "owner/repo", + externalIssueId: "42", + issueNumber: 42, + url: "https://github.com/owner/repo/issues/42", + }, + })]), + } as any, { githubLinkImportedIssuesToTracking: true } as any, { + owner: "owner", repo: "repo", issueNumber: 42, url: "https://github.com/owner/repo/issues/42", + }); + + expect(decision).toEqual({ suppressedByTaskId: "FN-existing" }); + }); + + it("uses imported-tracking settings only when no live source issue holder exists", async () => { + const store = { + projectId: "project-1", + listTasks: vi.fn().mockResolvedValue([]), + getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) })), + }; + const issue = { owner: "owner", repo: "repo", issueNumber: 42, url: "https://github.com/owner/repo/issues/42" }; + + await expect(resolvePlanningGithubTrackingDecision(store as any, { + githubLinkImportedIssuesToTracking: true, + } as any, issue)).resolves.toEqual({ githubTracking: { enabled: true } }); + await expect(resolvePlanningGithubTrackingDecision(store as any, {} as any, issue)).resolves.toEqual({}); + }); + + it("concurrently adopts a source issue for one deterministic tracking owner", async () => { + const issue = { owner: "owner", repo: "repo", number: 42, url: "https://github.com/owner/repo/issues/42" }; + const tasks = [ + buildTask({ + id: "FN-earlier", + createdAt: "2026-01-01T00:00:00.000Z", + sourceIssue: { provider: "github", repository: "owner/repo", externalIssueId: "42", issueNumber: 42, url: issue.url }, + // A previous import can hold truthful provenance while tracking was disabled. + // A later plan must not let project defaults start a second issue stream. + githubTracking: { enabled: false }, + }), + buildTask({ + id: "FN-later", + createdAt: "2026-01-02T00:00:00.000Z", + sourceIssue: { provider: "github", repository: "owner/repo", externalIssueId: "42", issueNumber: 42, url: issue.url }, + githubTracking: { enabled: true }, + }), + ]; + const store = { + projectId: "project-1", + listTasks: vi.fn(async () => tasks), + linkGithubIssue: vi.fn(async (taskId: string, linkedIssue: NonNullable["issue"]) => { + const task = tasks.find((candidate) => candidate.id === taskId)!; + task.githubTracking = { ...task.githubTracking, enabled: true, issue: linkedIssue }; + }), + unlinkGithubIssue: vi.fn(async (taskId: string) => { + const task = tasks.find((candidate) => candidate.id === taskId)!; + task.githubTracking = { ...task.githubTracking, issue: undefined }; + }), + updateGithubTracking: vi.fn(async (taskId: string, tracking: Partial>) => { + const task = tasks.find((candidate) => candidate.id === taskId)!; + task.githubTracking = { ...task.githubTracking, ...tracking }; + return task; + }), + }; + + const [, later] = await Promise.all([ + adoptGithubSourceIssueExclusively(store as any, "FN-earlier", issue), + adoptGithubSourceIssueExclusively(store as any, "FN-later", issue), + ]); + + expect(tasks.filter((task) => task.githubTracking?.issue).map((task) => task.id)).toEqual(["FN-earlier"]); + expect(later).toEqual({ adopted: false, holderTaskId: "FN-earlier" }); + expect(tasks.find((task) => task.id === "FN-later")?.githubTracking?.enabled).toBe(false); + }); + + it("repairs a cross-process source-link race after post-link verification", async () => { + const issue = { owner: "owner", repo: "repo", number: 42, url: "https://github.com/owner/repo/issues/42" }; + const self = buildTask({ + id: "FN-later", + createdAt: "2026-01-02T00:00:00.000Z", + sourceIssue: { provider: "github", repository: "owner/repo", externalIssueId: "42", issueNumber: 42, url: issue.url }, + githubTracking: { enabled: true }, + }); + const peer = buildTask({ + id: "FN-earlier", + createdAt: "2026-01-01T00:00:00.000Z", + sourceIssue: { provider: "github", repository: "owner/repo", externalIssueId: "42", issueNumber: 42, url: issue.url }, + githubTracking: { enabled: true }, + }); + const tasks = [self, peer]; + let reads = 0; + const store = { + projectId: "project-1", + // The peer is not visible until another node links it between this node's writes. + listTasks: vi.fn(async () => ++reads === 1 ? [self] : tasks), + linkGithubIssue: vi.fn(async (taskId: string, linkedIssue: NonNullable["issue"]) => { + self.githubTracking = { enabled: true, issue: linkedIssue }; + peer.githubTracking = { enabled: true, issue: { ...linkedIssue!, createdAt: "2026-01-01T00:00:00.000Z" } }; + expect(taskId).toBe(self.id); + }), + unlinkGithubIssue: vi.fn(async () => { self.githubTracking = { enabled: true }; }), + updateGithubTracking: vi.fn(async (_taskId: string, tracking: Partial>) => { + self.githubTracking = { ...self.githubTracking, ...tracking }; + return self; + }), + }; + + await expect(adoptGithubSourceIssueExclusively(store as any, self.id, issue)).resolves.toEqual({ + adopted: false, + holderTaskId: peer.id, + }); + expect(store.unlinkGithubIssue).toHaveBeenCalledWith(self.id); + expect(self.githubTracking).toEqual({ enabled: false }); + }); + it("links GitHub sourceIssue instead of creating a duplicate", async () => { const linkGithubIssue = vi.fn(); const recordActivity = vi.fn(); diff --git a/packages/dashboard/src/__tests__/github.test.ts b/packages/dashboard/src/__tests__/github.test.ts index a68ba3fdd5..9b162873a5 100644 --- a/packages/dashboard/src/__tests__/github.test.ts +++ b/packages/dashboard/src/__tests__/github.test.ts @@ -3,7 +3,18 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { execSync } from "node:child_process"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { GitHubClient, CreatePrParams, PrComment, isGitHubIssueAlreadyImported, isPrMergeReady } from "../github.js"; +import { + appendSourceIssueBlock, + buildGitHubIssueSource, + buildPlanningSourceIssueContext, + extractSeedIssueContext, + GitHubClient, + CreatePrParams, + parseGitHubIssueSeedSource, + PrComment, + isGitHubIssueAlreadyImported, + isPrMergeReady, +} from "../github.js"; // Mock the gh-cli module from @fusion/core vi.mock("@fusion/core", async () => { @@ -43,6 +54,71 @@ function createGraphQlBatchPayload(repository: Record) { return JSON.stringify({ data: { repository } }); } +describe("GitHub planning source issue helpers", () => { + const canonicalSeed = [ + "Plan work for GitHub issue: Preserve original context", + "", + "Issue description:", + "Keep this body verbatim.", + "", + "Source: https://github.com/Owner/Repo/issues/42", + ].join("\n"); + + it("parses only the canonical issue-planning seed shape", () => { + expect(extractSeedIssueContext(canonicalSeed)).toEqual({ + title: "Preserve original context", + body: "Keep this body verbatim.", + owner: "Owner", + repo: "Repo", + issueNumber: 42, + url: "https://github.com/Owner/Repo/issues/42", + }); + expect(parseGitHubIssueSeedSource(`${canonicalSeed}\nExtra prose`)).toBeNull(); + expect(parseGitHubIssueSeedSource("A prose link https://github.com/owner/repo/issues/42")).toBeNull(); + expect(parseGitHubIssueSeedSource(canonicalSeed.replace("/issues/42", "/pull/42"))).toBeNull(); + expect(parseGitHubIssueSeedSource(canonicalSeed.replace("github.com", "example.com"))).toBeNull(); + expect(parseGitHubIssueSeedSource(canonicalSeed.replace("Issue description:\n", ""))).toBeNull(); + // The fallback is intentionally an exact seed shape, not a loose prose parser. + expect(parseGitHubIssueSeedSource(` ${canonicalSeed}`)).toBeNull(); + expect(parseGitHubIssueSeedSource(canonicalSeed.replace("Issue description:", " Issue description:"))).toBeNull(); + expect(parseGitHubIssueSeedSource(canonicalSeed.replace("\nSource:", "\n Source:"))).toBeNull(); + }); + + it("preserves source context once and omits the empty issue body", () => { + const empty = extractSeedIssueContext(canonicalSeed.replace("Keep this body verbatim.", "(no description)")); + expect(empty?.body).toBeUndefined(); + const context = buildPlanningSourceIssueContext({ ...empty!, title: "Preserve original context" }); + expect(context.sourceIssue).toEqual(buildGitHubIssueSource("Owner", "Repo", { + number: 42, + html_url: "https://github.com/Owner/Repo/issues/42", + }).sourceIssue); + expect(context.sourceMetadata).toEqual(buildGitHubIssueSource("Owner", "Repo", { + number: 42, + html_url: "https://github.com/Owner/Repo/issues/42", + }).sourceMetadata); + expect(context.markdown).not.toContain("### Original issue description"); + + const rich = buildPlanningSourceIssueContext(extractSeedIssueContext(canonicalSeed)!); + const description = appendSourceIssueBlock("Generated plan", rich.markdown, rich.sourceIssue.url!); + expect(description).toContain("## Source Issue"); + expect(description).toContain("Keep this body verbatim."); + expect(appendSourceIssueBlock(description, rich.markdown, rich.sourceIssue.url!.toUpperCase())).toBe(description); + + const unrelatedUrlAfterAnotherSourceBlock = [ + "Generated plan", + "", + "## Source Issue", + "", + "- **URL:** https://github.com/other/repo/issues/7", + "", + "## References", + "", + `- **URL:** ${rich.sourceIssue.url}`, + ].join("\n"); + expect(appendSourceIssueBlock(unrelatedUrlAfterAnotherSourceBlock, rich.markdown, rich.sourceIssue.url!)).toContain("- **Repository:** Owner/Repo"); + }); +}); + describe("GitHubClient", () => { let client: GitHubClient; diff --git a/packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts b/packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts index b2c20e827c..85a6ea5bd5 100644 --- a/packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts +++ b/packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts @@ -23,6 +23,7 @@ import { request as performRequest } from "../test-request.js"; import { __resetPlanningState, __setCreateFnAgent, + createTaskFromPlanSession, getSession, } from "../planning.js"; import { registerPlanningSubtaskRoutes } from "../routes/register-planning-subtask-routes.js"; @@ -122,13 +123,11 @@ function createStore() { covered). `tasks` is exposed so tests can inject an orphaned row simulating a crash between task insert and session finalize. */ - const tasks: Array<{ id: string; title: string; description: string; proposalClaimId?: string }> = []; - const createTask = vi.fn(async (input: { title: string; description: string; proposalClaimId?: string }) => { + const tasks: Array & { id: string; title: string; description: string; proposalClaimId?: string }> = []; + const createTask = vi.fn(async (input: Record & { title: string; description: string; proposalClaimId?: string }) => { const created = { + ...input, id: `FN-E2E-00${tasks.length + 1}`, - title: input.title, - description: input.description, - proposalClaimId: input.proposalClaimId, }; tasks.push(created); return created; @@ -138,6 +137,7 @@ function createStore() { autoMerge: false, agentClarificationEnabled: false, ntfyEnabled: false, + githubLinkImportedIssuesToTracking: true, }), getRootDir: vi.fn().mockReturnValue("/tmp/planning-e2e"), listTasks: vi.fn(async () => [...tasks]), @@ -149,6 +149,8 @@ function createStore() { createTask, updateTask: vi.fn().mockResolvedValue(undefined), logEntry: vi.fn().mockResolvedValue(undefined), + upsertTaskDocument: vi.fn().mockResolvedValue(undefined), + getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) })), tasks, } as unknown as TaskStore & { createTask: typeof createTask; tasks: typeof tasks }; } @@ -228,6 +230,43 @@ describe("Planning Mode plan creation E2E", () => { __setCreateFnAgent(undefined as never); }); + /* + FNXC:GitHubPlanningSourceIssue 2026-08-09-09:45: + The agent/CLI task-creation twin must consume the same persisted canonical seed as the + dashboard route. This regression keeps its source provenance and source block intact + without relying on the HTTP registrar's mocked planning adapter. + */ + it("creates GitHub provenance through the production agent planning twin", async () => { + const initialPlan = [ + "Plan work for GitHub issue: Preserve imported context", + "", + "Issue description:", + "Verbatim imported body.", + "", + "Source: https://github.com/owner/repo/issues/42", + ].join("\n"); + const started = await post(app, "/api/planning/start", { initialPlan }); + expect(started.status).toBe(201); + + const created = await createTaskFromPlanSession(started.body.sessionId, store); + expect(created.alreadyCreated).toBe(false); + expect(created.task).toMatchObject({ + sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 42 }, + source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/42", issueNumber: 42 } }, + githubTracking: { enabled: true }, + }); + expect(created.task.description).toContain("## Source Issue"); + expect(created.task.description).toContain("Verbatim imported body."); + expect(store.upsertTaskDocument).toHaveBeenCalledWith(created.task.id, expect.objectContaining({ + key: "github-issue", + content: expect.stringContaining("Verbatim imported body."), + })); + + const replay = await createTaskFromPlanSession(started.body.sessionId, store); + expect(replay).toMatchObject({ alreadyCreated: true, task: { id: created.task.id } }); + expect(store.createTask).toHaveBeenCalledTimes(1); + }); + it("converts the lean running plan into a task without a separate validation step", async () => { const start = await post(app, "/api/planning/start", { initialPlan: "Build secure account recovery" }); expect(start.status).toBe(201); diff --git a/packages/dashboard/src/__tests__/planning-source-issue.test.ts b/packages/dashboard/src/__tests__/planning-source-issue.test.ts new file mode 100644 index 0000000000..b5ba640260 --- /dev/null +++ b/packages/dashboard/src/__tests__/planning-source-issue.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { resolvePlanningSourceIssue } from "../planning.js"; + +const seed = [ + "Plan work for GitHub issue: Preserve source context", + "", + "Issue description:", + "Verbatim issue body.", + "", + "Source: https://github.com/owner/repo/issues/42", +].join("\n"); + +describe("resolvePlanningSourceIssue", () => { + it("falls back only to a canonical GitHub issue seed", () => { + const resolved = resolvePlanningSourceIssue({ initialPlan: seed } as any); + expect(resolved?.sourceIssue).toMatchObject({ + provider: "github", + repository: "owner/repo", + issueNumber: 42, + url: "https://github.com/owner/repo/issues/42", + }); + expect(resolved?.markdown).toContain("Verbatim issue body."); + expect(resolvePlanningSourceIssue({ initialPlan: "Mention https://github.com/owner/repo/issues/42" } as any)).toBeUndefined(); + }); + + it("uses persisted GitHub provenance over a conflicting seed", () => { + const resolved = resolvePlanningSourceIssue({ + initialPlan: seed.replace("owner/repo/issues/42", "other/project/issues/7"), + sourceIssue: { + provider: "github", + repository: "owner/repo", + externalIssueId: "42", + issueNumber: 42, + url: "https://github.com/owner/repo/issues/42", + title: "Persisted title", + }, + } as any); + + expect(resolved?.sourceIssue.repository).toBe("owner/repo"); + expect(resolved?.markdown).toContain("Persisted title"); + expect(resolved?.markdown).not.toContain("Verbatim issue body."); + }); + + it("enriches matching persisted GitHub provenance without accepting another provider", () => { + const resolved = resolvePlanningSourceIssue({ + initialPlan: seed, + sourceIssue: { + provider: "github", + repository: "owner/repo", + externalIssueId: "42", + issueNumber: 42, + url: "https://github.com/owner/repo/issues/42", + }, + } as any); + expect(resolved?.markdown).toContain("Verbatim issue body."); + expect(resolvePlanningSourceIssue({ + initialPlan: seed, + sourceIssue: { provider: "gitlab", repository: "group/project", issueNumber: 42, url: "https://gitlab.example.com/42" }, + } as any)).toBeUndefined(); + }); +}); diff --git a/packages/dashboard/src/__tests__/routes-planning-tracking.test.ts b/packages/dashboard/src/__tests__/routes-planning-tracking.test.ts index 319b00843c..47cbd5d102 100644 --- a/packages/dashboard/src/__tests__/routes-planning-tracking.test.ts +++ b/packages/dashboard/src/__tests__/routes-planning-tracking.test.ts @@ -19,6 +19,14 @@ type PlanningSession = { }; initialPlan: string; history: Array<{ role: string; content: string }>; + sourceIssue?: { + provider: "github"; + repository: string; + externalIssueId: string; + issueNumber: number; + url: string; + title?: string; + }; }; const sessions = new Map(); @@ -50,6 +58,30 @@ vi.mock("../planning.js", () => ({ // FNXC:PlanningMultiTask 2026-07-24-00:20: create-task derives an epoch-scoped proposalClaimId. planningProposalClaimId: (sessionId: string, epoch?: number) => epoch && epoch > 0 ? `planning-session:${sessionId}#${epoch}` : `planning-session:${sessionId}`, + createSessionWithAgent: vi.fn(), + startExistingSession: vi.fn(), + RateLimitError: class RateLimitError extends Error {}, + rateLimit: vi.fn(), + resolvePlanningSourceIssue: (session: PlanningSession) => session.sourceIssue + ? { + sourceIssue: session.sourceIssue, + sourceMetadata: { + issueUrl: session.sourceIssue.url, + issueNumber: session.sourceIssue.issueNumber, + }, + markdown: [ + "## Source Issue", + "", + `- **Repository:** ${session.sourceIssue.repository}`, + `- **Issue:** #${session.sourceIssue.issueNumber} — ${session.sourceIssue.title ?? "Issue"}`, + `- **URL:** ${session.sourceIssue.url}`, + "", + "### Original issue description", + "", + "Verbatim issue body.", + ].join("\n"), + } + : undefined, })); function deferred() { @@ -109,6 +141,7 @@ describe("planning routes github tracking background dispatch", () => { let createIssueSpy: MockInstance; let planningWarn: ReturnType; let warnSignal: ReturnType>; + let createTaskMock: ReturnType; beforeEach(() => { sessions.clear(); @@ -119,11 +152,10 @@ describe("planning routes github tracking background dispatch", () => { const createdTasks = new Map>(); let storeRef: TaskStore | undefined; const store = { - createTask: vi.fn(async (input: { title?: string; description: string }) => { + createTask: createTaskMock = vi.fn(async (input: { title?: string; description: string; [key: string]: unknown }) => { const task = { + ...input, id: `FN-${idCounter++}`, - title: input.title, - description: input.description, column: "triage", }; createdTasks.set(task.id, task); @@ -152,6 +184,7 @@ describe("planning routes github tracking background dispatch", () => { getTask: vi.fn(async (id: string) => createdTasks.get(id)), listTasks: vi.fn(async () => [...createdTasks.values()]), getSettings: vi.fn(async () => ({ + githubLinkImportedIssuesToTracking: true, githubTrackingEnabledByDefault: true, githubTrackingDefaultRepo: "o/r", githubAuthMode: "token", @@ -187,7 +220,8 @@ describe("planning routes github tracking background dispatch", () => { ); app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { - res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + const apiError = err as { statusCode?: number }; + res.status(apiError.statusCode ?? 500).json({ error: err instanceof Error ? err.message : String(err) }); }); createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue"); @@ -198,6 +232,41 @@ describe("planning routes github tracking background dispatch", () => { setTaskCreatedHook(undefined); }); + it("validates and forwards structured GitHub source context when planning starts", async () => { + const planning = await import("../planning.js") as { createSessionWithAgent: ReturnType }; + planning.createSessionWithAgent.mockResolvedValue("source-session"); + const sourceIssue = { + provider: "github", + repository: "owner/repo", + issueNumber: 42, + url: "https://github.com/owner/repo/issues/42", + title: "Imported issue", + }; + + const valid = await performRequest(app, "POST", "/planning/start-streaming", JSON.stringify({ + initialPlan: "Plan this imported issue", + sourceIssue, + }), { "content-type": "application/json" }); + expect(valid.status, JSON.stringify(valid)).toBe(201); + expect(planning.createSessionWithAgent).toHaveBeenCalledTimes(1); + expect(planning.createSessionWithAgent.mock.calls[0]?.at(-1)).toEqual(expect.objectContaining({ + sourceIssue: expect.objectContaining({ + provider: "github", + repository: "owner/repo", + externalIssueId: "42", + issueNumber: 42, + url: sourceIssue.url, + }), + })); + + const malformed = await performRequest(app, "POST", "/planning/start-streaming", JSON.stringify({ + initialPlan: "Do not start", + sourceIssue: { ...sourceIssue, url: "https://github.com/owner/repo/pull/42" }, + }), { "content-type": "application/json" }); + expect(malformed.status).toBe(400); + expect(planning.createSessionWithAgent).toHaveBeenCalledTimes(1); + }); + it("POST /planning/create-task returns before createIssue resolves", async () => { const issueDeferred = deferred<{ number: number; htmlUrl: string; createdAt: string }>(); const createIssue = signalOnCall(() => issueDeferred.promise as never); @@ -242,6 +311,47 @@ describe("planning routes github tracking background dispatch", () => { expect(createIssueSpy).toHaveBeenCalledTimes(1); }); + it("preserves canonical GitHub source provenance and issue context on a planned task", async () => { + sessions.set("github-plan", { + validated: true, + summary: { + title: "Planned GitHub task", + description: "Planned task description", + suggestedSize: "M", + priority: "normal", + suggestedDependencies: [], + keyDeliverables: [], + }, + initialPlan: "canonical seed", + history: [], + sourceIssue: { + provider: "github", + repository: "owner/repo", + externalIssueId: "42", + issueNumber: 42, + url: "https://github.com/owner/repo/issues/42", + title: "Original issue", + }, + }); + + const response = await performRequest( + app, + "POST", + "/planning/create-task", + JSON.stringify({ sessionId: "github-plan" }), + { "content-type": "application/json" }, + ); + + expect(response.status).toBe(201); + expect(createTaskMock).toHaveBeenCalledWith(expect.objectContaining({ + sourceIssue: expect.objectContaining({ provider: "github", repository: "owner/repo", issueNumber: 42 }), + source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/42", issueNumber: 42 } }, + githubTracking: { enabled: true }, + description: expect.stringContaining("## Source Issue\n\n- **Repository:** owner/repo"), + })); + expect(createTaskMock.mock.calls[0]?.[0]?.description).toContain("Verbatim issue body."); + }); + it("POST /planning/create-task still returns 201 when createIssue rejects", async () => { createIssueSpy.mockRejectedValue(new Error("github down")); diff --git a/packages/dashboard/src/github-tracking.ts b/packages/dashboard/src/github-tracking.ts index 76ac4a6f83..2774f821df 100644 --- a/packages/dashboard/src/github-tracking.ts +++ b/packages/dashboard/src/github-tracking.ts @@ -10,7 +10,7 @@ import { type TaskStore, } from "@fusion/core"; import type { CreatedIssue } from "./github.js"; -import { GitHubClient } from "./github.js"; +import { GitHubClient, isGitHubIssueAlreadyImported } from "./github.js"; import { resolveGithubTrackingAuth } from "./github-auth.js"; import { buildIssueSearchQueries, @@ -147,6 +147,102 @@ export interface MaybeCreateTrackingIssueDeps { logger?: Pick; } +export async function resolveImportedIssueGithubTracking( + store: TaskStore, + projectSettings: ProjectSettings, +): Promise<{ enabled: true } | undefined> { + if (projectSettings.githubLinkImportedIssuesToTracking === true) return { enabled: true }; + const globalSettings = await store.getGlobalSettingsStore().getSettings(); + return resolveTaskGithubTracking({ githubTracking: undefined }, projectSettings, globalSettings).enabled ? { enabled: true } : undefined; +} + +/* +FNXC:GitHubPlanningSourceIssue 2026-08-09-05:36: +Create-time serialization narrows same-process races, but shared database nodes can still race. +Source adoption rechecks after linking and deterministically suppresses the loser so one issue has one tracker. +*/ +const planningSourceIssueLocks = new Map>(); +function sourceIssueKey(store: TaskStore, issue: { owner: string; repo: string; number: number }): string { + return `github-source-tracking:${(store as unknown as { projectId?: string }).projectId ?? "__legacy_unscoped__"}:${issue.owner.toLowerCase()}/${issue.repo.toLowerCase()}#${issue.number}`; +} +async function withSourceIssueLock(store: TaskStore, issue: { owner: string; repo: string; number: number }, action: () => Promise): Promise { + const key = sourceIssueKey(store, issue); + const previous = planningSourceIssueLocks.get(key) ?? Promise.resolve(); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const queued = previous.then(() => gate); + planningSourceIssueLocks.set(key, queued); + await previous; + try { + return await action(); + } finally { + release(); + if (planningSourceIssueLocks.get(key) === queued) planningSourceIssueLocks.delete(key); + } +} +function liveIssueHolders(tasks: Task[], taskId: string, issue: { owner: string; repo: string; number: number; url: string }): Task[] { + return tasks.filter((candidate) => candidate.id !== taskId && isGitHubIssueAlreadyImported(candidate, { owner: issue.owner, repo: issue.repo, issueNumber: issue.number, sourceUrl: issue.url })); +} +export async function resolvePlanningGithubTrackingDecision(store: TaskStore, projectSettings: ProjectSettings, sourceIssueInput: { owner: string; repo: string; issueNumber: number; url: string }): Promise<{ githubTracking?: { enabled: true }; suppressedByTaskId?: string }> { + const issue = { owner: sourceIssueInput.owner, repo: sourceIssueInput.repo, number: sourceIssueInput.issueNumber }; + return withSourceIssueLock(store, issue, async () => { + const tasks = await store.listTasks({ slim: false, includeArchived: false }); + const holder = liveIssueHolders(tasks, "", { ...issue, url: sourceIssueInput.url })[0]; + if (holder) return { suppressedByTaskId: holder.id }; + return (await resolveImportedIssueGithubTracking(store, projectSettings)) ? { githubTracking: { enabled: true } } : {}; + }); +} + +export async function adoptGithubSourceIssueExclusively(store: TaskStore, taskId: string, issue: { owner: string; repo: string; number: number; url: string }): Promise<{ adopted: boolean; holderTaskId?: string }> { + // Legacy unit adapters predate listTasks; retain their established single-task adoption behavior. + if (typeof store.listTasks !== "function") { + await store.linkGithubIssue(taskId, { owner: issue.owner, repo: issue.repo, number: issue.number, url: issue.url, createdAt: new Date().toISOString() }); + return { adopted: true }; + } + return withSourceIssueLock(store, issue, async () => { + const all = await store.listTasks({ slim: false, includeArchived: false }); + const linked = all.filter((task) => task.id !== taskId && task.githubTracking?.issue + && task.githubTracking.issue.owner.toLowerCase() === issue.owner.toLowerCase() + && task.githubTracking.issue.repo.toLowerCase() === issue.repo.toLowerCase() + && task.githubTracking.issue.number === issue.number); + // A pre-existing link is authoritative even when this task is older: never steal a live stream. + if (linked.length > 0) { + const holder = linked.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))[0]!; + if (all.find((task) => task.id === taskId)?.githubTracking?.issue) { + await store.unlinkGithubIssue(taskId); + } + await store.updateGithubTracking(taskId, { enabled: false }); + return { adopted: false, holderTaskId: holder.id }; + } + /* + FNXC:GitHubPlanningSourceIssue 2026-08-09-08:09: + Layer 1 suppresses any existing provenance holder, not only an already-enabled tracker. + Keep that rule in Layer 2 too: the post-create hook can otherwise re-enable a + Layer-1-suppressed task through project defaults before exclusive adoption runs. + */ + const candidates = liveIssueHolders(all, taskId, issue); + const winner = [all.find((task) => task.id === taskId), ...candidates] + .filter((task): task is Task => Boolean(task)) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))[0]; + if (winner?.id !== taskId) { + if (all.find((task) => task.id === taskId)?.githubTracking?.issue) { + await store.unlinkGithubIssue(taskId); + } + await store.updateGithubTracking(taskId, { enabled: false }); + return { adopted: false, holderTaskId: winner.id }; + } + await store.linkGithubIssue(taskId, { owner: issue.owner, repo: issue.repo, number: issue.number, url: issue.url, createdAt: new Date().toISOString() }); + const after = await store.listTasks({ slim: false, includeArchived: false }); + const linkedAfter = after.filter((task) => task.githubTracking?.issue && task.githubTracking.issue.owner.toLowerCase() === issue.owner.toLowerCase() && task.githubTracking.issue.repo.toLowerCase() === issue.repo.toLowerCase() && task.githubTracking.issue.number === issue.number).sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)); + if (linkedAfter[0]?.id !== taskId) { + await store.unlinkGithubIssue(taskId); + await store.updateGithubTracking(taskId, { enabled: false }); + return { adopted: false, holderTaskId: linkedAfter[0]?.id }; + } + return { adopted: true }; + }); +} + export type MaybeCreateTrackingIssueReason = | "tracking_disabled" | "issue_already_linked" @@ -154,6 +250,7 @@ export type MaybeCreateTrackingIssueReason = | "no_title_available" | "existing_issue_found" | "source_issue_linked" + | "source_issue_already_tracked_elsewhere" | "github_error" | "auth_token_missing" | "auth_gh_not_installed" @@ -235,14 +332,13 @@ export async function maybeCreateTrackingIssue( if (sourceRepo && Number.isFinite(sourceIssue.issueNumber)) { const url = sourceIssue.url ?? `https://github.com/${sourceRepo.owner}/${sourceRepo.repo}/issues/${sourceIssue.issueNumber}`; - const createdAt = new Date().toISOString(); - await deps.taskStore.linkGithubIssue(task.id, { - owner: sourceRepo.owner, - repo: sourceRepo.repo, - number: sourceIssue.issueNumber, - url, - createdAt, + const adoption = await adoptGithubSourceIssueExclusively(deps.taskStore, task.id, { + owner: sourceRepo.owner, repo: sourceRepo.repo, number: sourceIssue.issueNumber, url, }); + if (!adoption.adopted) { + await deps.taskStore.logEntry(task.id, `Source issue already tracked by ${adoption.holderTaskId ?? "another task"}`); + return { created: false, reason: "source_issue_already_tracked_elsewhere" }; + } await deps.taskStore.recordActivity({ type: "task:updated", taskId: task.id, diff --git a/packages/dashboard/src/github.ts b/packages/dashboard/src/github.ts index abd421ff6f..6c5bbc5adb 100644 --- a/packages/dashboard/src/github.ts +++ b/packages/dashboard/src/github.ts @@ -137,6 +137,56 @@ export function buildGitHubIssueSource(owner: string, repo: string, issue: { num }; } +/* +FNXC:GitHubPlanningSourceIssue 2026-08-09-05:36: +Source adoption accepts only the complete seed shape emitted by buildIssuePlanningSeed. A prose URL is +not provenance: a false link can create GitHub side effects, while a missed link is safely recoverable. +The planning create path recovers title/body from this persisted seed and never re-fetches GitHub. +*/ +export function extractSeedIssueContext(initialPlan: string): { title?: string; body?: string; owner: string; repo: string; issueNumber: number; url: string } | null { + const lines = initialPlan.split(/\r?\n/); + const firstIndex = lines.findIndex((line) => line.trim().length > 0); + const lastIndex = lines.findLastIndex((line) => line.trim().length > 0); + if (firstIndex < 0 || lastIndex < 0) return null; + const titleMatch = lines[firstIndex].match(/^Plan work for GitHub issue:\s*(.*)$/); + if (!titleMatch) return null; + const descriptionIndex = lines.findIndex((line, index) => index > firstIndex && line === "Issue description:"); + if (descriptionIndex < 0 || descriptionIndex >= lastIndex) return null; + const sourceMatch = lines[lastIndex].match(/^Source:\s*(https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)\/?)(?:\s*)$/i); + if (!sourceMatch || Number.parseInt(sourceMatch[4], 10) <= 0) return null; + const body = lines.slice(descriptionIndex + 1, lastIndex).join("\n").trim(); + return { + ...(titleMatch[1].trim() ? { title: titleMatch[1].trim() } : {}), + ...(body && body !== "(no description)" ? { body } : {}), + owner: sourceMatch[2], repo: sourceMatch[3], issueNumber: Number.parseInt(sourceMatch[4], 10), url: sourceMatch[1], + }; +} + +export function parseGitHubIssueSeedSource(initialPlan: string): { owner: string; repo: string; issueNumber: number; url: string } | null { + const context = extractSeedIssueContext(initialPlan); + return context ? { owner: context.owner, repo: context.repo, issueNumber: context.issueNumber, url: context.url } : null; +} + +export function buildPlanningSourceIssueContext(input: { owner: string; repo: string; issueNumber: number; url: string; title?: string; body?: string }): { sourceIssue: TaskSourceIssue; sourceMetadata: Record; markdown: string } { + const { sourceIssue, sourceMetadata } = buildGitHubIssueSource(input.owner, input.repo, { number: input.issueNumber, html_url: input.url }); + const issueLine = input.title ? `- **Issue:** #${input.issueNumber} — ${input.title}` : `- **Issue:** #${input.issueNumber}`; + const markdown = ["## Source Issue", "", `- **Repository:** ${input.owner}/${input.repo}`, issueLine, `- **URL:** ${input.url}`, ...(input.body ? ["", "### Original issue description", "", input.body] : [])].join("\n"); + return { sourceIssue, sourceMetadata, markdown }; +} + +export function appendSourceIssueBlock(description: string, markdown: string, issueUrl: string): string { + const headings = [...description.matchAll(/^## (?!#).*$/gm)]; + const urlLine = new RegExp(`^- \\*\\*URL:\\*\\*\\s*${issueUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "im"); + const hasMatchingBlock = headings.some((heading, index) => { + if (!/^## Source Issue\s*$/i.test(heading[0])) return false; + const start = (heading.index ?? 0) + heading[0].length; + const end = headings[index + 1]?.index ?? description.length; + return urlLine.test(description.slice(start, end)); + }); + if (hasMatchingBlock) return description; + return `${description.trimEnd()}\n\n${markdown}`; +} + function equalsIgnoreCase(left: string | undefined, right: string | undefined): boolean { return Boolean(left && right && left.toLocaleLowerCase() === right.toLocaleLowerCase()); } diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index 55ae9c7d0a..4462eaf556 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -17,6 +17,7 @@ import type { PlanningSummary, PlanningResponse, TaskPriority, + TaskSourceIssue, TaskStore, Settings, NtfyNotificationEvent, @@ -54,6 +55,7 @@ import { } from "@fusion/engine"; import * as engineModule from "@fusion/engine"; import { createPlanningBoardTools } from "./planning-board-tools.js"; +import { buildPlanningSourceIssueContext, extractSeedIssueContext } from "./github.js"; // The planning lane has no ambient task; fn_workflow_select therefore has no // default target and an agent must pass an explicit task_id. @@ -75,6 +77,8 @@ Planning questions must never create dashboard Mailbox messages. Retain the opti */ type PlanningSessionOptions = { projectId?: string; + /** Structured import provenance; only GitHub is accepted by the route boundary. */ + sourceIssue?: TaskSourceIssue & { title?: string }; ntfyConfig?: PlanningNtfyConfig; clarificationEnabled?: boolean; /** Workflow selected by the planning entry point; retained for agent rebuilds. */ @@ -332,6 +336,7 @@ export interface DraftInputPayload { summarizedFor?: string; validated?: boolean; workflowId?: string; + sourceIssue?: (TaskSourceIssue & { title?: string }); createdTaskId?: string; createClaimStatus?: "none" | "creating" | "created"; claimOwnerToken?: string; @@ -401,6 +406,8 @@ interface Session { projectId?: string; /** Workflow selected at session start, retained for agent reconstruction. */ workflowId?: string; + /** Structured GitHub import provenance persisted in inputPayload. */ + sourceIssue?: TaskSourceIssue & { title?: string }; /** Model override the user picked at draft-create time. Persisted in inputPayload so reopen restores it. */ draftModelProvider?: string; draftModelId?: string; @@ -474,6 +481,24 @@ interface Session { updatedAt: Date; } +/* +FNXC:GitHubPlanningSourceIssue 2026-08-09-05:36: +A persisted explicit source wins over seed text; only a canonical seed may enrich matching title/body. +This keeps user prose and conflicting stale seeds from changing outward-facing GitHub provenance. +*/ +export function resolvePlanningSourceIssue(session: Pick): ReturnType | undefined { + const explicit = session.sourceIssue; + const seed = extractSeedIssueContext(session.initialPlan); + if (explicit) { + if (explicit.provider !== "github") return undefined; + const [owner, repo] = explicit.repository.split("/"); + if (!owner || !repo || !Number.isFinite(explicit.issueNumber) || !explicit.url) return undefined; + const enrich = seed && seed.url.toLowerCase() === explicit.url.toLowerCase() ? seed : undefined; + return buildPlanningSourceIssueContext({ owner, repo, issueNumber: explicit.issueNumber, url: explicit.url, title: enrich?.title ?? explicit.title, body: enrich?.body }); + } + return seed ? buildPlanningSourceIssueContext(seed) : undefined; +} + interface RateLimitEntry { count: number; firstRequestAt: Date; @@ -789,6 +814,7 @@ function persistSession(session: Session, status: "generating" | "awaiting_input ...(session.draftThinkingLevel ? { thinkingLevel: session.draftThinkingLevel } : {}), ...(session.draftSummarizedFor ? { summarizedFor: session.draftSummarizedFor } : {}), ...(session.workflowId ? { workflowId: session.workflowId } : {}), + ...(session.sourceIssue ? { sourceIssue: session.sourceIssue } : {}), validated: session.validated, ...(session.createdTaskId ? { createdTaskId: session.createdTaskId } : {}), ...(session.createClaimStatus ? { createClaimStatus: session.createClaimStatus } : {}), @@ -954,6 +980,7 @@ function buildSessionFromRow(row: AiSessionRow): Session { title: row.title, projectId: row.projectId ?? undefined, workflowId: payload.workflowId, + sourceIssue: payload.sourceIssue, draftModelProvider: payload.modelProvider, draftModelId: payload.modelId, draftThinkingLevel: thinkingLevel, @@ -1279,7 +1306,7 @@ export async function createSession( rootDir?: string, promptOverrides?: PromptOverrideMap, pluginRunner?: SkillPluginRunner, - options?: Pick, + options?: Pick, ): Promise<{ sessionId: string; firstQuestion: PlanningQuestion; summary: PlanningSummary; validated: boolean }> { // Check rate limit if (!checkRateLimit(ip)) { @@ -1316,6 +1343,7 @@ export async function createSession( pluginRunner, clarificationEnabled: options?.clarificationEnabled === true, workflowId: options?.workflowId, + sourceIssue: options?.sourceIssue, ntfyConfig: options?.ntfyConfig, }; @@ -1773,7 +1801,7 @@ export async function startExistingSession( thinkingLevelOrPromptOverrides?: ThinkingLevel | PromptOverrideMap, promptOverridesOrPluginRunner?: PromptOverrideMap | SkillPluginRunner, pluginRunnerMaybe?: SkillPluginRunner, - runtimeOptions?: Pick, + runtimeOptions?: Pick, ): Promise { const thinkingLevel = isThinkingLevel(thinkingLevelOrPromptOverrides) ? thinkingLevelOrPromptOverrides : undefined; const promptOverrides = isThinkingLevel(thinkingLevelOrPromptOverrides) @@ -1782,6 +1810,7 @@ export async function startExistingSession( const pluginRunner = (isThinkingLevel(thinkingLevelOrPromptOverrides) ? pluginRunnerMaybe : promptOverridesOrPluginRunner) as SkillPluginRunner | undefined; let session = sessions.get(sessionId); if (session && runtimeOptions?.workflowId) session.workflowId = runtimeOptions.workflowId; + if (session && runtimeOptions?.sourceIssue) session.sourceIssue = runtimeOptions.sourceIssue; // Draft sessions aren't included in rehydrateFromStore (which only loads // recoverable in-flight sessions), and a backend restart drops the in-memory @@ -1807,6 +1836,7 @@ export async function startExistingSession( throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`); } if (runtimeOptions?.workflowId) session.workflowId = runtimeOptions.workflowId; + if (runtimeOptions?.sourceIssue) session.sourceIssue = runtimeOptions.sourceIssue; // Drafts are sync'd via aiSessionStore.updateDraft, which only writes // SQLite. Pull the latest initialPlan + persisted model override + the @@ -1954,6 +1984,7 @@ export async function createSessionWithAgent( title: initialPlan.slice(0, 120), projectId: options?.projectId, workflowId: options?.workflowId, + sourceIssue: options?.sourceIssue, ntfyConfig: options?.ntfyConfig ? { enabled: options.ntfyConfig.enabled, @@ -4364,12 +4395,16 @@ export async function createTaskFromPlanSession( try { const planMd = formatPlanningTaskHandoff(summary, session.history); const originalRequest = session.initialPlan?.trim() || summary.description.trim(); + const sourceContext = resolvePlanningSourceIssue(session); + const trackingDecision = sourceContext + ? await (await import("./github-tracking.js")).resolvePlanningGithubTrackingDecision(store, await store.getSettings(), { owner: sourceContext.sourceIssue.repository.split("/")[0], repo: sourceContext.sourceIssue.repository.split("/")[1], issueNumber: sourceContext.sourceIssue.issueNumber, url: sourceContext.sourceIssue.url ?? "" }) + : undefined; const task = await store.createTask({ title: summary.title, - description: planMd, + description: sourceContext ? (await import("./github.js")).appendSourceIssueBlock(planMd, sourceContext.markdown, sourceContext.sourceIssue.url ?? "") : planMd, dependencies: summary.suggestedDependencies?.length ? summary.suggestedDependencies : undefined, priority: isTaskPriority(summary.priority) ? summary.priority : DEFAULT_TASK_PRIORITY, - source: { sourceType: options?.sourceType ?? "cli" }, + ...(sourceContext ? { sourceIssue: sourceContext.sourceIssue, source: { sourceType: "github_import" as const, sourceMetadata: sourceContext.sourceMetadata }, ...(trackingDecision?.githubTracking ? { githubTracking: trackingDecision.githubTracking } : {}) } : { source: { sourceType: options?.sourceType ?? "cli" } }), ...(options?.baseBranch?.trim() ? { baseBranch: options.baseBranch.trim() } : {}), proposalClaimId: currentProposalClaimId(), }); @@ -4389,6 +4424,11 @@ export async function createTaskFromPlanSession( if (originalRequest) { await sideEffect("Planning create-task original description document write failed", () => store.upsertTaskDocument?.(task.id, { key: "original-description", content: originalRequest, author: "planning", metadata: { planningSessionId: sessionId, source: "planning-mode-initial-plan" } })); } + if (sourceContext) { + await sideEffect("Planning create-task GitHub issue document write failed", () => store.upsertTaskDocument?.(task.id, { key: "github-issue", content: sourceContext.markdown, author: "planning", metadata: { planningSessionId: sessionId, source: "github-source-issue" } })); + await sideEffect("Planning create-task GitHub source log failed", () => store.logEntry?.(task.id, "Imported from GitHub", sourceContext.sourceIssue.url)); + } + if (trackingDecision?.suppressedByTaskId) await sideEffect("Planning create-task duplicate source issue log failed", () => store.logEntry?.(task.id, `Source issue already tracked by ${trackingDecision.suppressedByTaskId}`)); await sideEffect("Planning create-task log entry failed", () => store.logEntry?.(task.id, "Created via Planning Mode", `Initial plan: ${(session?.initialPlan ?? "").slice(0, 200)}`)); await finalizePlanningTaskCreation(sessionId, claimOwnerToken, task.id, claimEpoch); await markSessionComplete(); diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index 649fbad1f7..8c8da214c0 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -20,7 +20,7 @@ import type { Task, TaskStore, } from "@fusion/core"; -import { classifyGhError, getCurrentRepo, isGhAuthenticated, loadWorkspaceConfig, resolveTaskGithubTracking } from "@fusion/core"; +import { classifyGhError, getCurrentRepo, isGhAuthenticated, loadWorkspaceConfig } from "@fusion/core"; import { dropAutostashHandle, generateSyntheticRunId, @@ -46,6 +46,7 @@ import { GitHubClient, buildGitHubIssueSource, isGitHubIssueAlreadyImported, typ import { importIssueImageAttachments, githubImagePolicy } from "../issue-image-attachments.js"; import { GitHubIssueCommentService } from "../github-issue-comment.js"; import { GitHubTrackingCommentService } from "../github-tracking-comments.js"; +import { resolveImportedIssueGithubTracking } from "../github-tracking.js"; import { GitHubTrackingStateService } from "../github-tracking-state.js"; import { GitHubTrackingReconciler, RECONCILE_SCAN_LIMIT } from "../github-tracking-reconciler.js"; import { GitHubSourceIssueCloseService } from "../github-source-issue-close.js"; @@ -2186,34 +2187,6 @@ async function resolveImportedIssueTranslation( } } -async function resolveImportedIssueGithubTracking( - store: TaskStore, - projectSettings: Awaited>, -): Promise<{ enabled: true } | undefined> { - /* - FNXC:GithubImportTracking 2026-07-16-11:22: - FN-8115 shares the import request's project settings with translation and tracking, removing duplicate project-store reads after FN-8112 stabilized the prior test setup. The global settings read remains distinct because tracking precedence still requires it. - */ - if (projectSettings.githubLinkImportedIssuesToTracking === true) { - /* - FNXC:GithubImportTracking 2026-07-01-00:00: - The imported-issue linking option is narrower than the general new-task default. Dashboard imports force githubTracking.enabled only for GitHub source issues so the post-create hook adopts sourceIssue instead of opening a separate Fusion tracking issue. - */ - return { enabled: true }; - } - const globalSettings = await store.getGlobalSettingsStore().getSettings(); - const resolvedTracking = resolveTaskGithubTracking( - { githubTracking: undefined }, - projectSettings, - globalSettings, - ); - /* - FNXC:GithubImportTracking 2026-06-26-00:00: - Dashboard GitHub issue imports must mark tasks tracking-enabled only when project/global defaults resolve on. The post-create hook uses the GitHub sourceIssue to link source_issue_linked and prevents duplicate Fusion-created tracking issues. - */ - return resolvedTracking.enabled ? { enabled: true } : undefined; -} - export function getDefaultGitHubRepo(store: TaskStore): { owner: string; repo: string } | null { const envRepo = process.env.GITHUB_REPOSITORY; if (envRepo) { diff --git a/packages/dashboard/src/routes/register-planning-subtask-routes.ts b/packages/dashboard/src/routes/register-planning-subtask-routes.ts index 899ba3cf3e..69a9176bf2 100644 --- a/packages/dashboard/src/routes/register-planning-subtask-routes.ts +++ b/packages/dashboard/src/routes/register-planning-subtask-routes.ts @@ -666,6 +666,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann thinkingLevel, clarificationEnabled, workflowId, + sourceIssue, } = req.body; if (!initialPlan || typeof initialPlan !== "string") { @@ -693,6 +694,16 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann throw badRequest("existingSessionId must be a string when provided"); } + const validatedSourceIssue = (() => { + if (sourceIssue === undefined) return undefined; + if (!sourceIssue || typeof sourceIssue !== "object" || (sourceIssue as { provider?: unknown }).provider !== "github") throw badRequest("sourceIssue must be a GitHub issue"); + const value = sourceIssue as { repository?: unknown; issueNumber?: unknown; url?: unknown; title?: unknown }; + if (typeof value.repository !== "string" || typeof value.issueNumber !== "number" || !Number.isInteger(value.issueNumber) || value.issueNumber <= 0 || typeof value.url !== "string") throw badRequest("sourceIssue is malformed"); + const match = value.url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)\/?$/i); + if (!match || match[3] !== String(value.issueNumber) || `${match[1]}/${match[2]}`.toLowerCase() !== value.repository.toLowerCase()) throw badRequest("sourceIssue URL must match repository and issue number"); + return { provider: "github" as const, repository: value.repository, externalIssueId: String(value.issueNumber), issueNumber: value.issueNumber, url: value.url, ...(typeof value.title === "string" ? { title: value.title } : {}) }; + })(); + if (thinkingLevel !== undefined && !THINKING_LEVELS.includes(thinkingLevel as ThinkingLevel)) { throw badRequest("thinkingLevel must be one of: " + THINKING_LEVELS.join(", ")); } @@ -776,7 +787,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann validatedThinkingLevel, settings.promptOverrides, ctx.options?.pluginRunner as SkillPluginRunner, - { ...runtime, workflowId }, + { ...runtime, workflowId, sourceIssue: validatedSourceIssue }, ); } else { await startExistingSession( @@ -788,7 +799,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann settings.promptOverrides, ctx.options?.pluginRunner as SkillPluginRunner, undefined, - { ...runtime, workflowId }, + { ...runtime, workflowId, sourceIssue: validatedSourceIssue }, ); } res.status(201).json({ sessionId: existingSessionId }); @@ -799,6 +810,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann const planningOptions = { projectId, workflowId, + ...(validatedSourceIssue ? { sourceIssue: validatedSourceIssue } : {}), ...runtime, pluginRunner: ctx.options?.pluginRunner as SkillPluginRunner, }; @@ -1196,6 +1208,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann const summaryOverride = parsePlanningSummaryOverride(summaryInput); const { store: scopedStore } = await getProjectContext(req); + const projectSettings = await scopedStore.getSettings(); const { getSession, getSummary, @@ -1210,6 +1223,12 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann planningProposalClaimId, formatPlanningTaskHandoff, } = await import("../planning.js"); + const planningSourceModule = await import("../planning.js"); + const resolvePlanningSourceIssue = "resolvePlanningSourceIssue" in planningSourceModule + ? planningSourceModule.resolvePlanningSourceIssue + : undefined; + const { resolvePlanningGithubTrackingDecision } = await import("../github-tracking.js"); + const { appendSourceIssueBlock } = await import("../github.js"); let session = await getSession(sessionId); let summary = summaryOverride ?? getSummary(sessionId); @@ -1459,13 +1478,24 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann const originalRequest = typeof initialPlan === "string" && initialPlan.trim() ? initialPlan.trim() : summary.description.trim(); - // Create the task + /* + FNXC:GitHubPlanningSourceIssue 2026-08-09-08:09: + Planning creates truthful GitHub provenance from only persisted structured context or a canonical + seed. A live holder suppresses tracking rather than blocking the task, while post-create adoption + remains the cross-process authority that prevents duplicate GitHub tracking streams. + */ + // Older route harnesses and pre-rollout planning adapters do not export the additive resolver. + const sourceContext = session && typeof resolvePlanningSourceIssue === "function" ? resolvePlanningSourceIssue(session) : undefined; + const trackingDecision = sourceContext + ? await resolvePlanningGithubTrackingDecision(scopedStore, projectSettings, { owner: sourceContext.sourceIssue.repository.split("/")[0], repo: sourceContext.sourceIssue.repository.split("/")[1], issueNumber: sourceContext.sourceIssue.issueNumber, url: sourceContext.sourceIssue.url ?? "" }) + : undefined; + // Create the task. Provenance is truthful even when a live importer suppresses tracking. const task = await scopedStore.createTask({ title: summary.title, - description: planMd, + description: sourceContext ? appendSourceIssueBlock(planMd, sourceContext.markdown, sourceContext.sourceIssue.url ?? "") : planMd, dependencies: summary.suggestedDependencies.length > 0 ? summary.suggestedDependencies : undefined, priority: isTaskPriority(summary.priority) ? summary.priority : DEFAULT_TASK_PRIORITY, - source: { sourceType: "api" }, + ...(sourceContext ? { sourceIssue: sourceContext.sourceIssue, source: { sourceType: "github_import" as const, sourceMetadata: sourceContext.sourceMetadata }, ...(trackingDecision?.githubTracking ? { githubTracking: trackingDecision.githubTracking } : {}) } : { source: { sourceType: "api" as const } }), branch: resolvedBranch, baseBranch: resolvedBaseBranch, /* @@ -1498,6 +1528,22 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann ); } + if (sourceContext) { + await runPlanningCreateSideEffect( + "Planning create-task GitHub issue document write failed", + () => scopedStore.upsertTaskDocument(task.id, { key: "github-issue", content: sourceContext.markdown, author: "planning", metadata: { planningSessionId: sessionId, source: "github-source-issue" } }), + { taskId: task.id, sessionId }, + ); + await runPlanningCreateSideEffect( + "Planning create-task GitHub source log failed", + () => scopedStore.logEntry(task.id, "Imported from GitHub", sourceContext.sourceIssue.url), + { taskId: task.id, sessionId }, + ); + if (trackingDecision?.suppressedByTaskId) { + await runPlanningCreateSideEffect("Planning create-task duplicate source issue log failed", () => scopedStore.logEntry(task.id, `Source issue already tracked by ${trackingDecision.suppressedByTaskId}`), { taskId: task.id, sessionId }); + } + } + // Log the planning mode creation. await runPlanningCreateSideEffect( "Planning create-task log entry failed", @@ -1619,6 +1665,11 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann const { store: scopedStore } = await getProjectContext(req); const { getSession, releaseSession, formatInterviewQA, formatPlanningTaskHandoff, mergePlanningSubtaskDrafts } = await import("../planning.js"); + const planningSourceModule = await import("../planning.js"); + const resolvePlanningSourceIssue = "resolvePlanningSourceIssue" in planningSourceModule + ? planningSourceModule.resolvePlanningSourceIssue + : undefined; + const { appendSourceIssueBlock } = await import("../github.js"); const session = await getSession(planningSessionId); if (!session) { @@ -1630,6 +1681,12 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann throw badRequest("Planning session is not complete"); } + /* + FNXC:GitHubPlanningSourceIssue 2026-08-09-05:36: + A planning breakdown preserves the source text on every child but must not create N source + links or tracking streams for one GitHub issue. + */ + const sourceContext = typeof resolvePlanningSourceIssue === "function" ? resolvePlanningSourceIssue(session) : undefined; const qaSection = formatInterviewQA(session.history); const logDetails = qaSection ? `Source: ${session.initialPlan.slice(0, 200)}\n\n${qaSection}` @@ -1737,7 +1794,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann */ const task = await scopedStore.createTask({ title: item.title.trim(), - description: planMd, + description: sourceContext ? appendSourceIssueBlock(planMd, sourceContext.markdown, sourceContext.sourceIssue.url ?? "") : planMd, dependencies: undefined, priority: isTaskPriority(item.priority) ? item.priority : DEFAULT_TASK_PRIORITY, source: { sourceType: "api", sourceMetadata: { planningSessionId } },