FN-8333: embed report screenshots in GitHub filings
Enable opt-in report screenshots to be safely hosted and embedded in filed GitHub reports. - add GitHub Contents API image upload support and CLI-safe payload handling - validate and resolve report screenshot artifacts before best-effort Markdown embedding - cover upload, media access, filing, and report modal behavior with tests Files changed: .changeset/github-report-screenshot-embed.md | 7 +++ docs/dashboard-guide.md | 4 +- packages/core/src/__tests__/gh-cli-input.test.ts | 32 ++++++++++ packages/core/src/gh-cli.ts | 21 ++++++- .../app/components/__tests__/ReportModal.test.tsx | 5 ++ .../dashboard/src/__tests__/artifact-media.test.ts | 20 ++++++ .../__tests__/github-upload-image-asset.test.ts | 39 ++++++++++++ .../__tests__/report-pipeline-screenshots.test.ts | 67 ++++++++++++++++++++ .../dashboard/src/__tests__/report-routes.test.ts | 36 ++++++++++- packages/dashboard/src/artifact-media.ts | 32 ++++++++++ packages/dashboard/src/github.ts | 57 +++++++++++++++++ packages/dashboard/src/issue-image-attachments.ts | 6 +- packages/dashboard/src/report-pipeline.ts | 72 ++++++++++++++++++---- .../dashboard/src/routes/register-report-routes.ts | 29 ++++++--- .../src/routes/register-task-workflow-routes.ts | 24 +------- 15 files changed, 401 insertions(+), 50 deletions(-) Fusion-Task-Id: FN-8333 Fusion-Task-Lineage: b706810d-2fec-4053-b57a-1128e5c16e94 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/github-report-screenshot-embed.md
Normal file
7
.changeset/github-report-screenshot-embed.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Embed opted-in report screenshots in filed GitHub reports.
|
||||||
|
category: feature
|
||||||
|
dev: Uploads one validated local report artifact through the GitHub Contents API; private raw URLs require viewer access.
|
||||||
@@ -2134,7 +2134,9 @@ In **Settings → General**, choose **Review draft before filing** (the default)
|
|||||||
|
|
||||||
Reports can include a short activity trace of recent built-in view names (up to 20 entries). The trace is ordinary text and receives the same mandatory server-side scrub as every other report field on every egress path, including edited drafts and duplicate endorsements.
|
Reports can include a short activity trace of recent built-in view names (up to 20 entries). The trace is ordinary text and receives the same mandatory server-side scrub as every other report field on every egress path, including edited drafts and duplicate endorsements.
|
||||||
|
|
||||||
Choose **Store a screenshot locally** to request browser screen-capture permission. Fusion captures and uploads one PNG frame to its local artifact registry, then requires confirmation that the screenshot may be retained before a report can reference it. The report carries only `screenshotArtifactId` and a text note that the locally stored artifact exists; pixels never leave Fusion or enter report text, including automatic filing.
|
Choose **Store a screenshot locally** to request browser screen-capture permission. Fusion captures one PNG frame in its local artifact registry, then requires confirmation that the screenshot may be retained before a report can reference it. When filing or endorsing the report, Fusion resolves that single provenance-validated local artifact server-side and makes a best-effort GitHub Contents API upload; a successful upload is embedded inline in the Issue, Discussion, or duplicate data-point comment. Upload failure never blocks the scrubbed text report, and raw pixels are never accepted from the report-file request.
|
||||||
|
|
||||||
|
The uploaded image uses the tracking repository's raw URL. It renders inline for public repositories; viewers of private repositories need GitHub authorization for that raw URL, so anonymous viewers will not see the image. The original screenshot remains a local artifact as well.
|
||||||
|
|
||||||
## Chat-requested task verification
|
## Chat-requested task verification
|
||||||
|
|
||||||
|
|||||||
32
packages/core/src/__tests__/gh-cli-input.test.ts
Normal file
32
packages/core/src/__tests__/gh-cli-input.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("node:child_process", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||||
|
return { ...actual, execFile };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { MAX_GH_STDIN_INPUT_BYTES, runGhAsync } from "../gh-cli.js";
|
||||||
|
|
||||||
|
describe("runGhAsync stdin input", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
it("streams bounded input to stdin instead of command arguments", async () => {
|
||||||
|
const end = vi.fn();
|
||||||
|
execFile.mockImplementation((_bin, _args, _options, callback) => {
|
||||||
|
callback(null, "ok", "");
|
||||||
|
return { stdin: { end } };
|
||||||
|
});
|
||||||
|
const input = JSON.stringify({ content: "A".repeat(1024 * 1024) });
|
||||||
|
|
||||||
|
await expect(runGhAsync(["api", "--input", "-"], { input })).resolves.toBe("ok");
|
||||||
|
expect(execFile).toHaveBeenCalledWith("gh", ["api", "--input", "-"], expect.any(Object), expect.any(Function));
|
||||||
|
expect(end).toHaveBeenCalledWith(input);
|
||||||
|
expect(JSON.stringify(execFile.mock.calls[0]?.[1])).not.toContain(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects input exceeding the stdin ceiling before spawning gh", async () => {
|
||||||
|
await expect(runGhAsync(["api"], { input: "x".repeat(MAX_GH_STDIN_INPUT_BYTES + 1) })).rejects.toMatchObject({ code: "INPUT_TOO_LARGE" });
|
||||||
|
expect(execFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,6 +33,8 @@ export interface StructuredGhError {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const MAX_GH_STDIN_INPUT_BYTES = 8 * 1024 * 1024;
|
||||||
|
|
||||||
export interface RunGhOptions {
|
export interface RunGhOptions {
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
/** External abort signal — propagated to the spawned `gh` process. */
|
/** External abort signal — propagated to the spawned `gh` process. */
|
||||||
@@ -47,6 +49,11 @@ export interface RunGhOptions {
|
|||||||
* pins any AI session's `prompt()` that triggered the tool call.
|
* pins any AI session's `prompt()` that triggered the tool call.
|
||||||
*/
|
*/
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
/**
|
||||||
|
* Optional UTF-8 request body streamed to child stdin. Limited to 8 MiB so
|
||||||
|
* callers can send bounded Contents API payloads without argv overflow.
|
||||||
|
*/
|
||||||
|
input?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_GH_TIMEOUT_MS = 30_000;
|
const DEFAULT_GH_TIMEOUT_MS = 30_000;
|
||||||
@@ -160,8 +167,11 @@ export function runGh(args: string[], cwd?: string): string {
|
|||||||
* hangs when `gh` stalls on the network or a credential helper.
|
* hangs when `gh` stalls on the network or a credential helper.
|
||||||
*/
|
*/
|
||||||
export function runGhAsync(args: string[], cwdOrOptions?: string | RunGhOptions): Promise<string> {
|
export function runGhAsync(args: string[], cwdOrOptions?: string | RunGhOptions): Promise<string> {
|
||||||
const { cwd, signal: externalSignal, timeoutMs = DEFAULT_GH_TIMEOUT_MS } =
|
const { cwd, signal: externalSignal, timeoutMs = DEFAULT_GH_TIMEOUT_MS, input } =
|
||||||
normalizeRunGhOptions(cwdOrOptions);
|
normalizeRunGhOptions(cwdOrOptions);
|
||||||
|
if (input !== undefined && Buffer.byteLength(input, "utf8") > MAX_GH_STDIN_INPUT_BYTES) {
|
||||||
|
return Promise.reject(makeGhError(`gh stdin input exceeds ${MAX_GH_STDIN_INPUT_BYTES} byte limit`, "INPUT_TOO_LARGE"));
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (externalSignal?.aborted) {
|
if (externalSignal?.aborted) {
|
||||||
@@ -196,7 +206,7 @@ export function runGhAsync(args: string[], cwdOrOptions?: string | RunGhOptions)
|
|||||||
if (externalSignal) externalSignal.removeEventListener("abort", onExternalAbort);
|
if (externalSignal) externalSignal.removeEventListener("abort", onExternalAbort);
|
||||||
};
|
};
|
||||||
|
|
||||||
execFile(
|
const child = execFile(
|
||||||
"gh",
|
"gh",
|
||||||
args,
|
args,
|
||||||
{
|
{
|
||||||
@@ -229,6 +239,13 @@ export function runGhAsync(args: string[], cwdOrOptions?: string | RunGhOptions)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
/*
|
||||||
|
FNXC:GhCliStdin 2026-07-19-12:00:
|
||||||
|
GitHub Contents API image bodies can contain several megabytes of base64.
|
||||||
|
Keep that payload on stdin because operating-system argv limits are much
|
||||||
|
smaller and can otherwise reject a valid bounded report screenshot.
|
||||||
|
*/
|
||||||
|
if (input !== undefined) child.stdin?.end(input);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ describe("ReportModal", () => {
|
|||||||
await waitFor(() => expect(reportDraft).toHaveBeenCalledWith(expect.objectContaining({
|
await waitFor(() => expect(reportDraft).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
screenshotArtifactId: "123e4567-e89b-42d3-a456-426614174000",
|
screenshotArtifactId: "123e4567-e89b-42d3-a456-426614174000",
|
||||||
})));
|
})));
|
||||||
|
reportFile.mockResolvedValueOnce({ kind: "filed", url: "https://example.test/1" });
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "File report" }));
|
||||||
|
await waitFor(() => expect(reportFile).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
screenshotArtifactId: "123e4567-e89b-42d3-a456-426614174000",
|
||||||
|
})));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears a pending capture when the reporter opts out", async () => {
|
it("clears a pending capture when the reporter opts out", async () => {
|
||||||
|
|||||||
20
packages/dashboard/src/__tests__/artifact-media.test.ts
Normal file
20
packages/dashboard/src/__tests__/artifact-media.test.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveArtifactMediaPath } from "../artifact-media.js";
|
||||||
|
|
||||||
|
const store = {
|
||||||
|
getTaskDir: (taskId: string) => `/workspace/.fusion/tasks/${taskId}`,
|
||||||
|
getFusionDir: () => "/workspace/.fusion",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("resolveArtifactMediaPath", () => {
|
||||||
|
it("preserves task artifact and attachment media paths", () => {
|
||||||
|
expect(resolveArtifactMediaPath(store as never, { taskId: "FN-1", uri: "artifacts/screenshot.png" })).toBe("/workspace/.fusion/tasks/FN-1/artifacts/screenshot.png");
|
||||||
|
expect(resolveArtifactMediaPath(store as never, { taskId: "FN-1", uri: "attachments/capture.png" })).toBe("/workspace/.fusion/tasks/FN-1/attachments/capture.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects traversal and absolute paths outside the artifact storage roots", () => {
|
||||||
|
for (const uri of ["../../etc/passwd", "/etc/passwd", "other/capture.png"]) {
|
||||||
|
expect(() => resolveArtifactMediaPath(store as never, { taskId: "FN-1", uri })).toThrow("Invalid artifact media path");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@fusion/core", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||||
|
return { ...actual, isGhAvailable: vi.fn(() => true), isGhAuthenticated: vi.fn(() => true), runGhJsonAsync: vi.fn(), runGhAsync: vi.fn(), runGh: vi.fn(), getGhErrorMessage: vi.fn((error) => String(error)) };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { runGhJsonAsync } from "@fusion/core";
|
||||||
|
import { GitHubClient } from "../github.js";
|
||||||
|
|
||||||
|
const image = Buffer.alloc(1024 * 1024, 0xab).toString("base64");
|
||||||
|
const params = { owner: "owner", repo: "repo", path: ".fusion-reports/safe/image.png", contentBase64: image, message: "Store report screenshot", mimeType: "image/png" };
|
||||||
|
const response = { content: { html_url: "https://github.com/owner/repo/blob/main/.fusion-reports/safe/image.png", download_url: "https://raw.githubusercontent.com/owner/repo/main/.fusion-reports/safe/image.png", path: params.path, sha: "abc" } };
|
||||||
|
|
||||||
|
describe("GitHubClient.uploadImageAsset", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("sends large contents payload through gh stdin, never argv", async () => {
|
||||||
|
vi.mocked(runGhJsonAsync).mockResolvedValue(response as never);
|
||||||
|
const uploaded = await new GitHubClient({ forceMode: "gh-cli" }).uploadImageAsset(params);
|
||||||
|
const [argv, options] = vi.mocked(runGhJsonAsync).mock.calls[0]!;
|
||||||
|
expect(argv).toEqual(["api", "--method", "PUT", "repos/owner/repo/contents/.fusion-reports/safe/image.png", "--input", "-"]);
|
||||||
|
expect(JSON.stringify(argv)).not.toContain(image);
|
||||||
|
expect(JSON.parse((options as { input: string }).input).content).toBe(image);
|
||||||
|
expect(uploaded.rawUrl).toBe(response.content.download_url);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses encoded Contents API endpoint in token mode", async () => {
|
||||||
|
const fetch = vi.spyOn(global, "fetch").mockResolvedValue({ ok: true, status: 201, json: async () => response, headers: new Headers() } as Response);
|
||||||
|
await new GitHubClient({ token: "token", forceMode: "token" }).uploadImageAsset({ ...params, path: ".fusion-reports/a space/image.png" });
|
||||||
|
expect(fetch).toHaveBeenCalledWith("https://api.github.com/repos/owner/repo/contents/.fusion-reports/a%20space/image.png", expect.objectContaining({ method: "PUT" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([{ mimeType: "image/svg+xml" }, { contentBase64: Buffer.alloc(5 * 1024 * 1024 + 1).toString("base64") }])("rejects invalid upload before transport", async (patch) => {
|
||||||
|
const client = new GitHubClient({ forceMode: "gh-cli" });
|
||||||
|
await expect(client.uploadImageAsset({ ...params, ...patch })).rejects.toThrow();
|
||||||
|
expect(runGhJsonAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { runReportPipeline, type ReportPipelineDeps, type ReportScreenshot } from "../report-pipeline.js";
|
||||||
|
|
||||||
|
const settings = { reportMode: "auto-file" as const, reportRoadmapDedupeEnabled: false, githubTrackingDefaultRepo: "Runfusion/Fusion", githubAuthMode: "token", githubAuthToken: "test" };
|
||||||
|
const screenshot: ReportScreenshot = { artifactId: "f1e2d3c4-b5a6-4789-8abc-def012345678", filename: "/Users/alice/private-project/evil[alt](break)!../capture.png", mimeType: "image/png", bytes: Buffer.from([0x89, 0x50, 0x4e, 0x47]) };
|
||||||
|
|
||||||
|
function client() {
|
||||||
|
return {
|
||||||
|
createIssue: vi.fn().mockResolvedValue({ htmlUrl: "https://github.com/Runfusion/Fusion/issues/42" }), createDiscussion: vi.fn().mockResolvedValue({ htmlUrl: "https://github.com/Runfusion/Fusion/discussions/42" }),
|
||||||
|
searchIssues: vi.fn().mockResolvedValue([]), searchDiscussions: vi.fn().mockResolvedValue([]),
|
||||||
|
commentOnIssue: vi.fn().mockResolvedValue({ url: "issue-comment" }), commentOnDiscussion: vi.fn().mockResolvedValue({ url: "discussion-comment" }),
|
||||||
|
addIssueReaction: vi.fn(), addDiscussionReaction: vi.fn(),
|
||||||
|
uploadImageAsset: vi.fn().mockResolvedValue({ rawUrl: "https://raw.githubusercontent.com/Runfusion/Fusion/main/.fusion-reports/safe/screenshot.png" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deps(fakeClient = client()): ReportPipelineDeps {
|
||||||
|
return { projectSettings: settings, client: fakeClient, scrubContext: { rootDir: "/Users/alice/private-project", projectName: "private-project" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("report screenshot embedding", () => {
|
||||||
|
it("uploads and embeds one resolved PNG in a filed issue without leaking untrusted path input", async () => {
|
||||||
|
const fakeClient = client();
|
||||||
|
const result = await runReportPipeline({ actionType: "bug", userPrompt: "Report failure", attachment: screenshot }, deps(fakeClient), { file: true });
|
||||||
|
|
||||||
|
expect(result.kind).toBe("filed");
|
||||||
|
expect(fakeClient.uploadImageAsset).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fakeClient.uploadImageAsset).toHaveBeenCalledWith(expect.objectContaining({ path: expect.stringMatching(/^\.fusion-reports\/[a-f0-9]{32}\/screenshot\.png$/), contentBase64: screenshot.bytes.toString("base64") }));
|
||||||
|
const body = fakeClient.createIssue.mock.calls[0][0].body as string;
|
||||||
|
expect(body).toContain("## Screenshots");
|
||||||
|
expect(body).toMatch(/!\[[^\r\n]*\\\[alt\\\]\\\(break\\\)\\![^\r\n]*\]\(https:\/\/raw\.githubusercontent\.com\/Runfusion\/Fusion\/main\/\.fusion-reports\/safe\/screenshot\.png\)/);
|
||||||
|
expect(body).not.toContain("private-project");
|
||||||
|
expect(body).not.toContain("/Users/alice");
|
||||||
|
expect(body).not.toContain(".fusion-reports/../");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not upload or alter a filed body without the optional screenshot", async () => {
|
||||||
|
const fakeClient = client();
|
||||||
|
await runReportPipeline({ actionType: "idea", userPrompt: "Add a filter" }, deps(fakeClient), { file: true });
|
||||||
|
expect(fakeClient.uploadImageAsset).not.toHaveBeenCalled();
|
||||||
|
expect(fakeClient.createIssue.mock.calls[0][0].body).not.toContain("## Screenshots");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["feedback", "help"] as const)("embeds screenshots in filed %s discussions", async (actionType) => {
|
||||||
|
const fakeClient = client();
|
||||||
|
await runReportPipeline({ actionType, userPrompt: "Clarify report status", attachment: screenshot }, deps(fakeClient), { file: true });
|
||||||
|
expect(fakeClient.uploadImageAsset).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fakeClient.createDiscussion.mock.calls[0][3]).toContain("## Screenshots");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps filing the scrubbed text body if hosting fails", async () => {
|
||||||
|
const fakeClient = client();
|
||||||
|
fakeClient.uploadImageAsset.mockRejectedValueOnce(new Error("permission denied"));
|
||||||
|
const result = await runReportPipeline({ actionType: "bug", userPrompt: "Report failure", attachment: screenshot }, deps(fakeClient), { file: true });
|
||||||
|
expect(result.kind).toBe("filed");
|
||||||
|
expect(fakeClient.createIssue).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fakeClient.createIssue.mock.calls[0][0].body).not.toContain("## Screenshots");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("embeds the one screenshot in an endorsed duplicate data-point comment", async () => {
|
||||||
|
const fakeClient = client();
|
||||||
|
fakeClient.searchIssues.mockResolvedValue([{ number: 7, title: "dashboard rendering failed", body: "dashboard rendering failed", html_url: "issue", state: "open" }]);
|
||||||
|
await runReportPipeline({ actionType: "bug", userPrompt: "dashboard rendering failed", attachment: screenshot }, deps(fakeClient), { file: true, endorseIssueNumber: 7 });
|
||||||
|
expect(fakeClient.uploadImageAsset).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fakeClient.commentOnIssue.mock.calls[0][3]).toContain("## Screenshots");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,9 +10,13 @@ vi.mock("../require-async-layer.js", () => ({
|
|||||||
vi.mock("../report-pipeline.js", () => ({
|
vi.mock("../report-pipeline.js", () => ({
|
||||||
runReportPipeline: vi.fn(),
|
runReportPipeline: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("../artifact-media.js", () => ({
|
||||||
|
readArtifactMediaBytes: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
import { queryKnowledgePagesAsync } from "../knowledge-index.js";
|
import { queryKnowledgePagesAsync } from "../knowledge-index.js";
|
||||||
import { runReportPipeline } from "../report-pipeline.js";
|
import { runReportPipeline } from "../report-pipeline.js";
|
||||||
|
import { readArtifactMediaBytes } from "../artifact-media.js";
|
||||||
import { ARTIFACT_ID_PATTERN, MAX_SCREENSHOT_BYTES, registerReportRoutes } from "../routes/register-report-routes.js";
|
import { ARTIFACT_ID_PATTERN, MAX_SCREENSHOT_BYTES, registerReportRoutes } from "../routes/register-report-routes.js";
|
||||||
|
|
||||||
type TestRequest = { body?: unknown; file?: { buffer: Buffer; mimetype?: string } };
|
type TestRequest = { body?: unknown; file?: { buffer: Buffer; mimetype?: string } };
|
||||||
@@ -33,7 +37,7 @@ function setup(projectSettings: Record<string, unknown> = { reportMode: "auto-fi
|
|||||||
const store = {
|
const store = {
|
||||||
getSettingsByScopeFast: vi.fn().mockResolvedValue({ project: projectSettings, global: {} }),
|
getSettingsByScopeFast: vi.fn().mockResolvedValue({ project: projectSettings, global: {} }),
|
||||||
getRootDir: () => "/Users/alice/private-project",
|
getRootDir: () => "/Users/alice/private-project",
|
||||||
getArtifact: vi.fn().mockResolvedValue({ type: "image", metadata: { source: "report-attachment" } }),
|
getArtifact: vi.fn().mockResolvedValue({ id: "123e4567-e89b-42d3-a456-426614174000", type: "image", title: "Report screenshot", mimeType: "image/png", uri: "artifacts/report.png", taskId: "FN-1", metadata: { source: "report-attachment" } }),
|
||||||
registerArtifact: vi.fn().mockResolvedValue({ id: "123e4567-e89b-42d3-a456-426614174000" }),
|
registerArtifact: vi.fn().mockResolvedValue({ id: "123e4567-e89b-42d3-a456-426614174000" }),
|
||||||
};
|
};
|
||||||
registerReportRoutes({
|
registerReportRoutes({
|
||||||
@@ -65,7 +69,10 @@ async function invoke(handlers: TestHandler[], body: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("report routes", () => {
|
describe("report routes", () => {
|
||||||
beforeEach(() => vi.clearAllMocks());
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(readArtifactMediaBytes).mockResolvedValue(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
||||||
|
});
|
||||||
|
|
||||||
it("passes roadmap settings and a roadmap endorsement target to the pipeline", async () => {
|
it("passes roadmap settings and a roadmap endorsement target to the pipeline", async () => {
|
||||||
vi.mocked(queryKnowledgePagesAsync).mockResolvedValue([]);
|
vi.mocked(queryKnowledgePagesAsync).mockResolvedValue([]);
|
||||||
@@ -134,6 +141,31 @@ describe("report routes", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves the single persisted screenshot server-side before filing", async () => {
|
||||||
|
vi.mocked(runReportPipeline).mockResolvedValue({ kind: "filed" } as never);
|
||||||
|
const { handlers, store } = setup();
|
||||||
|
const id = "123e4567-e89b-42d3-a456-426614174000";
|
||||||
|
await invoke(handlers.get("/report/file")!, { actionType: "bug", report: { userPrompt: "It crashes", context: {}, screenshotArtifactId: id } });
|
||||||
|
|
||||||
|
expect(readArtifactMediaBytes).toHaveBeenCalledWith(store, expect.objectContaining({ id, metadata: { source: "report-attachment" } }));
|
||||||
|
expect(runReportPipeline).toHaveBeenCalledWith(expect.objectContaining({ attachment: expect.objectContaining({ artifactId: id, mimeType: "image/png", bytes: expect.any(Buffer) }) }), expect.anything(), expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid artifact bytes before the filing pipeline", async () => {
|
||||||
|
const id = "123e4567-e89b-42d3-a456-426614174000";
|
||||||
|
const { handlers, store } = setup();
|
||||||
|
store.getArtifact.mockResolvedValue({ id, type: "image", mimeType: "image/png", uri: "../../etc/passwd", taskId: "FN-1", metadata: { source: "report-attachment" } });
|
||||||
|
vi.mocked(readArtifactMediaBytes).mockRejectedValueOnce(new Error("Invalid artifact media path"));
|
||||||
|
|
||||||
|
await expect(invoke(handlers.get("/report/file")!, { actionType: "bug", report: { userPrompt: "It crashes", context: {}, screenshotArtifactId: id } })).rejects.toThrow("Invalid artifact media path");
|
||||||
|
expect(runReportPipeline).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
store.getArtifact.mockResolvedValue({ id, type: "image", mimeType: "image/png", uri: "artifacts/report.png", taskId: "FN-1", metadata: { source: "report-attachment" } });
|
||||||
|
vi.mocked(readArtifactMediaBytes).mockResolvedValueOnce(Buffer.alloc(MAX_SCREENSHOT_BYTES + 1));
|
||||||
|
await expect(invoke(handlers.get("/report/file")!, { actionType: "bug", report: { userPrompt: "It crashes", context: {}, screenshotArtifactId: id } })).rejects.toThrow("Screenshot artifact is unavailable or invalid");
|
||||||
|
expect(runReportPipeline).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["/report/draft", "/report/file"])("passes valid targetType through the filing pipeline on %s", async (path) => {
|
it.each(["/report/draft", "/report/file"])("passes valid targetType through the filing pipeline on %s", async (path) => {
|
||||||
|
|||||||
32
packages/dashboard/src/artifact-media.ts
Normal file
32
packages/dashboard/src/artifact-media.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { resolve, sep } from "node:path";
|
||||||
|
import type { TaskStore } from "@fusion/core";
|
||||||
|
import { badRequest } from "./api-error.js";
|
||||||
|
|
||||||
|
export type MediaArtifact = { taskId?: string; uri?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:ArtifactMedia 2026-07-19-17:25:
|
||||||
|
* Artifact URIs are storage metadata, not trusted filesystem paths. Media reads
|
||||||
|
* must stay confined to the owning task's artifacts/attachments directories (or
|
||||||
|
* task-less .fusion/artifacts) before a report can upload user-reviewed pixels.
|
||||||
|
*/
|
||||||
|
export function resolveArtifactMediaPath(scopedStore: TaskStore, artifact: MediaArtifact): string | null {
|
||||||
|
if (!artifact.uri) return null;
|
||||||
|
|
||||||
|
const anchorDir = artifact.taskId ? scopedStore.getTaskDir(artifact.taskId) : scopedStore.getFusionDir();
|
||||||
|
const expectedArtifactsDir = resolve(anchorDir, "artifacts");
|
||||||
|
const expectedAttachmentsDir = artifact.taskId ? resolve(anchorDir, "attachments") : null;
|
||||||
|
const mediaPath = resolve(anchorDir, artifact.uri);
|
||||||
|
const underArtifacts = mediaPath === expectedArtifactsDir || mediaPath.startsWith(`${expectedArtifactsDir}${sep}`);
|
||||||
|
const underAttachments = expectedAttachmentsDir !== null && (mediaPath === expectedAttachmentsDir || mediaPath.startsWith(`${expectedAttachmentsDir}${sep}`));
|
||||||
|
if (!underArtifacts && !underAttachments) throw badRequest("Invalid artifact media path");
|
||||||
|
return mediaPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads bytes only after the shared task-directory confinement check succeeds. */
|
||||||
|
export async function readArtifactMediaBytes(scopedStore: TaskStore, artifact: MediaArtifact): Promise<Buffer> {
|
||||||
|
const mediaPath = resolveArtifactMediaPath(scopedStore, artifact);
|
||||||
|
if (!mediaPath) throw badRequest("Artifact has no stored media");
|
||||||
|
return readFile(mediaPath);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
getCurrentRepo,
|
getCurrentRepo,
|
||||||
runGh,
|
runGh,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
|
import { ALLOWED_IMAGE_MIMES, MAX_IMAGE_BYTES } from "./issue-image-attachments.js";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
@@ -234,6 +235,23 @@ export interface CreatedIssue {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UploadImageAssetParams {
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
path: string;
|
||||||
|
contentBase64: string;
|
||||||
|
message: string;
|
||||||
|
branch?: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadedImageAsset {
|
||||||
|
htmlUrl: string;
|
||||||
|
rawUrl: string;
|
||||||
|
path: string;
|
||||||
|
sha: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DiscussionCandidate {
|
export interface DiscussionCandidate {
|
||||||
id: string;
|
id: string;
|
||||||
number: number;
|
number: number;
|
||||||
@@ -759,6 +777,45 @@ export class GitHubClient {
|
|||||||
this.forceMode = tokenOrOptions?.forceMode;
|
this.forceMode = tokenOrOptions?.forceMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:ReportScreenshotUpload 2026-07-19-12:00:
|
||||||
|
* Report pixels cross the permanent GitHub boundary only through the documented
|
||||||
|
* Contents API, never the undocumented web upload endpoint. MIME and decoded-size
|
||||||
|
* validation happens before either auth transport. A private repository's raw URL
|
||||||
|
* requires viewer authentication and therefore cannot be promised as anonymous inline media.
|
||||||
|
*/
|
||||||
|
async uploadImageAsset(params: UploadImageAssetParams): Promise<UploadedImageAsset> {
|
||||||
|
if (!ALLOWED_IMAGE_MIMES.has(params.mimeType)) throw new Error("Unsupported image MIME type for GitHub upload.");
|
||||||
|
const normalized = params.contentBase64.replace(/\s/g, "");
|
||||||
|
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized) || Buffer.from(normalized, "base64").byteLength > MAX_IMAGE_BYTES) {
|
||||||
|
throw new Error("Image upload exceeds the 5MB limit or is not valid base64.");
|
||||||
|
}
|
||||||
|
const endpoint = `repos/${encodeURIComponent(params.owner)}/${encodeURIComponent(params.repo)}/contents/${params.path.split("/").map(encodeURIComponent).join("/")}`;
|
||||||
|
if (this.forceMode === "gh-cli") { this.requireGh(); return this.uploadImageAssetWithGh(endpoint, params); }
|
||||||
|
if (this.forceMode === "token") { this.requireToken(); return this.uploadImageAssetWithApi(endpoint, params); }
|
||||||
|
if (this.hasGhAuth()) {
|
||||||
|
try { return await this.uploadImageAssetWithGh(endpoint, params); }
|
||||||
|
catch (error) { if (!this.token) throw new Error("Failed to upload GitHub image asset.", { cause: error }); }
|
||||||
|
}
|
||||||
|
if (this.token) return this.uploadImageAssetWithApi(endpoint, params);
|
||||||
|
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided. Run 'gh auth login' or set GITHUB_TOKEN.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async uploadImageAssetWithGh(endpoint: string, params: UploadImageAssetParams): Promise<UploadedImageAsset> {
|
||||||
|
const body = JSON.stringify({ message: params.message, content: params.contentBase64, ...(params.branch ? { branch: params.branch } : {}) });
|
||||||
|
const result = await runGhJsonAsync<{ content?: { html_url?: string; download_url?: string; path?: string; sha?: string } }>(["api", "--method", "PUT", endpoint, "--input", "-"], { input: body });
|
||||||
|
const content = result.content;
|
||||||
|
if (!content?.html_url || !content.download_url || !content.path || !content.sha) throw new Error("GitHub Contents API returned an incomplete image asset.");
|
||||||
|
return { htmlUrl: content.html_url, rawUrl: content.download_url, path: content.path, sha: content.sha };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async uploadImageAssetWithApi(endpoint: string, params: UploadImageAssetParams): Promise<UploadedImageAsset> {
|
||||||
|
const result = await this.fetchThrottled<{ content?: { html_url?: string; download_url?: string; path?: string; sha?: string } }>(`${this.baseUrl}/${endpoint}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: params.message, content: params.contentBase64, ...(params.branch ? { branch: params.branch } : {}) }) });
|
||||||
|
const content = result.data?.content;
|
||||||
|
if (!result.success || !content?.html_url || !content.download_url || !content.path || !content.sha) throw new Error(result.error ?? "GitHub Contents API returned an incomplete image asset.");
|
||||||
|
return { htmlUrl: content.html_url, rawUrl: content.download_url, path: content.path, sha: content.sha };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FNXC:ReportPipeline 2026-07-18-16:30:
|
* FNXC:ReportPipeline 2026-07-18-16:30:
|
||||||
* An explicitly reviewed report screenshot may be hosted only in the selected
|
* An explicitly reviewed report screenshot may be hosted only in the selected
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ Import must never fail because an image failed to download: the task (the operat
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** Mirrors TaskStore.ALLOWED_MIME_TYPES image subset — addAttachment rejects anything else. */
|
/** Mirrors TaskStore.ALLOWED_MIME_TYPES image subset — addAttachment rejects anything else. */
|
||||||
const ALLOWED_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
export const ALLOWED_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
||||||
|
|
||||||
/** Mirrors TaskStore.MAX_ATTACHMENT_SIZE (5MB). Checked before buffering so a huge asset can't balloon dashboard memory. */
|
/** Mirrors TaskStore.MAX_ATTACHMENT_SIZE (5MB). Checked before buffering so a huge asset can't balloon dashboard memory. */
|
||||||
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
/** Bound per-issue work: a pathological issue (or a long comment thread) must not stall the import request. */
|
/** Bound per-issue work: a pathological issue (or a long comment thread) must not stall the import request. */
|
||||||
const MAX_IMAGES_PER_ISSUE = 10;
|
const MAX_IMAGES_PER_ISSUE = 10;
|
||||||
@@ -28,7 +28,7 @@ const DOWNLOAD_TIMEOUT_MS = 15_000;
|
|||||||
const MAX_DOWNLOAD_REDIRECTS = 3;
|
const MAX_DOWNLOAD_REDIRECTS = 3;
|
||||||
const IMAGE_DOWNLOAD_CONCURRENCY = 3;
|
const IMAGE_DOWNLOAD_CONCURRENCY = 3;
|
||||||
|
|
||||||
const EXT_BY_MIME: Record<string, string> = {
|
export const EXT_BY_MIME: Record<string, string> = {
|
||||||
"image/png": "png",
|
"image/png": "png",
|
||||||
"image/jpeg": "jpg",
|
"image/jpeg": "jpg",
|
||||||
"image/gif": "gif",
|
"image/gif": "gif",
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
import type { GlobalSettings, ProjectSettings, ReportActionType, ReportMode, ReportTarget } from "@fusion/core";
|
import type { GlobalSettings, ProjectSettings, ReportActionType, ReportMode, ReportTarget } from "@fusion/core";
|
||||||
import { parseRepoSlug, resolveTaskGithubTracking } from "@fusion/core";
|
import { parseRepoSlug, resolveTaskGithubTracking } from "@fusion/core";
|
||||||
import { GitHubClient } from "./github.js";
|
import { GitHubClient } from "./github.js";
|
||||||
|
import { EXT_BY_MIME } from "./issue-image-attachments.js";
|
||||||
import { resolveGithubTrackingAuth } from "./github-auth.js";
|
import { resolveGithubTrackingAuth } from "./github-auth.js";
|
||||||
import { buildIssueSearchQueries, DEDUP_MATCH_THRESHOLD, scoreCandidateIssue } from "./github-tracking-dedup.js";
|
import { buildIssueSearchQueries, DEDUP_MATCH_THRESHOLD, scoreCandidateIssue } from "./github-tracking-dedup.js";
|
||||||
import { scrubReportPayload, type ReportScrubContext } from "./report-scrub.js";
|
import { scrubReportPayload, scrubReportText, type ReportScrubContext } from "./report-scrub.js";
|
||||||
|
|
||||||
export type { ReportActionType, ReportMode, ReportTarget };
|
export type { ReportActionType, ReportMode, ReportTarget };
|
||||||
|
|
||||||
|
export interface ReportScreenshot {
|
||||||
|
artifactId: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
bytes: Buffer | Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReportInput {
|
export interface ReportInput {
|
||||||
actionType: ReportActionType;
|
actionType: ReportActionType;
|
||||||
userPrompt: string;
|
userPrompt: string;
|
||||||
@@ -14,6 +23,8 @@ export interface ReportInput {
|
|||||||
activityTrace?: string[];
|
activityTrace?: string[];
|
||||||
/** Provenance-validated local screenshot artifact reference. */
|
/** Provenance-validated local screenshot artifact reference. */
|
||||||
screenshotArtifactId?: string;
|
screenshotArtifactId?: string;
|
||||||
|
/** Server-resolved report screenshot; never client-supplied pixels. */
|
||||||
|
attachment?: ReportScreenshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StructuredReport {
|
export interface StructuredReport {
|
||||||
@@ -25,6 +36,7 @@ export interface StructuredReport {
|
|||||||
context: Record<string, unknown>;
|
context: Record<string, unknown>;
|
||||||
/** Local screenshot artifact reference; pixels never transit egress. */
|
/** Local screenshot artifact reference; pixels never transit egress. */
|
||||||
screenshotArtifactId?: string;
|
screenshotArtifactId?: string;
|
||||||
|
attachment?: ReportScreenshot;
|
||||||
sessionToken?: string;
|
sessionToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +51,7 @@ export type ReportResult =
|
|||||||
export interface ReportPipelineDeps {
|
export interface ReportPipelineDeps {
|
||||||
projectSettings: Pick<ProjectSettings, "reportMode" | "reportModeByAction" | "reportTarget" | "reportTargetByAction" | "reportDiscussionCategory" | "reportRoadmapDedupeEnabled" | "reportRoadmapLabel" | "reportRoadmapRepo" | "githubTrackingDefaultRepo" | "githubAuthMode" | "githubAuthToken">;
|
projectSettings: Pick<ProjectSettings, "reportMode" | "reportModeByAction" | "reportTarget" | "reportTargetByAction" | "reportDiscussionCategory" | "reportRoadmapDedupeEnabled" | "reportRoadmapLabel" | "reportRoadmapRepo" | "githubTrackingDefaultRepo" | "githubAuthMode" | "githubAuthToken">;
|
||||||
globalSettings?: Partial<GlobalSettings>;
|
globalSettings?: Partial<GlobalSettings>;
|
||||||
client?: Pick<GitHubClient, "createIssue" | "searchIssues" | "commentOnIssue" | "addIssueReaction"> & Partial<Pick<GitHubClient, "searchDiscussions" | "createDiscussion" | "commentOnDiscussion" | "addDiscussionReaction" | "listDiscussionCategories">>;
|
client?: Pick<GitHubClient, "createIssue" | "searchIssues" | "commentOnIssue" | "addIssueReaction"> & Partial<Pick<GitHubClient, "searchDiscussions" | "createDiscussion" | "commentOnDiscussion" | "addDiscussionReaction" | "listDiscussionCategories" | "uploadImageAsset">>;
|
||||||
scrubContext?: ReportScrubContext;
|
scrubContext?: ReportScrubContext;
|
||||||
gatherContext?: (input: ReportInput) => Promise<Record<string, unknown>>;
|
gatherContext?: (input: ReportInput) => Promise<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
@@ -95,6 +107,7 @@ function structureReport(input: ReportInput, gathered: Record<string, unknown>):
|
|||||||
body: `## Summary\n${prompt}\n\n## Reproduction / context\n${formattedContext}\n\n## Expected behavior\n${expectedBehavior(input.actionType)}\n\n## Actual behavior / request\n${prompt}\n\n## Environment\n${formattedContext}${input.screenshotArtifactId ? `\n\n## Screenshot\nA screenshot was captured and stored locally (artifact ${input.screenshotArtifactId}).` : ""}`,
|
body: `## Summary\n${prompt}\n\n## Reproduction / context\n${formattedContext}\n\n## Expected behavior\n${expectedBehavior(input.actionType)}\n\n## Actual behavior / request\n${prompt}\n\n## Environment\n${formattedContext}${input.screenshotArtifactId ? `\n\n## Screenshot\nA screenshot was captured and stored locally (artifact ${input.screenshotArtifactId}).` : ""}`,
|
||||||
context,
|
context,
|
||||||
screenshotArtifactId: input.screenshotArtifactId,
|
screenshotArtifactId: input.screenshotArtifactId,
|
||||||
|
attachment: input.attachment,
|
||||||
sessionToken: crypto.randomUUID(),
|
sessionToken: crypto.randomUUID(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -176,6 +189,34 @@ async function findRoadmapDuplicate(client: NonNullable<ReportPipelineDeps["clie
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markdownEscapeImageAlt(value: string): string {
|
||||||
|
return value.replace(/[[\]()!\r\n]/g, (character) => character === "\r" || character === "\n" ? " " : `\\` + character);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function embedScreenshot(args: { report: StructuredReport; client: NonNullable<ReportPipelineDeps["client"]>; owner: string; repo: string; scrubContext?: ReportScrubContext }): Promise<StructuredReport> {
|
||||||
|
const { attachment } = args.report;
|
||||||
|
if (!attachment || !args.client.uploadImageAsset) return args.report;
|
||||||
|
try {
|
||||||
|
const extension = EXT_BY_MIME[attachment.mimeType];
|
||||||
|
if (!extension) return args.report;
|
||||||
|
/*
|
||||||
|
FNXC:ReportScreenshotEmbedding 2026-07-19-12:30:
|
||||||
|
Repository paths must not reveal client tokens, artifact ids, filenames, or
|
||||||
|
local project details. A one-way digest yields a fixed, traversal-free
|
||||||
|
identifier while the separately scrubbed and Markdown-escaped alt text
|
||||||
|
preserves a safe user-facing label.
|
||||||
|
*/
|
||||||
|
const safeId = createHash("sha256").update(`${args.report.sessionToken ?? ""}:${attachment.artifactId}`).digest("hex").slice(0, 32);
|
||||||
|
const path = `.fusion-reports/${safeId}/screenshot.${extension}`;
|
||||||
|
const uploaded = await args.client.uploadImageAsset({ owner: args.owner, repo: args.repo, path, contentBase64: Buffer.from(attachment.bytes).toString("base64"), message: "chore: add Fusion report screenshot", mimeType: attachment.mimeType });
|
||||||
|
const alt = markdownEscapeImageAlt(scrubReportText(attachment.filename, args.scrubContext) || "Report screenshot");
|
||||||
|
return { ...args.report, body: `${args.report.body}\n\n## Screenshots\n` };
|
||||||
|
} catch {
|
||||||
|
// Image hosting is explicitly best-effort: scrubbed text filing must proceed.
|
||||||
|
return args.report;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function endorseDiscussionDuplicate(args: { issueNumber: number; discussionId: string; report: StructuredReport; client: NonNullable<ReportPipelineDeps["client"]> & Pick<GitHubClient, "commentOnDiscussion" | "addDiscussionReaction">; scrubContext?: ReportScrubContext }): Promise<Extract<ReportResult, { kind: "endorsed" }>> {
|
async function endorseDiscussionDuplicate(args: { issueNumber: number; discussionId: string; report: StructuredReport; client: NonNullable<ReportPipelineDeps["client"]> & Pick<GitHubClient, "commentOnDiscussion" | "addDiscussionReaction">; scrubContext?: ReportScrubContext }): Promise<Extract<ReportResult, { kind: "endorsed" }>> {
|
||||||
|
|
||||||
const sessionToken = args.report.sessionToken ?? `${args.discussionId}:${args.report.summary}`;
|
const sessionToken = args.report.sessionToken ?? `${args.discussionId}:${args.report.summary}`;
|
||||||
@@ -234,6 +275,7 @@ function normalizeSubmittedReport(input: ReportInput, gathered: Record<string, u
|
|||||||
body: !promptChangedSinceDerivation && typeof submitted.body === "string" && submitted.body.trim() ? submitted.body : rebuilt.body,
|
body: !promptChangedSinceDerivation && typeof submitted.body === "string" && submitted.body.trim() ? submitted.body : rebuilt.body,
|
||||||
context: submitted.context && typeof submitted.context === "object" ? { ...rebuilt.context, ...submitted.context } : rebuilt.context,
|
context: submitted.context && typeof submitted.context === "object" ? { ...rebuilt.context, ...submitted.context } : rebuilt.context,
|
||||||
screenshotArtifactId: input.screenshotArtifactId,
|
screenshotArtifactId: input.screenshotArtifactId,
|
||||||
|
attachment: input.attachment,
|
||||||
sessionToken: typeof submitted.sessionToken === "string" && submitted.sessionToken ? submitted.sessionToken : rebuilt.sessionToken,
|
sessionToken: typeof submitted.sessionToken === "string" && submitted.sessionToken ? submitted.sessionToken : rebuilt.sessionToken,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -241,7 +283,8 @@ 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; endorseIssueNumber?: number; endorseDiscussionId?: string; endorseRoadmapIssueNumber?: number; report?: StructuredReport } = {}): Promise<ReportResult> {
|
||||||
const gathered = await deps.gatherContext?.(input) ?? { taskId: input.contextRefs?.taskId, agentId: input.contextRefs?.agentId };
|
const gathered = await deps.gatherContext?.(input) ?? { taskId: input.contextRefs?.taskId, agentId: input.contextRefs?.agentId };
|
||||||
const normalized = normalizeSubmittedReport(input, gathered, options.report);
|
const normalized = normalizeSubmittedReport(input, gathered, options.report);
|
||||||
const report: StructuredReport = scrubReportPayload(normalized, deps.scrubContext);
|
// Buffers are not text scrub input; retain the already route-validated attachment separately.
|
||||||
|
const report: StructuredReport = { ...scrubReportPayload({ ...normalized, attachment: undefined }, deps.scrubContext), attachment: normalized.attachment };
|
||||||
|
|
||||||
const mode = resolveReportMode(input.actionType, deps.projectSettings);
|
const mode = resolveReportMode(input.actionType, deps.projectSettings);
|
||||||
const clientResult = createClient(deps);
|
const clientResult = createClient(deps);
|
||||||
@@ -267,25 +310,25 @@ export async function runReportPipeline(input: ReportInput, deps: ReportPipeline
|
|||||||
if (!roadmapDuplicate || roadmapDuplicate.number !== options.endorseRoadmapIssueNumber || !roadmap.repo) {
|
if (!roadmapDuplicate || roadmapDuplicate.number !== options.endorseRoadmapIssueNumber || !roadmap.repo) {
|
||||||
return { kind: "unavailable", reason: "duplicate_not_verified", message: "The selected roadmap item is no longer an open matching report. Please prepare the report again." };
|
return { kind: "unavailable", reason: "duplicate_not_verified", message: "The selected roadmap item is no longer an open matching report. Please prepare the report again." };
|
||||||
}
|
}
|
||||||
const endorsed = await endorseDuplicate({ owner: roadmap.repo.owner, repo: roadmap.repo.repo, issueNumber: roadmapDuplicate.number, report, client: clientResult.client, scrubContext: deps.scrubContext });
|
const endorsed = await endorseDuplicate({ owner: roadmap.repo.owner, repo: roadmap.repo.repo, issueNumber: roadmapDuplicate.number, report: await embedScreenshot({ report, client: clientResult.client, owner: roadmap.repo.owner, repo: roadmap.repo.repo, scrubContext: deps.scrubContext }), client: clientResult.client, scrubContext: deps.scrubContext });
|
||||||
return endorsed;
|
return endorsed;
|
||||||
}
|
}
|
||||||
if (options.endorseDiscussionId) {
|
if (options.endorseDiscussionId) {
|
||||||
if (!clientResult.client.commentOnDiscussion || !clientResult.client.addDiscussionReaction || destination !== "discussion" || duplicate?.discussionId !== options.endorseDiscussionId) {
|
if (!clientResult.client.commentOnDiscussion || !clientResult.client.addDiscussionReaction || destination !== "discussion" || duplicate?.discussionId !== options.endorseDiscussionId) {
|
||||||
return { kind: "unavailable", reason: "duplicate_not_verified", message: "The selected discussion is no longer an open matching report. Please prepare the report again." };
|
return { kind: "unavailable", reason: "duplicate_not_verified", message: "The selected discussion is no longer an open matching report. Please prepare the report again." };
|
||||||
}
|
}
|
||||||
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 });
|
const endorsed = await endorseDiscussionDuplicate({ issueNumber: duplicate.number, discussionId: duplicate.discussionId, report: await embedScreenshot({ report, client: clientResult.client, owner: repo.owner, repo: repo.repo, scrubContext: deps.scrubContext }), client: clientResult.client as NonNullable<ReportPipelineDeps["client"]> & Pick<GitHubClient, "commentOnDiscussion" | "addDiscussionReaction">, scrubContext: deps.scrubContext });
|
||||||
return endorsed;
|
return endorsed;
|
||||||
}
|
}
|
||||||
if (options.endorseIssueNumber) {
|
if (options.endorseIssueNumber) {
|
||||||
if (destination !== "issue" || duplicate?.number !== options.endorseIssueNumber) {
|
if (destination !== "issue" || duplicate?.number !== options.endorseIssueNumber) {
|
||||||
return { kind: "unavailable", reason: "duplicate_not_verified", message: "The selected issue is no longer an open matching report. Please prepare the report again." };
|
return { kind: "unavailable", reason: "duplicate_not_verified", message: "The selected issue is no longer an open matching report. Please prepare the report again." };
|
||||||
}
|
}
|
||||||
const endorsed = await endorseDuplicate({ owner: repo.owner, repo: repo.repo, issueNumber: duplicate.number, report, client: clientResult.client, scrubContext: deps.scrubContext });
|
const endorsed = await endorseDuplicate({ owner: repo.owner, repo: repo.repo, issueNumber: duplicate.number, report: await embedScreenshot({ report, client: clientResult.client, owner: repo.owner, repo: repo.repo, scrubContext: deps.scrubContext }), client: clientResult.client, scrubContext: deps.scrubContext });
|
||||||
return endorsed;
|
return endorsed;
|
||||||
}
|
}
|
||||||
if (roadmapDuplicate) {
|
if (roadmapDuplicate) {
|
||||||
if (mode === "auto-file") return endorseDuplicate({ owner: roadmap.repo!.owner, repo: roadmap.repo!.repo, issueNumber: roadmapDuplicate.number, report, client: clientResult.client, scrubContext: deps.scrubContext });
|
if (mode === "auto-file") return endorseDuplicate({ owner: roadmap.repo!.owner, repo: roadmap.repo!.repo, issueNumber: roadmapDuplicate.number, report: await embedScreenshot({ report, client: clientResult.client, owner: roadmap.repo!.owner, repo: roadmap.repo!.repo, scrubContext: deps.scrubContext }), client: clientResult.client, scrubContext: deps.scrubContext });
|
||||||
return { kind: "duplicate-found", report, mode, issue: { number: roadmapDuplicate.number, url: roadmapDuplicate.html_url, title: roadmapDuplicate.title, roadmap: true } };
|
return { kind: "duplicate-found", report, mode, issue: { number: roadmapDuplicate.number, url: roadmapDuplicate.html_url, title: roadmapDuplicate.title, roadmap: true } };
|
||||||
}
|
}
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
@@ -295,11 +338,11 @@ export async function runReportPipeline(input: ReportInput, deps: ReportPipeline
|
|||||||
return { kind: "unavailable", reason: "discussion_unsupported", message: "This GitHub connection cannot endorse discussions." };
|
return { kind: "unavailable", reason: "discussion_unsupported", message: "This GitHub connection cannot endorse discussions." };
|
||||||
}
|
}
|
||||||
try {
|
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 });
|
const endorsed = await endorseDiscussionDuplicate({ issueNumber: duplicate.number, discussionId: duplicate.discussionId, report: await embedScreenshot({ report, client: clientResult.client, owner: repo.owner, repo: repo.repo, scrubContext: deps.scrubContext }), client: clientResult.client as NonNullable<ReportPipelineDeps["client"]> & Pick<GitHubClient, "commentOnDiscussion" | "addDiscussionReaction">, scrubContext: deps.scrubContext });
|
||||||
return endorsed;
|
return endorsed;
|
||||||
} catch { return { kind: "unavailable", reason: "discussion_unavailable", message: "GitHub Discussions are unavailable for this repository or token." }; }
|
} catch { return { kind: "unavailable", reason: "discussion_unavailable", message: "GitHub Discussions are unavailable for this repository or token." }; }
|
||||||
}
|
}
|
||||||
const endorsed = await endorseDuplicate({ owner: repo.owner, repo: repo.repo, issueNumber: duplicate.number, report, client: clientResult.client, scrubContext: deps.scrubContext });
|
const endorsed = await endorseDuplicate({ owner: repo.owner, repo: repo.repo, issueNumber: duplicate.number, report: await embedScreenshot({ report, client: clientResult.client, owner: repo.owner, repo: repo.repo, scrubContext: deps.scrubContext }), client: clientResult.client, scrubContext: deps.scrubContext });
|
||||||
return endorsed;
|
return endorsed;
|
||||||
}
|
}
|
||||||
return { kind: "duplicate-found", report, mode, issue: { number: duplicate.number, url: duplicate.html_url, title: duplicate.title, discussionId: duplicate.discussionId } };
|
return { kind: "duplicate-found", report, mode, issue: { number: duplicate.number, url: duplicate.html_url, title: duplicate.title, discussionId: duplicate.discussionId } };
|
||||||
@@ -312,8 +355,13 @@ export async function runReportPipeline(input: ReportInput, deps: ReportPipeline
|
|||||||
let categoryId: string | undefined;
|
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." }; }
|
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." };
|
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." }; }
|
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." }; }
|
||||||
}
|
}
|
||||||
const created = await clientResult.client.createIssue({ owner: repo.owner, repo: repo.repo, title: report.summary, body: report.body, labels: ["community"] });
|
const embeddedReport = await embedScreenshot({ report, client: clientResult.client, owner: repo.owner, repo: repo.repo, scrubContext: deps.scrubContext });
|
||||||
return { kind: "filed", url: created.htmlUrl, report };
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { REPORT_ATTACHMENT_SOURCE, resolveTaskGithubTracking } from "@fusion/core";
|
import { REPORT_ATTACHMENT_SOURCE, resolveTaskGithubTracking } from "@fusion/core";
|
||||||
|
import { ALLOWED_IMAGE_MIMES, MAX_IMAGE_BYTES } from "../issue-image-attachments.js";
|
||||||
|
import { readArtifactMediaBytes } from "../artifact-media.js";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { ApiError } from "../api-error.js";
|
import { ApiError } from "../api-error.js";
|
||||||
import { GitHubClient } from "../github.js";
|
import { GitHubClient } from "../github.js";
|
||||||
import { resolveGithubTrackingAuth } from "../github-auth.js";
|
import { resolveGithubTrackingAuth } from "../github-auth.js";
|
||||||
import { queryKnowledgePagesAsync } from "../knowledge-index.js";
|
import { queryKnowledgePagesAsync } from "../knowledge-index.js";
|
||||||
import { requireAsyncLayer } from "../require-async-layer.js";
|
import { requireAsyncLayer } from "../require-async-layer.js";
|
||||||
import { runReportPipeline, type ReportInput, type StructuredReport } from "../report-pipeline.js";
|
import { runReportPipeline, type ReportInput, type ReportScreenshot, type StructuredReport } from "../report-pipeline.js";
|
||||||
import { scrubReportPayload } from "../report-scrub.js";
|
import { scrubReportPayload } from "../report-scrub.js";
|
||||||
import { selfCheckHelp } from "../report-help-selfcheck.js";
|
import { selfCheckHelp } from "../report-help-selfcheck.js";
|
||||||
import type { ApiRouteRegistrar } from "./types.js";
|
import type { ApiRouteRegistrar } from "./types.js";
|
||||||
@@ -30,13 +32,22 @@ function parseActivityTrace(value: unknown): string[] | undefined {
|
|||||||
function parseScreenshotArtifactId(value: unknown): string | undefined {
|
function parseScreenshotArtifactId(value: unknown): string | undefined {
|
||||||
if (value === undefined) return undefined;
|
if (value === undefined) return undefined;
|
||||||
if (typeof value !== "string" || !ARTIFACT_ID_PATTERN.test(value)) throw new ApiError(400, "Screenshot artifact reference is invalid.");
|
if (typeof value !== "string" || !ARTIFACT_ID_PATTERN.test(value)) throw new ApiError(400, "Screenshot artifact reference is invalid.");
|
||||||
return value as "issue" | "discussion";
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveReportScreenshot(store: Awaited<ReturnType<Parameters<ApiRouteRegistrar>[0]["getScopedStore"]>>, id: string | undefined): Promise<ReportScreenshot | undefined> {
|
||||||
|
if (!id) return undefined;
|
||||||
|
const artifact = await store.getArtifact(id);
|
||||||
|
if (artifact?.type !== "image" || artifact.metadata?.source !== REPORT_ATTACHMENT_SOURCE || !artifact.mimeType || !ALLOWED_IMAGE_MIMES.has(artifact.mimeType)) throw new ApiError(400, "Screenshot artifact is unavailable or invalid.");
|
||||||
|
const bytes = await readArtifactMediaBytes(store, artifact);
|
||||||
|
if (bytes.length === 0 || bytes.length > MAX_IMAGE_BYTES || imageMimeType(bytes) !== artifact.mimeType) throw new ApiError(400, "Screenshot artifact is unavailable or invalid.");
|
||||||
|
return { artifactId: artifact.id, filename: artifact.title || "Report screenshot", mimeType: artifact.mimeType, bytes };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateScreenshotArtifact(store: Awaited<ReturnType<Parameters<ApiRouteRegistrar>[0]["getScopedStore"]>>, id: string | undefined): Promise<void> {
|
async function validateScreenshotArtifact(store: Awaited<ReturnType<Parameters<ApiRouteRegistrar>[0]["getScopedStore"]>>, id: string | undefined): Promise<void> {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
const artifact = await store.getArtifact(id);
|
const artifact = await store.getArtifact(id);
|
||||||
if (artifact?.type !== "image" || artifact.metadata?.source !== REPORT_ATTACHMENT_SOURCE) throw new ApiError(400, "Screenshot artifact is unavailable or invalid.");
|
if (artifact?.type !== "image" || artifact.metadata?.source !== REPORT_ATTACHMENT_SOURCE || !artifact.mimeType || !ALLOWED_IMAGE_MIMES.has(artifact.mimeType)) throw new ApiError(400, "Screenshot artifact is unavailable or invalid.");
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageMimeType(buffer: Buffer): "image/png" | "image/jpeg" | undefined {
|
function imageMimeType(buffer: Buffer): "image/png" | "image/jpeg" | undefined {
|
||||||
@@ -80,7 +91,8 @@ function parseInput(body: unknown): ReportInput {
|
|||||||
* FNXC:ReportPipeline 2026-07-19-10:00:
|
* FNXC:ReportPipeline 2026-07-19-10:00:
|
||||||
* Report routes persist opted-in PNG/JPEG pixels locally as provenance-marked
|
* Report routes persist opted-in PNG/JPEG pixels locally as provenance-marked
|
||||||
* artifacts. Draft and file requests carry only a validated reference and text
|
* artifacts. Draft and file requests carry only a validated reference and text
|
||||||
* note, so no screenshot pixels can cross the GitHub egress boundary.
|
* note. Filing resolves that explicit reference through the guarded artifact-media
|
||||||
|
* seam before the separately consented Contents-API upload; raw request pixels are never accepted.
|
||||||
*/
|
*/
|
||||||
export const registerReportRoutes: ApiRouteRegistrar = ({ router, getScopedStore, rethrowAsApiError, reportUpload }) => {
|
export const registerReportRoutes: ApiRouteRegistrar = ({ router, getScopedStore, rethrowAsApiError, reportUpload }) => {
|
||||||
const attachment = async (req: Request & { file?: { buffer?: Buffer; mimetype?: string } }, res: Response) => {
|
const attachment = async (req: Request & { file?: { buffer?: Buffer; mimetype?: string } }, res: Response) => {
|
||||||
@@ -114,15 +126,16 @@ export const registerReportRoutes: ApiRouteRegistrar = ({ router, getScopedStore
|
|||||||
router.post("/report/file", async (req, res) => {
|
router.post("/report/file", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const store = await getScopedStore(req); const scopes = await store.getSettingsByScopeFast(); const raw = (req.body ?? {}) as Record<string, unknown>; const rawReport = (raw.report ?? raw) as StructuredReport;
|
const store = await getScopedStore(req); const scopes = await store.getSettingsByScopeFast(); const raw = (req.body ?? {}) as Record<string, unknown>; const rawReport = (raw.report ?? raw) as StructuredReport;
|
||||||
const { screenshotArtifactId: reportArtifactId, ...textualRawReport } = rawReport;
|
const { screenshotArtifactId: reportArtifactId, attachment: _untrustedAttachment, ...textualRawReport } = rawReport;
|
||||||
const untrusted = scrubReportPayload(textualRawReport, { rootDir: store.getRootDir(), projectName: store.getRootDir().split(/[\\/]/).pop() });
|
const untrusted = scrubReportPayload(textualRawReport, { rootDir: store.getRootDir(), projectName: store.getRootDir().split(/[\\/]/).pop() });
|
||||||
const input = parseInput({ actionType: raw.actionType ?? (untrusted.context as Record<string, unknown> | undefined)?.actionType ?? "bug", userPrompt: untrusted.userPrompt ?? untrusted.summary, contextRefs: (untrusted.context as Record<string, unknown> | undefined) && { taskId: typeof (untrusted.context as Record<string, unknown>).taskId === "string" ? (untrusted.context as Record<string, unknown>).taskId : undefined, agentId: typeof (untrusted.context as Record<string, unknown>).agentId === "string" ? (untrusted.context as Record<string, unknown>).agentId : undefined }, activityTrace: raw.activityTrace ?? (untrusted.context as Record<string, unknown> | undefined)?.activityTrace, screenshotArtifactId: raw.screenshotArtifactId ?? reportArtifactId });
|
const input = parseInput({ actionType: raw.actionType ?? (untrusted.context as Record<string, unknown> | undefined)?.actionType ?? "bug", userPrompt: untrusted.userPrompt ?? untrusted.summary, contextRefs: (untrusted.context as Record<string, unknown> | undefined) && { taskId: typeof (untrusted.context as Record<string, unknown>).taskId === "string" ? (untrusted.context as Record<string, unknown>).taskId : undefined, agentId: typeof (untrusted.context as Record<string, unknown>).agentId === "string" ? (untrusted.context as Record<string, unknown>).agentId : undefined }, activityTrace: raw.activityTrace ?? (untrusted.context as Record<string, unknown> | undefined)?.activityTrace, screenshotArtifactId: raw.screenshotArtifactId ?? reportArtifactId });
|
||||||
// FNXC:ReportPipeline 2026-07-16-21:00: Validate target before Help can return locally.
|
// FNXC:ReportPipeline 2026-07-16-21:00: Validate target before Help can return locally.
|
||||||
const targetType = parseTargetType(raw.targetType);
|
const targetType = parseTargetType(raw.targetType);
|
||||||
await validateScreenshotArtifact(store, input.screenshotArtifactId);
|
const attachment = await resolveReportScreenshot(store, input.screenshotArtifactId);
|
||||||
const help = await selfCheckHelpBeforePipeline(store, input);
|
const inputWithAttachment = attachment ? { ...input, attachment } : input;
|
||||||
|
const help = await selfCheckHelpBeforePipeline(store, inputWithAttachment);
|
||||||
if (help?.answered) return void res.json({ kind: "help", answer: help.answer });
|
if (help?.answered) return void res.json({ kind: "help", answer: help.answer });
|
||||||
res.json(await runReportPipeline(input, { projectSettings: scopes.project, globalSettings: scopes.global, scrubContext: { rootDir: store.getRootDir(), projectName: store.getRootDir().split(/[\\/]/).pop() }, gatherContext: (reportInput) => gatherReportContext(store, reportInput, scopes.project as Record<string, unknown>) }, { file: true, 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, endorseIssueNumber: typeof raw.endorseIssueNumber === "number" ? raw.endorseIssueNumber : undefined, endorseDiscussionId: typeof raw.endorseDiscussionId === "string" ? raw.endorseDiscussionId : undefined, endorseRoadmapIssueNumber: typeof raw.endorseRoadmapIssueNumber === "number" ? raw.endorseRoadmapIssueNumber : undefined, report: untrusted }));
|
||||||
} catch (error) { if (error instanceof ApiError) throw error; rethrowAsApiError(error, "Failed to file report"); }
|
} catch (error) { if (error instanceof ApiError) throw error; rethrowAsApiError(error, "Failed to file report"); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createReadStream } from "node:fs";
|
import { createReadStream } from "node:fs";
|
||||||
import { stat } from "node:fs/promises";
|
import { stat } from "node:fs/promises";
|
||||||
import { join, resolve, sep } from "node:path";
|
import { join } from "node:path";
|
||||||
import type {
|
import type {
|
||||||
TaskStore,
|
TaskStore,
|
||||||
Task,
|
Task,
|
||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
type ThinkingLevel,
|
type ThinkingLevel,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import { GitHubClient } from "../github.js";
|
import { GitHubClient } from "../github.js";
|
||||||
|
import { resolveArtifactMediaPath } from "../artifact-media.js";
|
||||||
import { githubRateLimiter } from "../github-poll.js";
|
import { githubRateLimiter } from "../github-poll.js";
|
||||||
import { createTrackingIssueForTask } from "../github-tracking-hook.js";
|
import { createTrackingIssueForTask } from "../github-tracking-hook.js";
|
||||||
import { parseGitHubBadgeUrl } from "./register-git-github.js";
|
import { parseGitHubBadgeUrl } from "./register-git-github.js";
|
||||||
@@ -163,27 +164,6 @@ function isArtifactType(value: string): value is ArtifactType {
|
|||||||
return ARTIFACT_TYPES.has(value as ArtifactType);
|
return ARTIFACT_TYPES.has(value as ArtifactType);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveArtifactMediaPath(scopedStore: TaskStore, artifact: { taskId?: string; uri?: string }): string | null {
|
|
||||||
if (!artifact.uri) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const anchorDir = artifact.taskId ? scopedStore.getTaskDir(artifact.taskId) : scopedStore.getFusionDir();
|
|
||||||
const expectedArtifactsDir = resolve(anchorDir, "artifacts");
|
|
||||||
const expectedAttachmentsDir = artifact.taskId ? resolve(anchorDir, "attachments") : null;
|
|
||||||
const mediaPath = resolve(anchorDir, artifact.uri);
|
|
||||||
const underArtifacts = mediaPath === expectedArtifactsDir || mediaPath.startsWith(`${expectedArtifactsDir}${sep}`);
|
|
||||||
const underAttachments = expectedAttachmentsDir !== null && (mediaPath === expectedAttachmentsDir || mediaPath.startsWith(`${expectedAttachmentsDir}${sep}`));
|
|
||||||
/*
|
|
||||||
* FNXC:ArtifactRegistry 2026-07-10-00:00:
|
|
||||||
* Attachment-sourced image artifacts intentionally store `attachments/<file>` URIs so /media streams the original task attachment bytes without a second artifact copy. Keep the resolver anchored to task-owned artifact/attachment directories only; task-less artifacts still resolve exclusively under `.fusion/artifacts/`.
|
|
||||||
*/
|
|
||||||
if (!underArtifacts && !underAttachments) {
|
|
||||||
throw badRequest("Invalid artifact media path");
|
|
||||||
}
|
|
||||||
return mediaPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AutoSyncOutcome {
|
interface AutoSyncOutcome {
|
||||||
worktreePath: string | null;
|
worktreePath: string | null;
|
||||||
outcome: string;
|
outcome: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user