项目文件夹

文件
2026-07-13 12:35:20 +08:00

617 行
14 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
#!/usr/bin/env node
/**
* GitHub Pull Request 创建脚本
*
* 功能:
* - 自动检测当前分支和主分支
* - 从 commit 信息生成 PR 标题和描述
* - 使用 GitHub REST API 创建 PR
* - 支持草稿 PR 和预演模式
*
* 使用方法:
* tsx scripts/create-pr.ts
* tsx scripts/create-pr.ts --title "自定义标题"
* tsx scripts/create-pr.ts --draft --dry-run
*/
import { consola } from "consola";
import { execa } from "execa";
/**
* 日志级别
*/
type LogLevel = "info" | "success" | "error" | "warn";
/**
* 日志函数
*/
function log(level: LogLevel, message: string): void {
const methods: Record<LogLevel, keyof typeof consola> = {
info: "info",
success: "success",
error: "error",
warn: "warn",
};
(consola[methods[level]] as (msg: string) => void)(message);
}
/**
* 创建 PR 选项接口
*/
interface CreatePROptions {
/** 自定义 PR 标题 */
title?: string;
/** 自定义 PR 描述 */
body?: string;
/** 目标分支(默认自动检测) */
base?: string;
/** 是否创建为草稿 */
draft?: boolean;
/** 预演模式,不实际创建 PR */
dryRun?: boolean;
}
/**
* Git 信息接口
*/
interface GitInfo {
/** 仓库所有者 */
owner: string;
/** 仓库名称 */
repo: string;
/** 当前分支名称 */
currentBranch: string;
/** 主分支名称 */
baseBranch: string;
/** 与主分支的 commits */
commits: Array<{
/** commit hash */
hash: string;
/** commit 消息 */
message: string;
}>;
}
/**
* PR 数据接口
*/
interface PRData {
/** PR 标题 */
title: string;
/** PR 描述 */
body: string;
/** 源分支 */
head: string;
/** 目标分支 */
base: string;
/** 是否为草稿 */
draft: boolean;
}
/**
* GitHub API 错误响应接口
*/
interface GitHubApiError {
message: string;
errors?: Array<{
message: string;
resource: string;
field: string;
code?: string;
}>;
}
/**
* 获取远程仓库信息(owner/repo
*
* @returns 仓库所有者和仓库名称
* @throws 当无法获取远程仓库信息时抛出错误
*/
async function getRepoInfo(): Promise<{ owner: string; repo: string }> {
try {
// 获取 origin remote URL
const { stdout: remoteUrl } = await execa("git", [
"remote",
"get-url",
"origin",
]);
// 解析 URL 格式:
// - https: //github.com/owner/repo.git
// - git@github.com:owner/repo.git
let match = remoteUrl.match(/github\.com[/:]([^/]+)\/([^/]+?)(\.git)?$/);
if (!match) {
throw new Error(`无法解析仓库 URL: ${remoteUrl}`);
}
return { owner: match[1], repo: match[2] };
} catch (error) {
throw new Error(
`获取仓库信息失败: ${(error as Error).message}. 请确保已设置 origin remote。`
);
}
}
/**
* 获取当前分支名称
*
* @returns 当前分支名称
* @throws 当无法获取当前分支时抛出错误
*/
async function getCurrentBranch(): Promise<string> {
try {
const { stdout: branch } = await execa("git", ["branch", "--show-current"]);
return branch.trim();
} catch (error) {
throw new Error(`获取当前分支失败: ${(error as Error).message}`);
}
}
/**
* 获取主分支名称(main 或 master
*
* @returns 主分支名称
*/
async function getBaseBranch(): Promise<string> {
try {
// 尝试获取远程的 HEAD 分支
const { stdout: symbolicRef } = await execa("git", [
"symbolic-ref",
"refs/remotes/origin/HEAD",
]);
return symbolicRef.replace("refs/remotes/origin/", "").trim();
} catch {
// 如果无法获取,尝试检测 main 或 master
try {
await execa("git", ["rev-parse", "--verify", "origin/main"]);
return "main";
} catch {
try {
await execa("git", ["rev-parse", "--verify", "origin/master"]);
return "master";
} catch {
// 默认返回 main
return "main";
}
}
}
}
/**
* 获取与主分支的差异 commits
*
* @param baseBranch - 主分支名称
* @returns commit 列表
*/
async function getCommits(baseBranch: string): Promise<
Array<{
hash: string;
message: string;
}>
> {
try {
// 先更新远程引用
await execa("git", ["fetch", "origin", "--quiet"]);
// 获取 commits
const { stdout: logOutput } = await execa("git", [
"--no-pager",
"log",
`origin/${baseBranch}..HEAD`,
"--pretty=format:%H|%s",
]);
if (!logOutput.trim()) {
return [];
}
return logOutput
.trim()
.split("\n")
.map((line) => {
const [hash, ...messageParts] = line.split("|");
return {
hash,
message: messageParts.join("|").trim(),
};
});
} catch (error) {
throw new Error(`获取 commits 失败: ${(error as Error).message}`);
}
}
/**
* 收集 Git 信息
*
* @returns Git 信息对象
*/
async function collectGitInfo(): Promise<GitInfo> {
const repoInfo = await getRepoInfo();
const currentBranch = await getCurrentBranch();
const baseBranch = await getBaseBranch();
const commits = await getCommits(baseBranch);
return {
...repoInfo,
currentBranch,
baseBranch,
commits,
};
}
/**
* 从 commit 信息生成 PR 标题
*
* @param commits - commit 列表
* @param customTitle - 自定义标题
* @returns PR 标题
*/
function generateTitle(
commits: Array<{ hash: string; message: string }>,
customTitle?: string
): string {
if (customTitle) {
return customTitle;
}
if (commits.length === 0) {
return "feat: 更新";
}
// 使用第一个 commit 的消息作为标题
return commits[0].message;
}
/**
* 从 commit 信息生成 PR 描述
*
* @param commits - commit 列表
* @param customBody - 自定义描述
* @returns PR 描述
*/
function generateBody(
commits: Array<{ hash: string; message: string }>,
customBody?: string
): string {
if (customBody) {
return customBody;
}
if (commits.length === 0) {
return "## 概述\n\n此 PR 包含一些更新。";
}
const commitsList = commits
.map((commit) => `- ${commit.message} (${commit.hash.slice(0, 7)})`)
.join("\n");
return `## 概述
此 PR 包含 ${commits.length} 个 commit。
## 变更内容
${commitsList}
## 测试计划
- [ ] 单元测试通过
- [ ] 集成测试通过
- [ ] 手动测试完成
🤖 Generated with [Claude Code](https://claude.com/claude-code)
`;
}
/**
* 通过 GitHub API 创建 PR
*
* @param owner - 仓库所有者
* @param repo - 仓库名称
* @param prData - PR 数据
* @returns 创建的 PR 信息
* @throws 当 API 请求失败时抛出错误
*/
async function createPullRequest(
owner: string,
repo: string,
prData: PRData
): Promise<{ html_url: string; number: number }> {
const token = process.env.GITHUB_TOKEN;
if (!token) {
throw new Error(
"GITHUB_TOKEN 环境变量未设置。请设置 GitHub Personal Access Token:\n" +
" export GITHUB_TOKEN=your_token_here\n\n" +
"或使用 GitHub CLI:\n" +
" export GITHUB_TOKEN=$(gh auth token)"
);
}
const url = `https://api.github.com/repos/${owner}/${repo}/pulls`;
try {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
"X-GitHub-Api-Version": "2022-11-28",
},
body: JSON.stringify(prData),
});
if (!response.ok) {
const errorData = (await response.json()) as GitHubApiError;
const errorMessage = errorData.message || response.statusText;
// 检查是否是认证错误
if (response.status === 401) {
throw new Error(
"GitHub 认证失败。请检查 GITHUB_TOKEN 是否正确且具有 repo 权限。"
);
}
// 检查是否是分支未推送错误
if (errorData.errors?.[0]?.message?.includes("not found")) {
throw new Error(
`分支 '${prData.head}' 未推送到远程仓库。请先运行: git push origin ${prData.head}`
);
}
// 检查是否 PR 已存在
if (response.status === 422) {
throw new Error(
`PR 已存在或请求验证失败: ${errorMessage}\n` +
`可能原因:分支 ${prData.head} 没有与 ${prData.base} 的差异。`
);
}
throw new Error(`GitHub API 错误 (${response.status}): ${errorMessage}`);
}
const data = (await response.json()) as { html_url: string; number: number };
return data;
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error(`创建 PR 失败: ${String(error)}`);
}
}
/**
* 显示预览信息
*
* @param gitInfo - Git 信息
* @param prData - PR 数据
*/
function showPreview(gitInfo: GitInfo, prData: PRData): void {
console.log(`\n${"=".repeat(60)}`);
console.log("📋 PR 预览");
console.log("=".repeat(60));
console.log(`仓库: ${gitInfo.owner}/${gitInfo.repo}`);
console.log(`源分支: ${prData.head}`);
console.log(`目标分支: ${prData.base}`);
console.log(`标题: ${prData.title}`);
console.log(`草稿: ${prData.draft ? "是" : "否"}`);
console.log(`\n描述:`);
console.log("-".repeat(60));
console.log(prData.body);
console.log("-".repeat(60));
console.log(`${"=".repeat(60)}\n`);
}
/**
* 主函数
*
* @param options - 创建 PR 选项
*/
async function main(options: CreatePROptions): Promise<void> {
console.log("\n🚀 GitHub PR 创建脚本启动");
console.log("=".repeat(60));
// 1. 收集 Git 信息
log("info", "📊 收集 Git 信息...");
const gitInfo = await collectGitInfo();
log("info", ` 仓库: ${gitInfo.owner}/${gitInfo.repo}`);
log("info", ` 当前分支: ${gitInfo.currentBranch}`);
log("info", ` 主分支: ${gitInfo.baseBranch}`);
log("info", ` 差异 commits: ${gitInfo.commits.length} 个`);
// 检查是否在主分支上
if (gitInfo.currentBranch === gitInfo.baseBranch) {
log(
"error",
`当前分支是主分支 (${gitInfo.baseBranch}),无法创建 PR。`
);
log("info", "请先创建并切换到一个新分支:");
log("info", " git checkout -b feature/my-feature");
process.exit(1);
return;
}
// 检查是否有 commits
if (gitInfo.commits.length === 0) {
log(
"warn",
`当前分支与 ${gitInfo.baseBranch} 没有差异,无法创建 PR。`
);
log("info", "请先进行一些提交:");
log("info", " git add .");
log("info", " git commit -m 'feat: 添加新功能'");
log("info", " git push origin <branch>");
process.exit(1);
return;
}
// 2. 生成 PR 内容
const baseBranch = options.base || gitInfo.baseBranch;
const prData: PRData = {
title: generateTitle(gitInfo.commits, options.title),
body: generateBody(gitInfo.commits, options.body),
head: gitInfo.currentBranch,
base: baseBranch,
draft: options.draft ?? false,
};
// 3. 显示预览
showPreview(gitInfo, prData);
// 4. 预演模式
if (options.dryRun) {
log("info", "💡 这是预演模式,未实际创建 PR");
log("info", "如需创建 PR,请移除 --dry-run 参数");
return;
}
// 5. 创建 PR
log("info", "📤 创建 Pull Request...");
try {
const result = await createPullRequest(gitInfo.owner, gitInfo.repo, prData);
log("success", `✅ PR 创建成功!`);
log("info", ` 编号: #${result.number}`);
log("info", ` 链接: ${result.html_url}`);
} catch (error) {
log("error", (error as Error).message);
process.exit(1);
return;
}
console.log(`${"=".repeat(60)}\n`);
}
/**
* 解析命令行参数
*
* @returns 解析后的选项
*/
function parseArgs(): CreatePROptions {
const args = process.argv.slice(2);
const options: CreatePROptions = {
title: undefined,
body: undefined,
base: undefined,
draft: false,
dryRun: false,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
switch (arg) {
case "--title":
case "-t":
options.title = args[++i];
break;
case "--body":
case "-b":
options.body = args[++i];
break;
case "--base":
options.base = args[++i];
break;
case "--draft":
case "-d":
options.draft = true;
break;
case "--dry-run":
case "-n":
options.dryRun = true;
break;
case "--help":
case "-h":
showHelp();
process.exit(0);
break;
default:
log("error", `未知参数: ${arg}`);
log("info", "使用 --help 查看帮助信息");
process.exit(1);
}
}
return options;
}
/**
* 显示帮助信息
*/
function showHelp(): void {
console.log(`
GitHub Pull Request 创建脚本
使用方法:
tsx scripts/create-pr.ts [选项]
选项:
-t, --title <标题> 自定义 PR 标题
-b, --body <描述> 自定义 PR 描述
--base <分支> 指定目标分支(默认自动检测主分支)
-d, --draft 创建为草稿 PR
-n, --dry-run 预演模式,不实际创建 PR
-h, --help 显示帮助信息
环境变量:
GITHUB_TOKEN GitHub Personal Access Token(必需)
示例:
# 基本用法(自动生成标题和描述)
tsx scripts/create-pr.ts
# 自定义标题
tsx scripts/create-pr.ts --title "feat: 添加新功能"
# 自定义标题和描述
tsx scripts/create-pr.ts -t "fix: 修复 bug" -b "详细描述修复内容"
# 创建草稿 PR
tsx scripts/create-pr.ts --draft
# 预演模式(不实际创建)
tsx scripts/create-pr.ts --dry-run
# 指定目标分支
tsx scripts/create-pr.ts --base develop
设置 GitHub Token:
export GITHUB_TOKEN=your_token_here
或使用 GitHub CLI:
export GITHUB_TOKEN=$(gh auth token)
`);
}
// 错误处理
process.on("uncaughtException", (error: Error) => {
log("error", `未捕获的异常: ${error.message}`);
if (error.stack) {
console.error(error.stack);
}
process.exit(1);
});
process.on("unhandledRejection", (reason: unknown) => {
log("error", `未处理的 Promise 拒绝: ${String(reason)}`);
process.exit(1);
});
// 检查是否直接运行此脚本
const isMainModule = process.argv[1]?.endsWith("create-pr.ts") ?? false;
if (isMainModule) {
const options = parseArgs();
main(options).catch((error: Error) => {
log("error", `主函数执行失败: ${error.message}`);
if (error.stack) {
console.error(error.stack);
}
process.exit(1);
});
}
export { main, parseArgs, collectGitInfo, generateTitle, generateBody };