Repository: vercel/next.js
Pull request: script: improve release log generation

Found it difficult to generate the backport release logs manually, hence I updated this script to make it easier. This improvment help generating the release log easier with the versions range, you can run it as 

```
node ./scripts/generate-release-log.mjs <last release version> <last backport commit>
```

e.g. where `v15.5.2` is last release tag and `db56d77` is the lastest commit of backport
```
node ./scripts/generate-release-log.mjs v15.5.2 db56d77
```

It will fetch the commits in between and generate the logs

```
Fetching commits between v15.5.2 and db56d77...
Found 3 commits between v15.5.2 and db56d77
After filtering: 3 commits to include
# Changes between v15.5.2 and db56d77

### Patches

- [backport] fix: remove `satisfies` keyword from type validation to preserve old TS compatibility (#83071) (bgub, fddaeb8)
- [backport] fix: relative paths in dev in validator.ts (#83073) (#83190) (bgub, 7a80623)
- [backport] fix: validation return types of pages API routes (#83069) (#83580) (bgub, db56d77)

### Credits

Huge thanks to @bgub for helping!
```

Changed files:
filename: scripts/generate-release-log.mjs
patch: @@ -1,6 +1,108 @@
+// @ts-check
 import fetch from 'node-fetch'
 
+// Helper function to create a fresh copy of the categories structure
+function createCategoriesStructure() {
+  const RELEASE_CATEGORIES = {
+    '### Core Changes': [],
+    '### Minor Changes': [],
+    '### Documentation Changes': [],
+    '### Example Changes': [],
+    '### Misc Changes': [],
+    '### Patches': [],
+    '### Credits': [],
+  }
+  return RELEASE_CATEGORIES
+}
+
 async function main() {
+  // Check if we have command line arguments for version comparison
+  const args = process.argv.slice(2)
+
+  if (args.length === 2) {
+    // Mode 1: Compare between two versions/commits
+    const [fromVersion, toVersion] = args
+    return await generateLogBetweenVersions(fromVersion, toVersion)
+  } else if (args.length === 0) {
+    // Mode 2: Original behavior - generate logs for latest canary releases
+    return await generateLatestCanaryLogs()
+  } else {
+    console.error(
+      'Usage: node generate-release-log.mjs [fromVersion toVersion]'
+    )
+    console.error(
+      '  With no arguments: generates logs for latest canary releases'
+    )
+    console.error(
+      '  With two arguments: generates logs between two versions/commits'
+    )
+    process.exit(1)
+  }
+}
+
+async function generateLogBetweenVersions(fromVersion, toVersion) {
+  console.log(`Fetching commits between ${fromVersion} and ${toVersion}...`)
+
+  try {
+    // Use GitHub API to compare commits between two references
+    const response = await fetch(
+      `https://api.github.com/repos/vercel/next.js/compare/${fromVersion}...${toVersion}`
+    )
+
+    if (!response.ok) {
+      throw new Error(
+        `GitHub API error: ${response.status} ${response.statusText}`
+      )
+    }
+
+    const compareData = await response.json()
+    const commits = compareData.commits || []
+
+    console.log(
+      `Found ${commits.length} commits between ${fromVersion} and ${toVersion}`
+    )
+
+    // Filter out version bump commits and other non-meaningful commits
+    const filteredCommits = commits.filter((commit) => {
+      const message = commit.commit.message.split('\n')[0]
+      const author = commit.author?.login || commit.commit.author.name
+
+      // Skip version bump commits (usually just version number changes)
+      if (message.match(/^v\d+\.\d+\.\d+(-\w+\.\d+)?$/)) {
+        console.log(`Skipping version bump commit: ${message}`)
+        return false
+      }
+
+      // Skip automated release commits
+      if (author === 'vercel-release-bot' && message.match(/^v\d+\.\d+\.\d+/)) {
+        console.log(`Skipping automated release commit: ${message}`)
+        return false
+      }
+
+      return true
+    })
+
+    console.log(`After filtering: ${filteredCommits.length} commits to include`)
+
+    // Categorize commits based on their commit messages
+    const categorizedCommits = categorizeCommits(filteredCommits)
+
+    // Generate formatted output
+    const content = formatCommitLog(categorizedCommits, fromVersion, toVersion)
+
+    return {
+      fromVersion,
+      toVersion,
+      commitCount: filteredCommits.length,
+      content,
+    }
+  } catch (error) {
+    console.error('Error fetching commits:', error.message)
+    process.exit(1)
+  }
+}
+
+async function generateLatestCanaryLogs() {
   const releasesArray = await fetch(
     'https://api.github.com/repos/vercel/next.js/releases?per_page=100'
   ).then((r) => r.json())
@@ -24,15 +126,7 @@ async function main() {
 
   const releases = allReleases.filter((v) => v.tag_name.includes(targetVersion))
 
-  const lineItems = {
-    '### Core Changes': [],
-    '### Minor Changes': [],
-    '### Documentation Changes': [],
-    '### Example Changes': [],
-    '### Misc Changes': [],
-    '### Patches': [],
-    '### Credits': [],
-  }
+  const lineItems = createCategoriesStructure()
 
   Object.keys(lineItems).forEach((header) => {
     releases.forEach((release) => {
@@ -90,10 +184,13 @@ async function main() {
       let creditsMessage = `Huge thanks to `
 
       if (items.length > 1) {
-        creditsMessage += items.slice(0, items.length - 1).join(`, `)
+        creditsMessage += items
+          .slice(0, items.length - 1)
+          .map((name) => `@${name}`)
+          .join(`, `)
         creditsMessage += `, and `
       }
-      creditsMessage += items[items.length - 1]
+      creditsMessage += `@${items[items.length - 1]}`
       creditsMessage += ` for helping!`
 
       finalMessage.push(creditsMessage)
@@ -112,6 +209,105 @@ async function main() {
   }
 }
 
+function categorizeCommits(commits) {
+  const categories = createCategoriesStructure()
+
+  commits.forEach((commit) => {
+    const message = commit.commit.message
+    const author = commit.author?.login || commit.commit.author.name
+    const sha = commit.sha.substring(0, 7)
+
+    // Extract commit message without merge info
+    const cleanMessage = message.split('\n')[0]
+
+    // Categorize based on commit message patterns
+    if (cleanMessage.includes('feat:') || cleanMessage.includes('feature:')) {
+      categories['### Core Changes'].push(
+        `- ${cleanMessage} (${author}, ${sha})`
+      )
+    } else if (
+      cleanMessage.includes('fix:') ||
+      cleanMessage.includes('bugfix:')
+    ) {
+      categories['### Patches'].push(`- ${cleanMessage} (${author}, ${sha})`)
+    } else if (
+      cleanMessage.includes('docs:') ||
+      cleanMessage.includes('documentation:')
+    ) {
+      categories['### Documentation Changes'].push(
+        `- ${cleanMessage} (${author}, ${sha})`
+      )
+    } else if (
+      cleanMessage.includes('example:') ||
+      cleanMessage.includes('examples:')
+    ) {
+      categories['### Example Changes'].push(
+        `- ${cleanMessage} (${author}, ${sha})`
+      )
+    } else if (
+      cleanMessage.includes('chore:') ||
+      cleanMessage.includes('refactor:')
+    ) {
+      categories['### Minor Changes'].push(
+        `- ${cleanMessage} (${author}, ${sha})`
+      )
+    } else {
+      categories['### Misc Changes'].push(
+        `- ${cleanMessage} (${author}, ${sha})`
+      )
+    }
+
+    // Extract contributors for credits
+    if (author && !categories['### Credits'].includes(author)) {
+      categories['### Credits'].push(author)
+    }
+  })
+
+  return categories
+}
+
+function formatCommitLog(categorizedCommits, fromVersion, toVersion) {
+  let finalMessage = []
+
+  // Add header
+  finalMessage.push(`# Changes between ${fromVersion} and ${toVersion}`)
+  finalMessage.push('')
+
+  Object.keys(categorizedCommits).forEach((header) => {
+    const items = categorizedCommits[header]
+
+    if (!items.length) {
+      return
+    }
+
+    finalMessage.push(header)
+    finalMessage.push('')
+
+    if (header === '### Credits') {
+      const uniqueCredits = [...new Set(items)]
+      let creditsMessage = `Huge thanks to `
+
+      if (uniqueCredits.length > 1) {
+        creditsMessage += uniqueCredits
+          .slice(0, uniqueCredits.length - 1)
+          .map((name) => `@${name}`)
+          .join(`, `)
+        creditsMessage += `, and `
+      }
+      creditsMessage += `@${uniqueCredits[uniqueCredits.length - 1]}`
+      creditsMessage += ` for helping!`
+
+      finalMessage.push(creditsMessage)
+    } else {
+      items.forEach((item) => finalMessage.push(item))
+    }
+
+    finalMessage.push('')
+  })
+
+  return finalMessage.join('\n')
+}
+
 main().then((result) => {
   console.log(result.content)
 })