From b153388d7ae2e1e8b745265be5da7c45ae3a4658 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 Aug 2025 23:10:13 +0000 Subject: [PATCH 1/3] Initial plan From b50a3d3b32eae41c7d154048735b7c8de865f0c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 Aug 2025 23:21:11 +0000 Subject: [PATCH 2/3] Fix pagination bug and type safety issues in runners API Co-authored-by: ZainRizvi <4468967+ZainRizvi@users.noreply.github.com> --- torchci/pages/api/runners/[org].ts | 80 ++++++++++++++++++++------- torchci/test/runners-api.test.ts | 86 ++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 19 deletions(-) create mode 100644 torchci/test/runners-api.test.ts diff --git a/torchci/pages/api/runners/[org].ts b/torchci/pages/api/runners/[org].ts index d5ba6647f6..0fc9718155 100644 --- a/torchci/pages/api/runners/[org].ts +++ b/torchci/pages/api/runners/[org].ts @@ -2,6 +2,23 @@ import { createAppAuth } from "@octokit/auth-app"; import { App, Octokit } from "octokit"; import type { NextApiRequest, NextApiResponse } from "next"; +// GitHub API response types +interface GitHubRunnerLabel { + id?: number; + name: string; + type?: "read-only" | "custom"; +} + +interface GitHubApiRunner { + id: number; + name: string; + os: string; + status: string; // GitHub API may return other statuses we don't know about + busy: boolean; + labels: GitHubRunnerLabel[]; +} + +// Our application response types export interface RunnerData { id: number; name: string; @@ -20,6 +37,47 @@ export interface RunnersApiResponse { runners: RunnerData[]; } +// Fetch all runners with proper pagination +async function fetchAllRunners(octokit: Octokit, org: string): Promise { + const allRunners: RunnerData[] = []; + let page = 1; + const perPage = 100; // GitHub API maximum per page + + while (true) { + const response = await octokit.request("GET /orgs/{org}/actions/runners", { + org, + per_page: perPage, + page, + }); + + const runnersPage = response.data; + + // Map GitHub API response to our format with proper type safety + const mappedRunners: RunnerData[] = runnersPage.runners.map((runner: any) => ({ + id: runner.id, + name: runner.name, + os: runner.os, + status: (runner.status === "online" || runner.status === "offline") ? runner.status : "offline", + busy: runner.busy, + labels: runner.labels.map((label: any) => ({ + id: label.id, + name: label.name, + type: (label.type === "read-only" || label.type === "custom") ? label.type : "custom", + })), + })); + + allRunners.push(...mappedRunners); + + // Check if we've fetched all runners + if (runnersPage.runners.length < perPage) { + break; + } + + page++; + } + + return allRunners; +} // Get Octokit instance authenticated for organization-level access async function getOctokitForOrg(org: string): Promise { let privateKey = process.env.PRIVATE_KEY as string; @@ -63,27 +121,11 @@ export default async function handler( try { const octokit = await getOctokitForOrg(org); - // Fetch runners from GitHub API - const response = await octokit.request("GET /orgs/{org}/actions/runners", { - org, - per_page: 100, // GitHub API default/max - }); - - const runners: RunnerData[] = response.data.runners.map((runner: any) => ({ - id: runner.id, - name: runner.name, - os: runner.os, - status: runner.status, - busy: runner.busy, - labels: runner.labels.map((label: any) => ({ - id: label.id, - name: label.name, - type: label.type, - })), - })); + // Fetch all runners with proper pagination + const runners = await fetchAllRunners(octokit, org); return res.status(200).json({ - total_count: response.data.total_count, + total_count: runners.length, runners, }); } catch (error: any) { diff --git a/torchci/test/runners-api.test.ts b/torchci/test/runners-api.test.ts new file mode 100644 index 0000000000..62d70a720d --- /dev/null +++ b/torchci/test/runners-api.test.ts @@ -0,0 +1,86 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import handler, { RunnerData, RunnersApiResponse } from "../pages/api/runners/[org]"; + +// Mock the octokit modules +jest.mock("@octokit/auth-app", () => ({ + createAppAuth: jest.fn(), +})); + +jest.mock("octokit", () => ({ + App: jest.fn().mockImplementation(() => ({ + octokit: { + request: jest.fn().mockResolvedValue({ + data: { id: 123 }, + }), + }, + })), + Octokit: jest.fn().mockImplementation(() => ({ + request: jest.fn(), + })), +})); + +describe("/api/runners/[org]", () => { + let req: Partial; + let res: Partial; + + beforeEach(() => { + req = { + method: "GET", + query: { org: "test-org" }, + }; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + // Set up environment variables + process.env.APP_ID = "123"; + process.env.PRIVATE_KEY = Buffer.from("fake-key").toString("base64"); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test("should return error for non-GET requests", async () => { + req.method = "POST"; + + await handler(req as NextApiRequest, res as NextApiResponse); + + expect(res.status).toHaveBeenCalledWith(405); + expect(res.json).toHaveBeenCalledWith({ error: "Method not allowed" }); + }); + + test("should return error for missing org parameter", async () => { + req.query = {}; + + await handler(req as NextApiRequest, res as NextApiResponse); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: "Organization parameter is required" }); + }); + + test("should validate response structure", () => { + // Test that our interfaces are correct + const mockRunner: RunnerData = { + id: 1, + name: "test-runner", + os: "linux", + status: "online", + busy: false, + labels: [ + { id: 1, name: "self-hosted", type: "read-only" }, + { id: 2, name: "custom", type: "custom" }, + ], + }; + + const mockResponse: RunnersApiResponse = { + total_count: 1, + runners: [mockRunner], + }; + + expect(mockResponse.runners).toHaveLength(1); + expect(mockResponse.runners[0].status).toMatch(/^(online|offline)$/); + expect(mockResponse.total_count).toBe(mockResponse.runners.length); + }); +}); \ No newline at end of file From 86a775cc4c96629c297a41aba652ec5662648ace Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 Aug 2025 23:23:49 +0000 Subject: [PATCH 3/3] Clean up unused interface in runners API Co-authored-by: ZainRizvi <4468967+ZainRizvi@users.noreply.github.com> --- torchci/pages/api/runners/[org].ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/torchci/pages/api/runners/[org].ts b/torchci/pages/api/runners/[org].ts index 0fc9718155..6200bc0b2a 100644 --- a/torchci/pages/api/runners/[org].ts +++ b/torchci/pages/api/runners/[org].ts @@ -9,15 +9,6 @@ interface GitHubRunnerLabel { type?: "read-only" | "custom"; } -interface GitHubApiRunner { - id: number; - name: string; - os: string; - status: string; // GitHub API may return other statuses we don't know about - busy: boolean; - labels: GitHubRunnerLabel[]; -} - // Our application response types export interface RunnerData { id: number;