mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 01:22:57 +00:00
fix(dev): bound issue labeler OpenAI waits
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
81
test/scripts/label-open-issues.test.ts
Normal file
81
test/scripts/label-open-issues.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user