#!/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 = { 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 { 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 { 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 { // 获取仓库信息 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 };