mirror of
https://github.com/unraid/api.git
synced 2026-01-05 08:00:33 -06:00
chore(deps): update conventional commit (#1693)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Chores** * Updated changelog tooling dependencies and CI to fetch full Git history; added conventional-changelog-conventionalcommits. * **Tests** * Added comprehensive tests for changelog output, header/tag handling, fallback behavior, and compatibility with the updated changelog API. * **Refactor** * Reworked changelog generation to use the newer changelog API, improve tag-aware headers, and support deriving PR-style changelogs with graceful fallbacks. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
181
plugin/builder/utils/changelog.test.ts
Normal file
181
plugin/builder/utils/changelog.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { execSync } from "child_process";
|
||||
import { getStagingChangelogFromGit } from "./changelog.js";
|
||||
|
||||
describe.sequential("getStagingChangelogFromGit", () => {
|
||||
let currentCommitMessage: string | null = null;
|
||||
|
||||
beforeAll(() => {
|
||||
// Get the current commit message to validate it appears in changelog
|
||||
try {
|
||||
currentCommitMessage = execSync('git log -1 --pretty=%s', { encoding: 'utf8' }).trim();
|
||||
} catch (e) {
|
||||
// Ignore if we can't get commit
|
||||
}
|
||||
});
|
||||
|
||||
it("should generate changelog header with version", { timeout: 20000 }, async () => {
|
||||
const result = await getStagingChangelogFromGit({
|
||||
pluginVersion: "99.99.99",
|
||||
tag: undefined as any,
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result).toBe("string");
|
||||
// Should contain version header
|
||||
expect(result).toContain("99.99.99");
|
||||
// Should have markdown header formatting
|
||||
expect(result).toMatch(/##\s+/);
|
||||
});
|
||||
|
||||
it("should generate changelog with tag parameter", { timeout: 20000 }, async () => {
|
||||
// When tag is provided, it should generate changelog with tag in header
|
||||
const result = await getStagingChangelogFromGit({
|
||||
pluginVersion: "99.99.99",
|
||||
tag: "test-tag-99",
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result).toContain("test-tag-99");
|
||||
|
||||
// Should have a version header
|
||||
expect(result).toMatch(/##\s+/);
|
||||
|
||||
// IMPORTANT: Verify that actual commits are included in the changelog
|
||||
// This ensures the gitRawCommitsOpts is working correctly
|
||||
// The changelog should include commits if there are any between origin/main and HEAD
|
||||
// We check for common changelog patterns that indicate actual content
|
||||
if (result.length > 100) {
|
||||
// If we have a substantial changelog, it should contain commit information
|
||||
expect(
|
||||
result.includes("### Features") ||
|
||||
result.includes("### Bug Fixes") ||
|
||||
result.includes("### ") ||
|
||||
result.includes("* ") // Commit entries typically start with asterisk
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle error gracefully and return tag", { timeout: 20000 }, async () => {
|
||||
// The function catches errors and returns the tag
|
||||
// An empty version might not cause an error, so let's just verify
|
||||
// the function completes without throwing
|
||||
const result = await getStagingChangelogFromGit({
|
||||
pluginVersion: "test-version",
|
||||
tag: "fallback-tag",
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result).toBe("string");
|
||||
// Should either return a changelog or the fallback tag
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should use conventional-changelog v7 API correctly", { timeout: 20000 }, async () => {
|
||||
// This test validates that the v7 API is being called correctly
|
||||
// by checking that the function executes without throwing
|
||||
let error: any = null;
|
||||
|
||||
try {
|
||||
await getStagingChangelogFromGit({
|
||||
pluginVersion: "99.99.99",
|
||||
tag: undefined as any,
|
||||
});
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
// The v7 API should work without errors
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
it("should validate changelog structure", { timeout: 20000 }, async () => {
|
||||
// Create a changelog with high version number to avoid conflicts
|
||||
const result = await getStagingChangelogFromGit({
|
||||
pluginVersion: "999.0.0",
|
||||
tag: "v999-test",
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result).toBe("string");
|
||||
|
||||
// Verify basic markdown structure
|
||||
if (result.length > 50) {
|
||||
// Should have tag in header when tag is provided
|
||||
expect(result).toMatch(/##\s+\[?v999-test/);
|
||||
// Should be valid markdown with proper line breaks
|
||||
expect(result).toMatch(/\n/);
|
||||
}
|
||||
});
|
||||
|
||||
it("should include actual commits when using gitRawCommitsOpts with tag", { timeout: 20000 }, async () => {
|
||||
// This test ensures that gitRawCommitsOpts is working correctly
|
||||
// and actually fetching commits between origin/main and HEAD
|
||||
const result = await getStagingChangelogFromGit({
|
||||
pluginVersion: "99.99.99",
|
||||
tag: "CI-TEST",
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result).toBe("string");
|
||||
|
||||
// The header should contain the tag
|
||||
expect(result).toContain("CI-TEST");
|
||||
|
||||
// Critical: The changelog should NOT be just the tag (error fallback)
|
||||
expect(result).not.toBe("CI-TEST");
|
||||
|
||||
// The changelog should have a proper markdown header
|
||||
expect(result).toMatch(/^##\s+/);
|
||||
|
||||
// Check if we're in a git repo with commits ahead of the base branch
|
||||
let commitCount = 0;
|
||||
try {
|
||||
// Try to detect the base branch (same logic as in changelog.ts)
|
||||
let baseBranch = "origin/main";
|
||||
try {
|
||||
const originHead = execSync("git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null", {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"]
|
||||
}).trim();
|
||||
if (originHead) {
|
||||
baseBranch = originHead.replace("refs/remotes/", "");
|
||||
}
|
||||
} catch {
|
||||
// Try common branches
|
||||
const branches = ["origin/main", "origin/master", "origin/develop"];
|
||||
for (const branch of branches) {
|
||||
try {
|
||||
execSync(`git rev-parse --verify ${branch} 2>/dev/null`, { stdio: "ignore" });
|
||||
baseBranch = branch;
|
||||
break;
|
||||
} catch {
|
||||
// Continue to next branch
|
||||
}
|
||||
}
|
||||
}
|
||||
commitCount = parseInt(execSync(`git rev-list --count ${baseBranch}..HEAD`, { encoding: "utf8" }).trim());
|
||||
} catch {
|
||||
// If we can't determine, we'll check for minimal content
|
||||
}
|
||||
|
||||
// If there are commits on this branch, the changelog MUST include them
|
||||
if (commitCount > 0) {
|
||||
// The changelog must be more than just a header
|
||||
// A minimal header is "## CI-TEST (2025-09-12)\n\n" which is ~30 chars
|
||||
expect(result.length).toBeGreaterThan(50);
|
||||
|
||||
// Should have actual commit content
|
||||
const hasCommitContent =
|
||||
result.includes("### ") || // Section headers like ### Features
|
||||
result.includes("* ") || // Commit bullet points
|
||||
result.includes("- "); // Alternative bullet style
|
||||
|
||||
if (!hasCommitContent) {
|
||||
throw new Error(`Expected changelog to contain commits but got only: ${result.substring(0, 100)}...`);
|
||||
}
|
||||
expect(hasCommitContent).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,40 +1,167 @@
|
||||
import conventionalChangelog from "conventional-changelog";
|
||||
import { ConventionalChangelog } from "conventional-changelog";
|
||||
import { execSync } from "child_process";
|
||||
|
||||
import { PluginEnv } from "../cli/setup-plugin-environment";
|
||||
import { PluginEnv } from "../cli/setup-plugin-environment.js";
|
||||
|
||||
/**
|
||||
* Detects the base branch and finds the merge base for PR changelog generation
|
||||
* Returns the merge-base commit to only show commits from the current PR
|
||||
*/
|
||||
function getMergeBase(): string | null {
|
||||
try {
|
||||
// First, find the base branch
|
||||
let baseBranch: string | null = null;
|
||||
|
||||
// Try to get the default branch from origin/HEAD
|
||||
try {
|
||||
const originHead = execSync("git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null", {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"]
|
||||
}).trim();
|
||||
if (originHead) {
|
||||
baseBranch = originHead.replace("refs/remotes/", "");
|
||||
}
|
||||
} catch {
|
||||
// origin/HEAD not set, continue to next strategy
|
||||
}
|
||||
|
||||
// Try common default branch names if origin/HEAD didn't work
|
||||
if (!baseBranch) {
|
||||
const commonBranches = ["origin/main", "origin/master", "origin/develop"];
|
||||
for (const branch of commonBranches) {
|
||||
try {
|
||||
execSync(`git rev-parse --verify ${branch} 2>/dev/null`, { stdio: "ignore" });
|
||||
baseBranch = branch;
|
||||
break;
|
||||
} catch {
|
||||
// Branch doesn't exist, try next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!baseBranch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the merge-base between the current branch and the base branch
|
||||
// This gives us the commit where the PR branch diverged from main
|
||||
try {
|
||||
const mergeBase = execSync(`git merge-base ${baseBranch} HEAD`, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"]
|
||||
}).trim();
|
||||
|
||||
return mergeBase;
|
||||
} catch {
|
||||
// If merge-base fails, fall back to the base branch itself
|
||||
return baseBranch;
|
||||
}
|
||||
} catch {
|
||||
// Git command failed entirely, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a simple changelog for PR builds
|
||||
*/
|
||||
function generatePRChangelog(tag: string, mergeBase: string): string | null {
|
||||
try {
|
||||
// Get commits from this PR only with conventional commit parsing
|
||||
const commits = execSync(
|
||||
`git log ${mergeBase}..HEAD --pretty=format:"%s|%h" --reverse`,
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }
|
||||
).trim();
|
||||
|
||||
if (!commits) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = commits.split('\n').filter(Boolean);
|
||||
const features: string[] = [];
|
||||
const fixes: string[] = [];
|
||||
const other: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const [message, hash] = line.split('|');
|
||||
const formatted = `* ${message} (${hash})`;
|
||||
|
||||
if (message.startsWith('feat')) {
|
||||
features.push(formatted);
|
||||
} else if (message.startsWith('fix')) {
|
||||
fixes.push(formatted);
|
||||
} else {
|
||||
other.push(formatted);
|
||||
}
|
||||
}
|
||||
|
||||
let changelog = `## [${tag}](https://github.com/unraid/api/${tag})\n\n`;
|
||||
|
||||
if (features.length > 0) {
|
||||
changelog += `### Features\n\n${features.join('\n')}\n\n`;
|
||||
}
|
||||
if (fixes.length > 0) {
|
||||
changelog += `### Bug Fixes\n\n${fixes.join('\n')}\n\n`;
|
||||
}
|
||||
if (other.length > 0) {
|
||||
changelog += `### Other Changes\n\n${other.join('\n')}\n\n`;
|
||||
}
|
||||
|
||||
return changelog;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const getStagingChangelogFromGit = async ({
|
||||
pluginVersion,
|
||||
tag,
|
||||
}: Pick<PluginEnv, "pluginVersion" | "tag">): Promise<string> => {
|
||||
try {
|
||||
const changelogStream = conventionalChangelog(
|
||||
{
|
||||
preset: "conventionalcommits",
|
||||
},
|
||||
{
|
||||
version: pluginVersion,
|
||||
},
|
||||
tag
|
||||
? {
|
||||
from: "origin/main",
|
||||
to: "HEAD",
|
||||
}
|
||||
: {},
|
||||
undefined,
|
||||
tag
|
||||
? {
|
||||
headerPartial: `## [${tag}](https://github.com/unraid/api/${tag})\n\n`,
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
// For PR builds with a tag, try to generate a simple PR-specific changelog
|
||||
if (tag) {
|
||||
const mergeBase = getMergeBase();
|
||||
if (mergeBase) {
|
||||
const prChangelog = generatePRChangelog(tag, mergeBase);
|
||||
if (prChangelog) {
|
||||
return prChangelog;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to conventional-changelog for non-PR builds or if PR detection fails
|
||||
const options: any = {
|
||||
releaseCount: 1,
|
||||
};
|
||||
|
||||
if (tag) {
|
||||
options.writerOpts = {
|
||||
headerPartial: `## [${tag}](https://github.com/unraid/api/${tag})\n\n`,
|
||||
};
|
||||
}
|
||||
|
||||
const generator = new ConventionalChangelog()
|
||||
.loadPreset("conventionalcommits")
|
||||
.context({
|
||||
version: tag || pluginVersion,
|
||||
...(tag && {
|
||||
linkCompare: false,
|
||||
}),
|
||||
})
|
||||
.options(options);
|
||||
|
||||
let changelog = "";
|
||||
for await (const chunk of changelogStream) {
|
||||
for await (const chunk of generator.write()) {
|
||||
changelog += chunk;
|
||||
}
|
||||
// Encode HTML entities using the 'he' library
|
||||
return changelog ?? "";
|
||||
|
||||
return changelog || "";
|
||||
} catch (err) {
|
||||
console.log('Non-fatal error: Failed to get changelog from git:', err);
|
||||
return tag;
|
||||
// Return a properly formatted fallback with markdown header
|
||||
if (tag) {
|
||||
return `## [${tag}](https://github.com/unraid/api/${tag})\n\n`;
|
||||
}
|
||||
return `## ${pluginVersion}\n\n`;
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -4,7 +4,8 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"commander": "14.0.0",
|
||||
"conventional-changelog": "6.0.0",
|
||||
"conventional-changelog": "7.1.1",
|
||||
"conventional-changelog-conventionalcommits": "^9.1.0",
|
||||
"date-fns": "4.1.0",
|
||||
"glob": "11.0.3",
|
||||
"html-sloppy-escaper": "0.1.0",
|
||||
|
||||
Reference in New Issue
Block a user