feat(FN-4841): merge fusion/fn-4841
This commit is contained in:
5
.changeset/FN-4841-copilot-fusion-oauth-usage.md
Normal file
5
.changeset/FN-4841-copilot-fusion-oauth-usage.md
Normal 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.
|
||||||
@@ -142,8 +142,11 @@ describe("usage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("fetchGitHubCopilotUsage (via fetchAllProviderUsage)", () => {
|
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 () => {
|
it("returns no-auth when gh auth status fails", async () => {
|
||||||
mockReadFile.mockRejectedValue(new Error("File not found"));
|
mockReadFile.mockRejectedValue(new Error("File not found"));
|
||||||
|
coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({});
|
||||||
mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
|
mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
|
||||||
if (cmd === "gh" && args[0] === "auth") {
|
if (cmd === "gh" && args[0] === "auth") {
|
||||||
throw new Error("not logged in");
|
throw new Error("not logged in");
|
||||||
@@ -157,14 +160,117 @@ describe("usage", () => {
|
|||||||
expect(copilot).toBeUndefined();
|
expect(copilot).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns ok with plan when gh api succeeds", async () => {
|
it("returns ok when Fusion github-copilot oauth is present", async () => {
|
||||||
mockReadFile.mockRejectedValue(new Error("File not found"));
|
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[]) => {
|
mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
|
||||||
if (cmd === "gh" && args[0] === "auth") {
|
if (cmd === "gh" && args[0] === "auth") {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
if (cmd === "gh" && args[0] === "api" && args[1] === "/user/copilot") {
|
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");
|
throw new Error("File not found");
|
||||||
});
|
});
|
||||||
@@ -173,7 +279,8 @@ describe("usage", () => {
|
|||||||
const copilot = providers.find((p) => p.name === "GitHub Copilot");
|
const copilot = providers.find((p) => p.name === "GitHub Copilot");
|
||||||
expect(copilot).toBeDefined();
|
expect(copilot).toBeDefined();
|
||||||
expect(copilot!.status).toBe("ok");
|
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 () => {
|
it("returns error when Copilot subscription not found (404)", async () => {
|
||||||
|
|||||||
@@ -1644,6 +1644,77 @@ async function fetchZaiUsage(authStorage?: AuthStorageLike): Promise<ProviderUsa
|
|||||||
|
|
||||||
// ── GitHub Copilot fetcher ──────────────────────────────────────────────────
|
// ── 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> {
|
async function fetchGitHubCopilotUsage(): Promise<ProviderUsage> {
|
||||||
const usage: ProviderUsage = {
|
const usage: ProviderUsage = {
|
||||||
name: "GitHub Copilot",
|
name: "GitHub Copilot",
|
||||||
@@ -1652,6 +1723,40 @@ async function fetchGitHubCopilotUsage(): Promise<ProviderUsage> {
|
|||||||
windows: [],
|
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 {
|
try {
|
||||||
await execFileAsync("gh", ["auth", "status"], { encoding: "utf-8", timeout: 5000 });
|
await execFileAsync("gh", ["auth", "status"], { encoding: "utf-8", timeout: 5000 });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1665,47 +1770,7 @@ async function fetchGitHubCopilotUsage(): Promise<ProviderUsage> {
|
|||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = JSON.parse(stdout.trim());
|
applyGitHubCopilotUsagePayload(usage, 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const errMsg = e instanceof Error ? e.message : "Failed to fetch";
|
const errMsg = e instanceof Error ? e.message : "Failed to fetch";
|
||||||
if (errMsg.includes("404") || errMsg.includes("Not Found")) {
|
if (errMsg.includes("404") || errMsg.includes("Not Found")) {
|
||||||
|
|||||||
Reference in New Issue
Block a user