项目文件夹

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

448 行
11 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
#!/usr/bin/env node
/**
* Claude Issue 回复解析脚本
*
* 功能:
* - 获取指定 Issue 的所有评论
* - 找到最新的 Claude 回复(作者为 github-actions[bot] 且包含 "Claude finished"
* - 从回复内容中解析分支名、PR 标题和评论内容
* - 输出 JSON 到 stdout,设置 GitHub Actions 输出变量
*
* 使用方法:
* tsx scripts/parse-claude-reply.ts --issue 761
* tsx scripts/parse-claude-reply.ts --issue 761 --repo shenjingnan/xiaozhi-client
*/
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);
}
/**
* 脚本选项接口
*/
interface ParseOptions {
/** Issue 编号 */
issue: string;
/** 仓库路径(默认从 git remote 获取) */
repo?: string;
}
/**
* Issue 评论接口
*/
interface IssueComment {
/** 评论 ID */
id: number;
/** 评论作者 */
user: {
/** 作者登录名 */
login: string;
};
/** 评论内容 */
body: string;
/** 创建时间 */
created_at: string;
}
/**
* 解析结果接口
*/
interface ParseResult {
/** 是否成功 */
success: boolean;
/** 分支名称 */
branchName?: string;
/** PR 标题 */
title?: string;
/** PR 描述 */
body?: string;
/** 错误信息 */
error?: string;
}
/**
* 解析命令行参数
*
* @returns 解析后的选项
*/
function parseArgs(): ParseOptions {
const args = process.argv.slice(2);
const options: ParseOptions = {
issue: "",
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
switch (arg) {
case "--issue":
case "-i":
options.issue = args[++i];
break;
case "--repo":
case "-r":
options.repo = args[++i];
break;
case "--help":
case "-h":
showHelp();
process.exit(0);
break;
default:
log("error", `未知参数: ${arg}`);
log("info", "使用 --help 查看帮助信息");
process.exit(1);
}
}
if (!options.issue) {
log("error", "缺少必需参数: --issue");
log("info", "使用 --help 查看帮助信息");
process.exit(1);
}
return options;
}
/**
* 显示帮助信息
*/
function showHelp(): void {
console.log(`
Claude Issue 回复解析脚本
使用方法:
tsx scripts/parse-claude-reply.ts --issue <编号> [选项]
选项:
-i, --issue <编号> Issue 编号(必需)
-r, --repo <仓库> 仓库路径(默认从 git remote 获取)
-h, --help 显示帮助信息
示例:
# 解析 Issue 761
tsx scripts/parse-claude-reply.ts --issue 761
# 指定仓库
tsx scripts/parse-claude-reply.ts --issue 761 --repo shenjingnan/xiaozhi-client
输出格式:
{
"success": true,
"branchName": "claude/issue-761-20260204-0158",
"title": "docs(handlers): 添加文件级 JSDoc 注释",
"body": "## 摘要\\n\\n..."
}
`);
}
/**
* 获取远程仓库信息(owner/repo
*
* @returns 仓库所有者和仓库名称
* @throws 当无法获取远程仓库信息时抛出错误
*/
async function getRepoInfo(): Promise<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
const match = remoteUrl.match(/github\.com[/:]([^/]+)\/([^/]+?)(\.git)?$/);
if (!match) {
throw new Error(`无法解析仓库 URL: ${remoteUrl}`);
}
return `${match[1]}/${match[2]}`;
} catch (error) {
throw new Error(
`获取仓库信息失败: ${(error as Error).message}. 请确保已设置 origin remote。`
);
}
}
/**
* 获取 Issue 的所有评论
*
* @param repo - 仓库路径(owner/repo
* @param issueNumber - Issue 编号
* @returns Issue 评论列表
* @throws 当获取评论失败时抛出错误
*/
async function getIssueComments(
repo: string,
issueNumber: string
): Promise<IssueComment[]> {
try {
const { stdout } = await execa("gh", [
"api",
`repos/${repo}/issues/${issueNumber}/comments`,
"--jq",
".",
]);
return JSON.parse(stdout) as IssueComment[];
} catch (error) {
throw new Error(
`获取 Issue 评论失败: ${(error as Error).message}. 请确保已安装 gh CLI 并通过认证。`
);
}
}
/**
* 查找最新的 Claude 回复
*
* @param comments - Issue 评论列表
* @returns 最新的 Claude 回复,如果没有找到则返回 null
*/
function findLatestClaudeReply(comments: IssueComment[]): IssueComment | null {
// 过滤出 Claude 的回复(作者为 github-actions[bot] 且包含 "Claude finished"
const claudeComments = comments.filter(
(comment) =>
comment.user.login === "github-actions[bot]" &&
comment.body.includes("Claude finished")
);
if (claudeComments.length === 0) {
return null;
}
// 按创建时间排序,返回最新的
claudeComments.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
return claudeComments[0];
}
/**
* 从回复内容中提取分支名
*
* @param body - 回复内容
* @returns 分支名称,如果没有找到则返回 null
*/
function extractBranchName(body: string): string | null {
// 匹配格式:`claude/issue-761-20260204-0158`
const branchRegex = /`([a-z\/\-0-9]+)`/g;
const matches = body.matchAll(branchRegex);
for (const match of matches) {
const branchName = match[1];
// 检查是否是 claude 分支(以 claude/ 开头)
if (branchName.startsWith("claude/")) {
return branchName;
}
}
return null;
}
/**
* 从回复内容中提取 URL 参数
*
* @param body - 回复内容
* @returns 包含 title 和 body 的对象,如果没有找到则返回 null
*/
function extractUrlParams(body: string): { title: string; body: string } | null {
// 查找 Create PR 链接
// 格式:[Create PR ➔](https://github.com/.../compare/main...branch?quick_pull=1&title=xxx&body=xxx)
// 使用非贪婪匹配来获取完整的 URL,匹配任意字符(包括箭头等特殊字符)
const prLinkRegex =
/\[Create[^\]]+PR[^\]]*\]\((https:\/\/github\.com\/[^)]+quick_pull=1[^)]*)\)/;
const match = body.match(prLinkRegex);
if (!match) {
return null;
}
try {
const url = new URL(match[1]);
const title = url.searchParams.get("title");
const body = url.searchParams.get("body");
if (!title || !body) {
return null;
}
// 解码 URL 编码的参数
return {
title: decodeURIComponent(title),
body: decodeURIComponent(body),
};
} catch {
return null;
}
}
/**
* 解析 Claude 回复
*
* @param body - 回复内容
* @returns 解析结果
*/
function parseClaudeReply(body: string): ParseResult {
// 提取分支名
const branchName = extractBranchName(body);
if (!branchName) {
return {
success: false,
error: "无法从回复中提取分支名",
};
}
return {
success: true,
branchName,
};
}
/**
* 获取分支最后一次 commit 信息
*
* @param repo - 仓库路径(owner/repo
* @param branchName - 分支名称
* @returns commit 标题和描述
*/
async function getCommitInfo(
repo: string,
branchName: string
): Promise<{ title: string; body: string }> {
try {
// 获取分支信息
const { stdout: branchInfo } = await execa("gh", [
"api",
`repos/${repo}/branches/${branchName}`,
"--jq",
".commit.sha",
]);
const commitSha = branchInfo.trim();
// 获取 commit 详细信息
const { stdout: commitInfo } = await execa("gh", [
"api",
`repos/${repo}/commits/${commitSha}`,
"--jq",
".commit.message",
]);
// 解析 commit 消息(第一行是标题,后面是 body)
const lines = commitInfo.trim().split("\n");
const title = lines[0];
const body = lines.slice(2).join("\n").trim(); // 跳过空行
return {
title,
body: body || `Commit from branch ${branchName}`,
};
} catch (error) {
throw new Error(
`获取 commit 信息失败: ${(error as Error).message}`
);
}
}
/**
* 主函数
*
* @param options - 解析选项
*/
async function main(options: ParseOptions): Promise<void> {
// 获取仓库信息
const repo = options.repo || (await getRepoInfo());
log("info", `📊 获取 Issue #${options.issue} 的评论...`);
log("info", ` 仓库: ${repo}`);
// 获取 Issue 评论
const comments = await getIssueComments(repo, options.issue);
log("info", ` 找到 ${comments.length} 条评论`);
// 查找最新的 Claude 回复
const claudeReply = findLatestClaudeReply(comments);
if (!claudeReply) {
const result: ParseResult = {
success: false,
error: "未找到 Claude 的回复",
};
console.log(JSON.stringify(result, null, 2));
process.exit(1);
return;
}
log("success", ` 找到 Claude 回复(创建于 ${claudeReply.created_at}`);
// 解析回复内容
const result = parseClaudeReply(claudeReply.body);
if (!result.branchName) {
log("error", '无法从回复中提取分支名');
console.log(JSON.stringify(result, null, 2));
process.exit(1);
return;
}
const commitInfo = await getCommitInfo(repo, result.branchName);
if (!commitInfo.title || !commitInfo.body) {
log("error", '无法从回复中提取 commit 信息');
console.log(JSON.stringify(commitInfo, null, 2));
process.exit(1);
return;
}
// 输出 JSON 到 stdout
console.log(JSON.stringify({ branchName: result.branchName, title: commitInfo.title, body: commitInfo.body }, null, 2));
}
// 错误处理
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("parse-claude-reply.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, parseClaudeReply, extractBranchName, extractUrlParams };