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:
7
.changeset/fn-8308-report-discussions.md
Normal file
7
.changeset/fn-8308-report-discussions.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
44
packages/dashboard/src/__tests__/github-discussions.test.ts
Normal file
44
packages/dashboard/src/__tests__/github-discussions.test.ts
Normal 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");
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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!) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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"); } });
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 토론"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 讨论"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 討論"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user