FN-8277: add in-app agentic reporting
Add guided in-app reporting that safely files or endorses GitHub issues and discussions. - Add report actions, modal UI, API routes, and configurable per-action filing modes. - Scrub report payloads, self-check Help requests, and deduplicate before filing or endorsing. - Extend GitHub discussion support and cover the report pipeline with tests and documentation. Files changed: .changeset/fn-8277-agentic-reports.md | 7 + docs/dashboard-guide.md | 8 + docs/settings-reference.md | 2 + .../core/src/__tests__/settings-parity.test.ts | 9 + packages/core/src/index.gate.ts | 2 +- packages/core/src/index.ts | 2 +- packages/core/src/settings-schema.ts | 2 + packages/core/src/types.ts | 10 + packages/dashboard/app/api.ts | 1 + packages/dashboard/app/api/report.ts | 11 + packages/dashboard/app/components/Header.tsx | 30 +++ .../dashboard/app/components/ReportActionMenu.css | 6 + .../dashboard/app/components/ReportActionMenu.tsx | 22 ++ packages/dashboard/app/components/ReportModal.css | 6 + packages/dashboard/app/components/ReportModal.tsx | 81 ++++++++ .../app/components/__tests__/Header.test.tsx | 7 +- .../components/__tests__/ReportActionMenu.test.tsx | 18 ++ .../app/components/__tests__/ReportModal.test.tsx | 94 +++++++++ .../__tests__/SettingsModal.general.test.tsx | 15 ++ .../settings/sections/GeneralSection.tsx | 31 ++- .../src/__tests__/report-pipeline.test.ts | 127 ++++++++++++ .../dashboard/src/__tests__/report-routes.test.ts | 51 +++++ .../dashboard/src/__tests__/report-scrub.test.ts | 47 +++++ packages/dashboard/src/github.ts | 151 +++++++++++++- packages/dashboard/src/index.ts | 3 + packages/dashboard/src/report-help-selfcheck.ts | 13 ++ packages/dashboard/src/report-pipeline.ts | 225 +++++++++++++++++++++ packages/dashboard/src/report-scrub.ts | 66 ++++++ packages/dashboard/src/routes.ts | 2 + .../dashboard/src/routes/register-report-routes.ts | 118 +++++++++++ 30 files changed, 1156 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-8277 Fusion-Task-Lineage: d50dc1da-7534-4324-ab05-1f8a0ad77c6a Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8277-agentic-reports.md
Normal file
7
.changeset/fn-8277-agentic-reports.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add guided in-app Bug, Feedback, Idea, and Help reporting.
|
||||
category: feature
|
||||
dev: Reports are privacy-scrubbed, deduplicated against open GitHub issues, and can be reviewed or filed automatically.
|
||||
@@ -2078,3 +2078,11 @@ When enabled, a proactive question holds the planner at `awaiting_input`, sends
|
||||
### Mobile footer quick actions
|
||||
|
||||
In **Settings → General**, choose up to six Mobile footer quick actions from the add dropdown. The selected list is ordered: use its earlier/later controls or remove actions to adjust the footer, and see each change immediately while Settings remains open. Eligible sidebar and More-sheet destinations can be promoted; unselected available destinations stay reachable in More. Feature-gated views remain hidden until enabled, and the trailing More tab is always present.
|
||||
|
||||
## In-app reports
|
||||
|
||||
The Header **Report** menu is available on desktop and mobile and offers **Bug**, **Feedback**, **Idea**, and **Help**. Each action begins with a short, guided prompt rather than a raw GitHub issue form.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -651,6 +651,8 @@ Default notes:
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. 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). |
|
||||
|
||||
@@ -163,6 +163,15 @@ describe("settings key parity", () => {
|
||||
expect(GLOBAL_SETTINGS_KEYS).not.toContain("allowAbsoluteFileBrowserPaths");
|
||||
});
|
||||
|
||||
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(isProjectSettingsKey("reportMode")).toBe(true);
|
||||
expect(isProjectSettingsKey("reportModeByAction")).toBe(true);
|
||||
expect(isGlobalSettingsKey("reportMode")).toBe(false);
|
||||
expect(isGlobalSettingsKey("reportModeByAction")).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults autoClaimCandidatesInPrompt to 5 and keeps it project-scoped", () => {
|
||||
expect(DEFAULT_PROJECT_SETTINGS.autoClaimCandidatesInPrompt).toBe(5);
|
||||
expect(isProjectSettingsKey("autoClaimCandidatesInPrompt")).toBe(true);
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -634,6 +634,8 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
sessionAdvisorEnabledByDefault: false,
|
||||
githubLinkImportedIssuesToTracking: false,
|
||||
githubTrackingDefaultRepo: undefined,
|
||||
reportMode: "draft-review" as const,
|
||||
reportModeByAction: undefined,
|
||||
gitlabEnabled: undefined,
|
||||
gitlabInstanceUrl: undefined,
|
||||
gitlabApiBaseUrl: undefined,
|
||||
|
||||
@@ -3089,6 +3089,9 @@ export type SecretsEnvConfig = SecretsEnvSettings;
|
||||
* Runtime state fields (globalPause, enginePaused) also live here because
|
||||
* different projects may need independent pause control.
|
||||
*/
|
||||
export type ReportMode = "draft-review" | "auto-file";
|
||||
export type ReportActionType = "bug" | "feedback" | "idea" | "help";
|
||||
|
||||
export interface ProjectSettings {
|
||||
/** Hard stop: when true, all automated agent activity is **immediately**
|
||||
* terminated — active triage, execution, and merge agent sessions are
|
||||
@@ -3995,6 +3998,13 @@ export interface ProjectSettings {
|
||||
/** Project default GitHub tracking repo in `owner/repo` format (FN-3868).
|
||||
* Falls back to global githubTrackingDefaultRepo when unset. */
|
||||
githubTrackingDefaultRepo?: string;
|
||||
/**
|
||||
* FNXC:ReportPipeline 2026-07-16-12:00:
|
||||
* In-app reports default to review before egress; operators can opt into
|
||||
* direct filing globally or for one of the four guided report actions.
|
||||
*/
|
||||
reportMode?: ReportMode;
|
||||
reportModeByAction?: Partial<Record<ReportActionType, ReportMode>>;
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@@ -6,3 +6,4 @@
|
||||
*/
|
||||
export * from "./api/legacy";
|
||||
export * from "./api-node";
|
||||
export * from "./api/report";
|
||||
|
||||
11
packages/dashboard/app/api/report.ts
Normal file
11
packages/dashboard/app/api/report.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { ReportActionType } from "@fusion/core";
|
||||
|
||||
async function post(path: string, body: unknown) {
|
||||
const response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
if (!response.ok) throw new Error((await response.json().catch(() => ({ error: response.statusText }))).error ?? response.statusText);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function reportDraft(input: { actionType: ReportActionType; userPrompt: string; contextRefs?: { taskId?: string; agentId?: string } }) { return post("/api/report/draft", input); }
|
||||
export function reportFile(input: { actionType: ReportActionType; report: unknown; endorseIssueNumber?: number; endorseDiscussionId?: string }) { return post("/api/report/file", input); }
|
||||
export function reportHelp(question: string) { return post("/api/report/help", { question }); }
|
||||
@@ -17,11 +17,28 @@ import type { PluginDashboardViewEntry } from "../api";
|
||||
import { buildPluginTaskViewId, isPluginViewId } from "../plugins/pluginViewRegistry";
|
||||
import { getPluginNavIcon } from "./pluginNavIcon";
|
||||
import type { ShellHostContext } from "../shell-host";
|
||||
import type { ReportActionType } from "@fusion/core";
|
||||
import { ReportActionMenu } from "./ReportActionMenu";
|
||||
import { ReportModal } from "./ReportModal";
|
||||
|
||||
export { useViewportMode };
|
||||
|
||||
const NO_BRANCH_FILTER_VALUE = "__fusion:no-branch__";
|
||||
|
||||
/**
|
||||
* FNXC:ReportPipeline 2026-07-16-19:30:
|
||||
* Reports opened from task detail routes must gather the active task's logs,
|
||||
* agent, and errors. Task navigation uses hash routes, while legacy links use
|
||||
* query parameters, so resolve both at modal-open render time.
|
||||
*/
|
||||
export function resolveReportContextRefs(location: Pick<Location, "hash" | "search">): { taskId?: string; agentId?: string } | undefined {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const hashTaskMatch = location.hash.match(/^#\/tasks\/([^/?#]+)/);
|
||||
const taskId = hashTaskMatch?.[1] ? decodeURIComponent(hashTaskMatch[1]) : params.get("taskId") ?? undefined;
|
||||
const agentId = params.get("agentId") ?? undefined;
|
||||
return taskId || agentId ? { taskId, agentId } : undefined;
|
||||
}
|
||||
|
||||
// Status icon config for project selector dropdown
|
||||
const PROJECT_STATUS_CONFIG: Record<ProjectStatus, { color: string }> = {
|
||||
active: { color: "var(--success)" },
|
||||
@@ -172,6 +189,10 @@ export function Header({
|
||||
const isTablet = mode === "tablet";
|
||||
const isCompact = isMobile || isTablet;
|
||||
const hideFullNav = isMobile && mobileNavEnabled;
|
||||
const [reportAction, setReportAction] = useState<ReportActionType | null>(null);
|
||||
// Resolve on every render so opening a report after in-app hash navigation
|
||||
// uses the current task rather than the Header's initial route.
|
||||
const reportContextRefs = typeof window === "undefined" ? undefined : resolveReportContextRefs(window.location);
|
||||
/*
|
||||
FNXC:Navigation 2026-06-19-00:00:
|
||||
When experimental left sidebar navigation is active on tablet/desktop, Header must suppress its view-toggle and More-views trigger so there is one canonical non-mobile navigation surface and no orphaned chevron remains.
|
||||
@@ -963,6 +984,14 @@ export function Header({
|
||||
FNXC:Navigation 2026-06-22-00:00:
|
||||
When the left sidebar is active it owns Workflows as a main-content destination, so the Header drops its duplicate desktop Workflow button. The flag-off desktop layout keeps the Header button; mobile/tablet keep the overflow entry.
|
||||
*/}
|
||||
{/*
|
||||
FNXC:ReportPipeline 2026-07-16-21:30:
|
||||
Bug, Feedback, Idea, and Help must remain reachable at every responsive
|
||||
breakpoint. Compact navigation can hide the overflow trigger entirely,
|
||||
so the report menu lives directly in the header action row instead.
|
||||
*/}
|
||||
<ReportActionMenu onSelect={setReportAction} />
|
||||
|
||||
{!isCompact && !leftSidebarNavActive && onOpenWorkflowEditor && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
@@ -1294,6 +1323,7 @@ export function Header({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{reportAction && <ReportModal actionType={reportAction} contextRefs={reportContextRefs} onClose={() => setReportAction(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
6
packages/dashboard/app/components/ReportActionMenu.css
Normal file
6
packages/dashboard/app/components/ReportActionMenu.css
Normal file
@@ -0,0 +1,6 @@
|
||||
.report-action-menu { position: relative; }
|
||||
.report-action-menu__list { position: absolute; z-index: var(--z-dropdown); right: 0; display: grid; gap: var(--space-1); min-inline-size: var(--space-48); padding: var(--space-2); border-radius: var(--radius-md); background: var(--bg-elevated); box-shadow: var(--shadow-lg); }
|
||||
.report-action-menu__item { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-3); border: 0; border-radius: var(--radius-sm); color: var(--text-primary); background: transparent; text-align: left; }
|
||||
.report-action-menu__item:hover, .report-action-menu__item:focus-visible { background: var(--bg-hover); }
|
||||
.report-action-menu__item svg { inline-size: 1em; block-size: 1em; }
|
||||
@media (max-width: 768px) { .report-action-menu__list { right: auto; left: 0; } }
|
||||
22
packages/dashboard/app/components/ReportActionMenu.tsx
Normal file
22
packages/dashboard/app/components/ReportActionMenu.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useState } from "react";
|
||||
import { Bug, Lightbulb, LifeBuoy, MessageSquare } from "lucide-react";
|
||||
import type { ReportActionType } from "@fusion/core";
|
||||
import "./ReportActionMenu.css";
|
||||
|
||||
const actions: Array<{ type: ReportActionType; label: string; Icon: typeof Bug }> = [
|
||||
{ type: "bug", label: "Report bug", Icon: Bug },
|
||||
{ type: "feedback", label: "Send feedback", Icon: MessageSquare },
|
||||
{ type: "idea", label: "Share idea", Icon: Lightbulb },
|
||||
{ type: "help", label: "Get help", Icon: LifeBuoy },
|
||||
];
|
||||
|
||||
/** Four guided entry points share the same report pipeline rather than issue textboxes. */
|
||||
export function ReportActionMenu({ onSelect }: { onSelect: (action: ReportActionType) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return <div className="report-action-menu">
|
||||
<button className="btn btn-secondary" type="button" aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen((value) => !value)}>Report</button>
|
||||
{open && <div className="report-action-menu__list" role="menu">
|
||||
{actions.map(({ type, label, Icon }) => <button className="report-action-menu__item" type="button" role="menuitem" key={type} onClick={() => { setOpen(false); onSelect(type); }}><Icon aria-hidden="true" />{label}</button>)}
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
6
packages/dashboard/app/components/ReportModal.css
Normal file
6
packages/dashboard/app/components/ReportModal.css
Normal file
@@ -0,0 +1,6 @@
|
||||
.report-modal-backdrop { position: fixed; inset: 0; z-index: var(--z-modal); display: grid; place-items: center; padding: var(--space-4); background: color-mix(in srgb, var(--bg-base) 70%, transparent); }
|
||||
.report-modal { position: relative; display: grid; gap: var(--space-3); inline-size: min(var(--space-96), 100%); padding: var(--space-5); }
|
||||
.report-modal textarea { min-block-size: var(--space-32); resize: vertical; }
|
||||
.report-modal__close { position: absolute; inset-block-start: var(--space-2); inset-inline-end: var(--space-2); }
|
||||
.report-modal__error { color: var(--color-error); }
|
||||
@media (max-width: 768px) { .report-modal { inline-size: 100%; padding: var(--space-4); } }
|
||||
81
packages/dashboard/app/components/ReportModal.tsx
Normal file
81
packages/dashboard/app/components/ReportModal.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useState } from "react";
|
||||
import type { ReportActionType } from "@fusion/core";
|
||||
import { reportDraft, reportFile, reportHelp } from "../api";
|
||||
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 };
|
||||
|
||||
/**
|
||||
* FNXC:ReportPipeline 2026-07-16-12:00:
|
||||
* The four actions start with a guided user prompt, not a raw issue form. The
|
||||
* 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 [prompt, setPrompt] = useState("");
|
||||
const [result, setResult] = useState<ModalResult>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const submit = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
if (actionType === "help") {
|
||||
const help = await reportHelp(prompt);
|
||||
if (help.answered) { setResult({ kind: "help", answer: help.answer }); return; }
|
||||
}
|
||||
setResult(await reportDraft({ actionType, userPrompt: prompt, contextRefs }));
|
||||
} catch {
|
||||
setError("We could not prepare your report. Check your connection and try again.");
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
const file = async (endorseIssueNumber?: number, endorseDiscussionId?: string) => {
|
||||
if (!result?.report) return;
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
setResult(await reportFile({ actionType, report: result.report, endorseIssueNumber, endorseDiscussionId }));
|
||||
} catch {
|
||||
setError("We could not send your report. Your draft is still here; try again.");
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
return <div className="report-modal-backdrop" role="presentation"><section className="card report-modal" role="dialog" aria-modal="true" aria-label={`${actionType} report`}>
|
||||
<button className="btn-icon report-modal__close" type="button" aria-label="Close report" onClick={onClose}>×</button>
|
||||
{error && <p className="report-modal__error" role="alert">{error}</p>}
|
||||
{!result && <><h2>{actionType[0].toUpperCase() + actionType.slice(1)}</h2><label htmlFor="report-prompt">{prompts[actionType]}</label><textarea id="report-prompt" className="input" value={prompt} onChange={(event) => setPrompt(event.target.value)} maxLength={4000} /><button className="btn btn-primary" type="button" disabled={!prompt.trim() || busy} onClick={() => void submit()}>{error ? "Retry" : "Continue"}</button></>}
|
||||
{result?.kind === "draft-ready" && result.report && <><h2>Review your report</h2><label htmlFor="report-review-prompt">Report summary</label><textarea id="report-review-prompt" className="input" value={result.report.userPrompt} onChange={(event) => {
|
||||
const userPrompt = event.target.value;
|
||||
// FNXC:ReportPipeline 2026-07-16-18:45:
|
||||
// Keep the original derivation marker when the guided prompt changes.
|
||||
// The server then rebuilds every derived section with newly gathered
|
||||
// context, rather than discarding the report's reproduction/environment
|
||||
// sections while the user is editing a draft.
|
||||
setResult({ ...result, report: { ...result.report!, userPrompt } });
|
||||
}} /><label htmlFor="report-review-body">Structured report</label><textarea id="report-review-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()}>File report</button></>}
|
||||
{result?.kind === "duplicate-found" && result.issue && result.report && <>
|
||||
{/*
|
||||
FNXC:ReportPipeline 2026-07-16-21:30:
|
||||
Draft-review applies to duplicate endorsements too. Show the scrubbed
|
||||
structured data point and require an explicit confirmation after edits
|
||||
instead of posting a dedupe match immediately.
|
||||
*/}
|
||||
<h2>Review data point for a similar open issue</h2>
|
||||
<a href={result.issue.url} target="_blank" rel="noreferrer">{result.issue.title}</a>
|
||||
<label htmlFor="report-duplicate-prompt">Report summary</label>
|
||||
<textarea id="report-duplicate-prompt" className="input" value={result.report.userPrompt} onChange={(event) => {
|
||||
const userPrompt = event.target.value;
|
||||
setResult({ ...result, report: { ...result.report!, userPrompt } });
|
||||
}} />
|
||||
<label htmlFor="report-duplicate-body">Structured data point</label>
|
||||
<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 === "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" && <>
|
||||
<p role="alert">{result.message}</p>
|
||||
<button className="btn btn-secondary" type="button" onClick={() => { setResult(undefined); setError(undefined); }}>Return to prompt</button>
|
||||
</>}
|
||||
</section></div>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { Header } from "../Header";
|
||||
import { Header, resolveReportContextRefs } from "../Header";
|
||||
|
||||
// Mock fetchScripts for overflow submenu
|
||||
const mockFetchScripts = vi.fn();
|
||||
@@ -49,6 +49,11 @@ function renderHeader(props = {}, tier: ViewportTier = "desktop") {
|
||||
}
|
||||
|
||||
describe("Header", () => {
|
||||
it("derives report context from task hash routes and legacy query parameters", () => {
|
||||
expect(resolveReportContextRefs({ hash: "#/tasks/FN-8277", search: "?agentId=agent-1" })).toEqual({ taskId: "FN-8277", agentId: "agent-1" });
|
||||
expect(resolveReportContextRefs({ hash: "", search: "?taskId=FN-8277" })).toEqual({ taskId: "FN-8277", agentId: undefined });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ReportActionMenu } from "../ReportActionMenu";
|
||||
|
||||
describe("ReportActionMenu", () => {
|
||||
it("exposes all four guided actions without empty controls", () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<ReportActionMenu onSelect={onSelect} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Report" }));
|
||||
const actions = ["Report bug", "Send feedback", "Share idea", "Get help"];
|
||||
for (const action of actions) expect(screen.getByRole("menuitem", { name: action })).toBeEnabled();
|
||||
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Get help" }));
|
||||
expect(onSelect).toHaveBeenCalledWith("help");
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ReportModal } from "../ReportModal";
|
||||
import * as api from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
reportDraft: vi.fn(),
|
||||
reportFile: vi.fn(),
|
||||
reportHelp: vi.fn(),
|
||||
}));
|
||||
|
||||
const reportDraft = vi.mocked(api.reportDraft);
|
||||
const reportFile = vi.mocked(api.reportFile);
|
||||
|
||||
describe("ReportModal", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("shows an actionable error and retry affordance when preparing a report fails", async () => {
|
||||
reportDraft.mockRejectedValueOnce(new Error("offline"));
|
||||
render(<ReportModal actionType="bug" onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("What went wrong?"), { target: { value: "It crashes" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("could not prepare");
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeEnabled();
|
||||
|
||||
reportDraft.mockResolvedValueOnce({ kind: "draft-ready", report: { userPrompt: "It crashes", body: "## Summary\nIt crashes", context: {} } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
await waitFor(() => expect(reportDraft).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("preserves an editable draft and makes filing failures retryable", async () => {
|
||||
reportDraft.mockResolvedValueOnce({ kind: "draft-ready", report: { userPrompt: "It crashes", body: "## Summary\nIt crashes", context: {} } });
|
||||
reportFile.mockRejectedValueOnce(new Error("offline"));
|
||||
render(<ReportModal actionType="bug" onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("What went wrong?"), { target: { value: "It crashes" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
await screen.findByText("Review your report");
|
||||
fireEvent.click(screen.getByRole("button", { name: "File report" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("draft is still here");
|
||||
expect(screen.getByRole("button", { name: "File report" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("shows an editable draft and requires confirmation before endorsing a duplicate", async () => {
|
||||
reportDraft.mockResolvedValueOnce({ kind: "duplicate-found", issue: { number: 22, url: "https://example.test/22", title: "Existing issue" }, report: { userPrompt: "It crashes", body: "## Summary\nIt crashes", context: {} } });
|
||||
reportFile.mockResolvedValueOnce({ kind: "endorsed", url: "https://example.test/comments/1" });
|
||||
render(<ReportModal actionType="bug" onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("What went wrong?"), { target: { value: "It crashes" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
await screen.findByText("Review data point for a similar open issue");
|
||||
expect(reportFile).not.toHaveBeenCalled();
|
||||
fireEvent.change(screen.getByLabelText("Report summary"), { target: { value: "It crashes after saving" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Confirm and add data point" }));
|
||||
|
||||
await waitFor(() => expect(reportFile).toHaveBeenCalledWith(expect.objectContaining({
|
||||
endorseIssueNumber: 22,
|
||||
report: expect.objectContaining({ userPrompt: "It crashes after saving" }),
|
||||
})));
|
||||
});
|
||||
|
||||
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()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("What would you like to share?"), { target: { value: "A thought" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
await screen.findByRole("alert");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Return to prompt" }));
|
||||
expect(screen.getByLabelText("What would you like to share?")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the original derivation marker when the review prompt is edited", async () => {
|
||||
reportDraft.mockResolvedValueOnce({ kind: "draft-ready", report: { userPrompt: "It crashes", sourcePrompt: "It crashes", body: "## Summary\nIt crashes\n\n## Environment\nCollected context", context: {} } });
|
||||
reportFile.mockResolvedValueOnce({ kind: "filed", url: "https://example.test/1" });
|
||||
render(<ReportModal actionType="bug" onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("What went wrong?"), { target: { value: "It crashes" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
await screen.findByText("Review your report");
|
||||
fireEvent.change(screen.getByLabelText("Report summary"), { target: { value: "It crashes after saving" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "File report" }));
|
||||
|
||||
await waitFor(() => expect(reportFile).toHaveBeenCalledOnce());
|
||||
expect(reportFile).toHaveBeenCalledWith(expect.objectContaining({ report: expect.objectContaining({
|
||||
userPrompt: "It crashes after saving",
|
||||
sourcePrompt: "It crashes",
|
||||
body: expect.stringContaining("## Environment"),
|
||||
}) }));
|
||||
});
|
||||
});
|
||||
@@ -1302,6 +1302,21 @@ describe("SettingsModal", () => {
|
||||
await expectSettingPersists(input);
|
||||
});
|
||||
|
||||
it("persists report default and per-action filing mode overrides", async () => {
|
||||
renderModal({ initialSection: "general" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
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.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" },
|
||||
}));
|
||||
});
|
||||
|
||||
it("saves ephemeral agent toggle in project settings payload", async () => {
|
||||
renderModal({ initialSection: "general" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { DEPRECATED_BUILTIN_WORKFLOW_IDS, isLocale, SUPPORTED_LOCALES, type WorkflowDefinition } from "@fusion/core";
|
||||
import { DEPRECATED_BUILTIN_WORKFLOW_IDS, isLocale, SUPPORTED_LOCALES, type ReportActionType, type WorkflowDefinition } from "@fusion/core";
|
||||
import { DEFAULT_MOBILE_NAV_PRIMARY_ITEMS, MAX_MOBILE_NAV_PRIMARY_ITEMS, MOBILE_NAV_SELECTABLE_ITEMS, MOBILE_NAV_SELECTABLE_ITEM_LABEL_KEYS } from "../../../../../core/src/mobile-nav-primary-items";
|
||||
import { SettingsFieldRow } from "../SettingsFieldRow";
|
||||
import { SettingsToggleRow } from "../SettingsToggleRow";
|
||||
@@ -273,6 +273,35 @@ export function GeneralSection({ form, setForm, projectId, addToast, prefixError
|
||||
<option value="changelog">{t("settings.general.requireChangelogUpdateExistingChangelog", "Require changelog update (existing changelog)")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
{/*
|
||||
FNXC:ReportPipeline 2026-07-16-19:15:
|
||||
A privacy-safe draft review remains the project default, while each of
|
||||
the four guided report actions can explicitly opt into direct filing.
|
||||
Persist overrides as one map so the pipeline resolves them consistently.
|
||||
*/}
|
||||
<label htmlFor="reportMode">In-app report mode</label>
|
||||
<select id="reportMode" value={form.reportMode ?? "draft-review"} onChange={(e) => setForm((f) => ({ ...f, reportMode: e.target.value as "draft-review" | "auto-file" }))}>
|
||||
<option value="draft-review">Review draft before filing</option>
|
||||
<option value="auto-file">File automatically</option>
|
||||
</select>
|
||||
{(["bug", "feedback", "idea", "help"] as const).map((action) => (
|
||||
<label key={action} htmlFor={`reportMode-${action}`}>
|
||||
{`${action[0].toUpperCase()}${action.slice(1)} report override`}
|
||||
<select id={`reportMode-${action}`} value={form.reportModeByAction?.[action] ?? ""} onChange={(e) => setForm((current) => {
|
||||
const reportModeByAction = { ...current.reportModeByAction };
|
||||
const selected = e.target.value as "" | "draft-review" | "auto-file";
|
||||
if (selected) reportModeByAction[action as ReportActionType] = selected;
|
||||
else delete reportModeByAction[action as ReportActionType];
|
||||
return { ...current, reportModeByAction: Object.keys(reportModeByAction).length ? reportModeByAction : undefined };
|
||||
})}>
|
||||
<option value="">Use project default</option>
|
||||
<option value="draft-review">Review draft before filing</option>
|
||||
<option value="auto-file">File automatically</option>
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{/*
|
||||
FNXC:SettingsGeneral 2026-07-15-17:35:
|
||||
`showQuickChatFAB` is written alongside `quickChatButtonMode` on every change: the legacy boolean
|
||||
|
||||
127
packages/dashboard/src/__tests__/report-pipeline.test.ts
Normal file
127
packages/dashboard/src/__tests__/report-pipeline.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { endorseDuplicate, runReportPipeline, type ReportPipelineDeps } from "../report-pipeline.js";
|
||||
|
||||
const settings = { reportMode: "draft-review" as const, githubTrackingDefaultRepo: "Runfusion/Fusion", githubAuthMode: "token", githubAuthToken: "test" };
|
||||
|
||||
function deps(overrides: Partial<ReportPipelineDeps> = {}): ReportPipelineDeps {
|
||||
return {
|
||||
projectSettings: settings,
|
||||
client: { createIssue: vi.fn().mockResolvedValue({ htmlUrl: "https://github.com/Runfusion/Fusion/issues/42" }), searchIssues: vi.fn().mockResolvedValue([]), addIssueReaction: vi.fn(), commentOnIssue: vi.fn().mockResolvedValue({ url: "https://github.com/Runfusion/Fusion/issues/1#issuecomment-1" }), searchDiscussions: vi.fn().mockResolvedValue([]), createDiscussion: vi.fn().mockResolvedValue({ htmlUrl: "https://github.com/Runfusion/Fusion/discussions/42" }), commentOnDiscussion: vi.fn().mockResolvedValue({ url: "https://github.com/Runfusion/Fusion/discussions/1#discussioncomment-1" }) },
|
||||
scrubContext: { projectName: "private-project", rootDir: "/Users/alice/private-project" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("report pipeline", () => {
|
||||
it.each(["bug", "feedback", "idea", "help"] as const)("structures the guided %s prompt without filing in review mode", async (actionType) => {
|
||||
const context = deps();
|
||||
const result = await runReportPipeline({ actionType, userPrompt: "The private-project view failed at /Users/alice/private-project/a.ts" }, context);
|
||||
expect(result.kind).toBe("draft-ready");
|
||||
if (result.kind === "draft-ready") {
|
||||
expect(result.report.userPrompt).toContain("[REDACTED]");
|
||||
expect(result.report.body).toContain("## Expected behavior");
|
||||
expect(result.report.body).toContain("## Actual behavior / request");
|
||||
expect(result.report.body).toContain("## Environment");
|
||||
}
|
||||
expect(context.client!.createIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only accepts open duplicate matches", async () => {
|
||||
const context = deps({ client: { createIssue: vi.fn(), addIssueReaction: vi.fn(), commentOnIssue: vi.fn(), searchIssues: vi.fn().mockResolvedValue([{ number: 1, title: "dashboard rendering failed issue", body: "dashboard rendering failed", html_url: "url", state: "closed" }]) } });
|
||||
const result = await runReportPipeline({ actionType: "bug", userPrompt: "dashboard rendering failed" }, context);
|
||||
expect(result.kind).toBe("draft-ready");
|
||||
});
|
||||
|
||||
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);
|
||||
expect(result.kind).toBe("filed");
|
||||
expect(context.client!.createIssue).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("files feedback as a repository discussion in auto-file mode", async () => {
|
||||
const context = deps({ projectSettings: { ...settings, reportMode: "auto-file" } });
|
||||
const result = await runReportPipeline({ actionType: "feedback", userPrompt: "The report flow needs clearer status" }, context);
|
||||
expect(result.kind).toBe("filed");
|
||||
expect(context.client!.createDiscussion).toHaveBeenCalledOnce();
|
||||
expect(context.client!.createIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("automatically endorses an open duplicate in auto-file mode", async () => {
|
||||
const client = {
|
||||
createIssue: vi.fn(),
|
||||
addIssueReaction: vi.fn(),
|
||||
commentOnIssue: vi.fn().mockResolvedValue({ url: "https://github.com/Runfusion/Fusion/issues/9#issuecomment-9" }),
|
||||
searchIssues: vi.fn().mockResolvedValue([{ number: 9, title: "dashboard rendering controls", body: "dashboard rendering controls", html_url: "https://github.com/Runfusion/Fusion/issues/9", state: "open" }]),
|
||||
};
|
||||
const result = await runReportPipeline({ actionType: "idea", userPrompt: "Add dashboard rendering controls" }, deps({ projectSettings: { ...settings, reportMode: "auto-file" }, client }));
|
||||
expect(result.kind).toBe("endorsed");
|
||||
expect(client.addIssueReaction).toHaveBeenCalledWith("Runfusion", "Fusion", 9, "+1");
|
||||
expect(client.commentOnIssue).toHaveBeenCalledOnce();
|
||||
expect(client.createIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("endorses a duplicate discussion with a reaction and one scrubbed data point", async () => {
|
||||
const client = {
|
||||
createIssue: vi.fn(),
|
||||
addIssueReaction: vi.fn(),
|
||||
commentOnIssue: vi.fn(),
|
||||
searchIssues: vi.fn(),
|
||||
searchDiscussions: vi.fn().mockResolvedValue([{ id: "D_kwDO1", number: 6, title: "report flow status feedback", body: "status feedback", url: "https://github.com/Runfusion/Fusion/discussions/6", state: "open" }]),
|
||||
addDiscussionReaction: vi.fn(),
|
||||
commentOnDiscussion: vi.fn().mockResolvedValue({ url: "https://github.com/Runfusion/Fusion/discussions/6#discussioncomment-1" }),
|
||||
};
|
||||
const result = await runReportPipeline({ actionType: "feedback", userPrompt: "report flow status feedback" }, deps({ projectSettings: { ...settings, reportMode: "auto-file" }, client }));
|
||||
expect(result.kind).toBe("endorsed");
|
||||
expect(client.addDiscussionReaction).toHaveBeenCalledWith("D_kwDO1");
|
||||
expect(client.commentOnDiscussion).toHaveBeenCalledOnce();
|
||||
expect(client.createIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves reviewed gathered context and session token when filing", async () => {
|
||||
const context = deps({ projectSettings: { ...settings, reportMode: "auto-file" } });
|
||||
const result = await runReportPipeline({ actionType: "bug", userPrompt: "reviewed prompt" }, context, {
|
||||
file: true,
|
||||
report: { userPrompt: "reviewed prompt", summary: "Reviewed summary", body: "Reviewed gathered context", context: { taskId: "FN-1", recentLogs: ["error"] }, sessionToken: "preserved-session" },
|
||||
});
|
||||
expect(result.kind).toBe("filed");
|
||||
if (result.kind === "filed") expect(result.report).toMatchObject({ body: "Reviewed gathered context", sessionToken: "preserved-session", context: { taskId: "FN-1" } });
|
||||
});
|
||||
|
||||
it("rebuilds derived fields when the reviewed prompt changes", async () => {
|
||||
const context = deps({ projectSettings: { ...settings, reportMode: "auto-file" } });
|
||||
const result = await runReportPipeline({ actionType: "bug", userPrompt: "original prompt" }, context, {
|
||||
file: true,
|
||||
report: { userPrompt: "edited prompt", sourcePrompt: "original prompt", summary: "[bug] original prompt", body: "## Summary\noriginal prompt", context: {} },
|
||||
});
|
||||
expect(result.kind).toBe("filed");
|
||||
expect(context.client!.createIssue).toHaveBeenCalledWith(expect.objectContaining({
|
||||
title: "[bug] edited prompt",
|
||||
body: expect.stringContaining("edited prompt"),
|
||||
}));
|
||||
});
|
||||
|
||||
it("only endorses an issue that is still an open matching duplicate", async () => {
|
||||
const client = {
|
||||
createIssue: vi.fn(),
|
||||
addIssueReaction: vi.fn(),
|
||||
commentOnIssue: vi.fn(),
|
||||
searchIssues: vi.fn().mockResolvedValue([{ number: 7, title: "unrelated report", body: "unrelated report", html_url: "url", state: "closed" }]),
|
||||
};
|
||||
const result = await runReportPipeline({ actionType: "bug", userPrompt: "dashboard rendering failed" }, deps({ client }), { file: true, endorseIssueNumber: 7 });
|
||||
expect(result).toMatchObject({ kind: "unavailable", reason: "duplicate_not_verified" });
|
||||
expect(client.commentOnIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("posts one scrubbed duplicate endorsement per report session", async () => {
|
||||
const context = deps();
|
||||
const report = { userPrompt: "private-project", summary: "dashboard rendering failed", body: "/Users/alice/private-project ghp_abcdefghijk1234567890", context: {}, sessionToken: "session-test" };
|
||||
const first = await endorseDuplicate({ owner: "Runfusion", repo: "Fusion", issueNumber: 1, report, client: context.client!, scrubContext: context.scrubContext });
|
||||
const second = await endorseDuplicate({ owner: "Runfusion", repo: "Fusion", issueNumber: 1, report, client: context.client!, scrubContext: context.scrubContext });
|
||||
expect(first.url).toContain("issuecomment");
|
||||
expect(second).toEqual(first);
|
||||
expect(context.client!.addIssueReaction).toHaveBeenCalledOnce();
|
||||
expect(context.client!.commentOnIssue).toHaveBeenCalledOnce();
|
||||
expect(String((context.client!.commentOnIssue as ReturnType<typeof vi.fn>).mock.calls[0][3])).not.toContain("private-project");
|
||||
});
|
||||
});
|
||||
51
packages/dashboard/src/__tests__/report-routes.test.ts
Normal file
51
packages/dashboard/src/__tests__/report-routes.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Router } from "express";
|
||||
|
||||
vi.mock("../knowledge-index.js", () => ({
|
||||
queryKnowledgePagesAsync: vi.fn(),
|
||||
}));
|
||||
vi.mock("../require-async-layer.js", () => ({
|
||||
requireAsyncLayer: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("../report-pipeline.js", () => ({
|
||||
runReportPipeline: vi.fn(),
|
||||
}));
|
||||
|
||||
import { queryKnowledgePagesAsync } from "../knowledge-index.js";
|
||||
import { runReportPipeline } from "../report-pipeline.js";
|
||||
import { registerReportRoutes } from "../routes/register-report-routes.js";
|
||||
|
||||
function setup() {
|
||||
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: {} }),
|
||||
getRootDir: () => "/Users/alice/private-project",
|
||||
};
|
||||
registerReportRoutes({
|
||||
router,
|
||||
getScopedStore: vi.fn().mockResolvedValue(store),
|
||||
rethrowAsApiError: (error: unknown) => { throw error; },
|
||||
} as never);
|
||||
return handlers;
|
||||
}
|
||||
|
||||
async function invoke(handler: (req: { body?: unknown }, res: { json: (body: unknown) => void }) => Promise<void>, body: unknown) {
|
||||
const json = vi.fn();
|
||||
await handler({ body }, { json });
|
||||
return json.mock.calls[0][0];
|
||||
}
|
||||
|
||||
describe("report routes 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();
|
||||
const response = await invoke(handlers.get(path)!, path === "/report/file"
|
||||
? { actionType: "help", report: { userPrompt: "How do I use settings?", context: {} } }
|
||||
: { actionType: "help", userPrompt: "How do I use settings?" });
|
||||
expect(response).toMatchObject({ kind: "help", answer: { title: "Use settings" } });
|
||||
expect(runReportPipeline).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
47
packages/dashboard/src/__tests__/report-scrub.test.ts
Normal file
47
packages/dashboard/src/__tests__/report-scrub.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { scrubReportPayload, scrubReportText } from "../report-scrub.js";
|
||||
|
||||
describe("report scrub", () => {
|
||||
const context = {
|
||||
rootDir: "/Users/alice/work/acme-private",
|
||||
homeDir: "/Users/alice",
|
||||
projectName: "acme-private",
|
||||
};
|
||||
|
||||
it("removes paths, project identity, usernames, and secret-like values", () => {
|
||||
const result = scrubReportPayload({
|
||||
userPrompt: "Crash in acme-private at /Users/alice/work/acme-private/src/a.ts with ghp_abcdefghijk1234567890",
|
||||
summary: "acme-private failed",
|
||||
body: "See /home/bob/private/file and sk-abcdefghijk1234567890",
|
||||
context: { logs: "token=very-secret-value path C:\\Users\\alice\\secret.txt" },
|
||||
}, context);
|
||||
|
||||
expect(JSON.stringify(result)).not.toContain("acme-private");
|
||||
expect(JSON.stringify(result)).not.toContain("/Users/alice");
|
||||
expect(JSON.stringify(result)).not.toContain("/home/bob");
|
||||
expect(JSON.stringify(result)).not.toContain("ghp_abcdefghijk1234567890");
|
||||
expect(JSON.stringify(result)).not.toContain("sk-abcdefghijk1234567890");
|
||||
expect(JSON.stringify(result)).toContain("[REDACTED]");
|
||||
});
|
||||
|
||||
it("removes PII from user prompts and nested gathered context", () => {
|
||||
const result = scrubReportPayload({
|
||||
userPrompt: "Jane Doe cannot sign in; email jane.doe@example.com",
|
||||
summary: "Reporter: Jane Doe",
|
||||
body: "Owner=John Smith saw the failure",
|
||||
context: { task: { title: "Jane Doe's project" }, logs: ["email: john.smith@company.test", "User: John Smith"] },
|
||||
}, context);
|
||||
|
||||
expect(JSON.stringify(result)).not.toContain("Jane Doe");
|
||||
expect(JSON.stringify(result)).not.toContain("John Smith");
|
||||
expect(JSON.stringify(result)).not.toContain("jane.doe@example.com");
|
||||
expect(JSON.stringify(result)).not.toContain("john.smith@company.test");
|
||||
expect(JSON.stringify(result)).toContain("[REDACTED_EMAIL]");
|
||||
expect(JSON.stringify(result)).toContain("[REDACTED_NAME]");
|
||||
});
|
||||
|
||||
it("handles absent text and context safely", () => {
|
||||
expect(scrubReportText(undefined, context)).toBe("");
|
||||
expect(scrubReportPayload({ summary: "", context: undefined }, context)).toEqual({ summary: "", context: undefined });
|
||||
});
|
||||
});
|
||||
@@ -229,6 +229,21 @@ export interface CreatedIssue {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DiscussionCandidate {
|
||||
id: string;
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
url: string;
|
||||
state: "open" | "closed";
|
||||
}
|
||||
|
||||
export interface CreatedDiscussion {
|
||||
id: string;
|
||||
number: number;
|
||||
htmlUrl: string;
|
||||
}
|
||||
|
||||
export interface PrComment {
|
||||
id: number;
|
||||
body: string;
|
||||
@@ -2126,11 +2141,95 @@ export class GitHubClient {
|
||||
}));
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ReportPipeline 2026-07-16-23:45:
|
||||
Feedback and unresolved Help reports can belong in repository Discussions,
|
||||
not only Issues. Keep their search, creation, and data-point comments in the
|
||||
existing GitHub client so the established gh/token transport and auth fallback
|
||||
remain the only egress mechanism.
|
||||
*/
|
||||
async searchDiscussions(owner: string, repo: string, query: string, options?: { limit?: number }): Promise<DiscussionCandidate[]> {
|
||||
const limit = Math.max(1, options?.limit ?? 1000);
|
||||
const words = query.toLocaleLowerCase().split(/\s+/).filter((word) => word.length > 3);
|
||||
const matches: DiscussionCandidate[] = [];
|
||||
let cursor: string | null = null;
|
||||
let hasNextPage = true;
|
||||
type DiscussionConnection = {
|
||||
nodes?: Array<{ id: string; number: number; title: string; body: string | null; url: string; isClosed?: boolean | null }> | null;
|
||||
pageInfo?: { hasNextPage: boolean; endCursor: string | null } | null;
|
||||
};
|
||||
type DiscussionSearchPayload = { repository?: { discussions?: DiscussionConnection | null } | null };
|
||||
|
||||
/*
|
||||
FNXC:ReportPipeline 2026-07-18-11:15:
|
||||
Discussion dedupe must not only inspect the most recently updated page.
|
||||
Page through open and older discussions until the caller's explicit bound,
|
||||
so reports cannot silently miss a duplicate merely because it is inactive.
|
||||
*/
|
||||
while (hasNextPage && matches.length < limit) {
|
||||
const payload: DiscussionSearchPayload | undefined = await this.runGraphqlQuery<DiscussionSearchPayload>(`query($owner:String!, $repo:String!, $cursor:String) {
|
||||
repository(owner:$owner, name:$repo) { discussions(first:100, after:$cursor, orderBy:{field:UPDATED_AT, direction:DESC}) { nodes { id number title body url isClosed } pageInfo { hasNextPage endCursor } } }
|
||||
}`, { owner, repo, cursor });
|
||||
const discussions: DiscussionConnection | undefined = payload?.repository?.discussions ?? undefined;
|
||||
for (const discussion of discussions?.nodes ?? []) {
|
||||
if (discussion.isClosed) continue;
|
||||
if (words.length > 0 && !words.some((word) => `${discussion.title} ${discussion.body ?? ""}`.toLocaleLowerCase().includes(word))) continue;
|
||||
matches.push({ ...discussion, state: "open" });
|
||||
if (matches.length >= limit) break;
|
||||
}
|
||||
hasNextPage = discussions?.pageInfo?.hasNextPage === true;
|
||||
cursor = discussions?.pageInfo?.endCursor ?? null;
|
||||
if (hasNextPage && !cursor) break;
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
/** Adds the same visible +1 signal to a Discussion duplicate as to an Issue duplicate. */
|
||||
async addDiscussionReaction(discussionId: string): Promise<void> {
|
||||
const payload = await this.runGraphqlQuery<{
|
||||
addReaction?: { reaction?: { content?: string | null } | null } | null;
|
||||
}>(`mutation($subjectId:ID!) {
|
||||
addReaction(input:{subjectId:$subjectId, content:THUMBS_UP}) { reaction { content } }
|
||||
}`, { subjectId: discussionId });
|
||||
if (!payload?.addReaction?.reaction) throw new Error("GitHub did not return the discussion reaction.");
|
||||
}
|
||||
|
||||
async createDiscussion(owner: string, repo: string, title: string, body: string): Promise<CreatedDiscussion> {
|
||||
const categoryPayload = await this.runGraphqlQuery<{
|
||||
repository?: { id?: string; discussionCategories?: { nodes?: Array<{ id: string }> | null } | null } | null;
|
||||
}>(`query($owner:String!, $repo:String!) {
|
||||
repository(owner:$owner, name:$repo) { id discussionCategories(first:1) { nodes { id } } }
|
||||
}`, { owner, repo });
|
||||
const repositoryId = categoryPayload?.repository?.id;
|
||||
const categoryId = categoryPayload?.repository?.discussionCategories?.nodes?.[0]?.id;
|
||||
if (!repositoryId || !categoryId) throw new Error(`Discussions are not enabled for ${owner}/${repo}.`);
|
||||
const payload = await this.runGraphqlQuery<{
|
||||
createDiscussion?: { discussion?: { id: string; number: number; url: string } | null } | null;
|
||||
}>(`mutation($repositoryId:ID!, $categoryId:ID!, $title:String!, $body:String!) {
|
||||
createDiscussion(input:{repositoryId:$repositoryId, categoryId:$categoryId, title:$title, body:$body}) { discussion { id number url } }
|
||||
}`, { repositoryId, categoryId, title, body });
|
||||
const discussion = payload?.createDiscussion?.discussion;
|
||||
if (!discussion) throw new Error("GitHub did not return the created discussion.");
|
||||
return { id: discussion.id, number: discussion.number, htmlUrl: discussion.url };
|
||||
}
|
||||
|
||||
async commentOnDiscussion(discussionId: string, body: string): Promise<{ url: string }> {
|
||||
const payload = await this.runGraphqlQuery<{
|
||||
addDiscussionComment?: { comment?: { url: string } | null } | null;
|
||||
}>(`mutation($discussionId:ID!, $body:String!) {
|
||||
addDiscussionComment(input:{discussionId:$discussionId, body:$body}) { comment { url } }
|
||||
}`, { discussionId, body });
|
||||
const url = payload?.addDiscussionComment?.comment?.url;
|
||||
if (!url) throw new Error("GitHub did not return the discussion comment.");
|
||||
return { url };
|
||||
}
|
||||
|
||||
/** Run a read-only GraphQL query (gh CLI when available, else token/REST). */
|
||||
private async runGraphqlQuery<T>(query: string, variables: Record<string, string | number>): Promise<T | undefined> {
|
||||
private async runGraphqlQuery<T>(query: string, variables: Record<string, string | number | null>): Promise<T | undefined> {
|
||||
if (this.hasGhAuth()) {
|
||||
const args = ["api", "graphql", "-f", `query=${query}`];
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
if (value === null) continue;
|
||||
const flag = typeof value === "number" ? "-F" : "-f";
|
||||
args.push(flag, `${key}=${value}`);
|
||||
}
|
||||
@@ -2491,10 +2590,47 @@ export class GitHubClient {
|
||||
return response.json() as Promise<PrComment[]>;
|
||||
}
|
||||
|
||||
async commentOnIssue(owner: string, repo: string, issueNumber: number, body: string): Promise<void> {
|
||||
/**
|
||||
* Adds a GitHub reaction to an issue through the same authenticated client
|
||||
* transport used for issue comments.
|
||||
*/
|
||||
async addIssueReaction(owner: string, repo: string, issueNumber: number, content: "+1" = "+1"): Promise<void> {
|
||||
const endpoint = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}/reactions`;
|
||||
if (this.forceMode === "gh-cli") {
|
||||
this.requireGh();
|
||||
runGh([
|
||||
await runGhJsonAsync(["api", "--method", "POST", endpoint, "-f", `content=${content}`]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.forceMode === "token") {
|
||||
this.requireToken();
|
||||
} else if (this.hasGhAuth()) {
|
||||
try {
|
||||
await runGhJsonAsync(["api", "--method", "POST", endpoint, "-f", `content=${content}`]);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (!this.token) throw new Error(getGhErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.token) {
|
||||
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided.");
|
||||
}
|
||||
const result = await this.fetchThrottled(`${this.baseUrl}/${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/vnd.github+json",
|
||||
},
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!result.success) throw new Error(result.error ?? "Failed to react to GitHub issue");
|
||||
}
|
||||
|
||||
async commentOnIssue(owner: string, repo: string, issueNumber: number, body: string): Promise<{ url?: string }> {
|
||||
if (this.forceMode === "gh-cli") {
|
||||
this.requireGh();
|
||||
const output = runGh([
|
||||
"issue",
|
||||
"comment",
|
||||
String(issueNumber),
|
||||
@@ -2503,14 +2639,14 @@ export class GitHubClient {
|
||||
"--body",
|
||||
body,
|
||||
]);
|
||||
return;
|
||||
return { url: output.match(/https:\/\/github\.com\/[^\s]+/i)?.[0] };
|
||||
}
|
||||
|
||||
if (this.forceMode === "token") {
|
||||
this.requireToken();
|
||||
} else if (this.hasGhAuth()) {
|
||||
try {
|
||||
runGh([
|
||||
const output = runGh([
|
||||
"issue",
|
||||
"comment",
|
||||
String(issueNumber),
|
||||
@@ -2519,7 +2655,7 @@ export class GitHubClient {
|
||||
"--body",
|
||||
body,
|
||||
]);
|
||||
return;
|
||||
return { url: output.match(/https:\/\/github\.com\/[^\s]+/i)?.[0] };
|
||||
} catch (err) {
|
||||
if (!this.token) {
|
||||
throw new Error(getGhErrorMessage(err));
|
||||
@@ -2532,7 +2668,7 @@ export class GitHubClient {
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}/comments`;
|
||||
const result = await this.fetchThrottled<{ id: number }>(
|
||||
const result = await this.fetchThrottled<{ id: number; html_url?: string }>(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
@@ -2546,6 +2682,7 @@ export class GitHubClient {
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? "Failed to comment on GitHub issue");
|
||||
}
|
||||
return { url: result.data?.html_url };
|
||||
}
|
||||
|
||||
async setIssueState(
|
||||
|
||||
@@ -74,6 +74,9 @@ export {
|
||||
type KnowledgeQueryOptions,
|
||||
} 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 { scrubReportPayload, scrubReportText, type ReportScrubContext } from "./report-scrub.js";
|
||||
export { selfCheckHelp, type HelpKnowledgeResult } from "./report-help-selfcheck.js";
|
||||
export {
|
||||
recordDeployment,
|
||||
ingestIncidentSignal,
|
||||
|
||||
13
packages/dashboard/src/report-help-selfcheck.ts
Normal file
13
packages/dashboard/src/report-help-selfcheck.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface HelpKnowledgeResult {
|
||||
title: string;
|
||||
summary?: string | null;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
/** Deterministic adapter used by the report route before Help is escalated. */
|
||||
export async function selfCheckHelp(question: string, query: (question: string) => Promise<HelpKnowledgeResult[]>): Promise<{ answered: boolean; answer?: HelpKnowledgeResult }> {
|
||||
const trimmed = question.trim();
|
||||
if (!trimmed) throw new Error("A help question is required.");
|
||||
const pages = await query(trimmed);
|
||||
return pages.length > 0 ? { answered: true, answer: pages[0] } : { answered: false };
|
||||
}
|
||||
225
packages/dashboard/src/report-pipeline.ts
Normal file
225
packages/dashboard/src/report-pipeline.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import type { GlobalSettings, ProjectSettings, ReportActionType, ReportMode } from "@fusion/core";
|
||||
import { resolveTaskGithubTracking } from "@fusion/core";
|
||||
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";
|
||||
|
||||
export type { ReportActionType, ReportMode };
|
||||
|
||||
export interface ReportInput {
|
||||
actionType: ReportActionType;
|
||||
userPrompt: string;
|
||||
contextRefs?: { taskId?: string; agentId?: string };
|
||||
}
|
||||
|
||||
export interface StructuredReport {
|
||||
userPrompt: string;
|
||||
/** The prompt from which the displayed derived fields were generated. */
|
||||
sourcePrompt?: string;
|
||||
summary: string;
|
||||
body: string;
|
||||
context: Record<string, unknown>;
|
||||
sessionToken?: string;
|
||||
}
|
||||
|
||||
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: "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">;
|
||||
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>>;
|
||||
}
|
||||
|
||||
const MAX_PROMPT_LENGTH = 4_000;
|
||||
const endorsedSessions = new Map<string, { url: string; issueNumber: number }>();
|
||||
|
||||
export function resolveReportMode(actionType: ReportActionType, settings: ReportPipelineDeps["projectSettings"]): ReportMode {
|
||||
return settings.reportModeByAction?.[actionType] ?? settings.reportMode ?? "draft-review";
|
||||
}
|
||||
|
||||
function requirePrompt(input: ReportInput): string {
|
||||
const prompt = input.userPrompt.trim();
|
||||
if (!prompt) throw new Error("A report description is required.");
|
||||
if (prompt.length > MAX_PROMPT_LENGTH) throw new Error(`Report descriptions must be at most ${MAX_PROMPT_LENGTH} characters.`);
|
||||
return prompt;
|
||||
}
|
||||
|
||||
function formatContext(context: Record<string, unknown>): string {
|
||||
return Object.entries(context)
|
||||
.map(([key, value]) => `- ${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`)
|
||||
.join("\n") || "- No additional context collected";
|
||||
}
|
||||
|
||||
function expectedBehavior(actionType: ReportActionType): string {
|
||||
switch (actionType) {
|
||||
case "bug": return "The affected feature should complete its normal, documented behavior.";
|
||||
case "idea": return "The suggested improvement should be considered as a product capability.";
|
||||
case "feedback": return "The experience should support the described workflow without the reported friction.";
|
||||
case "help": return "The product documentation or interface should make the requested workflow clear.";
|
||||
}
|
||||
}
|
||||
|
||||
function structureReport(input: ReportInput, gathered: Record<string, unknown>): StructuredReport {
|
||||
const prompt = requirePrompt(input);
|
||||
const context = { actionType: input.actionType, ...gathered, ...input.contextRefs };
|
||||
const formattedContext = formatContext(context);
|
||||
return {
|
||||
userPrompt: prompt,
|
||||
sourcePrompt: prompt,
|
||||
summary: `[${input.actionType}] ${prompt.slice(0, 120)}`,
|
||||
body: `## Summary\n${prompt}\n\n## Reproduction / context\n${formattedContext}\n\n## Expected behavior\n${expectedBehavior(input.actionType)}\n\n## Actual behavior / request\n${prompt}\n\n## Environment\n${formattedContext}`,
|
||||
context,
|
||||
sessionToken: crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
function createClient(deps: ReportPipelineDeps): { client?: ReportPipelineDeps["client"]; unavailable?: Extract<ReportResult, { kind: "unavailable" }> } {
|
||||
if (deps.client) return { client: deps.client };
|
||||
const resolution = resolveGithubTrackingAuth({ projectSettings: deps.projectSettings, globalSettings: deps.globalSettings });
|
||||
if (!resolution.ok) return { unavailable: { kind: "unavailable", reason: resolution.reason, message: resolution.message } };
|
||||
return { client: resolution.auth.mode === "token" ? new GitHubClient({ token: resolution.auth.token, forceMode: "token" }) : new GitHubClient({ forceMode: "gh-cli" }) };
|
||||
}
|
||||
|
||||
function resolveRepo(deps: ReportPipelineDeps) {
|
||||
return resolveTaskGithubTracking({ githubTracking: undefined }, deps.projectSettings, deps.globalSettings).repo;
|
||||
}
|
||||
|
||||
type ReportDestination = "issue" | "discussion";
|
||||
type DuplicateCandidate = { number: number; title: string; body: string | null; html_url: string; state: "open" | "closed"; discussionId?: string };
|
||||
|
||||
function destinationFor(actionType: ReportActionType): ReportDestination {
|
||||
return actionType === "feedback" || actionType === "help" ? "discussion" : "issue";
|
||||
}
|
||||
|
||||
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);
|
||||
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 }))
|
||||
: await client.searchIssues(owner, repo, query, { state: "open", limit: 20 });
|
||||
const match = candidates.filter((candidate) => candidate.state === "open")
|
||||
.map((candidate) => ({ candidate, score: scoreCandidateIssue(candidate, [], keywords).score }))
|
||||
.find(({ score }) => score >= DEDUP_MATCH_THRESHOLD);
|
||||
if (match) return match.candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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);
|
||||
const existing = endorsedSessions.get(sessionToken);
|
||||
if (existing) return { kind: "endorsed", ...existing, report };
|
||||
/*
|
||||
FNXC:ReportPipeline 2026-07-18-11:15:
|
||||
Discussion duplicates receive the same +1 and scrubbed data-point contract
|
||||
as Issue duplicates. This keeps Feedback and Help dedupe visibly useful.
|
||||
*/
|
||||
await args.client.addDiscussionReaction(args.discussionId);
|
||||
const comment = await args.client.commentOnDiscussion(args.discussionId, `## Additional Fusion report data point\n\n${report.body}`);
|
||||
const result = { url: comment.url, issueNumber: args.issueNumber };
|
||||
endorsedSessions.set(sessionToken, result);
|
||||
return { kind: "endorsed", ...result, report };
|
||||
}
|
||||
|
||||
export async function endorseDuplicate(args: { owner: string; repo: string; issueNumber: number; report: StructuredReport; client: NonNullable<ReportPipelineDeps["client"]>; scrubContext?: ReportScrubContext }): Promise<Extract<ReportResult, { kind: "endorsed" }>> {
|
||||
const sessionToken = args.report.sessionToken ?? `${args.issueNumber}:${args.report.summary}`;
|
||||
const existing = endorsedSessions.get(sessionToken);
|
||||
const report = scrubReportPayload(args.report, args.scrubContext);
|
||||
if (existing) return { kind: "endorsed", ...existing, report };
|
||||
/*
|
||||
FNXC:ReportPipeline 2026-07-16-18:00:
|
||||
A confirmed open duplicate must strengthen its existing thread rather than
|
||||
create another issue. Add the visible +1 signal and the scrubbed data point
|
||||
together, while the session token prevents retries from multiplying either.
|
||||
*/
|
||||
await args.client.addIssueReaction(args.owner, args.repo, args.issueNumber, "+1");
|
||||
const comment = await args.client.commentOnIssue(args.owner, args.repo, args.issueNumber, `## Additional Fusion report data point\n\n${report.body}`);
|
||||
const url = typeof comment === "object" && comment && "url" in comment && typeof comment.url === "string"
|
||||
? comment.url
|
||||
: `https://github.com/${args.owner}/${args.repo}/issues/${args.issueNumber}`;
|
||||
const result = { url, issueNumber: args.issueNumber };
|
||||
endorsedSessions.set(sessionToken, result);
|
||||
return { kind: "endorsed", ...result, report };
|
||||
}
|
||||
|
||||
function normalizeSubmittedReport(input: ReportInput, gathered: Record<string, unknown>, submitted: StructuredReport | undefined): StructuredReport {
|
||||
const structured = structureReport(input, gathered);
|
||||
if (!submitted) return structured;
|
||||
|
||||
const userPrompt = requirePrompt({ ...input, userPrompt: submitted.userPrompt || input.userPrompt });
|
||||
const rebuilt = structureReport({ ...input, userPrompt }, gathered);
|
||||
const promptChangedSinceDerivation = typeof submitted.sourcePrompt === "string" && submitted.sourcePrompt !== userPrompt;
|
||||
|
||||
// FNXC:ReportPipeline 2026-07-16-17:15:
|
||||
// A draft's summary and body are derived from its guided prompt. Rebuild them
|
||||
// when that prompt changed after drafting, while preserving intentional edits
|
||||
// made to fields derived from the same prompt.
|
||||
return {
|
||||
userPrompt,
|
||||
sourcePrompt: userPrompt,
|
||||
summary: !promptChangedSinceDerivation && typeof submitted.summary === "string" && submitted.summary.trim() ? submitted.summary : rebuilt.summary,
|
||||
body: !promptChangedSinceDerivation && typeof submitted.body === "string" && submitted.body.trim() ? submitted.body : rebuilt.body,
|
||||
context: submitted.context && typeof submitted.context === "object" ? { ...rebuilt.context, ...submitted.context } : rebuilt.context,
|
||||
sessionToken: typeof submitted.sessionToken === "string" && submitted.sessionToken ? submitted.sessionToken : rebuilt.sessionToken,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runReportPipeline(input: ReportInput, deps: ReportPipelineDeps, options: { file?: boolean; endorseIssueNumber?: number; endorseDiscussionId?: string; report?: StructuredReport } = {}): Promise<ReportResult> {
|
||||
const gathered = await deps.gatherContext?.(input) ?? { taskId: input.contextRefs?.taskId, agentId: input.contextRefs?.agentId };
|
||||
const report = scrubReportPayload(normalizeSubmittedReport(input, gathered, options.report), deps.scrubContext);
|
||||
const mode = resolveReportMode(input.actionType, deps.projectSettings);
|
||||
const clientResult = createClient(deps);
|
||||
if (clientResult.unavailable) return clientResult.unavailable;
|
||||
const repo = resolveRepo(deps);
|
||||
if (!repo || !clientResult.client) return { kind: "unavailable", reason: "repo_missing", message: "Configure a GitHub tracking repository before filing reports." };
|
||||
// FNXC:ReportPipeline 2026-07-16-17:15:
|
||||
// A browser-provided issue number is not authorization to comment. Re-run
|
||||
// open-only matching immediately before endorsement so callers cannot post
|
||||
// arbitrary data to closed or unrelated issues.
|
||||
const destination = destinationFor(input.actionType);
|
||||
const duplicate = await findDuplicate(clientResult.client, repo.owner, repo.repo, report, destination);
|
||||
if (options.endorseDiscussionId) {
|
||||
if (!clientResult.client.commentOnDiscussion || !clientResult.client.addDiscussionReaction || destination !== "discussion" || duplicate?.discussionId !== options.endorseDiscussionId) {
|
||||
return { kind: "unavailable", reason: "duplicate_not_verified", message: "The selected discussion is no longer an open matching report. Please prepare the report again." };
|
||||
}
|
||||
return endorseDiscussionDuplicate({ issueNumber: duplicate.number, discussionId: duplicate.discussionId, report, client: clientResult.client as NonNullable<ReportPipelineDeps["client"]> & Pick<GitHubClient, "commentOnDiscussion" | "addDiscussionReaction">, scrubContext: deps.scrubContext });
|
||||
}
|
||||
if (options.endorseIssueNumber) {
|
||||
if (destination !== "issue" || duplicate?.number !== options.endorseIssueNumber) {
|
||||
return { kind: "unavailable", reason: "duplicate_not_verified", message: "The selected issue is no longer an open matching report. Please prepare the report again." };
|
||||
}
|
||||
return endorseDuplicate({ owner: repo.owner, repo: repo.repo, issueNumber: duplicate.number, report, client: clientResult.client, scrubContext: deps.scrubContext });
|
||||
}
|
||||
if (duplicate) {
|
||||
// FNXC:ReportPipeline 2026-07-16-16:30:
|
||||
// Auto-file promises a zero-friction result for every dedupe outcome, so a
|
||||
// strong open match receives the scrubbed data point automatically.
|
||||
if (mode === "auto-file") {
|
||||
if (destination === "discussion") {
|
||||
if (!duplicate.discussionId || !clientResult.client.commentOnDiscussion || !clientResult.client.addDiscussionReaction) {
|
||||
return { kind: "unavailable", reason: "discussion_unsupported", message: "This GitHub connection cannot endorse discussions." };
|
||||
}
|
||||
return endorseDiscussionDuplicate({ issueNumber: duplicate.number, discussionId: duplicate.discussionId, report, client: clientResult.client as NonNullable<ReportPipelineDeps["client"]> & Pick<GitHubClient, "commentOnDiscussion" | "addDiscussionReaction">, scrubContext: deps.scrubContext });
|
||||
}
|
||||
return endorseDuplicate({ owner: repo.owner, repo: repo.repo, issueNumber: duplicate.number, report, client: clientResult.client, scrubContext: deps.scrubContext });
|
||||
}
|
||||
return { kind: "duplicate-found", report, mode, issue: { number: duplicate.number, url: duplicate.html_url, title: duplicate.title, discussionId: duplicate.discussionId } };
|
||||
}
|
||||
if (!options.file && mode === "draft-review") return { kind: "draft-ready", report, mode };
|
||||
if (destination === "discussion") {
|
||||
if (!clientResult.client.createDiscussion) return { kind: "unavailable", reason: "discussion_unsupported", message: "This GitHub connection cannot create discussions." };
|
||||
const created = await clientResult.client.createDiscussion(repo.owner, repo.repo, report.summary, report.body);
|
||||
return { kind: "filed", url: created.htmlUrl, report };
|
||||
}
|
||||
const created = await clientResult.client.createIssue({ owner: repo.owner, repo: repo.repo, title: report.summary, body: report.body, labels: ["community"] });
|
||||
return { kind: "filed", url: created.htmlUrl, report };
|
||||
}
|
||||
66
packages/dashboard/src/report-scrub.ts
Normal file
66
packages/dashboard/src/report-scrub.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { redactSecrets } from "@fusion/core";
|
||||
|
||||
export interface ReportScrubContext {
|
||||
rootDir?: string;
|
||||
projectName?: string;
|
||||
homeDir?: string;
|
||||
}
|
||||
|
||||
export interface ScrubbableReport {
|
||||
userPrompt?: string;
|
||||
summary?: string;
|
||||
body?: string;
|
||||
context?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:ReportPipeline 2026-07-16-12:00:
|
||||
* Reports cross a privacy boundary when they leave Fusion for GitHub. Every
|
||||
* textual field is scrubbed here and is scrubbed again immediately before
|
||||
* egress so an edited draft cannot reintroduce paths, project identity, or
|
||||
* credentials.
|
||||
*
|
||||
* FNXC:ReportPipeline 2026-07-16-18:30:
|
||||
* Free-form prompts and collected task/log context can contain PII as well as
|
||||
* credentials. Remove email addresses and likely personal names before a
|
||||
* report can leave Fusion; this protection is mandatory in every mode.
|
||||
*/
|
||||
export function scrubReportText(text: string | undefined, context: ReportScrubContext = {}): string {
|
||||
if (!text) return "";
|
||||
let scrubbed = redactSecrets(text);
|
||||
const namedValues = [context.rootDir, context.homeDir, context.projectName]
|
||||
.filter((value): value is string => Boolean(value?.trim()))
|
||||
.sort((a, b) => b.length - a.length);
|
||||
|
||||
for (const value of namedValues) {
|
||||
scrubbed = scrubbed.replace(new RegExp(escapeRegExp(value), "gi"), "[REDACTED]");
|
||||
}
|
||||
|
||||
// Home-directory usernames and absolute paths are identifying even when the
|
||||
// caller cannot provide a fully resolved local root directory.
|
||||
return scrubbed
|
||||
.replace(/(?:~|\/Users|\/home)\/[A-Za-z0-9._-]+(?:\/[\w .@+=,~:/-]*)?/g, "[REDACTED_PATH]")
|
||||
.replace(/(?:[A-Za-z]:\\|\\\\[^\\/]+\\[^\\/]+|\/(?:[\w .@+=,~-]+\/)+[\w .@+=,~-]*)/g, "[REDACTED_PATH]")
|
||||
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[REDACTED_EMAIL]")
|
||||
.replace(/\b(?:name|user(?:name)?|author|owner|reporter)\s*[:=]\s*[A-Za-z][A-Za-z'’-]*(?:\s+[A-Za-z][A-Za-z'’-]*){0,2}/gi, (match) => `${match.slice(0, match.search(/[:=]/) + 1)} [REDACTED_NAME]`)
|
||||
.replace(/\b[A-Z][a-z'’-]+\s+[A-Z][a-z'’-]+\b/g, "[REDACTED_NAME]");
|
||||
}
|
||||
|
||||
function scrubValue(value: unknown, context: ReportScrubContext): unknown {
|
||||
if (typeof value === "string") return scrubReportText(value, context);
|
||||
if (Array.isArray(value)) return value.map((item) => scrubValue(item, context));
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, scrubValue(item, context)]));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Scrub every textual payload field, including nested gathered context. */
|
||||
export function scrubReportPayload<T extends object>(report: T, context: ReportScrubContext = {}): T {
|
||||
return scrubValue(report, context) as T;
|
||||
}
|
||||
@@ -181,6 +181,7 @@ import { registerCustomProviderRoutes } from "./routes/register-custom-provider-
|
||||
import { registerUsageRoutes } from "./routes/register-usage-routes.js";
|
||||
import { registerCommandCenterRoutes } from "./routes/register-command-center-routes.js";
|
||||
import { registerKnowledgeRoutes } from "./routes/register-knowledge-routes.js";
|
||||
import { registerReportRoutes } from "./routes/register-report-routes.js";
|
||||
import { registerSignalRoutes } from "./routes/register-signal-routes.js";
|
||||
import { registerMonitorRoutes } from "./routes/monitor-routes.js";
|
||||
import { registerAuthRoutes } from "./routes/register-auth-routes.js";
|
||||
@@ -2255,6 +2256,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
// index holds sensitive repo/PR content so no endpoint is unauthenticated or
|
||||
// cross-project readable.
|
||||
registerKnowledgeRoutes(routeContext);
|
||||
registerReportRoutes(routeContext);
|
||||
// U11 — inbound external signal webhooks (Sentry/Datadog/PagerDuty/generic).
|
||||
// Each route HMAC-verifies against a per-provider secret; never an
|
||||
// unauthenticated task-creation endpoint.
|
||||
|
||||
118
packages/dashboard/src/routes/register-report-routes.ts
Normal file
118
packages/dashboard/src/routes/register-report-routes.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
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 { scrubReportPayload } from "../report-scrub.js";
|
||||
import { selfCheckHelp } from "../report-help-selfcheck.js";
|
||||
import type { ApiRouteRegistrar } from "./types.js";
|
||||
|
||||
const ACTION_TYPES = new Set(["bug", "feedback", "idea", "help"]);
|
||||
|
||||
async function gatherReportContext(store: Awaited<ReturnType<Parameters<ApiRouteRegistrar>[0]["getScopedStore"]>>, input: ReportInput, settings: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
const context: Record<string, unknown> = {
|
||||
reportMode: settings.reportMode,
|
||||
githubAuthMode: settings.githubAuthMode,
|
||||
taskId: input.contextRefs?.taskId,
|
||||
agentId: input.contextRefs?.agentId,
|
||||
};
|
||||
if (!input.contextRefs?.taskId) return context;
|
||||
|
||||
const task = await store.getTask(input.contextRefs.taskId).catch(() => null);
|
||||
if (!task) return context;
|
||||
const logs = await store.getAgentLogs(task.id, { limit: 10 }).catch(() => []);
|
||||
context.task = { id: task.id, title: task.title, column: task.column, status: task.status, error: task.error, assignedAgentId: task.assignedAgentId };
|
||||
context.recentLogs = logs.map((entry) => entry.text ?? JSON.stringify(entry)).slice(-10);
|
||||
return context;
|
||||
}
|
||||
|
||||
async function selfCheckHelpBeforePipeline(store: Awaited<ReturnType<Parameters<ApiRouteRegistrar>[0]["getScopedStore"]>>, input: ReportInput) {
|
||||
if (input.actionType !== "help") return undefined;
|
||||
const layer = requireAsyncLayer(store, "Help self-check");
|
||||
return selfCheckHelp(input.userPrompt, (query) => queryKnowledgePagesAsync(layer, { query, limit: 1 }));
|
||||
}
|
||||
|
||||
function parseInput(body: unknown): ReportInput {
|
||||
const value = (body ?? {}) as Record<string, unknown>;
|
||||
const actionType = typeof value.actionType === "string" ? value.actionType : "";
|
||||
const userPrompt = typeof value.userPrompt === "string" ? value.userPrompt : "";
|
||||
if (!ACTION_TYPES.has(actionType) || !userPrompt.trim()) throw new ApiError(400, "A report type and description are required.");
|
||||
return { actionType: actionType as ReportInput["actionType"], userPrompt, contextRefs: typeof value.contextRefs === "object" && value.contextRefs ? value.contextRefs as ReportInput["contextRefs"] : undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:ReportPipeline 2026-07-16-12:00:
|
||||
* All report routes inherit dashboard auth and resolve a scoped store. The file
|
||||
* route treats edited drafts as untrusted and re-scrubs server-side immediately
|
||||
* before the pipeline may call GitHub.
|
||||
*/
|
||||
export const registerReportRoutes: ApiRouteRegistrar = ({ router, getScopedStore, rethrowAsApiError }) => {
|
||||
router.post("/report/draft", async (req, res) => {
|
||||
try {
|
||||
const store = await getScopedStore(req);
|
||||
const scopes = await store.getSettingsByScopeFast();
|
||||
const input = parseInput(req.body);
|
||||
const help = await selfCheckHelpBeforePipeline(store, input);
|
||||
if (help?.answered) {
|
||||
res.json({ kind: "help", answer: help.answer });
|
||||
return;
|
||||
}
|
||||
const result = await runReportPipeline(input, {
|
||||
projectSettings: scopes.project,
|
||||
globalSettings: scopes.global,
|
||||
scrubContext: { rootDir: store.getRootDir(), projectName: store.getRootDir().split(/[\\/]/).pop() },
|
||||
gatherContext: (reportInput) => gatherReportContext(store, reportInput, scopes.project as Record<string, unknown>),
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) throw error;
|
||||
rethrowAsApiError(error, "Failed to prepare report draft");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/report/file", async (req, res) => {
|
||||
try {
|
||||
const store = await getScopedStore(req);
|
||||
const scopes = await store.getSettingsByScopeFast();
|
||||
const raw = (req.body ?? {}) as Record<string, unknown>;
|
||||
const untrusted = scrubReportPayload((raw.report ?? raw) as StructuredReport, { rootDir: store.getRootDir(), projectName: store.getRootDir().split(/[\\/]/).pop() });
|
||||
const input = parseInput({
|
||||
actionType: raw.actionType ?? (untrusted.context as Record<string, unknown> | undefined)?.actionType ?? "bug",
|
||||
userPrompt: untrusted.userPrompt ?? untrusted.summary,
|
||||
contextRefs: (untrusted.context as Record<string, unknown> | undefined) && {
|
||||
taskId: typeof (untrusted.context as Record<string, unknown>).taskId === "string" ? (untrusted.context as Record<string, unknown>).taskId : undefined,
|
||||
agentId: typeof (untrusted.context as Record<string, unknown>).agentId === "string" ? (untrusted.context as Record<string, unknown>).agentId : undefined,
|
||||
},
|
||||
});
|
||||
const endorseIssueNumber = typeof raw.endorseIssueNumber === "number" ? raw.endorseIssueNumber : undefined;
|
||||
const endorseDiscussionId = typeof raw.endorseDiscussionId === "string" ? raw.endorseDiscussionId : undefined;
|
||||
const help = await selfCheckHelpBeforePipeline(store, input);
|
||||
if (help?.answered) {
|
||||
res.json({ kind: "help", answer: help.answer });
|
||||
return;
|
||||
}
|
||||
const result = await runReportPipeline(input, {
|
||||
projectSettings: scopes.project,
|
||||
globalSettings: scopes.global,
|
||||
scrubContext: { rootDir: store.getRootDir(), projectName: store.getRootDir().split(/[\\/]/).pop() },
|
||||
gatherContext: (reportInput) => gatherReportContext(store, reportInput, scopes.project as Record<string, unknown>),
|
||||
}, { file: true, endorseIssueNumber, endorseDiscussionId, report: untrusted });
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) throw error;
|
||||
rethrowAsApiError(error, "Failed to file report");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/report/help", async (req, res) => {
|
||||
try {
|
||||
const store = await getScopedStore(req);
|
||||
const question = typeof req.body?.question === "string" ? req.body.question : "";
|
||||
const layer = requireAsyncLayer(store, "Help self-check");
|
||||
const result = await selfCheckHelp(question, (query) => queryKnowledgePagesAsync(layer, { query, limit: 1 }));
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) throw error;
|
||||
rethrowAsApiError(error, "Failed to self-check help question");
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user