|
| 1 | +import fs from 'node:fs' |
| 2 | +import path from 'node:path' |
| 3 | +import process from 'node:process' |
| 4 | +import fg from 'fast-glob' |
| 5 | +import { load } from 'js-yaml' |
| 6 | +import k from 'kleur' |
| 7 | +import simpleGit from 'simple-git' |
| 8 | + |
| 9 | +// eslint-disable-next-line ts/naming-convention |
| 10 | +let __dirname = globalThis.__dirname |
| 11 | +if (!globalThis.__dirname) |
| 12 | + __dirname = new URL('.', import.meta.url).pathname |
| 13 | + |
| 14 | +const git = simpleGit() |
| 15 | +const workspacePath = path.resolve(__dirname, '../pnpm-workspace.yaml') |
| 16 | +const commitMessageRegex = /^([a-z]+)(\(.+\))?: (.*)$/ |
| 17 | +const changesetMatchOptions: IChangesetMatchOptions = { |
| 18 | + minor: [/^feat/], |
| 19 | + patch: [/^fix/, /^perf/, /^refactor/, /^style/, /^docs/, /^chore/, /^revert/, /^ci/, /^build/, /^test/], |
| 20 | + ignore: [/^chore\(release\)/], |
| 21 | +} |
| 22 | + |
| 23 | +export type ChangesetType = 'patch' | 'minor' | 'major' |
| 24 | +export interface IChangesetMatchOptions extends Partial<Record<ChangesetType, RegExp[]>> { |
| 25 | + default?: ChangesetType |
| 26 | + ignore?: RegExp[] |
| 27 | +} |
| 28 | + |
| 29 | +interface ICommittedFile { |
| 30 | + filePath: string |
| 31 | +} |
| 32 | + |
| 33 | +interface IWorkspacePackage { |
| 34 | + folderPath: string |
| 35 | + packageName: string |
| 36 | +} |
| 37 | + |
| 38 | +export async function getLastCommitId() { |
| 39 | + const log = await git.log({ maxCount: 1 }) |
| 40 | + return log.latest?.hash || '' |
| 41 | +} |
| 42 | + |
| 43 | +export async function getLastCommitFiles(): Promise<ICommittedFile[]> { |
| 44 | + // 使用git命令直接获取最近一次commit的文件列表 |
| 45 | + const result = await git.raw(['diff-tree', '--no-commit-id', '--name-only', '-r', 'HEAD']) |
| 46 | + // 分割成数组并过滤空行 |
| 47 | + const files = result.trim().split('\n').filter(Boolean) |
| 48 | + // 转换为绝对路径 |
| 49 | + return files.map(file => ({ filePath: path.resolve(file) })) |
| 50 | +} |
| 51 | + |
| 52 | +export async function getLastCommitMessage(): Promise<string> { |
| 53 | + const log = await git.log({ maxCount: 1 }) |
| 54 | + return log.latest?.message || '' |
| 55 | +} |
| 56 | + |
| 57 | +async function getWorkspacePackages(): Promise<IWorkspacePackage[]> { |
| 58 | + if (!fs.existsSync(workspacePath)) |
| 59 | + return [] |
| 60 | + if (!fs.statSync(workspacePath).isFile()) |
| 61 | + return [] |
| 62 | + |
| 63 | + const workspace = fs.readFileSync(workspacePath, 'utf-8') |
| 64 | + const parsedWorkspace = load(workspace) |
| 65 | + if (!parsedWorkspace || typeof parsedWorkspace !== 'object' || !('packages' in parsedWorkspace) || !Array.isArray(parsedWorkspace.packages)) |
| 66 | + return [] |
| 67 | + const { packages } = parsedWorkspace |
| 68 | + return packages |
| 69 | + .map(packageGlob => (fg.sync(packageGlob || '', { |
| 70 | + onlyDirectories: true, |
| 71 | + onlyFiles: false, |
| 72 | + absolute: true, |
| 73 | + deep: 1, |
| 74 | + }))) |
| 75 | + // 拍平 |
| 76 | + .flat() |
| 77 | + // 去重 |
| 78 | + .filter((folderPath, index, self) => self.findIndex(fp => fp === folderPath) === index) |
| 79 | + // 没有package.json的文件夹 不视为一个workspace |
| 80 | + .filter((folderPath) => { |
| 81 | + const packageJsonPath = path.resolve(folderPath, 'package.json') |
| 82 | + return fs.existsSync(packageJsonPath) && fs.statSync(packageJsonPath).isFile() |
| 83 | + }) |
| 84 | + .map(folderPath => ({ |
| 85 | + folderPath, |
| 86 | + packageName: JSON.parse(fs.readFileSync(path.resolve(folderPath, 'package.json'), 'utf-8')).name, |
| 87 | + })) |
| 88 | +} |
| 89 | + |
| 90 | +interface IWorkspaceCommittedFile extends ICommittedFile { |
| 91 | + workspace: IWorkspacePackage | null |
| 92 | +} |
| 93 | + |
| 94 | +async function getWorkspaceCommittedFiles(committedFiles: ICommittedFile[], workspacePackages: IWorkspacePackage[]): Promise<IWorkspaceCommittedFile[]> { |
| 95 | + return committedFiles.map((committedFile): IWorkspaceCommittedFile => ({ |
| 96 | + ...committedFile, |
| 97 | + workspace: workspacePackages.find(workspacePackage => committedFile.filePath.startsWith(workspacePackage.folderPath)) || null, |
| 98 | + })) |
| 99 | +} |
| 100 | + |
| 101 | +async function getMatchedChangeset(lastCommitMessage: string): Promise<[ChangesetType, string] | undefined> { |
| 102 | + if (!lastCommitMessage) |
| 103 | + return undefined |
| 104 | + if (!commitMessageRegex.test(lastCommitMessage)) |
| 105 | + return undefined |
| 106 | + |
| 107 | + const match = lastCommitMessage.match(commitMessageRegex) |
| 108 | + if (!match) |
| 109 | + return undefined |
| 110 | + const [, commitType, , commitMessage] = match |
| 111 | + if (!commitType) |
| 112 | + return undefined |
| 113 | + |
| 114 | + for (const changesetType of Object.keys(changesetMatchOptions) as ChangesetType[]) { |
| 115 | + const matchOptions = changesetMatchOptions[changesetType] |
| 116 | + if (!matchOptions) |
| 117 | + continue |
| 118 | + |
| 119 | + for (const matchOption of matchOptions) { |
| 120 | + if (matchOption.test(commitType)) |
| 121 | + return [changesetType, commitMessage] |
| 122 | + } |
| 123 | + } |
| 124 | + |
| 125 | + return undefined |
| 126 | +} |
| 127 | + |
| 128 | +async function createMarkdown(changesetType: ChangesetType, workspaceCommittedFiles: IWorkspaceCommittedFile[], commitMessage: string, lastCommitId: string) { |
| 129 | + return `--- |
| 130 | +${workspaceCommittedFiles.filter(v => v.workspace !== null).map(file => `"${file.workspace?.packageName}": "${changesetType}"`).join('\n')} |
| 131 | +--- |
| 132 | +
|
| 133 | +${lastCommitId} ${commitMessage} |
| 134 | +` |
| 135 | +} |
| 136 | + |
| 137 | +async function main() { |
| 138 | + const committedFiles = await getLastCommitFiles() |
| 139 | + const lastCommitId = await getLastCommitId() |
| 140 | + const workspacePackages = await getWorkspacePackages() |
| 141 | + const workspaceCommittedFiles = await getWorkspaceCommittedFiles(committedFiles, workspacePackages) |
| 142 | + const lastCommitMessage = await getLastCommitMessage() |
| 143 | + const matchedChangeset = await getMatchedChangeset(lastCommitMessage) |
| 144 | + if (!matchedChangeset) |
| 145 | + return console.warn(`The commit message "${lastCommitMessage}" does not match any angular changeset type.`) |
| 146 | + const [changesetType, commitMessage] = matchedChangeset |
| 147 | + |
| 148 | + console.log(`${workspaceCommittedFiles.map(v => k.dim(`${k.green(`[${v.workspace ? '✓' : '✗'}]`)} ${path.relative(process.cwd(), v.filePath)}: detected workspace: ${v.workspace?.folderPath ? path.relative(process.cwd(), v.workspace.folderPath) : 'null'}`)).join('\n')}`) |
| 149 | + if (workspaceCommittedFiles.every(v => v.workspace === null)) |
| 150 | + return console.warn(k.yellow(`The commit "${lastCommitMessage}" has no files in the workspace.`)) |
| 151 | + else if (changesetMatchOptions.ignore?.some(v => v.test(lastCommitMessage))) |
| 152 | + return console.warn(k.yellow(`The commit "${lastCommitMessage}" is ignored.`)) |
| 153 | + |
| 154 | + console.log(k.dim(`Creating changeset file for ${lastCommitId}.md ${lastCommitMessage}`)) |
| 155 | + const markdown = await createMarkdown(changesetType, workspaceCommittedFiles, commitMessage, lastCommitId) |
| 156 | + fs.writeFileSync(`.changeset/${lastCommitId}.md`, markdown) |
| 157 | + await git.add('--all') |
| 158 | + await git.commit(`docs(changeset): ${lastCommitId} ${commitMessage}`, { |
| 159 | + '--no-verify': true, |
| 160 | + } as any) |
| 161 | +} |
| 162 | + |
| 163 | +main() |
0 commit comments