feat(FN-4764): complete Step 2-4 — surface structured gh errors in API UI CLI

Fusion-Task-Id: FN-4764
Fusion-Task-Lineage: 14809ad5-8463-4e16-a934-1414225f17ae
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 14:27:53 -07:00
committed by gsxdsm
parent 71dfc916f4
commit 26c3d330d3
8 changed files with 242 additions and 25 deletions

View File

@@ -83,6 +83,13 @@ vi.mock("@fusion/core/gh-cli", () => ({
getCurrentRepo: vi.fn(),
runGhJsonAsync: vi.fn(),
getGhErrorMessage: vi.fn((error: unknown) => (error instanceof Error ? error.message : String(error))),
classifyGhError: vi.fn((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
if (message.toLowerCase().includes("rate limit")) {
return { code: "rate-limited", message: "GitHub API rate limit exceeded. Please try again later.", retryable: true, action: { kind: "retry" } };
}
return { code: "unknown", message, retryable: true, action: { kind: "retry" } };
}),
}));
// Mock project-context
@@ -2546,6 +2553,7 @@ import type { PrInfo } from "@fusion/core";
describe("runTaskPrCreate", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let stderrWriteSpy: ReturnType<typeof vi.spyOn>;
let mockGetTask: ReturnType<typeof vi.fn>;
let mockUpdatePrInfo: ReturnType<typeof vi.fn>;
let mockLogEntry: ReturnType<typeof vi.fn>;
@@ -2585,7 +2593,8 @@ describe("runTaskPrCreate", () => {
beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
stderrWriteSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
// Reset process.env
process.env = { ...originalEnv };
delete process.env.GITHUB_REPOSITORY;
@@ -2862,18 +2871,21 @@ describe("runTaskPrCreate", () => {
exitSpy.mockRestore();
});
it("exits with generic error for unexpected failures", async () => {
it("formats retryable GitHub errors for CLI output", async () => {
const task = makeInReviewTask();
mockGetTask.mockResolvedValueOnce(task);
mockCreatePr.mockRejectedValueOnce(new Error("Network error"));
mockCreatePr.mockRejectedValueOnce({ message: "403 API rate limit exceeded", stderr: "Retry-After: 5" });
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit");
}) as (code?: number) => never);
await expect(runTaskPrCreate("FN-001", {})).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Network error"));
const output = stderrWriteSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("GitHub error:");
expect(output).toContain("retryable");
expect(output).toContain("fn pr create <task-id>");
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
});

View File

@@ -9,6 +9,7 @@ import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node
import { basename, join } from "node:path";
import * as dashboard from "@fusion/dashboard";
import {
classifyGhError,
getGhErrorMessage,
getCurrentRepo,
isGhAuthenticated,
@@ -29,6 +30,17 @@ try {
// Some tests partially mock @fusion/dashboard and omit the hook export.
}
function formatGhErrorForCli(err: unknown): string {
const structured = classifyGhError(err);
const lines = [`GitHub error: ${structured.message}`];
if (structured.hint) lines.push(` Hint: ${structured.hint}`);
if (structured.action?.kind === "shell") lines.push(` Action: run \`${structured.action.command}\``);
if (structured.action?.kind === "open") lines.push(` Action: open ${structured.action.url}`);
if (structured.action?.kind === "retry") lines.push(" Action: retry the command");
if (structured.retryable) lines.push(" (retryable — re-run `fn pr create <task-id>` to try again)");
return `${lines.join("\n")}\n`;
}
function getGitHubIssueUrl(sourceMetadata: unknown): string | undefined {
if (!sourceMetadata || typeof sourceMetadata !== "object") return undefined;
const issueUrl = (sourceMetadata as { issueUrl?: unknown }).issueUrl;
@@ -1536,7 +1548,7 @@ export async function runTaskPrCreate(id: string, options: PrCreateOptions = {},
console.error(`Error: No commits between ${options.base || "default base"} and ${branchName}. Push changes before creating PR.`);
process.exit(1);
} else {
console.error(`Error: ${msg || "Failed to create PR"}`);
process.stderr.write(formatGhErrorForCli(err));
process.exit(1);
}
}