diff --git a/README.md b/README.md index 8e99e497..613aa1aa 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,18 @@ npm run build Then, commit action.yml and dist/index.js to your repository. +### Run unit test +First install [jest](https://jestjs.io/) testing framework. +``` +npm install --save-dev jest +npm install @actions/core +npm install @actions/github +``` +Launch unit tests. +``` +npm test +``` + ## Features ### Release Notes Extraction Process diff --git a/__tests__/data/rls_notes_empty_with_all_chapters.md b/__tests__/data/rls_notes_empty_with_all_chapters.md new file mode 100644 index 00000000..a825d4c7 --- /dev/null +++ b/__tests__/data/rls_notes_empty_with_all_chapters.md @@ -0,0 +1,29 @@ +### Breaking Changes 💥 +No entries detected. + +### New Features 🎉 +No entries detected. + +### Bugfixes 🛠 +No entries detected. + +### Closed Issues without Pull Request ⚠️ +All closed issues linked to a Pull Request. + +### Closed Issues without User Defined Labels ⚠️ +All closed issues contain at least one of user defined labels. + +### Closed Issues without Release Notes ⚠️ +All closed issues have release notes. + +### Merged PRs without Linked Issue ⚠️ +All merged PRs are linked to issues. + +### Merged PRs Linked to Open Issue ⚠️ +All merged PRs are linked to Closed issues. + +### Closed PRs without Linked Issue ⚠️ +All closed PRs are linked to issues. + +#### Full Changelog +https://github.com/owner/repo/commits/v0.1.1 \ No newline at end of file diff --git a/__tests__/data/rls_notes_empty_with_hidden_empty_chapters.md b/__tests__/data/rls_notes_empty_with_hidden_empty_chapters.md new file mode 100644 index 00000000..26cc0408 --- /dev/null +++ b/__tests__/data/rls_notes_empty_with_hidden_empty_chapters.md @@ -0,0 +1,2 @@ +#### Full Changelog +https://github.com/owner/repo/commits/v0.1.1 \ No newline at end of file diff --git a/__tests__/data/rls_notes_empty_with_hidden_warning_chapters.md b/__tests__/data/rls_notes_empty_with_hidden_warning_chapters.md new file mode 100644 index 00000000..2f6af4d3 --- /dev/null +++ b/__tests__/data/rls_notes_empty_with_hidden_warning_chapters.md @@ -0,0 +1,11 @@ +### Breaking Changes 💥 +No entries detected. + +### New Features 🎉 +No entries detected. + +### Bugfixes 🛠 +No entries detected. + +#### Full Changelog +https://github.com/owner/repo/commits/v0.1.1 \ No newline at end of file diff --git a/__tests__/data/rls_notes_empty_with_no_custom_chapters.md b/__tests__/data/rls_notes_empty_with_no_custom_chapters.md new file mode 100644 index 00000000..1e974ca7 --- /dev/null +++ b/__tests__/data/rls_notes_empty_with_no_custom_chapters.md @@ -0,0 +1,20 @@ +### Closed Issues without Pull Request ⚠️ +All closed issues linked to a Pull Request. + +### Closed Issues without User Defined Labels ⚠️ +All closed issues contain at least one of user defined labels. + +### Closed Issues without Release Notes ⚠️ +All closed issues have release notes. + +### Merged PRs without Linked Issue ⚠️ +All merged PRs are linked to issues. + +### Merged PRs Linked to Open Issue ⚠️ +All merged PRs are linked to Closed issues. + +### Closed PRs without Linked Issue ⚠️ +All closed PRs are linked to issues. + +#### Full Changelog +https://github.com/owner/repo/commits/v0.1.1 \ No newline at end of file diff --git a/__tests__/data/rls_notes_fully_populated_custom_skip_label.md b/__tests__/data/rls_notes_fully_populated_custom_skip_label.md new file mode 100644 index 00000000..1fdf12d4 --- /dev/null +++ b/__tests__/data/rls_notes_fully_populated_custom_skip_label.md @@ -0,0 +1,54 @@ +### Breaking Changes 💥 +- #8 _Issue title 8 - with co-author|private mail_ implemented by @johnDoe, Jane Doe in [#8](link-to-pr-8) + - note about change in Issue 8 +- #7 _Issue title 7 - with co-author|public mail_ implemented by @janeDoe, @johnDoe in [#7](link-to-pr-7) + - note about change in Issue 7 + + +### New Features 🎉 +- #6 _Issue title 6 - not assigned|no PR_ implemented by "Missing Assignee or Contributor" + - note about change in Issue 6 +- #4 _Issue title 4 - assigned|one PR_ implemented by @johnDoe in [#4](link-to-pr-4) + - note about change in Issue 4 +- #2 _Issue title 2_ implemented by @johnDoe in [#2](link-to-pr-2) + - note about change in Issue 2 + + +### Bugfixes 🛠 +- #1 _Issue title 1_ implemented by @janeDoe in [#1](link-to-pr-1) + - note about change in Issue 1 + + +### Closed Issues without Pull Request ⚠️ +- #10 _Issue title 10 - skip label_ implemented by @janeDoe +- #6 _Issue title 6 - not assigned|no PR_ implemented by "Missing Assignee or Contributor" + - note about change in Issue 6 + + +### Closed Issues without User Defined Labels ⚠️ +- #10 _Issue title 10 - skip label_ implemented by @janeDoe +- #9 _Issue title 9 - no user defined label_ implemented by @janeDoe in [#9](link-to-pr-9) + - note about change in Issue 9 +- #3 _Issue title 3 - no release note comment|typo label_ implemented by @janeDoe in [#3](link-to-pr-3) + + +### Closed Issues without Release Notes ⚠️ +- #10 _Issue title 10 - skip label_ implemented by @janeDoe +- #3 _Issue title 3 - no release note comment|typo label_ implemented by @janeDoe in [#3](link-to-pr-3) + + +### Merged PRs without Linked Issue ⚠️ +#1004 _Pull Request 4 - no linked issue - merged_ +#1006 _Pull Request 6 - skip label_ + + +### Merged PRs Linked to Open Issue ⚠️ +#1003 _Pull Request 3 - linked to open issue_ + + +### Closed PRs without Linked Issue ⚠️ +#1002 _Pull Request 2 - no linked issue - closed_ + + +#### Full Changelog +https://github.com/owner/repo/compare/v0.1.0...v0.1.1 \ No newline at end of file diff --git a/__tests__/data/rls_notes_fully_populated_first_release.md b/__tests__/data/rls_notes_fully_populated_first_release.md new file mode 100644 index 00000000..08f98324 --- /dev/null +++ b/__tests__/data/rls_notes_fully_populated_first_release.md @@ -0,0 +1,53 @@ +### Breaking Changes 💥 +- #8 _Issue title 8 - with co-author|private mail_ implemented by @johnDoe, Jane Doe in [#8](link-to-pr-8) + - note about change in Issue 8 +- #7 _Issue title 7 - with co-author|public mail_ implemented by @janeDoe, @johnDoe in [#7](link-to-pr-7) + - note about change in Issue 7 + + +### New Features 🎉 +- #6 _Issue title 6 - not assigned|no PR_ implemented by "Missing Assignee or Contributor" + - note about change in Issue 6 +- #5 _Issue title 5 - not assigned|three PRs_ implemented by "Missing Assignee or Contributor" in [#5](link-to-pr-5), [#15](link-to-pr-15), [#16](link-to-pr-16) + - note about change in Issue 5 +- #4 _Issue title 4 - assigned|one PR_ implemented by @johnDoe in [#4](link-to-pr-4) + - note about change in Issue 4 +- #2 _Issue title 2_ implemented by @johnDoe in [#2](link-to-pr-2) + - note about change in Issue 2 + + +### Bugfixes 🛠 +- #1 _Issue title 1_ implemented by @janeDoe in [#1](link-to-pr-1) + - note about change in Issue 1 + + +### Closed Issues without Pull Request ⚠️ +- #6 _Issue title 6 - not assigned|no PR_ implemented by "Missing Assignee or Contributor" + - note about change in Issue 6 + + +### Closed Issues without User Defined Labels ⚠️ +- #9 _Issue title 9 - no user defined label_ implemented by @janeDoe in [#9](link-to-pr-9) + - note about change in Issue 9 +- #3 _Issue title 3 - no release note comment|typo label_ implemented by @janeDoe in [#3](link-to-pr-3) + + +### Closed Issues without Release Notes ⚠️ +- #3 _Issue title 3 - no release note comment|typo label_ implemented by @janeDoe in [#3](link-to-pr-3) + + +### Merged PRs without Linked Issue ⚠️ +#1001 _Pull Request 1_ +#1004 _Pull Request 4 - no linked issue - merged_ + + +### Merged PRs Linked to Open Issue ⚠️ +#1003 _Pull Request 3 - linked to open issue_ + + +### Closed PRs without Linked Issue ⚠️ +#1002 _Pull Request 2 - no linked issue - closed_ + + +#### Full Changelog +https://github.com/owner/repo-no-rls/commits/v0.1.1 \ No newline at end of file diff --git a/__tests__/data/rls_notes_fully_populated_hide_warning_chapters.md b/__tests__/data/rls_notes_fully_populated_hide_warning_chapters.md new file mode 100644 index 00000000..3203969b --- /dev/null +++ b/__tests__/data/rls_notes_fully_populated_hide_warning_chapters.md @@ -0,0 +1,25 @@ +### Breaking Changes 💥 +- #8 _Issue title 8 - with co-author|private mail_ implemented by @johnDoe, Jane Doe in [#8](link-to-pr-8) + - note about change in Issue 8 +- #7 _Issue title 7 - with co-author|public mail_ implemented by @janeDoe, @johnDoe in [#7](link-to-pr-7) + - note about change in Issue 7 + + +### New Features 🎉 +- #6 _Issue title 6 - not assigned|no PR_ implemented by "Missing Assignee or Contributor" + - note about change in Issue 6 +- #5 _Issue title 5 - not assigned|three PRs_ implemented by "Missing Assignee or Contributor" in [#5](link-to-pr-5), [#15](link-to-pr-15), [#16](link-to-pr-16) + - note about change in Issue 5 +- #4 _Issue title 4 - assigned|one PR_ implemented by @johnDoe in [#4](link-to-pr-4) + - note about change in Issue 4 +- #2 _Issue title 2_ implemented by @johnDoe in [#2](link-to-pr-2) + - note about change in Issue 2 + + +### Bugfixes 🛠 +- #1 _Issue title 1_ implemented by @janeDoe in [#1](link-to-pr-1) + - note about change in Issue 1 + + +#### Full Changelog +https://github.com/owner/repo/compare/v0.1.0...v0.1.1 \ No newline at end of file diff --git a/__tests__/data/rls_notes_fully_populated_in_default.md b/__tests__/data/rls_notes_fully_populated_in_default.md new file mode 100644 index 00000000..2b5d9dae --- /dev/null +++ b/__tests__/data/rls_notes_fully_populated_in_default.md @@ -0,0 +1,53 @@ +### Breaking Changes 💥 +- #8 _Issue title 8 - with co-author|private mail_ implemented by @johnDoe, Jane Doe in [#8](link-to-pr-8) + - note about change in Issue 8 +- #7 _Issue title 7 - with co-author|public mail_ implemented by @janeDoe, @johnDoe in [#7](link-to-pr-7) + - note about change in Issue 7 + + +### New Features 🎉 +- #6 _Issue title 6 - not assigned|no PR_ implemented by "Missing Assignee or Contributor" + - note about change in Issue 6 +- #5 _Issue title 5 - not assigned|three PRs_ implemented by "Missing Assignee or Contributor" in [#5](link-to-pr-5), [#15](link-to-pr-15), [#16](link-to-pr-16) + - note about change in Issue 5 +- #4 _Issue title 4 - assigned|one PR_ implemented by @johnDoe in [#4](link-to-pr-4) + - note about change in Issue 4 +- #2 _Issue title 2_ implemented by @johnDoe in [#2](link-to-pr-2) + - note about change in Issue 2 + + +### Bugfixes 🛠 +- #1 _Issue title 1_ implemented by @janeDoe in [#1](link-to-pr-1) + - note about change in Issue 1 + + +### Closed Issues without Pull Request ⚠️ +- #6 _Issue title 6 - not assigned|no PR_ implemented by "Missing Assignee or Contributor" + - note about change in Issue 6 + + +### Closed Issues without User Defined Labels ⚠️ +- #9 _Issue title 9 - no user defined label_ implemented by @janeDoe in [#9](link-to-pr-9) + - note about change in Issue 9 +- #3 _Issue title 3 - no release note comment|typo label_ implemented by @janeDoe in [#3](link-to-pr-3) + + +### Closed Issues without Release Notes ⚠️ +- #3 _Issue title 3 - no release note comment|typo label_ implemented by @janeDoe in [#3](link-to-pr-3) + + +### Merged PRs without Linked Issue ⚠️ +#1001 _Pull Request 1_ +#1004 _Pull Request 4 - no linked issue - merged_ + + +### Merged PRs Linked to Open Issue ⚠️ +#1003 _Pull Request 3 - linked to open issue_ + + +### Closed PRs without Linked Issue ⚠️ +#1002 _Pull Request 2 - no linked issue - closed_ + + +#### Full Changelog +https://github.com/owner/repo/compare/v0.1.0...v0.1.1 \ No newline at end of file diff --git a/__tests__/data/rls_notes_fully_populated_no_custom_chapters.md b/__tests__/data/rls_notes_fully_populated_no_custom_chapters.md new file mode 100644 index 00000000..7b47c6ba --- /dev/null +++ b/__tests__/data/rls_notes_fully_populated_no_custom_chapters.md @@ -0,0 +1,44 @@ +### Closed Issues without Pull Request ⚠️ +- #6 _Issue title 6 - not assigned|no PR_ implemented by "Missing Assignee or Contributor" + - note about change in Issue 6 + + +### Closed Issues without User Defined Labels ⚠️ +- #9 _Issue title 9 - no user defined label_ implemented by @janeDoe in [#9](link-to-pr-9) + - note about change in Issue 9 +- #8 _Issue title 8 - with co-author|private mail_ implemented by @johnDoe, Jane Doe in [#8](link-to-pr-8) + - note about change in Issue 8 +- #7 _Issue title 7 - with co-author|public mail_ implemented by @janeDoe, @johnDoe in [#7](link-to-pr-7) + - note about change in Issue 7 +- #6 _Issue title 6 - not assigned|no PR_ implemented by "Missing Assignee or Contributor" + - note about change in Issue 6 +- #5 _Issue title 5 - not assigned|three PRs_ implemented by "Missing Assignee or Contributor" in [#5](link-to-pr-5), [#15](link-to-pr-15), [#16](link-to-pr-16) + - note about change in Issue 5 +- #4 _Issue title 4 - assigned|one PR_ implemented by @johnDoe in [#4](link-to-pr-4) + - note about change in Issue 4 +- #3 _Issue title 3 - no release note comment|typo label_ implemented by @janeDoe in [#3](link-to-pr-3) +- #2 _Issue title 2_ implemented by @johnDoe in [#2](link-to-pr-2) + - note about change in Issue 2 +- #1 _Issue title 1_ implemented by @janeDoe in [#1](link-to-pr-1) + - note about change in Issue 1 + + +### Closed Issues without Release Notes ⚠️ +- #3 _Issue title 3 - no release note comment|typo label_ implemented by @janeDoe in [#3](link-to-pr-3) + + +### Merged PRs without Linked Issue ⚠️ +#1001 _Pull Request 1_ +#1004 _Pull Request 4 - no linked issue - merged_ + + +### Merged PRs Linked to Open Issue ⚠️ +#1003 _Pull Request 3 - linked to open issue_ + + +### Closed PRs without Linked Issue ⚠️ +#1002 _Pull Request 2 - no linked issue - closed_ + + +#### Full Changelog +https://github.com/owner/repo/compare/v0.1.0...v0.1.1 \ No newline at end of file diff --git a/__tests__/data/rls_notes_fully_populated_second_release.md b/__tests__/data/rls_notes_fully_populated_second_release.md new file mode 100644 index 00000000..3f81bc25 --- /dev/null +++ b/__tests__/data/rls_notes_fully_populated_second_release.md @@ -0,0 +1,29 @@ +### Breaking Changes 💥 +No entries detected. + +### New Features 🎉 +No entries detected. + +### Bugfixes 🛠 +No entries detected. + +### Closed Issues without Pull Request ⚠️ +All closed issues linked to a Pull Request. + +### Closed Issues without User Defined Labels ⚠️ +All closed issues contain at least one of user defined labels. + +### Closed Issues without Release Notes ⚠️ +All closed issues have release notes. + +### Merged PRs without Linked Issue ⚠️ +All merged PRs are linked to issues. + +### Merged PRs Linked to Open Issue ⚠️ +All merged PRs are linked to Closed issues. + +### Closed PRs without Linked Issue ⚠️ +All closed PRs are linked to issues. + +#### Full Changelog +https://github.com/owner/repo-2nd-rls/compare/v0.1.0...v0.1.1 \ No newline at end of file diff --git a/__tests__/generate-release-notes.test.js b/__tests__/generate-release-notes.test.js new file mode 100644 index 00000000..6ff7920f --- /dev/null +++ b/__tests__/generate-release-notes.test.js @@ -0,0 +1,319 @@ +const { Octokit } = require("@octokit/rest"); +const core = require('@actions/core'); +const github = require('@actions/github'); +const { run } = require('./../scripts/generate-release-notes'); +const fs = require('fs'); +const path = require('path'); +const octokitMocks = require('./mocks/octokit.mocks'); +const coreMocks = require('./mocks/core.mocks'); +const {mockFullPerfectData} = require("./mocks/octokit.mocks"); + +jest.mock('@octokit/rest'); +jest.mock('@actions/core'); +jest.mock('@actions/github'); + +describe('run', () => { + beforeEach(() => { + // Reset environment variables and mocks before each test + process.env.GITHUB_TOKEN = 'fake-token'; + process.env.GITHUB_REPOSITORY = 'owner/repo'; + + jest.resetAllMocks(); + + github.context.repo = { owner: 'owner', repo: 'repo' }; + }); + + /* + Check if the action fails if the required environment variables are missing. + */ + it('should fail if GITHUB_TOKEN is missing', async () => { + console.log('Test started: should fail if GITHUB_TOKEN is missing'); + delete process.env.GITHUB_TOKEN; + + await run(); + + // Check if core.setFailed was called with the expected message + expect(core.setFailed).toHaveBeenCalledWith("GitHub token is missing."); + + // Check if core.getInput was called exactly once + expect(core.getInput).toHaveBeenCalledTimes(1); + expect(core.getInput).toHaveBeenCalledWith('tag-name'); + }); + + it('should fail if GITHUB_REPOSITORY is missing', async () => { + console.log('Test started: should fail if GITHUB_REPOSITORY is missing'); + delete process.env.GITHUB_REPOSITORY; + + await run(); + + // Check if core.setFailed was called with the expected message + expect(core.setFailed).toHaveBeenCalledWith("GITHUB_REPOSITORY environment variable is missing."); + + // Check if core.getInput was called exactly once + expect(core.getInput).toHaveBeenCalledTimes(1); + expect(core.getInput).toHaveBeenCalledWith('tag-name'); + }); + + it('should fail if GITHUB_REPOSITORY is not in the correct format', async () => { + console.log('Test started: should fail if GITHUB_REPOSITORY is not in the correct format'); + process.env.GITHUB_REPOSITORY = 'owner-repo'; + + await run(); + + // Check if core.setFailed was called with the expected message + expect(core.setFailed).toHaveBeenCalledWith("GITHUB_REPOSITORY environment variable is not in the correct format 'owner/repo'."); + + // Check if core.getInput was called exactly once + expect(core.getInput).toHaveBeenCalledTimes(1); + expect(core.getInput).toHaveBeenCalledWith('tag-name'); + }); + + it('should fail if tag-name input is missing', async () => { + console.log('Test started: should fail if tag-name input is missing'); + + // Mock core.getInput to return null for 'tag-name' - here return null only as 'tag-name' is the only required + core.getInput.mockImplementation((name) => { + return null; + }); + + await run(); + + // Check if core.setFailed was called with the expected message + expect(core.setFailed).toHaveBeenCalledWith("Tag name is missing."); + + // Check if core.getInput was called exactly once + expect(core.getInput).toHaveBeenCalledTimes(1); + expect(core.getInput).toHaveBeenCalledWith('tag-name'); + }); + + /* + Happy path tests - default values. + */ + it('should run successfully with valid inputs - required defaults only', async () => { + console.log('Test started: should run successfully with valid inputs - required defaults only'); + + // Mock core.getInput to return null for all except 'tag-name' + core.getInput.mockImplementation((name) => { + switch (name) { + case 'tag-name': + return 'v0.1.1'; + default: + return null; + } + }); + Octokit.mockImplementation(octokitMocks.mockEmptyData); + + await run(); + + expect(core.setFailed).not.toHaveBeenCalled(); + + // Get the arguments of the first call to setOutput + const firstCallArgs = core.setOutput.mock.calls[0]; + expect(firstCallArgs[0]).toBe('releaseNotes'); + + const filePath = path.join(__dirname, 'data', 'rls_notes_empty_with_no_custom_chapters.md'); + let expectedOutput = fs.readFileSync(filePath, 'utf8'); + expect(firstCallArgs[1]).toBe(expectedOutput); + }); + + it('should run successfully with valid inputs - all defined', async () => { + console.log('Test started: should run successfully with valid inputs - all defined'); + + core.getInput.mockImplementation((name) => { + return coreMocks.fullDefaultInputs(name); + }); + Octokit.mockImplementation(octokitMocks.mockFullPerfectData); + + await run(); + + expect(core.setFailed).not.toHaveBeenCalled(); + + // Get the arguments of the first call to setOutput + const firstCallArgs = core.setOutput.mock.calls[0]; + expect(firstCallArgs[0]).toBe('releaseNotes'); + + const filePath = path.join(__dirname, 'data', 'rls_notes_fully_populated_in_default.md'); + let expectedOutput = fs.readFileSync(filePath, 'utf8'); + expect(firstCallArgs[1]).toBe(expectedOutput); + }); + + /* + Happy path tests - non default options. + */ + it('should run successfully with valid inputs - hide warning chapters', async () => { + console.log('Test started: should run successfully with valid inputs - hide warning chapters'); + + core.getInput.mockImplementation((name) => { + return coreMocks.fullAndHideWarningChaptersInputs(name); + }); + Octokit.mockImplementation(octokitMocks.mockFullPerfectData); + + await run(); + + expect(core.setFailed).not.toHaveBeenCalled(); + + // Get the arguments of the first call to setOutput + const firstCallArgs = core.setOutput.mock.calls[0]; + expect(firstCallArgs[0]).toBe('releaseNotes'); + + const filePath = path.join(__dirname, 'data', 'rls_notes_fully_populated_hide_warning_chapters.md'); + let expectedOutput = fs.readFileSync(filePath, 'utf8'); + expect(firstCallArgs[1]).toBe(expectedOutput); + }); + + it('should run successfully with valid inputs - use custom skip label', async () => { + console.log('Test started: should run successfully with valid inputs - use custom skip label'); + + core.getInput.mockImplementation((name) => { + return coreMocks.fullAndCustomSkipLabel(name); + }); + Octokit.mockImplementation(octokitMocks.mockFullPerfectData); + + await run(); + + expect(core.setFailed).not.toHaveBeenCalled(); + + // Get the arguments of the first call to setOutput + const firstCallArgs = core.setOutput.mock.calls[0]; + expect(firstCallArgs[0]).toBe('releaseNotes'); + + const filePath = path.join(__dirname, 'data', 'rls_notes_fully_populated_custom_skip_label.md'); + let expectedOutput = fs.readFileSync(filePath, 'utf8'); + expect(firstCallArgs[1]).toBe(expectedOutput); + }); + + it('should run successfully with valid inputs - no chapters defined', async () => { + console.log('Test started: should run successfully with valid inputs - no chapters defined'); + + core.getInput.mockImplementation((name) => { + return coreMocks.fullDefaultInputsNoCustomChapters(name); + }); + Octokit.mockImplementation(octokitMocks.mockFullPerfectData); + + await run(); + + expect(core.setFailed).not.toHaveBeenCalled(); + + // Get the arguments of the first call to setOutput + const firstCallArgs = core.setOutput.mock.calls[0]; + expect(firstCallArgs[0]).toBe('releaseNotes'); + + const filePath = path.join(__dirname, 'data', 'rls_notes_fully_populated_no_custom_chapters.md'); + let expectedOutput = fs.readFileSync(filePath, 'utf8'); + expect(firstCallArgs[1]).toBe(expectedOutput); + }); + + it('should run successfully with valid inputs - first release', async () => { + console.log('Test started: should run successfully with valid inputs - first release'); + + github.context.repo = { owner: 'owner', repo: 'repo-no-rls' }; + core.getInput.mockImplementation((name) => { + return coreMocks.fullDefaultInputs(name); + }); + Octokit.mockImplementation(octokitMocks.mockFullPerfectData); + + await run(); + + expect(core.setFailed).not.toHaveBeenCalled(); + + // Get the arguments of the first call to setOutput + const firstCallArgs = core.setOutput.mock.calls[0]; + expect(firstCallArgs[0]).toBe('releaseNotes'); + + const filePath = path.join(__dirname, 'data', 'rls_notes_fully_populated_first_release.md'); + let expectedOutput = fs.readFileSync(filePath, 'utf8'); + expect(firstCallArgs[1]).toBe(expectedOutput); + }); + + it('should run successfully with valid inputs - second release', async () => { + console.log('Test started: should run successfully with valid inputs - second release no changes'); + + github.context.repo = { owner: 'owner', repo: 'repo-2nd-rls' }; + core.getInput.mockImplementation((name) => { + return coreMocks.fullDefaultInputs(name); + }); + Octokit.mockImplementation(octokitMocks.mockPerfectDataWithoutIssues); + + await run(); + + expect(core.setFailed).not.toHaveBeenCalled(); + + // Get the arguments of the first call to setOutput + const firstCallArgs = core.setOutput.mock.calls[0]; + expect(firstCallArgs[0]).toBe('releaseNotes'); + + const filePath = path.join(__dirname, 'data', 'rls_notes_fully_populated_second_release.md'); + let expectedOutput = fs.readFileSync(filePath, 'utf8'); + expect(firstCallArgs[1]).toBe(expectedOutput); + }); + + /* + Happy path tests - no option related + */ + it('should run successfully with valid inputs - no data available', async () => { + console.log('Test started: should run successfully with valid inputs - no data available'); + + core.getInput.mockImplementation((name) => { + return coreMocks.fullDefaultInputs(name); + }); + Octokit.mockImplementation(octokitMocks.mockEmptyData); + + await run(); + + expect(core.setFailed).not.toHaveBeenCalled(); + + // Get the arguments of the first call to setOutput + const firstCallArgs = core.setOutput.mock.calls[0]; + expect(firstCallArgs[0]).toBe('releaseNotes'); + + const filePath = path.join(__dirname, 'data', 'rls_notes_empty_with_all_chapters.md'); + let expectedOutput = fs.readFileSync(filePath, 'utf8'); + + expect(firstCallArgs[1]).toBe(expectedOutput); + }); + + it('should run successfully with valid inputs - no data available - hide empty chapters', async () => { + console.log('Test started: should run successfully with valid inputs - no data available - hide empty chapters'); + + // Define empty data + core.getInput.mockImplementation((name) => { + return coreMocks.fullAndHideEmptyChaptersInputs(name); + }); + Octokit.mockImplementation(octokitMocks.mockEmptyData); + + await run(); + + expect(core.setFailed).not.toHaveBeenCalled(); + + // Get the arguments of the first call to setOutput + const firstCallArgs = core.setOutput.mock.calls[0]; + expect(firstCallArgs[0]).toBe('releaseNotes'); + + const filePath = path.join(__dirname, 'data', 'rls_notes_empty_with_hidden_empty_chapters.md'); + let expectedOutput = fs.readFileSync(filePath, 'utf8'); + + expect(firstCallArgs[1]).toBe(expectedOutput); + }); + + it('should run successfully with valid inputs - no data available - hide warning chapters', async () => { + console.log('Test started: should run successfully with valid inputs - no data available - hide warning chapters'); + + core.getInput.mockImplementation((name) => { + return coreMocks.fullAndHideWarningChaptersInputs(name); + }); + Octokit.mockImplementation(octokitMocks.mockEmptyData); + + await run(); + + expect(core.setFailed).not.toHaveBeenCalled(); + + // Get the arguments of the first call to setOutput + const firstCallArgs = core.setOutput.mock.calls[0]; + expect(firstCallArgs[0]).toBe('releaseNotes'); + + const filePath = path.join(__dirname, 'data', 'rls_notes_empty_with_hidden_warning_chapters.md'); + let expectedOutput = fs.readFileSync(filePath, 'utf8'); + + expect(firstCallArgs[1]).toBe(expectedOutput); + }); +}); diff --git a/__tests__/mocks/core.mocks.js b/__tests__/mocks/core.mocks.js new file mode 100644 index 00000000..65820521 --- /dev/null +++ b/__tests__/mocks/core.mocks.js @@ -0,0 +1,121 @@ +const fullDefaultInputs = (name) => { + switch (name) { + case 'tag-name': + return 'v0.1.1'; + case 'chapters': + return JSON.stringify([ + {"title": "Breaking Changes 💥", "label": "breaking-change"}, + {"title": "New Features 🎉", "label": "enhancement"}, + {"title": "New Features 🎉", "label": "feature"}, + {"title": "Bugfixes 🛠", "label": "bug"} + ]); + case 'warnings': + return 'true'; + case 'published-at': + return 'false'; + case 'skip-release-notes-label': + return null; + case 'print-empty-chapters': + return 'true'; + default: + return null; + } +}; + +const fullAndHideEmptyChaptersInputs = (name) => { + switch (name) { + case 'tag-name': + return 'v0.1.1'; + case 'chapters': + return JSON.stringify([ + {"title": "Breaking Changes 💥", "label": "breaking-change"}, + {"title": "New Features 🎉", "label": "enhancement"}, + {"title": "New Features 🎉", "label": "feature"}, + {"title": "Bugfixes 🛠", "label": "bug"} + ]); + case 'warnings': + return 'true'; + case 'published-at': + return 'false'; + case 'skip-release-notes-label': + return null; + case 'print-empty-chapters': + return 'false'; + default: + return null; + } +}; + +const fullAndCustomSkipLabel = (name) => { + switch (name) { + case 'tag-name': + return 'v0.1.1'; + case 'chapters': + return JSON.stringify([ + {"title": "Breaking Changes 💥", "label": "breaking-change"}, + {"title": "New Features 🎉", "label": "enhancement"}, + {"title": "New Features 🎉", "label": "feature"}, + {"title": "Bugfixes 🛠", "label": "bug"} + ]); + case 'warnings': + return 'true'; + case 'published-at': + return 'false'; + case 'skip-release-notes-label': + return 'user-custom-label'; + case 'print-empty-chapters': + return 'true'; + default: + return null; + } +}; + +const fullAndHideWarningChaptersInputs = (name) => { + switch (name) { + case 'tag-name': + return 'v0.1.1'; + case 'chapters': + return JSON.stringify([ + {"title": "Breaking Changes 💥", "label": "breaking-change"}, + {"title": "New Features 🎉", "label": "enhancement"}, + {"title": "New Features 🎉", "label": "feature"}, + {"title": "Bugfixes 🛠", "label": "bug"} + ]); + case 'warnings': + return 'false'; + case 'published-at': + return 'false'; + case 'skip-release-notes-label': + return null; + case 'print-empty-chapters': + return 'true'; + default: + return null; + } +}; + +const fullDefaultInputsNoCustomChapters = (name) => { + switch (name) { + case 'tag-name': + return 'v0.1.1'; + case 'warnings': + return 'true'; + case 'published-at': + return 'false'; + case 'skip-release-notes-label': + return null; + case 'print-empty-chapters': + return 'true'; + default: + return null; + } +}; + + +module.exports = { + fullDefaultInputs, + fullAndHideEmptyChaptersInputs, + fullAndHideWarningChaptersInputs, + fullDefaultInputsNoCustomChapters, + fullAndCustomSkipLabel, +}; \ No newline at end of file diff --git a/__tests__/mocks/octokit.mocks.js b/__tests__/mocks/octokit.mocks.js new file mode 100644 index 00000000..495e1df4 --- /dev/null +++ b/__tests__/mocks/octokit.mocks.js @@ -0,0 +1,926 @@ +const mockEmptyData = () => ({ + rest: { + repos: { + getLatestRelease: jest.fn(() => { + throw { + status: 404, + message: "Not Found" + }; + }), + }, + issues: { + listForRepo: jest.fn().mockResolvedValue({ + data: [], + }), + listEventsForTimeline: jest.fn().mockResolvedValue({ + data: [], + }), + listComments: jest.fn().mockResolvedValue({ + data: [], + }), + }, + pulls: { + list: jest.fn().mockResolvedValue({ + data: [], + }), + listCommits: jest.fn().mockResolvedValue({ + data: [], + }), + get: jest.fn().mockResolvedValue({ + data: [], + }), + }, + } +}); + +const mockFullPerfectData = () => ({ + rest: { + repos: { + getLatestRelease: jest.fn(({owner, repo}) => { + if (repo === "repo-no-rls") { + throw { + status: 404, + message: "Not Found" + }; + } else if (repo === "repo-2nd-rls") { + return Promise.resolve({ + data: { + tag_name: 'v0.1.0', + published_at: '2023-12-15T09:58:30.000Z', + created_at: '2023-12-15T06:56:30.000Z', + } + }); + } else { + return Promise.resolve({ + data: { + tag_name: 'v0.1.0', + published_at: '2022-12-12T09:58:30.000Z', + created_at: '2022-12-12T06:56:30.000Z', + } + }); + } + }), + }, + issues: { + listForRepo: jest.fn().mockResolvedValue({ + data: [ + { + number: 1, + title: 'Issue title 1', + state: 'closed', + labels: [{ name: 'bug' }], + assignees: [ + { + login: "janeDoe", + }, + ], + "closed_at": "2023-12-12T11:58:30.000Z", + "created_at": "2023-12-12T09:58:30.000Z", + "updated_at": "2023-12-12T10:58:30.000Z", + }, + { + number: 2, + title: 'Issue title 2', + state: 'open', + labels: [{ name: 'enhancement' }], + assignees: [ + { + login: "johnDoe", + }, + ], + "closed_at": null, + "created_at": "2023-12-12T11:58:30.000Z", + "updated_at": "2023-12-12T12:58:30.000Z", + }, + { + number: 3, + title: 'Issue title 3 - no release note comment|typo label', + state: 'closed', + labels: [{ name: 'enhacement' }], + assignees: [ + { + login: "janeDoe", + }, + ], + "closed_at": "2023-12-12T15:58:30.000Z", + "created_at": "2023-12-12T13:58:30.000Z", + "updated_at": "2023-12-12T14:58:30.000Z", + }, + { + number: 4, + title: 'Issue title 4 - assigned|one PR', + state: 'closed', + labels: [{ name: 'feature' }], + assignees: [ + { + login: "johnDoe", + }, + ], + "closed_at": "2023-12-12T16:58:30.000Z", + "created_at": "2023-12-12T14:58:30.000Z", + "updated_at": "2023-12-12T15:58:30.000Z", + }, + { + number: 5, + title: 'Issue title 5 - not assigned|three PRs', + state: 'closed', + labels: [{ name: 'feature' }, { name: 'user-custom-label' }], + assignees: [], + "closed_at": "2023-12-12T18:58:30.000Z", + "created_at": "2023-12-12T16:58:30.000Z", + "updated_at": "2023-12-12T17:58:30.000Z", + }, + { + number: 6, + title: 'Issue title 6 - not assigned|no PR', + state: 'closed', + labels: [{ name: 'feature' }], + assignees: [], + "closed_at": "2023-12-12T20:58:30.000Z", + "created_at": "2023-12-12T18:58:30.000Z", + "updated_at": "2023-12-12T19:58:30.000Z", + }, + { + number: 7, + title: 'Issue title 7 - with co-author|public mail', + state: 'closed', + labels: [{ name: 'breaking-change' }], + assignees: [ + { + login: "janeDoe", + }, + ], + "closed_at": "2023-12-12T22:58:30.000Z", + "created_at": "2023-12-12T20:58:30.000Z", + "updated_at": "2023-12-12T21:58:30.000Z", + }, + { + number: 8, + title: 'Issue title 8 - with co-author|private mail', + state: 'closed', + labels: [{ name: 'breaking-change' }], + assignees: [ + { + login: "johnDoe", + }, + ], + "closed_at": "2023-12-13T07:58:30.000Z", + "created_at": "2023-12-13T05:58:30.000Z", + "updated_at": "2023-12-13T06:58:30.000Z", + }, + { + number: 9, + title: 'Issue title 9 - no user defined label', + state: 'closed', + labels: [{ name: 'no-user-defined' }], + assignees: [ + { + login: "janeDoe", + }, + ], + "closed_at": "2023-12-13T09:58:30.000Z", + "created_at": "2023-12-13T07:58:30.000Z", + "updated_at": "2023-12-13T08:58:30.000Z", + }, + { + number: 10, + title: 'Issue title 10 - skip label', + state: 'closed', + labels: [{ name: 'skip-release-notes' }], + assignees: [ + { + login: "janeDoe", + }, + ], + "closed_at": "2023-12-13T11:58:30.000Z", + "created_at": "2023-12-13T09:58:30.000Z", + "updated_at": "2023-12-13T10:58:30.000Z", + }, + ], + }), + listEventsForTimeline: jest.fn(({owner, repo, issue_number}) => { + if (issue_number === 1) { + return Promise.resolve({ + data: [ + { + id: 1, + event: 'cross-referenced', + labels: [], + source: { + issue: { + pull_request: "link-to-pr-1", + number: 1, + html_url: "link-to-pr-1", + }, + }, + }, + ], + }); + } else if (issue_number === 2) { + return Promise.resolve({ + data: [ + { + id: 2, + event: 'cross-referenced', + labels: [], + source: { + issue: { + pull_request: "link-to-pr-2", + number: 2, + html_url: "link-to-pr-2", + }, + }, + }, + ], + }); + } else if (issue_number === 3) { + return Promise.resolve({ + data: [ + { + id: 3, + event: 'cross-referenced', + labels: [], + source: { + issue: { + pull_request: "link-to-pr-3", + number: 3, + html_url: "link-to-pr-3", + }, + }, + }, + ], + }); + } else if (issue_number === 4) { + return Promise.resolve({ + data: [ + { + id: 4, + event: 'cross-referenced', + labels: [], + source: { + issue: { + pull_request: "link-to-pr-4", + number: 4, + html_url: "link-to-pr-4", + }, + }, + }, + ], + }); + } else if (issue_number === 5) { + return Promise.resolve({ + data: [ + { + id: 5, + event: 'cross-referenced', + labels: [], + source: { + issue: { + pull_request: "link-to-pr-5", + number: 5, + html_url: "link-to-pr-5", + }, + }, + }, + { + id: 15, + event: 'cross-referenced', + labels: [], + source: { + issue: { + pull_request: "link-to-pr-15", + number: 15, + html_url: "link-to-pr-15", + }, + }, + }, + { + id: 16, + event: 'cross-referenced', + labels: [], + source: { + issue: { + pull_request: "link-to-pr-16", + number: 16, + html_url: "link-to-pr-16", + }, + }, + }, + ], + }); + } else if (issue_number === 6) { + return Promise.resolve({ + data: [], + }); + } else if (issue_number === 7) { + return Promise.resolve({ + data: [ + { + id: 7, + event: 'cross-referenced', + labels: [], + source: { + issue: { + pull_request: "link-to-pr-7", + number: 7, + html_url: "link-to-pr-7", + }, + }, + }, + ], + }); + } else if (issue_number === 8) { + return Promise.resolve({ + data: [ + { + id: 8, + event: 'cross-referenced', + labels: [], + source: { + issue: { + pull_request: "link-to-pr-8", + number: 8, + html_url: "link-to-pr-8", + }, + }, + }, + ], + }); + } else if (issue_number === 9) { + return Promise.resolve({ + data: [ + { + id: 9, + event: 'cross-referenced', + labels: [], + source: { + issue: { + pull_request: "link-to-pr-9", + number: 9, + html_url: "link-to-pr-9", + }, + }, + }, + ], + }); + } else if (issue_number === 100) { + return Promise.resolve({ + data: [ + { + id: 1, + event: 'labeled', + labels: [ + { + name: 'todo', + } + ], + source: { + issue: { + pull_request: "link-to-issue-X" + }, + }, + }, + { + id: 2, + event: 'cross-referenced', + labels: [ + { + name: 'todo', + } + ], + source: { + issue: { + pull_request: "link-to-issue-Y" + }, + }, + }, + ], + }); + } else { + // default universal return value + return Promise.resolve({ + data: [], + }); + } + }), + listComments: jest.fn(({owner, repo, issue_number}) => { + if (issue_number === 1) { + return Promise.resolve({ + data: [ + { + id: 101, + user: {login: 'user 1'}, + body: 'This is the first comment in Issue 1', + created_at: '2023-01-01T10:00:00Z', + updated_at: '2023-01-01T10:00:00Z' + }, + { + id: 102, + user: {login: 'user2'}, + body: 'Release notes:\n- note about change in Issue 1', + created_at: '2023-01-02T11:00:00Z', + updated_at: '2023-01-02T11:00:00Z' + }, + ] + }); + } else if (issue_number === 2) { + return Promise.resolve({ + data: [ + { + id: 201, + user: {login: 'user 1'}, + body: 'This is the first comment in Issue 2.', + created_at: '2023-01-01T10:00:00Z', + updated_at: '2023-01-01T10:00:00Z' + }, + { + id: 202, + user: {login: 'user2'}, + body: 'Release notes:\n- note about change in Issue 2', + created_at: '2023-01-02T11:00:00Z', + updated_at: '2023-01-02T11:00:00Z' + }, + ] + }); + } else if (issue_number === 4) { + return Promise.resolve({ + data: [ + { + id: 401, + user: {login: 'user 1'}, + body: 'This is the first comment in Issue 4.', + created_at: '2023-01-01T10:00:00Z', + updated_at: '2023-01-01T10:00:00Z' + }, + { + id: 402, + user: {login: 'user2'}, + body: 'Release notes:\n- note about change in Issue 4', + created_at: '2023-01-02T11:00:00Z', + updated_at: '2023-01-02T11:00:00Z' + }, + ] + }); + } else if (issue_number === 5) { + return Promise.resolve({ + data: [ + { + id: 501, + user: {login: 'user 1'}, + body: 'This is the first comment in Issue 5.', + created_at: '2023-01-01T10:00:00Z', + updated_at: '2023-01-01T10:00:00Z' + }, + { + id: 502, + user: {login: 'user2'}, + body: 'Release notes:\n- note about change in Issue 5', + created_at: '2023-01-02T11:00:00Z', + updated_at: '2023-01-02T11:00:00Z' + }, + ] + }); + } else if (issue_number === 6) { + return Promise.resolve({ + data: [ + { + id: 601, + user: {login: 'user 1'}, + body: 'This is the first comment in Issue 6.', + created_at: '2023-01-01T10:00:00Z', + updated_at: '2023-01-01T10:00:00Z' + }, + { + id: 602, + user: {login: 'user2'}, + body: 'Release notes:\n- note about change in Issue 6', + created_at: '2023-01-02T11:00:00Z', + updated_at: '2023-01-02T11:00:00Z' + }, + ] + }); + } else if (issue_number === 7) { + return Promise.resolve({ + data: [ + { + id: 701, + user: {login: 'user 1'}, + body: 'This is the first comment in Issue 7.', + created_at: '2023-01-01T10:00:00Z', + updated_at: '2023-01-01T10:00:00Z' + }, + { + id: 702, + user: {login: 'user2'}, + body: 'Release notes:\n- note about change in Issue 7', + created_at: '2023-01-02T11:00:00Z', + updated_at: '2023-01-02T11:00:00Z' + }, + ] + }); + } else if (issue_number === 8) { + return Promise.resolve({ + data: [ + { + id: 801, + user: {login: 'user 1'}, + body: 'This is the first comment in Issue 8.', + created_at: '2023-01-01T10:00:00Z', + updated_at: '2023-01-01T10:00:00Z' + }, + { + id: 802, + user: {login: 'user2'}, + body: 'Release notes:\n- note about change in Issue 8', + created_at: '2023-01-02T11:00:00Z', + updated_at: '2023-01-02T11:00:00Z' + }, + ] + }); + } else if (issue_number === 9) { + return Promise.resolve({ + data: [ + { + id: 801, + user: {login: 'user 1'}, + body: 'This is the first comment in Issue 9.', + created_at: '2023-01-01T10:00:00Z', + updated_at: '2023-01-01T10:00:00Z' + }, + { + id: 802, + user: {login: 'user2'}, + body: 'Release notes\n- note about change in Issue 9', + created_at: '2023-01-02T11:00:00Z', + updated_at: '2023-01-02T11:00:00Z' + }, + ] + }); + } else { + return Promise.resolve({ + data: [], + }); + } + }), + get: jest.fn(({owner, repo, issue_number}) => { + console.log(`Called 'get' with issue_number: ${issue_number}`); + + if (issue_number === 2) { + return Promise.resolve({ + data: { + state: 'open', + }, + }); + } else { + return Promise.resolve({ + data: {}, + }); + } + }), + }, + pulls: { + // ready only for process with existing previous release + list: jest.fn(({owner, repo, state, sort, direction, since}) => { + return Promise.resolve({ + data: [ + { + number: 1001, + title: 'Pull Request 1', + state: 'merged', + labels: [{ name: 'user-custom-label' }], + created_at: '2023-12-12T15:56:30.000Z', + merged_at: '2023-12-12T15:58:30.000Z', + }, + { + number: 1002, + title: 'Pull Request 2 - no linked issue - closed', + state: 'closed', + labels: [], + created_at: '2023-12-12T15:57:30.000Z', + closed_at: '2023-12-12T15:58:30.000Z', + }, + { + number: 1003, + title: 'Pull Request 3 - linked to open issue', + state: 'merged', + labels: [], + created_at: '2023-12-12T15:58:30.000Z', + merged_at: '2023-12-12T15:59:30.000Z', + }, + { + number: 1004, + title: 'Pull Request 4 - no linked issue - merged', + state: 'merged', + labels: [], + created_at: '2023-12-12T15:59:30.000Z', + merged_at: '2023-12-12T16:59:30.000Z', + }, + { + number: 1005, + title: 'Pull Request 5', + state: 'open', + labels: [], + created_at: '2023-12-12T15:59:35.000Z', + }, + { + number: 1006, + title: 'Pull Request 6 - skip label', + state: 'merged', + labels: [{ name: 'skip-release-notes' }], + created_at: '2023-12-12T15:59:37.000Z', + merged_at: '2023-12-12T17:59:37.000Z', + }, + ], + }); + }), + listCommits: jest.fn(({owner, repo, pull_number}) => { + if (pull_number === 7) { + return Promise.resolve({ + data: [ + { + commit: { + author: { + name: 'Jane Doe', + email: 'jane.doe@example.com', + }, + message: 'Initial commit\n\nCo-authored-by: John Doe ' + }, + author: { + login: 'janeDoe', + }, + url: 'https://api.github.com/repos/owner/repo/commits/abc123' + }, + ] + }); + } else if (pull_number === 8) { + return Promise.resolve({ + data: [ + { + commit: { + author: { + name: 'John Doe', + email: 'john.doe@example.com', + }, + message: 'Initial commit\n\nCo-authored-by: Jane Doe ' + }, + author: { + login: 'johnDoe', + }, + url: 'https://api.github.com/repos/owner/repo/commits/abc124' + }, + ] + }); + } else { + return Promise.resolve({ + data: [], + }); + } + }), + get: jest.fn(({owner, repo, pull_number}) => { + if (pull_number === 1003) { + return Promise.resolve({ + data: { + body: 'This is a detailed description of the pull request.\n\nCloses #2', + }, + }); + } else { + return Promise.resolve({ + data: [], + }); + } + }), + }, + search: { + users: jest.fn(({q}) => { + if (q === "john.doe@example.com in:email") { + return Promise.resolve({ + data: { + total_count: 1, + items: [ + { + id: 1, + login: "johnDoe" + } + ] + } + }); + } else if (q === "jane.doe@example.com in:email") { + return Promise.resolve({ + data: { + total_count: 1, + items: [ + { + id: 2 + } + ] + }, + }) + } else { + return Promise.resolve({ + data: [], + }) + } + }), + }, + } +}); +const mockPerfectDataWithoutIssues = () => ({ + rest: { + repos: { + getLatestRelease: jest.fn(({owner, repo}) => { + if (repo === "repo-no-rls") { + throw { + status: 404, + message: "Not Found" + }; + } else if (repo === "repo-2nd-rls") { + return Promise.resolve({ + data: { + tag_name: 'v0.1.0', + published_at: '2023-12-15T09:58:30.000Z', + created_at: '2023-12-15T06:56:30.000Z', + } + }); + } else { + return Promise.resolve({ + data: { + tag_name: 'v0.1.0', + published_at: '2022-12-12T09:58:30.000Z', + created_at: '2022-12-12T06:56:30.000Z', + } + }); + } + }), + }, + issues: { + listForRepo: jest.fn().mockResolvedValue({ + data: [] + }), + listEventsForTimeline: jest.fn(({owner, repo, issue_number}) => { + return Promise.resolve({ + data: [], + }); + }), + listComments: jest.fn(({owner, repo, issue_number}) => { + return Promise.resolve({ + data: [], + }); + }), + get: jest.fn(({owner, repo, issue_number}) => { + return Promise.resolve({ + data: {}, + }); + }), + }, + pulls: { + // ready only for process with existing previous release + list: jest.fn(({owner, repo, state, sort, direction, since}) => { + return Promise.resolve({ + data: [ + { + number: 1001, + title: 'Pull Request 1', + state: 'merged', + labels: [{ name: 'user-custom-label' }], + created_at: '2023-12-12T15:56:30.000Z', + merged_at: '2023-12-12T15:58:30.000Z', + }, + { + number: 1002, + title: 'Pull Request 2 - no linked issue - closed', + state: 'closed', + labels: [], + created_at: '2023-12-12T15:57:30.000Z', + closed_at: '2023-12-12T15:58:30.000Z', + }, + { + number: 1003, + title: 'Pull Request 3 - linked to open issue', + state: 'merged', + labels: [], + created_at: '2023-12-12T15:58:30.000Z', + merged_at: '2023-12-12T15:59:30.000Z', + }, + { + number: 1004, + title: 'Pull Request 4 - no linked issue - merged', + state: 'merged', + labels: [], + created_at: '2023-12-12T15:59:30.000Z', + merged_at: '2023-12-12T16:59:30.000Z', + }, + { + number: 1005, + title: 'Pull Request 5', + state: 'open', + labels: [], + created_at: '2023-12-12T15:59:35.000Z', + }, + { + number: 1006, + title: 'Pull Request 6 - skip label', + state: 'merged', + labels: [{ name: 'skip-release-notes' }], + created_at: '2023-12-12T15:59:37.000Z', + merged_at: '2023-12-12T17:59:37.000Z', + }, + ], + }); + }), + listCommits: jest.fn(({owner, repo, pull_number}) => { + if (pull_number === 7) { + return Promise.resolve({ + data: [ + { + commit: { + author: { + name: 'Jane Doe', + email: 'jane.doe@example.com', + }, + message: 'Initial commit\n\nCo-authored-by: John Doe ' + }, + author: { + login: 'janeDoe', + }, + url: 'https://api.github.com/repos/owner/repo/commits/abc123' + }, + ] + }); + } else if (pull_number === 8) { + return Promise.resolve({ + data: [ + { + commit: { + author: { + name: 'John Doe', + email: 'john.doe@example.com', + }, + message: 'Initial commit\n\nCo-authored-by: Jane Doe ' + }, + author: { + login: 'johnDoe', + }, + url: 'https://api.github.com/repos/owner/repo/commits/abc124' + }, + ] + }); + } else { + return Promise.resolve({ + data: [], + }); + } + }), + get: jest.fn(({owner, repo, pull_number}) => { + if (pull_number === 1003) { + return Promise.resolve({ + data: { + body: 'This is a detailed description of the pull request.\n\nCloses #2', + }, + }); + } else { + return Promise.resolve({ + data: [], + }); + } + }), + }, + search: { + users: jest.fn(({q}) => { + if (q === "john.doe@example.com in:email") { + return Promise.resolve({ + data: { + total_count: 1, + items: [ + { + id: 1, + login: "johnDoe" + } + ] + } + }); + } else if (q === "jane.doe@example.com in:email") { + return Promise.resolve({ + data: { + total_count: 1, + items: [ + { + id: 2 + } + ] + }, + }) + } else { + return Promise.resolve({ + data: [], + }) + } + }), + }, + } +}); + +module.exports = { + mockEmptyData, + mockFullPerfectData, + mockPerfectDataWithoutIssues, +}; \ No newline at end of file diff --git a/action.yml b/action.yml index 45d69240..1d23ed01 100644 --- a/action.yml +++ b/action.yml @@ -6,11 +6,11 @@ inputs: required: true chapters: description: 'JSON string defining chapters and corresponding labels for categorization.' - required: true + required: false warnings: description: 'Print warning chapters if true.' required: false - default: 'false' + default: 'true' published-at: description: 'Use `published-at` timestamp instead of `created-at` as the reference point.' required: false diff --git a/dist/index.js b/dist/index.js index f3d3b94a..04429970 100644 --- a/dist/index.js +++ b/dist/index.js @@ -29017,360 +29017,949 @@ function wrappy (fn, cb) { /***/ }), -/***/ 4978: -/***/ ((module) => { +/***/ 192: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { -module.exports = eval("require")("util/types"); +const { Octokit } = __nccwpck_require__(5375); +const core = __nccwpck_require__(2186); +const github = __nccwpck_require__(5438); +/** + * Fetches the latest release information for a given repository. + * @param {Octokit} octokit - The Octokit instance. + * @param {string} owner - The owner of the repository. + * @param {string} repo - The name of the repository. + * @returns {Promise} The latest release data. + */ +async function fetchLatestRelease(octokit, owner, repo) { + console.log(`Starting to fetch the latest release for ${owner}/${repo}`); -/***/ }), + try { + const release = await octokit.rest.repos.getLatestRelease({owner, repo}); + console.log(`Found latest release for ${owner}/${repo}: ${release.data.tag_name}, created at: ${release.data.created_at}, published at: ${release.data.published_at}`); + return release.data; + } catch (error) { + console.warn(`Fetching latest release for ${owner}/${repo}: ${error.status} - ${error.message}`); + return null; + } +} -/***/ 9491: -/***/ ((module) => { +/** + * Retrieves related pull requests for a specific issue. + * @param {Octokit} octokit - The Octokit instance. + * @param {number} issueNumber - The issue number. + * @param {string} repoOwner - The owner of the repository. + * @param {string} repoName - The name of the repository. + * @returns {Promise} An array of related pull requests. + */ +async function getRelatedPRsForIssue(octokit, issueNumber, repoOwner, repoName) { + console.log(`Fetching related PRs for issue #${issueNumber}`); + const relatedPRs = await octokit.rest.issues.listEventsForTimeline({owner: repoOwner, repo: repoName, issue_number: issueNumber}); -"use strict"; -module.exports = require("assert"); + // Filter events to get only those that are linked pull requests + const pullRequestEvents = relatedPRs.data.filter(event => event.event === 'cross-referenced' && event.source && event.source.issue.pull_request); + if (pullRequestEvents) { + console.log(`Found ${pullRequestEvents.length} related PRs for issue #${issueNumber}`); + } else { + console.log(`Found 0 related PRs for issue #${issueNumber}`); + } + return pullRequestEvents; +} -/***/ }), +/** + * Fetches contributors for an issue. + * @param {Array} issueAssignees - List of assignees for the issue. + * @param {Array} commitAuthors - List of authors of commits. + * @returns {Set} A set of contributors' usernames. + */ +async function getIssueContributors(issueAssignees, commitAuthors) { + // Map the issueAssignees to the required format + const assignees = issueAssignees.map(assignee => '@' + assignee.login); -/***/ 852: -/***/ ((module) => { + // Combine the assignees and commit authors + const combined = [...assignees, ...commitAuthors]; -"use strict"; -module.exports = require("async_hooks"); + // Check if the combined array is empty + if (combined && combined.length === 0) { + return new Set(["\"Missing Assignee or Contributor\""]); + } -/***/ }), + // If not empty, return the Set of combined values + return new Set(combined); +} -/***/ 4300: -/***/ ((module) => { +/** + * Retrieves authors of commits from pull requests related to an issue. + * @param {Octokit} octokit - The Octokit instance. + * @param {string} repoOwner - The owner of the repository. + * @param {string} repoName - The name of the repository. + * @param {Array} relatedPRs - Related pull requests. + * @returns {Set} A set of commit authors' usernames. + */ +async function getPRCommitAuthors(octokit, repoOwner, repoName, relatedPRs) { + let commitAuthors = new Set(); + for (const event of relatedPRs) { + const prNumber = event.source.issue.number; + const commits = await octokit.rest.pulls.listCommits({ + owner: repoOwner, + repo: repoName, + pull_number: prNumber + }); -"use strict"; -module.exports = require("buffer"); + for (const commit of commits.data) { + commitAuthors.add('@' + commit.author.login); -/***/ }), + const coAuthorMatches = commit.commit.message.match(/Co-authored-by: (.+ <.+>)/gm); + if (coAuthorMatches) { + for (const coAuthorLine of coAuthorMatches) { + const emailRegex = /<([^>]+)>/; + const nameRegex = /Co-authored-by: (.+) { + console.log(`Searching for GitHub user with email: ${email}`); -"use strict"; -module.exports = require("console"); + const searchResult = await octokit.rest.search.users({ + q: `${email} in:email` + }); -/***/ }), + const user = searchResult.data.items[0]; + if (user && user.login) { + commitAuthors.add('@' + user.login); + } else { + console.log(`No public GitHub account found for email: ${email}`); + commitAuthors.add(name); + } + } + } + } + } + } -/***/ 6113: -/***/ ((module) => { + return commitAuthors; +} -"use strict"; -module.exports = require("crypto"); +/** + * Fetches comments for a specific issue. + * @param {Octokit} octokit - The Octokit instance. + * @param {number} issueNumber - The issue number. + * @param {string} repoOwner - The owner of the repository. + * @param {string} repoName - The name of the repository. + * @returns {Promise} An array of issue comments. + */ +async function getIssueComments(octokit, issueNumber, repoOwner, repoName) { + return await octokit.rest.issues.listComments({owner: repoOwner, repo: repoName, issue_number: issueNumber}); +} -/***/ }), +/** + * Generates release notes from issue comments. + * @param {Octokit} octokit - The Octokit instance. + * @param {number} issueNumber - The issue number. + * @param {string} issueTitle - The title of the issue. + * @param {Array} issueAssignees - List of assignees for the issue. + * @param {string} repoOwner - The owner of the repository. + * @param {string} repoName - The name of the repository. + * @param {Array} relatedPRs - Related pull requests. + * @param {string} relatedPRLinksString - String of related PR links. + * @returns {Promise} The formatted release note for the issue. + */ +async function getReleaseNotesFromComments(octokit, issueNumber, issueTitle, issueAssignees, repoOwner, repoName, relatedPRs, relatedPRLinksString) { + console.log(`Fetching release notes from comments for issue #${issueNumber}`); + const comments = await getIssueComments(octokit, issueNumber, repoOwner, repoName); + let commitAuthors = await getPRCommitAuthors(octokit, repoOwner, repoName, relatedPRs); + let contributors = await getIssueContributors(issueAssignees, commitAuthors); -/***/ 7643: -/***/ ((module) => { + let releaseNotes = []; + for (const comment of comments.data) { + if (comment.body.toLowerCase().startsWith('release notes')) { + const noteContent = comment.body.replace(/^release notes:?.*(\r\n|\n|\r)?/i, '').trim(); + console.log(`Found release notes in comments for issue #${issueNumber}`); + releaseNotes.push(noteContent.replace(/^\s*[\r\n]/gm, '').replace(/^/gm, ' ')); + } + } -"use strict"; -module.exports = require("diagnostics_channel"); + if (releaseNotes.length === 0) { + console.log(`No specific release notes found in comments for issue #${issueNumber}`); + const contributorsList = Array.from(contributors).join(', '); + if (relatedPRs.length === 0) { + return `- x#${issueNumber} _${issueTitle}_ implemented by ${contributorsList}\n`; + } else { + return `- x#${issueNumber} _${issueTitle}_ implemented by ${contributorsList} in ${relatedPRLinksString}\n`; + } + } else { + const contributorsList = Array.from(contributors).join(', '); + const notes = releaseNotes.join('\n'); + if (relatedPRs.length === 0) { + return `- #${issueNumber} _${issueTitle}_ implemented by ${contributorsList}\n${notes}\n`; + } else { + return `- #${issueNumber} _${issueTitle}_ implemented by ${contributorsList} in ${relatedPRLinksString}\n${notes}\n`; + } + } +} -/***/ }), +/** + * Checks if a pull request is linked to an issue. + * @param {Octokit} octokit - The Octokit instance. + * @param {number} prNumber - The pull request number. + * @param {string} repoOwner - The owner of the repository. + * @param {string} repoName - The name of the repository. + * @returns {Promise} True if the pull request is linked to an issue, false otherwise. + */ +async function isPrLinkedToIssue(octokit, prNumber, repoOwner, repoName) { + // Get the pull request details + const pr = await octokit.rest.pulls.get({ + owner: repoOwner, + repo: repoName, + pull_number: prNumber + }); -/***/ 2361: -/***/ ((module) => { + // Regex pattern to find references to issues + const regexPattern = /([Cc]los(e|es|ed)|[Ff]ix(es|ed)?|[Rr]esolv(e|es|ed))\s*#\s*([0-9]+)/g; -"use strict"; -module.exports = require("events"); + // Test if the PR body contains any issue references + return regexPattern.test(pr.data.body); +} -/***/ }), -/***/ 7147: -/***/ ((module) => { +/** + * Checks if a pull request is linked to any open issues. + * @param {Octokit} octokit - The Octokit instance. + * @param {number} prNumber - The pull request number. + * @param {string} repoOwner - The owner of the repository. + * @param {string} repoName - The name of the repository. + * @returns {Promise} True if the pull request is linked to any open issue, false otherwise. + */ +async function isPrLinkedToOpenIssue(octokit, prNumber, repoOwner, repoName) { + // Get the pull request details + const pr = await octokit.rest.pulls.get({ + owner: repoOwner, + repo: repoName, + pull_number: prNumber + }); -"use strict"; -module.exports = require("fs"); + // Regex pattern to find references to issues + const regexPattern = /([Cc]los(e|es|ed)|[Ff]ix(es|ed)?|[Rr]esolv(e|es|ed))\s*#\s*([0-9]+)/g; -/***/ }), + // Extract all issue numbers from the PR body + const issueMatches = pr.data.body.match(regexPattern); + if (!issueMatches) { + return false; // No issue references found in PR body + } -/***/ 3685: -/***/ ((module) => { + // Check each linked issue + for (const match of issueMatches) { + const issueNumber = +match.match(/#([0-9]+)/)[1]; -"use strict"; -module.exports = require("http"); + // Get the issue details + const issue = await octokit.rest.issues.get({ + owner: repoOwner, + repo: repoName, + issue_number: issueNumber + }); -/***/ }), - -/***/ 5158: -/***/ ((module) => { - -"use strict"; -module.exports = require("http2"); - -/***/ }), - -/***/ 5687: -/***/ ((module) => { - -"use strict"; -module.exports = require("https"); + // If any of the issues is open, return true + if (issue.data.state === 'open') { + return true; + } + } -/***/ }), + // If none of the issues are open, return false + return false; +} -/***/ 1808: -/***/ ((module) => { -"use strict"; -module.exports = require("net"); +/** + * Parses the JSON string of chapters into a map. + * @param {string} chaptersJson - The JSON string of chapters. + * @returns {Map} A map where each key is a chapter title and the value is an array of corresponding labels. + */ +function parseChaptersJson(chaptersJson) { + try { + const chaptersArray = JSON.parse(chaptersJson); + const titlesToLabelsMap = new Map(); + chaptersArray.forEach(chapter => { + if (titlesToLabelsMap.has(chapter.title)) { + titlesToLabelsMap.get(chapter.title).push(chapter.label); + } else { + titlesToLabelsMap.set(chapter.title, [chapter.label]); + } + }); + return titlesToLabelsMap; + } catch (error) { + core.setFailed(`Error parsing chapters JSON: ${error.message}`) + } +} -/***/ }), +/** + * Fetches a list of closed issues since the latest release. + * @param {Octokit} octokit - The Octokit instance. + * @param {string} repoOwner - The owner of the repository. + * @param {string} repoName - The name of the repository. + * @param {Object} latestRelease - The latest release object. + * @param {boolean} usePublishedAt - Flag to use created-at or published-at time point. + * @param {string} skipLabel - The label to skip issues. + * @returns {Promise} An array of closed issues since the latest release. + */ +async function fetchClosedIssues(octokit, repoOwner, repoName, latestRelease, usePublishedAt, skipLabel) { + let since; + if (latestRelease) { + if (usePublishedAt) { + since = new Date(latestRelease.published_at) + console.log(`Fetching closed issues since ${since.toISOString()} - published-at.`); + } else { + since = new Date(latestRelease.created_at) + console.log(`Fetching closed issues since ${since.toISOString()} - created-at.`); + } + } else { + const firstClosedIssue = await octokit.rest.issues.listForRepo({ + owner: repoOwner, + repo: repoName, + state: 'closed', + per_page: 1, + sort: 'created', + direction: 'asc' + }); -/***/ 5673: -/***/ ((module) => { + if (firstClosedIssue && firstClosedIssue.data.length > 0) { + since = new Date(firstClosedIssue.data[0].created_at); + console.log(`Fetching closed issues since the first closed issue on ${since.toISOString()}`); + } else { + console.log("No closed issues found."); + return []; + } + } -"use strict"; -module.exports = require("node:events"); + const closedIssues = await octokit.rest.issues.listForRepo({ + owner: repoOwner, + repo: repoName, + state: 'closed', + since: since + }); -/***/ }), + return closedIssues.data + .filter(issue => !issue.pull_request) // Filter out pull requests + .filter(issue => !issue.labels.some(label => label.name === skipLabel)) // Filter out issues with skip label + .reverse(); +} -/***/ 4492: -/***/ ((module) => { +/** + * Fetches a list of closed pull requests since the latest release. + * @param {Octokit} octokit - The Octokit instance. + * @param {string} repoOwner - The owner of the repository. + * @param {string} repoName - The name of the repository. + * @param {Object} latestRelease - The latest release object. + * @param {boolean} usePublishedAt - Flag to use created-at or published-at time point. + * @param {string} skipLabel - The label to skip issues. + * @param {string} prState - The state of the pull request. + * @returns {Promise} An array of closed pull requests since the latest release. + */ +async function fetchPullRequests(octokit, repoOwner, repoName, latestRelease, usePublishedAt, skipLabel, prState = 'merged') { + console.log(`Fetching ${prState} pull requests for ${repoOwner}/${repoName}`); -"use strict"; -module.exports = require("node:stream"); + let pullRequests; + let since; + let response; -/***/ }), + if (latestRelease) { + if (usePublishedAt) { + since = new Date(latestRelease.published_at); + console.log(`Since latest release date: ${since.toISOString()} - published-at.`); + } else { + since = new Date(latestRelease.created_at); + console.log(`Since latest release date: ${since.toISOString()} - created-at.`); + } + } -/***/ 7261: -/***/ ((module) => { + response = await octokit.rest.pulls.list({ + owner: repoOwner, + repo: repoName, + state: 'all', + sort: 'updated', + direction: 'desc' + }); -"use strict"; -module.exports = require("node:util"); + pullRequests = response.data; -/***/ }), + if (latestRelease) { + pullRequests = pullRequests.filter(pr => { + const prCreatedAt = new Date(pr.created_at); + return prCreatedAt > since; + }); + } else { + console.log('No latest release found. Fetching all pull requests of repository.'); + } -/***/ 2037: -/***/ ((module) => { + console.log(`Found ${pullRequests.length} pull requests for ${repoOwner}/${repoName}`) -"use strict"; -module.exports = require("os"); + // Filter based on prState + if (prState === 'merged') { + pullRequests = pullRequests.filter(pr => pr.merged_at); + } else if (prState === 'closed') { + pullRequests = pullRequests.filter(pr => !pr.merged_at && pr.state === 'closed'); + } -/***/ }), + // Filter out pull requests with the specified skipLabel + console.log(`Filtering out pull requests with label: ${skipLabel}`) + pullRequests = pullRequests.filter(pr => !pr.labels.some(label => label.name === skipLabel)); -/***/ 1017: -/***/ ((module) => { + return pullRequests; +} -"use strict"; -module.exports = require("path"); +async function run() { + console.log('Starting GitHub Action'); + const githubToken = process.env.GITHUB_TOKEN; + const tagName = core.getInput('tag-name'); + const githubRepository = process.env.GITHUB_REPOSITORY; -/***/ }), + // Validate GitHub token + if (!githubToken) { + core.setFailed("GitHub token is missing."); + return; + } -/***/ 4074: -/***/ ((module) => { + // Validate GitHub repository environment variable + if (!githubRepository) { + core.setFailed("GITHUB_REPOSITORY environment variable is missing."); + return; + } -"use strict"; -module.exports = require("perf_hooks"); + // Extract owner and repo from GITHUB_REPOSITORY + const [owner, repo] = githubRepository.split('/'); + if (!owner || !repo) { + core.setFailed("GITHUB_REPOSITORY environment variable is not in the correct format 'owner/repo'."); + return; + } -/***/ }), + // Validate tag name + if (!tagName) { + core.setFailed("Tag name is missing."); + return; + } -/***/ 3477: -/***/ ((module) => { + const repoOwner = github.context.repo.owner; + const repoName = github.context.repo.repo; + const chaptersJson = core.getInput('chapters') || "[]"; + const warnings = core.getInput('warnings') ? core.getInput('warnings').toLowerCase() === 'true' : true; + const skipLabel = core.getInput('skip-release-notes-label') || 'skip-release-notes'; + const usePublishedAt = core.getInput('published-at') ? core.getInput('published-at').toLowerCase() === 'true' : false; + const printEmptyChapters = core.getInput('print-empty-chapters') ? core.getInput('print-empty-chapters').toLowerCase() === 'true' : true; -"use strict"; -module.exports = require("querystring"); + const octokit = new Octokit({ auth: githubToken }); -/***/ }), + try { + const latestRelease = await fetchLatestRelease(octokit, repoOwner, repoName); -/***/ 2781: -/***/ ((module) => { + // Fetch closed issues since the latest release + const closedIssuesOnlyIssues = await fetchClosedIssues(octokit, repoOwner, repoName, latestRelease, usePublishedAt, skipLabel); + if (closedIssuesOnlyIssues) { + console.log(`Found ${closedIssuesOnlyIssues.length} closed issues (only Issues) since last release`); + } else { + console.log(`Found 0 closed issues (only Issues) since last release`); + } -"use strict"; -module.exports = require("stream"); + // Initialize variables for each chapter + const titlesToLabelsMap = parseChaptersJson(chaptersJson); + const chapterContents = new Map(Array.from(titlesToLabelsMap.keys()).map(label => [label, ''])); + let closedIssuesWithoutReleaseNotes = '', closedIssuesWithoutUserLabels = '', closedIssuesWithoutPR = '', mergedPRsWithoutLinkedIssue = ''; + let mergedPRsLinkedToOpenIssue = '', closedPRsLinkedToIssue = ''; -/***/ }), + // Categorize issues and PRs + for (const issue of closedIssuesOnlyIssues) { + let relatedPRs = await getRelatedPRsForIssue(octokit, issue.number, repoOwner, repoName); + console.log(`Related PRs for issue #${issue.number}: ${relatedPRs.map(event => event.id).join(', ')}`); + let prLinks = relatedPRs + .map(event => `[#${event.source.issue.number}](${event.source.issue.html_url})`) + .join(', '); + console.log(`Related PRs for issue #${issue.number}: ${prLinks}`); -/***/ 5356: -/***/ ((module) => { + const releaseNotesRaw = await getReleaseNotesFromComments(octokit, issue.number, issue.title, issue.assignees, repoOwner, repoName, relatedPRs, prLinks); + const releaseNotes = releaseNotesRaw.replace(/^- x#/, '- #'); -"use strict"; -module.exports = require("stream/web"); + // Check for issues without release notes + if (warnings && releaseNotesRaw.startsWith('- x#')) { + closedIssuesWithoutReleaseNotes += releaseNotes; + } -/***/ }), + let foundUserLabels = false; + titlesToLabelsMap.forEach((labels, title) => { + if (labels.some(label => issue.labels.map(l => l.name).includes(label))) { + chapterContents.set(title, chapterContents.get(title) + releaseNotes); + foundUserLabels = true; + } + }); -/***/ 1576: -/***/ ((module) => { + // Check for issues without user defined labels + if (!foundUserLabels && warnings) { + closedIssuesWithoutUserLabels += releaseNotes; + } -"use strict"; -module.exports = require("string_decoder"); + // Check for issues without PR + if (!relatedPRs.length && warnings) { + closedIssuesWithoutPR += releaseNotes; + } + } -/***/ }), + // Check PRs for linked issues + if (warnings) { + // Fetch merged pull requests since the latest release + const mergedPRsSinceLastRelease = await fetchPullRequests(octokit, repoOwner, repoName, latestRelease, usePublishedAt, skipLabel); + if (mergedPRsSinceLastRelease) { + console.log(`Found ${mergedPRsSinceLastRelease.length} merged PRs since last release`); + const sortedMergedPRs = mergedPRsSinceLastRelease.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); -/***/ 4404: + for (const pr of sortedMergedPRs) { + if (!await isPrLinkedToIssue(octokit, pr.number, repoOwner, repoName)) { + mergedPRsWithoutLinkedIssue += `#${pr.number} _${pr.title}_\n`; + } else { + if (await isPrLinkedToOpenIssue(octokit, pr.number, repoOwner, repoName)) { + mergedPRsLinkedToOpenIssue += `#${pr.number} _${pr.title}_\n`; + } + } + } + } else { + console.log(`Found 0 merged PRs since last release`); + } + + // Fetch closed pull requests since the latest release + const closedPRsSinceLastRelease = await fetchPullRequests(octokit, repoOwner, repoName, latestRelease, usePublishedAt, skipLabel, 'closed'); + if (closedPRsSinceLastRelease) { + console.log(`Found ${closedPRsSinceLastRelease.length} closed PRs since last release`); + const sortedClosedPRs = closedPRsSinceLastRelease.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); + + for (const pr of sortedClosedPRs) { + if (!await isPrLinkedToIssue(octokit, pr.number, repoOwner, repoName)) { + closedPRsLinkedToIssue += `#${pr.number} _${pr.title}_\n`; + } + } + } else { + console.log(`Found 0 closed PRs since last release`); + } + } + + let changelogUrl; + if (latestRelease) { + // If there is a latest release, create a URL pointing to commits since the latest release + changelogUrl = `https://github.com/${repoOwner}/${repoName}/compare/${latestRelease.tag_name}...${tagName}`; + console.log('Changelog URL (since latest release):', changelogUrl); + } else { + // If there is no latest release, create a URL pointing to all commits + changelogUrl = `https://github.com/${repoOwner}/${repoName}/commits/${tagName}`; + console.log('Changelog URL (all commits):', changelogUrl); + } + + // Prepare Release Notes using chapterContents + let releaseNotes = ''; + titlesToLabelsMap.forEach((_, title) => { + const content = chapterContents.get(title); + if (printEmptyChapters || (content && content.trim() !== '')) { + releaseNotes += `### ${title}\n` + (content && content.trim() !== '' ? content : "No entries detected.") + "\n\n"; + } + }); + + if (warnings) { + if (printEmptyChapters) { + releaseNotes += "### Closed Issues without Pull Request ⚠️\n" + (closedIssuesWithoutPR || "All closed issues linked to a Pull Request.") + "\n\n"; + releaseNotes += "### Closed Issues without User Defined Labels ⚠️\n" + (closedIssuesWithoutUserLabels || "All closed issues contain at least one of user defined labels.") + "\n\n"; + releaseNotes += "### Closed Issues without Release Notes ⚠️\n" + (closedIssuesWithoutReleaseNotes || "All closed issues have release notes.") + "\n\n"; + releaseNotes += "### Merged PRs without Linked Issue ⚠️\n" + (mergedPRsWithoutLinkedIssue || "All merged PRs are linked to issues.") + "\n\n"; + releaseNotes += "### Merged PRs Linked to Open Issue ⚠️\n" + (mergedPRsLinkedToOpenIssue || "All merged PRs are linked to Closed issues.") + "\n\n"; + releaseNotes += "### Closed PRs without Linked Issue ⚠️\n" + (closedPRsLinkedToIssue || "All closed PRs are linked to issues.") + "\n\n"; + } else { + releaseNotes += closedIssuesWithoutPR ? "### Closed Issues without Pull Request ⚠️\n" + closedIssuesWithoutPR + "\n\n" : ""; + releaseNotes += closedIssuesWithoutUserLabels ? "### Closed Issues without User Defined Labels ⚠️\n" + closedIssuesWithoutUserLabels + "\n\n" : ""; + releaseNotes += closedIssuesWithoutReleaseNotes ? "### Closed Issues without Release Notes ⚠️\n" + closedIssuesWithoutReleaseNotes + "\n\n" : ""; + releaseNotes += mergedPRsWithoutLinkedIssue ? "### Merged PRs without Linked Issue ⚠️\n" + mergedPRsWithoutLinkedIssue + "\n\n" : ""; + releaseNotes += mergedPRsLinkedToOpenIssue ? "### Merged PRs Linked to Open Issue ⚠️\n" + mergedPRsLinkedToOpenIssue + "\n\n" : ""; + releaseNotes += closedPRsLinkedToIssue ? "### Closed PRs without Linked Issue ⚠️\n" + closedPRsLinkedToIssue + "\n\n" : ""; + } + } + releaseNotes += "#### Full Changelog\n" + changelogUrl; + + console.log('Release Notes:', releaseNotes); + + // Set outputs (only needed if this script is part of a GitHub Action) + core.setOutput('releaseNotes', releaseNotes); + console.log('GitHub Action completed successfully'); + } catch (error) { + if (error.status === 404) { + console.error('Repository not found. Please check the owner and repository name.'); + core.setFailed(error.message) + } else if (error.status === 401) { + console.error('Authentication failed. Please check your GitHub token.'); + core.setFailed(error.message) + } else { + console.error(`Error fetching data: ${error.status} - ${error.message}`); + core.setFailed(`Error fetching data: ${error.status} - ${error.message}`); + } + } +} + +module.exports.run = run; + +if (require.main === require.cache[eval('__filename')]) { + run(); +} + + +/***/ }), + +/***/ 4978: /***/ ((module) => { -"use strict"; -module.exports = require("tls"); +module.exports = eval("require")("util/types"); + /***/ }), -/***/ 7310: +/***/ 9491: /***/ ((module) => { "use strict"; -module.exports = require("url"); +module.exports = require("assert"); /***/ }), -/***/ 3837: +/***/ 852: /***/ ((module) => { "use strict"; -module.exports = require("util"); +module.exports = require("async_hooks"); /***/ }), -/***/ 1267: +/***/ 4300: /***/ ((module) => { "use strict"; -module.exports = require("worker_threads"); +module.exports = require("buffer"); /***/ }), -/***/ 9796: +/***/ 6206: /***/ ((module) => { "use strict"; -module.exports = require("zlib"); +module.exports = require("console"); /***/ }), -/***/ 2960: -/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { +/***/ 6113: +/***/ ((module) => { "use strict"; +module.exports = require("crypto"); +/***/ }), -const WritableStream = (__nccwpck_require__(4492).Writable) -const inherits = (__nccwpck_require__(7261).inherits) +/***/ 7643: +/***/ ((module) => { -const StreamSearch = __nccwpck_require__(1142) +"use strict"; +module.exports = require("diagnostics_channel"); -const PartStream = __nccwpck_require__(1620) -const HeaderParser = __nccwpck_require__(2032) +/***/ }), -const DASH = 45 -const B_ONEDASH = Buffer.from('-') -const B_CRLF = Buffer.from('\r\n') -const EMPTY_FN = function () {} +/***/ 2361: +/***/ ((module) => { -function Dicer (cfg) { - if (!(this instanceof Dicer)) { return new Dicer(cfg) } - WritableStream.call(this, cfg) +"use strict"; +module.exports = require("events"); - if (!cfg || (!cfg.headerFirst && typeof cfg.boundary !== 'string')) { throw new TypeError('Boundary required') } +/***/ }), - if (typeof cfg.boundary === 'string') { this.setBoundary(cfg.boundary) } else { this._bparser = undefined } +/***/ 7147: +/***/ ((module) => { - this._headerFirst = cfg.headerFirst +"use strict"; +module.exports = require("fs"); - this._dashes = 0 - this._parts = 0 - this._finished = false - this._realFinish = false - this._isPreamble = true - this._justMatched = false - this._firstWrite = true - this._inHeader = true - this._part = undefined - this._cb = undefined - this._ignoreData = false - this._partOpts = { highWaterMark: cfg.partHwm } - this._pause = false +/***/ }), - const self = this - this._hparser = new HeaderParser(cfg) - this._hparser.on('header', function (header) { - self._inHeader = false - self._part.emit('header', header) - }) -} -inherits(Dicer, WritableStream) +/***/ 3685: +/***/ ((module) => { -Dicer.prototype.emit = function (ev) { - if (ev === 'finish' && !this._realFinish) { - if (!this._finished) { - const self = this - process.nextTick(function () { - self.emit('error', new Error('Unexpected end of multipart data')) - if (self._part && !self._ignoreData) { - const type = (self._isPreamble ? 'Preamble' : 'Part') - self._part.emit('error', new Error(type + ' terminated early due to unexpected end of multipart data')) - self._part.push(null) - process.nextTick(function () { - self._realFinish = true - self.emit('finish') - self._realFinish = false - }) - return - } - self._realFinish = true - self.emit('finish') - self._realFinish = false - }) - } - } else { WritableStream.prototype.emit.apply(this, arguments) } -} +"use strict"; +module.exports = require("http"); -Dicer.prototype._write = function (data, encoding, cb) { - // ignore unexpected data (e.g. extra trailer data after finished) - if (!this._hparser && !this._bparser) { return cb() } +/***/ }), - if (this._headerFirst && this._isPreamble) { - if (!this._part) { - this._part = new PartStream(this._partOpts) - if (this._events.preamble) { this.emit('preamble', this._part) } else { this._ignore() } - } - const r = this._hparser.push(data) - if (!this._inHeader && r !== undefined && r < data.length) { data = data.slice(r) } else { return cb() } - } +/***/ 5158: +/***/ ((module) => { - // allows for "easier" testing - if (this._firstWrite) { - this._bparser.push(B_CRLF) - this._firstWrite = false - } +"use strict"; +module.exports = require("http2"); - this._bparser.push(data) +/***/ }), - if (this._pause) { this._cb = cb } else { cb() } -} +/***/ 5687: +/***/ ((module) => { -Dicer.prototype.reset = function () { - this._part = undefined - this._bparser = undefined - this._hparser = undefined -} +"use strict"; +module.exports = require("https"); -Dicer.prototype.setBoundary = function (boundary) { - const self = this - this._bparser = new StreamSearch('\r\n--' + boundary) - this._bparser.on('info', function (isMatch, data, start, end) { - self._oninfo(isMatch, data, start, end) - }) -} +/***/ }), -Dicer.prototype._ignore = function () { - if (this._part && !this._ignoreData) { - this._ignoreData = true - this._part.on('error', EMPTY_FN) - // we must perform some kind of read on the stream even though we are - // ignoring the data, otherwise node's Readable stream will not emit 'end' - // after pushing null to the stream - this._part.resume() - } -} +/***/ 1808: +/***/ ((module) => { -Dicer.prototype._oninfo = function (isMatch, data, start, end) { - let buf; const self = this; let i = 0; let r; let shouldWriteMore = true +"use strict"; +module.exports = require("net"); - if (!this._part && this._justMatched && data) { +/***/ }), + +/***/ 5673: +/***/ ((module) => { + +"use strict"; +module.exports = require("node:events"); + +/***/ }), + +/***/ 4492: +/***/ ((module) => { + +"use strict"; +module.exports = require("node:stream"); + +/***/ }), + +/***/ 7261: +/***/ ((module) => { + +"use strict"; +module.exports = require("node:util"); + +/***/ }), + +/***/ 2037: +/***/ ((module) => { + +"use strict"; +module.exports = require("os"); + +/***/ }), + +/***/ 1017: +/***/ ((module) => { + +"use strict"; +module.exports = require("path"); + +/***/ }), + +/***/ 4074: +/***/ ((module) => { + +"use strict"; +module.exports = require("perf_hooks"); + +/***/ }), + +/***/ 3477: +/***/ ((module) => { + +"use strict"; +module.exports = require("querystring"); + +/***/ }), + +/***/ 2781: +/***/ ((module) => { + +"use strict"; +module.exports = require("stream"); + +/***/ }), + +/***/ 5356: +/***/ ((module) => { + +"use strict"; +module.exports = require("stream/web"); + +/***/ }), + +/***/ 1576: +/***/ ((module) => { + +"use strict"; +module.exports = require("string_decoder"); + +/***/ }), + +/***/ 4404: +/***/ ((module) => { + +"use strict"; +module.exports = require("tls"); + +/***/ }), + +/***/ 7310: +/***/ ((module) => { + +"use strict"; +module.exports = require("url"); + +/***/ }), + +/***/ 3837: +/***/ ((module) => { + +"use strict"; +module.exports = require("util"); + +/***/ }), + +/***/ 1267: +/***/ ((module) => { + +"use strict"; +module.exports = require("worker_threads"); + +/***/ }), + +/***/ 9796: +/***/ ((module) => { + +"use strict"; +module.exports = require("zlib"); + +/***/ }), + +/***/ 2960: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { + +"use strict"; + + +const WritableStream = (__nccwpck_require__(4492).Writable) +const inherits = (__nccwpck_require__(7261).inherits) + +const StreamSearch = __nccwpck_require__(1142) + +const PartStream = __nccwpck_require__(1620) +const HeaderParser = __nccwpck_require__(2032) + +const DASH = 45 +const B_ONEDASH = Buffer.from('-') +const B_CRLF = Buffer.from('\r\n') +const EMPTY_FN = function () {} + +function Dicer (cfg) { + if (!(this instanceof Dicer)) { return new Dicer(cfg) } + WritableStream.call(this, cfg) + + if (!cfg || (!cfg.headerFirst && typeof cfg.boundary !== 'string')) { throw new TypeError('Boundary required') } + + if (typeof cfg.boundary === 'string') { this.setBoundary(cfg.boundary) } else { this._bparser = undefined } + + this._headerFirst = cfg.headerFirst + + this._dashes = 0 + this._parts = 0 + this._finished = false + this._realFinish = false + this._isPreamble = true + this._justMatched = false + this._firstWrite = true + this._inHeader = true + this._part = undefined + this._cb = undefined + this._ignoreData = false + this._partOpts = { highWaterMark: cfg.partHwm } + this._pause = false + + const self = this + this._hparser = new HeaderParser(cfg) + this._hparser.on('header', function (header) { + self._inHeader = false + self._part.emit('header', header) + }) +} +inherits(Dicer, WritableStream) + +Dicer.prototype.emit = function (ev) { + if (ev === 'finish' && !this._realFinish) { + if (!this._finished) { + const self = this + process.nextTick(function () { + self.emit('error', new Error('Unexpected end of multipart data')) + if (self._part && !self._ignoreData) { + const type = (self._isPreamble ? 'Preamble' : 'Part') + self._part.emit('error', new Error(type + ' terminated early due to unexpected end of multipart data')) + self._part.push(null) + process.nextTick(function () { + self._realFinish = true + self.emit('finish') + self._realFinish = false + }) + return + } + self._realFinish = true + self.emit('finish') + self._realFinish = false + }) + } + } else { WritableStream.prototype.emit.apply(this, arguments) } +} + +Dicer.prototype._write = function (data, encoding, cb) { + // ignore unexpected data (e.g. extra trailer data after finished) + if (!this._hparser && !this._bparser) { return cb() } + + if (this._headerFirst && this._isPreamble) { + if (!this._part) { + this._part = new PartStream(this._partOpts) + if (this._events.preamble) { this.emit('preamble', this._part) } else { this._ignore() } + } + const r = this._hparser.push(data) + if (!this._inHeader && r !== undefined && r < data.length) { data = data.slice(r) } else { return cb() } + } + + // allows for "easier" testing + if (this._firstWrite) { + this._bparser.push(B_CRLF) + this._firstWrite = false + } + + this._bparser.push(data) + + if (this._pause) { this._cb = cb } else { cb() } +} + +Dicer.prototype.reset = function () { + this._part = undefined + this._bparser = undefined + this._hparser = undefined +} + +Dicer.prototype.setBoundary = function (boundary) { + const self = this + this._bparser = new StreamSearch('\r\n--' + boundary) + this._bparser.on('info', function (isMatch, data, start, end) { + self._oninfo(isMatch, data, start, end) + }) +} + +Dicer.prototype._ignore = function () { + if (this._part && !this._ignoreData) { + this._ignoreData = true + this._part.on('error', EMPTY_FN) + // we must perform some kind of read on the stream even though we are + // ignoring the data, otherwise node's Readable stream will not emit 'end' + // after pushing null to the stream + this._part.resume() + } +} + +Dicer.prototype._oninfo = function (isMatch, data, start, end) { + let buf; const self = this; let i = 0; let r; let shouldWriteMore = true + + if (!this._part && this._justMatched && data) { while (this._dashes < 2 && (start + i) < end) { if (data[start + i] === DASH) { ++i @@ -30898,560 +31487,12 @@ module.exports = parseParams /******/ if (typeof __nccwpck_require__ !== 'undefined') __nccwpck_require__.ab = __dirname + "/"; /******/ /************************************************************************/ -var __webpack_exports__ = {}; -// This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk. -(() => { -const { Octokit } = __nccwpck_require__(5375); -const core = __nccwpck_require__(2186); -const github = __nccwpck_require__(5438); - -/** - * Fetches the latest release information for a given repository. - * @param {Octokit} octokit - The Octokit instance. - * @param {string} owner - The owner of the repository. - * @param {string} repo - The name of the repository. - * @returns {Promise} The latest release data. - */ -async function fetchLatestRelease(octokit, owner, repo) { - console.log(`Starting to fetch the latest release for ${owner}/${repo}`); - try { - const release = await octokit.rest.repos.getLatestRelease({owner, repo}); - return release.data; - } catch (error) { - console.error(`Error fetching latest release for ${owner}/${repo}: ${error.status} - ${error.message}`); - return null; - } -} - -/** - * Retrieves related pull requests for a specific issue. - * @param {Octokit} octokit - The Octokit instance. - * @param {number} issueNumber - The issue number. - * @param {string} repoOwner - The owner of the repository. - * @param {string} repoName - The name of the repository. - * @returns {Promise} An array of related pull requests. - */ -async function getRelatedPRsForIssue(octokit, issueNumber, repoOwner, repoName) { - console.log(`Fetching related PRs for issue #${issueNumber}`); - const relatedPRs = await octokit.rest.issues.listEventsForTimeline({owner: repoOwner, repo: repoName, issue_number: issueNumber}); - - // Filter events to get only those that are linked pull requests - const pullRequestEvents = relatedPRs.data.filter(event => event.event === 'cross-referenced' && event.source && event.source.issue.pull_request); - if (pullRequestEvents) { - console.log(`Found ${pullRequestEvents.length} related PRs for issue #${issueNumber}`); - } else { - console.log(`Found 0 related PRs for issue #${issueNumber}`); - } - return pullRequestEvents; -} - -/** - * Fetches contributors for an issue. - * @param {Array} issueAssignees - List of assignees for the issue. - * @param {Array} commitAuthors - List of authors of commits. - * @returns {Set} A set of contributors' usernames. - */ -async function getIssueContributors(issueAssignees, commitAuthors) { - // Map the issueAssignees to the required format - const assignees = issueAssignees.map(assignee => '@' + assignee.login); - - // Combine the assignees and commit authors - const combined = [...assignees, ...commitAuthors]; - - // Check if the combined array is empty - if (combined && combined.length === 0) { - return new Set(["\"Missing Assignee or Contributor\""]); - } - - // If not empty, return the Set of combined values - return new Set(combined); -} - -/** - * Retrieves authors of commits from pull requests related to an issue. - * @param {Octokit} octokit - The Octokit instance. - * @param {string} repoOwner - The owner of the repository. - * @param {string} repoName - The name of the repository. - * @param {Array} relatedPRs - Related pull requests. - * @returns {Set} A set of commit authors' usernames. - */ -async function getPRCommitAuthors(octokit, repoOwner, repoName, relatedPRs) { - let commitAuthors = new Set(); - for (const event of relatedPRs) { - const prNumber = event.source.issue.number; - const commits = await octokit.rest.pulls.listCommits({ - owner: repoOwner, - repo: repoName, - pull_number: prNumber - }); - - for (const commit of commits.data) { - commitAuthors.add('@' + commit.author.login); - - const coAuthorMatches = commit.commit.message.match(/Co-authored-by: (.+ <.+>)/gm); - if (coAuthorMatches) { - for (const coAuthorLine of coAuthorMatches) { - const emailRegex = /<([^>]+)>/; - const nameRegex = /Co-authored-by: (.+) } An array of issue comments. - */ -async function getIssueComments(octokit, issueNumber, repoOwner, repoName) { - return await octokit.rest.issues.listComments({owner: repoOwner, repo: repoName, issue_number: issueNumber}); -} - -/** - * Generates release notes from issue comments. - * @param {Octokit} octokit - The Octokit instance. - * @param {number} issueNumber - The issue number. - * @param {string} issueTitle - The title of the issue. - * @param {Array} issueAssignees - List of assignees for the issue. - * @param {string} repoOwner - The owner of the repository. - * @param {string} repoName - The name of the repository. - * @param {Array} relatedPRs - Related pull requests. - * @param {string} relatedPRLinksString - String of related PR links. - * @returns {Promise} The formatted release note for the issue. - */ -async function getReleaseNotesFromComments(octokit, issueNumber, issueTitle, issueAssignees, repoOwner, repoName, relatedPRs, relatedPRLinksString) { - console.log(`Fetching release notes from comments for issue #${issueNumber}`); - const comments = await getIssueComments(octokit, issueNumber, repoOwner, repoName); - let commitAuthors = await getPRCommitAuthors(octokit, repoOwner, repoName, relatedPRs); - let contributors = await getIssueContributors(issueAssignees, commitAuthors); - - let releaseNotes = []; - for (const comment of comments.data) { - if (comment.body.toLowerCase().startsWith('release notes')) { - const noteContent = comment.body.replace(/^release notes\s*/i, '').trim(); - console.log(`Found release notes in comments for issue #${issueNumber}`); - releaseNotes.push(noteContent.replace(/^\s*[\r\n]/gm, '').replace(/^/gm, ' ')); - } - } - - if (releaseNotes.length === 0) { - console.log(`No specific release notes found in comments for issue #${issueNumber}`); - const contributorsList = Array.from(contributors).join(', '); - if (relatedPRs.length === 0) { - return `- x#${issueNumber} _${issueTitle}_ implemented by ${contributorsList}\n`; - } else { - return `- x#${issueNumber} _${issueTitle}_ implemented by ${contributorsList} in ${relatedPRLinksString}\n`; - } - } else { - const contributorsList = Array.from(contributors).join(', '); - const notes = releaseNotes.join('\n'); - if (relatedPRs.length === 0) { - return `- #${issueNumber} _${issueTitle}_ implemented by ${contributorsList}\n${notes}\n`; - } else { - return `- #${issueNumber} _${issueTitle}_ implemented by ${contributorsList} in ${relatedPRLinksString}\n${notes}\n`; - } - } -} - -/** - * Checks if a pull request is linked to an issue. - * @param {Octokit} octokit - The Octokit instance. - * @param {number} prNumber - The pull request number. - * @param {string} repoOwner - The owner of the repository. - * @param {string} repoName - The name of the repository. - * @returns {Promise} True if the pull request is linked to an issue, false otherwise. - */ -async function isPrLinkedToIssue(octokit, prNumber, repoOwner, repoName) { - // Get the pull request details - const pr = await octokit.rest.pulls.get({ - owner: repoOwner, - repo: repoName, - pull_number: prNumber - }); - - // Regex pattern to find references to issues - const regexPattern = /([Cc]los(e|es|ed)|[Ff]ix(es|ed)?|[Rr]esolv(e|es|ed))\s*#\s*([0-9]+)/g; - - // Test if the PR body contains any issue references - return regexPattern.test(pr.data.body); -} - - -/** - * Checks if a pull request is linked to any open issues. - * @param {Octokit} octokit - The Octokit instance. - * @param {number} prNumber - The pull request number. - * @param {string} repoOwner - The owner of the repository. - * @param {string} repoName - The name of the repository. - * @returns {Promise} True if the pull request is linked to any open issue, false otherwise. - */ -async function isPrLinkedToOpenIssue(octokit, prNumber, repoOwner, repoName) { - // Get the pull request details - const pr = await octokit.rest.pulls.get({ - owner: repoOwner, - repo: repoName, - pull_number: prNumber - }); - - // Regex pattern to find references to issues - const regexPattern = /([Cc]los(e|es|ed)|[Ff]ix(es|ed)?|[Rr]esolv(e|es|ed))\s*#\s*([0-9]+)/g; - - // Extract all issue numbers from the PR body - const issueMatches = pr.data.body.match(regexPattern); - if (!issueMatches) { - return false; // No issue references found in PR body - } - - // Check each linked issue - for (const match of issueMatches) { - const issueNumber = match.match(/#([0-9]+)/)[1]; - - // Get the issue details - const issue = await octokit.rest.issues.get({ - owner: repoOwner, - repo: repoName, - issue_number: issueNumber - }); - - // If any of the issues is open, return true - if (issue.data.state === 'open') { - return true; - } - } - - // If none of the issues are open, return false - return false; -} - - -/** - * Parses the JSON string of chapters into a map. - * @param {string} chaptersJson - The JSON string of chapters. - * @returns {Map} A map where each key is a chapter title and the value is an array of corresponding labels. - */ -function parseChaptersJson(chaptersJson) { - try { - const chaptersArray = JSON.parse(chaptersJson); - const titlesToLabelsMap = new Map(); - chaptersArray.forEach(chapter => { - if (titlesToLabelsMap.has(chapter.title)) { - titlesToLabelsMap.get(chapter.title).push(chapter.label); - } else { - titlesToLabelsMap.set(chapter.title, [chapter.label]); - } - }); - return titlesToLabelsMap; - } catch (error) { - throw new Error(`Error parsing chapters JSON: ${error.message}`); - } -} - -/** - * Fetches a list of closed issues since the latest release. - * @param {Octokit} octokit - The Octokit instance. - * @param {string} repoOwner - The owner of the repository. - * @param {string} repoName - The name of the repository. - * @param {Object} latestRelease - The latest release object. - * @param {boolean} usePublishedAt - Flag to use created-at or published-at time point. - * @param {string} skipLabel - The label to skip issues. - * @returns {Promise} An array of closed issues since the latest release. - */ -async function fetchClosedIssues(octokit, repoOwner, repoName, latestRelease, usePublishedAt, skipLabel) { - let since; - if (latestRelease) { - if (usePublishedAt) { - since = new Date(latestRelease.published_at) - console.log(`Fetching closed issues since ${since.toISOString()} - published-at.`); - } else { - since = new Date(latestRelease.created_at) - console.log(`Fetching closed issues since ${since.toISOString()} - created-at.`); - } - } else { - const firstClosedIssue = await octokit.rest.issues.listForRepo({ - owner: repoOwner, - repo: repoName, - state: 'closed', - per_page: 1, - sort: 'created', - direction: 'asc' - }); - - if (firstClosedIssue && firstClosedIssue.data.length > 0) { - since = new Date(firstClosedIssue.data[0].created_at); - console.log(`Fetching closed issues since the first closed issue on ${since.toISOString()}`); - } else { - console.log("No closed issues found."); - return []; - } - } - - const closedIssues = await octokit.rest.issues.listForRepo({ - owner: repoOwner, - repo: repoName, - state: 'closed', - since: since - }); - - return closedIssues.data - .filter(issue => !issue.pull_request) // Filter out pull requests - .filter(issue => !issue.labels.some(label => label.name === skipLabel)) // Filter out issues with skip label - .reverse(); -} - -/** - * Fetches a list of closed pull requests since the latest release. - * @param {Octokit} octokit - The Octokit instance. - * @param {string} repoOwner - The owner of the repository. - * @param {string} repoName - The name of the repository. - * @param {Object} latestRelease - The latest release object. - * @param {boolean} usePublishedAt - Flag to use created-at or published-at time point. - * @param {string} skipLabel - The label to skip issues. - * @param {string} prState - The state of the pull request. - * @returns {Promise} An array of closed pull requests since the latest release. - */ -async function fetchPullRequests(octokit, repoOwner, repoName, latestRelease, usePublishedAt, skipLabel, prState = 'merged') { - console.log(`Fetching ${prState} pull requests for ${repoOwner}/${repoName}`); - - let pullRequests; - let since; - let response; - - if (latestRelease) { - if (usePublishedAt) { - console.log(`Since latest release date: ${latestRelease.published_at} - published-at.`); - since = new Date(latestRelease.published_at); - } else { - console.log(`Since latest release date: ${latestRelease.created_at} - created-at.`); - since = new Date(latestRelease.created_at); - } - - response = await octokit.rest.pulls.list({ - owner: repoOwner, - repo: repoName, - state: 'all', - sort: 'updated', - direction: 'desc', - since: since - }); - } else { - console.log('No latest release found. Fetching all pull requests of repository.'); - response = await octokit.rest.pulls.list({ - owner: repoOwner, - repo: repoName, - state: 'all', - sort: 'updated', - direction: 'desc' - }); - } - - pullRequests = response.data; - console.log(`Found ${pullRequests.length} pull requests for ${repoOwner}/${repoName}`) - - // Filter based on prState - if (prState === 'merged') { - pullRequests = pullRequests.filter(pr => pr.merged_at); - } else if (prState === 'closed') { - pullRequests = pullRequests.filter(pr => !pr.merged_at && pr.state === 'closed'); - } - - // Filter out pull requests with the specified skipLabel - console.log(`Filtering out pull requests with label: ${skipLabel}`) - pullRequests = pullRequests.filter(pr => !pr.labels.some(label => label.name === skipLabel)); - - return pullRequests; -} - -async function run() { - const repoOwner = github.context.repo.owner; - const repoName = github.context.repo.repo; - const tagName = core.getInput('tag-name'); - const chaptersJson = core.getInput('chapters'); - const warnings = core.getInput('warnings').toLowerCase() === 'true'; - const githubToken = process.env.GITHUB_TOKEN; - const usePublishedAt = core.getInput('published-at').toLowerCase() === 'true'; - const skipLabel = core.getInput('skip-release-notes-label') || 'skip-release-notes'; - const printEmptyChapters = core.getInput('print-empty-chapters').toLowerCase() === 'true'; - - // Validate environment variables and arguments - if (!githubToken || !repoOwner || !repoName) { - console.error("Missing required inputs or environment variables."); - process.exit(1); - } - - const octokit = new Octokit({ auth: githubToken }); - - try { - const latestRelease = await fetchLatestRelease(octokit, repoOwner, repoName); - - // Fetch closed issues since the latest release - const closedIssuesOnlyIssues = await fetchClosedIssues(octokit, repoOwner, repoName, latestRelease, usePublishedAt, skipLabel); - if (closedIssuesOnlyIssues) { - console.log(`Found ${closedIssuesOnlyIssues.length} closed issues (only Issues) since last release`); - } else { - console.log(`Found 0 closed issues (only Issues) since last release`); - } - - // Initialize variables for each chapter - const titlesToLabelsMap = parseChaptersJson(chaptersJson); - const chapterContents = new Map(Array.from(titlesToLabelsMap.keys()).map(label => [label, ''])); - let closedIssuesWithoutReleaseNotes = '', closedIssuesWithoutUserLabels = '', closedIssuesWithoutPR = '', mergedPRsWithoutLinkedIssue = ''; - let mergedPRsLinkedToOpenIssue = '', closedPRsLinkedToIssue = ''; - - // Categorize issues and PRs - for (const issue of closedIssuesOnlyIssues) { - let relatedPRs = await getRelatedPRsForIssue(octokit, issue.number, repoOwner, repoName); - let prLinks = relatedPRs - .map(event => `[#${event.source.issue.number}](${event.source.issue.html_url})`) - .join(', '); - console.log(`Related PRs for issue #${issue.number}: ${prLinks}`); - - const releaseNotesRaw = await getReleaseNotesFromComments(octokit, issue.number, issue.title, issue.assignees, repoOwner, repoName, relatedPRs, prLinks); - const releaseNotes = releaseNotesRaw.replace(/^- x#/, '- #'); - - // Check for issues without release notes - if (warnings && releaseNotesRaw.startsWith('- x#')) { - closedIssuesWithoutReleaseNotes += releaseNotes; - } - - let foundUserLabels = false; - titlesToLabelsMap.forEach((labels, title) => { - if (labels.some(label => issue.labels.map(l => l.name).includes(label))) { - chapterContents.set(title, chapterContents.get(title) + releaseNotes); - foundUserLabels = true; - } - }); - - // Check for issues without user defined labels - if (!foundUserLabels && warnings) { - closedIssuesWithoutUserLabels += releaseNotes; - } - - // Check for issues without PR - if (!relatedPRs.length && warnings) { - closedIssuesWithoutPR += releaseNotes; - } - } - - // Check PRs for linked issues - if (warnings) { - // Fetch merged pull requests since the latest release - const mergedPRsSinceLastRelease = await fetchPullRequests(octokit, repoOwner, repoName, latestRelease, usePublishedAt, skipLabel); - if (mergedPRsSinceLastRelease) { - console.log(`Found ${mergedPRsSinceLastRelease.length} merged PRs since last release`); - const sortedMergedPRs = mergedPRsSinceLastRelease.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); - - for (const pr of sortedMergedPRs) { - if (!await isPrLinkedToIssue(octokit, pr.number, repoOwner, repoName)) { - mergedPRsWithoutLinkedIssue += `#${pr.number} _${pr.title}_\n`; - } else { - if (await isPrLinkedToOpenIssue(octokit, pr.number, repoOwner, repoName)) { - mergedPRsLinkedToOpenIssue += `#${pr.number} _${pr.title}_\n`; - } - } - } - } else { - console.log(`Found 0 merged PRs since last release`); - } - - // Fetch closed pull requests since the latest release - const closedPRsSinceLastRelease = await fetchPullRequests(octokit, repoOwner, repoName, latestRelease, usePublishedAt, skipLabel, 'closed'); - if (closedPRsSinceLastRelease) { - console.log(`Found ${closedPRsSinceLastRelease.length} closed PRs since last release`); - const sortedClosedPRs = closedPRsSinceLastRelease.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); - - for (const pr of sortedClosedPRs) { - if (!await isPrLinkedToIssue(octokit, pr.number, repoOwner, repoName)) { - closedPRsLinkedToIssue += `#${pr.number} _${pr.title}_\n`; - } - } - } else { - console.log(`Found 0 closed PRs since last release`); - } - } - - let changelogUrl; - if (latestRelease) { - // If there is a latest release, create a URL pointing to commits since the latest release - changelogUrl = `https://github.com/${repoOwner}/${repoName}/compare/${latestRelease.tag_name}...${tagName}`; - console.log('Changelog URL (since latest release):', changelogUrl); - } else { - // If there is no latest release, create a URL pointing to all commits - changelogUrl = `https://github.com/${repoOwner}/${repoName}/commits/${tagName}`; - console.log('Changelog URL (all commits):', changelogUrl); - } - - // Prepare Release Notes using chapterContents - let releaseNotes = ''; - titlesToLabelsMap.forEach((_, title) => { - const content = chapterContents.get(title); - if (printEmptyChapters || (content && content.trim() !== '')) { - releaseNotes += `### ${title}\n` + (content && content.trim() !== '' ? content : "No entries detected.") + "\n\n"; - } - }); - - if (warnings) { - if (printEmptyChapters) { - releaseNotes += "### Closed Issues without Pull Request ⚠️\n" + (closedIssuesWithoutPR || "All closed issues linked to a Pull Request.") + "\n\n"; - releaseNotes += "### Closed Issues without User Defined Labels ⚠️\n" + (closedIssuesWithoutUserLabels || "All closed issues contain at least one of user defined labels.") + "\n\n"; - releaseNotes += "### Closed Issues without Release Notes ⚠️\n" + (closedIssuesWithoutReleaseNotes || "All closed issues have release notes.") + "\n\n"; - releaseNotes += "### Merged PRs without Linked Issue ⚠️\n" + (mergedPRsWithoutLinkedIssue || "All merged PRs are linked to issues.") + "\n\n"; - releaseNotes += "### Merged PRs Linked to Open Issue ⚠️\n" + (mergedPRsLinkedToOpenIssue || "All merged PRs are linked to Closed issues.") + "\n\n"; - releaseNotes += "### Closed PRs without Linked Issue ⚠️\n" + (closedPRsLinkedToIssue || "All closed PRs are linked to issues.") + "\n\n"; - } else { - releaseNotes += closedIssuesWithoutPR ? "### Closed Issues without Pull Request ⚠️\n" + closedIssuesWithoutPR + "\n\n" : ""; - releaseNotes += closedIssuesWithoutUserLabels ? "### Closed Issues without User Defined Labels ⚠️\n" + closedIssuesWithoutUserLabels + "\n\n" : ""; - releaseNotes += closedIssuesWithoutReleaseNotes ? "### Closed Issues without Release Notes ⚠️\n" + closedIssuesWithoutReleaseNotes + "\n\n" : ""; - releaseNotes += mergedPRsWithoutLinkedIssue ? "### Merged PRs without Linked Issue ⚠️\n" + mergedPRsWithoutLinkedIssue + "\n\n" : ""; - releaseNotes += mergedPRsLinkedToOpenIssue ? "### Merged PRs Linked to Open Issue ⚠️\n" + mergedPRsLinkedToOpenIssue + "\n\n" : ""; - releaseNotes += closedPRsLinkedToIssue ? "### Closed PRs without Linked Issue ⚠️\n" + closedPRsLinkedToIssue + "\n\n" : ""; - } - } - releaseNotes += "#### Full Changelog\n" + changelogUrl; - - console.log('Release Notes:', releaseNotes); - - // Set outputs (only needed if this script is part of a GitHub Action) - core.setOutput('releaseNotes', releaseNotes); - console.log('GitHub Action completed successfully'); - } catch (error) { - if (error.status === 404) { - console.error('Repository not found. Please check the owner and repository name.'); - } else if (error.status === 401) { - console.error('Authentication failed. Please check your GitHub token.'); - } else { - console.error(`Error fetching data: ${error.status} - ${error.message}`); - } - process.exit(1); - } -} - -run(); - -})(); - -module.exports = __webpack_exports__; +/******/ +/******/ // startup +/******/ // Load entry module and return exports +/******/ // This entry module used 'module' so it can't be inlined +/******/ var __webpack_exports__ = __nccwpck_require__(192); +/******/ module.exports = __webpack_exports__; +/******/ /******/ })() ; \ No newline at end of file diff --git a/package.json b/package.json index bfacf826..6e5b7dcb 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,27 @@ "dependencies": { "@actions/github": "^6.0.0", "@octokit/core": "^5.0.2", - "@octokit/rest": "^20.0.2" + "@octokit/rest": "^20.0.2", + "expect": "^29.7.0", + "nock": "^13.5.0" }, "devDependencies": { "@actions/core": "^1.10.1", - "@vercel/ncc": "^0.33.0" + "@types/jest": "^29.5.11", + "@vercel/ncc": "^0.33.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.2" }, "scripts": { - "build": "ncc build scripts/generate-release-notes.js -o dist" + "build": "ncc build scripts/generate-release-notes.js -o dist", + "test": "jest" + }, + "jest": { + "testPathIgnorePatterns": [ + "/node_modules/", + "/dist/", + "/.github/", + "/__tests__/mocks/" + ] } } diff --git a/scripts/generate-release-notes.js b/scripts/generate-release-notes.js index 102fac17..b33b7704 100644 --- a/scripts/generate-release-notes.js +++ b/scripts/generate-release-notes.js @@ -11,11 +11,13 @@ const github = require('@actions/github'); */ async function fetchLatestRelease(octokit, owner, repo) { console.log(`Starting to fetch the latest release for ${owner}/${repo}`); + try { const release = await octokit.rest.repos.getLatestRelease({owner, repo}); + console.log(`Found latest release for ${owner}/${repo}: ${release.data.tag_name}, created at: ${release.data.created_at}, published at: ${release.data.published_at}`); return release.data; } catch (error) { - console.error(`Error fetching latest release for ${owner}/${repo}: ${error.status} - ${error.message}`); + console.warn(`Fetching latest release for ${owner}/${repo}: ${error.status} - ${error.message}`); return null; } } @@ -95,10 +97,13 @@ async function getPRCommitAuthors(octokit, repoOwner, repoName, relatedPRs) { if (emailMatch && nameMatch) { const email = emailMatch[1]; const name = nameMatch[1].trim(); + console.log(`Searching for GitHub user with email: ${email}`); + const searchResult = await octokit.rest.search.users({ q: `${email} in:email` }); + const user = searchResult.data.items[0]; if (user && user.login) { commitAuthors.add('@' + user.login); @@ -148,7 +153,7 @@ async function getReleaseNotesFromComments(octokit, issueNumber, issueTitle, iss let releaseNotes = []; for (const comment of comments.data) { if (comment.body.toLowerCase().startsWith('release notes')) { - const noteContent = comment.body.replace(/^release notes\s*/i, '').trim(); + const noteContent = comment.body.replace(/^release notes:?.*(\r\n|\n|\r)?/i, '').trim(); console.log(`Found release notes in comments for issue #${issueNumber}`); releaseNotes.push(noteContent.replace(/^\s*[\r\n]/gm, '').replace(/^/gm, ' ')); } @@ -224,7 +229,7 @@ async function isPrLinkedToOpenIssue(octokit, prNumber, repoOwner, repoName) { // Check each linked issue for (const match of issueMatches) { - const issueNumber = match.match(/#([0-9]+)/)[1]; + const issueNumber = +match.match(/#([0-9]+)/)[1]; // Get the issue details const issue = await octokit.rest.issues.get({ @@ -262,7 +267,7 @@ function parseChaptersJson(chaptersJson) { }); return titlesToLabelsMap; } catch (error) { - throw new Error(`Error parsing chapters JSON: ${error.message}`); + core.setFailed(`Error parsing chapters JSON: ${error.message}`) } } @@ -338,33 +343,33 @@ async function fetchPullRequests(octokit, repoOwner, repoName, latestRelease, us if (latestRelease) { if (usePublishedAt) { - console.log(`Since latest release date: ${latestRelease.published_at} - published-at.`); since = new Date(latestRelease.published_at); + console.log(`Since latest release date: ${since.toISOString()} - published-at.`); } else { - console.log(`Since latest release date: ${latestRelease.created_at} - created-at.`); since = new Date(latestRelease.created_at); + console.log(`Since latest release date: ${since.toISOString()} - created-at.`); } + } - response = await octokit.rest.pulls.list({ - owner: repoOwner, - repo: repoName, - state: 'all', - sort: 'updated', - direction: 'desc', - since: since + response = await octokit.rest.pulls.list({ + owner: repoOwner, + repo: repoName, + state: 'all', + sort: 'updated', + direction: 'desc' + }); + + pullRequests = response.data; + + if (latestRelease) { + pullRequests = pullRequests.filter(pr => { + const prCreatedAt = new Date(pr.created_at); + return prCreatedAt > since; }); } else { console.log('No latest release found. Fetching all pull requests of repository.'); - response = await octokit.rest.pulls.list({ - owner: repoOwner, - repo: repoName, - state: 'all', - sort: 'updated', - direction: 'desc' - }); } - pullRequests = response.data; console.log(`Found ${pullRequests.length} pull requests for ${repoOwner}/${repoName}`) // Filter based on prState @@ -382,22 +387,44 @@ async function fetchPullRequests(octokit, repoOwner, repoName, latestRelease, us } async function run() { - const repoOwner = github.context.repo.owner; - const repoName = github.context.repo.repo; - const tagName = core.getInput('tag-name'); - const chaptersJson = core.getInput('chapters'); - const warnings = core.getInput('warnings').toLowerCase() === 'true'; + console.log('Starting GitHub Action'); const githubToken = process.env.GITHUB_TOKEN; - const usePublishedAt = core.getInput('published-at').toLowerCase() === 'true'; - const skipLabel = core.getInput('skip-release-notes-label') || 'skip-release-notes'; - const printEmptyChapters = core.getInput('print-empty-chapters').toLowerCase() === 'true'; + const tagName = core.getInput('tag-name'); + const githubRepository = process.env.GITHUB_REPOSITORY; + + // Validate GitHub token + if (!githubToken) { + core.setFailed("GitHub token is missing."); + return; + } + + // Validate GitHub repository environment variable + if (!githubRepository) { + core.setFailed("GITHUB_REPOSITORY environment variable is missing."); + return; + } - // Validate environment variables and arguments - if (!githubToken || !repoOwner || !repoName) { - console.error("Missing required inputs or environment variables."); - process.exit(1); + // Extract owner and repo from GITHUB_REPOSITORY + const [owner, repo] = githubRepository.split('/'); + if (!owner || !repo) { + core.setFailed("GITHUB_REPOSITORY environment variable is not in the correct format 'owner/repo'."); + return; } + // Validate tag name + if (!tagName) { + core.setFailed("Tag name is missing."); + return; + } + + const repoOwner = github.context.repo.owner; + const repoName = github.context.repo.repo; + const chaptersJson = core.getInput('chapters') || "[]"; + const warnings = core.getInput('warnings') ? core.getInput('warnings').toLowerCase() === 'true' : true; + const skipLabel = core.getInput('skip-release-notes-label') || 'skip-release-notes'; + const usePublishedAt = core.getInput('published-at') ? core.getInput('published-at').toLowerCase() === 'true' : false; + const printEmptyChapters = core.getInput('print-empty-chapters') ? core.getInput('print-empty-chapters').toLowerCase() === 'true' : true; + const octokit = new Octokit({ auth: githubToken }); try { @@ -420,6 +447,7 @@ async function run() { // Categorize issues and PRs for (const issue of closedIssuesOnlyIssues) { let relatedPRs = await getRelatedPRsForIssue(octokit, issue.number, repoOwner, repoName); + console.log(`Related PRs for issue #${issue.number}: ${relatedPRs.map(event => event.id).join(', ')}`); let prLinks = relatedPRs .map(event => `[#${event.source.issue.number}](${event.source.issue.html_url})`) .join(', '); @@ -536,13 +564,19 @@ async function run() { } catch (error) { if (error.status === 404) { console.error('Repository not found. Please check the owner and repository name.'); + core.setFailed(error.message) } else if (error.status === 401) { console.error('Authentication failed. Please check your GitHub token.'); + core.setFailed(error.message) } else { console.error(`Error fetching data: ${error.status} - ${error.message}`); + core.setFailed(`Error fetching data: ${error.status} - ${error.message}`); } - process.exit(1); } } -run(); +module.exports.run = run; + +if (require.main === module) { + run(); +}