diff --git a/__mocks__/@actions/core.ts b/__mocks__/@actions/core.ts index 3104820..e47f476 100644 --- a/__mocks__/@actions/core.ts +++ b/__mocks__/@actions/core.ts @@ -55,9 +55,7 @@ export const error: MockedFunction<(message: string | Error, properties?: Annota * @param message - The error message or object. * @throws An error with the specified message. */ -export const setFailed: MockedFunction<(message: string | Error) => void> = vi.fn((message: string | Error) => { - actual.setFailed(message); -}); +export const setFailed: MockedFunction<(message: string | Error) => void> = vi.fn((message: string | Error) => {}); /** * Begins a new output group. Output until the next `endGroup` will be foldable in this group. diff --git a/__tests__/context.test.ts b/__tests__/context.test.ts index aea0e05..0d61896 100644 --- a/__tests__/context.test.ts +++ b/__tests__/context.test.ts @@ -147,6 +147,23 @@ describe('context', () => { expect(getContext().prTitle).toEqual(prTitle.trim()); }); + it('should initialize and output only summary of long pull request body', () => { + const prBody = 'Test PR with long long title that extends past the 57 character mark'; + mockReadFileSync.mockImplementation(() => { + return JSON.stringify( + createPullRequestMock({ + action: 'test', + pull_request: { + body: prBody, + }, + }), + ); + }); + + getContext(); + expect(info).toHaveBeenCalledWith(`Pull Request Body: ${prBody.slice(0, 57)}...`); + }); + it('should initialize with null pull request body', () => { mockReadFileSync.mockImplementation(() => { return JSON.stringify( diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts new file mode 100644 index 0000000..4261a2a --- /dev/null +++ b/__tests__/main.test.ts @@ -0,0 +1,239 @@ +import { run } from '@/main'; +import { config } from '@/mocks/config'; +import { context } from '@/mocks/context'; +import { addPostReleaseComment, addReleasePlanComment, hasReleaseComment } from '@/pull-request'; +import { createTaggedRelease, deleteLegacyReleases } from '@/releases'; +import { deleteLegacyTags } from '@/tags'; +import { ensureTerraformDocsConfigDoesNotExist, installTerraformDocs } from '@/terraform-docs'; +import { getAllTerraformModules, getTerraformChangedModules, getTerraformModulesToRemove } from '@/terraform-module'; +import type { GitHubRelease, TerraformChangedModule, TerraformModule } from '@/types'; +import { WikiStatus, checkoutWiki, commitAndPushWikiChanges, generateWikiFiles } from '@/wiki'; +import { info, setFailed } from '@actions/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock all required dependencies +vi.mock('@/pull-request'); +vi.mock('@/releases'); +vi.mock('@/tags'); +vi.mock('@/terraform-docs'); +vi.mock('@/terraform-module'); +vi.mock('@/wiki'); + +describe('main', () => { + // Mock module data + const mockRelease: GitHubRelease = { + id: 1, + title: 'Release v1.0.0', + body: 'Release notes', + tagName: 'modules/test-module/v1.0.0', + }; + + const mockChangedModule: TerraformChangedModule = { + moduleName: 'test-module', + directory: './modules/test-module', + tags: ['modules/test-module/v1.0.0'], + releases: [mockRelease], + latestTag: 'modules/test-module/v1.0.0', + latestTagVersion: 'v1.0.0', + isChanged: true, + commitMessages: ['feat: new feature'], + releaseType: 'minor', + nextTag: 'modules/test-module/v1.1.0', + nextTagVersion: 'v1.1.0', + }; + + // Add mock for getAllTerraformModules + const mockTerraformModule: TerraformModule = { + moduleName: 'test-module', + directory: './modules/test-module', + tags: ['modules/test-module/v1.0.0'], + releases: [mockRelease], + latestTag: 'modules/test-module/v1.0.0', + latestTagVersion: 'v1.0.0', + }; + + beforeEach(() => { + vi.clearAllMocks(); + + // Reset context and config before each test + context.isPrMergeEvent = false; + config.disableWiki = false; + + // Reset mocks with default values + vi.mocked(hasReleaseComment).mockResolvedValue(false); + vi.mocked(getTerraformChangedModules).mockReturnValue([mockChangedModule]); + vi.mocked(getAllTerraformModules).mockReturnValue([mockTerraformModule]); + vi.mocked(getTerraformModulesToRemove).mockReturnValue([]); + }); + + it('should exit early if release comment exists', async () => { + vi.mocked(hasReleaseComment).mockResolvedValue(true); + + await run(); + + expect(info).toHaveBeenCalledWith('Release comment found. Exiting.'); + }); + + it('should handle errors', async () => { + vi.mocked(hasReleaseComment).mockRejectedValue(new Error('Test error')); + + await run(); + + expect(setFailed).toHaveBeenCalledWith('Test error'); + }); + + it('should handle non-Error type being thrown', async () => { + // Mock hasReleaseComment to throw a string instead of an Error + vi.mocked(checkoutWiki).mockImplementationOnce(() => { + throw 'string error message'; + }); + + // Run the function + await run(); + + // The setFailed function should not have been called with an error message + // since the error wasn't an instance of Error + expect(addReleasePlanComment).toHaveBeenCalledTimes(1); + expect(setFailed).not.toHaveBeenCalled(); + }); + + it('should call checkoutWiki when wiki is enabled', async () => { + vi.mocked(hasReleaseComment).mockResolvedValue(false); + vi.mocked(getTerraformChangedModules).mockReturnValue([mockChangedModule]); + context.isPrMergeEvent = false; + config.disableWiki = false; + + await run(); + + expect(vi.mocked(checkoutWiki)).toHaveBeenCalledTimes(1); + }); + + it('should not call checkoutWiki when wiki is disabled', async () => { + vi.mocked(hasReleaseComment).mockResolvedValue(false); + vi.mocked(getTerraformChangedModules).mockReturnValue([mockChangedModule]); + context.isPrMergeEvent = false; + config.disableWiki = true; + + await run(); + + expect(vi.mocked(checkoutWiki)).not.toHaveBeenCalled(); + }); + + // Wiki checkout error handling + it('should handle wiki checkout errors and add release plan comment', async () => { + vi.mocked(hasReleaseComment).mockResolvedValue(false); + context.isPrMergeEvent = false; + config.disableWiki = false; + + const mockError = new Error('Wiki checkout failed\nAdditional error details'); + vi.mocked(checkoutWiki).mockImplementationOnce(() => { + throw mockError; + }); + + vi.mocked(getTerraformChangedModules).mockReturnValue([mockChangedModule]); + vi.mocked(getTerraformModulesToRemove).mockReturnValue(['old-module']); + + await run(); + + expect(addReleasePlanComment).toHaveBeenCalledWith([mockChangedModule], ['old-module'], { + status: WikiStatus.FAILURE, + errorMessage: 'Wiki checkout failed', + }); + expect(setFailed).toHaveBeenCalledWith('Wiki checkout failed\nAdditional error details'); + }); + + describe('merge event handling', () => { + const mockReleaseResponse: GitHubRelease = { + id: 2, + title: 'Release v1.1.0', + body: 'New release notes', + tagName: 'modules/test-module/v1.1.0', + }; + + beforeEach(() => { + context.isPrMergeEvent = true; + + vi.mocked(hasReleaseComment).mockResolvedValue(false); + vi.mocked(getTerraformChangedModules).mockReturnValue([mockChangedModule]); + vi.mocked(createTaggedRelease).mockResolvedValue([ + { + moduleName: mockChangedModule.moduleName, + release: mockReleaseResponse, + }, + ]); + }); + + it('should handle merge event with wiki enabled', async () => { + config.disableWiki = false; + + await run(); + + expect(createTaggedRelease).toHaveBeenCalledWith([mockChangedModule]); + expect(addPostReleaseComment).toHaveBeenCalledWith([ + { + moduleName: mockChangedModule.moduleName, + release: mockReleaseResponse, + }, + ]); + expect(deleteLegacyReleases).toHaveBeenCalled(); + expect(deleteLegacyTags).toHaveBeenCalled(); + expect(installTerraformDocs).toHaveBeenCalledWith(config.terraformDocsVersion); + expect(ensureTerraformDocsConfigDoesNotExist).toHaveBeenCalled(); + expect(checkoutWiki).toHaveBeenCalled(); + expect(generateWikiFiles).toHaveBeenCalledWith([mockTerraformModule]); + expect(commitAndPushWikiChanges).toHaveBeenCalled(); + }); + + it('should handle merge event with wiki disabled', async () => { + config.disableWiki = true; + + await run(); + + expect(createTaggedRelease).toHaveBeenCalledWith([mockChangedModule]); + expect(addPostReleaseComment).toHaveBeenCalledWith([ + { + moduleName: mockChangedModule.moduleName, + release: mockReleaseResponse, + }, + ]); + expect(deleteLegacyReleases).toHaveBeenCalled(); + expect(deleteLegacyTags).toHaveBeenCalled(); + expect(installTerraformDocs).not.toHaveBeenCalled(); + expect(ensureTerraformDocsConfigDoesNotExist).not.toHaveBeenCalled(); + expect(checkoutWiki).not.toHaveBeenCalled(); + expect(generateWikiFiles).not.toHaveBeenCalled(); + expect(commitAndPushWikiChanges).not.toHaveBeenCalled(); + expect(info).toHaveBeenCalledWith('Wiki generation is disabled.'); + }); + + it('should handle merge event sequence correctly', async () => { + config.disableWiki = false; + const expectedTaggedRelease = { + moduleName: mockChangedModule.moduleName, + release: mockReleaseResponse, + }; + + vi.mocked(getTerraformChangedModules).mockReturnValue([mockChangedModule]); + vi.mocked(createTaggedRelease).mockResolvedValue([expectedTaggedRelease]); + + await run(); + + const createTaggedReleaseMock = vi.mocked(createTaggedRelease); + const addPostReleaseCommentMock = vi.mocked(addPostReleaseComment); + const deleteLegacyReleasesMock = vi.mocked(deleteLegacyReleases); + const deleteLegacyTagsMock = vi.mocked(deleteLegacyTags); + + // Verify correct arguments + expect(createTaggedReleaseMock).toHaveBeenCalledWith([mockChangedModule]); + expect(addPostReleaseCommentMock).toHaveBeenCalledWith([expectedTaggedRelease]); + + // Verify sequence order + const createTaggedReleaseCallOrder = createTaggedReleaseMock.mock.invocationCallOrder[0]; + const deleteLegacyReleasesCallOrder = deleteLegacyReleasesMock.mock.invocationCallOrder[0]; + const deleteLegacyTagsCallOrder = deleteLegacyTagsMock.mock.invocationCallOrder[0]; + + expect(createTaggedReleaseCallOrder).toBeLessThan(deleteLegacyReleasesCallOrder); + expect(deleteLegacyReleasesCallOrder).toBeLessThan(deleteLegacyTagsCallOrder); + }); + }); +}); diff --git a/__tests__/releases.test.ts b/__tests__/releases.test.ts index 70910ba..0f52a0d 100644 --- a/__tests__/releases.test.ts +++ b/__tests__/releases.test.ts @@ -1,3 +1,5 @@ +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { config } from '@/mocks/config'; import { context } from '@/mocks/context'; import { createTaggedRelease, deleteLegacyReleases, getAllReleases } from '@/releases'; @@ -13,7 +15,9 @@ vi.mock('node:child_process', () => ({ vi.mock('node:fs', () => ({ existsSync: vi.fn(), copyFileSync: vi.fn(), - mkdirSync: vi.fn(), + mkdtempSync: vi.fn().mockImplementation(() => { + return join(tmpdir(), (Math.random() + 1).toString(36).substring(7)); + }), cpSync: vi.fn(), readdirSync: vi.fn().mockImplementation(() => []), })); @@ -322,6 +326,8 @@ describe('releases', () => { }); const result = await createTaggedRelease([mockTerraformModule]); + console.log(result); + expect(result).toHaveLength(1); expect(result[0].moduleName).toBe('test-module'); expect(result[0].release.title).toBe('test-module/v1.0.1'); diff --git a/__tests__/utils/file.test.ts b/__tests__/utils/file.test.ts index 5d6e8fb..e731848 100644 --- a/__tests__/utils/file.test.ts +++ b/__tests__/utils/file.test.ts @@ -5,27 +5,27 @@ import { copyModuleContents, isTerraformDirectory, removeDirectoryContents, shou import { afterEach, beforeEach, describe, expect, it } from 'vitest'; describe('utils/file', () => { - let tempDir: string; + let tmpDir: string; beforeEach(() => { // Create a temporary directory before each test - tempDir = mkdtempSync(join(tmpdir(), 'test-dir-')); + tmpDir = mkdtempSync(join(tmpdir(), 'test-dir-')); }); afterEach(() => { // Remove temporary directory - rmSync(tempDir, { recursive: true }); + rmSync(tmpDir, { recursive: true }); }); describe('isTerraformDirectory()', () => { it('should return true for a directory that has .tf files', () => { - writeFileSync(join(tempDir, 'main.tf'), '# terraform code'); - expect(isTerraformDirectory(tempDir)).toBe(true); + writeFileSync(join(tmpDir, 'main.tf'), '# terraform code'); + expect(isTerraformDirectory(tmpDir)).toBe(true); }); it('should return false for a directory that has .tf files', () => { - writeFileSync(join(tempDir, 'README.md'), '# README'); - expect(isTerraformDirectory(tempDir)).toBe(false); + writeFileSync(join(tmpDir, 'README.md'), '# README'); + expect(isTerraformDirectory(tmpDir)).toBe(false); }); it('should return false for invalid directory', () => { @@ -35,33 +35,33 @@ describe('utils/file', () => { describe('shouldExcludeFile()', () => { it('should exclude file when pattern matches', () => { - const baseDirectory = tempDir; - const filePath = join(tempDir, 'file.txt'); + const baseDirectory = tmpDir; + const filePath = join(tmpDir, 'file.txt'); const excludePatterns = ['*.txt']; expect(shouldExcludeFile(baseDirectory, filePath, excludePatterns)).toBe(true); }); it('should not exclude file when pattern does not match', () => { - const baseDirectory = tempDir; - const filePath = join(tempDir, 'file.txt'); + const baseDirectory = tmpDir; + const filePath = join(tmpDir, 'file.txt'); const excludePatterns = ['*.js']; expect(shouldExcludeFile(baseDirectory, filePath, excludePatterns)).toBe(false); }); it('should handle relative paths correctly', () => { - const baseDirectory = tempDir; - const filePath = join(tempDir, 'subdir', 'file.txt'); + const baseDirectory = tmpDir; + const filePath = join(tmpDir, 'subdir', 'file.txt'); const excludePatterns = ['subdir/*.txt']; expect(shouldExcludeFile(baseDirectory, filePath, excludePatterns)).toBe(true); }); it('should handle exclusion pattern: *.md', () => { - const baseDirectory = tempDir; - const filePath1 = join(tempDir, 'README.md'); - const filePath2 = join(tempDir, 'nested', 'README.md'); + const baseDirectory = tmpDir; + const filePath1 = join(tmpDir, 'README.md'); + const filePath2 = join(tmpDir, 'nested', 'README.md'); const excludePatterns = ['*.md']; expect(shouldExcludeFile(baseDirectory, filePath1, excludePatterns)).toBe(true); @@ -69,9 +69,9 @@ describe('utils/file', () => { }); it('should handle exclusion pattern: **/*.md', () => { - const baseDirectory = tempDir; - const filePath1 = join(tempDir, 'README.md'); - const filePath2 = join(tempDir, 'nested', 'README.md'); + const baseDirectory = tmpDir; + const filePath1 = join(tmpDir, 'README.md'); + const filePath2 = join(tmpDir, 'nested', 'README.md'); const excludePatterns = ['**/*.md']; expect(shouldExcludeFile(baseDirectory, filePath1, excludePatterns)).toBe(true); @@ -79,10 +79,10 @@ describe('utils/file', () => { }); it('should handle exclusion pattern: tests/**', () => { - const baseDirectory = tempDir; - const filePath1 = join(tempDir, 'tests/config.test.ts'); - const filePath2 = join(tempDir, 'tests2/config.test.ts'); - const filePath3 = join(tempDir, 'tests2/tests/config.test.ts'); + const baseDirectory = tmpDir; + const filePath1 = join(tmpDir, 'tests/config.test.ts'); + const filePath2 = join(tmpDir, 'tests2/config.test.ts'); + const filePath3 = join(tmpDir, 'tests2/tests/config.test.ts'); const excludePatterns = ['tests/**']; expect(shouldExcludeFile(baseDirectory, filePath1, excludePatterns)).toBe(true); @@ -91,10 +91,10 @@ describe('utils/file', () => { }); it('should handle exclusion pattern: **/tests/**', () => { - const baseDirectory = tempDir; - const filePath1 = join(tempDir, 'tests/config.test.ts'); - const filePath2 = join(tempDir, 'tests2/config.test.ts'); - const filePath3 = join(tempDir, 'tests2/tests/config.test.ts'); + const baseDirectory = tmpDir; + const filePath1 = join(tmpDir, 'tests/config.test.ts'); + const filePath2 = join(tmpDir, 'tests2/config.test.ts'); + const filePath3 = join(tmpDir, 'tests2/tests/config.test.ts'); const excludePatterns = ['**/tests/**']; expect(shouldExcludeFile(baseDirectory, filePath1, excludePatterns)).toBe(true); @@ -106,13 +106,13 @@ describe('utils/file', () => { describe('copyModuleContents()', () => { beforeEach(() => { // Create src and dest directories for every test in this suite - mkdirSync(join(tempDir, 'src'), { recursive: true }); - mkdirSync(join(tempDir, 'dest'), { recursive: true }); + mkdirSync(join(tmpDir, 'src'), { recursive: true }); + mkdirSync(join(tmpDir, 'dest'), { recursive: true }); }); it('should copy directory contents excluding files that match patterns', () => { - const srcDirectory = join(tempDir, 'src'); - const destDirectory = join(tempDir, 'dest'); + const srcDirectory = join(tmpDir, 'src'); + const destDirectory = join(tmpDir, 'dest'); const excludePatterns = ['*.txt']; // Create files in src directory @@ -128,8 +128,8 @@ describe('utils/file', () => { }); it('should handle recursive directory copying', () => { - const srcDirectory = join(tempDir, 'src'); - const destDirectory = join(tempDir, 'dest'); + const srcDirectory = join(tmpDir, 'src'); + const destDirectory = join(tmpDir, 'dest'); const excludePatterns: string[] = []; // Create source structure @@ -146,8 +146,8 @@ describe('utils/file', () => { }); it('should copy files excluding multiple patterns', () => { - const srcDirectory = join(tempDir, 'src'); - const destDirectory = join(tempDir, 'dest'); + const srcDirectory = join(tmpDir, 'src'); + const destDirectory = join(tmpDir, 'dest'); const excludePatterns = ['*.txt', '*.js']; writeFileSync(join(srcDirectory, 'file.txt'), 'Hello World!'); @@ -162,8 +162,8 @@ describe('utils/file', () => { }); it('should handle copying from an empty directory', () => { - const srcDirectory = join(tempDir, 'src'); - const destDirectory = join(tempDir, 'dest'); + const srcDirectory = join(tmpDir, 'src'); + const destDirectory = join(tmpDir, 'dest'); const excludePatterns = ['*.txt']; copyModuleContents(srcDirectory, destDirectory, excludePatterns); @@ -173,8 +173,8 @@ describe('utils/file', () => { }); it('should throw an error if the source directory does not exist', () => { - const nonExistentSrcDirectory = join(tempDir, 'non-existent-src'); - const destDirectory = join(tempDir, 'dest'); + const nonExistentSrcDirectory = join(tmpDir, 'non-existent-src'); + const destDirectory = join(tmpDir, 'dest'); const excludePatterns = ['*.txt']; expect(() => { @@ -183,8 +183,8 @@ describe('utils/file', () => { }); it('should copy files that do not match any exclusion patterns', () => { - const srcDirectory = join(tempDir, 'src'); - const destDirectory = join(tempDir, 'dest'); + const srcDirectory = join(tmpDir, 'src'); + const destDirectory = join(tmpDir, 'dest'); const excludePatterns = ['*.js']; writeFileSync(join(srcDirectory, 'file.txt'), 'Hello World!'); @@ -197,8 +197,8 @@ describe('utils/file', () => { }); it('should overwrite files in the destination if they have the same name and do not match exclusion patterns', () => { - const srcDirectory = join(tempDir, 'src'); - const destDirectory = join(tempDir, 'dest'); + const srcDirectory = join(tmpDir, 'src'); + const destDirectory = join(tmpDir, 'dest'); const excludePatterns: string[] = []; writeFileSync(join(srcDirectory, 'file.txt'), 'Hello World from source!'); @@ -213,7 +213,7 @@ describe('utils/file', () => { describe('removeDirectoryContents()', () => { it('should remove directory contents except for specified exceptions', () => { - const directory = join(tempDir, 'dir'); + const directory = join(tmpDir, 'dir'); const exceptions = ['file.txt']; mkdirSync(directory); @@ -227,7 +227,7 @@ describe('utils/file', () => { }); it('should handle recursive directory removal', () => { - const directory = join(tempDir, 'dir'); + const directory = join(tmpDir, 'dir'); const exceptions: string[] = []; mkdirSync(directory); @@ -242,7 +242,7 @@ describe('utils/file', () => { }); it('should handle exceptions correctly', () => { - const directory = join(tempDir, 'dir'); + const directory = join(tmpDir, 'dir'); const exceptions = ['file.txt', 'subdir']; mkdirSync(directory); @@ -259,7 +259,7 @@ describe('utils/file', () => { }); it('should handle an empty directory', () => { - const directory = join(tempDir, 'dir'); + const directory = join(tmpDir, 'dir'); const exceptions: string[] = []; mkdirSync(directory); // Create an empty directory @@ -270,7 +270,7 @@ describe('utils/file', () => { }); it('should not remove if only exceptions are present', () => { - const directory = join(tempDir, 'dir'); + const directory = join(tmpDir, 'dir'); const exceptions = ['file.txt']; mkdirSync(directory); @@ -283,7 +283,7 @@ describe('utils/file', () => { }); it('should handle nested exceptions correctly', () => { - const directory = join(tempDir, 'dir'); + const directory = join(tmpDir, 'dir'); const exceptions = ['subdir']; mkdirSync(directory); @@ -299,7 +299,7 @@ describe('utils/file', () => { }); it('should not throw an error if the directory does not exist', () => { - const nonExistentDirectory = join(tempDir, 'non-existent-dir'); + const nonExistentDirectory = join(tmpDir, 'non-existent-dir'); const exceptions = ['file.txt']; expect(() => { @@ -308,7 +308,7 @@ describe('utils/file', () => { }); it('should handle exceptions that do not exist in the directory', () => { - const directory = join(tempDir, 'dir'); + const directory = join(tmpDir, 'dir'); const exceptions = ['file.txt']; mkdirSync(directory); @@ -320,7 +320,7 @@ describe('utils/file', () => { }); it('should remove directory contents when no exceptions specified', () => { - const directory = join(tempDir, 'dir'); + const directory = join(tmpDir, 'dir'); mkdirSync(directory); writeFileSync(join(directory, 'file.txt'), 'Hello World!'); diff --git a/assets/coverage-badge.svg b/assets/coverage-badge.svg index 6bd33ac..5bb55be 100644 --- a/assets/coverage-badge.svg +++ b/assets/coverage-badge.svg @@ -1 +1 @@ -Coverage: 80.86%Coverage80.86% \ No newline at end of file +Coverage: 100%Coverage100% \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index aecd4d0..6a904f6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,32 +5,115 @@ import { createTaggedRelease, deleteLegacyReleases, getAllReleases } from '@/rel import { deleteLegacyTags, getAllTags } from '@/tags'; import { ensureTerraformDocsConfigDoesNotExist, installTerraformDocs } from '@/terraform-docs'; import { getAllTerraformModules, getTerraformChangedModules, getTerraformModulesToRemove } from '@/terraform-module'; -import type { Config, Context } from '@/types'; +import type { + Config, + Context, + GitHubRelease, + ReleasePlanCommentOptions, + TerraformChangedModule, + TerraformModule, +} from '@/types'; import { WikiStatus, checkoutWiki, commitAndPushWikiChanges, generateWikiFiles } from '@/wiki'; import { info, setFailed } from '@actions/core'; /** - * Ensures both config and context are initialized in the correct order. - * This function handles the initialization sequence and returns both objects. + * Initializes and returns the configuration and context objects. + * Config must be initialized before context due to dependency constraints. + * + * @returns {{ config: Config; context: Context }} Initialized config and context objects. */ function initialize(): { config: Config; context: Context } { - // Force config initialization first const configInstance = getConfig(); - - // Then initialize context const contextInstance = getContext(); - return { config: configInstance, context: contextInstance }; } /** - * The main function for the action. - * @returns {Promise} Resolves when the action is complete. + * Handles wiki-related operations, including checkout, generating release plan comments, + * and error handling for failures. + * + * @param {Config} config - The configuration object containing wiki and Terraform Docs settings. + * @param {TerraformChangedModule[]} terraformChangedModules - List of changed Terraform modules. + * @param {string[]} terraformModuleNamesToRemove - List of Terraform module names to remove. + * @returns {Promise} Resolves when wiki-related operations are completed. + */ +async function handleWikiOperations( + config: Config, + terraformChangedModules: TerraformChangedModule[], + terraformModuleNamesToRemove: string[], +): Promise { + let wikiStatus: WikiStatus = WikiStatus.DISABLED; + let failure: string | undefined; + let error: Error | undefined; + + try { + if (!config.disableWiki) { + checkoutWiki(); + wikiStatus = WikiStatus.SUCCESS; + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message.split('\n')[0] : String(err).split('\n')[0]; + wikiStatus = WikiStatus.FAILURE; + failure = errorMessage; + error = err as Error; + } finally { + const commentOptions: ReleasePlanCommentOptions = { + status: wikiStatus, + errorMessage: failure, + }; + await addReleasePlanComment(terraformChangedModules, terraformModuleNamesToRemove, commentOptions); + } + + if (error) { + throw error; + } +} + +/** + * Handles merge-event-specific operations, including tagging new releases, deleting legacy resources, + * and optionally generating Terraform Docs-based wiki documentation. + * + * @param {Config} config - The configuration object. + * @param {TerraformChangedModule[]} terraformChangedModules - List of changed Terraform modules. + * @param {string[]} terraformModuleNamesToRemove - List of Terraform module names to remove. + * @param {TerraformModule[]} terraformModules - List of all Terraform modules in the repository. + * @param {GitHubRelease[]} allReleases - List of all GitHub releases in the repository. + * @param {string[]} allTags - List of all tags in the repository. + * @returns {Promise} Resolves when merge-event operations are complete. + */ +async function handleMergeEvent( + config: Config, + terraformChangedModules: TerraformChangedModule[], + terraformModuleNamesToRemove: string[], + terraformModules: TerraformModule[], + allReleases: GitHubRelease[], + allTags: string[], +): Promise { + const updatedModules = await createTaggedRelease(terraformChangedModules); + await addPostReleaseComment(updatedModules); + + await deleteLegacyReleases(terraformModuleNamesToRemove, allReleases); + await deleteLegacyTags(terraformModuleNamesToRemove, allTags); + + if (config.disableWiki) { + info('Wiki generation is disabled.'); + } else { + installTerraformDocs(config.terraformDocsVersion); + ensureTerraformDocsConfigDoesNotExist(); + checkoutWiki(); + await generateWikiFiles(terraformModules); + commitAndPushWikiChanges(); + } +} + +/** + * Entry point for the GitHub Action. Determines the flow based on whether the event + * is a pull request or a merge, and executes the appropriate operations. + * + * @returns {Promise} Resolves when the action completes successfully or fails. */ export async function run(): Promise { try { - // Initialize the config and context which will be used throughout the action - // caching each instance as a singleton with proxy accesors to improve performance. const { config, context } = initialize(); if (await hasReleaseComment()) { @@ -38,69 +121,24 @@ export async function run(): Promise { return; } - // Fetch all commits along with associated files in this PR const commits = await getPullRequestCommits(); - - // Fetch all tags associated with this PR const allTags = await getAllTags(); - - // Fetch all releases associated with this PR const allReleases = await getAllReleases(); - - // Get all Terraform modules in this repository including changed metadata const terraformModules = getAllTerraformModules(commits, allTags, allReleases); - - // Create a new array of only changed Terraform modules const terraformChangedModules = getTerraformChangedModules(terraformModules); - - // Get an array of terraform module names to remove based on existing tags const terraformModuleNamesToRemove = getTerraformModulesToRemove(allTags, terraformModules); if (!context.isPrMergeEvent) { - let wikiStatus = WikiStatus.DISABLED; - let failure: string | undefined; - let error: Error | undefined; - - try { - if (!config.disableWiki) { - checkoutWiki(); - wikiStatus = WikiStatus.SUCCESS; - } - } catch (err) { - // Capture error message if the checkout fails - const errorMessage = (err as Error).message.split('\n')[0] || 'Unknown error during wiki checkout'; - wikiStatus = WikiStatus.FAILURE; - failure = errorMessage; - error = err as Error; - } finally { - await addReleasePlanComment(terraformChangedModules, terraformModuleNamesToRemove, { - status: wikiStatus, - errorMessage: failure, - }); - } - - // If we have an error, let's throw it so that the action fails after we've successfully commented on the PR. - if (error !== undefined) { - throw error; - } + await handleWikiOperations(config, terraformChangedModules, terraformModuleNamesToRemove); } else { - // Create the tagged release and post a comment to the PR - const updatedModules = await createTaggedRelease(terraformChangedModules); - await addPostReleaseComment(updatedModules); - - // Delete legacy releases and tags (Ensure we delete releases first) - await deleteLegacyReleases(terraformModuleNamesToRemove, allReleases); - await deleteLegacyTags(terraformModuleNamesToRemove, allTags); - - if (config.disableWiki) { - info('Wiki generation is disabled.'); - } else { - installTerraformDocs(config.terraformDocsVersion); - ensureTerraformDocsConfigDoesNotExist(); - checkoutWiki(); - await generateWikiFiles(terraformModules); - commitAndPushWikiChanges(); - } + await handleMergeEvent( + config, + terraformChangedModules, + terraformModuleNamesToRemove, + terraformModules, + allReleases, + allTags, + ); } } catch (error) { if (error instanceof Error) { diff --git a/src/pull-request.ts b/src/pull-request.ts index 09c41c7..e3368a4 100644 --- a/src/pull-request.ts +++ b/src/pull-request.ts @@ -1,7 +1,7 @@ import { getPullRequestChangelog } from '@/changelog'; import { config } from '@/config'; import { context } from '@/context'; -import type { CommitDetails, GitHubRelease, TerraformChangedModule } from '@/types'; +import type { CommitDetails, GitHubRelease, ReleasePlanCommentOptions, TerraformChangedModule } from '@/types'; import { BRANDING_COMMENT, GITHUB_ACTIONS_BOT_USER_ID, PR_RELEASE_MARKER, PR_SUMMARY_MARKER } from '@/utils/constants'; import { WikiStatus, getWikiLink } from '@/wiki'; import { debug, endGroup, info, startGroup } from '@actions/core'; @@ -205,7 +205,7 @@ export async function getPullRequestCommits(): Promise { export async function addReleasePlanComment( terraformChangedModules: TerraformChangedModule[], terraformModuleNamesToRemove: string[], - wikiStatus: { status: WikiStatus; errorMessage?: string }, + wikiStatus: ReleasePlanCommentOptions, ): Promise { console.time('Elapsed time commenting on pull request'); startGroup('Adding pull request release plan comment'); diff --git a/src/releases.ts b/src/releases.ts index 198af3b..e8b5c72 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -1,6 +1,7 @@ import { execFileSync } from 'node:child_process'; import type { ExecSyncOptions } from 'node:child_process'; -import { cpSync, mkdirSync } from 'node:fs'; +import { cpSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { getModuleChangelog } from '@/changelog'; import { config } from '@/config'; @@ -115,14 +116,13 @@ export async function createTaggedRelease( try { for (const module of terraformChangedModules) { const { moduleName, directory, releaseType, nextTag, nextTagVersion } = module; - const tmpDir = join(process.env.RUNNER_TEMP ?? '', 'tmp', moduleName); info(`Release type: ${releaseType}`); info(`Next tag version: ${nextTag}`); // Create a temporary working directory - mkdirSync(tmpDir, { recursive: true }); - info(`Creating temp directory: ${tmpDir}`); + const tmpDir = mkdtempSync(join(tmpdir(), moduleName)); + info(`Created temp directory: ${tmpDir}`); // Copy the module's contents to the temporary directory, excluding specified patterns copyModuleContents(directory, tmpDir, config.moduleAssetExcludePatterns); diff --git a/src/terraform-docs.ts b/src/terraform-docs.ts index a4df4aa..d0f1efe 100644 --- a/src/terraform-docs.ts +++ b/src/terraform-docs.ts @@ -145,7 +145,7 @@ export function installTerraformDocs(terraformDocsVersion: string): void { startGroup(`Installing terraform-docs ${terraformDocsVersion}`); const cwd = process.cwd(); - let tempDir = null; + let tmpDir = null; try { validateTerraformDocsVersion(terraformDocsVersion); @@ -156,8 +156,8 @@ export function installTerraformDocs(terraformDocsVersion: string): void { // Create a temp directory to handle the extraction so this doesn't clobber our // current working directory. - tempDir = mkdtempSync(join(tmpdir(), 'terraform-docs-')); - process.chdir(tempDir); + tmpDir = mkdtempSync(join(tmpdir(), 'terraform-docs-')); + process.chdir(tmpDir); if (goPlatform === 'windows') { const powershellPath = which.sync('powershell'); @@ -206,9 +206,9 @@ export function installTerraformDocs(terraformDocsVersion: string): void { execFileSync('/usr/local/bin/terraform-docs', ['--version'], { stdio: 'inherit' }); } } finally { - if (tempDir !== null) { - info(`Removing temp dir ${tempDir}`); - rmSync(tempDir, { recursive: true }); + if (tmpDir !== null) { + info(`Removing temp dir ${tmpDir}`); + rmSync(tmpDir, { recursive: true }); } process.chdir(cwd); console.timeEnd('Elapsed time installing terraform-docs'); diff --git a/src/types/index.ts b/src/types/index.ts index eb64c0a..2a3ce1d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,4 @@ +import type { WikiStatus } from '@/wiki'; import type { PaginateInterface } from '@octokit/plugin-paginate-rest'; import type { Api } from '@octokit/plugin-rest-endpoint-methods'; @@ -290,3 +291,8 @@ export interface ExecSyncError extends Error { */ error: Error; } + +export interface ReleasePlanCommentOptions { + status: WikiStatus; + errorMessage?: string; +}