-
Notifications
You must be signed in to change notification settings - Fork 56
feat(ng-dev/release): add marker between generated changelog entries #212
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
ed9bc0c
feat(ng-dev/release): add marker between generated changelog entries
josephperrott 442a02c
fixup! feat(ng-dev/release): add marker between generated changelog e…
josephperrott 7d7ef15
fixup! feat(ng-dev/release): add marker between generated changelog e…
josephperrott c1f5098
fixup! feat(ng-dev/release): add marker between generated changelog e…
josephperrott File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
import {existsSync, readFileSync} from 'fs'; | ||
import {SemVer} from 'semver'; | ||
import {dedent} from '../../utils/testing/dedent'; | ||
import {getMockGitClient} from '../publish/test/test-utils/git-client-mock'; | ||
import {Changelog, splitMarker} from './changelog'; | ||
|
||
describe('Changelog', () => { | ||
let changelog: Changelog; | ||
|
||
beforeEach(() => { | ||
const gitClient = getMockGitClient( | ||
{owner: 'angular', name: 'dev-infra-test', mainBranchName: 'main'}, | ||
/* useSandboxGitClient */ false, | ||
); | ||
changelog = new Changelog(gitClient); | ||
}); | ||
|
||
it('throws an error if it cannot find the anchor containing the version for an entry', () => { | ||
expect(() => changelog.prependEntryToChangelog('does not have version <a> tag')).toThrow(); | ||
}); | ||
|
||
it('throws an error if it cannot determine the version for an entry', () => { | ||
expect(() => changelog.prependEntryToChangelog(createChangelogEntry('NotSemVer'))).toThrow(); | ||
}); | ||
|
||
it('concatenates the changelog entries into the changelog file with the split marker between', () => { | ||
changelog.prependEntryToChangelog(createChangelogEntry('1.0.0')); | ||
changelog.prependEntryToChangelog(createChangelogEntry('2.0.0')); | ||
changelog.prependEntryToChangelog(createChangelogEntry('3.0.0')); | ||
|
||
expect(readFileAsString(changelog.filePath)).toBe( | ||
dedent` | ||
<a name="3.0.0"></a> | ||
|
||
${splitMarker} | ||
|
||
<a name="2.0.0"></a> | ||
|
||
${splitMarker} | ||
|
||
<a name="1.0.0"></a> | ||
`.trim(), | ||
); | ||
|
||
changelog.moveEntriesPriorToVersionToArchive(new SemVer('3.0.0')); | ||
|
||
expect(readFileAsString(changelog.archiveFilePath)).toBe( | ||
dedent` | ||
<a name="2.0.0"></a> | ||
|
||
${splitMarker} | ||
|
||
<a name="1.0.0"></a> | ||
`.trim(), | ||
); | ||
|
||
expect(readFileAsString(changelog.filePath)).toBe(`<a name="3.0.0"></a>`); | ||
}); | ||
|
||
describe('adds entries to the changelog', () => { | ||
it('creates a new changelog file if one does not exist.', () => { | ||
expect(existsSync(changelog.filePath)).toBe(false); | ||
|
||
changelog.prependEntryToChangelog(createChangelogEntry('0.0.0')); | ||
expect(existsSync(changelog.filePath)).toBe(true); | ||
}); | ||
|
||
it('should not include a split marker when only one changelog entry is in the changelog.', () => { | ||
changelog.prependEntryToChangelog(createChangelogEntry('0.0.0')); | ||
|
||
expect(readFileAsString(changelog.filePath)).not.toContain(splitMarker); | ||
}); | ||
|
||
it('separates multiple changelog entries using a standard split marker', () => { | ||
for (let i = 0; i < 2; i++) { | ||
changelog.prependEntryToChangelog(createChangelogEntry(`0.0.${i}`)); | ||
} | ||
|
||
expect(readFileAsString(changelog.filePath)).toContain(splitMarker); | ||
}); | ||
}); | ||
|
||
describe('adds entries to the changelog archive', () => { | ||
it('only updates or creates the changelog archive if necessary', () => { | ||
changelog.prependEntryToChangelog(createChangelogEntry('1.0.0')); | ||
expect(existsSync(changelog.archiveFilePath)).toBe(false); | ||
|
||
changelog.moveEntriesPriorToVersionToArchive(new SemVer('1.0.0')); | ||
expect(existsSync(changelog.archiveFilePath)).toBe(false); | ||
|
||
changelog.moveEntriesPriorToVersionToArchive(new SemVer('2.0.0')); | ||
expect(existsSync(changelog.archiveFilePath)).toBe(true); | ||
}); | ||
|
||
it('from the primary changelog older than a provided version', () => { | ||
changelog.prependEntryToChangelog(createChangelogEntry('1.0.0', 'This is version 1')); | ||
changelog.prependEntryToChangelog(createChangelogEntry('2.0.0', 'This is version 2')); | ||
changelog.prependEntryToChangelog(createChangelogEntry('3.0.0', 'This is version 3')); | ||
|
||
changelog.moveEntriesPriorToVersionToArchive(new SemVer('3.0.0')); | ||
expect(readFileAsString(changelog.archiveFilePath)).toContain('version 1'); | ||
expect(readFileAsString(changelog.archiveFilePath)).toContain('version 2'); | ||
expect(readFileAsString(changelog.archiveFilePath)).not.toContain('version 3'); | ||
}); | ||
}); | ||
}); | ||
|
||
function readFileAsString(file: string) { | ||
return readFileSync(file, {encoding: 'utf8'}); | ||
} | ||
|
||
function createChangelogEntry(version: string, content = '') { | ||
return dedent` | ||
<a name="${version}"></a> | ||
${content} | ||
`; | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,142 @@ | ||
import {existsSync, readFileSync, writeFileSync} from 'fs'; | ||
import {join} from 'path'; | ||
import * as semver from 'semver'; | ||
import {GitClient} from '../../utils/git/git-client'; | ||
|
||
/** Project-relative path for the changelog file. */ | ||
export const changelogPath = 'CHANGELOG.md'; | ||
|
||
/** Project-relative path for the changelog archive file. */ | ||
export const changelogArchivePath = 'CHANGELOG_ARCHIVE.md'; | ||
|
||
/** A marker used to split a CHANGELOG.md file into individual entries. */ | ||
export const splitMarker = '<!-- CHANGELOG SPLIT MARKER -->'; | ||
|
||
/** | ||
* A string to use between each changelog entry when joining them together. | ||
* | ||
* Since all every changelog entry's content is trimmed, when joining back together, two new lines | ||
* must be placed around the splitMarker to create a one line buffer around the comment in the | ||
* markdown. | ||
* i.e. | ||
* <changelog entry content> | ||
* | ||
* <!-- CHANGELOG SPLIT MARKER --> | ||
* | ||
* <changelog entry content> | ||
*/ | ||
const joinMarker = `\n\n${splitMarker}\n\n`; | ||
|
||
/** A RegExp matcher to extract the version of a changelog entry from the entry content. */ | ||
const versionAnchorMatcher = new RegExp(`<a name="(.*)"></a>`); | ||
|
||
/** An individual changelog entry. */ | ||
interface ChangelogEntry { | ||
content: string; | ||
version: semver.SemVer; | ||
} | ||
|
||
export class Changelog { | ||
/** The absolute path to the changelog file. */ | ||
readonly filePath = join(this.git.baseDir, changelogPath); | ||
/** The absolute path to the changelog archive file. */ | ||
readonly archiveFilePath = join(this.git.baseDir, changelogArchivePath); | ||
/** The changelog entries in the CHANGELOG.md file. */ | ||
private entries = this.getEntriesFor(this.filePath); | ||
/** | ||
* The changelog entries in the CHANGELOG_ARCHIVE.md file. | ||
* Delays reading the CHANGELOG_ARCHIVE.md file until it is actually used. | ||
*/ | ||
private get archiveEntries() { | ||
if (this._archiveEntries === undefined) { | ||
return (this._archiveEntries = this.getEntriesFor(this.archiveFilePath)); | ||
} | ||
return this._archiveEntries; | ||
} | ||
private _archiveEntries: undefined | ChangelogEntry[] = undefined; | ||
|
||
constructor(private git: GitClient) {} | ||
|
||
/** Prepend a changelog entry to the changelog. */ | ||
prependEntryToChangelog(entry: string) { | ||
this.entries.unshift(parseChangelogEntry(entry)); | ||
this.writeToChangelogFile(); | ||
} | ||
|
||
/** | ||
* Move all changelog entries from the CHANGELOG.md file for versions prior to the provided | ||
* version to the changelog archive. | ||
* | ||
* Versions should be used to determine which entries are moved to archive as versions are the | ||
* most accurate piece of context found within a changelog entry to determine its relationship to | ||
* other changelog entries. This allows for example, moving all changelog entries out of the | ||
* main changelog when a version moves out of support. | ||
*/ | ||
moveEntriesPriorToVersionToArchive(version: semver.SemVer) { | ||
josephperrott marked this conversation as resolved.
Show resolved
Hide resolved
|
||
[...this.entries].reverse().forEach((entry: ChangelogEntry) => { | ||
if (semver.lt(entry.version, version)) { | ||
this.archiveEntries.unshift(entry); | ||
this.entries.splice(this.entries.indexOf(entry), 1); | ||
} | ||
}); | ||
|
||
this.writeToChangelogFile(); | ||
if (this.archiveEntries.length) { | ||
this.writeToChangelogArchiveFile(); | ||
} | ||
} | ||
|
||
/** Update the changelog archive file with the known changelog archive entries. */ | ||
private writeToChangelogArchiveFile(): void { | ||
const changelogArchive = this.archiveEntries.map((entry) => entry.content).join(joinMarker); | ||
writeFileSync(this.archiveFilePath, changelogArchive); | ||
} | ||
|
||
/** Update the changelog file with the known changelog entries. */ | ||
private writeToChangelogFile(): void { | ||
const changelog = this.entries.map((entry) => entry.content).join(joinMarker); | ||
writeFileSync(this.filePath, changelog); | ||
} | ||
|
||
/** | ||
* Retrieve the changelog entries for the provide changelog path, if the file does not exist an | ||
* empty array is returned. | ||
*/ | ||
private getEntriesFor(path: string): ChangelogEntry[] { | ||
if (!existsSync(path)) { | ||
return []; | ||
} | ||
|
||
return ( | ||
readFileSync(path, {encoding: 'utf8'}) | ||
// Use the versionMarker as the separator for .split(). | ||
.split(splitMarker) | ||
// If the `split()` method finds the separator at the beginning or end of a string, it | ||
// includes an empty string at the respective locaiton, so we filter to remove all of these | ||
// potential empty strings. | ||
.filter((entry) => entry.trim().length !== 0) | ||
// Create a ChangelogEntry for each of the string entry. | ||
.map(parseChangelogEntry) | ||
); | ||
} | ||
} | ||
|
||
/** Parse the provided string into a ChangelogEntry object. */ | ||
function parseChangelogEntry(content: string): ChangelogEntry { | ||
const versionMatcherResult = versionAnchorMatcher.exec(content); | ||
if (versionMatcherResult === null) { | ||
throw Error(`Unable to determine version for changelog entry: ${content}`); | ||
} | ||
const version = semver.parse(versionMatcherResult[1]); | ||
|
||
if (version === null) { | ||
throw Error( | ||
`Unable to determine version for changelog entry, with tag: ${versionMatcherResult[1]}`, | ||
); | ||
} | ||
|
||
return { | ||
content: content.trim(), | ||
version, | ||
}; | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm a bit unhappy about the reliance on some template-specifics here. It will become problematic if we make changes of the anchor. e.g. even when we switch from
<a name
to<a id
(which is actually better for fragment resolution in w3c).If you see my comment on
moveEntriesPriorToVersionToArchive
, an option is that we might not need the version at all, or alternatively we could incorporate the version in a specific comment that is generated here as part of theChangelog
class (that keeps the logic more local and self-contained)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we include a metadata comment object? Something like.
<!--ReleaseMetadata {version: '1.2.3', name: 'release name'} -->
Then we can retrieve it with a regex and parse the results. We can then
JSON.parse
the matched content into something likePartial<ReleaseMetadata>
where we haveThere would need to be more validation/assertions I suppose, but this concept.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah, something around that is what I'd love to see. Could also be just something like:
Doesn't necessarily need to be JSON. I'm happy with either thing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it makes sense to use JSON since it will make parsing easier. It won't be as whitespace sensitive and is easy to roll and unroll.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually as I am looking at it now, I think it kind of ends up being a lot more change than we currently have to set this up, how do you feel about doing it in a follow up PR? It just feels like it ends up nearly unrelated to the current change.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sure, works for me!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Creating tracking issue at: #219