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
29 changes: 29 additions & 0 deletions .github/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
changelog:
exclude:
labels:
- ignore-for-release
authors:
- dependabot
- github-actions
categories:
- title: Features
labels:
- feature
- enhancement
- title: Fixes
labels:
- fix
- bug
- title: Documentation
labels:
- documentation
- docs
- title: Maintenance
labels:
- chore
- ci
- refactor
- test
- title: Other Changes
labels:
- "*"
16 changes: 14 additions & 2 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,8 @@ jobs:
LIVEAGENT_RELEASE_TAG: ${{ needs.release-metadata.outputs.release_tag }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

- uses: actions/download-artifact@v8
with:
Expand All @@ -345,16 +347,26 @@ jobs:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ needs.release-metadata.outputs.release_tag }}
PACKYCODE_API_KEY: ${{ secrets.PACKYCODE_API_KEY }}
AI_RELEASE_NOTES_API_KEY: ${{ secrets.AI_RELEASE_NOTES_API_KEY }}
AI_RELEASE_NOTES_BASE_URL: ${{ vars.AI_RELEASE_NOTES_BASE_URL }}
AI_RELEASE_NOTES_MODEL: ${{ vars.AI_RELEASE_NOTES_MODEL }}
run: |
set -euo pipefail
is_prerelease="$LIVEAGENT_IS_PRERELEASE"

github_notes_path="$RUNNER_TEMP/liveagent-github-release-notes.md"
notes_path="$RUNNER_TEMP/liveagent-release-notes.md"
if ! gh api "repos/$GH_REPO/releases/generate-notes" \
-f tag_name="$RELEASE_TAG" \
--jq ".body" > "$notes_path"; then
printf 'LiveAgent %s\n' "$RELEASE_TAG" > "$notes_path"
--jq ".body" > "$github_notes_path"; then
printf 'LiveAgent %s\n' "$RELEASE_TAG" > "$github_notes_path"
fi
if [ ! -s "$github_notes_path" ]; then
printf 'LiveAgent %s\n' "$RELEASE_TAG" > "$github_notes_path"
fi

node scripts/release/create-ai-release-notes.mjs "$RELEASE_TAG" "$notes_path" "$github_notes_path"
if [ ! -s "$notes_path" ]; then
printf 'LiveAgent %s\n' "$RELEASE_TAG" > "$notes_path"
fi
Expand Down
172 changes: 172 additions & 0 deletions crates/agent-gui/test/backend/release-notes.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import http from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";

const guiRoot = path.resolve(fileURLToPath(new URL("../..", import.meta.url)));
const repoRoot = path.resolve(guiRoot, "../..");
const notesScript = path.join(repoRoot, "scripts/release/create-ai-release-notes.mjs");

function runNotesScript(args, env = {}, options = {}) {
return spawnSync(process.execPath, [notesScript, ...args], {
cwd: options.cwd ?? repoRoot,
encoding: "utf8",
env: {
...process.env,
...env,
},
});
}

function runNotesScriptAsync(args, env = {}, options = {}) {
return new Promise((resolve) => {
const child = spawn(process.execPath, [notesScript, ...args], {
cwd: options.cwd ?? repoRoot,
env: {
...process.env,
...env,
},
stdio: ["ignore", "pipe", "pipe"],
});

let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("close", (status) => {
resolve({ status, stdout, stderr });
});
});
}

function runGit(args, cwd) {
const result = spawnSync("git", args, {
cwd,
encoding: "utf8",
});
assert.equal(
result.status,
0,
`git ${args.join(" ")} failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
);
}

function initTaggedRepo(dir, tag) {
runGit(["init"], dir);
runGit(["config", "user.name", "Release Test"], dir);
runGit(["config", "user.email", "release-test@example.com"], dir);
writeFileSync(path.join(dir, "README.md"), "# Release test\n");
runGit(["add", "README.md"], dir);
runGit(["commit", "-m", "Initial release"], dir);
runGit(["tag", "v0.1.5"], dir);
writeFileSync(path.join(dir, "README.md"), "# Release test\n\nAI notes.\n");
runGit(["add", "README.md"], dir);
runGit(["commit", "-m", "Improve release notes"], dir);
runGit(["tag", tag], dir);
}

function listen(server) {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve(server.address()));
});
}

function close(server) {
return new Promise((resolve, reject) => {
server.closeAllConnections?.();
server.close((error) => (error ? reject(error) : resolve()));
});
}

test("AI release notes script falls back when no API key is configured", () => {
const dir = mkdtempSync(path.join(tmpdir(), "liveagent-notes-"));
try {
const outputPath = path.join(dir, "notes.md");
const fallbackPath = path.join(dir, "fallback.md");
writeFileSync(fallbackPath, "## What's Changed\n\n- Fallback notes.\n");

const result = runNotesScript(["v0.1.6", outputPath, fallbackPath], {
AI_RELEASE_NOTES_API_KEY: "",
PACKYCODE_API_KEY: "",
OPENAI_API_KEY: "",
});

assert.equal(
result.status,
0,
`notes script failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
);
assert.equal(readFileSync(outputPath, "utf8"), "## What's Changed\n\n- Fallback notes.\n");
} finally {
rmSync(dir, { force: true, recursive: true });
}
});

test("AI release notes script calls Responses 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.headers.authorization, "Bearer test-key");

request.setEncoding("utf8");
request.on("data", (chunk) => {
requestBody += chunk;
});
request.on("end", () => {
response.setHeader("content-type", "application/json");
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.",
}),
);
});
});

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

const requestJson = JSON.parse(requestBody);
assert.equal(requestJson.model, "gpt-test");
assert.match(JSON.stringify(requestJson.input), /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 });
}
});
Loading
Loading