skillhub/.github/scripts/issue-llm-provider.ts
XiaoSeS 5bf8750658
feat(ci): add AI-powered release notes generation (#322)
* feat(ci): add AI-powered release notes generation

- Add GitHub Models integration for automated release notes
- Support bilingual (EN) release notes with highlights extraction
- Fallback to conventional commit grouping when LLM unavailable
- Trigger on tag push or manual workflow dispatch
- Zero configuration: uses GitHub Models (gpt-4o-mini) by default

* chore: pin action versions and update gitignore

- Pin checkout and setup-deno to commit hashes matching project convention
- Add .playwright-mcp/ and .mcp.json to gitignore
2026-04-17 15:19:48 +08:00

286 lines
7.1 KiB
TypeScript

import { IssueLlmConfig } from "./issue-llm-types.ts";
interface OpenAiCompatibleResponse {
choices?: Array<{
message?: {
content?: string | Array<{ type?: string; text?: string }>;
};
}>;
}
export async function requestOpenAiCompatibleJson(
config: IssueLlmConfig,
systemPrompt: string,
userPrompt: string,
) {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= config.maxAttempts; attempt += 1) {
try {
return await requestOnce(config, systemPrompt, userPrompt);
} catch (error) {
const normalized = normalizeRequestError(
error,
attempt,
config.maxAttempts,
);
lastError = normalized;
if (!shouldRetry(error) || attempt >= config.maxAttempts) {
throw normalized;
}
await sleep(resolveRetryDelay(error, config.retryBackoffMs, attempt));
}
}
throw lastError ?? new Error("LLM request failed for an unknown reason.");
}
export async function requestOpenAiCompatibleMarkdown(
config: IssueLlmConfig,
systemPrompt: string,
userPrompt: string,
): Promise<string> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= config.maxAttempts; attempt += 1) {
try {
return await requestOnceMarkdown(config, systemPrompt, userPrompt);
} catch (error) {
const normalized = normalizeRequestError(
error,
attempt,
config.maxAttempts,
);
lastError = normalized;
if (!shouldRetry(error) || attempt >= config.maxAttempts) {
throw normalized;
}
await sleep(resolveRetryDelay(error, config.retryBackoffMs, attempt));
}
}
throw lastError ?? new Error("LLM request failed for an unknown reason.");
}
async function requestOnce(
config: IssueLlmConfig,
systemPrompt: string,
userPrompt: string,
) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
try {
const response = await fetch(`${config.baseUrl}/chat/completions`, {
method: "POST",
signal: controller.signal,
headers: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: config.model,
temperature: config.temperature,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
}),
});
if (!response.ok) {
const message =
`LLM request failed with status ${response.status}: ${await response
.text()}`;
throw new RetryableHttpError(
message,
response.status,
response.headers.get("retry-after"),
);
}
const payload = (await response.json()) as OpenAiCompatibleResponse;
const content = payload.choices?.[0]?.message?.content;
const text = normalizeMessageContent(content);
if (!text) {
throw new Error("LLM response did not include message content.");
}
return extractJsonObject(text);
} finally {
clearTimeout(timeout);
}
}
async function requestOnceMarkdown(
config: IssueLlmConfig,
systemPrompt: string,
userPrompt: string,
): Promise<string> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
try {
const response = await fetch(`${config.baseUrl}/chat/completions`, {
method: "POST",
signal: controller.signal,
headers: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: config.model,
temperature: config.temperature,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
}),
});
if (!response.ok) {
const message =
`LLM request failed with status ${response.status}: ${await response
.text()}`;
throw new RetryableHttpError(
message,
response.status,
response.headers.get("retry-after"),
);
}
const payload = (await response.json()) as OpenAiCompatibleResponse;
const content = payload.choices?.[0]?.message?.content;
const text = normalizeMessageContent(content);
if (!text) {
throw new Error("LLM response did not include message content.");
}
return text;
} finally {
clearTimeout(timeout);
}
}
class RetryableHttpError extends Error {
status: number;
retryAfterSeconds: number | null;
constructor(
message: string,
status: number,
retryAfterHeader: string | null,
) {
super(message);
this.name = "RetryableHttpError";
this.status = status;
this.retryAfterSeconds = parseRetryAfterSeconds(retryAfterHeader);
}
}
function shouldRetry(error: unknown) {
if (error instanceof RetryableHttpError) {
return error.status === 408 || error.status === 429 || error.status >= 500;
}
if (error instanceof DOMException && error.name === "AbortError") {
return true;
}
if (error instanceof Error) {
const message = error.message.toLowerCase();
return message.includes("network") || message.includes("connection");
}
return false;
}
function normalizeRequestError(
error: unknown,
attempt: number,
maxAttempts: number,
) {
if (error instanceof DOMException && error.name === "AbortError") {
return new Error(
`LLM request timed out on attempt ${attempt}/${maxAttempts}.`,
);
}
if (error instanceof RetryableHttpError) {
return new Error(
`LLM request failed on attempt ${attempt}/${maxAttempts}: ${error.message}`,
);
}
if (error instanceof Error) {
return new Error(
`LLM request failed on attempt ${attempt}/${maxAttempts}: ${error.message}`,
);
}
return new Error(
`LLM request failed on attempt ${attempt}/${maxAttempts}: ${String(error)}`,
);
}
function resolveRetryDelay(
error: unknown,
retryBackoffMs: number,
attempt: number,
) {
if (error instanceof RetryableHttpError && error.retryAfterSeconds !== null) {
return error.retryAfterSeconds * 1000;
}
return retryBackoffMs * attempt;
}
function parseRetryAfterSeconds(value: string | null) {
if (!value) {
return null;
}
const seconds = Number.parseInt(value, 10);
return Number.isNaN(seconds) ? null : Math.max(0, seconds);
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function normalizeMessageContent(
content: string | Array<{ type?: string; text?: string }> | undefined,
) {
if (!content) {
return "";
}
if (typeof content === "string") {
return content;
}
return content
.map((item) => item.text ?? "")
.join("\n")
.trim();
}
function extractJsonObject(text: string) {
const trimmed = text.trim();
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
const candidate = fenced?.[1]?.trim() ?? trimmed;
const start = candidate.indexOf("{");
const end = candidate.lastIndexOf("}");
if (start < 0 || end < 0 || end <= start) {
throw new Error(`LLM response did not contain a JSON object: ${trimmed}`);
}
return candidate.slice(start, end + 1);
}