diff --git a/.eslintignore b/.eslintignore index e813d1917..d9fb8510c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -846,6 +846,7 @@ packages/tools/convertThemesToCss.js packages/tools/generate-database-types.js packages/tools/generate-images.js packages/tools/git-changelog.js +packages/tools/git-changelog.test.js packages/tools/licenseChecker.js packages/tools/release-android.js packages/tools/release-cli.js diff --git a/.gitignore b/.gitignore index 862cda7d4..f072aae6d 100644 --- a/.gitignore +++ b/.gitignore @@ -834,6 +834,7 @@ packages/tools/convertThemesToCss.js packages/tools/generate-database-types.js packages/tools/generate-images.js packages/tools/git-changelog.js +packages/tools/git-changelog.test.js packages/tools/licenseChecker.js packages/tools/release-android.js packages/tools/release-cli.js diff --git a/packages/tools/buildServerDocker.ts b/packages/tools/buildServerDocker.ts index 91b6c68c1..e88cb6b04 100644 --- a/packages/tools/buildServerDocker.ts +++ b/packages/tools/buildServerDocker.ts @@ -71,7 +71,7 @@ async function main() { } if (require.main === module) { -// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied + // eslint-disable-next-line promise/prefer-await-to-then main().catch((error) => { console.error('Fatal error'); console.error(error); diff --git a/packages/tools/git-changelog.test.ts b/packages/tools/git-changelog.test.ts new file mode 100644 index 000000000..b4c3b3960 --- /dev/null +++ b/packages/tools/git-changelog.test.ts @@ -0,0 +1,84 @@ +import { expectThrow } from '@joplin/lib/testing/test-utils'; +import { filesApplyToPlatform, parseRenovateMessage, RenovateMessage, summarizeRenovateMessages } from './git-changelog'; + +describe('git-changelog', () => { + + test('should find out if a file path is relevant to a platform', async () => { + type TestCase = [string[], string, boolean]; + + const testCases: TestCase[] = [ + [['packages/app-mobile/package.json'], 'ios', true], + [['packages/app-mobile/package.json'], 'android', true], + [['packages/app-mobile/package.json'], 'destop', false], + [[], 'destop', false], + [['packages/server/package.json'], 'server', true], + [['packages/app-mobile/package.json', 'packages/server/package.json'], 'server', true], + [['packages/app-mobile/package.json', 'packages/server/package.json'], 'android', true], + [['packages/app-mobile/package.json', 'packages/server/package.json'], 'desktop', false], + [['packages/server/package.json'], 'desktop', false], + [['packages/lib/package.json'], 'server', true], + [['packages/lib/package.json'], 'desktop', true], + [['packages/lib/package.json'], 'android', true], + [['packages/lib/package.json'], 'clipper', false], + [['packages/app-clipper/package.json'], 'clipper', true], + ]; + + for (const testCase of testCases) { + const [files, platform, expected] = testCase; + const actual = filesApplyToPlatform(files, platform); + expect(actual).toBe(expected); + } + }); + + test('should parse Renovate messages', async () => { + type TestCase = [string, string, string]; + + const testCases: TestCase[] = [ + ['Update typescript-eslint monorepo to v5 (#7291)', 'typescript-eslint', 'v5'], + ['Update aws-sdk-js-v3 monorepo to v3.215.0', 'aws-sdk-js-v3', 'v3.215.0'], + ['Update dependency moment to v2.29.4 (#7087)', 'moment', 'v2.29.4'], + ]; + + for (const testCase of testCases) { + const [message, pkg, version] = testCase; + const actual = parseRenovateMessage(message); + expect(actual.package).toBe(pkg); + expect(actual.version).toBe(version); + } + + await expectThrow(async () => parseRenovateMessage('not a renovate message')); + }); + + test('should summarize Renovate messages', async () => { + type TestCase = [RenovateMessage[], string]; + + const testCases: TestCase[] = [ + [ + [ + { package: 'sas', version: 'v1.0' }, + { package: 'sas', version: 'v1.2' }, + { package: 'moment', version: 'v3.4' }, + { package: 'eslint', version: 'v1.2' }, + ], + 'Updated packages moment (v3.4), sas (v1.2)', + ], + [ + [ + { package: 'eslint', version: 'v1.2' }, + ], + '', + ], + [ + [], + '', + ], + ]; + + for (const testCase of testCases) { + const [messages, expected] = testCase; + const actual = summarizeRenovateMessages(messages); + expect(actual).toBe(expected); + } + }); + +}); diff --git a/packages/tools/git-changelog.ts b/packages/tools/git-changelog.ts index 64d0020c1..73c9bd016 100644 --- a/packages/tools/git-changelog.ts +++ b/packages/tools/git-changelog.ts @@ -8,6 +8,7 @@ interface LogEntry { message: string; commit: string; author: Author; + files: string[]; } enum Platform { @@ -58,6 +59,10 @@ async function gitLog(sinceTag: string) { const authorEmail = splitted[1]; const authorName = splitted[2]; const message = splitted[3].trim(); + let files: string[] = []; + + const filesResult = await execCommand(`git diff-tree --no-commit-id --name-only ${commit} -r`); + files = filesResult.split('\n').map(s => s.trim()).filter(s => !!s); output.push({ commit: commit, @@ -67,6 +72,7 @@ async function gitLog(sinceTag: string) { name: authorName, login: await githubUsername(authorEmail, authorName), }, + files, }); } @@ -91,6 +97,102 @@ function platformFromTag(tagName: string): Platform { throw new Error(`Could not determine platform from tag: "${tagName}"`); } +export const filesApplyToPlatform = (files: string[], platform: string): boolean => { + const isMainApp = ['android', 'ios', 'desktop', 'cli', 'server'].includes(platform); + const isMobile = ['android', 'ios'].includes(platform); + + for (const file of files) { + if (file.startsWith('packages/app-cli') && platform === 'cli') return true; + if (file.startsWith('packages/app-clipper') && platform === 'clipper') return true; + if (file.startsWith('packages/app-mobile') && isMobile) return true; + if (file.startsWith('packages/app-desktop') && platform === 'desktop') return true; + if (file.startsWith('packages/fork-htmlparser2') && isMainApp) return true; + if (file.startsWith('packages/fork-uslug') && isMainApp) return true; + if (file.startsWith('packages/htmlpack') && isMainApp) return true; + if (file.startsWith('packages/lib') && isMainApp) return true; + if (file.startsWith('packages/pdf-viewer') && platform === 'desktop') return true; + if (file.startsWith('packages/react-native-') && isMobile) return true; + if (file.startsWith('packages/renderer') && isMainApp) return true; + if (file.startsWith('packages/server') && platform === 'server') return true; + if (file.startsWith('packages/tools') && isMainApp) return true; + if (file.startsWith('packages/turndown') && isMainApp) return true; + } + + return false; +}; + +export interface RenovateMessage { + package: string; + version: string; +} + +export const parseRenovateMessage = (message: string): RenovateMessage => { + const regexes = [ + /^Update dependency ([^\s]+) to ([^\s]+)/, + /^Update ([^\s]+) monorepo to ([^\s]+)/, + ]; + + for (const regex of regexes) { + const m = message.match(regex); + + if (m) { + return { + package: m[1], + version: m[2], + }; + } + } + + throw new Error(`Not a Renovate message: ${message}`); +}; + +export const summarizeRenovateMessages = (messages: RenovateMessage[]): string => { + // Exclude some dev dependencies + messages = messages.filter(m => { + if ([ + 'yeoman-generator', + 'madge', + 'lint-staged', + 'gettext-extractor', + 'gettext-extractor', + 'ts-jest', + 'ts-node', + 'typescript', + 'eslint', + 'jest', + ].includes(m.package)) { + return false; + } + + if (m.package.startsWith('@types/')) return false; + if (m.package.startsWith('typescript-')) return false; + + return true; + }); + + const temp: Record = {}; + for (const message of messages) { + if (!temp[message.package]) { + temp[message.package] = message.version; + } else { + if (message.version > temp[message.package]) { + temp[message.package] = message.version; + } + } + } + + const temp2: string[] = []; + for (const [pkg, version] of Object.entries(temp)) { + temp2.push(`${pkg} (${version})`); + } + + temp2.sort(); + + if (temp2.length) return `Updated packages ${temp2.join(', ')}`; + + return ''; +}; + function filterLogs(logs: LogEntry[], platform: Platform) { const output: LogEntry[] = []; const revertedLogs = []; @@ -98,6 +200,8 @@ function filterLogs(logs: LogEntry[], platform: Platform) { // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars // let updatedTranslations = false; + const renovateMessages: RenovateMessage[] = []; + for (const log of logs) { // Save to an array any commit that has been reverted, and exclude @@ -110,9 +214,18 @@ function filterLogs(logs: LogEntry[], platform: Platform) { if (revertedLogs.indexOf(log.message) >= 0) continue; + let isRenovate = false; let prefix = log.message.trim().toLowerCase().split(':'); - if (prefix.length <= 1) continue; - prefix = prefix[0].split(',').map(s => s.trim()); + if (prefix.length <= 1) { + if (log.author && log.author.name === 'renovate[bot]') { + prefix = ['renovate']; + isRenovate = true; + } else { + continue; + } + } else { + prefix = prefix[0].split(',').map(s => s.trim()); + } let addIt = false; @@ -128,6 +241,11 @@ function filterLogs(logs: LogEntry[], platform: Platform) { if (platform === 'server' && prefix.indexOf('server') >= 0) addIt = true; if (platform === 'cloud' && (prefix.indexOf('cloud') >= 0 || prefix.indexOf('server') >= 0)) addIt = true; + if (isRenovate && filesApplyToPlatform(log.files, platform)) { + renovateMessages.push(parseRenovateMessage(log.message)); + addIt = false; + } + // Translation updates often comes in format "Translation: Update pt_PT.po" // but that's not useful in a changelog especially since most people // don't know country and language codes. So we catch all these and @@ -148,6 +266,16 @@ function filterLogs(logs: LogEntry[], platform: Platform) { // Actually we don't really need this info - translations are being updated all the time // if (updatedTranslations) output.push({ message: 'Updated translations' }); + const renovateSummary = summarizeRenovateMessages(renovateMessages); + if (renovateSummary) { + output.push({ + author: { name: '', email: '', login: '' }, + commit: '', + files: [], + message: renovateSummary, + }); + } + return output; } @@ -281,7 +409,9 @@ function formatCommitMessage(commit: string, msg: string, author: Author, option } else { const commitStrings = [commit.substr(0, 7)]; if (authorMd) commitStrings.push(`by ${authorMd}`); - output += ` (${commitStrings.join(' ')})`; + if (commitStrings.join('').length) { + output += ` (${commitStrings.join(' ')})`; + } } } @@ -379,8 +509,11 @@ async function main() { console.info(changelogString.join('\n')); } -main().catch((error) => { - console.error('Fatal error'); - console.error(error); - process.exit(1); -}); +if (require.main === module) { + // eslint-disable-next-line promise/prefer-await-to-then + main().catch((error) => { + console.error('Fatal error'); + console.error(error); + process.exit(1); + }); +}