FN-8308: add selectable GitHub Discussions report targets

Enable configurable GitHub Issue or Discussion destinations for in-app reports.

- Add report target and Discussion category settings with localized controls.
- Validate target overrides before Help shortcuts and route them through report filing.
- Discover and validate GitHub Discussion categories through GraphQL.
- Cover target validation on both draft and file routes.

Files changed:
 .changeset/fn-8308-report-discussions.md           |  7 +++
 docs/dashboard-guide.md                            |  7 +++
 .../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               |  4 ++
 packages/core/src/types.ts                         | 10 ++++
 packages/dashboard/app/api/report.ts               | 13 +++-
 packages/dashboard/app/components/ReportModal.tsx  |  9 ++-
 .../__tests__/SettingsModal.general.test.tsx       | 18 ++++++
 .../settings/sections/GeneralSection.tsx           | 16 ++++-
 .../src/__tests__/github-discussions.test.ts       | 44 ++++++++++++++
 .../src/__tests__/report-pipeline.test.ts          | 70 +++++++++++++++++++++-
 .../dashboard/src/__tests__/report-routes.test.ts  | 19 ++++++
 packages/dashboard/src/github.ts                   | 37 ++++++++++--
 packages/dashboard/src/index.ts                    |  4 +-
 packages/dashboard/src/report-pipeline.ts          | 41 +++++++++----
 .../dashboard/src/routes/register-report-routes.ts | 32 ++++++++--
 packages/i18n/locales/en/app.json                  | 26 +++++++-
 packages/i18n/locales/es/app.json                  | 26 +++++++-
 packages/i18n/locales/fr/app.json                  | 26 +++++++-
 packages/i18n/locales/ko/app.json                  | 26 +++++++-
 packages/i18n/locales/zh-CN/app.json               | 26 +++++++-
 packages/i18n/locales/zh-TW/app.json               | 26 +++++++-
 24 files changed, 453 insertions(+), 47 deletions(-)

Fusion-Task-Id: FN-8308

Fusion-Task-Lineage: f5ac1b67-ecbc-4765-88c7-c524c7e943bf

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-19 18:37:45 -07:00
parent fc8c7a6cca
commit 18f1cb743a
24 changed files with 453 additions and 47 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Let operators choose GitHub Issues or Discussions for in-app reports.
category: feature
dev: Adds project and per-action report target settings with optional Discussion category selection.

View File

@@ -1,5 +1,12 @@
# Dashboard Guide
## In-app report filing targets
In **Settings → General**, operators can choose a default GitHub report target (**Issue** or **Discussion**) and add an optional per-action override for Bug, Feedback, Idea, or Help. Leaving these settings unset preserves the built-in routing: Bug and Idea file Issues; Feedback and Help file Discussions. A Discussion category ID may be configured when a repository requires a specific category.
Discussion filing uses the same scrub-before-egress report pipeline as Issues. Duplicate matching considers open Discussions only; a confirmed duplicate receives an upvote reaction and one scrubbed data-point comment rather than a new Discussion.
[← Docs index](./README.md)
The Fusion dashboard is the main control plane for tasks, agents, missions, settings, logs, and repository operations.

View File

