feat(FN-4841): merge fusion/fn-4841

This commit is contained in:
gsxdsm
2026-05-17 00:22:57 -07:00
parent f0df7cb1d2
commit 2adc9fa0da
3 changed files with 222 additions and 45 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
GitHub Copilot now appears as logged-in in the dashboard usage dropdown when authenticated via Fusion's Settings → Authentication OAuth flow, in addition to the existing `gh` CLI detection.

View File

@@ -142,8 +142,11 @@ describe("usage", () => {
});
describe("fetchGitHubCopilotUsage (via fetchAllProviderUsage)", () => {
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
it("returns no-auth when gh auth status fails", async () => {
mockReadFile.mockRejectedValue(new Error("File not found"));
coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({});
mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "gh" && args[0] === "auth") {
throw new Error("not logged in");
@@ -157,14 +160,117 @@ describe("usage", () => {
expect(copilot).toBeUndefined();
});
it("returns ok with plan when gh api succeeds", async () => {
mockReadFile.mockRejectedValue(new Error("File not found"));
it("returns ok when Fusion github-copilot oauth is present", async () => {
coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({
"github-copilot": { type: "oauth", access: "fusion-gho", refresh: "r", expires: Date.now() + 60_000 },
});
mockRequest.mockImplementation((options: any, callback: any) => {
const mockRes = {
statusCode: 200,
headers: {},
on: vi.fn((event: string, handler: any) => {
if (event === "data") {
handler(Buffer.from('{"copilot_plan_type":"individual"}'));
}
if (event === "end") handler();
}),
};
callback(mockRes);
return mockReq;
});
const providers = await fetchAllProviderUsage();
const copilot = providers.find((p) => p.name === "GitHub Copilot");
expect(copilot).toBeDefined();
expect(copilot!.status).toBe("ok");
expect(copilot!.plan).toBe("Individual");
});
it("prefers Fusion oauth over gh CLI when both are present", async () => {
coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({
"github-copilot": { type: "oauth", access: "fusion-gho", refresh: "r", expires: Date.now() + 60_000 },
});
mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "gh" && args[0] === "auth") return "";
if (cmd === "gh" && args[0] === "api" && args[1] === "/user/copilot") {
return JSON.stringify({ copilot_plan_type: "business" });
}
throw new Error("File not found");
});
mockRequest.mockImplementation((options: any, callback: any) => {
expect(options.headers.authorization).toBe("Bearer fusion-gho");
const mockRes = {
statusCode: 200,
headers: {},
on: vi.fn((event: string, handler: any) => {
if (event === "data") handler(Buffer.from('{"copilot_plan_type":"individual"}'));
if (event === "end") handler();
}),
};
callback(mockRes);
return mockReq;
});
const providers = await fetchAllProviderUsage();
const copilot = providers.find((p) => p.name === "GitHub Copilot");
expect(copilot?.status).toBe("ok");
expect(mockRequest).toHaveBeenCalled();
expect(
mockExecFileSync.mock.calls.some(
([cmd, args]) => cmd === "gh" && Array.isArray(args) && args[0] === "api" && args[1] === "/user/copilot"
)
).toBe(false);
});
it("returns no-auth when Fusion github-copilot oauth is expired and gh CLI is also missing", async () => {
coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({
"github-copilot": { type: "oauth", access: "fusion-gho", refresh: "r", expires: Date.now() - 60_000 },
});
mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "gh" && args[0] === "auth") {
throw new Error("not logged in");
}
throw new Error("File not found");
});
const providers = await fetchAllProviderUsage();
const copilot = providers.find((p) => p.name === "GitHub Copilot");
expect(copilot).toBeUndefined();
});
it("surfaces Fusion re-login guidance when Fusion-sourced token gets 401", async () => {
coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({
"github-copilot": { type: "oauth", access: "fusion-gho", refresh: "r", expires: Date.now() + 60_000 },
});
mockRequest.mockImplementation((_options: any, callback: any) => {
const mockRes = {
statusCode: 401,
headers: {},
on: vi.fn((event: string, handler: any) => {
if (event === "data") handler(Buffer.from('{"message":"Bad credentials"}'));
if (event === "end") handler();
}),
};
callback(mockRes);
return mockReq;
});
const providers = await fetchAllProviderUsage();
const copilot = providers.find((p) => p.name === "GitHub Copilot");
expect(copilot?.status).toBe("error");
expect(copilot?.error).toContain("re-login from Fusion Settings");
});
it("falls back to gh CLI when no Fusion credential is present", async () => {
coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({});
mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "gh" && args[0] === "auth") {
return "";
}
if (cmd === "gh" && args[0] === "api" && args[1] === "/user/copilot") {
return JSON.stringify({ copilot_plan_type: "individual" });
return JSON.stringify({ copilot_plan_type: "free", chat_messages_used: 5, chat_messages_limit: 10 });
}
throw new Error("File not found");
});
@@ -173,7 +279,8 @@ describe("usage", () => {
const copilot = providers.find((p) => p.name === "GitHub Copilot");
expect(copilot).toBeDefined();
expect(copilot!.status).toBe("ok");
expect(copilot!.plan).toBe("Individual");
expect(copilot!.plan).toBe("Free");
expect(copilot!.windows.some((window) => window.label === "Chat (Monthly)")).toBe(true);
});
it("returns error when Copilot subscription not found (404)", async () => {

View File

@@ -1644,6 +1644,77 @@ async function fetchZaiUsage(authStorage?: AuthStorageLike): Promise<ProviderUsa
// ── GitHub Copilot fetcher ──────────────────────────────────────────────────
type CopilotCredential = {
accessToken: string;
source: "fusion-auth";
};
async function loadGitHubCopilotCredential(): Promise<CopilotCredential | null> {
let preferredCredential: ReturnType<typeof choosePreferredStoredCredential>;
for (const candidatePath of getAuthFileCandidates()) {
const authEntries = readStoredCredentialsFromAuthFile(candidatePath);
preferredCredential = choosePreferredStoredCredential(preferredCredential, authEntries["github-copilot"]);
}
if (
preferredCredential?.type === "oauth"
&& typeof preferredCredential.access === "string"
&& preferredCredential.access.length > 0
&& typeof preferredCredential.expires === "number"
&& Number.isFinite(preferredCredential.expires)
&& preferredCredential.expires > Date.now()
) {
return {
accessToken: preferredCredential.access,
source: "fusion-auth",
};
}
return null;
}
function applyGitHubCopilotUsagePayload(usage: ProviderUsage, data: Record<string, unknown>): void {
usage.status = "ok";
if (typeof data.seat_management_setting === "string" && data.seat_management_setting.length > 0) {
usage.plan = data.seat_management_setting;
}
const planType =
typeof data.copilot_plan_type === "string"
? data.copilot_plan_type
: typeof data.plan_type === "string"
? data.plan_type
: undefined;
if (planType) {
usage.plan = planType.charAt(0).toUpperCase() + planType.slice(1);
}
if (data.copilot_plan_type === "free" || data.plan_type === "free") {
if (typeof data.chat_messages_used === "number" && typeof data.chat_messages_limit === "number") {
const chatPct = data.chat_messages_limit > 0 ? (data.chat_messages_used / data.chat_messages_limit) * 100 : 0;
usage.windows.push({
label: "Chat (Monthly)",
percentUsed: Math.min(100, Math.max(0, chatPct)),
percentLeft: Math.min(100, Math.max(0, 100 - chatPct)),
resetText: null,
windowDurationMs: 30 * 24 * 60 * 60 * 1000,
});
}
if (typeof data.completions_used === "number" && typeof data.completions_limit === "number") {
const completionPct = data.completions_limit > 0 ? (data.completions_used / data.completions_limit) * 100 : 0;
usage.windows.push({
label: "Completions (Monthly)",
percentUsed: Math.min(100, Math.max(0, completionPct)),
percentLeft: Math.min(100, Math.max(0, 100 - completionPct)),
resetText: null,
windowDurationMs: 30 * 24 * 60 * 60 * 1000,
});
}
}
}
async function fetchGitHubCopilotUsage(): Promise<ProviderUsage> {
const usage: ProviderUsage = {
name: "GitHub Copilot",
@@ -1652,6 +1723,40 @@ async function fetchGitHubCopilotUsage(): Promise<ProviderUsage> {
windows: [],
};
const fusionCredential = await loadGitHubCopilotCredential();
if (fusionCredential) {
try {
const res = await httpsRequest("https://api.github.com/user/copilot", {
method: "GET",
headers: {
authorization: `Bearer ${fusionCredential.accessToken}`,
accept: "application/vnd.github+json",
"x-github-api-version": "2022-11-28",
"user-agent": "fusion-dashboard",
},
});
if (res.status === 200) {
applyGitHubCopilotUsagePayload(usage, JSON.parse(res.body));
return usage;
}
usage.status = "error";
if (res.status === 404) {
usage.error = "No Copilot subscription found";
} else if (res.status === 401 || res.status === 403) {
usage.error = "Auth expired — re-login from Fusion Settings → Authentication";
} else {
usage.error = `HTTP ${res.status}: ${res.body.slice(0, 200)}`;
}
return usage;
} catch (e: unknown) {
usage.status = "error";
usage.error = e instanceof Error ? e.message : "Failed to fetch";
return usage;
}
}
try {
await execFileAsync("gh", ["auth", "status"], { encoding: "utf-8", timeout: 5000 });
} catch {
@@ -1665,47 +1770,7 @@ async function fetchGitHubCopilotUsage(): Promise<ProviderUsage> {
timeout: 10000,
});
const data = JSON.parse(stdout.trim());
usage.status = "ok";
if (data.seat_management_setting) {
usage.plan = data.seat_management_setting;
}
const planType: string | undefined = data.copilot_plan_type || data.plan_type;
if (planType) {
usage.plan = planType.charAt(0).toUpperCase() + planType.slice(1);
}
if (data.copilot_plan_type === "free" || data.plan_type === "free") {
if (data.chat_messages_used !== undefined && data.chat_messages_limit !== undefined) {
const chatPct =
data.chat_messages_limit > 0
? (data.chat_messages_used / data.chat_messages_limit) * 100
: 0;
usage.windows.push({
label: "Chat (Monthly)",
percentUsed: Math.min(100, Math.max(0, chatPct)),
percentLeft: Math.min(100, Math.max(0, 100 - chatPct)),
resetText: null,
windowDurationMs: 30 * 24 * 60 * 60 * 1000,
});
}
if (data.completions_used !== undefined && data.completions_limit !== undefined) {
const completionPct =
data.completions_limit > 0
? (data.completions_used / data.completions_limit) * 100
: 0;
usage.windows.push({
label: "Completions (Monthly)",
percentUsed: Math.min(100, Math.max(0, completionPct)),
percentLeft: Math.min(100, Math.max(0, 100 - completionPct)),
resetText: null,
windowDurationMs: 30 * 24 * 60 * 60 * 1000,
});
}
}
applyGitHubCopilotUsagePayload(usage, JSON.parse(stdout.trim()));
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : "Failed to fetch";
if (errMsg.includes("404") || errMsg.includes("Not Found")) {