Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 71 additions & 44 deletions src/update-changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { strict as assert } from 'assert';
import execa from 'execa';

import type Changelog from './changelog';
import { Formatter } from './changelog';
import { Formatter, getKnownPropertyNames } from './changelog';
import { ChangeCategory, Version } from './constants';
import { parseChangelog } from './parse-changelog';
import { PackageRename } from './shared-types';
Expand All @@ -20,6 +20,9 @@ async function getMostRecentTag({
}: {
tagPrefixes: [string, ...string[]];
}) {
// Ensure we have all tags on remote
await runCommand('git', ['fetch', '--tags']);

let mostRecentTagCommitHash: string | null = null;
for (const tagPrefix of tagPrefixes) {
const revListArgs = [
Expand Down Expand Up @@ -157,6 +160,53 @@ async function getCommitHashesInRange(
return await runCommand('git', revListArgs);
}

type AddNewCommitsOptions = {
mostRecentTag: string | null;
repoUrl: string;
loggedPrNumbers: string[];
projectRootDirectory?: string;
};

/**
* Get the list of new change entries to add to a changelog.
*
* @param options - Options.
* @param options.mostRecentTag - The most recent tag.
* @param options.repoUrl - The GitHub repository URL for the current project.
* @param options.loggedPrNumbers - A list of all pull request numbers included in the relevant parsed changelog.
* @param options.projectRootDirectory - The root project directory, used to
* filter results from various git commands. This path is assumed to be either
* absolute, or relative to the current directory. Defaults to the root of the
* current git repository.
* @returns A list of new change entries to add to the changelog, based on commits made since the last release.
*/
async function getNewChangeEntries({
mostRecentTag,
repoUrl,
loggedPrNumbers,
projectRootDirectory,
}: AddNewCommitsOptions) {
const commitRange =
mostRecentTag === null ? 'HEAD' : `${mostRecentTag}..HEAD`;
Copy link
Contributor

@kanthesha kanthesha Feb 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a possibility to be little more flexible here!

Suggested change
mostRecentTag === null ? 'HEAD' : `${mostRecentTag}..HEAD`;
!mostRecentTag ? 'HEAD' : `${mostRecentTag}..HEAD`;

Copy link
Contributor Author

@MajorLift MajorLift Feb 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing these out! Unfortunately, this one results in a linter error:

Unexpected negated condition. eslint(no-negated-condition)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about this?

Suggested change
mostRecentTag === null ? 'HEAD' : `${mostRecentTag}..HEAD`;
mostRecentTag ? `${mostRecentTag}..HEAD` : 'HEAD';

Copy link
Contributor Author

@MajorLift MajorLift Feb 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would work, but I would prefer to prioritize type safety over brevity here, since mostRecentTag is derived from the output of running an actual git command in shell. There are no strong guarantees on what the runtime output will be, so we can't completely rule out edge cases where e.g. an empty string or undefined is returned.

This probably aligns with the original motivation for getMostRecentTag returning null in its early exit branch. It represents the exception case where we know for sure that no tag exists, which is distinct from the error case where some tags exist, but the most recent tag could not be found due to a failure or edge case. This second case would be better represented by an undefined.

What are your thoughts? In general, I feel less comfortable replacing === null with !, because our codebase mostly uses undefined, which makes me assume that any null usage is intentional.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, we did use null intentionally to represent lack of a most recent tag, so the use of null matches the type that getMostRecentTag returns. However, I suppose that the tag name could be an empty string in theory if for some reason git describe returns an empty string, and that case we would want to fall back to HEAD. So, we could update getMostRecentTag to just return string instead of string | null, except that in case there is no recent tag, it would then return '';. That might look strange, but we could add a comment saying why we're doing that or give that empty string a name that describe its purpose.

In any case, it seems that this line was merely copied from another function, so maybe we want to make that change in another PR?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like using null for clear distinction and visibility, but in any case I agree with revisiting this in its own ticket since this wasn't a change introduced by this PR.

const commitsHashesSinceLastRelease = await getCommitHashesInRange(
commitRange,
projectRootDirectory,
);
const commits = await getCommits(commitsHashesSinceLastRelease);

const newCommits = commits.filter(
({ prNumber }) => !prNumber || !loggedPrNumbers.includes(prNumber),
);

return newCommits.map(({ prNumber, description }) => {
if (prNumber) {
const suffix = `([#${prNumber}](${repoUrl}/pull/${prNumber}))`;
return `${description} ${suffix}`;
}
return description;
});
}

export type UpdateChangelogOptions = {
changelogContent: string;
currentVersion?: Version;
Expand Down Expand Up @@ -213,43 +263,17 @@ export async function updateChangelog({
packageRename,
});

// Ensure we have all tags on remote
await runCommand('git', ['fetch', '--tags']);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is moved to getMostRecentTag().

const mostRecentTag = await getMostRecentTag({
tagPrefixes,
});

const commitRange =
mostRecentTag === null ? 'HEAD' : `${mostRecentTag}..HEAD`;
const commitsHashesSinceLastRelease = await getCommitHashesInRange(
commitRange,
projectRootDirectory,
);
const commits = await getCommits(commitsHashesSinceLastRelease);

const loggedPrNumbers = getAllLoggedPrNumbers(changelog);
const newCommits = commits.filter(
({ prNumber }) =>
prNumber === undefined || !loggedPrNumbers.includes(prNumber),
);
Comment on lines -222 to -234
Copy link
Contributor Author

@MajorLift MajorLift Feb 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section is moved to getNewChangeEntries().


const hasUnreleasedChanges =
Object.keys(changelog.getUnreleasedChanges()).length !== 0;
if (
newCommits.length === 0 &&
(!isReleaseCandidate || hasUnreleasedChanges)
) {
return undefined;
}
Comment on lines -238 to -243
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously, the undefined return branch was located here.


if (isReleaseCandidate) {
if (!currentVersion) {
throw new Error(
`A version must be specified if 'isReleaseCandidate' is set.`,
);
}

if (mostRecentTag === `${tagPrefixes[0]}${currentVersion ?? ''}`) {
if (mostRecentTag === `${tagPrefixes[0]}${currentVersion}`) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currentVersion is narrowed to string thanks to the if (!currentVersion) block being moved directly above. Previously as string or ?? '' was required.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I just realized that we only use the first tag prefix here, which seems strange. This may be a potential bug. But we can investigate that later.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The jsdoc does specify that the first prefix is the intended prefix and the rest are fallbacks. parseChangelog also only uses the first prefix.

getMostRecentTag does iterate over the tagPrefix list. It should return the used prefix, or we could extract the prefix from the returned most recent tag.

But this also wasn't a change introduced in this PR, so it should get its own ticket.

throw new Error(
`Current version already has a tag ('${mostRecentTag}'), which is unexpected for a release candidate.`,
);
Expand All @@ -264,28 +288,31 @@ export async function updateChangelog({
changelog.addRelease({ version: currentVersion });
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currentVersion is correctly narrowed due to all related branches being refactored to fall under the if (isReleaseCandidate) block instead of there being multiple if (isReleaseCandidate &&) blocks. currentVersion as Version casting is now unnecessary.

}

if (hasUnreleasedChanges) {
const hasUnreleasedChangesToRelease =
getKnownPropertyNames(changelog.getUnreleasedChanges()).length > 0;
if (hasUnreleasedChangesToRelease) {
changelog.migrateUnreleasedChangesToRelease(currentVersion);
}
}

const newChangeEntries = newCommits.map(({ prNumber, description }) => {
if (prNumber) {
const suffix = `([#${prNumber}](${repoUrl}/pull/${prNumber}))`;
return `${description} ${suffix}`;
}
return description;
});
Comment on lines -271 to -277
Copy link
Contributor Author

@MajorLift MajorLift Feb 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section is moved to getNewChangeEntries().

const newChangeEntries = await getNewChangeEntries({
mostRecentTag,
repoUrl,
loggedPrNumbers: getAllLoggedPrNumbers(changelog),
projectRootDirectory,
});

for (const description of newChangeEntries.reverse()) {
changelog.addChange({
version: isReleaseCandidate ? currentVersion : undefined,
category: ChangeCategory.Uncategorized,
description,
});
}
for (const description of newChangeEntries.reverse()) {
changelog.addChange({
version: isReleaseCandidate ? currentVersion : undefined,
category: ChangeCategory.Uncategorized,
description,
});
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the bracket that caused the bug. Moving it to above the getNewChangeEntries call resolves the error.


return changelog.toString();
const newChangelogContent = changelog.toString();
const isChangelogUpdated = changelogContent !== newChangelogContent;
return isChangelogUpdated ? newChangelogContent : undefined;
Comment on lines +313 to +315
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update() in cli.ts expects that updateChangelog return undefined if there are no updates to be added to the changelog.

}

/**
Expand Down