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:
committed by
gsxdsm
parent
71dfc916f4
commit
26c3d330d3
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user