fix(dev): bound issue labeler OpenAI waits

This commit is contained in:
Vincent Koc
2026-05-27 14:56:10 +02:00
parent ef77428c95
commit b12bd3fc98
2 changed files with 207 additions and 52 deletions

View File

@@ -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<T>(
label: string,
timeoutMs: number,
run: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const controller = new AbortController();
let timeout: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<T>((_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<Classification> {
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();
}

View File

@@ -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,
);
});
});