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)", () => {
|
||||
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 () => {
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
Reference in New Issue
Block a user