diff --git a/scripts/label-open-issues.ts b/scripts/label-open-issues.ts index eca02ba678f..3802aacbf6a 100644 --- a/scripts/label-open-issues.ts +++ b/scripts/label-open-issues.ts @@ -2,7 +2,9 @@ import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; import { isRecord } from "../src/utils.js"; +import { parseStrictIntegerOption } from "./lib/dev-tooling-safety.ts"; function writeStdoutLine(message = ""): void { process.stdout.write(`${message}\n`); @@ -18,6 +20,7 @@ const GH_MAX_BUFFER = 50 * 1024 * 1024; const PAGE_SIZE = 50; const WORK_BATCH_SIZE = 500; const STATE_VERSION = 1; +const DEFAULT_OPENAI_TIMEOUT_MS = 60_000; const STATE_FILE_NAME = "issue-labeler-state.json"; const CONFIG_BASE_DIR = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"); const STATE_FILE_PATH = join(CONFIG_BASE_DIR, "openclaw", STATE_FILE_NAME); @@ -95,6 +98,13 @@ type ScriptOptions = { model: string; }; +type ClassifyOptions = { + apiKey: string; + model: string; + fetchImpl?: typeof fetch; + timeoutMs?: number; +}; + type OpenAIResponse = { output_text?: string; output?: OpenAIResponseOutput[]; @@ -235,6 +245,43 @@ function parseArgs(argv: string[]): ScriptOptions { return { limit, dryRun, model }; } +function isMainModule() { + const entry = process.argv[1]; + return entry ? import.meta.url === pathToFileURL(entry).href : false; +} + +function resolveOpenAITimeoutMs(raw = process.env.OPENCLAW_LABEL_OPEN_ISSUES_OPENAI_TIMEOUT_MS) { + return parseStrictIntegerOption({ + fallback: DEFAULT_OPENAI_TIMEOUT_MS, + label: "OPENCLAW_LABEL_OPEN_ISSUES_OPENAI_TIMEOUT_MS", + min: 1, + raw, + }); +} + +async function withOpenAITimeout( + label: string, + timeoutMs: number, + run: (signal: AbortSignal) => Promise, +): Promise { + const controller = new AbortController(); + let timeout: ReturnType | undefined; + const timeoutPromise = new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + const error = new Error(`${label} exceeded timeout of ${timeoutMs}ms`); + reject(error); + controller.abort(error); + }, timeoutMs); + }); + try { + return await Promise.race([run(controller.signal), timeoutPromise]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} + function logHeader(title: string) { writeStdoutLine(`\n${title}`); writeStdoutLine("=".repeat(title.length)); @@ -581,61 +628,70 @@ function normalizeClassification(raw: unknown, issueText: string): Classificatio async function classifyItem( item: LabelItem, kind: "issue" | "pull request", - options: { apiKey: string; model: string }, + options: ClassifyOptions, ): Promise { const itemText = buildItemPrompt(item, kind); - const response = await fetch("https://api.openai.com/v1/responses", { - method: "POST", - headers: { - Authorization: `Bearer ${options.apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - model: options.model, - max_output_tokens: 200, - text: { - format: { - type: "json_schema", - name: "issue_classification", - schema: { - type: "object", - additionalProperties: false, - properties: { - category: { type: "string", enum: ["bug", "enhancement"] }, - isSupport: { type: "boolean" }, - isSkillOnly: { type: "boolean" }, + const fetchImpl = options.fetchImpl ?? fetch; + const timeoutMs = options.timeoutMs ?? resolveOpenAITimeoutMs(); + const payload = await withOpenAITimeout( + "OpenAI issue label classification request", + timeoutMs, + async (signal) => { + const response = await fetchImpl("https://api.openai.com/v1/responses", { + method: "POST", + headers: { + Authorization: `Bearer ${options.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: options.model, + max_output_tokens: 200, + text: { + format: { + type: "json_schema", + name: "issue_classification", + schema: { + type: "object", + additionalProperties: false, + properties: { + category: { type: "string", enum: ["bug", "enhancement"] }, + isSupport: { type: "boolean" }, + isSkillOnly: { type: "boolean" }, + }, + required: ["category", "isSupport", "isSkillOnly"], + }, }, - required: ["category", "isSupport", "isSkillOnly"], }, - }, - }, - input: [ - { - role: "system", - content: - "You classify GitHub issues and pull requests for OpenClaw. Respond with JSON only, no extra text.", - }, - { - role: "user", - content: [ - "Determine classification:\n", - "- category: 'bug' if the item reports incorrect behavior, errors, crashes, or regressions; otherwise 'enhancement'.\n", - "- isSupport: true if the item is primarily a support request or troubleshooting/how-to question, not a change request.\n", - "- isSkillOnly: true if the item solely requests or delivers adding/updating skills (no other feature/bug work).\n\n", - itemText, - "\n\nReturn JSON with keys: category, isSupport, isSkillOnly.", - ].join(""), - }, - ], - }), - }); + input: [ + { + role: "system", + content: + "You classify GitHub issues and pull requests for OpenClaw. Respond with JSON only, no extra text.", + }, + { + role: "user", + content: [ + "Determine classification:\n", + "- category: 'bug' if the item reports incorrect behavior, errors, crashes, or regressions; otherwise 'enhancement'.\n", + "- isSupport: true if the item is primarily a support request or troubleshooting/how-to question, not a change request.\n", + "- isSkillOnly: true if the item solely requests or delivers adding/updating skills (no other feature/bug work).\n\n", + itemText, + "\n\nReturn JSON with keys: category, isSupport, isSkillOnly.", + ].join(""), + }, + ], + }), + signal, + }); - if (!response.ok) { - const text = await response.text(); - throw new Error(`OpenAI request failed (${response.status}): ${text}`); - } + if (!response.ok) { + const text = await response.text(); + throw new Error(`OpenAI request failed (${response.status}): ${text}`); + } - const payload = (await response.json()) as OpenAIResponse; + return (await response.json()) as OpenAIResponse; + }, + ); const rawText = extractResponseText(payload); let parsed: unknown = undefined; @@ -691,10 +747,12 @@ async function main() { if (!apiKey) { throw new Error("OPENAI_API_KEY is required to classify issues and pull requests."); } + const openAITimeoutMs = resolveOpenAITimeoutMs(); logHeader("OpenClaw Issue Label Audit"); logStep(`Mode: ${dryRun ? "dry-run" : "apply labels"}`); logStep(`Model: ${model}`); + logStep(`OpenAI timeout: ${openAITimeoutMs}ms`); logStep(`Issue limit: ${Number.isFinite(limit) ? limit : "unlimited"}`); logStep(`PR limit: ${Number.isFinite(limit) ? limit : "unlimited"}`); logStep(`Batch size: ${WORK_BATCH_SIZE}`); @@ -749,7 +807,11 @@ async function main() { const labels = new Set(issue.labels.map((label) => label.name)); logInfo(`Existing labels: ${Array.from(labels).toSorted().join(", ") || "none"}`); - const classification = await classifyItem(issue, "issue", { apiKey, model }); + const classification = await classifyItem(issue, "issue", { + apiKey, + model, + timeoutMs: openAITimeoutMs, + }); logInfo( `Classification: category=${classification.category}, support=${classification.isSupport ? "yes" : "no"}, skill-only=${classification.isSkillOnly ? "yes" : "no"}.`, ); @@ -831,7 +893,11 @@ async function main() { continue; } - const classification = await classifyItem(pullRequest, "pull request", { apiKey, model }); + const classification = await classifyItem(pullRequest, "pull request", { + apiKey, + model, + timeoutMs: openAITimeoutMs, + }); logInfo( `Classification: category=${classification.category}, support=${classification.isSupport ? "yes" : "no"}, skill-only=${classification.isSkillOnly ? "yes" : "no"}.`, ); @@ -884,4 +950,12 @@ async function main() { logInfo(`Added r: skill labels (PRs): ${prSkillCount}`); } -await main(); +export const testing = { + classifyItem, + normalizeClassification, + resolveOpenAITimeoutMs, +}; + +if (isMainModule()) { + await main(); +} diff --git a/test/scripts/label-open-issues.test.ts b/test/scripts/label-open-issues.test.ts new file mode 100644 index 00000000000..8816000c678 --- /dev/null +++ b/test/scripts/label-open-issues.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { testing } from "../../scripts/label-open-issues.ts"; + +const labelItem = { + number: 123, + title: "Crash when loading channel", + body: "The app crashes on startup.", + labels: [], +}; + +describe("label-open-issues helpers", () => { + it("classifies items from OpenAI structured response text", async () => { + const response = { + ok: true, + status: 200, + json: async () => ({ + output_text: JSON.stringify({ + category: "bug", + isSupport: true, + isSkillOnly: false, + }), + }), + } as Response; + + await expect( + testing.classifyItem(labelItem, "issue", { + apiKey: "test-key", + model: "test-model", + timeoutMs: 50, + fetchImpl: (() => Promise.resolve(response)) as typeof fetch, + }), + ).resolves.toEqual({ + category: "bug", + isSupport: true, + isSkillOnly: false, + }); + }); + + it("aborts stalled OpenAI classification fetches at the request timeout", async () => { + let signal: AbortSignal | undefined; + const request = testing.classifyItem(labelItem, "issue", { + apiKey: "test-key", + model: "test-model", + timeoutMs: 5, + fetchImpl: ((_url, init) => { + signal = init?.signal ?? undefined; + return new Promise(() => {}); + }) as typeof fetch, + }); + + await expect(request).rejects.toThrow( + /OpenAI issue label classification request exceeded timeout/u, + ); + expect(signal?.aborted).toBe(true); + }); + + it("times out stalled OpenAI classification body reads", async () => { + const response = { + ok: true, + status: 200, + json: () => new Promise(() => {}), + } as Response; + const request = testing.classifyItem(labelItem, "issue", { + apiKey: "test-key", + model: "test-model", + timeoutMs: 5, + fetchImpl: (() => Promise.resolve(response)) as typeof fetch, + }); + + await expect(request).rejects.toThrow( + /OpenAI issue label classification request exceeded timeout/u, + ); + }); + + it("rejects invalid OpenAI classification timeout values", () => { + expect(testing.resolveOpenAITimeoutMs("250")).toBe(250); + expect(() => testing.resolveOpenAITimeoutMs("slow")).toThrow( + /OPENCLAW_LABEL_OPEN_ISSUES_OPENAI_TIMEOUT_MS must be an integer/u, + ); + }); +});