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 # 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) [← Docs index](./README.md)
The Fusion dashboard is the main control plane for tasks, agents, missions, settings, logs, and repository operations. 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", () => { 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.reportMode).toBe("draft-review");
expect(DEFAULT_PROJECT_SETTINGS.reportModeByAction).toBeUndefined(); 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.reportRoadmapDedupeEnabled).toBe(true);
expect(DEFAULT_PROJECT_SETTINGS.reportRoadmapLabel).toBe("roadmap"); expect(DEFAULT_PROJECT_SETTINGS.reportRoadmapLabel).toBe("roadmap");
expect(isProjectSettingsKey("reportMode")).toBe(true); expect(isProjectSettingsKey("reportMode")).toBe(true);
expect(isProjectSettingsKey("reportModeByAction")).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("reportRoadmapDedupeEnabled")).toBe(true);
expect(isProjectSettingsKey("reportRoadmapLabel")).toBe(true); expect(isProjectSettingsKey("reportRoadmapLabel")).toBe(true);
expect(isProjectSettingsKey("reportRoadmapRepo")).toBe(true); expect(isProjectSettingsKey("reportRoadmapRepo")).toBe(true);
expect(isGlobalSettingsKey("reportMode")).toBe(false); expect(isGlobalSettingsKey("reportMode")).toBe(false);
expect(isGlobalSettingsKey("reportModeByAction")).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("reportRoadmapDedupeEnabled")).toBe(true);
expect(isGlobalSettingsKey("reportRoadmapLabel")).toBe(true); expect(isGlobalSettingsKey("reportRoadmapLabel")).toBe(true);
expect(isGlobalSettingsKey("reportRoadmapRepo")).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, githubTrackingDefaultRepo: undefined,
reportMode: "draft-review" as const, reportMode: "draft-review" as const,
reportModeByAction: undefined, reportModeByAction: undefined,
// FNXC:ReportPipeline 2026-07-16-20:15: Unset targets preserve action-specific routing.
reportTarget: undefined,
reportTargetByAction: undefined,
reportDiscussionCategory: undefined,
reportRoadmapDedupeEnabled: true, reportRoadmapDedupeEnabled: true,
reportRoadmapLabel: "roadmap", reportRoadmapLabel: "roadmap",
reportRoadmapRepo: undefined, reportRoadmapRepo: undefined,

View File

