FN-8327: fall back to issues when Discussions are disabled

Reports can select a Discussion category and reliably file to Issues when Discussions are unavailable.

- Add Discussion-disabled detection across GitHub GraphQL operations.
- Let report submission select a category and disclose the actual filing destination.
- Preserve duplicate handling and document the fallback behavior.

Files changed:
 .changeset/fn-8327-discussion-fallback.md          |  7 +++
 docs/dashboard-guide.md                            |  4 +-
 packages/dashboard/app/api/report.ts               | 22 +++++--
 packages/dashboard/app/components/ReportModal.tsx  |  7 ++-
 .../app/components/__tests__/ReportModal.test.tsx  | 13 ++++
 .../src/__tests__/github-discussions.test.ts       | 29 +++++++--
 .../src/__tests__/report-pipeline.test.ts          | 63 ++++++++++++++-----
 .../dashboard/src/__tests__/report-routes.test.ts  | 15 +++--
 packages/dashboard/src/github.ts                   | 71 ++++++++++++++++------
 packages/dashboard/src/report-pipeline.ts          | 54 +++++++++++-----
 .../dashboard/src/routes/register-report-routes.ts | 12 +++-
 11 files changed, 227 insertions(+), 70 deletions(-)

Fusion-Task-Id: FN-8327

Fusion-Task-Lineage: 510d3d4f-ffc9-4167-bee8-7043e0ddbd9c

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-19 20:42:05 -07:00
parent f6c788dee4
commit a11f789b0c
11 changed files with 227 additions and 70 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: File Feedback and Help reports as Issues when GitHub Discussions is disabled.
category: feature
dev: Reuses the reportDiscussionCategory setting and exposes the final filing destination.

View File

@@ -2,9 +2,9 @@
## 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.
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. The `reportDiscussionCategory` setting selects the category for Discussion reports.
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.
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. If a repository has Discussions disabled, Fusion detects that during either Discussion search or creation, reruns Issue deduplication, and files an Issue instead; the filed result explicitly identifies that fallback destination.
[← Docs index](./README.md)

View File

