diff --git a/scripts/release-notes/package.json b/scripts/release-notes/package.json
index 32a90c9a..bf4765d7 100644
--- a/scripts/release-notes/package.json
+++ b/scripts/release-notes/package.json
@@ -1,5 +1,6 @@
{
"private": true,
+ "type": "module",
"dependencies": {
"@anthropic-ai/sdk": "^0.25.0",
"@octokit/rest": "^21.0.2",
diff --git a/scripts/release-notes/src/legacy.ts b/scripts/release-notes/src/legacy.ts
new file mode 100644
index 00000000..9bf023f8
--- /dev/null
+++ b/scripts/release-notes/src/legacy.ts
@@ -0,0 +1,305 @@
+import { Anthropic } from '@anthropic-ai/sdk'
+import { verifyEnvVars } from '@radashi-org/common/verifyEnvVars.ts'
+import { execa } from 'execa'
+import type mri from 'mri'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { uid } from 'radashi/random/uid.ts'
+import { dedent } from 'radashi/string/dedent.ts'
+
+export async function legacyGenerateReleaseNotes(argv: mri.Argv) {
+ const outFile = argv.o || argv.output
+ const limit = argv.limit ? +argv.limit : Number.POSITIVE_INFINITY
+
+ if (!outFile && !argv.publish) {
+ console.error('Error: No --output file or --publish flag provided')
+ process.exit(1)
+ } else if (outFile && argv.publish) {
+ console.error('Error: Cannot use --output file and --publish flag together')
+ process.exit(1)
+ }
+
+ const { anthropicApiKey, githubToken } = verifyEnvVars({
+ anthropicApiKey: 'ANTHROPIC_API_KEY',
+ githubToken: !!argv.publish && 'GITHUB_TOKEN',
+ })
+
+ const version: string = JSON.parse(
+ fs.readFileSync('package.json', 'utf8'),
+ ).version
+
+ log('Generating release notes for v' + version)
+
+ /**
+ * If no commit ref is provided, use the latest two tags.
+ */
+ let commitRange: string
+ if (argv._.length) {
+ commitRange = 'v' + version + '..' + argv._[0]
+ } else {
+ const tags = await execa('git', [
+ 'tag',
+ '--format=%(refname:short)',
+ '--sort=-version:refname',
+ '-n',
+ 'v*',
+ ]).then(result =>
+ result.stdout
+ .split('\n')
+ .filter(tag => !tag.includes('-'))
+ .slice(0, 2),
+ )
+
+ const [currentVersion, previousVersion] = tags
+ commitRange = previousVersion + '..' + currentVersion
+ }
+
+ const commits = await execa('git', [
+ 'log',
+ '--format=%H %s',
+ commitRange,
+ ]).then(result =>
+ result.stdout
+ .trim()
+ .split('\n')
+ .map(line => {
+ const [, sha, message] = /^(\w+) (.+)$/.exec(line)!
+ return { sha, message, diff: '' }
+ }),
+ )
+
+ log('Grouping commits and fetching diffs...')
+
+ const sections = getSections()
+ for (const commit of commits) {
+ for (const section of sections) {
+ if (
+ section.match.test(commit.message) &&
+ !section.exclude?.test(commit.message)
+ ) {
+ const diff = await execa('git', [
+ 'log',
+ '-p',
+ commit.sha + '^..' + commit.sha,
+ 'src',
+ 'docs',
+ ])
+ commit.diff = diff.stdout
+ section.commits ??= []
+ section.commits.push(commit)
+ }
+ }
+ }
+
+ const anthropic = new Anthropic({
+ apiKey: anthropicApiKey,
+ })
+
+ for (const section of sections) {
+ if (!section.commits?.length) {
+ continue
+ }
+
+ log('Generating release notes for', section.name)
+
+ section.commits.length = Math.min(section.commits.length, limit)
+ section.notes = ''
+
+ const chunkSize = 4
+
+ for (let offset = 0; offset < section.commits.length; offset += chunkSize) {
+ log(`${section.commits.length - offset} commits left`)
+
+ const linkLocation =
+ section.noun === 'feature' || section.noun === 'fix'
+ ? 'immediately after the heading'
+ : 'at the end'
+
+ const rules = [
+ `You're tasked with writing in-depth release notes (using Markdown) in a professional tone.`,
+ 'Never converse with me.',
+ 'Always mention every change I give you.',
+ `Always link to the relevant PR (or the commit if there's no PR) ${linkLocation} of each ${section.noun} in a format like "[→ PR #110](…)" or "[→ commit {short-hash}](…)". The GitHub URL is "https://github.com/radashi-org/radashi".`,
+ `Never include headings like "Release Notes" or "v1.0.0".`,
+ ...section.rules(section.noun),
+ ]
+
+ log('Sending request to Anthropic...')
+
+ const response = await anthropic.messages.create({
+ model: 'claude-3-haiku-20240307',
+ max_tokens: 4096,
+ messages: [
+ {
+ role: 'user',
+ content: dedent`
+ - ${rules.join('\n- ')}
+
+ The following changes are from \`git log -p\`:
+
+
+ ${section.commits
+ .slice(offset, offset + chunkSize)
+ .map(commit => commit.diff)
+ .join('\n\n')}
+
+ `,
+ },
+ ],
+ })
+
+ const [message] = response.content
+ if (message.type !== 'text') {
+ console.error('Expected a text message, got:', message)
+ process.exit(1)
+ }
+
+ section.notes += message.text.trim() + '\n\n'
+ }
+ }
+
+ let notes = sections
+ .filter(section => section.notes)
+ .map(section => `## ${section.name}\n\n${section.notes}`)
+ .join('\n\n')
+
+ const tmpFile = path.join(os.tmpdir(), 'release-notes.' + uid(20) + '.md')
+ fs.writeFileSync(tmpFile, notes)
+
+ try {
+ const editor = await getPreferredEditor()
+ log('Opening', tmpFile, 'with', editor)
+
+ // Open the generated release notes in the user's preferred text editor
+ await execa(editor, [tmpFile], { stdio: 'inherit' })
+
+ // Read the potentially modified content after the editor is closed
+ notes = fs.readFileSync(tmpFile, 'utf-8')
+ } finally {
+ fs.unlinkSync(tmpFile)
+ }
+
+ if (argv.publish) {
+ const { Octokit } = await import('@octokit/rest')
+
+ const octokit = new Octokit({
+ auth: githubToken,
+ })
+
+ log('Publishing release notes for version', version)
+
+ try {
+ await octokit.rest.repos.createRelease({
+ owner: 'radashi-org',
+ repo: 'radashi',
+ tag_name: `v${version}`,
+ name: `v${version}`,
+ body: notes,
+ draft: !!argv.draft,
+ prerelease: !!argv.prerelease,
+ })
+
+ log('Successfully published release notes to GitHub')
+ } catch (error) {
+ console.error('Failed to publish release notes:', error)
+ process.exit(1)
+ }
+ } else {
+ fs.writeFileSync(outFile, notes)
+ log('Saved release notes to', path.resolve(outFile))
+ }
+}
+
+function getSections(): Section[] {
+ const getFormattingRules = (noun: string) => [
+ `Use an H4 (####) for the heading of each ${noun}.`,
+ 'Headings must be in sentence case.',
+ noun === 'feature' &&
+ `Each heading must describe what the ${noun} enables, not simply what the change is (e.g. "Allow throttled function to be triggered immediately" instead of "Add trigger method to throttle function").`,
+ 'Be concise but not vague.',
+ 'Omit prefixes like "Fix:" from headings.',
+ `The paragraph(s) after each heading must describe the ${noun} in more detail (but be brief where possible).`,
+ ]
+
+ const getCodeExampleRules = (noun: string) => [
+ `Every ${noun} needs a concise code example to showcase it.`,
+ 'Never preface examples with "Example:" or similar.',
+ dedent`
+ In each example, import the functions or types like this:
+ \`\`\`ts
+ import { sum } from 'radashi'
+ \`\`\`
+ `,
+ ]
+
+ const getBulletedListRules = (noun: string) => [
+ `Describe each ${noun} in a bulleted list, without being vague.`,
+ 'Never use headings.',
+ 'Only give me the bulleted list. No prefacing like “Here are the changes” or similar.',
+ ]
+
+ return [
+ {
+ name: 'Features',
+ match: /^feat/,
+ exclude: /\((types|perf)\)/,
+ noun: 'feature',
+ rules: noun => [
+ ...getFormattingRules(noun),
+ ...getCodeExampleRules(noun),
+ ],
+ },
+ {
+ name: 'Bug Fixes',
+ match: /^fix/,
+ exclude: /\((types|perf)\)/,
+ noun: 'fix',
+ rules: noun => [
+ ...getFormattingRules(noun),
+ ...getCodeExampleRules(noun),
+ ],
+ },
+ {
+ name: 'Performance',
+ match: /^(perf|\w+\(perf\))/,
+ noun: 'improvement',
+ rules: noun => [...getBulletedListRules(noun)],
+ },
+ {
+ name: 'Types',
+ match: /^(fix|feat)\(types\)/,
+ noun: 'change',
+ rules: noun => [...getBulletedListRules(noun)],
+ },
+ ]
+}
+
+function log(message: string, ...args: any[]) {
+ console.log('• ' + message, ...args)
+}
+
+async function getPreferredEditor() {
+ const { stdout: gitEditor } = await execa('git', [
+ 'config',
+ '--global',
+ 'core.editor',
+ ])
+ return gitEditor.trim() || process.env.EDITOR || 'nano'
+}
+
+type Section = {
+ name: string
+ match: RegExp
+ exclude?: RegExp
+ noun: string
+ rules: (noun: string) => (string | false)[]
+ commits?: Commit[]
+ notes?: string
+}
+
+type Commit = {
+ sha: string
+ message: string
+ diff: string
+}
diff --git a/scripts/release-notes/src/main.ts b/scripts/release-notes/src/main.ts
index e024977f..c012b9e4 100644
--- a/scripts/release-notes/src/main.ts
+++ b/scripts/release-notes/src/main.ts
@@ -1,308 +1,19 @@
-import { Anthropic } from '@anthropic-ai/sdk'
-import { verifyEnvVars } from '@radashi-org/common/verifyEnvVars.ts'
-import { execa } from 'execa'
import mri from 'mri'
-import fs from 'node:fs'
-import os from 'node:os'
-import path from 'node:path'
-import { uid } from 'radashi/random/uid.ts'
-import { dedent } from 'radashi/string/dedent.ts'
main()
async function main() {
const argv = mri(process.argv.slice(2))
- const outFile = argv.o || argv.output
- const limit = argv.limit ? +argv.limit : Number.POSITIVE_INFINITY
- if (!outFile && !argv.publish) {
- console.error('Error: No --output file or --publish flag provided')
- process.exit(1)
- } else if (outFile && argv.publish) {
- console.error('Error: Cannot use --output file and --publish flag together')
- process.exit(1)
+ if (argv._[0] === 'minor') {
+ const { generateNextMinorReleaseNotes } = await import('./next-minor.ts')
+ return generateNextMinorReleaseNotes(argv)
}
- const { anthropicApiKey, githubToken } = verifyEnvVars({
- anthropicApiKey: 'ANTHROPIC_API_KEY',
- githubToken: !!argv.publish && 'GITHUB_TOKEN',
- })
-
- const version: string = JSON.parse(
- fs.readFileSync('package.json', 'utf8'),
- ).version
-
- log('Generating release notes for v' + version)
-
- /**
- * If no commit ref is provided, use the latest two tags.
- */
- let commitRange: string
- if (argv._.length) {
- commitRange = 'v' + version + '..' + argv._[0]
- } else {
- const tags = await execa('git', [
- 'tag',
- '--format=%(refname:short)',
- '--sort=-version:refname',
- '-n',
- 'v*',
- ]).then(result =>
- result.stdout
- .split('\n')
- .filter(tag => !tag.includes('-'))
- .slice(0, 2),
- )
-
- const [currentVersion, previousVersion] = tags
- commitRange = previousVersion + '..' + currentVersion
- }
-
- const commits = await execa('git', [
- 'log',
- '--format=%H %s',
- commitRange,
- ]).then(result =>
- result.stdout
- .trim()
- .split('\n')
- .map(line => {
- const [, sha, message] = /^(\w+) (.+)$/.exec(line)!
- return { sha, message, diff: '' }
- }),
- )
-
- log('Grouping commits and fetching diffs...')
-
- const sections = getSections()
- for (const commit of commits) {
- for (const section of sections) {
- if (
- section.match.test(commit.message) &&
- !section.exclude?.test(commit.message)
- ) {
- const diff = await execa('git', [
- 'log',
- '-p',
- commit.sha + '^..' + commit.sha,
- 'src',
- 'docs',
- ])
- commit.diff = diff.stdout
- section.commits ??= []
- section.commits.push(commit)
- }
- }
- }
-
- const anthropic = new Anthropic({
- apiKey: anthropicApiKey,
- })
-
- for (const section of sections) {
- if (!section.commits?.length) {
- continue
- }
-
- log('Generating release notes for', section.name)
-
- section.commits.length = Math.min(section.commits.length, limit)
- section.notes = ''
-
- const chunkSize = 4
-
- for (let offset = 0; offset < section.commits.length; offset += chunkSize) {
- log(`${section.commits.length - offset} commits left`)
-
- const linkLocation =
- section.noun === 'feature' || section.noun === 'fix'
- ? 'immediately after the heading'
- : 'at the end'
-
- const rules = [
- `You're tasked with writing in-depth release notes (using Markdown) in a professional tone.`,
- 'Never converse with me.',
- 'Always mention every change I give you.',
- `Always link to the relevant PR (or the commit if there's no PR) ${linkLocation} of each ${section.noun} in a format like "[→ PR #110](…)" or "[→ commit {short-hash}](…)". The GitHub URL is "https://github.com/radashi-org/radashi".`,
- `Never include headings like "Release Notes" or "v1.0.0".`,
- ...section.rules(section.noun),
- ]
-
- log('Sending request to Anthropic...')
-
- const response = await anthropic.messages.create({
- model: 'claude-3-haiku-20240307',
- max_tokens: 4096,
- messages: [
- {
- role: 'user',
- content: dedent`
- - ${rules.join('\n- ')}
-
- The following changes are from \`git log -p\`:
-
-
- ${section.commits
- .slice(offset, offset + chunkSize)
- .map(commit => commit.diff)
- .join('\n\n')}
-
- `,
- },
- ],
- })
-
- const [message] = response.content
- if (message.type !== 'text') {
- console.error('Expected a text message, got:', message)
- process.exit(1)
- }
-
- section.notes += message.text.trim() + '\n\n'
- }
+ if (argv._[0] === 'legacy') {
+ const { legacyGenerateReleaseNotes } = await import('./legacy.ts')
+ return legacyGenerateReleaseNotes(argv)
}
- let notes = sections
- .filter(section => section.notes)
- .map(section => `## ${section.name}\n\n${section.notes}`)
- .join('\n\n')
-
- const tmpFile = path.join(os.tmpdir(), 'release-notes.' + uid(20) + '.md')
- fs.writeFileSync(tmpFile, notes)
-
- try {
- const editor = await getPreferredEditor()
- log('Opening', tmpFile, 'with', editor)
-
- // Open the generated release notes in the user's preferred text editor
- await execa(editor, [tmpFile], { stdio: 'inherit' })
-
- // Read the potentially modified content after the editor is closed
- notes = fs.readFileSync(tmpFile, 'utf-8')
- } finally {
- fs.unlinkSync(tmpFile)
- }
-
- if (argv.publish) {
- const { Octokit } = await import('@octokit/rest')
-
- const octokit = new Octokit({
- auth: githubToken,
- })
-
- log('Publishing release notes for version', version)
-
- try {
- await octokit.rest.repos.createRelease({
- owner: 'radashi-org',
- repo: 'radashi',
- tag_name: `v${version}`,
- name: `v${version}`,
- body: notes,
- draft: !!argv.draft,
- prerelease: !!argv.prerelease,
- })
-
- log('Successfully published release notes to GitHub')
- } catch (error) {
- console.error('Failed to publish release notes:', error)
- process.exit(1)
- }
- } else {
- fs.writeFileSync(outFile, notes)
- log('Saved release notes to', path.resolve(outFile))
- }
-}
-
-function getSections(): Section[] {
- const getFormattingRules = (noun: string) => [
- `Use an H4 (####) for the heading of each ${noun}.`,
- 'Headings must be in sentence case.',
- noun === 'feature' &&
- `Each heading must describe what the ${noun} enables, not simply what the change is (e.g. "Allow throttled function to be triggered immediately" instead of "Add trigger method to throttle function").`,
- 'Be concise but not vague.',
- 'Omit prefixes like "Fix:" from headings.',
- `The paragraph(s) after each heading must describe the ${noun} in more detail (but be brief where possible).`,
- ]
-
- const getCodeExampleRules = (noun: string) => [
- `Every ${noun} needs a concise code example to showcase it.`,
- 'Never preface examples with "Example:" or similar.',
- dedent`
- In each example, import the functions or types like this:
- \`\`\`ts
- import { sum } from 'radashi'
- \`\`\`
- `,
- ]
-
- const getBulletedListRules = (noun: string) => [
- `Describe each ${noun} in a bulleted list, without being vague.`,
- 'Never use headings.',
- 'Only give me the bulleted list. No prefacing like “Here are the changes” or similar.',
- ]
-
- return [
- {
- name: 'Features',
- match: /^feat/,
- exclude: /\((types|perf)\)/,
- noun: 'feature',
- rules: noun => [
- ...getFormattingRules(noun),
- ...getCodeExampleRules(noun),
- ],
- },
- {
- name: 'Bug Fixes',
- match: /^fix/,
- exclude: /\((types|perf)\)/,
- noun: 'fix',
- rules: noun => [
- ...getFormattingRules(noun),
- ...getCodeExampleRules(noun),
- ],
- },
- {
- name: 'Performance',
- match: /^(perf|\w+\(perf\))/,
- noun: 'improvement',
- rules: noun => [...getBulletedListRules(noun)],
- },
- {
- name: 'Types',
- match: /^(fix|feat)\(types\)/,
- noun: 'change',
- rules: noun => [...getBulletedListRules(noun)],
- },
- ]
-}
-
-function log(message: string, ...args: any[]) {
- console.log('• ' + message, ...args)
-}
-
-async function getPreferredEditor() {
- const { stdout: gitEditor } = await execa('git', [
- 'config',
- '--global',
- 'core.editor',
- ])
- return gitEditor.trim() || process.env.EDITOR || 'nano'
-}
-
-type Section = {
- name: string
- match: RegExp
- exclude?: RegExp
- noun: string
- rules: (noun: string) => (string | false)[]
- commits?: Commit[]
- notes?: string
-}
-
-type Commit = {
- sha: string
- message: string
- diff: string
+ throw new Error('Expected one of: minor, legacy')
}
diff --git a/scripts/release-notes/src/next-minor.ts b/scripts/release-notes/src/next-minor.ts
new file mode 100644
index 00000000..85d26889
--- /dev/null
+++ b/scripts/release-notes/src/next-minor.ts
@@ -0,0 +1,259 @@
+import { Anthropic } from '@anthropic-ai/sdk'
+import { Octokit, type RestEndpointMethodTypes } from '@octokit/rest'
+import { verifyEnvVars } from '@radashi-org/common/verifyEnvVars.ts'
+import { execa } from 'execa'
+import type mri from 'mri'
+import fs from 'node:fs'
+import { dedent } from 'radashi/string/dedent.ts'
+import { isNumber } from 'radashi/typed/isNumber.ts'
+
+export async function generateNextMinorReleaseNotes(argv: mri.Argv) {
+ const { anthropicApiKey, githubToken } = verifyEnvVars({
+ anthropicApiKey: 'ANTHROPIC_API_KEY',
+ githubToken: 'GITHUB_TOKEN',
+ })
+
+ const version = JSON.parse(fs.readFileSync('package.json', 'utf8')).version
+ const [major, prevMinor] = version.split('.')
+
+ const nextVersion = `${major}.${+prevMinor + 1}.0`
+
+ const content = fs.readFileSync('.github/next-minor.md', 'utf8')
+ const lines = content.split('\n')
+
+ interface Change {
+ section: string
+ title: string
+ url: string
+ }
+
+ const changes: Change[] = []
+
+ let section: string | null = null
+ let title: string | null = null
+
+ for (const line of lines) {
+ if (line.startsWith('## ')) {
+ section = line.slice(2).trim()
+ } else if (section && line.startsWith('#### ')) {
+ title = line.slice(4).trim()
+ } else if (section && title && line.startsWith('http')) {
+ changes.push({
+ section,
+ title,
+ url: line.trim(),
+ })
+ title = null
+ }
+ }
+
+ let octokit: Octokit | undefined
+
+ type OctokitCommitArray =
+ RestEndpointMethodTypes['pulls']['listCommits']['response']['data']
+
+ const cachedCommits = new Map()
+
+ async function addAuthor(
+ prNumber: number,
+ authors: Set,
+ [, name, email]: string[],
+ ) {
+ if (!email) {
+ if (name) {
+ authors.add(name)
+ }
+ return
+ }
+
+ let username: string | undefined
+
+ if (email.endsWith('@users.noreply.github.com')) {
+ username = email.split('@')[0].replace(/^\d+\+/, '')
+ } else if (!githubToken) {
+ throw new Error('Cannot look up author without GitHub token')
+ } else {
+ octokit ||= new Octokit({ auth: githubToken })
+
+ let commits = cachedCommits.get(prNumber)
+ if (!commits) {
+ const response = await octokit.rest.pulls.listCommits({
+ owner: 'radashi-org',
+ repo: 'radashi',
+ pull_number: prNumber,
+ })
+ commits = response.data
+ cachedCommits.set(prNumber, commits)
+ if (argv.debug) {
+ console.dir(commits, { depth: null })
+ }
+ }
+
+ username = commits.find(commit => commit.commit.author?.email === email)
+ ?.author?.login
+
+ if (!username) {
+ authors.add(name)
+ return
+ }
+ }
+
+ authors.add(`[${name}](https://github.com/${username})`)
+ }
+
+ const anthropic = new Anthropic({
+ apiKey: anthropicApiKey,
+ })
+
+ let notes = ''
+
+ section = null
+
+ for (const change of changes) {
+ const prNumber = change.url.match(/\/pull\/(\d+)/)?.[1]
+ if (!prNumber) {
+ throw new Error(`Expected PR number in ${change.url}`)
+ }
+
+ if (argv.debug && isNumber(argv.debug) && argv.debug !== +prNumber) {
+ continue
+ }
+
+ const { stdout: commit } = await execa('git', [
+ 'log',
+ '--format=%H',
+ '--grep',
+ `(#${prNumber})$`,
+ '-n',
+ '1',
+ ])
+
+ if (!commit) {
+ throw new Error(`Expected commit for PR ${prNumber}`)
+ }
+
+ const { stdout: diff } = await execa('git', [
+ 'show',
+ `${commit}`,
+ '--',
+ 'src',
+ 'docs',
+ ])
+
+ const authors = new Set()
+ await addAuthor(
+ +prNumber,
+ authors,
+ diff.match(/^Author: +([^<]+) +<([^>]+)>/m) || [],
+ )
+ for (const coAuthor of diff.matchAll(
+ /^ *Co-authored-by: +([^<]+) +<([^>]+)>/gm,
+ )) {
+ await addAuthor(+prNumber, authors, coAuthor)
+ }
+
+ if (argv.debug) {
+ console.log({ prNumber, commit, authors })
+ console.log(
+ diff
+ .replace(
+ /^([-+].+?)([ \t]+)$/gm,
+ (_, content, whitespace) => content + '█'.repeat(whitespace.length),
+ )
+ .replace(/^(\+[^+].*)/gm, '\x1b[32m$1\x1b[0m')
+ .replace(/^(-[^-].*)/gm, '\x1b[31m$1\x1b[0m'),
+ )
+ // process.exit(0)
+ }
+
+ if (section !== change.section) {
+ section = change.section
+ notes += `## ${section}\n\n`
+ }
+
+ let prompt: string
+
+ switch (section) {
+ case 'New Functions':
+ notes += `### Add \`${change.title}\` function [→ PR #${prNumber}](${change.url})\n\n`
+ prompt = dedent`
+ Briefly explain the new function's purpose in the first paragraph.
+ If the function isn't simple, you may list more details (i.e. limitations,
+ options, etc.) in a bulleted list after the first paragraph. Try to be concise.
+ You don't have to mention every detail, as we link to the documentation page.
+ `
+ break
+ case 'New Features':
+ notes += `### ${change.title} [→ PR #${prNumber}](${change.url})\n\n`
+ prompt = dedent`
+ Briefly explain what the new feature is in the first paragraph.
+ If the feature isn't simple, you may list more details (i.e. limitations,
+ options, etc.) in a bulleted list after the first paragraph. Try to be concise.
+ `
+ break
+ default:
+ throw new Error(`Unknown section: ${section}`)
+ }
+
+ prompt = dedent`
+ ${prompt}
+ Finally, write a code block containing an example of how to use the new
+ function. Keep the example straight-forward and add comments if necessary.
+ Use \`import * as _ from 'radashi'\` to import the function.
+ Do not include headings anywhere in your response.
+
+ Use the git diff below to write the release notes.
+
+
+ ${diff}
+
+ `
+
+ const response = await anthropic.messages.create({
+ model: 'claude-3-5-haiku-latest',
+ max_tokens: 4096,
+ messages: [
+ {
+ role: 'user',
+ content: prompt,
+ },
+ ],
+ })
+
+ const textBlock = response.content.find(block => block.type === 'text')!
+ notes += textBlock.text + '\n\n'
+
+ const renderCommaSeparatedList = (items: T[]) =>
+ items.length === 1
+ ? items[0]
+ : items.slice(0, -1).join(', ') +
+ (items.length > 2 ? ',' : '') +
+ ` and ${items.at(-1)}`
+
+ notes += `Thanks to ${renderCommaSeparatedList([
+ ...authors,
+ ])} for their work on this feature!\n\n`
+
+ if (section === 'New Functions') {
+ const { stdout: addedFiles } = await execa('git', [
+ 'diff-tree',
+ '--no-commit-id',
+ '--name-only',
+ '-r',
+ '--diff-filter=A',
+ commit,
+ '--',
+ 'src',
+ ])
+
+ const slug = addedFiles
+ .split('\n')[0]
+ .replace(/^src\//, '')
+ .replace(/\.ts$/, '')
+
+ notes += `🔗 [Docs](https://radashi.js.org/reference/${slug}) / [Source](https://github.com/radashi-org/radashi/blob/main/src/${slug}.ts) / [Tests](https://github.com/radashi-org/radashi/blob/main/tests/${slug}.test.ts)\n\n`
+ }
+
+ fs.writeFileSync('notes.md', notes)
+ }
+}
diff --git a/scripts/run.js b/scripts/run.js
index 115968d5..f8bd4d1a 100644
--- a/scripts/run.js
+++ b/scripts/run.js
@@ -18,6 +18,8 @@ async function main([command, ...argv]) {
process.exit(1)
}
+ let forceTSX = false
+
// Only a few environment variables are exposed to install/postinstall
// scripts when installing dependencies from NPM.
const strictEnv = pick(process.env, [
@@ -32,10 +34,6 @@ async function main([command, ...argv]) {
const __dirname = path.dirname(__filename)
async function installDependencies(pkgDir) {
- if (fs.existsSync(path.join(pkgDir, 'node_modules'))) {
- return
- }
-
const pkgPath = path.join(pkgDir, 'package.json')
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
@@ -43,6 +41,16 @@ async function main([command, ...argv]) {
return
}
+ // Since modules in Radashi may import "radashi" (which resolves to the "dist" folder), forcing
+ // the use of TSX helps us avoid resolution errors.
+ if ('radashi' in pkg.dependencies) {
+ forceTSX = true
+ }
+
+ if (fs.existsSync(path.join(pkgDir, 'node_modules'))) {
+ return
+ }
+
console.warn(
`> Installing dependencies for ${path.relative(process.cwd(), pkgDir)}`,
)
@@ -83,7 +91,7 @@ async function main([command, ...argv]) {
let runner
let runnerArgs
- if (major < 22 || (major === 22 && minor < 6)) {
+ if (forceTSX || process.env.CI || major < 22 || (major === 22 && minor < 6)) {
const tsxSpecifier = 'tsx@4.19.1'
if (process.env.CI) {
console.warn(`> pnpm add -g ${tsxSpecifier}`)