initial commit
All checks were successful
CI / Check Dist (pull_request) Successful in 11s
CI / Test (pull_request) Successful in 56s
CI / Dry-Run (pull_request) Successful in 11s

This commit is contained in:
2025-08-17 21:28:44 +02:00
parent 4eefc46a2f
commit 17c1cd8a41
16 changed files with 6804 additions and 1 deletions

3
src/index.ts Normal file
View File

@@ -0,0 +1,3 @@
import { run } from "./main";
run();

404
src/main.spec.ts Normal file
View File

@@ -0,0 +1,404 @@
import {
IncrementType,
Tag,
GitService,
CoreService,
ConventionalCommitAnalyzer,
TagService,
TagIncrementer,
LocalGitService,
LocalCoreService,
} from "./main";
// Mock child_process.execSync
jest.mock("child_process", () => ({
execSync: jest.fn(),
}));
import { execSync } from "child_process";
const mockExecSync = execSync as jest.MockedFunction<typeof execSync>;
// Mock interfaces for testing
class MockGitService implements GitService {
constructor(
private mockTags: { name: string }[] = [],
private mockCommit: { commit: { message: string } } = { commit: { message: "test commit" } }
) {}
async listTags(perPage: number): Promise<{ name: string }[]> {
return this.mockTags.slice(0, perPage);
}
async getCommit(ref: string): Promise<{ commit: { message: string } }> {
// Use ref parameter to avoid unused parameter warning
if (!ref) {
throw new Error("Ref is required");
}
return this.mockCommit;
}
async testConnection(): Promise<void> {
// Mock successful connection
}
setMockTags(tags: { name: string }[]) {
this.mockTags = tags;
}
setMockCommit(commit: { commit: { message: string } }) {
this.mockCommit = commit;
}
}
class MockCoreService implements CoreService {
private inputs: Record<string, string> = {};
private outputs: Record<string, string> = {};
private logs: string[] = [];
private warnings: string[] = [];
private failures: string[] = [];
setInput(name: string, value: string) {
this.inputs[name] = value;
}
getInput(name: string): string {
return this.inputs[name] || "";
}
info(message: string): void {
this.logs.push(`INFO: ${message}`);
}
warning(message: string): void {
this.warnings.push(`WARNING: ${message}`);
}
setOutput(name: string, value: string): void {
this.outputs[name] = value;
}
setFailed(message: string): void {
this.failures.push(message);
}
getOutputs(): Record<string, string> {
return { ...this.outputs };
}
getLogs(): string[] {
return [...this.logs];
}
getWarnings(): string[] {
return [...this.warnings];
}
getFailures(): string[] {
return [...this.failures];
}
clear() {
this.outputs = {};
this.logs = [];
this.warnings = [];
this.failures = [];
}
}
describe("TagService", () => {
let tagService: TagService;
beforeEach(() => {
tagService = new TagService();
});
describe("bumpTag", () => {
const baseTag: Tag = { major: 1, minor: 2, patch: 3 };
it("should increment major version", () => {
const result = tagService.bumpTag(baseTag, IncrementType.MAJOR);
expect(result).toEqual({ major: 2, minor: 0, patch: 0 });
});
it("should increment minor version", () => {
const result = tagService.bumpTag(baseTag, IncrementType.MINOR);
expect(result).toEqual({ major: 1, minor: 3, patch: 0 });
});
it("should increment patch version", () => {
const result = tagService.bumpTag(baseTag, IncrementType.PATCH);
expect(result).toEqual({ major: 1, minor: 2, patch: 4 });
});
it("should handle zero versions", () => {
const zeroTag: Tag = { major: 0, minor: 0, patch: 0 };
const result = tagService.bumpTag(zeroTag, IncrementType.MINOR);
expect(result).toEqual({ major: 0, minor: 1, patch: 0 });
});
});
describe("renderTag", () => {
const tag: Tag = { major: 1, minor: 2, patch: 3 };
it("should render tag without prefix", () => {
const result = tagService.renderTag(tag);
expect(result).toBe("1.2.3");
});
it("should render tag with prefix", () => {
const result = tagService.renderTag(tag, "v");
expect(result).toBe("v1.2.3");
});
});
describe("parseTag", () => {
it("should parse valid tag without prefix", () => {
const result = tagService.parseTag("1.2.3");
expect(result).toEqual({ major: 1, minor: 2, patch: 3 });
});
it("should parse valid tag with prefix", () => {
const result = tagService.parseTag("v1.2.3");
expect(result).toEqual({ major: 1, minor: 2, patch: 3 });
});
it("should throw error for invalid format", () => {
expect(() => tagService.parseTag("invalid")).toThrow(
"Invalid tag format: invalid. Expected semantic version (e.g., 1.2.3)"
);
});
it("should throw error for non-numeric versions", () => {
expect(() => tagService.parseTag("1.a.3")).toThrow(
"Invalid tag format: 1.a.3. Expected semantic version (e.g., 1.2.3)"
);
});
it("should throw error for incomplete version", () => {
expect(() => tagService.parseTag("1.2")).toThrow(
"Invalid tag format: 1.2. Expected semantic version (e.g., 1.2.3)"
);
});
});
});
describe("TagIncrementer", () => {
let tagIncrementer: TagIncrementer;
let mockGitService: MockGitService;
let mockCoreService: MockCoreService;
let mockCommitAnalyzer: ConventionalCommitAnalyzer;
let mockTagService: TagService;
beforeEach(() => {
mockGitService = new MockGitService();
mockCoreService = new MockCoreService();
mockCommitAnalyzer = new ConventionalCommitAnalyzer();
mockTagService = new TagService();
tagIncrementer = new TagIncrementer(
mockGitService,
mockCoreService,
mockCommitAnalyzer,
mockTagService
);
});
afterEach(() => {
mockCoreService.clear();
});
describe("getBaseTag", () => {
it("should return provided tag when available", async () => {
const result = await tagIncrementer.getBaseTag("v1.2.3");
expect(result).toBe("v1.2.3");
expect(mockCoreService.getLogs()).toContain("INFO: Using provided tag: v1.2.3");
});
it("should fetch latest tag when no tag provided", async () => {
mockGitService.setMockTags([{ name: "v2.0.0" }]);
const result = await tagIncrementer.getBaseTag(undefined);
expect(result).toBe("v2.0.0");
expect(mockCoreService.getLogs()).toContain("INFO: Using latest tag: v2.0.0");
});
it("should default to 0.0.0 when no tags found", async () => {
mockGitService.setMockTags([]);
const result = await tagIncrementer.getBaseTag(undefined);
expect(result).toBe("0.0.0");
expect(mockCoreService.getWarnings()).toContain(
"WARNING: No tags found in repository, defaulting to 0.0.0"
);
});
});
describe("determineIncrementType", () => {
it("should analyze commit and determine increment type", async () => {
mockGitService.setMockCommit({
commit: { message: "feat: new feature" },
});
const result = await tagIncrementer.determineIncrementType("ref");
expect(result).toBe(IncrementType.MINOR);
});
});
describe("incrementTag", () => {
it("should increment tag correctly for patch increment", async () => {
mockGitService.setMockTags([{ name: "v1.2.3" }]);
mockGitService.setMockCommit({
commit: { message: "fix: bug fix" },
});
const result = await tagIncrementer.incrementTag(undefined, "ref");
expect(result).toBe("1.2.4");
expect(mockCoreService.getOutputs()).toEqual({});
});
it("should increment tag correctly for minor increment", async () => {
mockGitService.setMockTags([{ name: "v1.2.3" }]);
mockGitService.setMockCommit({
commit: { message: "feat: new feature" },
});
const result = await tagIncrementer.incrementTag(undefined, "ref");
expect(result).toBe("1.3.0");
});
it("should increment tag correctly for major increment", async () => {
mockGitService.setMockTags([{ name: "v1.2.3" }]);
mockGitService.setMockCommit({
commit: { message: "feat!: breaking change" },
});
const result = await tagIncrementer.incrementTag(undefined, "ref");
expect(result).toBe("2.0.0");
});
it("should use provided tag when available", async () => {
mockGitService.setMockCommit({
commit: { message: "fix: bug fix" },
});
const result = await tagIncrementer.incrementTag("v2.1.0", "ref");
expect(result).toBe("2.1.1");
});
});
});
describe("LocalGitService", () => {
let localGitService: LocalGitService;
beforeEach(() => {
localGitService = new LocalGitService();
mockExecSync.mockClear();
});
describe("listTags", () => {
it("should call git command to list tags", async () => {
mockExecSync.mockReturnValue("v1.0.0\nv1.1.0\n" as any);
const result = await localGitService.listTags(5);
expect(mockExecSync).toHaveBeenCalledWith("git tag --sort=-version:refname", {
encoding: "utf8",
});
expect(result).toEqual([{ name: "v1.0.0" }, { name: "v1.1.0" }]);
});
it("should return empty array when no tags exist", async () => {
mockExecSync.mockReturnValue("" as any);
const result = await localGitService.listTags(5);
expect(result).toEqual([]);
});
});
describe("getCommit", () => {
it("should call git command to get commit", async () => {
mockExecSync.mockReturnValue("test commit message" as any);
const result = await localGitService.getCommit("abc123");
expect(mockExecSync).toHaveBeenCalledWith("git log -1 --pretty=%B abc123", {
encoding: "utf8",
});
expect(result).toEqual({ commit: { message: "test commit message" } });
});
});
describe("testConnection", () => {
it("should call git command to test connection", async () => {
mockExecSync.mockReturnValue(".git" as any);
await localGitService.testConnection();
expect(mockExecSync).toHaveBeenCalledWith("git rev-parse --git-dir", { stdio: "pipe" });
});
it("should throw error when not in git repository", async () => {
mockExecSync.mockImplementation(() => {
throw new Error("fatal: not a git repository");
});
await expect(localGitService.testConnection()).rejects.toThrow(
"Not in a git repository. Please ensure this action runs after checkout."
);
});
});
});
describe("LocalCoreService", () => {
let localCoreService: LocalCoreService;
beforeEach(() => {
localCoreService = new LocalCoreService();
});
// Note: These tests would require mocking the @actions/core module
// In a real implementation, you might want to use jest.mock() to mock the entire module
// For now, we'll just test that the methods exist and can be called
it("should have all required methods", () => {
expect(typeof localCoreService.getInput).toBe("function");
expect(typeof localCoreService.info).toBe("function");
expect(typeof localCoreService.warning).toBe("function");
expect(typeof localCoreService.setOutput).toBe("function");
expect(typeof localCoreService.setFailed).toBe("function");
});
});
// Integration test example
describe("TagIncrementer Integration", () => {
it("should handle complete tag increment flow", async () => {
const mockGitService = new MockGitService([{ name: "v1.2.3" }], {
commit: { message: "feat: new feature" },
});
const mockCoreService = new MockCoreService();
const commitAnalyzer = new ConventionalCommitAnalyzer();
const tagService = new TagService();
const tagIncrementer = new TagIncrementer(
mockGitService,
mockCoreService,
commitAnalyzer,
tagService
);
const result = await tagIncrementer.incrementTag(undefined, "ref");
expect(result).toBe("1.3.0");
expect(mockCoreService.getLogs()).toContain("INFO: Determined increment type: minor");
expect(mockCoreService.getLogs()).toContain("INFO: New tag: 1.3.0");
});
});