@@ -1,15 +1,27 @@
import type { ReportActionType, ReportTarget } from "@fusion/core";
async function post(path: string, body: unknown) {
async function post<T>(path: string, body: unknown): Promise<T> {
const response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
if (!response.ok) throw new Error((await response.json().catch(() => ({ error: response.statusText }))).error ?? response.statusText);
return response.json();
return response.json() as Promise<T>;
}
/** Filed report responses expose the actual GitHub destination after fallback. */
export interface FiledReportResponse { kind: "filed"; url: string; destination: "issue" | "discussion"; }
export interface ReportResponse {
kind: string;
destination?: "issue" | "discussion";
url?: string;
message?: string;
report?: { userPrompt: string; sourcePrompt?: string; summary?: string; body?: string; context?: Record<string, unknown>; sessionToken?: string };
issue?: { number: number; url: string; title: string; discussionId?: string; roadmap?: true };
answer?: { summary?: string; content?: string };
}
export interface ReportContextInput { actionType: ReportActionType; userPrompt: string; contextRefs?: { taskId?: string; agentId?: string }; activityTrace?: string[]; screenshotArtifactId?: string; }
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 }); }
export function reportDraft(input: ReportContextInput & { targetType?: ReportTarget; discussionCategoryId?: string }) { return post<ReportResponse>("/api/report/draft", input); }
export function reportFile(input: { actionType: ReportActionType; report: unknown; targetType?: ReportTarget; discussionCategoryId?: string; endorseIssueNumber?: number; endorseDiscussionId?: string; endorseRoadmapIssueNumber?: number; activityTrace?: string[]; screenshotArtifactId?: string }) { return post<ReportResponse>("/api/report/file", input); }
export function reportHelp(question: string) { return post<{ answered?: boolean; answer?: { summary?: string; content?: string } }>("/api/report/help", { question }); }
/** Upload is intentionally multipart: screenshot bytes never join JSON report text. */
export async function reportAttachment(screenshot: Blob): Promise<{ artifactId: string }> {

View File

@@ -8,7 +8,7 @@ import "./ReportModal.css";
const prompts: Record<ReportActionType, string> = { bug: "What went wrong?", feedback: "What would you like to share?", idea: "What would you like Fusion to do?", help: "What would you like help with?" };
type ModalResult = { kind: string; report?: { userPrompt: string; sourcePrompt?: string; summary?: string; body?: string; context?: Record<string, unknown>; sessionToken?: string }; issue?: { number: number; url: string; title: string; discussionId?: string; roadmap?: true }; url?: string; answer?: { summary?: string; content?: string }; message?: string; screenshotNotAttached?: boolean };
type ModalResult = { kind: string; destination?: "issue" | "discussion"; report?: { userPrompt: string; sourcePrompt?: string; summary?: string; body?: string; context?: Record<string, unknown>; sessionToken?: string }; issue?: { number: number; url: string; title: string; discussionId?: string; roadmap?: true }; url?: string; answer?: { summary?: string; content?: string }; message?: string; screenshotNotAttached?: boolean };
/**
@@ -119,7 +119,10 @@ setResult(await reportFile({ actionType, targetType, report: result.report, endo
<button className="btn btn-primary" type="button" disabled={busy} onClick={() => void file(result.issue!.discussionId ? undefined : result.issue!.roadmap ? undefined : result.issue!.number, result.issue!.discussionId, result.issue!.roadmap ? result.issue!.number : undefined)}>Confirm and add data point</button>
</>}
{(result?.kind === "filed" || result?.kind === "endorsed") && <><h2>Report sent</h2><a href={result.url} target="_blank" rel="noreferrer">View on GitHub</a>{result.report?.body && <><label htmlFor="filed-report">Final report</label><textarea id="filed-report" className="input" value={result.report.body} readOnly /></>}</>}
{(result?.kind === "filed" || result?.kind === "endorsed") && <>
{/* FNXC:ReportPipeline 2026-07-18-12:30: When disabled Discussions fall back to
Issues, state the actual filed destination rather than implying the report became a Discussion. */}
<h2>{result.kind === "filed" && result.destination === "issue" ? "Report filed as an Issue" : "Report sent"}</h2><a href={result.url} target="_blank" rel="noreferrer">View on GitHub</a>{result.report?.body && <><label htmlFor="filed-report">Final report</label><textarea id="filed-report" className="input" value={result.report.body} readOnly /></>}</>}
{result?.kind === "help" && <><h2>Suggested help</h2><p>{result.answer?.summary ?? result.answer?.content}</p></>}
{result?.kind === "unavailable" && <>

View File

@@ -132,6 +132,19 @@ describe("ReportModal", () => {
expect(screen.getByLabelText("What would you like to share?")).toBeInTheDocument();
});
it.each([
["issue", "Report filed as an Issue"],
["discussion", "Report sent"],
] as const)("shows the %s filing destination", async (destination, message) => {
reportDraft.mockResolvedValueOnce({ kind: "filed", destination, url: "https://example.test/1" });
render(<ReportModal actionType="feedback" onClose={vi.fn()} />);
fireEvent.change(screen.getByLabelText("What would you like to share?"), { target: { value: "A thought" } });
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
expect(await screen.findByText(message)).toBeInTheDocument();
});
it("keeps the original derivation marker when the review prompt is edited", async () => {
reportDraft.mockResolvedValueOnce({ kind: "draft-ready", report: { userPrompt: "It crashes", sourcePrompt: "It crashes", body: "## Summary\nIt crashes\n\n## Environment\nCollected context", context: {} } });
reportFile.mockResolvedValueOnce({ kind: "filed", url: "https://example.test/1" });

View File

@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { GitHubClient } from "../github.js";
import { DiscussionsDisabledError, GitHubClient } from "../github.js";
describe("GitHub Discussions GraphQL transport", () => {
afterEach(() => vi.unstubAllGlobals());
@@ -32,13 +32,32 @@ describe("GitHub Discussions GraphQL transport", () => {
});
it("requires callers to provide a validated category instead of choosing the first one", async () => {
it("maps disabled Discussions errors from search to a typed signal", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({
data: { repository: { id: "R_1", discussionCategories: { nodes: [{ id: "DC_1" }] } } },
errors: [{ message: "Discussions are disabled for this repository" }],
}), { status: 200 })));
await expect(new GitHubClient({ token: "test", forceMode: "token" }).createDiscussion("Runfusion", "Fusion", "Title", "Body"))
.rejects.toThrow("Discussion category is unavailable");
await expect(new GitHubClient({ token: "test", forceMode: "token" }).searchDiscussions("Runfusion", "Fusion", "report"))
.rejects.toBeInstanceOf(DiscussionsDisabledError);
});
it("maps disabled Discussions errors from create to the same typed signal", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({
errors: [{ message: "Discussions are not enabled for this repository" }],
}), { status: 200 })));
await expect(new GitHubClient({ token: "test", forceMode: "token" }).createDiscussion("Runfusion", "Fusion", "Title", "Body", "DC_1"))
.rejects.toBeInstanceOf(DiscussionsDisabledError);
});
it("uses the first category when the selected category is stale", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ data: { repository: { id: "R_1", discussionCategories: { nodes: [{ id: "DC_1" }] } } } }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ data: { createDiscussion: { discussion: { id: "D_1", number: 1, url: "https://github.com/Runfusion/Fusion/discussions/1" } } } }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
await new GitHubClient({ token: "test", forceMode: "token" }).createDiscussion("Runfusion", "Fusion", "Title", "Body", "stale-category");
expect(JSON.parse(String(fetchMock.mock.calls[1][1].body)).variables.categoryId).toBe("DC_1");
});
});

