feat(FN-2588): add GitHub Copilot usage to provider metrics
- Add a GitHub Copilot usage fetcher to usage collection flow - Include copilot in provider aggregation so totals include Copilot consumption - Map the copilot provider to the correct icon key in UsageIndicator - Expand usage tests to cover Copilot provider parsing and aggregation behavior
This commit is contained in:
@@ -56,6 +56,9 @@ describe("usage", () => {
|
||||
mockRequest.mockClear();
|
||||
mockReadFile.mockClear();
|
||||
mockExecFileSync.mockClear();
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("File not found");
|
||||
});
|
||||
vi.stubEnv("HOME", "/home/testuser");
|
||||
});
|
||||
|
||||
@@ -72,12 +75,13 @@ describe("usage", () => {
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
|
||||
expect(providers).toHaveLength(5);
|
||||
expect(providers).toHaveLength(6);
|
||||
expect(providers.map((p) => p.name)).toContain("Claude");
|
||||
expect(providers.map((p) => p.name)).toContain("Codex");
|
||||
expect(providers.map((p) => p.name)).toContain("Gemini");
|
||||
expect(providers.map((p) => p.name)).toContain("Minimax");
|
||||
expect(providers.map((p) => p.name)).toContain("Zai");
|
||||
expect(providers.map((p) => p.name)).toContain("GitHub Copilot");
|
||||
expect(providers.map((p) => p.name)).not.toContain("Kimi");
|
||||
|
||||
// All should be no-auth status
|
||||
@@ -113,7 +117,63 @@ describe("usage", () => {
|
||||
|
||||
// Should be different array reference
|
||||
expect(second).not.toBe(first);
|
||||
expect(second).toHaveLength(5);
|
||||
expect(second).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGitHubCopilotUsage (via fetchAllProviderUsage)", () => {
|
||||
it("returns no-auth when gh auth status fails", async () => {
|
||||
mockReadFile.mockRejectedValue(new Error("File not found"));
|
||||
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).toBeDefined();
|
||||
expect(copilot!.status).toBe("no-auth");
|
||||
expect(copilot!.error).toContain("not authenticated");
|
||||
});
|
||||
|
||||
it("returns ok with plan when gh api succeeds", async () => {
|
||||
mockReadFile.mockRejectedValue(new Error("File not found"));
|
||||
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" });
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
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("returns error when Copilot subscription not found (404)", async () => {
|
||||
mockReadFile.mockRejectedValue(new Error("File not found"));
|
||||
mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
|
||||
if (cmd === "gh" && args[0] === "auth") {
|
||||
return "";
|
||||
}
|
||||
if (cmd === "gh" && args[0] === "api") {
|
||||
throw new Error("HTTP 404: Not Found");
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const copilot = providers.find((p) => p.name === "GitHub Copilot");
|
||||
expect(copilot).toBeDefined();
|
||||
expect(copilot!.status).toBe("error");
|
||||
expect(copilot!.error).toContain("No Copilot subscription");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1537,6 +1537,87 @@ async function fetchZaiUsage(authStorage?: AuthStorageLike): Promise<ProviderUsa
|
||||
return usage;
|
||||
}
|
||||
|
||||
// ── GitHub Copilot fetcher ──────────────────────────────────────────────────
|
||||
|
||||
async function fetchGitHubCopilotUsage(): Promise<ProviderUsage> {
|
||||
const usage: ProviderUsage = {
|
||||
name: "GitHub Copilot",
|
||||
icon: "⚫",
|
||||
status: "no-auth",
|
||||
windows: [],
|
||||
};
|
||||
|
||||
try {
|
||||
await execFileAsync("gh", ["auth", "status"], { encoding: "utf-8", timeout: 5000 });
|
||||
} catch {
|
||||
usage.error = "GitHub CLI not authenticated — run 'gh auth login'";
|
||||
return usage;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync("gh", ["api", "/user/copilot", "--jq", "."], {
|
||||
encoding: "utf-8",
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const errMsg = e instanceof Error ? e.message : "Failed to fetch";
|
||||
if (errMsg.includes("404") || errMsg.includes("Not Found")) {
|
||||
usage.status = "error";
|
||||
usage.error = "No Copilot subscription found";
|
||||
} else if (errMsg.includes("401") || errMsg.includes("403")) {
|
||||
usage.status = "error";
|
||||
usage.error = "GitHub auth expired — run 'gh auth login'";
|
||||
} else {
|
||||
usage.status = "error";
|
||||
usage.error = errMsg;
|
||||
}
|
||||
}
|
||||
|
||||
return usage;
|
||||
}
|
||||
|
||||
// ── Main export ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -1598,13 +1679,14 @@ export async function fetchAllProviderUsage(authStorage?: AuthStorageLike): Prom
|
||||
}
|
||||
|
||||
// Fetch all providers in parallel with per-provider timeout
|
||||
// Currently includes: Claude, Codex, Gemini, Minimax, Zai
|
||||
// Currently includes: Claude, Codex, Gemini, Minimax, Zai, GitHub Copilot
|
||||
const results = await Promise.allSettled([
|
||||
withTimeout(fetchClaudeUsage(), "Claude", CLAUDE_FETCH_TIMEOUT_MS),
|
||||
withTimeout(fetchCodexUsage(), "Codex"),
|
||||
withTimeout(fetchGeminiUsage(), "Gemini"),
|
||||
withTimeout(fetchMinimaxUsage(authStorage), "Minimax"),
|
||||
withTimeout(fetchZaiUsage(authStorage), "Zai"),
|
||||
withTimeout(fetchGitHubCopilotUsage(), "GitHub Copilot"),
|
||||
]);
|
||||
|
||||
const providers: ProviderUsage[] = [];
|
||||
|
||||
Reference in New Issue
Block a user