281
src/main.ts Normal file
View File

@@ -0,0 +1,281 @@
import * as core from "@actions/core";
import * as ccp from "conventional-commits-parser";
import type { Commit } from "conventional-commits-parser";
import { execSync } from "child_process";
export enum IncrementType {
MAJOR = "major",
MINOR = "minor",
PATCH = "patch",
}
export type Tag = {
major: number;
minor: number;
patch: number;
};
export interface GitService {
listTags(perPage: number): Promise<{ name: string }[]>;
getCommit(ref: string): Promise<{ commit: { message: string } }>;
testConnection(): Promise<void>;
}
export interface CoreService {
getInput(name: string): string;
info(message: string): void;
warning(message: string): void;
setOutput(name: string, value: string): void;
setFailed(message: string): void;
}
export class ConventionalCommitAnalyzer {
analyzeCommit(message: string): Commit {
return ccp.sync(message);
}
determineIncrementType(analyzedCommit: Commit): IncrementType {
if (
analyzedCommit.breaking ||
analyzedCommit.header?.includes("!") ||
analyzedCommit.notes.some((note: any) => note.title === "BREAKING CHANGE")
) {
return IncrementType.MAJOR;
} else if (analyzedCommit.type === "feat") {
return IncrementType.MINOR;
} else {
return IncrementType.PATCH;
}
}
}
export class TagService {
bumpTag(tag: Tag, incrementType: IncrementType): Tag {
switch (incrementType) {
case IncrementType.MAJOR:
return {
major: tag.major + 1,
minor: 0,
patch: 0,
};
case IncrementType.MINOR:
return {
major: tag.major,
minor: tag.minor + 1,
patch: 0,
};
case IncrementType.PATCH:
return {
major: tag.major,
minor: tag.minor,
patch: tag.patch + 1,
};
}
}
renderTag(tag: Tag, prefix: string = ""): string {
return `${prefix}${tag.major}.${tag.minor}.${tag.patch}`;
}
parseTag(tagString: string): Tag {
if (!/^v?\d+\.\d+\.\d+$/.test(tagString)) {
throw new Error(`Invalid tag format: ${tagString}. Expected semantic version (e.g., 1.2.3)`);
}
const versionMatch = tagString.match(/^v?(\d+)\.(\d+)\.(\d+)$/);
if (!versionMatch) {
throw new Error(`Could not parse version from tag: ${tagString}`);
}
return {
major: parseInt(versionMatch[1]),
minor: parseInt(versionMatch[2]),
patch: parseInt(versionMatch[3]),
};
}
}
export class TagIncrementer {
constructor(
private gitService: GitService,
private coreService: CoreService,
private commitAnalyzer: ConventionalCommitAnalyzer,
private tagService: TagService
) {}
async getBaseTag(lastTag: string | undefined): Promise<string> {
if (lastTag) {
this.coreService.info(`Using provided tag: ${lastTag}`);
return lastTag;
}
this.coreService.info("Fetching tags from local repository");
const tags = await this.gitService.listTags(1);
if (tags.length === 0) {
this.coreService.warning("No tags found in repository, defaulting to 0.0.0");
return "0.0.0";
}
const baseTag = tags[0].name;
this.coreService.info(`Using latest tag: ${baseTag}`);
return baseTag;
}
async determineIncrementType(ref: string): Promise<IncrementType> {
this.coreService.info(`Fetching commit ${ref}`);
try {
const { commit } = await this.gitService.getCommit(ref);
this.coreService.info(`Successfully fetched commit: ${commit.message.substring(0, 100)}...`);
const analyzedCommit = this.commitAnalyzer.analyzeCommit(commit.message);
this.coreService.info(
`Analyzed commit - type: ${analyzedCommit.type}, breaking: ${analyzedCommit.breaking}`
);
const incrementType = this.commitAnalyzer.determineIncrementType(analyzedCommit);
this.coreService.info(`Determined increment type: ${incrementType}`);
return incrementType;
} catch (error) {
this.coreService.info(
`Error fetching commit ${ref}: ${error instanceof Error ? error.message : String(error)}`
);
// Additional debugging for commit-related errors
if (error instanceof Error && error.message.includes("404")) {
this.coreService.info(`404 error suggests commit ${ref} was not found in repository`);
this.coreService.info(
`This could indicate: 1) SHA doesn't exist, 2) Repository access issue, 3) API endpoint mismatch`
);
}
throw error;
}
}
async incrementTag(lastTag: string | undefined, ref: string): Promise<string> {
this.coreService.info(`Starting tag increment process at ref ${ref}`);
const baseTag = await this.getBaseTag(lastTag);
const parsedTag = this.tagService.parseTag(baseTag);
this.coreService.info(
`Parsed version: ${parsedTag.major}.${parsedTag.minor}.${parsedTag.patch}`
);
const incrementType = await this.determineIncrementType(ref);
const newTag = this.tagService.bumpTag(parsedTag, incrementType);
const renderedTag = this.tagService.renderTag(newTag);
this.coreService.info(`New tag: ${renderedTag}`);
this.coreService.info(
`Successfully determined new tag: ${renderedTag} (${incrementType} increment)`
);
return renderedTag;
}
}
export class LocalGitService implements GitService {
async listTags(perPage: number): Promise<{ name: string }[]> {
try {
const tags = execSync("git tag --sort=-version:refname", { encoding: "utf8" })
.split("\n")
.filter(Boolean)
.slice(0, perPage);
return tags.map((name) => ({ name }));
} catch (error) {
// If no tags exist, return empty array
return [];
}
}
async getCommit(ref: string): Promise<{ commit: { message: string } }> {
try {
const message = execSync(`git log -1 --pretty=%B ${ref}`, { encoding: "utf8" }).trim();
return { commit: { message } };
} catch (error) {
throw new Error(
`Failed to get commit ${ref}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
async testConnection(): Promise<void> {
try {
// Test if we're in a git repository
execSync("git rev-parse --git-dir", { stdio: "pipe" });
} catch (error) {
throw new Error("Not in a git repository. Please ensure this action runs after checkout.");
}
}
}
export class LocalCoreService implements CoreService {
getInput(name: string): string {
return core.getInput(name);
}
info(message: string): void {
core.info(message);
}
warning(message: string): void {
core.warning(message);
}
setOutput(name: string, value: string): void {
core.setOutput(name, value);
}
setFailed(message: string): void {
core.setFailed(message);
}
}
export async function run(): Promise<void> {
try {
const coreService = new LocalCoreService();
const lastTag = coreService.getInput("last-tag");
const ref = "HEAD";
// Log context information for debugging
coreService.info(`Working with local git repository`);
coreService.info(`Commit ref: ${ref}`);
coreService.info(`Last tag input: ${lastTag || "undefined"}`);
const gitService = new LocalGitService();
const commitAnalyzer = new ConventionalCommitAnalyzer();
const tagService = new TagService();
const tagIncrementer = new TagIncrementer(gitService, coreService, commitAnalyzer, tagService);
coreService.info("Starting tag increment process...");
// Test the connection first
coreService.info("Testing git repository connection...");
await gitService.testConnection();
coreService.info("Git repository connection successful");
const newTag = await tagIncrementer.incrementTag(lastTag || undefined, ref);
coreService.info(`Process completed successfully. New tag: ${newTag}`);
coreService.setOutput("new-tag", newTag);
} catch (error) {
const coreService = new LocalCoreService();
// Enhanced error logging
if (error instanceof Error) {
coreService.info(`Error details: ${error.message}`);
coreService.info(`Error stack: ${error.stack}`);
coreService.setFailed(error.message);
} else {
coreService.info(`Unknown error: ${String(error)}`);
coreService.setFailed("An unknown error occurred");
}
}
}