View File

@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { DiscussionsDisabledError } from "../github.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" };
@@ -125,11 +126,11 @@ describe("report pipeline", () => {
expect(context.client!.createIssue).toHaveBeenCalledOnce();
});
it("files feedback as a repository discussion in auto-file mode", async () => {
it("files enabled Discussions with the configured category and destination", async () => {
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(result).toMatchObject({ kind: "filed", destination: "discussion" });
expect(context.client!.createDiscussion).toHaveBeenCalledWith("Runfusion", "Fusion", expect.any(String), expect.any(String), "DC_ideas");
expect(context.client!.createIssue).not.toHaveBeenCalled();
});
@@ -142,11 +143,10 @@ describe("report pipeline", () => {
});
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("uses an explicit Discussion category before the FN-8308 persisted default", async () => {
const context = deps({ projectSettings: { ...settings, reportMode: "auto-file", reportDiscussionCategory: "DC_default" } });
await runReportPipeline({ actionType: "feedback", userPrompt: "Report status needs clarity" }, context, { discussionCategoryId: "DC_explicit" });
expect(context.client!.createDiscussion).toHaveBeenCalledWith("Runfusion", "Fusion", expect.any(String), expect.any(String), "DC_explicit");
});
it("maps Discussion search and endorsement GraphQL failures to a safe unavailable result", async () => {
@@ -169,13 +169,46 @@ describe("report pipeline", () => {
.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("ignores a supplied Discussion category for Issue-targeted actions", async () => {
const context = deps({ projectSettings: { ...settings, reportMode: "auto-file" } });
const result = await runReportPipeline({ actionType: "bug", userPrompt: "Report failure needs attention" }, context, { discussionCategoryId: "DC_ignored" });
expect(result).toMatchObject({ kind: "filed", destination: "issue" });
expect(context.client!.createDiscussion).not.toHaveBeenCalled();
expect(context.client!.createIssue).toHaveBeenCalledOnce();
});
it.each([
["draft-review", false],
["draft-review", true],
["auto-file", false],
] as const)("falls back to Issue after a disabled Discussion search in %s mode", async (reportMode, file) => {
const client = {
...deps().client!,
searchDiscussions: vi.fn().mockRejectedValue(new DiscussionsDisabledError("Runfusion", "Fusion")),
searchIssues: vi.fn().mockResolvedValue([]),
};
const result = await runReportPipeline({ actionType: "feedback", userPrompt: "Report status needs clarity" }, deps({ projectSettings: { ...settings, reportMode }, client }), { file });
expect(client.searchIssues).toHaveBeenCalled();
if (file || reportMode === "auto-file") {
expect(result).toMatchObject({ kind: "filed", destination: "issue" });
expect(client.createIssue).toHaveBeenCalledOnce();
} else {
expect(result.kind).toBe("draft-ready");
expect(client.createIssue).not.toHaveBeenCalled();
}
});
it("falls back to an Issue after create reports disabled Discussions", async () => {
const client = {
...deps().client!,
searchDiscussions: vi.fn().mockResolvedValue([]),
searchIssues: vi.fn().mockResolvedValue([]),
createDiscussion: vi.fn().mockRejectedValue(new DiscussionsDisabledError("Runfusion", "Fusion")),
};
const result = await runReportPipeline({ actionType: "feedback", userPrompt: "Report status needs clarity" }, deps({ projectSettings: { ...settings, reportMode: "auto-file", reportDiscussionCategory: "DC_ideas" }, client }));
expect(client.searchIssues).toHaveBeenCalled();
expect(client.createIssue).toHaveBeenCalledOnce();
expect(result).toMatchObject({ kind: "filed", destination: "issue" });
});
it("automatically endorses an open duplicate in auto-file mode", async () => {

View File

@@ -168,13 +168,20 @@ describe("report routes", () => {
});
});
it.each(["/report/draft", "/report/file"])("passes valid targetType through the filing pipeline on %s", async (path) => {
it.each(["/report/draft", "/report/file"])("passes target and category inputs 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 } : {}) });
? { actionType: "bug", targetType: "discussion", discussionCategoryId: "DC_ideas", report: { userPrompt: "Dashboard report controls", context: {} } }
: { actionType: "bug", targetType: "discussion", discussionCategoryId: "DC_ideas", userPrompt: "Dashboard report controls" });
expect(vi.mocked(runReportPipeline).mock.calls.at(-1)?.[2]).toMatchObject({ targetType: "discussion", discussionCategoryId: "DC_ideas", ...(path === "/report/file" ? { file: true } : {}) });
});
it("returns the pipeline's Issue fallback destination unchanged", async () => {
vi.mocked(runReportPipeline).mockResolvedValue({ kind: "filed", url: "https://github.com/Runfusion/Fusion/issues/42", destination: "issue", report: {} } as never);
const { handlers } = setup();
const response = await invoke(handlers.get("/report/file")!, { actionType: "feedback", report: { userPrompt: "Dashboard report controls", context: {} } });
expect(response.body).toMatchObject({ kind: "filed", destination: "issue" });
});
describe("Help self-check", () => {

View File

@@ -267,6 +267,27 @@ export interface DiscussionCategory {
slug: string;
}
/** A repository capability error that allows report delivery to fall back to Issues. */
export class DiscussionsDisabledError extends Error {
override readonly name = "DiscussionsDisabledError";
constructor(owner: string, repo: string, cause?: unknown) {
super(`Discussions are not enabled for ${owner}/${repo}.`, { cause });
}
}
export function isDiscussionsDisabledError(error: unknown): error is DiscussionsDisabledError {
return error instanceof DiscussionsDisabledError;
}
function mapDiscussionsDisabledError(owner: string, repo: string, error: unknown): never {
const message = error instanceof Error ? error.message : String(error);
if (/discussions? (?:are |is )?(?:not )?(?:enabled|disabled)|discussion.*disabled/i.test(message)) {
throw new DiscussionsDisabledError(owner, repo, error);
}
throw error;
}
export interface CreatedDiscussion {
id: string;
number: number;
@@ -2342,9 +2363,14 @@ export class GitHubClient {
so reports cannot silently miss a duplicate merely because it is inactive.
*/
while (hasNextPage && matches.length < limit) {
const payload: DiscussionSearchPayload | undefined = await this.runGraphqlQuery<DiscussionSearchPayload>(`query($owner:String!, $repo:String!, $cursor:String) {
repository(owner:$owner, name:$repo) { discussions(first:100, after:$cursor, orderBy:{field:UPDATED_AT, direction:DESC}) { nodes { id number title body url isClosed } pageInfo { hasNextPage endCursor } } }
}`, { owner, repo, cursor });
let payload: DiscussionSearchPayload | undefined;
try {
payload = await this.runGraphqlQuery<DiscussionSearchPayload>(`query($owner:String!, $repo:String!, $cursor:String) {
repository(owner:$owner, name:$repo) { discussions(first:100, after:$cursor, orderBy:{field:UPDATED_AT, direction:DESC}) { nodes { id number title body url isClosed } pageInfo { hasNextPage endCursor } } }
}`, { owner, repo, cursor });
} catch (error) {
mapDiscussionsDisabledError(owner, repo, error);
}
const discussions: DiscussionConnection | undefined = payload?.repository?.discussions ?? undefined;
for (const discussion of discussions?.nodes ?? []) {
if (discussion.isClosed) continue;
@@ -2386,26 +2412,33 @@ export class GitHubClient {
if (!payload?.addReaction?.reaction) throw new Error("GitHub did not return the discussion reaction.");
}
/*
FNXC:ReportPipeline 2026-07-18-12:00:
FN-8308 owns category discovery and the reportDiscussionCategory setting.
A stale or absent selected category deterministically uses the repository's
first category, while disabled Discussions is a typed signal for Issue fallback.
*/
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:100) { nodes { id } } }
}`, { owner, repo });
let categoryPayload: { repository?: { id?: string; discussionCategories?: { nodes?: Array<{ id: string }> | null } | null } | null } | undefined;
try {
categoryPayload = await this.runGraphqlQuery(`query($owner:String!, $repo:String!) {
repository(owner:$owner, name:$repo) { id discussionCategories(first:100) { nodes { id } } }
}`, { owner, repo });
} catch (error) {
mapDiscussionsDisabledError(owner, repo, error);
}
const repositoryId = categoryPayload?.repository?.id;
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 categoryId = categories.find((category) => category.id === selectedCategoryId)?.id ?? categories[0]?.id;
if (!repositoryId || !categoryId) throw new DiscussionsDisabledError(owner, repo);
let payload: { createDiscussion?: { discussion?: { id: string; number: number; url: string } | null } | null } | undefined;
try {
payload = await this.runGraphqlQuery(`mutation($repositoryId:ID!, $categoryId:ID!, $title:String!, $body:String!) {
createDiscussion(input:{repositoryId:$repositoryId, categoryId:$categoryId, title:$title, body:$body}) { discussion { id number url } }
}`, { repositoryId, categoryId, title, body });
} catch (error) {
mapDiscussionsDisabledError(owner, repo, error);
}
const payload = await this.runGraphqlQuery<{
createDiscussion?: { discussion?: { id: string; number: number; url: string } | null } | null;
}>(`mutation($repositoryId:ID!, $categoryId:ID!, $title:String!, $body:String!) {
createDiscussion(input:{repositoryId:$repositoryId, categoryId:$categoryId, title:$title, body:$body}) { discussion { id number url } }
}`, { repositoryId, categoryId, title, body });
const discussion = payload?.createDiscussion?.discussion;
if (!discussion) throw new Error("GitHub did not return the created discussion.");
return { id: discussion.id, number: discussion.number, htmlUrl: discussion.url };

View File

@@ -1,7 +1,7 @@
import { createHash } from "node:crypto";
import type { GlobalSettings, ProjectSettings, ReportActionType, ReportMode, ReportTarget } from "@fusion/core";
import { parseRepoSlug, resolveTaskGithubTracking } from "@fusion/core";
import { GitHubClient } from "./github.js";
import { GitHubClient, isDiscussionsDisabledError } from "./github.js";
import { EXT_BY_MIME } from "./issue-image-attachments.js";
import { resolveGithubTrackingAuth } from "./github-auth.js";
import { buildIssueSearchQueries, DEDUP_MATCH_THRESHOLD, scoreCandidateIssue } from "./github-tracking-dedup.js";
@@ -43,7 +43,7 @@ export interface StructuredReport {
export type ReportResult =
| { kind: "draft-ready"; report: StructuredReport; mode: ReportMode }
| { kind: "duplicate-found"; report: StructuredReport; mode: ReportMode; issue: { number: number; url: string; title: string; discussionId?: string; roadmap?: true } }
| { kind: "filed"; url: string; report: StructuredReport }
| { kind: "filed"; url: string; report: StructuredReport; destination: "issue" | "discussion" }
| { kind: "endorsed"; url: string; issueNumber: number; report: StructuredReport }
| { kind: "unavailable"; reason: string; message: string };
@@ -280,7 +280,7 @@ function normalizeSubmittedReport(input: ReportInput, gathered: Record<string, u
};
}
export async function runReportPipeline(input: ReportInput, deps: ReportPipelineDeps, options: { file?: boolean; targetType?: ReportTarget; endorseIssueNumber?: number; endorseDiscussionId?: string; endorseRoadmapIssueNumber?: number; report?: StructuredReport } = {}): Promise<ReportResult> {
export async function runReportPipeline(input: ReportInput, deps: ReportPipelineDeps, options: { file?: boolean; targetType?: ReportTarget; discussionCategoryId?: string; 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);
// Buffers are not text scrub input; retain the already route-validated attachment separately.
@@ -299,12 +299,19 @@ 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 = resolveReportTarget(input.actionType, deps.projectSettings, options.targetType);
let 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.");
try {
duplicate = await findDuplicate(clientResult.client, repo.owner, repo.repo, report, destination);
} catch (error) {
if (destination === "discussion" && isDiscussionsDisabledError(error)) {
destination = "issue";
duplicate = await findDuplicate(clientResult.client, repo.owner, repo.repo, report, destination);
} else if (destination === "discussion") {
return { kind: "unavailable", reason: "discussion_unavailable", message: "GitHub Discussions are unavailable for this repository or token." };
} else {
throw new Error("GitHub Issue duplicate search failed.");
}
}
if (options.endorseRoadmapIssueNumber) {
if (!roadmapDuplicate || roadmapDuplicate.number !== options.endorseRoadmapIssueNumber || !roadmap.repo) {
@@ -350,18 +357,33 @@ 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 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." };
let categoryId = options.discussionCategoryId;
if (!categoryId) {
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." };
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 embeddedReport = await embedScreenshot({ report, client: clientResult.client, owner: repo.owner, repo: repo.repo, scrubContext: deps.scrubContext });
const created = await clientResult.client.createDiscussion(repo.owner, repo.repo, embeddedReport.summary, embeddedReport.body, categoryId);
return { kind: "filed", url: created.htmlUrl, report: embeddedReport };
} catch { return { kind: "unavailable", reason: "discussion_unavailable", message: "GitHub Discussions are unavailable for this repository or token." }; }
return { kind: "filed", url: created.htmlUrl, report: embeddedReport, destination: "discussion" };
} catch (error) {
if (!isDiscussionsDisabledError(error)) return { kind: "unavailable", reason: "discussion_unavailable", message: "GitHub Discussions are unavailable for this repository or token." };
/*
FNXC:ReportPipeline 2026-07-18-12:15:
Disabled Discussions can surface during dedupe or creation. Both signals
must switch to Issue dedupe before filing, and filed results disclose the
actual destination so reporters are never told a Discussion was created.
*/
const issueDuplicate = await findDuplicate(clientResult.client, repo.owner, repo.repo, report, "issue");
if (issueDuplicate) {
if (mode === "auto-file") return endorseDuplicate({ owner: repo.owner, repo: repo.repo, issueNumber: issueDuplicate.number, report: await embedScreenshot({ report, client: clientResult.client, owner: repo.owner, repo: repo.repo, scrubContext: deps.scrubContext }), client: clientResult.client, scrubContext: deps.scrubContext });
return { kind: "duplicate-found", report, mode, issue: { number: issueDuplicate.number, url: issueDuplicate.html_url, title: issueDuplicate.title } };
}
}
}
const embeddedReport = await embedScreenshot({ report, client: clientResult.client, owner: repo.owner, repo: repo.repo, scrubContext: deps.scrubContext });
const created = await clientResult.client.createIssue({ owner: repo.owner, repo: repo.repo, title: embeddedReport.summary, body: embeddedReport.body, labels: ["community"] });
return { kind: "filed", url: created.htmlUrl, report: embeddedReport };
return { kind: "filed", url: created.htmlUrl, report: embeddedReport, destination: "issue" };
}

View File

@@ -44,6 +44,12 @@ async function resolveReportScreenshot(store: Awaited<ReturnType<Parameters<ApiR
return { artifactId: artifact.id, filename: artifact.title || "Report screenshot", mimeType: artifact.mimeType, bytes };
}
function parseDiscussionCategoryId(value: unknown): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string" || !value.trim()) throw new ApiError(400, "Discussion category must be a non-empty string.");
return value;
}
async function validateScreenshotArtifact(store: Awaited<ReturnType<Parameters<ApiRouteRegistrar>[0]["getScopedStore"]>>, id: string | undefined): Promise<void> {
if (!id) return;
const artifact = await store.getArtifact(id);
@@ -116,10 +122,11 @@ export const registerReportRoutes: ApiRouteRegistrar = ({ router, getScopedStore
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);
const discussionCategoryId = parseDiscussionCategoryId((req.body as Record<string, unknown> | undefined)?.discussionCategoryId);
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>) }, targetType ? { targetType } : {}));
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 } : {}), ...(discussionCategoryId ? { discussionCategoryId } : {}) }));
} catch (error) { if (error instanceof ApiError) throw error; rethrowAsApiError(error, "Failed to prepare report draft"); }
});
@@ -131,11 +138,12 @@ export const registerReportRoutes: ApiRouteRegistrar = ({ router, getScopedStore
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);
const discussionCategoryId = parseDiscussionCategoryId(raw.discussionCategoryId);
const attachment = await resolveReportScreenshot(store, input.screenshotArtifactId);
const inputWithAttachment = attachment ? { ...input, attachment } : input;
const help = await selfCheckHelpBeforePipeline(store, inputWithAttachment);
if (help?.answered) return void res.json({ kind: "help", answer: help.answer });
res.json(await runReportPipeline(inputWithAttachment, { 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 }));
res.json(await runReportPipeline(inputWithAttachment, { 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, discussionCategoryId, 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"); }
});