@@ -3318,6 +3318,7 @@ export type SecretsEnvConfig = SecretsEnvSettings;
*/ */
export type ReportMode = "draft-review" | "auto-file"; export type ReportMode = "draft-review" | "auto-file";
export type ReportActionType = "bug" | "feedback" | "idea" | "help"; export type ReportActionType = "bug" | "feedback" | "idea" | "help";
export type ReportTarget = "issue" | "discussion";
export interface ProjectSettings { export interface ProjectSettings {
/** Hard stop: when true, all automated agent activity is **immediately** /** Hard stop: when true, all automated agent activity is **immediately**
@@ -4234,6 +4235,15 @@ export interface ProjectSettings {
*/ */
reportMode?: ReportMode; reportMode?: ReportMode;
reportModeByAction?: Partial<Record<ReportActionType, 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: * FNXC:ReportPipeline 2026-07-18-12:00:
* FR-30 public-roadmap dedupe is an additive GitHub Issue source. Effective * 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) { async function post(path: string, body: unknown) {
const response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); 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 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 reportDraft(input: ReportContextInput & { targetType?: ReportTarget }) { 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 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 }); } export function reportHelp(question: string) { return post("/api/report/help", { question }); }
/** Upload is intentionally multipart: screenshot bytes never join JSON report text. */ /** 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); if (!response.ok) throw new Error((await response.json().catch(() => ({ error: response.statusText }))).error ?? response.statusText);
return response.json() as Promise<{ artifactId: string }>; 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 { useRef, useState } from "react";
import { useTranslation } from "react-i18next"; 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 { reportAttachment, reportDraft, reportFile, reportHelp } from "../api";
import { captureScreenshot as captureScreen, getRecentActivity, recordActivity } from "../utils/report-capture"; import { captureScreenshot as captureScreen, getRecentActivity, recordActivity } from "../utils/report-capture";
import "./ReportModal.css"; import "./ReportModal.css";
@@ -23,6 +23,8 @@ export function ReportModal({ actionType, onClose, contextRefs }: { actionType:
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [screenshotEnabled, setScreenshotEnabled] = useState(false); 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 [screenshotArtifactId, setScreenshotArtifactId] = useState<string>();
const [retentionConfirmed, setRetentionConfirmed] = useState(false); const [retentionConfirmed, setRetentionConfirmed] = useState(false);
const captureGeneration = useRef(0); const captureGeneration = useRef(0);
@@ -57,7 +59,7 @@ export function ReportModal({ actionType, onClose, contextRefs }: { actionType:
const help = await reportHelp(prompt); const help = await reportHelp(prompt);
if (help.answered) { setResult({ kind: "help", answer: help.answer }); return; } 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 { } catch {
setError("We could not prepare your report. Check your connection and try again."); setError("We could not prepare your report. Check your connection and try again.");
} finally { setBusy(false); } } finally { setBusy(false); }
@@ -68,7 +70,7 @@ export function ReportModal({ actionType, onClose, contextRefs }: { actionType:
setError(undefined); setError(undefined);
try { try {
recordActivity("report"); 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 { } catch {
setError("We could not send your report. Your draft is still here; try again."); 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"> {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>} {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>} </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> <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></>} <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) => { {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, installSettingsModalEnv,
} from "./SettingsModal.test-harness"; } from "./SettingsModal.test-harness";
const mockListDiscussionCategories = vi.fn(async () => ({ categories: [] }));
vi.mock("../../api", async (importOriginal) => { vi.mock("../../api", async (importOriginal) => {
const { createDashboardApiMock } = await import("../../test/mockApi"); const { createDashboardApiMock } = await import("../../test/mockApi");
return createDashboardApiMock(() => importOriginal<typeof import("../../api")>(), { return createDashboardApiMock(() => importOriginal<typeof import("../../api")>(), {
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args), fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
listDiscussionCategories: (...args: unknown[]) => mockListDiscussionCategories(...args),
fetchSettingsByScope: (...args: unknown[]) => mockFetchSettingsByScope(...args), fetchSettingsByScope: (...args: unknown[]) => mockFetchSettingsByScope(...args),
updateSettings: (...args: unknown[]) => mockUpdateSettings(...args), updateSettings: (...args: unknown[]) => mockUpdateSettings(...args),
updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args), updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args),
@@ -209,6 +212,8 @@ describe("SettingsModal", () => {
afterEach(() => { afterEach(() => {
viewportMode = "mobile"; viewportMode = "mobile";
mockListDiscussionCategories.mockReset();
mockListDiscussionCategories.mockResolvedValue({ categories: [] });
}); });
const availableUpdate = { const availableUpdate = {
@@ -1227,6 +1232,19 @@ describe("SettingsModal", () => {
}); });
describe("General · Project", () => { 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 () => { it("renders completion documentation automation control", async () => {
renderModal({ initialSection: "general" }); renderModal({ initialSection: "general" });
await waitForSettingsModalReady(); await waitForSettingsModalReady();

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; 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 { 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 { SettingsFieldRow } from "../SettingsFieldRow";
import { SettingsToggleRow } from "../SettingsToggleRow"; 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 { localeDisplayName } from "@fusion/core/detect-content-language";
import { ProjectDefaultWorkflowField } from "../../WorkflowSelector"; import { ProjectDefaultWorkflowField } from "../../WorkflowSelector";
import { WorkflowIcon } from "../../WorkflowIcon"; import { WorkflowIcon } from "../../WorkflowIcon";
import { fetchWorkflows } from "../../../api"; import { fetchWorkflows, listDiscussionCategories, type DiscussionCategoryOption } from "../../../api";
import { clearAllLocalCache } from "../../../utils/swrCache"; import { clearAllLocalCache } from "../../../utils/swrCache";
import type { ToastType } from "../../../hooks/useToast"; import type { ToastType } from "../../../hooks/useToast";
import type { SectionBaseProps } from "./context"; import type { SectionBaseProps } from "./context";
@@ -48,7 +48,14 @@ export function GeneralSection({ form, setForm, projectId, addToast, prefixError
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const [builtinWorkflows, setBuiltinWorkflows] = useState<WorkflowDefinition[]>([]); const [builtinWorkflows, setBuiltinWorkflows] = useState<WorkflowDefinition[]>([]);
const [reportAction, setReportAction] = useState<ReportActionType | null>(null); const [reportAction, setReportAction] = useState<ReportActionType | null>(null);
const [discussionCategories, setDiscussionCategories] = useState<DiscussionCategoryOption[]>([]);
const reportContextRefs = typeof window === "undefined" ? undefined : resolveReportContextRefs(window.location); 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
fetchWorkflows(projectId, { includeDisabledBuiltins: true }) fetchWorkflows(projectId, { includeDisabledBuiltins: true })
@@ -354,6 +361,11 @@ export function GeneralSection({ form, setForm, projectId, addToast, prefixError
</select> </select>
</div> </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 <SettingsToggleRow
descriptor={{ descriptor={{
key: "reportRoadmapDedupeEnabled", 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 { 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" }; const settings = { reportRoadmapDedupeEnabled: false, reportMode: "draft-review" as const, githubTrackingDefaultRepo: "Runfusion/Fusion", githubAuthMode: "token", githubAuthToken: "test" };
function deps(overrides: Partial<ReportPipelineDeps> = {}): ReportPipelineDeps { function deps(overrides: Partial<ReportPipelineDeps> = {}): ReportPipelineDeps {
return { return {
projectSettings: settings, 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" }, scrubContext: { projectName: "private-project", rootDir: "/Users/alice/private-project" },
...overrides, ...overrides,
}; };
} }
describe("report pipeline", () => { 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) => { it.each(["bug", "feedback", "idea", "help"] as const)("structures the guided %s prompt without filing in review mode", async (actionType) => {
const context = deps(); const context = deps();
const result = await runReportPipeline({ actionType, userPrompt: "The private-project view failed at /Users/alice/private-project/a.ts" }, context); 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 () => { 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); const result = await runReportPipeline({ actionType: "feedback", userPrompt: "The report flow needs clearer status" }, context);
expect(result.kind).toBe("filed"); expect(result.kind).toBe("filed");
expect(context.client!.createDiscussion).toHaveBeenCalledOnce(); expect(context.client!.createDiscussion).toHaveBeenCalledOnce();
expect(context.client!.createIssue).not.toHaveBeenCalled(); 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 () => { it("automatically endorses an open duplicate in auto-file mode", async () => {
const client = { const client = {
createIssue: vi.fn(), createIssue: vi.fn(),

View File

@@ -28,6 +28,7 @@ function setup(projectSettings: Record<string, unknown> = { reportMode: "auto-fi
}); });
const router = { const router = {
post: vi.fn((path: string, ...routeHandlers: TestHandler[]) => handlers.set(path, routeHandlers)), 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; } as unknown as Router;
const store = { const store = {
getSettingsByScopeFast: vi.fn().mockResolvedValue({ project: projectSettings, global: {} }), 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", () => { describe("Help self-check", () => {
it.each(["/report/draft", "/report/file"])("does not let direct Help %s bypass a confident knowledge answer", async (path) => { 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." }]); 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(response.body).toMatchObject({ kind: "help", answer: { title: "Use settings" } });
expect(runReportPipeline).not.toHaveBeenCalled(); 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"; state: "open" | "closed";
} }
export interface DiscussionCategory {
id: string;
name: string;
slug: string;
}
export interface CreatedDiscussion { export interface CreatedDiscussion {
id: string; id: string;
number: number; number: number;
@@ -2296,6 +2302,23 @@ export class GitHubClient {
return matches; 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. */ /** Adds the same visible +1 signal to a Discussion duplicate as to an Issue duplicate. */
async addDiscussionReaction(discussionId: string): Promise<void> { async addDiscussionReaction(discussionId: string): Promise<void> {
const payload = await this.runGraphqlQuery<{ 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."); 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<{ const categoryPayload = await this.runGraphqlQuery<{
repository?: { id?: string; discussionCategories?: { nodes?: Array<{ id: string }> | null } | null } | null; repository?: { id?: string; discussionCategories?: { nodes?: Array<{ id: string }> | null } | null } | null;
}>(`query($owner:String!, $repo:String!) { }>(`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 }); }`, { owner, repo });
const repositoryId = categoryPayload?.repository?.id; const repositoryId = categoryPayload?.repository?.id;
const categoryId = categoryPayload?.repository?.discussionCategories?.nodes?.[0]?.id; const categories = categoryPayload?.repository?.discussionCategories?.nodes ?? [];
if (!repositoryId || !categoryId) throw new Error(`Discussions are not enabled for ${owner}/${repo}.`); // 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<{ const payload = await this.runGraphqlQuery<{
createDiscussion?: { discussion?: { id: string; number: number; url: string } | null } | null; createDiscussion?: { discussion?: { id: string; number: number; url: string } | null } | null;
}>(`mutation($repositoryId:ID!, $categoryId:ID!, $title:String!, $body:String!) { }>(`mutation($repositoryId:ID!, $categoryId:ID!, $title:String!, $body:String!) {

View File

@@ -17,7 +17,7 @@ export {
type RuntimeLogSink, type RuntimeLogSink,
} from "./runtime-logger.js"; } 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 { 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 { generatePrMetadata, type GeneratedPrMetadata } from "./pr-metadata-generator.js";
export { export {
resolvePrConflicts, resolvePrConflicts,
@@ -74,7 +74,7 @@ export {
type KnowledgeQueryOptions, type KnowledgeQueryOptions,
} from "./knowledge-index.js"; } from "./knowledge-index.js";
export { KnowledgeIndexRefreshService } from "./knowledge-index-refresh.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 { createProjectRoadmapDedupSource, createRoadmapDedupSourceForTaskStore, type RoadmapCandidate, type RoadmapDedupSource, type RoadmapDedupSourceDeps } from "./report-roadmap-source.js";
export { scrubReportPayload, scrubReportText, type ReportScrubContext } from "./report-scrub.js"; export { scrubReportPayload, scrubReportText, type ReportScrubContext } from "./report-scrub.js";
export { selfCheckHelp, type HelpKnowledgeResult } from "./report-help-selfcheck.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 { parseRepoSlug, resolveTaskGithubTracking } from "@fusion/core";
import { GitHubClient } from "./github.js"; import { GitHubClient } from "./github.js";
import { resolveGithubTrackingAuth } from "./github-auth.js"; import { resolveGithubTrackingAuth } from "./github-auth.js";
import { buildIssueSearchQueries, DEDUP_MATCH_THRESHOLD, scoreCandidateIssue } from "./github-tracking-dedup.js"; import { buildIssueSearchQueries, DEDUP_MATCH_THRESHOLD, scoreCandidateIssue } from "./github-tracking-dedup.js";
import { scrubReportPayload, type ReportScrubContext } from "./report-scrub.js"; import { scrubReportPayload, type ReportScrubContext } from "./report-scrub.js";
export type { ReportActionType, ReportMode }; export type { ReportActionType, ReportMode, ReportTarget };
export interface ReportInput { export interface ReportInput {
actionType: ReportActionType; actionType: ReportActionType;
@@ -37,9 +37,9 @@ export type ReportResult =
| { kind: "unavailable"; reason: string; message: string }; | { kind: "unavailable"; reason: string; message: string };
export interface ReportPipelineDeps { 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>; 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; scrubContext?: ReportScrubContext;
gatherContext?: (input: ReportInput) => Promise<Record<string, unknown>>; gatherContext?: (input: ReportInput) => Promise<Record<string, unknown>>;
} }
@@ -110,13 +110,19 @@ function resolveRepo(deps: ReportPipelineDeps) {
return resolveTaskGithubTracking({ githubTracking: undefined }, deps.projectSettings, deps.globalSettings).repo; 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 }; type DuplicateCandidate = { number: number; title: string; body: string | null; html_url: string; state: "open" | "closed"; discussionId?: string };
function destinationFor(actionType: ReportActionType): ReportDestination { function destinationFor(actionType: ReportActionType): ReportDestination {
return actionType === "feedback" || actionType === "help" ? "discussion" : "issue"; 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[] { function reportKeywords(report: StructuredReport): string[] {
return report.summary.replace(/[^\w ]/g, " ").split(/\s+/).filter((word) => word.length > 3).slice(0, 6); 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 gathered = await deps.gatherContext?.(input) ?? { taskId: input.contextRefs?.taskId, agentId: input.contextRefs?.agentId };
const normalized = normalizeSubmittedReport(input, gathered, options.report); const normalized = normalizeSubmittedReport(input, gathered, options.report);
const report: StructuredReport = scrubReportPayload(normalized, deps.scrubContext); 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. issue +1/scrub path; unavailable roadmap search falls through without egress.
*/ */
const roadmapDuplicate = await findRoadmapDuplicate(clientResult.client, roadmap, report); const roadmapDuplicate = await findRoadmapDuplicate(clientResult.client, roadmap, report);
const destination = destinationFor(input.actionType); const destination = resolveReportTarget(input.actionType, deps.projectSettings, options.targetType);
const duplicate = await findDuplicate(clientResult.client, repo.owner, repo.repo, report, destination); 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 (options.endorseRoadmapIssueNumber) {
if (!roadmapDuplicate || roadmapDuplicate.number !== options.endorseRoadmapIssueNumber || !roadmap.repo) { 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." }; 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) { if (!duplicate.discussionId || !clientResult.client.commentOnDiscussion || !clientResult.client.addDiscussionReaction) {
return { kind: "unavailable", reason: "discussion_unsupported", message: "This GitHub connection cannot endorse discussions." }; 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 }); try {
return endorsed; 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 }); const endorsed = await endorseDuplicate({ owner: repo.owner, repo: repo.repo, issueNumber: duplicate.number, report, client: clientResult.client, scrubContext: deps.scrubContext });
return endorsed; 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 (!options.file && mode === "draft-review") return { kind: "draft-ready", report, mode };
if (destination === "discussion") { if (destination === "discussion") {
if (!clientResult.client.createDiscussion || !clientResult.client.commentOnDiscussion) return { kind: "unavailable", reason: "discussion_unsupported", message: "This GitHub connection cannot create discussions." }; 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); const configuredCategory = deps.projectSettings.reportDiscussionCategory;
return { kind: "filed", url: created.htmlUrl, report }; 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"] }); 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 }; 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 type { Request, Response } from "express";
import { ApiError } from "../api-error.js"; import { ApiError } from "../api-error.js";
import { GitHubClient } from "../github.js";
import { resolveGithubTrackingAuth } from "../github-auth.js";
import { queryKnowledgePagesAsync } from "../knowledge-index.js"; import { queryKnowledgePagesAsync } from "../knowledge-index.js";
import { requireAsyncLayer } from "../require-async-layer.js"; import { requireAsyncLayer } from "../require-async-layer.js";
import { runReportPipeline, type ReportInput, type StructuredReport } from "../report-pipeline.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"; import type { ApiRouteRegistrar } from "./types.js";
const ACTION_TYPES = new Set(["bug", "feedback", "idea", "help"]); 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_ENTRIES = 20;
const MAX_ACTIVITY_TRACE_CHARS = 4_000; const MAX_ACTIVITY_TRACE_CHARS = 4_000;
export const MAX_SCREENSHOT_BYTES = 2 * 1024 * 1024; export const MAX_SCREENSHOT_BYTES = 2 * 1024 * 1024;
@@ -27,7 +30,7 @@ function parseActivityTrace(value: unknown): string[] | undefined {
function parseScreenshotArtifactId(value: unknown): string | undefined { function parseScreenshotArtifactId(value: unknown): string | undefined {
if (value === undefined) return undefined; if (value === undefined) return undefined;
if (typeof value !== "string" || !ARTIFACT_ID_PATTERN.test(value)) throw new ApiError(400, "Screenshot artifact reference is invalid."); 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> { 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 })); 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 { function parseInput(body: unknown): ReportInput {
const value = (body ?? {}) as Record<string, unknown>; const value = (body ?? {}) as Record<string, unknown>;
const actionType = typeof value.actionType === "string" ? value.actionType : ""; 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) => { router.post("/report/draft", async (req, res) => {
try { try {
const store = await getScopedStore(req); const scopes = await store.getSettingsByScopeFast(); const input = parseInput(req.body); 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); await validateScreenshotArtifact(store, input.screenshotArtifactId);
const help = await selfCheckHelpBeforePipeline(store, input); const help = await selfCheckHelpBeforePipeline(store, input);
if (help?.answered) return void res.json({ kind: "help", answer: help.answer }); 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"); } } 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 { screenshotArtifactId: reportArtifactId, ...textualRawReport } = rawReport;
const untrusted = scrubReportPayload(textualRawReport, { rootDir: store.getRootDir(), projectName: store.getRootDir().split(/[\\/]/).pop() }); 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 }); 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); await validateScreenshotArtifact(store, input.screenshotArtifactId);
const help = await selfCheckHelpBeforePipeline(store, input); const help = await selfCheckHelpBeforePipeline(store, input);
if (help?.answered) return void res.json({ kind: "help", answer: help.answer }); 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"); } } 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"); } }); 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", "reportRoadmapDedupeEnabled": "Deduplicate reports against public roadmap",
"reportRoadmapDedupeEnabledHelp": "Match open, labeled roadmap issues before filing. Default: on.", "reportRoadmapDedupeEnabledHelp": "Match open, labeled roadmap issues before filing. Default: on.",
"reportRoadmapLabel": "Public roadmap label", "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": { "globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": " and shows update notices in the CLI and dashboard. Cadence is governed by the frequency below. Default: enabled. ", "andShowsUpdateNoticesInTheCLIAnd": " and shows update notices in the CLI and dashboard. Cadence is governed by the frequency below. Default: enabled. ",
@@ -9155,6 +9173,10 @@
}, },
"roadmapDuplicate": { "roadmapDuplicate": {
"title": "Already on the roadmap — add your data point?" "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", "reportRoadmapDedupeEnabled": "Deduplicate reports against public roadmap",
"reportRoadmapDedupeEnabledHelp": "Match open, labeled roadmap issues before filing. Default: on.", "reportRoadmapDedupeEnabledHelp": "Match open, labeled roadmap issues before filing. Default: on.",
"reportRoadmapLabel": "Public roadmap label", "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": { "globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "", "andShowsUpdateNoticesInTheCLIAnd": "",
@@ -9154,6 +9172,10 @@
}, },
"roadmapDuplicate": { "roadmapDuplicate": {
"title": "Already on the roadmap — add your data point?" "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", "reportRoadmapDedupeEnabled": "Deduplicate reports against public roadmap",
"reportRoadmapDedupeEnabledHelp": "Match open, labeled roadmap issues before filing. Default: on.", "reportRoadmapDedupeEnabledHelp": "Match open, labeled roadmap issues before filing. Default: on.",
"reportRoadmapLabel": "Public roadmap label", "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": { "globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "", "andShowsUpdateNoticesInTheCLIAnd": "",
@@ -9154,6 +9172,10 @@
}, },
"roadmapDuplicate": { "roadmapDuplicate": {
"title": "Already on the roadmap — add your data point?" "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", "reportRoadmapDedupeEnabled": "Deduplicate reports against public roadmap",
"reportRoadmapDedupeEnabledHelp": "Match open, labeled roadmap issues before filing. Default: on.", "reportRoadmapDedupeEnabledHelp": "Match open, labeled roadmap issues before filing. Default: on.",
"reportRoadmapLabel": "Public roadmap label", "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": { "globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "", "andShowsUpdateNoticesInTheCLIAnd": "",
@@ -9154,6 +9172,10 @@
}, },
"roadmapDuplicate": { "roadmapDuplicate": {
"title": "Already on the roadmap — add your data point?" "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)", "reportRoadmapRepo": "Public roadmap repository (optional)",
"reportRoadmapRepoHelp": "GitHub owner/repository containing public roadmap issues. When unset, uses the tracking repository. No default — unset.", "reportRoadmapRepoHelp": "GitHub owner/repository containing public roadmap issues. When unset, uses the tracking repository. No default — unset.",
"reviewArtifacts": "评审产物", "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": { "globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "", "andShowsUpdateNoticesInTheCLIAnd": "",
@@ -9151,6 +9169,10 @@
"roadmapMatch": { "roadmapMatch": {
"title": "已在路线图中", "title": "已在路线图中",
"message": "此报告与已经规划的功能相匹配。" "message": "此报告与已经规划的功能相匹配。"
} },
"targetLabel": "提交目标",
"targetInherit": "使用已配置的操作目标",
"targetIssue": "GitHub 议题",
"targetDiscussion": "GitHub 讨论"
} }
} }

View File

@@ -5953,7 +5953,25 @@
"reportRoadmapRepo": "Public roadmap repository (optional)", "reportRoadmapRepo": "Public roadmap repository (optional)",
"reportRoadmapRepoHelp": "GitHub owner/repository containing public roadmap issues. When unset, uses the tracking repository. No default — unset.", "reportRoadmapRepoHelp": "GitHub owner/repository containing public roadmap issues. When unset, uses the tracking repository. No default — unset.",
"reviewArtifacts": "審查產物", "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": { "globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "", "andShowsUpdateNoticesInTheCLIAnd": "",
@@ -9151,6 +9169,10 @@
"roadmapMatch": { "roadmapMatch": {
"title": "已在路線圖中", "title": "已在路線圖中",
"message": "此報告與已規劃的功能相符。" "message": "此報告與已規劃的功能相符。"
} },
"targetLabel": "提交目標",
"targetInherit": "使用設定的操作目標",
"targetIssue": "GitHub 議題",
"targetDiscussion": "GitHub 討論"
} }
} }