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:
7
.changeset/fn-8327-discussion-fallback.md
Normal file
7
.changeset/fn-8327-discussion-fallback.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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" && <>
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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"); }
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user