@@ -169,15 +169,24 @@ 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.reportTarget).toBeUndefined();
expect(DEFAULT_PROJECT_SETTINGS.reportTargetByAction).toBeUndefined();
expect(DEFAULT_PROJECT_SETTINGS.reportDiscussionCategory).toBeUndefined();
expect(DEFAULT_PROJECT_SETTINGS.reportRoadmapDedupeEnabled).toBe(true);
expect(DEFAULT_PROJECT_SETTINGS.reportRoadmapLabel).toBe("roadmap");
expect(isProjectSettingsKey("reportMode")).toBe(true);
expect(isProjectSettingsKey("reportModeByAction")).toBe(true);
expect(isProjectSettingsKey("reportTarget")).toBe(true);
expect(isProjectSettingsKey("reportTargetByAction")).toBe(true);
expect(isProjectSettingsKey("reportDiscussionCategory")).toBe(true);
expect(isProjectSettingsKey("reportRoadmapDedupeEnabled")).toBe(true);
expect(isProjectSettingsKey("reportRoadmapLabel")).toBe(true);
expect(isProjectSettingsKey("reportRoadmapRepo")).toBe(true);
expect(isGlobalSettingsKey("reportMode")).toBe(false);
expect(isGlobalSettingsKey("reportModeByAction")).toBe(false);
expect(isGlobalSettingsKey("reportTarget")).toBe(false);
expect(isGlobalSettingsKey("reportTargetByAction")).toBe(false);
expect(isGlobalSettingsKey("reportDiscussionCategory")).toBe(false);
expect(isGlobalSettingsKey("reportRoadmapDedupeEnabled")).toBe(true);
expect(isGlobalSettingsKey("reportRoadmapLabel")).toBe(true);
expect(isGlobalSettingsKey("reportRoadmapRepo")).toBe(true);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -646,6 +646,10 @@ export const DEFAULT_PROJECT_SETTINGS = {
githubTrackingDefaultRepo: undefined,
reportMode: "draft-review" as const,
reportModeByAction: undefined,
// FNXC:ReportPipeline 2026-07-16-20:15: Unset targets preserve action-specific routing.
reportTarget: undefined,
reportTargetByAction: undefined,
reportDiscussionCategory: undefined,
reportRoadmapDedupeEnabled: true,
reportRoadmapLabel: "roadmap",
reportRoadmapRepo: undefined,

View File

@@ -3318,6 +3318,7 @@ export type SecretsEnvConfig = SecretsEnvSettings;
*/
export type ReportMode = "draft-review" | "auto-file";
export type ReportActionType = "bug" | "feedback" | "idea" | "help";
export type ReportTarget = "issue" | "discussion";
export interface ProjectSettings {
/** Hard stop: when true, all automated agent activity is **immediately**
@@ -4234,6 +4235,15 @@ export interface ProjectSettings {
*/
reportMode?: ReportMode;
reportModeByAction?: Partial<Record<ReportActionType, ReportMode>>;
/*
FNXC:ReportPipeline 2026-07-16-20:15:
Report filing targets remain unset by default so the report pipeline retains
its established action-specific routing. Operators may select a project-wide
Issue/Discussion target, a per-action exception, and a Discussion category.
*/
reportTarget?: ReportTarget;
reportTargetByAction?: Partial<Record<ReportActionType, ReportTarget>>;
reportDiscussionCategory?: string;
/**
* FNXC:ReportPipeline 2026-07-18-12:00:
* FR-30 public-roadmap dedupe is an additive GitHub Issue source. Effective

View File

@@ -1,4 +1,4 @@
import type { ReportActionType } from "@fusion/core";
import type { ReportActionType, ReportTarget } 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) });
@@ -7,8 +7,8 @@ async function post(path: string, body: unknown) {
}
export interface ReportContextInput { actionType: ReportActionType; userPrompt: string; contextRefs?: { taskId?: string; agentId?: string }; activityTrace?: string[]; screenshotArtifactId?: string; }
export function reportDraft(input: ReportContextInput) { return post("/api/report/draft", input); }
export function reportFile(input: { actionType: ReportActionType; report: unknown; endorseIssueNumber?: number; endorseDiscussionId?: string; endorseRoadmapIssueNumber?: number; activityTrace?: string[]; screenshotArtifactId?: string }) { return post("/api/report/file", input); }
export function reportDraft(input: ReportContextInput & { targetType?: ReportTarget }) { return post("/api/report/draft", input); }
export function reportFile(input: { actionType: ReportActionType; report: unknown; targetType?: ReportTarget; endorseIssueNumber?: number; endorseDiscussionId?: string; endorseRoadmapIssueNumber?: number; activityTrace?: string[]; screenshotArtifactId?: string }) { return post("/api/report/file", input); }
export function reportHelp(question: string) { return post("/api/report/help", { question }); }
/** Upload is intentionally multipart: screenshot bytes never join JSON report text. */
@@ -18,3 +18,10 @@ export async function reportAttachment(screenshot: Blob): Promise<{ artifactId:
if (!response.ok) throw new Error((await response.json().catch(() => ({ error: response.statusText }))).error ?? response.statusText);
return response.json() as Promise<{ artifactId: string }>;
}
export interface DiscussionCategoryOption { id: string; name: string; slug: string; }
export async function listDiscussionCategories(): Promise<{ categories: DiscussionCategoryOption[]; reason?: string }> {
const response = await fetch("/api/report/discussion-categories");
if (!response.ok) throw new Error((await response.json().catch(() => ({ error: response.statusText }))).error ?? response.statusText);
return response.json();
}

View File

@@ -1,7 +1,7 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { ReportActionType } from "@fusion/core";
import type { ReportActionType, ReportTarget } from "@fusion/core";
import { reportAttachment, reportDraft, reportFile, reportHelp } from "../api";
import { captureScreenshot as captureScreen, getRecentActivity, recordActivity } from "../utils/report-capture";
import "./ReportModal.css";
@@ -23,6 +23,8 @@ export function ReportModal({ actionType, onClose, contextRefs }: { actionType:
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
const [screenshotEnabled, setScreenshotEnabled] = useState(false);
// FNXC:ReportPipeline 2026-07-16-21:00: A reporter can override the inherited target for this filing only.
const [targetType, setTargetType] = useState<ReportTarget | undefined>();
const [screenshotArtifactId, setScreenshotArtifactId] = useState<string>();
const [retentionConfirmed, setRetentionConfirmed] = useState(false);
const captureGeneration = useRef(0);
@@ -57,7 +59,7 @@ export function ReportModal({ actionType, onClose, contextRefs }: { actionType:
const help = await reportHelp(prompt);
if (help.answered) { setResult({ kind: "help", answer: help.answer }); return; }
}
setResult(await reportDraft({ actionType, userPrompt: prompt, contextRefs, activityTrace: getRecentActivity(), screenshotArtifactId: screenshotEnabled && retentionConfirmed ? screenshotArtifactId : undefined }));
setResult(await reportDraft({ actionType, targetType, userPrompt: prompt, contextRefs, activityTrace: getRecentActivity(), screenshotArtifactId: screenshotEnabled && retentionConfirmed ? screenshotArtifactId : undefined }));
} catch {
setError("We could not prepare your report. Check your connection and try again.");
} finally { setBusy(false); }
@@ -68,7 +70,7 @@ export function ReportModal({ actionType, onClose, contextRefs }: { actionType:
setError(undefined);
try {
recordActivity("report");
setResult(await reportFile({ actionType, report: result.report, endorseIssueNumber, endorseDiscussionId, endorseRoadmapIssueNumber, activityTrace: getRecentActivity(), screenshotArtifactId: screenshotEnabled && retentionConfirmed ? screenshotArtifactId : undefined }));
setResult(await reportFile({ actionType, targetType, report: result.report, endorseIssueNumber, endorseDiscussionId, endorseRoadmapIssueNumber, activityTrace: getRecentActivity(), screenshotArtifactId: screenshotEnabled && retentionConfirmed ? screenshotArtifactId : undefined }));
} catch {
setError("We could not send your report. Your draft is still here; try again.");
@@ -85,6 +87,7 @@ setResult(await reportFile({ actionType, report: result.report, endorseIssueNumb
{screenshotEnabled && <div className="report-modal__screenshot-preview">
{screenshotArtifactId ? <label className="report-modal__screenshot-option"><input type="checkbox" checked={retentionConfirmed} onChange={(event) => setRetentionConfirmed(event.target.checked)} /> I confirm Fusion may retain this screenshot locally for this report.</label> : <p>Capturing and storing locally…</p>}
</div>}
<label htmlFor="report-target">{t("report.targetLabel", "Filing target")}</label><select id="report-target" className="input" value={targetType ?? ""} onChange={(event) => setTargetType((event.target.value || undefined) as ReportTarget | undefined)}><option value="">{t("report.targetInherit", "Use configured action target")}</option><option value="issue">{t("report.targetIssue", "GitHub Issue")}</option><option value="discussion">{t("report.targetDiscussion", "GitHub Discussion")}</option></select>
<details className="report-modal__activity-trace"><summary>Activity trace to send</summary><ul>{getRecentActivity().map((entry, index) => <li key={`${entry}-${index}`}>{entry}</li>)}</ul></details>
<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) => {

View File

@@ -77,10 +77,13 @@ import {
installSettingsModalEnv,
} from "./SettingsModal.test-harness";
const mockListDiscussionCategories = vi.fn(async () => ({ categories: [] }));
vi.mock("../../api", async (importOriginal) => {
const { createDashboardApiMock } = await import("../../test/mockApi");
return createDashboardApiMock(() => importOriginal<typeof import("../../api")>(), {
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
listDiscussionCategories: (...args: unknown[]) => mockListDiscussionCategories(...args),
fetchSettingsByScope: (...args: unknown[]) => mockFetchSettingsByScope(...args),
updateSettings: (...args: unknown[]) => mockUpdateSettings(...args),
updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args),
@@ -209,6 +212,8 @@ describe("SettingsModal", () => {
afterEach(() => {
viewportMode = "mobile";
mockListDiscussionCategories.mockReset();
mockListDiscussionCategories.mockResolvedValue({ categories: [] });
});
const availableUpdate = {
@@ -1227,6 +1232,19 @@ describe("SettingsModal", () => {
});
describe("General · Project", () => {
it("populates the Discussion category selector from the report category route", async () => {
mockListDiscussionCategories.mockResolvedValue({ categories: [{ id: "DC_ideas", name: "Ideas", slug: "ideas" }] });
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();
const category = await screen.findByLabelText("Discussion category");
expect(category).toBeEnabled();
expect(screen.getByRole("option", { name: "Ideas" })).toHaveValue("DC_ideas");
await settingsModalUser.selectOptions(category, "DC_ideas");
await settingsModalUser.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(mockUpdateSettings.mock.calls[0]?.[0]).toMatchObject({ reportDiscussionCategory: "DC_ideas" }));
});
it("renders completion documentation automation control", async () => {
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { DEPRECATED_BUILTIN_WORKFLOW_IDS, isLocale, SUPPORTED_LOCALES, type ReportActionType, type WorkflowDefinition } from "@fusion/core";
import { DEPRECATED_BUILTIN_WORKFLOW_IDS, isLocale, SUPPORTED_LOCALES, type ReportActionType, type ReportTarget, type WorkflowDefinition } from "@fusion/core";
import { DEFAULT_MOBILE_NAV_PRIMARY_ITEMS, MAX_MOBILE_NAV_PRIMARY_ITEMS, MOBILE_NAV_PRIMARY_SELECTABLE_ITEMS, MOBILE_NAV_SELECTABLE_ITEM_LABEL_KEYS } from "../../../../../core/src/mobile-nav-primary-items";
import { SettingsFieldRow } from "../SettingsFieldRow";
import { SettingsToggleRow } from "../SettingsToggleRow";
@@ -18,7 +18,7 @@ The core helper is the same list the translate banner labels source languages wi
import { localeDisplayName } from "@fusion/core/detect-content-language";
import { ProjectDefaultWorkflowField } from "../../WorkflowSelector";
import { WorkflowIcon } from "../../WorkflowIcon";
import { fetchWorkflows } from "../../../api";
import { fetchWorkflows, listDiscussionCategories, type DiscussionCategoryOption } from "../../../api";
import { clearAllLocalCache } from "../../../utils/swrCache";
import type { ToastType } from "../../../hooks/useToast";
import type { SectionBaseProps } from "./context";
@@ -48,7 +48,14 @@ export function GeneralSection({ form, setForm, projectId, addToast, prefixError
const { t } = useTranslation("app");
const [builtinWorkflows, setBuiltinWorkflows] = useState<WorkflowDefinition[]>([]);
const [reportAction, setReportAction] = useState<ReportActionType | null>(null);
const [discussionCategories, setDiscussionCategories] = useState<DiscussionCategoryOption[]>([]);
const reportContextRefs = typeof window === "undefined" ? undefined : resolveReportContextRefs(window.location);
useEffect(() => {
let cancelled = false;
// FNXC:GithubDiscussions 2026-07-16-21:15: Category IDs are repository-owned, so settings loads safe server-provided choices.
void listDiscussionCategories().then((result) => { if (!cancelled) setDiscussionCategories(Array.isArray(result.categories) ? result.categories : []); }).catch(() => { if (!cancelled) setDiscussionCategories([]); });
return () => { cancelled = true; };
}, []);
useEffect(() => {
let cancelled = false;
fetchWorkflows(projectId, { includeDisabledBuiltins: true })
@@ -354,6 +361,11 @@ export function GeneralSection({ form, setForm, projectId, addToast, prefixError
</select>
</div>
))}
<div className="settings-field-label-row"><label htmlFor="reportTarget">{t("settings.general.reportTarget", "Default report target")}</label><SettingsHelpTip settingKey="reportTarget">{t("settings.general.reportTargetHelp", "Optional Issue or Discussion target. When unset, action defaults are preserved.")}</SettingsHelpTip></div>
<select id="reportTarget" value={form.reportTarget ?? ""} onChange={(event) => setForm((current) => ({ ...current, reportTarget: (event.target.value || undefined) as ReportTarget | undefined }))}><option value="">{t("settings.general.reportTargetInherit", "Preserve action default")}</option><option value="issue">{t("settings.general.reportTargetIssue", "GitHub Issue")}</option><option value="discussion">{t("settings.general.reportTargetDiscussion", "GitHub Discussion")}</option></select>
{(["bug", "feedback", "idea", "help"] as const).map((action) => <div key={`report-target-${action}`}><label htmlFor={`reportTarget-${action}`}>{t(`settings.general.reportTargetOverride.${action}`, `${action[0].toUpperCase()}${action.slice(1)} target override`)}</label><select id={`reportTarget-${action}`} value={form.reportTargetByAction?.[action] ?? ""} onChange={(event) => setForm((current) => { const reportTargetByAction = { ...current.reportTargetByAction }; const selected = event.target.value as "" | ReportTarget; if (selected) reportTargetByAction[action] = selected; else delete reportTargetByAction[action]; return { ...current, reportTargetByAction: Object.keys(reportTargetByAction).length ? reportTargetByAction : undefined }; })}><option value="">{t("settings.general.reportTargetInherit", "Preserve action default")}</option><option value="issue">{t("settings.general.reportTargetIssue", "GitHub Issue")}</option><option value="discussion">{t("settings.general.reportTargetDiscussion", "GitHub Discussion")}</option></select></div>)}
<div className="settings-field-label-row"><label htmlFor="reportDiscussionCategory">{t("settings.general.reportDiscussionCategory", "Discussion category")}</label><SettingsHelpTip settingKey="reportDiscussionCategory">{t("settings.general.reportDiscussionCategoryHelp", "Required when filing to GitHub Discussions.")}</SettingsHelpTip></div>
<select id="reportDiscussionCategory" value={form.reportDiscussionCategory ?? ""} onChange={(event) => setForm((current) => ({ ...current, reportDiscussionCategory: event.target.value || undefined }))} disabled={!discussionCategories.length}><option value="">{t("settings.general.reportDiscussionCategorySelect", "Select a Discussion category")}</option>{discussionCategories.map((category) => <option key={category.id} value={category.id}>{category.name}</option>)}</select>
<SettingsToggleRow
descriptor={{
key: "reportRoadmapDedupeEnabled",

View File

@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { GitHubClient } from "../github.js";
describe("GitHub Discussions GraphQL transport", () => {
afterEach(() => vi.unstubAllGlobals());
it("lists repository discussion categories through the authenticated GraphQL transport", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
data: { repository: { discussionCategories: { nodes: [
{ id: "DC_1", name: "Ideas", slug: "ideas" },
{ id: "DC_2", name: "General", slug: "general" },
] } } },
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const categories = await new GitHubClient({ token: "test", forceMode: "token" }).listDiscussionCategories("Runfusion", "Fusion");
expect(categories).toEqual([
{ id: "DC_1", name: "Ideas", slug: "ideas" },
{ id: "DC_2", name: "General", slug: "general" },
]);
expect(String(fetchMock.mock.calls[0][0])).toBe("https://api.github.com/graphql");
});
it("surfaces GraphQL scope or disabled-Discussions failures to the caller", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({
errors: [{ message: "Resource not accessible by personal access token" }],
}), { status: 200 })));
await expect(new GitHubClient({ token: "test", forceMode: "token" }).listDiscussionCategories("Runfusion", "Fusion"))
.rejects.toThrow("Resource not accessible by personal access token");
});
it("requires callers to provide a validated category instead of choosing the first one", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({
data: { repository: { id: "R_1", discussionCategories: { nodes: [{ id: "DC_1" }] } } },
}), { status: 200 })));
await expect(new GitHubClient({ token: "test", forceMode: "token" }).createDiscussion("Runfusion", "Fusion", "Title", "Body"))
.rejects.toThrow("Discussion category is unavailable");
});
});

View File

@@ -1,18 +1,37 @@
import { describe, expect, it, vi } from "vitest";
import { endorseDuplicate, runReportPipeline, type ReportPipelineDeps } from "../report-pipeline.js";
import { endorseDuplicate, resolveReportTarget, runReportPipeline, type ReportPipelineDeps } from "../report-pipeline.js";
const settings = { reportRoadmapDedupeEnabled: false, 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({ number: 42, 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" }) },
client: { createIssue: vi.fn().mockResolvedValue({ number: 42, 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" }), listDiscussionCategories: vi.fn().mockResolvedValue([{ id: "DC_ideas", name: "Ideas", slug: "ideas" }]) },
scrubContext: { projectName: "private-project", rootDir: "/Users/alice/private-project" },
...overrides,
};
}
describe("report pipeline", () => {
it.each([
["bug", "issue"], ["feedback", "discussion"], ["idea", "issue"], ["help", "discussion"],
] as const)("preserves the historical %s → %s target when settings are unset", (actionType, target) => {
expect(resolveReportTarget(actionType, {})).toBe(target);
});
it("uses per-action settings and an explicit target override in precedence order", () => {
const configured = { reportTarget: "discussion" as const, reportTargetByAction: { feedback: "issue" as const } };
expect(resolveReportTarget("feedback", configured)).toBe("issue");
expect(resolveReportTarget("bug", configured)).toBe("discussion");
expect(resolveReportTarget("feedback", configured, "discussion")).toBe("discussion");
});
it("ignores malformed persisted targets rather than silently filing them as Issues", () => {
const malformed = { reportTarget: "not-a-target", reportTargetByAction: { feedback: "also-invalid" } } as never;
expect(resolveReportTarget("feedback", malformed)).toBe("discussion");
expect(resolveReportTarget("bug", malformed)).toBe("issue");
});
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);
@@ -107,13 +126,58 @@ describe("report pipeline", () => {
});
it("files feedback as a repository discussion in auto-file mode", async () => {
const context = deps({ projectSettings: { ...settings, reportMode: "auto-file" } });
const context = deps({ projectSettings: { ...settings, reportMode: "auto-file", reportDiscussionCategory: "DC_ideas" } });
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("lets a per-action target override flip feedback to an issue and bug to a discussion", async () => {
const feedback = deps({ projectSettings: { ...settings, reportMode: "auto-file", reportDiscussionCategory: "DC_ideas", reportTargetByAction: { feedback: "issue", bug: "discussion" } } });
await runReportPipeline({ actionType: "feedback", userPrompt: "Report status needs clarity" }, feedback);
await runReportPipeline({ actionType: "bug", userPrompt: "Report failure needs attention" }, feedback);
expect(feedback.client!.createIssue).toHaveBeenCalledOnce();
expect(feedback.client!.createDiscussion).toHaveBeenCalledOnce();
});
it("requires an explicitly configured Discussion category before creating a discussion", async () => {
const context = deps({ projectSettings: { ...settings, reportMode: "auto-file", reportTarget: "discussion" } });
await expect(runReportPipeline({ actionType: "bug", userPrompt: "Report failure needs attention" }, context))
.resolves.toMatchObject({ kind: "unavailable", reason: "discussion_category_missing" });
expect(context.client!.createDiscussion).not.toHaveBeenCalled();
});
it("maps Discussion search and endorsement GraphQL failures to a safe unavailable result", async () => {
const searchFailure = deps({
projectSettings: { ...settings, reportMode: "auto-file", reportTarget: "discussion", reportDiscussionCategory: "DC_ideas" },
client: { ...deps().client!, searchDiscussions: vi.fn().mockRejectedValue(new Error("scope missing")) },
});
await expect(runReportPipeline({ actionType: "bug", userPrompt: "Report failure needs attention" }, searchFailure))
.resolves.toMatchObject({ kind: "unavailable", reason: "discussion_unavailable" });
const endorseFailure = deps({
projectSettings: { ...settings, reportMode: "auto-file" },
client: {
...deps().client!,
searchDiscussions: vi.fn().mockResolvedValue([{ id: "D_fail", number: 2, title: "report failure needs attention", body: "report failure needs attention", url: "https://github.com/Runfusion/Fusion/discussions/2", state: "open" }]),
addDiscussionReaction: vi.fn().mockRejectedValue(new Error("Discussions disabled")),
},
});
await expect(runReportPipeline({ actionType: "feedback", userPrompt: "report failure needs attention" }, endorseFailure))
.resolves.toMatchObject({ kind: "unavailable", reason: "discussion_unavailable" });
});
it("returns a typed reason when the configured Discussion category is unavailable", async () => {
const context = deps({
projectSettings: { ...settings, reportMode: "auto-file", reportTarget: "discussion", reportDiscussionCategory: "ideas" },
client: { ...deps().client!, listDiscussionCategories: vi.fn().mockResolvedValue([]) },
});
await expect(runReportPipeline({ actionType: "bug", userPrompt: "Report failure needs attention" }, context))
.resolves.toMatchObject({ kind: "unavailable", reason: "discussion_category_invalid" });
});
it("automatically endorses an open duplicate in auto-file mode", async () => {
const client = {
createIssue: vi.fn(),

View File

@@ -28,6 +28,7 @@ function setup(projectSettings: Record<string, unknown> = { reportMode: "auto-fi
});
const router = {
post: vi.fn((path: string, ...routeHandlers: TestHandler[]) => handlers.set(path, routeHandlers)),
get: vi.fn((path: string, ...routeHandlers: TestHandler[]) => handlers.set(path, routeHandlers)),
} as unknown as Router;
const store = {
getSettingsByScopeFast: vi.fn().mockResolvedValue({ project: projectSettings, global: {} }),
@@ -135,6 +136,15 @@ describe("report routes", () => {
});
});
it.each(["/report/draft", "/report/file"])("passes valid targetType through the filing pipeline on %s", async (path) => {
vi.mocked(runReportPipeline).mockResolvedValue({ kind: "draft-ready" } as never);
const { handlers } = setup();
await invoke(handlers.get(path)!, path === "/report/file"
? { actionType: "bug", targetType: "discussion", report: { userPrompt: "Dashboard report controls", context: {} } }
: { actionType: "bug", targetType: "discussion", userPrompt: "Dashboard report controls" });
expect(vi.mocked(runReportPipeline).mock.calls.at(-1)?.[2]).toMatchObject({ targetType: "discussion", ...(path === "/report/file" ? { file: true } : {}) });
});
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." }]);
@@ -145,5 +155,14 @@ describe("report routes", () => {
expect(response.body).toMatchObject({ kind: "help", answer: { title: "Use settings" } });
expect(runReportPipeline).not.toHaveBeenCalled();
});
it.each(["/report/draft", "/report/file"])("rejects invalid targetType before Help returns locally on %s", async (path) => {
vi.mocked(queryKnowledgePagesAsync).mockResolvedValue([{ title: "Use settings", summary: "Open settings first." }]);
const { handlers } = setup();
await expect(invoke(handlers.get(path)!, path === "/report/file"
? { actionType: "help", targetType: "invalid", report: { userPrompt: "How do I use settings?", context: {} } }
: { actionType: "help", targetType: "invalid", userPrompt: "How do I use settings?" })).rejects.toThrow("Report target must be issue or discussion");
expect(runReportPipeline).not.toHaveBeenCalled();
});
});
});

View File

@@ -243,6 +243,12 @@ export interface DiscussionCandidate {
state: "open" | "closed";
}
export interface DiscussionCategory {
id: string;
name: string;
slug: string;
}
export interface CreatedDiscussion {
id: string;
number: number;
@@ -2296,6 +2302,23 @@ export class GitHubClient {
return matches;
}
/*
FNXC:GithubDiscussions 2026-07-16-20:00:
GitHub Discussions have no REST API coverage. Category discovery stays on the
established GraphQL transport, preserving the same gh/token auth behavior as
search, creation, comments, and reactions.
*/
async listDiscussionCategories(owner: string, repo: string): Promise<DiscussionCategory[]> {
type CategoryPayload = {
repository?: { discussionCategories?: { nodes?: Array<DiscussionCategory | null> | null } | null } | null;
};
const payload = await this.runGraphqlQuery<CategoryPayload>(`query($owner:String!, $repo:String!) {
repository(owner:$owner, name:$repo) { discussionCategories(first:100) { nodes { id name slug } } }
}`, { owner, repo });
return (payload?.repository?.discussionCategories?.nodes ?? [])
.filter((category): category is DiscussionCategory => Boolean(category?.id && category.name && category.slug));
}
/** 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<{
@@ -2306,15 +2329,21 @@ export class GitHubClient {
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> {
async createDiscussion(owner: string, repo: string, title: string, body: string, selectedCategoryId?: 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 } } }
repository(owner:$owner, name:$repo) { id discussionCategories(first:100) { 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 categories = categoryPayload?.repository?.discussionCategories?.nodes ?? [];
// FNXC:GithubDiscussions 2026-07-16-21:15: A report must never silently
// file into the repository's first category; an operator-selected category
// is required so disabled, missing, and stale selections fail safely.
const categoryId = selectedCategoryId;
if (!repositoryId || !categoryId || !categories.some((category) => category.id === categoryId)) {
throw new Error(`Discussion category is unavailable 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!) {

View File

@@ -17,7 +17,7 @@ export {
type RuntimeLogSink,
} from "./runtime-logger.js";
export { createSkillsAdapter, getProjectSettingsPath, type SkillsAdapter, type DiscoveredSkill, type CatalogEntry, type CatalogFetchResult, type ToggleSkillResult, type UpstreamError, type UpstreamErrorCode, type SkillContent, type SkillFileEntry, type SkillFileContent } from "./skills-adapter.js";
export { GitHubClient, isPrMergeReady, closeGroupPullRequest, reconcileGroupPullRequest, buildGitHubIssueSource, isGitHubIssueAlreadyImported, type GitHubClientOptions, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, type UpdatePrParams, type ClosePrParams, type FindPrParams, type CreateIssueParams, type CreatedIssue, type CreateGroupPrResult } from "./github.js";
export { GitHubClient, isPrMergeReady, closeGroupPullRequest, reconcileGroupPullRequest, buildGitHubIssueSource, isGitHubIssueAlreadyImported, type GitHubClientOptions, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, type UpdatePrParams, type ClosePrParams, type FindPrParams, type CreateIssueParams, type CreatedIssue, type DiscussionCategory, type DiscussionCandidate, type CreatedDiscussion, type CreateGroupPrResult } from "./github.js";
export { generatePrMetadata, type GeneratedPrMetadata } from "./pr-metadata-generator.js";
export {
resolvePrConflicts,
@@ -74,7 +74,7 @@ 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 { runReportPipeline, endorseDuplicate, resolveReportMode, resolveReportTarget, 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";

View File

@@ -1,11 +1,11 @@
import type { GlobalSettings, ProjectSettings, ReportActionType, ReportMode } from "@fusion/core";
import type { GlobalSettings, ProjectSettings, ReportActionType, ReportMode, ReportTarget } from "@fusion/core";
import { parseRepoSlug, 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 type { ReportActionType, ReportMode, ReportTarget };
export interface ReportInput {
actionType: ReportActionType;
@@ -37,9 +37,9 @@ export type ReportResult =
| { kind: "unavailable"; reason: string; message: string };
export interface ReportPipelineDeps {
projectSettings: Pick<ProjectSettings, "reportMode" | "reportModeByAction" | "reportRoadmapDedupeEnabled" | "reportRoadmapLabel" | "reportRoadmapRepo" | "githubTrackingDefaultRepo" | "githubAuthMode" | "githubAuthToken">;
projectSettings: Pick<ProjectSettings, "reportMode" | "reportModeByAction" | "reportTarget" | "reportTargetByAction" | "reportDiscussionCategory" | "reportRoadmapDedupeEnabled" | "reportRoadmapLabel" | "reportRoadmapRepo" | "githubTrackingDefaultRepo" | "githubAuthMode" | "githubAuthToken">;
globalSettings?: Partial<GlobalSettings>;
client?: Pick<GitHubClient, "createIssue" | "searchIssues" | "commentOnIssue" | "addIssueReaction"> & Partial<Pick<GitHubClient, "searchDiscussions" | "createDiscussion" | "commentOnDiscussion" | "addDiscussionReaction">>;
client?: Pick<GitHubClient, "createIssue" | "searchIssues" | "commentOnIssue" | "addIssueReaction"> & Partial<Pick<GitHubClient, "searchDiscussions" | "createDiscussion" | "commentOnDiscussion" | "addDiscussionReaction" | "listDiscussionCategories">>;
scrubContext?: ReportScrubContext;
gatherContext?: (input: ReportInput) => Promise<Record<string, unknown>>;
}
@@ -110,13 +110,19 @@ function resolveRepo(deps: ReportPipelineDeps) {
return resolveTaskGithubTracking({ githubTracking: undefined }, deps.projectSettings, deps.globalSettings).repo;
}
type ReportDestination = "issue" | "discussion";
type ReportDestination = ReportTarget;
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";
}
/** FNXC:ReportPipeline 2026-07-16-20:30: Explicit and configured targets override historic action routing without changing its unset fallback. */
export function resolveReportTarget(actionType: ReportActionType, settings: Pick<ProjectSettings, "reportTarget" | "reportTargetByAction">, explicitOverride?: ReportTarget): ReportTarget {
const isTarget = (value: unknown): value is ReportTarget => value === "issue" || value === "discussion";
return [explicitOverride, settings.reportTargetByAction?.[actionType], settings.reportTarget].find(isTarget) ?? destinationFor(actionType);
}
function reportKeywords(report: StructuredReport): string[] {
return report.summary.replace(/[^\w ]/g, " ").split(/\s+/).filter((word) => word.length > 3).slice(0, 6);
}
@@ -232,7 +238,7 @@ function normalizeSubmittedReport(input: ReportInput, gathered: Record<string, u
};
}
export async function runReportPipeline(input: ReportInput, deps: ReportPipelineDeps, options: { file?: boolean; endorseIssueNumber?: number; endorseDiscussionId?: string; endorseRoadmapIssueNumber?: number; report?: StructuredReport } = {}): Promise<ReportResult> {
export async function runReportPipeline(input: ReportInput, deps: ReportPipelineDeps, options: { file?: boolean; targetType?: ReportTarget; endorseIssueNumber?: number; endorseDiscussionId?: string; endorseRoadmapIssueNumber?: number; report?: StructuredReport } = {}): Promise<ReportResult> {
const gathered = await deps.gatherContext?.(input) ?? { taskId: input.contextRefs?.taskId, agentId: input.contextRefs?.agentId };
const normalized = normalizeSubmittedReport(input, gathered, options.report);
const report: StructuredReport = scrubReportPayload(normalized, deps.scrubContext);
@@ -250,8 +256,13 @@ export async function runReportPipeline(input: ReportInput, deps: ReportPipeline
issue +1/scrub path; unavailable roadmap search falls through without egress.
*/
const roadmapDuplicate = await findRoadmapDuplicate(clientResult.client, roadmap, report);
const destination = destinationFor(input.actionType);
const duplicate = await findDuplicate(clientResult.client, repo.owner, repo.repo, report, destination);
const destination = resolveReportTarget(input.actionType, deps.projectSettings, options.targetType);
let duplicate: DuplicateCandidate | undefined;
try { duplicate = await findDuplicate(clientResult.client, repo.owner, repo.repo, report, destination); } catch {
// FNXC:GithubDiscussions 2026-07-16-21:15: Discussion GraphQL scope and feature failures are safe availability states.
if (destination === "discussion") return { kind: "unavailable", reason: "discussion_unavailable", message: "GitHub Discussions are unavailable for this repository or token." };
throw new Error("GitHub Issue duplicate search failed.");
}
if (options.endorseRoadmapIssueNumber) {
if (!roadmapDuplicate || roadmapDuplicate.number !== options.endorseRoadmapIssueNumber || !roadmap.repo) {
return { kind: "unavailable", reason: "duplicate_not_verified", message: "The selected roadmap item is no longer an open matching report. Please prepare the report again." };
@@ -283,8 +294,10 @@ export async function runReportPipeline(input: ReportInput, deps: ReportPipeline
if (!duplicate.discussionId || !clientResult.client.commentOnDiscussion || !clientResult.client.addDiscussionReaction) {
return { kind: "unavailable", reason: "discussion_unsupported", message: "This GitHub connection cannot endorse discussions." };
}
const endorsed = await endorseDiscussionDuplicate({ issueNumber: duplicate.number, discussionId: duplicate.discussionId, report, client: clientResult.client as NonNullable<ReportPipelineDeps["client"]> & Pick<GitHubClient, "commentOnDiscussion" | "addDiscussionReaction">, scrubContext: deps.scrubContext });
return endorsed;
try {
const endorsed = await endorseDiscussionDuplicate({ issueNumber: duplicate.number, discussionId: duplicate.discussionId, report, client: clientResult.client as NonNullable<ReportPipelineDeps["client"]> & Pick<GitHubClient, "commentOnDiscussion" | "addDiscussionReaction">, scrubContext: deps.scrubContext });
return endorsed;
} catch { return { kind: "unavailable", reason: "discussion_unavailable", message: "GitHub Discussions are unavailable for this repository or token." }; }
}
const endorsed = await endorseDuplicate({ owner: repo.owner, repo: repo.repo, issueNumber: duplicate.number, report, client: clientResult.client, scrubContext: deps.scrubContext });
return endorsed;
@@ -294,8 +307,12 @@ export async function runReportPipeline(input: ReportInput, deps: ReportPipeline
if (!options.file && mode === "draft-review") return { kind: "draft-ready", report, mode };
if (destination === "discussion") {
if (!clientResult.client.createDiscussion || !clientResult.client.commentOnDiscussion) 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 configuredCategory = deps.projectSettings.reportDiscussionCategory;
if (!configuredCategory || !clientResult.client.listDiscussionCategories) return { kind: "unavailable", reason: "discussion_category_missing", message: "Select a Discussion category before filing reports to GitHub Discussions." };
let categoryId: string | undefined;
try { categoryId = (await clientResult.client.listDiscussionCategories(repo.owner, repo.repo)).find((category) => category.id === configuredCategory || category.slug === configuredCategory)?.id; } catch { return { kind: "unavailable", reason: "discussion_categories_unavailable", message: "GitHub Discussions are unavailable for this repository or token." }; }
if (!categoryId) return { kind: "unavailable", reason: "discussion_category_invalid", message: "The selected Discussion category is missing or unavailable for this repository." };
try { const created = await clientResult.client.createDiscussion(repo.owner, repo.repo, report.summary, report.body, categoryId); return { kind: "filed", url: created.htmlUrl, report }; } catch { return { kind: "unavailable", reason: "discussion_unavailable", message: "GitHub Discussions are unavailable for this repository or token." }; }
}
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 };

View File

@@ -1,6 +1,8 @@
import { REPORT_ATTACHMENT_SOURCE } from "@fusion/core";
import { REPORT_ATTACHMENT_SOURCE, resolveTaskGithubTracking } from "@fusion/core";
import type { Request, Response } from "express";
import { ApiError } from "../api-error.js";
import { GitHubClient } from "../github.js";
import { resolveGithubTrackingAuth } from "../github-auth.js";
import { queryKnowledgePagesAsync } from "../knowledge-index.js";
import { requireAsyncLayer } from "../require-async-layer.js";
import { runReportPipeline, type ReportInput, type StructuredReport } from "../report-pipeline.js";
@@ -9,6 +11,7 @@ import { selfCheckHelp } from "../report-help-selfcheck.js";
import type { ApiRouteRegistrar } from "./types.js";
const ACTION_TYPES = new Set(["bug", "feedback", "idea", "help"]);
const REPORT_TARGETS = new Set(["issue", "discussion"]);
const MAX_ACTIVITY_TRACE_ENTRIES = 20;
const MAX_ACTIVITY_TRACE_CHARS = 4_000;
export const MAX_SCREENSHOT_BYTES = 2 * 1024 * 1024;
@@ -27,7 +30,7 @@ function parseActivityTrace(value: unknown): string[] | undefined {
function parseScreenshotArtifactId(value: unknown): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string" || !ARTIFACT_ID_PATTERN.test(value)) throw new ApiError(400, "Screenshot artifact reference is invalid.");
return value;
return value as "issue" | "discussion";
}
async function validateScreenshotArtifact(store: Awaited<ReturnType<Parameters<ApiRouteRegistrar>[0]["getScopedStore"]>>, id: string | undefined): Promise<void> {
@@ -59,6 +62,12 @@ async function selfCheckHelpBeforePipeline(store: Awaited<ReturnType<Parameters<
return selfCheckHelp(input.userPrompt, (query) => queryKnowledgePagesAsync(layer, { query, limit: 1 }));
}
function parseTargetType(value: unknown): "issue" | "discussion" | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string" || !REPORT_TARGETS.has(value)) throw new ApiError(400, "Report target must be issue or discussion.");
return value as "issue" | "discussion";
}
function parseInput(body: unknown): ReportInput {
const value = (body ?? {}) as Record<string, unknown>;
const actionType = typeof value.actionType === "string" ? value.actionType : "";
@@ -93,10 +102,12 @@ export const registerReportRoutes: ApiRouteRegistrar = ({ router, getScopedStore
router.post("/report/draft", async (req, res) => {
try {
const store = await getScopedStore(req); const scopes = await store.getSettingsByScopeFast(); const input = parseInput(req.body);
// FNXC:ReportPipeline 2026-07-16-21:00: Validate target before Help can return locally.
const targetType = parseTargetType((req.body as Record<string, unknown> | undefined)?.targetType);
await validateScreenshotArtifact(store, input.screenshotArtifactId);
const help = await selfCheckHelpBeforePipeline(store, input);
if (help?.answered) return void res.json({ kind: "help", answer: help.answer });
res.json(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(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>) }, targetType ? { targetType } : {}));
} catch (error) { if (error instanceof ApiError) throw error; rethrowAsApiError(error, "Failed to prepare report draft"); }
});
@@ -106,12 +117,25 @@ export const registerReportRoutes: ApiRouteRegistrar = ({ router, getScopedStore
const { screenshotArtifactId: reportArtifactId, ...textualRawReport } = rawReport;
const untrusted = scrubReportPayload(textualRawReport, { 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 }, activityTrace: raw.activityTrace ?? (untrusted.context as Record<string, unknown> | undefined)?.activityTrace, screenshotArtifactId: raw.screenshotArtifactId ?? reportArtifactId });
// FNXC:ReportPipeline 2026-07-16-21:00: Validate target before Help can return locally.
const targetType = parseTargetType(raw.targetType);
await validateScreenshotArtifact(store, input.screenshotArtifactId);
const help = await selfCheckHelpBeforePipeline(store, input);
if (help?.answered) return void res.json({ kind: "help", answer: help.answer });
res.json(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: typeof raw.endorseIssueNumber === "number" ? raw.endorseIssueNumber : undefined, endorseDiscussionId: typeof raw.endorseDiscussionId === "string" ? raw.endorseDiscussionId : undefined, endorseRoadmapIssueNumber: typeof raw.endorseRoadmapIssueNumber === "number" ? raw.endorseRoadmapIssueNumber : undefined, report: untrusted }));
res.json(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, targetType, endorseIssueNumber: typeof raw.endorseIssueNumber === "number" ? raw.endorseIssueNumber : undefined, endorseDiscussionId: typeof raw.endorseDiscussionId === "string" ? raw.endorseDiscussionId : undefined, endorseRoadmapIssueNumber: typeof raw.endorseRoadmapIssueNumber === "number" ? raw.endorseRoadmapIssueNumber : undefined, report: untrusted }));
} catch (error) { if (error instanceof ApiError) throw error; rethrowAsApiError(error, "Failed to file report"); }
});
router.get("/report/discussion-categories", async (req, res) => {
try {
const store = await getScopedStore(req); const scopes = await store.getSettingsByScopeFast();
const auth = resolveGithubTrackingAuth({ projectSettings: scopes.project, globalSettings: scopes.global });
const repo = resolveTaskGithubTracking({ githubTracking: undefined }, scopes.project, scopes.global).repo;
if (!auth.ok || !repo) return void res.json({ categories: [], reason: auth.ok ? "repo_missing" : auth.reason });
const client = auth.auth.mode === "token" ? new GitHubClient({ token: auth.auth.token, forceMode: "token" }) : new GitHubClient({ forceMode: "gh-cli" });
try { res.json({ categories: await client.listDiscussionCategories(repo.owner, repo.repo) }); } catch { res.json({ categories: [], reason: "discussion_categories_unavailable" }); }
} catch (error) { if (error instanceof ApiError) throw error; rethrowAsApiError(error, "Failed to list Discussion categories"); }
});
router.post("/report/help", async (req, res) => { try { const store = await getScopedStore(req); const layer = requireAsyncLayer(store, "Help self-check"); res.json(await selfCheckHelp(typeof req.body?.question === "string" ? req.body.question : "", (query) => queryKnowledgePagesAsync(layer, { query, limit: 1 }))); } catch (error) { if (error instanceof ApiError) throw error; rethrowAsApiError(error, "Failed to self-check help question"); } });
};

View File

@@ -5964,7 +5964,25 @@
"reportRoadmapDedupeEnabled": "Deduplicate reports against public roadmap",
"reportRoadmapDedupeEnabledHelp": "Match open, labeled roadmap issues before filing. Default: on.",
"reportRoadmapLabel": "Public roadmap label",
"reportRoadmapLabelHelp": "Open GitHub Issues with this label are considered roadmap items. Default: roadmap."
"reportRoadmapLabelHelp": "Open GitHub Issues with this label are considered roadmap items. Default: roadmap.",
"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.",
"reportTarget": "Default report target",
"reportTargetHelp": "Optional Issue or Discussion target. When unset, Feedback and Help use Discussions while Bug and Idea use Issues.",
"reportTargetInherit": "Preserve action default",
"reportTargetIssue": "GitHub Issue",
"reportTargetDiscussion": "GitHub Discussion",
"reportTargetOverride": {
"bug": "Bug target override",
"feedback": "Feedback target override",
"idea": "Idea target override",
"help": "Help target override"
},
"reportDiscussionCategory": "Discussion category",
"reportDiscussionCategoryHelp": "Required when filing to GitHub Discussions. Categories are loaded from the configured repository.",
"reportDiscussionCategorySelect": "Select a Discussion category",
"reportDiscussionCategoryUnavailable": "Discussion categories are unavailable for the configured repository.",
"reportDiscussionCategoryEmpty": "No Discussion categories are available for the configured repository."
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": " and shows update notices in the CLI and dashboard. Cadence is governed by the frequency below. Default: enabled. ",
@@ -9155,6 +9173,10 @@
},
"roadmapDuplicate": {
"title": "Already on the roadmap — add your data point?"
}
},
"targetLabel": "Filing target",
"targetInherit": "Use configured action target",
"targetIssue": "GitHub Issue",
"targetDiscussion": "GitHub Discussion"
}
}

View File

@@ -5953,7 +5953,25 @@
"reportRoadmapDedupeEnabled": "Deduplicate reports against public roadmap",
"reportRoadmapDedupeEnabledHelp": "Match open, labeled roadmap issues before filing. Default: on.",
"reportRoadmapLabel": "Public roadmap label",
"reportRoadmapLabelHelp": "Open GitHub Issues with this label are considered roadmap items. Default: roadmap."
"reportRoadmapLabelHelp": "Open GitHub Issues with this label are considered roadmap items. Default: roadmap.",
"reportRoadmapDedup": "Comprobar la hoja de ruta antes de enviar informes",
"reportRoadmapDedupHelp": "Cuando está habilitado, las funciones coincidentes de la hoja de ruta se muestran en línea en vez de enviar otra incidencia o discusión de GitHub. Predeterminado: desactivado.",
"reportTarget": "Destino predeterminado del informe",
"reportTargetHelp": "Destino opcional de incidencia o discusión. Si no se configura, Comentarios y Ayuda usan Discusiones, mientras que Error e Idea usan Incidencias.",
"reportTargetInherit": "Conservar el destino predeterminado de la acción",
"reportTargetIssue": "Incidencia de GitHub",
"reportTargetDiscussion": "Discusión de GitHub",
"reportTargetOverride": {
"bug": "Anulación de destino para error",
"feedback": "Anulación de destino para comentarios",
"idea": "Anulación de destino para idea",
"help": "Anulación de destino para ayuda"
},
"reportDiscussionCategory": "Categoría de discusión",
"reportDiscussionCategoryHelp": "Obligatoria al enviar a Discusiones de GitHub. Las categorías se cargan desde el repositorio configurado.",
"reportDiscussionCategorySelect": "Selecciona una categoría de discusión",
"reportDiscussionCategoryUnavailable": "Las categorías de discusión no están disponibles para el repositorio configurado.",
"reportDiscussionCategoryEmpty": "No hay categorías de discusión disponibles para el repositorio configurado."
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",
@@ -9154,6 +9172,10 @@
},
"roadmapDuplicate": {
"title": "Already on the roadmap — add your data point?"
}
},
"targetLabel": "Destino del envío",
"targetInherit": "Usar el destino configurado para la acción",
"targetIssue": "Incidencia de GitHub",
"targetDiscussion": "Discusión de GitHub"
}
}

View File

@@ -5953,7 +5953,25 @@
"reportRoadmapDedupeEnabled": "Deduplicate reports against public roadmap",
"reportRoadmapDedupeEnabledHelp": "Match open, labeled roadmap issues before filing. Default: on.",
"reportRoadmapLabel": "Public roadmap label",
"reportRoadmapLabelHelp": "Open GitHub Issues with this label are considered roadmap items. Default: roadmap."
"reportRoadmapLabelHelp": "Open GitHub Issues with this label are considered roadmap items. Default: roadmap.",
"reportRoadmapDedup": "Vérifier la feuille de route avant d’envoyer les rapports",
"reportRoadmapDedupHelp": "Lorsqu’elle est activée, les fonctionnalités correspondantes de la feuille de route sont affichées directement au lieu de créer une autre Issue ou Discussion GitHub. Par défaut : désactivé.",
"reportTarget": "Cible de rapport par défaut",
"reportTargetHelp": "Cible facultative Issue ou Discussion. Si elle n’est pas définie, les retours et l’aide utilisent les Discussions, tandis que les bugs et idées utilisent les Issues.",
"reportTargetInherit": "Conserver la cible par défaut de l’action",
"reportTargetIssue": "Issue GitHub",
"reportTargetDiscussion": "Discussion GitHub",
"reportTargetOverride": {
"bug": "Remplacement de cible pour les bugs",
"feedback": "Remplacement de cible pour les retours",
"idea": "Remplacement de cible pour les idées",
"help": "Remplacement de cible pour l’aide"
},
"reportDiscussionCategory": "Catégorie de discussion",
"reportDiscussionCategoryHelp": "Obligatoire pour envoyer vers les Discussions GitHub. Les catégories sont chargées depuis le dépôt configuré.",
"reportDiscussionCategorySelect": "Sélectionnez une catégorie de discussion",
"reportDiscussionCategoryUnavailable": "Les catégories de discussion ne sont pas disponibles pour le dépôt configuré.",
"reportDiscussionCategoryEmpty": "Aucune catégorie de discussion n’est disponible pour le dépôt configuré."
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",
@@ -9154,6 +9172,10 @@
},
"roadmapDuplicate": {
"title": "Already on the roadmap — add your data point?"
}
},
"targetLabel": "Cible de dépôt",
"targetInherit": "Utiliser la cible configurée pour l’action",
"targetIssue": "Issue GitHub",
"targetDiscussion": "Discussion GitHub"
}
}

View File

@@ -5953,7 +5953,25 @@
"reportRoadmapDedupeEnabled": "Deduplicate reports against public roadmap",
"reportRoadmapDedupeEnabledHelp": "Match open, labeled roadmap issues before filing. Default: on.",
"reportRoadmapLabel": "Public roadmap label",
"reportRoadmapLabelHelp": "Open GitHub Issues with this label are considered roadmap items. Default: roadmap."
"reportRoadmapLabelHelp": "Open GitHub Issues with this label are considered roadmap items. Default: roadmap.",
"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.",
"reportTarget": "기본 보고 대상",
"reportTargetHelp": "선택적 이슈 또는 토론 대상입니다. 설정하지 않으면 피드백과 도움말은 토론을, 버그와 아이디어는 이슈를 사용합니다.",
"reportTargetInherit": "작업 기본 대상 유지",
"reportTargetIssue": "GitHub 이슈",
"reportTargetDiscussion": "GitHub 토론",
"reportTargetOverride": {
"bug": "버그 대상 재정의",
"feedback": "피드백 대상 재정의",
"idea": "아이디어 대상 재정의",
"help": "도움말 대상 재정의"
},
"reportDiscussionCategory": "토론 카테고리",
"reportDiscussionCategoryHelp": "Required when filing to GitHub Discussions. Categories are loaded from the configured repository.",
"reportDiscussionCategorySelect": "토론 카테고리 선택",
"reportDiscussionCategoryUnavailable": "Discussion categories are unavailable for the configured repository.",
"reportDiscussionCategoryEmpty": "No Discussion categories are available for the configured repository."
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",
@@ -9154,6 +9172,10 @@
},
"roadmapDuplicate": {
"title": "Already on the roadmap — add your data point?"
}
},
"targetLabel": "등록 대상",
"targetInherit": "구성된 작업 대상 사용",
"targetIssue": "GitHub 이슈",
"targetDiscussion": "GitHub 토론"
}
}

View File

@@ -5953,7 +5953,25 @@
"reportRoadmapRepo": "Public roadmap repository (optional)",
"reportRoadmapRepoHelp": "GitHub owner/repository containing public roadmap issues. When unset, uses the tracking repository. No default — unset.",
"reviewArtifacts": "评审产物",
"reviewArtifactsHint": "控制符合条件的未来任务是否生成评审交付物。面向用户的报告与活动捕获保持独立。默认:关闭。"
"reviewArtifactsHint": "控制符合条件的未来任务是否生成评审交付物。面向用户的报告与活动捕获保持独立。默认:关闭。",
"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.",
"reportTarget": "默认报告目标",
"reportTargetHelp": "可选的议题或讨论目标。未设置时,反馈和帮助使用讨论,缺陷和想法使用议题。",
"reportTargetInherit": "保留操作默认目标",
"reportTargetIssue": "GitHub 议题",
"reportTargetDiscussion": "GitHub 讨论",
"reportTargetOverride": {
"bug": "缺陷目标覆盖",
"feedback": "反馈目标覆盖",
"idea": "想法目标覆盖",
"help": "帮助目标覆盖"
},
"reportDiscussionCategory": "讨论类别",
"reportDiscussionCategoryHelp": "Required when filing to GitHub Discussions. Categories are loaded from the configured repository.",
"reportDiscussionCategorySelect": "选择讨论类别",
"reportDiscussionCategoryUnavailable": "Discussion categories are unavailable for the configured repository.",
"reportDiscussionCategoryEmpty": "No Discussion categories are available for the configured repository."
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",
@@ -9151,6 +9169,10 @@
"roadmapMatch": {
"title": "已在路线图中",
"message": "此报告与已经规划的功能相匹配。"
}
},
"targetLabel": "提交目标",
"targetInherit": "使用已配置的操作目标",
"targetIssue": "GitHub 议题",
"targetDiscussion": "GitHub 讨论"
}
}

