diff --git a/scripts/sync-labels.ts b/scripts/sync-labels.ts index 736e67ac0ff2..10e294a77c9c 100644 --- a/scripts/sync-labels.ts +++ b/scripts/sync-labels.ts @@ -5,6 +5,8 @@ import { resolve } from "node:path"; import { parse } from "yaml"; import { resolveGitHubRepoFromOrigin } from "./lib/github-repo.ts"; +const SYNC_LABELS_TIMEOUT_MS = 120_000; + type RepoLabel = { name: string; color?: string; @@ -77,7 +79,11 @@ for (const label of missing) { if (metadata.description) { args.push("-f", `description=${metadata.description}`); } - execFileSync("gh", args, { stdio: "inherit" }); + execFileSync("gh", args, { + stdio: "inherit", + timeout: SYNC_LABELS_TIMEOUT_MS, + killSignal: "SIGKILL", + }); console.log(`Created label: ${label}`); } @@ -93,6 +99,8 @@ function resolveLabelMetadata(label: string): { color: string; description?: str function fetchExistingLabels(repoLocal: string): Map { const raw = execFileSync("gh", ["api", `repos/${repoLocal}/labels?per_page=100`, "--paginate"], { encoding: "utf8", + timeout: SYNC_LABELS_TIMEOUT_MS, + killSignal: "SIGKILL", }); const labels = JSON.parse(raw) as RepoLabel[]; return new Map(labels.map((label) => [label.name, label])); diff --git a/test/scripts/sync-labels.test.ts b/test/scripts/sync-labels.test.ts new file mode 100644 index 000000000000..56568df99063 --- /dev/null +++ b/test/scripts/sync-labels.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { execFileSyncMock } = vi.hoisted(() => ({ + execFileSyncMock: vi.fn(), +})); + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + execFileSync: execFileSyncMock, + }; +}); + +describe("sync-labels", () => { + beforeEach(() => { + vi.resetModules(); + execFileSyncMock.mockReset(); + execFileSyncMock.mockImplementation((command: string, args?: readonly string[]) => { + if (command === "git") { + return "https://github.com/openclaw/openclaw.git\n"; + } + if (command === "gh" && args?.some((arg) => arg.includes("/labels?"))) { + return "[]"; + } + return ""; + }); + vi.spyOn(console, "log").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("bounds every GitHub CLI operation", async () => { + await import("../../scripts/sync-labels.ts"); + + const ghCalls = execFileSyncMock.mock.calls.filter(([command]) => command === "gh"); + expect(ghCalls.length).toBeGreaterThan(1); + for (const [, , options] of ghCalls) { + expect(options).toMatchObject({ + timeout: 120_000, + killSignal: "SIGKILL", + }); + } + }); +});