FN-8326: add roadmap report deduplication
Add an optional public-roadmap source for report deduplication. - Add project settings and localized controls for roadmap matching. - Resolve roadmap entries through the plugin API and stop strong duplicate reports before filing. - Cover report pipeline, route, UI, and roadmap store behavior with tests. Files changed: .changeset/fn-8326-roadmap-report-dedup.md | 7 +++ docs/dashboard-guide.md | 2 + docs/settings-reference.md | 1 + .../core/src/__tests__/settings-parity.test.ts | 3 + packages/core/src/settings-schema.ts | 1 + packages/core/src/types.ts | 6 ++ packages/dashboard/app/components/ReportModal.tsx | 11 +++- .../app/components/__tests__/ReportModal.test.tsx | 14 +++++ .../__tests__/SettingsModal.general.test.tsx | 2 + .../settings/sections/GeneralSection.tsx | 10 ++++ .../settings-default-descriptions.test.tsx | 1 + .../src/__tests__/report-pipeline.test.ts | 31 +++++++++++ .../src/__tests__/report-roadmap-source.test.ts | 32 +++++++++++ .../dashboard/src/__tests__/report-routes.test.ts | 27 +++++++-- packages/dashboard/src/index.ts | 1 + packages/dashboard/src/report-pipeline.ts | 36 +++++++++++- packages/dashboard/src/report-roadmap-source.ts | 64 ++++++++++++++++++++++ .../dashboard/src/routes/register-report-routes.ts | 3 + packages/i18n/locales/en/app.json | 10 +++- packages/i18n/locales/es/app.json | 10 +++- packages/i18n/locales/fr/app.json | 10 +++- packages/i18n/locales/ko/app.json | 10 +++- packages/i18n/locales/zh-CN/app.json | 10 +++- packages/i18n/locales/zh-TW/app.json | 10 +++- .../src/__tests__/roadmap-store-factory.test.ts | 25 +++++++++ .../src/routes/roadmap-routes.ts | 24 +------- plugins/fusion-plugin-roadmap/src/server/index.ts | 34 ++++++++++++ 27 files changed, 360 insertions(+), 35 deletions(-) Fusion-Task-Id: FN-8326 Fusion-Task-Lineage: e9731c3f-dd92-44ac-910b-0e0efbaaf288 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8326-roadmap-report-dedup.md
Normal file
7
.changeset/fn-8326-roadmap-report-dedup.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Let operators prevent duplicate in-app reports with optional roadmap matching.
|
||||
category: feature
|
||||
dev: Adds the project-scoped `reportRoadmapDedup` setting and local `roadmap-match` result.
|
||||
@@ -2090,6 +2090,8 @@ The Header **Report** menu is available on desktop and mobile and offers **Bug**
|
||||
|
||||
Fusion gathers available task/agent context, structures the prompt into a report, scrubs secrets, local paths, project names, home-directory identities, email addresses, and likely personal names, then checks **open** GitHub issues or Discussions for duplicates. Scrubbing is mandatory for every route and is repeated on the server when a reviewed draft is edited before filing. A strong duplicate receives a visible 👍 reaction and one scrubbed data-point comment instead of a new issue or Discussion. Bug and Idea reports use issues; Feedback and unresolved Help reports use repository Discussions. If preparation or filing cannot reach GitHub, Fusion preserves the draft and shows a retryable error.
|
||||
|
||||
Projects can opt into **Check roadmap before filing reports** in **Settings → General**. It is off by default. When enabled, the project roadmap is a third deduplication source: a strong feature match is shown inline as **Already on the roadmap**, including its title and description, and no Issue, Discussion, comment, or reaction is filed. The result intentionally has no roadmap link because Fusion does not expose a roadmap dashboard destination.
|
||||
|
||||
In **Settings → General**, choose **Review draft before filing** (the default) or **File automatically**. Both paths show the resulting issue, Discussion, or endorsement link. Help checks Fusion's local knowledge index on every server report path first and only escalates when it cannot find an answer.
|
||||
|
||||
Reports include a default-on activity trace of up to 20 recent views, report actions, toast messages, and uncaught client errors (at most 4,000 characters). It is ordinary report text and receives the same server-side scrub as every other report field. You can optionally attach a screenshot of the current Fusion view. Fusion captures only the dashboard DOM, shows the exact preview, and requires confirmation before it uploads the PNG/JPEG (up to 2MB) as a local image artifact. In automatic mode this confirmation and local artifact upload happen before filing begins. The server validates the resulting artifact reference (format, image type, MIME type, and report-upload provenance) before it can appear as a text-only local-retention note in a report. Screenshot pixels are never sent to GitHub.
|
||||
|
||||
@@ -655,6 +655,7 @@ Default notes:
|
||||
| `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. |
|
||||
| `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<Record<"bug" \| "feedback" \| "idea" \| "help", "draft-review" \| "auto-file">>` | `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. |
|
||||
| `reportRoadmapDedup` | `boolean` | `false` | Opt-in project roadmap deduplication for in-app reports. A strong roadmap feature match returns an inline `roadmap-match` result and performs no GitHub egress; off preserves Issues-and-Discussions-only behavior. Dashboard location: **Settings → Project → General → Check roadmap before filing reports**. |
|
||||
| `githubImportAutoTranslate` | `boolean` | `false` | Project-scoped, import-only option. When enabled, the Import Tasks panel automatically translates foreign-language **GitHub open issue** titles and bodies into `importTranslateTargetLocale` and shows the translation by default (the original text stays one toggle away). Translation follows each reachable page of the fetched list (up to the 300-issue fetch cap in one hour); cached repeat views do not spend model calls. Pull requests and GitLab retain per-selection translation. Translations persist across app restarts until the upstream issue changes or closes. Off by default so all-English projects never pay for a per-issue AI call. Issue-form template scaffolding (headings, field labels, placeholders, and checkboxes) is ignored when detecting an issue's language, so foreign-language answers remain eligible. Dashboard location: **Settings → Project → General → GitHub Tracking**. |
|
||||
| `importTranslateTargetLocale` | `Locale` | `undefined` | Target language for `githubImportAutoTranslate`. One of `SUPPORTED_LOCALES`. When unset, import translation follows the dashboard's own `language` setting. Dashboard location: **Settings → Project → General → GitHub Tracking**. |
|
||||
| `githubTrackingDefaultRepo` | `string` | `undefined` | Project default issue-tracking repo (`owner/repo`) used before global fallback for tracked task creation (precedence: task override → project default → global default). In Settings UI this is a detected-remote dropdown with a Custom fallback for manual entry. This key is dual-scope: project saves go through `PUT /api/settings` (Settings → General → GitHub Tracking) while global saves go through `PUT /api/settings/global` (Settings → Global General). |
|
||||
|
||||
@@ -169,10 +169,13 @@ describe("settings key parity", () => {
|
||||
it("defaults in-app reports to draft review and keeps mode settings project-scoped", () => {
|
||||
expect(DEFAULT_PROJECT_SETTINGS.reportMode).toBe("draft-review");
|
||||
expect(DEFAULT_PROJECT_SETTINGS.reportModeByAction).toBeUndefined();
|
||||
expect(DEFAULT_PROJECT_SETTINGS.reportRoadmapDedup).toBe(false);
|
||||
expect(isProjectSettingsKey("reportMode")).toBe(true);
|
||||
expect(isProjectSettingsKey("reportModeByAction")).toBe(true);
|
||||
expect(isProjectSettingsKey("reportRoadmapDedup")).toBe(true);
|
||||
expect(isGlobalSettingsKey("reportMode")).toBe(false);
|
||||
expect(isGlobalSettingsKey("reportModeByAction")).toBe(false);
|
||||
expect(isGlobalSettingsKey("reportRoadmapDedup")).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults autoClaimCandidatesInPrompt to 5 and keeps it project-scoped", () => {
|
||||
|
||||
@@ -640,6 +640,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
githubTrackingDefaultRepo: undefined,
|
||||
reportMode: "draft-review" as const,
|
||||
reportModeByAction: undefined,
|
||||
reportRoadmapDedup: false,
|
||||
gitlabEnabled: undefined,
|
||||
gitlabInstanceUrl: undefined,
|
||||
gitlabApiBaseUrl: undefined,
|
||||
|
||||
@@ -4088,6 +4088,12 @@ export interface ProjectSettings {
|
||||
*/
|
||||
reportMode?: ReportMode;
|
||||
reportModeByAction?: Partial<Record<ReportActionType, ReportMode>>;
|
||||
/**
|
||||
* FNXC:ReportPipeline 2026-07-18-12:00:
|
||||
* Roadmap deduplication is opt-in because projects without a roadmap must
|
||||
* retain the existing Issues-and-Discussions-only report behavior.
|
||||
*/
|
||||
reportRoadmapDedup?: boolean;
|
||||
/**
|
||||
* FNXC:GitLabConfiguration 2026-07-02-00:00:
|
||||
* FN-7422 adds durable GitLab instance/API URL settings for GitLab.com and self-managed hosts. FN-7423 layers token settings onto the same project-over-global configuration contract without adding runtime GitLab imports or tracking.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ReportActionType } from "@fusion/core";
|
||||
import { reportDraft, reportFile, reportHelp, uploadReportScreenshot } from "../api";
|
||||
import { captureAppScreenshot, type CapturedScreenshot } from "../utils/capture-screenshot";
|
||||
@@ -6,7 +7,7 @@ import { recordActivity, snapshotActivityTrace } from "../utils/activity-trace";
|
||||
import "./ReportModal.css";
|
||||
|
||||
const prompts: Record<ReportActionType, string> = { bug: "What went wrong?", feedback: "What would you like to share?", idea: "What would you like Fusion to do?", help: "What would you like help with?" };
|
||||
type ModalResult = { kind: string; report?: { userPrompt: string; sourcePrompt?: string; summary?: string; body?: string; context?: Record<string, unknown>; sessionToken?: string }; issue?: { number: number; url: string; title: string; discussionId?: string }; url?: string; answer?: { summary?: string; content?: string }; message?: string };
|
||||
type ModalResult = { kind: string; report?: { userPrompt: string; sourcePrompt?: string; summary?: string; body?: string; context?: Record<string, unknown>; sessionToken?: string }; issue?: { number: number; url: string; title: string; discussionId?: string }; roadmap?: { featureId: string; title: string; description: string }; url?: string; answer?: { summary?: string; content?: string }; message?: string };
|
||||
|
||||
/**
|
||||
* FNXC:ReportPipeline 2026-07-16-12:00:
|
||||
@@ -14,6 +15,7 @@ type ModalResult = { kind: string; report?: { userPrompt: string; sourcePrompt?:
|
||||
* final filed or endorsed link remains visible after the selected mode acts.
|
||||
*/
|
||||
export function ReportModal({ actionType, onClose, contextRefs }: { actionType: ReportActionType; onClose: () => void; contextRefs?: { taskId?: string; agentId?: string } }) {
|
||||
const { t } = useTranslation("app");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [result, setResult] = useState<ModalResult>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -107,6 +109,13 @@ export function ReportModal({ actionType, onClose, contextRefs }: { actionType:
|
||||
<textarea id="report-duplicate-body" className="input" value={result.report.body ?? ""} onChange={(event) => setResult({ ...result, report: { ...result.report!, body: event.target.value } })} />
|
||||
<button className="btn btn-primary" type="button" disabled={busy} onClick={() => void file(result.issue!.discussionId ? undefined : result.issue!.number, result.issue!.discussionId)}>Confirm and add data point</button>
|
||||
</>}
|
||||
{result?.kind === "roadmap-match" && result.roadmap && <>
|
||||
{/* FNXC:ReportPipeline 2026-07-18-12:45: A roadmap match stays inline because the roadmap view is intentionally not a dashboard destination; it informs the reporter and offers no dead deep-link or filing control. */}
|
||||
<h2>{t("report.roadmapMatch.title", "Already on the roadmap")}</h2>
|
||||
<p>{t("report.roadmapMatch.message", "This report matches a feature that is already planned.")}</p>
|
||||
<h3>{result.roadmap.title}</h3>
|
||||
{result.roadmap.description && <p>{result.roadmap.description}</p>}
|
||||
</>}
|
||||
{(result?.kind === "filed" || result?.kind === "endorsed") && <><h2>Report sent</h2><a href={result.url} target="_blank" rel="noreferrer">View on GitHub</a>{result.report?.body && <><label htmlFor="filed-report">Final report</label><textarea id="filed-report" className="input" value={result.report.body} readOnly /></>}</>}
|
||||
{result?.kind === "help" && <><h2>Suggested help</h2><p>{result.answer?.summary ?? result.answer?.content}</p></>}
|
||||
{result?.kind === "unavailable" && <>
|
||||
|
||||
@@ -62,6 +62,20 @@ describe("ReportModal", () => {
|
||||
})));
|
||||
});
|
||||
|
||||
it("shows a roadmap match inline without a filing or dead-link affordance", async () => {
|
||||
reportDraft.mockResolvedValueOnce({ kind: "roadmap-match", roadmap: { featureId: "RF-1", title: "Offline report queue", description: "Keep reports available while offline" } });
|
||||
render(<ReportModal actionType="idea" onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("What would you like Fusion to do?"), { target: { value: "Keep reports while offline" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
|
||||
expect(await screen.findByText("Already on the roadmap")).toBeInTheDocument();
|
||||
expect(screen.getByText("Offline report queue")).toBeInTheDocument();
|
||||
expect(screen.getByText("Keep reports available while offline")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /file/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lets people return to the guided prompt after an unavailable response", async () => {
|
||||
reportDraft.mockResolvedValueOnce({ kind: "unavailable", message: "GitHub is not connected" });
|
||||
render(<ReportModal actionType="feedback" onClose={vi.fn()} />);
|
||||
|
||||
@@ -1336,12 +1336,14 @@ describe("SettingsModal", () => {
|
||||
|
||||
fireEvent.change(screen.getByLabelText("In-app report mode"), { target: { value: "auto-file" } });
|
||||
fireEvent.change(screen.getByLabelText("Bug report override"), { target: { value: "draft-review" } });
|
||||
fireEvent.click(screen.getByLabelText("Check roadmap before filing reports"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalled());
|
||||
expect(mockUpdateSettings.mock.calls[0]?.[0]).toEqual(expect.objectContaining({
|
||||
reportMode: "auto-file",
|
||||
reportModeByAction: { bug: "draft-review" },
|
||||
reportRoadmapDedup: true,
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -328,6 +328,16 @@ export function GeneralSection({ form, setForm, projectId, addToast, prefixError
|
||||
</label>
|
||||
))}
|
||||
<p className="form-help">{t("settings.general.reportModeByActionHelp", "Optional per-action override of the project report mode for bug, feedback, idea, or help. No default — unset actions inherit reportMode.")}</p>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "reportRoadmapDedup",
|
||||
label: t("settings.general.reportRoadmapDedup", "Check roadmap before filing reports"),
|
||||
help: t("settings.general.reportRoadmapDedupHelp", "When enabled, matching roadmap features are shown inline instead of filing another GitHub Issue or Discussion. Default: off."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.reportRoadmapDedup === true}
|
||||
onChange={(value) => setForm((current) => ({ ...current, reportRoadmapDedup: value ?? false }))}
|
||||
/>
|
||||
</div>
|
||||
{/*
|
||||
FNXC:SettingsGeneral 2026-07-15-17:35:
|
||||
|
||||
@@ -270,6 +270,7 @@ const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
|
||||
*/
|
||||
reportMode: "general.reportModeHelp",
|
||||
reportModeByAction: "general.reportModeByActionHelp",
|
||||
reportRoadmapDedup: "general.reportRoadmapDedupHelp",
|
||||
githubTrackingDedupEnabled: "general.whenEnabledFusionChecksOpenAndClosedIssues",
|
||||
githubTrackingEnabledByDefault: "general.offDefault",
|
||||
sessionAdvisorEnabledByDefault: "general.offDefault",
|
||||
|
||||
@@ -45,6 +45,37 @@ describe("report pipeline", () => {
|
||||
expect(result.kind).toBe("draft-ready");
|
||||
});
|
||||
|
||||
it.each(["bug", "feedback", "idea", "help"] as const)("short-circuits %s reports that strongly match the roadmap without egress", async (actionType) => {
|
||||
const roadmapSource = vi.fn().mockResolvedValue([{ featureId: "RF-1", title: "Dashboard rendering controls", body: "Add dashboard rendering controls" }]);
|
||||
const context = deps({ projectSettings: { ...settings, reportMode: "auto-file", reportRoadmapDedup: true }, roadmapSource });
|
||||
const result = await runReportPipeline({ actionType, userPrompt: "Add dashboard rendering controls" }, context);
|
||||
expect(result).toMatchObject({ kind: "roadmap-match", roadmap: { featureId: "RF-1", title: "Dashboard rendering controls", description: "Add dashboard rendering controls" } });
|
||||
expect(roadmapSource).toHaveBeenCalledOnce();
|
||||
expect(context.client!.createIssue).not.toHaveBeenCalled();
|
||||
expect(context.client!.createDiscussion).not.toHaveBeenCalled();
|
||||
expect(context.client!.commentOnIssue).not.toHaveBeenCalled();
|
||||
expect(context.client!.commentOnDiscussion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not read the roadmap source when its opt-in setting is off", async () => {
|
||||
const roadmapSource = vi.fn().mockResolvedValue([{ featureId: "RF-1", title: "Dashboard rendering controls", body: "Add dashboard rendering controls" }]);
|
||||
const context = deps({ projectSettings: { ...settings, reportMode: "auto-file", reportRoadmapDedup: false }, roadmapSource });
|
||||
const result = await runReportPipeline({ actionType: "idea", userPrompt: "Add dashboard rendering controls" }, context);
|
||||
expect(result.kind).toBe("filed");
|
||||
expect(roadmapSource).not.toHaveBeenCalled();
|
||||
expect(context.client!.createIssue).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("falls through to existing GitHub routing when roadmap candidates do not match", async () => {
|
||||
const context = deps({
|
||||
projectSettings: { ...settings, reportMode: "auto-file", reportRoadmapDedup: true },
|
||||
roadmapSource: vi.fn().mockResolvedValue([{ featureId: "RF-1", title: "Unrelated access controls", body: "Manage permissions" }]),
|
||||
});
|
||||
const result = await runReportPipeline({ actionType: "idea", userPrompt: "Add dashboard rendering controls" }, context);
|
||||
expect(result.kind).toBe("filed");
|
||||
expect(context.client!.createIssue).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("files in auto-file mode", async () => {
|
||||
const context = deps({ projectSettings: { ...settings, reportMode: "auto-file" } });
|
||||
const result = await runReportPipeline({ actionType: "idea", userPrompt: "Add dashboard rendering controls" }, context);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createRoadmapDedupSourceForTaskStore } from "../report-roadmap-source.js";
|
||||
|
||||
const taskStore = { getAsyncLayer: () => ({}) } as any;
|
||||
|
||||
describe("roadmap report dedup source", () => {
|
||||
it("flattens features across roadmap hierarchies", async () => {
|
||||
const source = createRoadmapDedupSourceForTaskStore(taskStore, {
|
||||
createRoadmapStore: () => ({
|
||||
listRoadmaps: vi.fn().mockResolvedValue([{ id: "RM-1" }, { id: "RM-2" }]),
|
||||
getRoadmapWithHierarchy: vi.fn().mockImplementation(async (id: string) => id === "RM-1"
|
||||
? { milestones: [{ features: [{ id: "RF-1", title: "Offline reports", description: "Keep reports while offline" }] }] }
|
||||
: { milestones: [{ features: [{ id: "RF-2", title: "Report search" }] }] }),
|
||||
}),
|
||||
});
|
||||
await expect(source([])).resolves.toEqual([
|
||||
{ featureId: "RF-1", title: "Offline reports", body: "Keep reports while offline" },
|
||||
{ featureId: "RF-2", title: "Report search", body: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns no candidates for an empty or unavailable roadmap store", async () => {
|
||||
const empty = createRoadmapDedupSourceForTaskStore(taskStore, {
|
||||
createRoadmapStore: () => ({ listRoadmaps: vi.fn().mockResolvedValue([]), getRoadmapWithHierarchy: vi.fn() }),
|
||||
});
|
||||
const unavailable = createRoadmapDedupSourceForTaskStore(taskStore, {
|
||||
createRoadmapStore: () => { throw new Error("missing layer"); },
|
||||
});
|
||||
await expect(empty([])).resolves.toEqual([]);
|
||||
await expect(unavailable([])).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Router } from "express";
|
||||
|
||||
vi.mock("../knowledge-index.js", () => ({
|
||||
@@ -15,13 +15,13 @@ import { queryKnowledgePagesAsync } from "../knowledge-index.js";
|
||||
import { runReportPipeline } from "../report-pipeline.js";
|
||||
import { registerReportRoutes } from "../routes/register-report-routes.js";
|
||||
|
||||
function setup() {
|
||||
function setup(projectSettings: Record<string, unknown> = { reportMode: "auto-file" }) {
|
||||
const handlers = new Map<string, (req: { body?: unknown }, res: { json: (body: unknown) => void }) => Promise<void>>();
|
||||
const router = {
|
||||
post: vi.fn((path: string, handler: (req: { body?: unknown }, res: { json: (body: unknown) => void }) => Promise<void>) => handlers.set(path, handler)),
|
||||
} as unknown as Router;
|
||||
const store = {
|
||||
getSettingsByScopeFast: vi.fn().mockResolvedValue({ project: { reportMode: "auto-file" }, global: {} }),
|
||||
getSettingsByScopeFast: vi.fn().mockResolvedValue({ project: projectSettings, global: {} }),
|
||||
getRootDir: () => "/Users/alice/private-project",
|
||||
};
|
||||
registerReportRoutes({
|
||||
@@ -38,7 +38,25 @@ async function invoke(handler: (req: { body?: unknown }, res: { json: (body: unk
|
||||
return json.mock.calls[0][0];
|
||||
}
|
||||
|
||||
describe("report routes Help self-check", () => {
|
||||
describe("report routes", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
it("passes an opted-in project roadmap source through both report routes", async () => {
|
||||
vi.mocked(queryKnowledgePagesAsync).mockResolvedValue([]);
|
||||
vi.mocked(runReportPipeline).mockResolvedValue({ kind: "roadmap-match" } as never);
|
||||
const handlers = setup({ reportMode: "auto-file", reportRoadmapDedup: true });
|
||||
await invoke(handlers.get("/report/draft")!, { actionType: "idea", userPrompt: "Dashboard report controls" });
|
||||
await invoke(handlers.get("/report/file")!, { actionType: "idea", report: { userPrompt: "Dashboard report controls", context: {} } });
|
||||
for (const [, deps] of vi.mocked(runReportPipeline).mock.calls) expect(deps.roadmapSource).toEqual(expect.any(Function));
|
||||
});
|
||||
|
||||
it("does not create a roadmap source while the setting is off", async () => {
|
||||
vi.mocked(runReportPipeline).mockResolvedValue({ kind: "draft-ready" } as never);
|
||||
const handlers = setup();
|
||||
await invoke(handlers.get("/report/draft")!, { actionType: "idea", userPrompt: "Dashboard report controls" });
|
||||
expect(vi.mocked(runReportPipeline).mock.calls.at(-1)?.[1].roadmapSource).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("Help self-check", () => {
|
||||
it.each(["/report/draft", "/report/file"]) ("does not let direct Help %s bypass a confident knowledge answer", async (path) => {
|
||||
vi.mocked(queryKnowledgePagesAsync).mockResolvedValue([{ title: "Use settings", summary: "Open settings first." }]);
|
||||
const handlers = setup();
|
||||
@@ -48,4 +66,5 @@ describe("report routes Help self-check", () => {
|
||||
expect(response).toMatchObject({ kind: "help", answer: { title: "Use settings" } });
|
||||
expect(runReportPipeline).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,6 +75,7 @@ export {
|
||||
} from "./knowledge-index.js";
|
||||
export { KnowledgeIndexRefreshService } from "./knowledge-index-refresh.js";
|
||||
export { runReportPipeline, endorseDuplicate, resolveReportMode, type ReportInput, type ReportResult, type ReportPipelineDeps, type StructuredReport } from "./report-pipeline.js";
|
||||
export { createProjectRoadmapDedupSource, createRoadmapDedupSourceForTaskStore, type RoadmapCandidate, type RoadmapDedupSource, type RoadmapDedupSourceDeps } from "./report-roadmap-source.js";
|
||||
export { scrubReportPayload, scrubReportText, type ReportScrubContext } from "./report-scrub.js";
|
||||
export { selfCheckHelp, type HelpKnowledgeResult } from "./report-help-selfcheck.js";
|
||||
export {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { GitHubClient } from "./github.js";
|
||||
import { resolveGithubTrackingAuth } from "./github-auth.js";
|
||||
import { buildIssueSearchQueries, DEDUP_MATCH_THRESHOLD, scoreCandidateIssue } from "./github-tracking-dedup.js";
|
||||
import { scrubReportPayload, type ReportScrubContext } from "./report-scrub.js";
|
||||
import type { RoadmapDedupSource } from "./report-roadmap-source.js";
|
||||
|
||||
export type { ReportActionType, ReportMode };
|
||||
|
||||
@@ -34,16 +35,18 @@ export interface StructuredReport {
|
||||
export type ReportResult =
|
||||
| { kind: "draft-ready"; report: StructuredReport; mode: ReportMode }
|
||||
| { kind: "duplicate-found"; report: StructuredReport; mode: ReportMode; issue: { number: number; url: string; title: string; discussionId?: string } }
|
||||
| { kind: "roadmap-match"; report: StructuredReport; mode: ReportMode; roadmap: { featureId: string; title: string; description: string } }
|
||||
| { kind: "filed"; url: string; report: StructuredReport }
|
||||
| { kind: "endorsed"; url: string; issueNumber: number; report: StructuredReport }
|
||||
| { kind: "unavailable"; reason: string; message: string };
|
||||
|
||||
export interface ReportPipelineDeps {
|
||||
projectSettings: Pick<ProjectSettings, "reportMode" | "reportModeByAction" | "githubTrackingDefaultRepo" | "githubAuthMode" | "githubAuthToken">;
|
||||
projectSettings: Pick<ProjectSettings, "reportMode" | "reportModeByAction" | "reportRoadmapDedup" | "githubTrackingDefaultRepo" | "githubAuthMode" | "githubAuthToken">;
|
||||
globalSettings?: Partial<GlobalSettings>;
|
||||
client?: Pick<GitHubClient, "createIssue" | "searchIssues" | "commentOnIssue" | "addIssueReaction"> & Partial<Pick<GitHubClient, "searchDiscussions" | "createDiscussion" | "commentOnDiscussion" | "addDiscussionReaction">>;
|
||||
scrubContext?: ReportScrubContext;
|
||||
gatherContext?: (input: ReportInput) => Promise<Record<string, unknown>>;
|
||||
roadmapSource?: RoadmapDedupSource;
|
||||
}
|
||||
|
||||
const MAX_PROMPT_LENGTH = 4_000;
|
||||
@@ -116,8 +119,12 @@ function destinationFor(actionType: ReportActionType): ReportDestination {
|
||||
return actionType === "feedback" || actionType === "help" ? "discussion" : "issue";
|
||||
}
|
||||
|
||||
function reportKeywords(report: StructuredReport): string[] {
|
||||
return report.summary.replace(/[^\w ]/g, " ").split(/\s+/).filter((word) => word.length > 3).slice(0, 6);
|
||||
}
|
||||
|
||||
async function findDuplicate(client: NonNullable<ReportPipelineDeps["client"]>, owner: string, repo: string, report: StructuredReport, destination: ReportDestination) {
|
||||
const keywords = report.summary.replace(/[^\w ]/g, " ").split(/\s+/).filter((word) => word.length > 3).slice(0, 6);
|
||||
const keywords = reportKeywords(report);
|
||||
for (const query of buildIssueSearchQueries([], keywords)) {
|
||||
const candidates: DuplicateCandidate[] = destination === "discussion"
|
||||
? (client.searchDiscussions ? await client.searchDiscussions(owner, repo, query, { limit: 1000 }) : []).map((discussion) => ({ number: discussion.number, title: discussion.title, body: discussion.body, html_url: discussion.url, state: discussion.state, discussionId: discussion.id }))
|
||||
@@ -130,6 +137,16 @@ async function findDuplicate(client: NonNullable<ReportPipelineDeps["client"]>,
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function findRoadmapMatch(roadmapSource: RoadmapDedupSource, report: StructuredReport) {
|
||||
const keywords = reportKeywords(report);
|
||||
const candidates = await roadmapSource(keywords);
|
||||
const match = candidates
|
||||
.map((candidate) => ({ candidate, score: scoreCandidateIssue(candidate, [], keywords).score }))
|
||||
.filter(({ score }) => score >= DEDUP_MATCH_THRESHOLD)
|
||||
.sort((left, right) => right.score - left.score)[0];
|
||||
return match?.candidate;
|
||||
}
|
||||
|
||||
async function endorseDiscussionDuplicate(args: { issueNumber: number; discussionId: string; report: StructuredReport; client: NonNullable<ReportPipelineDeps["client"]> & Pick<GitHubClient, "commentOnDiscussion" | "addDiscussionReaction">; scrubContext?: ReportScrubContext }): Promise<Extract<ReportResult, { kind: "endorsed" }>> {
|
||||
const sessionToken = args.report.sessionToken ?? `${args.discussionId}:${args.report.summary}`;
|
||||
const report = scrubReportPayload(args.report, args.scrubContext);
|
||||
@@ -202,6 +219,21 @@ export async function runReportPipeline(input: ReportInput, deps: ReportPipeline
|
||||
if (!report.body.includes(note)) report = { ...report, body: `${report.body}\n\n${note}` };
|
||||
}
|
||||
const mode = resolveReportMode(input.actionType, deps.projectSettings);
|
||||
/*
|
||||
FNXC:ReportPipeline 2026-07-18-12:30:
|
||||
An opted-in roadmap hit takes deterministic precedence over GitHub matching
|
||||
and filing. It returns local roadmap context only, so no issue, discussion,
|
||||
comment, reaction, or other external egress can occur in either report mode.
|
||||
*/
|
||||
if (deps.projectSettings.reportRoadmapDedup && deps.roadmapSource) {
|
||||
const roadmap = await findRoadmapMatch(deps.roadmapSource, report);
|
||||
if (roadmap) return {
|
||||
kind: "roadmap-match",
|
||||
report,
|
||||
mode,
|
||||
roadmap: { featureId: roadmap.featureId, title: roadmap.title, description: roadmap.body ?? "" },
|
||||
};
|
||||
}
|
||||
const clientResult = createClient(deps);
|
||||
if (clientResult.unavailable) return clientResult.unavailable;
|
||||
const repo = resolveRepo(deps);
|
||||
|
||||
64
packages/dashboard/src/report-roadmap-source.ts
Normal file
64
packages/dashboard/src/report-roadmap-source.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { createRoadmapStoreForTaskStore, type RoadmapTaskStoreAccess } from "@fusion-plugin-examples/roadmap";
|
||||
import { getOrCreateProjectStore } from "./project-store-resolver.js";
|
||||
|
||||
export interface RoadmapCandidate {
|
||||
featureId: string;
|
||||
title: string;
|
||||
body: string | null;
|
||||
}
|
||||
|
||||
export type RoadmapDedupSource = (keywords: string[]) => Promise<RoadmapCandidate[]>;
|
||||
|
||||
type RoadmapHierarchyStore = {
|
||||
listRoadmaps(): Promise<Array<{ id: string }>>;
|
||||
getRoadmapWithHierarchy(id: string): Promise<{
|
||||
milestones: Array<{
|
||||
features: Array<{ id: string; title: string; description?: string }>;
|
||||
}>;
|
||||
} | undefined>;
|
||||
};
|
||||
|
||||
export interface RoadmapDedupSourceDeps {
|
||||
createRoadmapStore?: (taskStore: RoadmapTaskStoreAccess) => RoadmapHierarchyStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:ReportPipeline 2026-07-18-12:15:
|
||||
* The roadmap is a read-only report deduplication corpus. A candidate match
|
||||
* only informs pipeline routing and never mutates roadmap data or egresses it.
|
||||
*/
|
||||
export function createRoadmapDedupSourceForTaskStore(
|
||||
taskStore: RoadmapTaskStoreAccess,
|
||||
deps: RoadmapDedupSourceDeps = {},
|
||||
): RoadmapDedupSource {
|
||||
const createStore = deps.createRoadmapStore ?? createRoadmapStoreForTaskStore;
|
||||
return async () => {
|
||||
try {
|
||||
const store = createStore(taskStore);
|
||||
const roadmaps = await store.listRoadmaps();
|
||||
const hierarchies = await Promise.all(roadmaps.map(({ id }) => store.getRoadmapWithHierarchy(id)));
|
||||
return hierarchies.flatMap((roadmap) => roadmap?.milestones.flatMap((milestone) => milestone.features.map((feature) => ({
|
||||
featureId: feature.id,
|
||||
title: feature.title,
|
||||
body: feature.description ?? null,
|
||||
}))) ?? []);
|
||||
} catch {
|
||||
// The optional roadmap plugin and its PostgreSQL layer must not block reports.
|
||||
return [];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createProjectRoadmapDedupSource(
|
||||
projectId: string,
|
||||
deps: RoadmapDedupSourceDeps = {},
|
||||
): RoadmapDedupSource {
|
||||
return async (keywords) => {
|
||||
try {
|
||||
const taskStore = await getOrCreateProjectStore(projectId);
|
||||
return await createRoadmapDedupSourceForTaskStore(taskStore, deps)(keywords);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { ApiError } from "../api-error.js";
|
||||
import { queryKnowledgePagesAsync } from "../knowledge-index.js";
|
||||
import { requireAsyncLayer } from "../require-async-layer.js";
|
||||
import { runReportPipeline, type ReportInput, type StructuredReport } from "../report-pipeline.js";
|
||||
import { createRoadmapDedupSourceForTaskStore } from "../report-roadmap-source.js";
|
||||
import { scrubReportPayload } from "../report-scrub.js";
|
||||
import { selfCheckHelp } from "../report-help-selfcheck.js";
|
||||
import type { Request, Response } from "express";
|
||||
@@ -133,6 +134,7 @@ export const registerReportRoutes: ApiRouteRegistrar = ({ router, getScopedStore
|
||||
globalSettings: scopes.global,
|
||||
scrubContext: { rootDir: store.getRootDir(), projectName: store.getRootDir().split(/[\\/]/).pop() },
|
||||
gatherContext: (reportInput) => gatherReportContext(store, reportInput, scopes.project as Record<string, unknown>),
|
||||
roadmapSource: scopes.project.reportRoadmapDedup ? createRoadmapDedupSourceForTaskStore(store) : undefined,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
@@ -170,6 +172,7 @@ export const registerReportRoutes: ApiRouteRegistrar = ({ router, getScopedStore
|
||||
globalSettings: scopes.global,
|
||||
scrubContext: { rootDir: store.getRootDir(), projectName: store.getRootDir().split(/[\\/]/).pop() },
|
||||
gatherContext: (reportInput) => gatherReportContext(store, reportInput, scopes.project as Record<string, unknown>),
|
||||
roadmapSource: scopes.project.reportRoadmapDedup ? createRoadmapDedupSourceForTaskStore(store) : undefined,
|
||||
}, { file: true, endorseIssueNumber, endorseDiscussionId, report: untrusted });
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
|
||||
@@ -5972,7 +5972,9 @@
|
||||
"aiUndoTaskWorkflowInherit": "Inherit project default workflow",
|
||||
"aiUndoTaskWorkflowHelp": "Workflow assigned to AI-undo (revert) tasks, which reverse already-shipped code and warrant stricter review. Choose \"Inherit project default workflow\" to leave them on the project default. Default: review-heavy.",
|
||||
"reportModeHelp": "How in-app bug/feedback/idea/help reports are filed. Default: draft-review (operator reviews a draft before filing).",
|
||||
"reportModeByActionHelp": "Optional per-action override of the project report mode for bug, feedback, idea, or help. No default — unset actions inherit reportMode."
|
||||
"reportModeByActionHelp": "Optional per-action override of the project report mode for bug, feedback, idea, or help. No default — unset actions inherit reportMode.",
|
||||
"reportRoadmapDedup": "Check roadmap before filing reports",
|
||||
"reportRoadmapDedupHelp": "When enabled, matching roadmap features are shown inline instead of filing another GitHub Issue or Discussion. Default: off."
|
||||
},
|
||||
"globalGeneral": {
|
||||
"andShowsUpdateNoticesInTheCLIAnd": " and shows update notices in the CLI and dashboard. Cadence is governed by the frequency below. Default: enabled. ",
|
||||
@@ -9135,5 +9137,11 @@
|
||||
"toolResult": "Tool result",
|
||||
"you": "You",
|
||||
"youMessage": "You message"
|
||||
},
|
||||
"report": {
|
||||
"roadmapMatch": {
|
||||
"title": "Already on the roadmap",
|
||||
"message": "This report matches a feature that is already planned."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5948,7 +5948,9 @@
|
||||
"reportModeHelp": "Cómo se archivan los informes in-app (error, feedback, idea, ayuda). Predeterminado: draft-review (el operador revisa un borrador antes de archivar).",
|
||||
"reportModeByActionHelp": "Anulación opcional por acción del modo de informe del proyecto (error, feedback, idea o ayuda). Sin valor predeterminado: las acciones no definidas heredan reportMode.",
|
||||
"reviewArtifacts": "Artefactos de revisión",
|
||||
"reviewArtifactsHint": "Controla si las tareas futuras elegibles generan entregables de revisión. Los informes de usuario y la captura de actividad se mantienen independientes. Predeterminado: desactivado."
|
||||
"reviewArtifactsHint": "Controla si las tareas futuras elegibles generan entregables de revisión. Los informes de usuario y la captura de actividad se mantienen independientes. Predeterminado: desactivado.",
|
||||
"reportRoadmapDedup": "Comprobar la hoja de ruta antes de enviar informes",
|
||||
"reportRoadmapDedupHelp": "Cuando está activado, las funciones coincidentes se muestran en línea en lugar de crear otro problema o discusión de GitHub. Predeterminado: desactivado."
|
||||
},
|
||||
"globalGeneral": {
|
||||
"andShowsUpdateNoticesInTheCLIAnd": "",
|
||||
@@ -9135,5 +9137,11 @@
|
||||
"startCta": "Start engine",
|
||||
"starting": "Starting…",
|
||||
"title": "Project engine is not connected"
|
||||
},
|
||||
"report": {
|
||||
"roadmapMatch": {
|
||||
"title": "Ya está en la hoja de ruta",
|
||||
"message": "Este informe coincide con una función que ya está planificada."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5948,7 +5948,9 @@
|
||||
"reportModeHelp": "Comment les rapports in-app (bug, feedback, idée, aide) sont déposés. Par défaut : draft-review (l'opérateur revoit un brouillon avant dépôt).",
|
||||
"reportModeByActionHelp": "Surcharge optionnelle par action du mode de rapport du projet (bug, feedback, idée ou aide). Aucune valeur par défaut — les actions non définies héritent de reportMode.",
|
||||
"reviewArtifacts": "Artefacts de revue",
|
||||
"reviewArtifactsHint": "Contrôle si les futures tâches éligibles génèrent des livrables de revue. Les rapports utilisateur et la capture d'activité restent séparés. Par défaut : désactivé."
|
||||
"reviewArtifactsHint": "Contrôle si les futures tâches éligibles génèrent des livrables de revue. Les rapports utilisateur et la capture d'activité restent séparés. Par défaut : désactivé.",
|
||||
"reportRoadmapDedup": "Vérifier la feuille de route avant d’envoyer les rapports",
|
||||
"reportRoadmapDedupHelp": "Lorsqu’elle est activée, les fonctionnalités correspondantes sont affichées en ligne au lieu de créer un autre problème ou une autre discussion GitHub. Par défaut : désactivé."
|
||||
},
|
||||
"globalGeneral": {
|
||||
"andShowsUpdateNoticesInTheCLIAnd": "",
|
||||
@@ -9135,5 +9137,11 @@
|
||||
"startCta": "Start engine",
|
||||
"starting": "Starting…",
|
||||
"title": "Project engine is not connected"
|
||||
},
|
||||
"report": {
|
||||
"roadmapMatch": {
|
||||
"title": "Déjà sur la feuille de route",
|
||||
"message": "Ce signalement correspond à une fonctionnalité déjà planifiée."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5948,7 +5948,9 @@
|
||||
"reportModeHelp": "인앱 버그/피드백/아이디어/도움말 리포트 제출 방식. 기본값: draft-review(제출 전 운영자가 초안 검토).",
|
||||
"reportModeByActionHelp": "버그·피드백·아이디어·도움말 등 작업별 프로젝트 리포트 모드 선택 재정의. 기본값 없음 — 미설정 작업은 reportMode를 상속합니다.",
|
||||
"reviewArtifacts": "리뷰 산출물",
|
||||
"reviewArtifactsHint": "적격한 향후 작업이 리뷰 산출물을 생성할지 제어합니다. 사용자 보고와 활동 캡처는 별도로 유지됩니다. 기본값: 꺼짐."
|
||||
"reviewArtifactsHint": "적격한 향후 작업이 리뷰 산출물을 생성할지 제어합니다. 사용자 보고와 활동 캡처는 별도로 유지됩니다. 기본값: 꺼짐.",
|
||||
"reportRoadmapDedup": "보고서를 제출하기 전에 로드맵 확인",
|
||||
"reportRoadmapDedupHelp": "활성화하면 일치하는 로드맵 기능이 다른 GitHub 이슈나 토론을 만들지 않고 인라인으로 표시됩니다. 기본값: 꺼짐."
|
||||
},
|
||||
"globalGeneral": {
|
||||
"andShowsUpdateNoticesInTheCLIAnd": "",
|
||||
@@ -9135,5 +9137,11 @@
|
||||
"startCta": "Start engine",
|
||||
"starting": "Starting…",
|
||||
"title": "Project engine is not connected"
|
||||
},
|
||||
"report": {
|
||||
"roadmapMatch": {
|
||||
"title": "이미 로드맵에 있습니다",
|
||||
"message": "이 보고서는 이미 계획된 기능과 일치합니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5948,7 +5948,9 @@
|
||||
"reportModeHelp": "应用内缺陷/反馈/想法/帮助报告的提交方式。默认:draft-review(提交前由操作者审阅草稿)。",
|
||||
"reportModeByActionHelp": "可选的按操作覆盖项目报告模式(缺陷、反馈、想法或帮助)。无默认值 — 未设置的操作继承 reportMode。",
|
||||
"reviewArtifacts": "评审产物",
|
||||
"reviewArtifactsHint": "控制符合条件的未来任务是否生成评审交付物。面向用户的报告与活动捕获保持独立。默认:关闭。"
|
||||
"reviewArtifactsHint": "控制符合条件的未来任务是否生成评审交付物。面向用户的报告与活动捕获保持独立。默认:关闭。",
|
||||
"reportRoadmapDedup": "提交报告前检查路线图",
|
||||
"reportRoadmapDedupHelp": "启用后,匹配的路线图功能会以内联方式显示,而不会创建另一个 GitHub Issue 或讨论。默认:关闭。"
|
||||
},
|
||||
"globalGeneral": {
|
||||
"andShowsUpdateNoticesInTheCLIAnd": "",
|
||||
@@ -9135,5 +9137,11 @@
|
||||
"startCta": "Start engine",
|
||||
"starting": "Starting…",
|
||||
"title": "Project engine is not connected"
|
||||
},
|
||||
"report": {
|
||||
"roadmapMatch": {
|
||||
"title": "已在路线图中",
|
||||
"message": "此报告与已经规划的功能相匹配。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5948,7 +5948,9 @@
|
||||
"reportModeHelp": "應用內缺陷/回饋/想法/說明報告的提交方式。預設:draft-review(提交前由操作者審閱草稿)。",
|
||||
"reportModeByActionHelp": "可選的依操作覆寫專案報告模式(缺陷、回饋、想法或說明)。無預設值 — 未設定的操作繼承 reportMode。",
|
||||
"reviewArtifacts": "審查產物",
|
||||
"reviewArtifactsHint": "控制符合條件的未來任務是否產生審查交付物。面向使用者的報告與活動擷取保持獨立。預設:關閉。"
|
||||
"reviewArtifactsHint": "控制符合條件的未來任務是否產生審查交付物。面向使用者的報告與活動擷取保持獨立。預設:關閉。",
|
||||
"reportRoadmapDedup": "提交報告前檢查路線圖",
|
||||
"reportRoadmapDedupHelp": "啟用後,符合的路線圖功能會內嵌顯示,而不會建立另一個 GitHub Issue 或討論。預設:關閉。"
|
||||
},
|
||||
"globalGeneral": {
|
||||
"andShowsUpdateNoticesInTheCLIAnd": "",
|
||||
@@ -9135,5 +9137,11 @@
|
||||
"startCta": "Start engine",
|
||||
"starting": "Starting…",
|
||||
"title": "Project engine is not connected"
|
||||
},
|
||||
"report": {
|
||||
"roadmapMatch": {
|
||||
"title": "已在路線圖中",
|
||||
"message": "此報告與已規劃的功能相符。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AsyncRoadmapStore } from "../store/async-roadmap-store.js";
|
||||
import { createRoadmapStoreForTaskStore } from "../server/index.js";
|
||||
|
||||
describe("createRoadmapStoreForTaskStore", () => {
|
||||
it("prefers a native roadmap store bridge", () => {
|
||||
const roadmapStore = { listRoadmaps: vi.fn() };
|
||||
const taskStore = { getRoadmapStore: vi.fn(() => roadmapStore), getAsyncLayer: vi.fn() } as any;
|
||||
expect(createRoadmapStoreForTaskStore(taskStore)).toBe(roadmapStore);
|
||||
expect(taskStore.getAsyncLayer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("constructs and caches an async store from the task store layer", () => {
|
||||
const taskStore = { getAsyncLayer: vi.fn(() => ({ projectId: "project-1" })) } as any;
|
||||
const first = createRoadmapStoreForTaskStore(taskStore);
|
||||
const second = createRoadmapStoreForTaskStore(taskStore);
|
||||
expect(first).toBeInstanceOf(AsyncRoadmapStore);
|
||||
expect(second).toBe(first);
|
||||
expect(taskStore.getAsyncLayer).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("throws when neither a bridge nor async layer is available", () => {
|
||||
expect(() => createRoadmapStoreForTaskStore({ getAsyncLayer: () => null } as any)).toThrow("PostgreSQL AsyncDataLayer");
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,7 @@ interface RouteRequest {
|
||||
query?: Record<string, string | string[] | undefined>;
|
||||
body?: unknown;
|
||||
}
|
||||
import { RoadmapStore } from "../store/roadmap-store.js";
|
||||
import { AsyncRoadmapStore } from "../store/async-roadmap-store.js";
|
||||
import { createRoadmapStoreForTaskStore, type RoadmapRuntimeStore } from "../server/index.js";
|
||||
import {
|
||||
generateFeatureSuggestions,
|
||||
generateMilestoneSuggestions,
|
||||
@@ -18,9 +17,6 @@ import {
|
||||
ValidationError as SuggestionValidationError,
|
||||
} from "./roadmap-suggestions.js";
|
||||
|
||||
type RoadmapRuntimeStore = RoadmapStore | AsyncRoadmapStore;
|
||||
const roadmapStoreCache = new WeakMap<object, RoadmapRuntimeStore>();
|
||||
|
||||
function resolveProjectId(req: RouteRequest): string | undefined {
|
||||
const queryProjectId = paramValue(req.query?.projectId);
|
||||
if (queryProjectId.trim()) return queryProjectId.trim();
|
||||
@@ -37,23 +33,7 @@ async function getRoadmapStore(req: RouteRequest, ctx: PluginContext): Promise<R
|
||||
? await ctx.resolveProjectTaskStore(projectId)
|
||||
: ctx.taskStore;
|
||||
|
||||
const taskStoreWithRoadmaps = scopedTaskStore as PluginContext["taskStore"] & {
|
||||
getRoadmapStore?: () => RoadmapStore;
|
||||
};
|
||||
|
||||
if (typeof taskStoreWithRoadmaps.getRoadmapStore === "function") {
|
||||
return taskStoreWithRoadmaps.getRoadmapStore();
|
||||
}
|
||||
|
||||
const key = scopedTaskStore as object;
|
||||
const cached = roadmapStoreCache.get(key);
|
||||
if (cached) return cached;
|
||||
const layer = scopedTaskStore.getAsyncLayer();
|
||||
if (!layer) throw new Error("Roadmap plugin routes require the project PostgreSQL AsyncDataLayer");
|
||||
/* FNXC:PostgresSatelliteCutover 2026-07-14-17:30: Bundled roadmap routes use only their project-scoped PostgreSQL store. */
|
||||
const store = new AsyncRoadmapStore(layer);
|
||||
roadmapStoreCache.set(key, store);
|
||||
return store;
|
||||
return createRoadmapStoreForTaskStore(scopedTaskStore);
|
||||
}
|
||||
|
||||
function asRequest(req: unknown): RouteRequest {
|
||||
|
||||
@@ -1 +1,35 @@
|
||||
import type { AsyncDataLayer } from "@fusion/core";
|
||||
import { RoadmapStore } from "../store/roadmap-store.js";
|
||||
import { AsyncRoadmapStore } from "../store/async-roadmap-store.js";
|
||||
|
||||
export { createRoadmapPluginRoutes } from "../routes/roadmap-routes.js";
|
||||
|
||||
export type RoadmapRuntimeStore = RoadmapStore | AsyncRoadmapStore;
|
||||
|
||||
export interface RoadmapTaskStoreAccess {
|
||||
getRoadmapStore?: () => RoadmapRuntimeStore;
|
||||
getAsyncLayer(): AsyncDataLayer | null;
|
||||
}
|
||||
|
||||
const roadmapStoreCache = new WeakMap<object, AsyncRoadmapStore>();
|
||||
|
||||
/**
|
||||
* FNXC:ReportPipeline 2026-07-18-12:15:
|
||||
* Cross-package consumers obtain project-scoped roadmap reads through this
|
||||
* supported seam. It preserves the native bridge when supplied and otherwise
|
||||
* caches the AsyncRoadmapStore per TaskStore; callers may degrade when no
|
||||
* PostgreSQL layer is available rather than reaching into plugin internals.
|
||||
*/
|
||||
export function createRoadmapStoreForTaskStore(taskStore: RoadmapTaskStoreAccess): RoadmapRuntimeStore {
|
||||
if (typeof taskStore.getRoadmapStore === "function") return taskStore.getRoadmapStore();
|
||||
|
||||
const key = taskStore as object;
|
||||
const cached = roadmapStoreCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const layer = taskStore.getAsyncLayer();
|
||||
if (!layer) throw new Error("Roadmap plugin routes require the project PostgreSQL AsyncDataLayer");
|
||||
const store = new AsyncRoadmapStore(layer);
|
||||
roadmapStoreCache.set(key, store);
|
||||
return store;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user