Files
2026-07-13 12:35:20 +08:00

448 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 };