#!/usr/bin/env node import { Command } from "commander"; import { GitHubClient } from "./github/client.js"; import { handleGetFailingTests } from "./tools/get-failing-tests/handler.js"; import { handleFindUnresolvedComments } from "./tools/find-unresolved-comments/handler.js"; import { handleManageStackedPRs } from "./tools/manage-stacked-prs/handler.js"; import { GetFailingTestsSchema } from "./tools/get-failing-tests/schema.js"; import { FindUnresolvedCommentsSchema } from "./tools/find-unresolved-comments/schema.js"; import type { FindUnresolvedCommentsOutput } from "./tools/find-unresolved-comments/schema.js"; import { ManageStackedPRsSchema } from "./tools/manage-stacked-prs/schema.js"; import { handleResolveReviewThread } from "./tools/resolve-review-thread/handler.js"; import { ResolveReviewThreadInputSchema } from "./tools/resolve-review-thread/schema.js"; import { getVersionString } from "./utils/version.js"; const program = new Command(); // Lazy initialization of GitHub client let clientInstance: GitHubClient | null = null; /** * Get or create GitHub client instance * @returns GitHub client instance */ function getClient(): GitHubClient { if (!clientInstance) { clientInstance = new GitHubClient(); } return clientInstance; } program .name("github-pr-automation") .description( "MCP server and CLI for automated GitHub PR management, review resolution, and workflow optimization", ) .version(getVersionString()); program .command("get-failing-tests") .description("Analyze PR CI failures") .requiredOption("--pr ", "PR identifier (owner/repo#123)") .option("--wait", "Wait for CI completion") .option("--bail-on-first", "Bail on first failure") .option("--cursor ", "Pagination cursor (from previous response)") .option("--json", "Output as JSON") .action( async (options: { pr: string; wait?: boolean; bailOnFirst?: boolean; cursor?: string; json?: boolean; }) => { try { const client = getClient(); // Build input and let Zod schema apply defaults const input = GetFailingTestsSchema.parse({ pr: options.pr, ...(options.wait !== undefined && { wait: options.wait }), ...(options.bailOnFirst !== undefined && { bail_on_first: options.bailOnFirst, }), ...(options.cursor && { cursor: options.cursor }), }); const result = await handleGetFailingTests(client, input); if (options.json) { // eslint-disable-next-line no-console console.log(JSON.stringify(result, null, 2)); } else { /* eslint-disable no-console */ console.log(`\nšŸ“Š CI Status for ${result.pr}`); console.log(`Status: ${result.status}`); console.log(`Failures: ${result.failures.length}\n`); if (result.failures.length > 0) { console.log("Failed Tests:"); result.failures.forEach((test, i) => { console.log(`\n${i + 1}. ${test.test_name} (${test.check_name})`); if (test.file_path) console.log( ` File: ${test.file_path}${test.line_number ? `:${test.line_number}` : ""}`, ); if (test.error_message) console.log(` Error: ${test.error_message}`); }); } if (result.instructions) { console.log(`\nšŸ“ ${result.instructions.summary}`); } if (result.nextCursor) { console.log( `\nšŸ“„ More results available. Use --cursor "${result.nextCursor}"`, ); } /* eslint-enable no-console */ } process.exit(0); } catch (error) { console.error( `Error: ${error instanceof Error ? error.message : String(error)}`, ); process.exit(1); } }, ); program .command("find-unresolved-comments") .description("Find unresolved PR comments") .requiredOption("--pr ", "PR identifier (owner/repo#123)") .option("--include-bots", "Include bot comments") .option( "--exclude-authors ", "Comma-separated list of authors to exclude", ) .option("--cursor ", "Pagination cursor (from previous response)") .option( "--sort ", "Sort order (chronological|by_file|by_author|priority)", ) .option( "--include-status-indicators", "Include status indicators and priority scoring", ) .option("--priority-ordering", "Use priority-based ordering") .option("--json", "Output as JSON") .action( async (options: { pr: string; includeBots?: boolean; excludeAuthors?: string; cursor?: string; sort?: string; includeStatusIndicators?: boolean; priorityOrdering?: boolean; json?: boolean; }) => { try { const client = getClient(); // Build input and let Zod schema apply defaults const input = FindUnresolvedCommentsSchema.parse({ pr: options.pr, ...(options.includeBots !== undefined && { include_bots: options.includeBots, }), ...(options.excludeAuthors && { exclude_authors: options.excludeAuthors.split(","), }), ...(options.sort && { sort: options.sort }), ...(options.cursor && { cursor: options.cursor }), ...(options.includeStatusIndicators !== undefined && { include_status_indicators: options.includeStatusIndicators, }), ...(options.priorityOrdering !== undefined && { priority_ordering: options.priorityOrdering, }), }); const result: FindUnresolvedCommentsOutput = await handleFindUnresolvedComments(client, input); if (options.json) { // eslint-disable-next-line no-console console.log(JSON.stringify(result, null, 2)); if (result.nextCursor) { console.error( `\nāš ļø Large output detected. Use --cursor "${result.nextCursor}" for next page.`, ); } } else { /* eslint-disable no-console */ console.log(`\nšŸ’¬ Comments for ${result.pr}`); console.log(`Unresolved in page: ${result.unresolved_in_page}`); console.log(`Showing: ${result.comments.length}\n`); result.comments.forEach((comment, i) => { const icon = comment.is_bot ? "šŸ¤–" : "šŸ‘¤"; console.log( `\n${i + 1}. ${icon} ${comment.author} (${comment.type})`, ); if (comment.file_path) console.log( ` File: ${comment.file_path}${comment.line_number ? `:${comment.line_number}` : ""}`, ); console.log(` ${comment.body}`); console.log(` Created: ${comment.created_at}`); // Show action commands console.log( `\n šŸ“ Reply: ${comment.action_commands.reply_command}`, ); /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */ const ac: any = comment.action_commands as unknown as any; // display-only if (ac.mcp_action) { console.log( ` āœ… MCP: ${ac.mcp_action.tool} ${JSON.stringify(ac.mcp_action.args)}`, ); if (ac.resolve_condition) { console.log(` āš ļø ${ac.resolve_condition}`); } } else if (ac.resolve_command) { console.log(` āœ… Resolve: ${ac.resolve_command}`); if (ac.resolve_condition) { console.log(` āš ļø ${ac.resolve_condition}`); } } /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */ }); console.log(`\nšŸ“Š Summary:`); console.log( ` Bots: ${result.summary.bot_comments}, Humans: ${result.summary.human_comments}`, ); if (result.nextCursor) { console.log( `\nšŸ“„ More results available. Use --cursor "${result.nextCursor}"`, ); } /* eslint-enable no-console */ } process.exit(0); } catch (error) { console.error( `Error: ${error instanceof Error ? error.message : String(error)}`, ); process.exit(1); } }, ); program .command("manage-stacked-prs") .description("Manage stacked PRs") .requiredOption("--base-pr ", "Base PR (owner/repo#123)") .requiredOption( "--dependent-pr ", "Dependent PR (owner/repo#124)", ) .option("--auto-fix", "Auto-fix test failures") .option("--use-onto", "Use --onto rebase strategy") .option("--cursor ", "Pagination cursor (from previous response)") .option("--json", "Output as JSON") .action( async (options: { basePr: string; dependentPr: string; autoFix?: boolean; useOnto?: boolean; cursor?: string; json?: boolean; }) => { try { const client = getClient(); // Build input and let Zod schema apply defaults const input = ManageStackedPRsSchema.parse({ base_pr: options.basePr, dependent_pr: options.dependentPr, ...(options.autoFix !== undefined && { auto_fix: options.autoFix }), ...(options.useOnto !== undefined && { use_onto: options.useOnto }), ...(options.cursor && { cursor: options.cursor }), }); const result = await handleManageStackedPRs(client, input); if (options.json) { // eslint-disable-next-line no-console console.log(JSON.stringify(result, null, 2)); } else { /* eslint-disable no-console */ console.log(`\nšŸ”— Stack Analysis`); console.log(`Base PR: ${result.base_pr}`); console.log(`Dependent PR: ${result.dependent_pr}`); console.log(`Is stacked: ${result.is_stacked ? "āœ…" : "āŒ"}\n`); if (result.change_summary) { console.log( `Changes: ${result.change_summary.new_commits_in_base} new commits in base`, ); console.log( `Files changed: ${result.change_summary.files_changed.length}`, ); } if (result.commands.length > 0) { console.log("\nšŸ“ Rebase Commands:"); result.commands.forEach((cmd) => { console.log(`\n${cmd.step}. ${cmd.description}`); console.log(` $ ${cmd.command}`); if (cmd.estimated_duration) console.log(` ā±ļø ${cmd.estimated_duration}`); }); } console.log( `\nā±ļø Estimated time: ${result.summary.estimated_total_time}`, ); console.log(`āš ļø Risk level: ${result.summary.risk_level}`); if (result.nextCursor) { console.log( `\nšŸ“„ More commands available. Use --cursor "${result.nextCursor}"`, ); } /* eslint-enable no-console */ } process.exit(0); } catch (error) { console.error( `Error: ${error instanceof Error ? error.message : String(error)}`, ); process.exit(1); } }, ); program .command("resolve-review-thread") .description( "Resolve a specific review thread (or via comment id) immediately", ) .requiredOption("--pr ", "PR identifier (owner/repo#123)") .option("--thread-id ", "Review thread GraphQL node ID") .option("--comment-id ", "Comment GraphQL node ID (will map to thread)") .option( "--prefer ", 'Prefer "thread" or "comment" when both are provided', ) .option("--json", "Output as JSON") .action( async (options: { pr: string; threadId?: string; commentId?: string; prefer?: string; json?: boolean; }) => { try { const client = getClient(); const input = ResolveReviewThreadInputSchema.parse({ pr: options.pr, ...(options.threadId && { thread_id: options.threadId }), ...(options.commentId && { comment_id: options.commentId }), ...(options.prefer && { prefer: options.prefer }), }); const result = await handleResolveReviewThread(client, input); if (options.json) { // eslint-disable-next-line no-console console.log(JSON.stringify(result, null, 2)); } else { /* eslint-disable no-console */ console.log( `\nāœ… Resolved thread ${result.thread_id}${result.alreadyResolved ? " (already resolved)" : ""}`, ); if (result.message) console.log(result.message); /* eslint-enable no-console */ } process.exit(0); } catch (error) { console.error( `Error: ${error instanceof Error ? error.message : String(error)}`, ); process.exit(1); } }, ); await program.parseAsync(process.argv);