diff --git a/changelogs/fragments/5218.yml b/changelogs/fragments/5218.yml new file mode 100644 index 000000000000..4269edde909f --- /dev/null +++ b/changelogs/fragments/5218.yml @@ -0,0 +1,2 @@ +refactor: + - Improves an existing feature ([#5218](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5218)) diff --git a/changelogs/fragments/5243.yml b/changelogs/fragments/5243.yml new file mode 100644 index 000000000000..705d46caa4c2 --- /dev/null +++ b/changelogs/fragments/5243.yml @@ -0,0 +1,2 @@ +feat: + - Adds a new feature ([#5243](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5243)) diff --git a/changelogs/fragments/5321.yml b/changelogs/fragments/5321.yml new file mode 100644 index 000000000000..a4c99151f77e --- /dev/null +++ b/changelogs/fragments/5321.yml @@ -0,0 +1,2 @@ +test: + - Add unit testing to new feature ([#5321](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5321)) diff --git a/package.json b/package.json index b41c6b834fd9..d7134a98b14a 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,8 @@ "docs:acceptApiChanges": "scripts/use_node --max-old-space-size=6144 scripts/check_published_api_changes.js --accept", "osd:bootstrap": "scripts/use_node scripts/build_ts_refs && scripts/use_node scripts/register_git_hook", "spec_to_console": "scripts/use_node scripts/spec_to_console", - "pkg-version": "scripts/use_node -e \"console.log(require('./package.json').version)\"" + "pkg-version": "scripts/use_node -e \"console.log(require('./package.json').version)\"", + "release_note:generate": "scripts/use_node scripts/generate_release_note" }, "repository": { "type": "git", diff --git a/scripts/generate_release_note.js b/scripts/generate_release_note.js new file mode 100644 index 000000000000..4721fe0dec35 --- /dev/null +++ b/scripts/generate_release_note.js @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +require('../src/setup_node_env'); +require('../src/dev/generate_release_note'); +require('../src/dev/generate_release_note_helper'); diff --git a/src/dev/generate_release_note.ts b/src/dev/generate_release_note.ts new file mode 100644 index 000000000000..61d0e2210892 --- /dev/null +++ b/src/dev/generate_release_note.ts @@ -0,0 +1,136 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { resolve } from 'path'; +import { readFileSync, writeFileSync, Dirent, renameSync, rm } from 'fs'; +import { load as loadYaml } from 'js-yaml'; +import { mkdir, readdir } from 'fs/promises'; +import { version as pkgVersion } from '../../package.json'; +import { + validateFragment, + getCurrentDateFormatted, + Changelog, + SECTION_MAPPING, + fragmentDirPath, + fragmentTempDirPath, + SectionKey, + releaseNotesDirPath, + filePath, +} from './generate_release_note_helper'; + +// Function to add content after the 'Unreleased' section in the changelog +function addContentAfterUnreleased(path: string, newContent: string): void { + let fileContent = readFileSync(path, 'utf8'); + const targetString = '## [Unreleased]'; + const targetIndex = fileContent.indexOf(targetString); + + if (targetIndex !== -1) { + const endOfLineIndex = fileContent.indexOf('\n', targetIndex); + + if (endOfLineIndex !== -1) { + fileContent = + fileContent.slice(0, endOfLineIndex + 1) + + '\n' + + newContent + + fileContent.slice(endOfLineIndex + 1); + } else { + throw new Error('End of line for "Unreleased" section not found.'); + } + } else { + throw new Error("'## [Unreleased]' not found in the file."); + } + + writeFileSync(path, fileContent); +} + +async function deleteFragments() { + rm(fragmentTempDirPath, { recursive: true }, (err: any) => { + if (err) { + throw err; + } + }); +} + +// Read fragment files and populate sections +async function readFragments() { + // Initialize sections + const sections: Changelog = (Object.fromEntries( + Object.keys(SECTION_MAPPING).map((key) => [key, []]) + ) as unknown) as Changelog; + + const fragmentPaths = await readdir(fragmentDirPath, { withFileTypes: true }); + for (const fragmentFilename of fragmentPaths) { + // skip non yml or yaml files + if (!fragmentFilename.name.endsWith('.yml') && !fragmentFilename.name.endsWith('.yaml')) { + // eslint-disable-next-line no-console + console.warn(`Skipping non yml or yaml file ${fragmentFilename.name}`); + continue; + } + + const fragmentPath = resolve(fragmentDirPath, fragmentFilename.name); + const fragmentContents = readFileSync(fragmentPath, { encoding: 'utf-8' }); + + validateFragment(fragmentContents); + + const fragmentYaml = loadYaml(fragmentContents) as Changelog; + + for (const [sectionKey, entries] of Object.entries(fragmentYaml)) { + sections[sectionKey as SectionKey].push(...entries); + } + } + return { sections, fragmentPaths }; +} + +async function moveFragments(fragmentPaths: Dirent[]): Promise { + // create folder for temp fragments at fragmentTempDirPath + await mkdir(fragmentTempDirPath, { recursive: true }); + + // Move fragment files to temp fragments folder + for (const fragmentFilename of fragmentPaths) { + const fragmentPath = resolve(fragmentDirPath, fragmentFilename.name); + const fragmentTempPath = resolve(fragmentTempDirPath, fragmentFilename.name); + renameSync(fragmentPath, fragmentTempPath); + } +} + +function generateChangelog(sections: Changelog) { + // Generate changelog sections + const changelogSections = Object.entries(sections).map(([sectionKey, entries]) => { + const sectionName = SECTION_MAPPING[sectionKey as SectionKey]; + return entries.length === 0 + ? `### ${sectionName}` + : `### ${sectionName}\n\n${entries.map((entry) => ` - ${entry}`).join('\n')}`; + }); + + // Generate full changelog + const currentDate = getCurrentDateFormatted(); + const changelog = `## [${pkgVersion}-${currentDate}]( + ${changelogSections.join('\n\n')} + `; + // Update changelog file + addContentAfterUnreleased(filePath, changelog); + return changelogSections; +} + +function generateReleaseNote(changelogSections: string[]) { + // Generate release note + const releaseNoteFilename = `opensearch-dashboards.release-notes-${pkgVersion}.md`; + const releaseNoteHeader = `# VERSION ${pkgVersion} Release Note`; + const releaseNote = `${releaseNoteHeader}\n\n${changelogSections.join('\n\n')}`; + writeFileSync(resolve(releaseNotesDirPath, releaseNoteFilename), releaseNote); +} + +(async () => { + const { sections, fragmentPaths } = await readFragments(); + + // move fragments to temp fragments folder + await moveFragments(fragmentPaths); + + const changelogSections = generateChangelog(sections); + + generateReleaseNote(changelogSections); + + // remove temp fragments folder + await deleteFragments(); +})(); diff --git a/src/dev/generate_release_note_helper.ts b/src/dev/generate_release_note_helper.ts new file mode 100644 index 000000000000..0f73122755f7 --- /dev/null +++ b/src/dev/generate_release_note_helper.ts @@ -0,0 +1,68 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { resolve } from 'path'; + +export const filePath = resolve(__dirname, '..', '..', 'CHANGELOG.md'); +export const fragmentDirPath = resolve(__dirname, '..', '..', 'changelogs', 'fragments'); +export const fragmentTempDirPath = resolve(__dirname, '..', '..', 'changelogs', 'temp_fragments'); +export const releaseNotesDirPath = resolve(__dirname, '..', '..', 'release-notes'); + +export function getCurrentDateFormatted(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth() + 1; + const day = now.getDate(); + + const formattedMonth = month.toString().padStart(2, '0'); + const formattedDay = day.toString().padStart(2, '0'); + + return `${year}-${formattedMonth}-${formattedDay}`; +} + +export const SECTION_MAPPING = { + breaking: '💥 Breaking Changes', + deprecate: 'Deprecations', + security: '🛡 Security', + feat: '📈 Features/Enhancements', + fix: '🐛 Bug Fixes', + infra: '🚞 Infrastructure', + doc: '📝 Documentation', + chore: '🛠 Maintenance', + refactor: '🪛 Refactoring', + test: '🔩 Tests', +}; + +export type SectionKey = keyof typeof SECTION_MAPPING; +export type Changelog = Record; + +const MAX_ENTRY_LENGTH = 100; + +// validate format of fragment files +export function validateFragment(content: string) { + const sections = content.split('\n\n'); + + // validate each section + for (const section of sections) { + const lines = section.split('\n'); + const sectionName = lines[0]; + const sectionKey = sectionName.slice(0, -1); + + if (!SECTION_MAPPING[sectionKey as SectionKey] || !sectionName.endsWith(':')) { + throw new Error(`Unknown section ${sectionKey}.`); + } + // validate entries. each entry must start with '-' and a space. then followed by a string. string must be non-empty and less than 50 characters + const entryRegex = new RegExp(`^-.{1,${MAX_ENTRY_LENGTH}}\\(\\[#.+]\\(.+\\)\\)$`); + for (const entry of lines.slice(1)) { + if (entry === '') { + continue; + } + // if (!entryRegex.test(entry)) { + if (!entryRegex.test(entry.trim())) { + throw new Error(`Invalid entry ${entry} in section ${sectionKey}.`); + } + } + } +}