Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 85 additions & 5 deletions crates/agent-gui/test/backend/release-notes.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,13 @@ test("AI release notes script falls back when no API key is configured", () => {
}
});

test("AI release notes script calls Responses API and writes markdown", async () => {
test("AI release notes script calls Chat Completions API and writes markdown", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "liveagent-notes-"));
let requestBody = "";

const server = http.createServer((request, response) => {
assert.equal(request.method, "POST");
assert.equal(request.url, "/v1/responses");
assert.equal(request.url, "/v1/chat/completions");
assert.equal(request.headers.authorization, "Bearer test-key");

request.setEncoding("utf8");
Expand All @@ -131,8 +131,14 @@ test("AI release notes script calls Responses API and writes markdown", async ()
response.setHeader("connection", "close");
response.end(
JSON.stringify({
output_text:
"# LiveAgent v0.1.6\n\n> Release notes generated from repository context.\n\n## Overview\n\nLiveAgent now publishes cleaner release notes.",
choices: [
{
message: {
content:
"# LiveAgent v0.1.6\n\n> Release notes generated from repository context.\n\n## Overview\n\nLiveAgent now publishes cleaner release notes.",
},
},
],
}),
);
});
Expand Down Expand Up @@ -162,11 +168,85 @@ test("AI release notes script calls Responses API and writes markdown", async ()

const requestJson = JSON.parse(requestBody);
assert.equal(requestJson.model, "gpt-test");
assert.match(JSON.stringify(requestJson.input), /Release tag: v0\.1\.6/);
assert.equal(requestJson.reasoning_effort, undefined);
assert.match(JSON.stringify(requestJson.messages), /Release tag: v0\.1\.6/);
assert.match(readFileSync(outputPath, "utf8"), /^# LiveAgent v0\.1\.6/);
assert.match(readFileSync(outputPath, "utf8"), /cleaner release notes/);
} finally {
await close(server);
rmSync(dir, { force: true, recursive: true });
}
});

test("AI release notes script falls back to Responses API when chat output is empty", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "liveagent-notes-"));
const requests = [];

const server = http.createServer((request, response) => {
assert.equal(request.method, "POST");
assert.equal(request.headers.authorization, "Bearer test-key");

let requestBody = "";
request.setEncoding("utf8");
request.on("data", (chunk) => {
requestBody += chunk;
});
request.on("end", () => {
requests.push({
body: JSON.parse(requestBody),
url: request.url,
});

response.setHeader("content-type", "application/json");
response.setHeader("connection", "close");

if (request.url === "/v1/chat/completions") {
response.end(JSON.stringify({ choices: [{ message: { content: "" } }] }));
return;
}

assert.equal(request.url, "/v1/responses");
response.end(
JSON.stringify({
output_text:
"# LiveAgent v0.1.6\n\n> Release notes generated by the Responses fallback.\n\n## Overview\n\nLiveAgent still publishes AI release notes when chat output is empty.",
}),
);
});
});