View File

@@ -5953,7 +5953,25 @@
"reportRoadmapRepo": "Public roadmap repository (optional)",
"reportRoadmapRepoHelp": "GitHub owner/repository containing public roadmap issues. When unset, uses the tracking repository. No default — unset.",
"reviewArtifacts": "審查產物",
"reviewArtifactsHint": "控制符合條件的未來任務是否產生審查交付物。面向使用者的報告與活動擷取保持獨立。預設:關閉。"
"reviewArtifactsHint": "控制符合條件的未來任務是否產生審查交付物。面向使用者的報告與活動擷取保持獨立。預設:關閉。",
"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.",
"reportTarget": "預設報告目標",
"reportTargetHelp": "可選的議題或討論目標。未設定時,回饋和說明使用討論,錯誤和想法使用議題。",
"reportTargetInherit": "保留操作預設目標",
"reportTargetIssue": "GitHub 議題",
"reportTargetDiscussion": "GitHub 討論",
"reportTargetOverride": {
"bug": "錯誤目標覆寫",
"feedback": "回饋目標覆寫",
"idea": "想法目標覆寫",
"help": "說明目標覆寫"
},
"reportDiscussionCategory": "討論分類",
"reportDiscussionCategoryHelp": "Required when filing to GitHub Discussions. Categories are loaded from the configured repository.",
"reportDiscussionCategorySelect": "選擇討論分類",
"reportDiscussionCategoryUnavailable": "Discussion categories are unavailable for the configured repository.",
"reportDiscussionCategoryEmpty": "No Discussion categories are available for the configured repository."
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",
@@ -9151,6 +9169,10 @@
"roadmapMatch": {
"title": "已在路線圖中",
"message": "此報告與已規劃的功能相符。"
}
},
"targetLabel": "提交目標",
"targetInherit": "使用設定的操作目標",
"targetIssue": "GitHub 議題",
"targetDiscussion": "GitHub 討論"
}
}