try {
const address = await listen(server);
initTaggedRepo(dir, "v0.1.6");
const outputPath = path.join(dir, "notes.md");
const fallbackPath = path.join(dir, "fallback.md");
writeFileSync(fallbackPath, "## What's Changed\n\n- GitHub fallback notes.\n");

const result = await runNotesScriptAsync(["v0.1.6", outputPath, fallbackPath], {
AI_RELEASE_NOTES_API_KEY: "test-key",
AI_RELEASE_NOTES_BASE_URL: `http://${address.address}:${address.port}/v1`,
AI_RELEASE_NOTES_MODEL: "gpt-test",
AI_RELEASE_NOTES_TIMEOUT_MS: "2000",
PACKYCODE_API_KEY: "",
OPENAI_API_KEY: "",
}, { cwd: dir });

assert.equal(
result.status,
0,
`notes script failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
);

assert.deepEqual(requests.map((request) => request.url), [
"/v1/chat/completions",
"/v1/responses",
]);
assert.equal(requests[0].body.reasoning_effort, undefined);
assert.equal(requests[1].body.reasoning, undefined);
assert.match(readFileSync(outputPath, "utf8"), /^# LiveAgent v0\.1\.6/);
assert.match(readFileSync(outputPath, "utf8"), /Responses fallback/);
} finally {
await close(server);
rmSync(dir, { force: true, recursive: true });
}
});
137 changes: 111 additions & 26 deletions scripts/release/create-ai-release-notes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { parseReleaseVersion } from "./release-version.mjs";

const DEFAULT_BASE_URL = "https://codex-api.packycode.com/v1";
const DEFAULT_MODEL = "gpt-5.5";
const DEFAULT_REASONING_EFFORT = "";
const MAX_CONTEXT_CHARS = 22000;

const [releaseTagArg, outputPath, fallbackNotesPath] = process.argv.slice(2);
Expand Down Expand Up @@ -204,46 +205,93 @@ function responseText(payload) {
return "";
}

async function createResponse({ apiKey, baseUrl, model, prompt }) {
const endpoint = `${baseUrl.replace(/\/+$/, "")}/responses`;
const timeoutMs = Number.parseInt(process.env.AI_RELEASE_NOTES_TIMEOUT_MS ?? "60000", 10);
function normalizeReasoningEffort(value) {
const effort = value.trim().toLowerCase();
if (!effort || effort === "none" || effort === "off" || effort === "false" || effort === "xhigh") {
return "";
}
return effort;
}

async function fetchJsonWithTimeout(endpoint, { apiKey, body, timeoutMs }) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), Number.isFinite(timeoutMs) ? timeoutMs : 60000);
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
signal: controller.signal,
body: JSON.stringify({
input: [
{
role: "system",
content: [
{
type: "input_text",
text: "You are a precise release-notes editor. You never make claims that are not grounded in the provided repository context.",
},
],
},
{
role: "user",
content: [{ type: "input_text", text: prompt }],
},
],
max_output_tokens: 3000,
model,
}),
body: JSON.stringify(body),
}).finally(() => clearTimeout(timeout));

const text = await response.text();
if (!response.ok) {
throw new Error(`Responses API returned HTTP ${response.status}: ${text.slice(0, 500)}`);
throw new Error(`API returned HTTP ${response.status}: ${text.slice(0, 500)}`);
}
return JSON.parse(text);
}

async function createResponse({ apiKey, baseUrl, model, prompt, reasoningEffort, timeoutMs }) {
const endpoint = `${baseUrl.replace(/\/+$/, "")}/responses`;
const body = {
input: [
{
role: "system",
content: [
{
type: "input_text",
text: "You are a precise release-notes editor. You never make claims that are not grounded in the provided repository context.",
},
],
},
{
role: "user",
content: [{ type: "input_text", text: prompt }],
},
],
max_output_tokens: 3000,
model,
store: false,
};
if (reasoningEffort) {
body.reasoning = { effort: reasoningEffort };
}
return fetchJsonWithTimeout(endpoint, {
apiKey,
timeoutMs,
body,
});
}

async function createChatCompletion({ apiKey, baseUrl, model, prompt, reasoningEffort, timeoutMs }) {
const endpoint = `${baseUrl.replace(/\/+$/, "")}/chat/completions`;
const body = {
max_tokens: 3000,
messages: [
{
role: "system",
content:
"You are a precise release-notes editor. You never make claims that are not grounded in the provided repository context.",
},
{
role: "user",
content: prompt,
},
],
model,
};
if (reasoningEffort) {
body.reasoning_effort = reasoningEffort;
}
return fetchJsonWithTimeout(endpoint, {
apiKey,
timeoutMs,
body,
});
}

async function main() {
const apiKey =
process.env.AI_RELEASE_NOTES_API_KEY?.trim() ||
Expand All @@ -256,12 +304,49 @@ async function main() {

const baseUrl = process.env.AI_RELEASE_NOTES_BASE_URL?.trim() || DEFAULT_BASE_URL;
const model = process.env.AI_RELEASE_NOTES_MODEL?.trim() || DEFAULT_MODEL;
const reasoningEffort = normalizeReasoningEffort(
process.env.AI_RELEASE_NOTES_REASONING_EFFORT ?? DEFAULT_REASONING_EFFORT,
);
const parsedTimeoutMs = Number.parseInt(process.env.AI_RELEASE_NOTES_TIMEOUT_MS ?? "60000", 10);
const timeoutMs = Number.isFinite(parsedTimeoutMs) ? parsedTimeoutMs : 60000;

try {
const context = collectContext();
const prompt = buildPrompt(context);
const payload = await createResponse({ apiKey, baseUrl, model, prompt });
const markdown = normalizeMarkdown(responseText(payload));
let markdown = "";
let chatCompleted = false;
try {
const chatPayload = await createChatCompletion({
apiKey,
baseUrl,
model,
prompt,
reasoningEffort,
timeoutMs,
});
chatCompleted = true;
markdown = normalizeMarkdown(responseText(chatPayload));
} catch (error) {
console.warn(
`Chat completions unavailable: ${
error instanceof Error ? error.message : String(error)
}; trying Responses API fallback.`,
);
}
if (!markdown) {
if (chatCompleted) {
console.warn("Chat completions returned empty release notes; trying Responses API fallback.");
}
const responsesPayload = await createResponse({
apiKey,
baseUrl,
model,
prompt,
reasoningEffort,
timeoutMs,
});
markdown = normalizeMarkdown(responseText(responsesPayload));
}
if (!markdown) {
writeFallback("model returned empty release notes");
return;
Expand Down
Loading