UNPKG

@aaronshaf/ger

Version:

Gerrit CLI and SDK - A modern CLI tool and TypeScript SDK for Gerrit Code Review, built with Effect-TS

657 lines (613 loc) 24 kB
import type { Command } from 'commander' import { Effect } from 'effect' import { GerritApiServiceLive } from '@/api/gerrit' import { ConfigServiceLive } from '@/services/config' import { CommitHookServiceLive } from '@/services/commit-hook' import { registerStateCommands } from './register-state-commands' import { rebaseCommand } from './commands/rebase' import { submitCommand } from './commands/submit' import { topicCommand, TOPIC_HELP_TEXT } from './commands/topic' import { voteCommand } from './commands/vote' import { projectsCommand } from './commands/projects' import { buildStatusCommand, BUILD_STATUS_HELP_TEXT } from './commands/build-status' import { checkoutCommand, CHECKOUT_HELP_TEXT } from './commands/checkout' import { commentCommand, COMMENT_HELP_TEXT } from './commands/comment' import { commentsCommand } from './commands/comments' import { diffCommand } from './commands/diff' import { extractUrlCommand } from './commands/extract-url' import { installHookCommand } from './commands/install-hook' import { openCommand } from './commands/open' import { pushCommand, PUSH_HELP_TEXT } from './commands/push' import { searchCommand, SEARCH_HELP_TEXT } from './commands/search' import { setup } from './commands/setup' import { showCommand, SHOW_HELP_TEXT } from './commands/show' import { statusCommand } from './commands/status' import { workspaceCommand } from './commands/workspace' import { sanitizeCDATA } from '@/utils/shell-safety' import { registerGroupCommands } from './register-group-commands' import { registerReviewerCommands } from './register-reviewer-commands' import { registerTreeCommands } from './register-tree-commands' import { filesCommand } from './commands/files' import { reviewersCommand } from './commands/reviewers' import { retriggerCommand, RETRIGGER_HELP_TEXT } from './commands/retrigger' import { cherryCommand, CHERRY_HELP_TEXT } from './commands/cherry' import { registerListCommands } from './register-list-commands' import { registerAnalyticsCommands } from './register-analytics-commands' // Helper function to output error in plain text, JSON, or XML format function outputError( error: unknown, options: { xml?: boolean; json?: boolean }, resultTag: string, ): void { const errorMessage = error instanceof Error ? error.message : String(error) if (options.json) { console.log(JSON.stringify({ status: 'error', error: errorMessage }, null, 2)) } else if (options.xml) { console.log(`<?xml version="1.0" encoding="UTF-8"?>`) console.log(`<${resultTag}>`) console.log(` <status>error</status>`) console.log(` <error><![CDATA[${errorMessage}]]></error>`) console.log(`</${resultTag}>`) } else { console.error('✗ Error:', errorMessage) } } // Helper function to execute Effect with standard error handling async function executeEffect<E>( effect: Effect.Effect<void, E, never>, options: { xml?: boolean; json?: boolean }, resultTag: string, ): Promise<void> { if (options.xml && options.json) { outputError(new Error('--xml and --json are mutually exclusive'), options, resultTag) process.exit(1) } try { await Effect.runPromise(effect) } catch (error) { outputError(error, options, resultTag) process.exit(1) } } export function registerCommands(program: Command): void { // setup command (new primary command) program .command('setup') .description('Configure Gerrit credentials and AI tools') .action(async () => { await setup() }) // init command (kept for backward compatibility, redirects to setup) program .command('init') .description('Initialize Gerrit credentials (alias for setup)') .action(async () => { await setup() }) // status command program .command('status') .description('Check connection status') .option('--xml', 'XML output for LLM consumption') .option('--json', 'JSON output for programmatic consumption') .action(async (options) => { await executeEffect( statusCommand(options).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(ConfigServiceLive), ), options, 'status_result', ) }) // comment command program .command('comment <change-id>') .description('Post a comment on a change (accepts change number or Change-ID)') .option('-m, --message <message>', 'Comment message') .option('--file <file>', 'File path for line-specific comment (relative to repo root)') .option( '--line <line>', 'Line number in the NEW version of the file (not diff line numbers)', parseInt, ) .option( '--reply-to <comment-id>', 'Reply to a comment thread (requires --file and --line; resolves thread by default)', ) .option('--unresolved', 'Mark comment as unresolved (requires human attention)') .option('--batch', 'Read batch comments from stdin as JSON (see examples below)') .option('--xml', 'XML output for LLM consumption') .option('--json', 'JSON output for programmatic consumption') .addHelpText('after', COMMENT_HELP_TEXT) .action(async (changeId, options) => { await executeEffect( commentCommand(changeId, options).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(ConfigServiceLive), ), options, 'comment_result', ) }) // diff command program .command('diff <change-id>') .description('Get diff for a change (accepts change number or Change-ID)') .option('--xml', 'XML output for LLM consumption') .option('--json', 'JSON output for programmatic consumption') .option('--file <file>', 'Specific file to diff') .option('--files-only', 'List changed files only') .option('--format <format>', 'Output format (unified, json, files)') .action(async (changeId, options) => { await executeEffect( diffCommand(changeId, options).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(ConfigServiceLive), ), options, 'diff_result', ) }) registerListCommands(program) // search command program .command('search [query]') .description('Search changes using Gerrit query syntax') .option('--xml', 'XML output for LLM consumption') .option('--json', 'JSON output for programmatic consumption') .option('-n, --limit <number>', 'Limit results (default: 25)') .addHelpText('after', SEARCH_HELP_TEXT) .action(async (query, options) => { const effect = searchCommand(query, options).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(ConfigServiceLive), ) await Effect.runPromise(effect).catch((error: unknown) => { const errorMessage = error instanceof Error ? error.message : String(error) if (options.json) { console.log(JSON.stringify({ status: 'error', error: errorMessage }, null, 2)) } else if (options.xml) { console.log(`<?xml version="1.0" encoding="UTF-8"?>`) console.log(`<search_result>`) console.log(` <status>error</status>`) console.log(` <error><![CDATA[${errorMessage}]]></error>`) console.log(`</search_result>`) } else { console.error('✗ Error:', errorMessage) } process.exit(1) }) }) // workspace command (deprecated — use 'ger tree setup' instead) program .command('workspace <change-id>') .description('[deprecated: use "ger tree setup"] Create a git worktree for a Gerrit change') .option('--xml', 'XML output for LLM consumption') .option('--json', 'JSON output for programmatic consumption') .action(async (changeId, options) => { if (!options.xml && !options.json) { console.error('Note: "ger workspace" is deprecated. Use "ger tree setup" instead.') } await executeEffect( workspaceCommand(changeId, options).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(ConfigServiceLive), ), options, 'workspace_result', ) }) registerTreeCommands(program) // abandon / restore / set-ready / set-wip commands registerStateCommands(program) // rebase command program .command('rebase [change-id]') .description('Rebase a change onto target branch (auto-detects from HEAD if not provided)') .option('--base <ref>', 'Base revision to rebase onto (default: target branch HEAD)') .option('--allow-conflicts', 'Allow rebasing even if conflicts exist') .option('--xml', 'XML output for LLM consumption') .option('--json', 'JSON output for programmatic consumption') .action(async (changeId, options) => { await executeEffect( rebaseCommand(changeId, { ...options, allowConflicts: options.allowConflicts }).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(ConfigServiceLive), ), options, 'rebase_result', ) }) // submit command program .command('submit <change-id>') .description('Submit a change for merging (accepts change number or Change-ID)') .option('--xml', 'XML output for LLM consumption') .option('--json', 'JSON output for programmatic consumption') .action(async (changeId, options) => { await executeEffect( submitCommand(changeId, options).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(ConfigServiceLive), ), options, 'submit_result', ) }) // topic command program .command('topic [change-id] [topic]') .description('Get, set, or remove topic for a change (auto-detects from HEAD if not specified)') .option('--delete', 'Remove the topic from the change') .option('--xml', 'XML output for LLM consumption') .option('--json', 'JSON output for programmatic consumption') .addHelpText('after', TOPIC_HELP_TEXT) .action(async (changeId, topic, options) => { await executeEffect( topicCommand(changeId, topic, options).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(ConfigServiceLive), ), options, 'topic_result', ) }) // vote command program .command('vote <change-id>') .description('Cast votes on a change (accepts change number or Change-ID)') .option('--code-review <value>', 'Code-Review vote (-2 to +2)', parseInt) .option('--verified <value>', 'Verified vote (-1 to +1)', parseInt) .option('--label <name> <value>', 'Custom label vote (can be used multiple times)') .option('-m, --message <message>', 'Comment with vote') .option('--xml', 'XML output for LLM consumption') .option('--json', 'JSON output for programmatic consumption') .action(async (changeId, options) => { await executeEffect( voteCommand(changeId, options).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(ConfigServiceLive), ), options, 'vote_result', ) }) // Register all reviewer-related commands registerReviewerCommands(program) // projects command program .command('projects') .description('List Gerrit projects') .option('--pattern <regex>', 'Filter projects by name pattern') .option('--xml', 'XML output for LLM consumption') .option('--json', 'JSON output for programmatic consumption') .action(async (options) => { await executeEffect( projectsCommand(options).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(ConfigServiceLive), ), options, 'projects_result', ) }) // Register all group-related commands registerGroupCommands(program) // retrigger command program .command('retrigger [change-id]') .description( 'Post the CI retrigger comment on a change (auto-detects from HEAD if no change-id given)', ) .option('--xml', 'XML output for LLM consumption') .option('--json', 'JSON output for programmatic consumption') .addHelpText('after', RETRIGGER_HELP_TEXT) .action(async (changeId, options) => { await executeEffect( retriggerCommand(changeId as string | undefined, options).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(ConfigServiceLive), ), options, 'retrigger_result', ) }) // comments command program .command('comments <change-id>') .description( 'Show all comments on a change with diff context (accepts change number or Change-ID)', ) .option('--xml', 'XML output for LLM consumption') .option('--json', 'JSON output for programmatic consumption') .action(async (changeId, options) => { await executeEffect( commentsCommand(changeId, options).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(ConfigServiceLive), ), options, 'comments_result', ) }) // open command program .command('open <change-id>') .description('Open a change in the browser (accepts change number or Change-ID)') .action(async (changeId, options) => { try { const effect = openCommand(changeId, options).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(ConfigServiceLive), ) await Effect.runPromise(effect) } catch (error) { console.error('✗ Error:', error instanceof Error ? error.message : String(error)) process.exit(1) } }) // show command program .command('show [change-id]') .description( 'Show comprehensive change information (auto-detects from HEAD commit if not specified)', ) .option('--xml', 'XML output for LLM consumption') .option('--json', 'JSON output for programmatic consumption') .addHelpText('after', SHOW_HELP_TEXT) .action(async (changeId, options) => { try { const effect = showCommand(changeId, options).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(ConfigServiceLive), ) await Effect.runPromise(effect) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) if (options.json) { console.log(JSON.stringify({ status: 'error', error: errorMessage }, null, 2)) } else if (options.xml) { console.log(`<?xml version="1.0" encoding="UTF-8"?>`) console.log(`<show_result>`) console.log(` <status>error</status>`) console.log(` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>`) console.log(`</show_result>`) } else { console.error('✗ Error:', errorMessage) } process.exit(1) } }) // build-status command program .command('build-status [change-id]') .description( 'Check build status from Gerrit messages (auto-detects from HEAD commit if not specified)', ) .option('--watch', 'Watch build status until completion (mimics gh run watch)') .option('-i, --interval <seconds>', 'Refresh interval in seconds (default: 10)', '10') .option('--timeout <seconds>', 'Maximum wait time in seconds (default: 1800 / 30min)', '1800') .option('--exit-status', 'Exit with non-zero status if build fails') .addHelpText('after', BUILD_STATUS_HELP_TEXT) .action(async (changeId, cmdOptions) => { try { const effect = buildStatusCommand(changeId, { watch: cmdOptions.watch, interval: Number.parseInt(cmdOptions.interval, 10), timeout: Number.parseInt(cmdOptions.timeout, 10), exitStatus: cmdOptions.exitStatus, }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(ConfigServiceLive)) await Effect.runPromise(effect) } catch (error) { // Errors are handled within the command itself // This catch is just for any unexpected errors if (error instanceof Error && error.message !== 'Process exited') { console.error('✗ Unexpected error:', error.message) process.exit(3) } } }) // extract-url command program .command('extract-url <pattern> [change-id]') .description( 'Extract URLs from change messages and comments (auto-detects from HEAD commit if not specified)', ) .option('--include-comments', 'Also search inline comments (default: messages only)') .option('--regex', 'Treat pattern as regex instead of substring match') .option('--xml', 'XML output for LLM consumption') .option('--json', 'JSON output for programmatic consumption') .addHelpText( 'after', ` Examples: # Extract all Jenkins build-summary-report URLs (substring match) $ ger extract-url "build-summary-report" # Get the latest build URL using tail $ ger extract-url "build-summary-report" | tail -1 # Get the first build URL using head $ ger extract-url "jenkins.inst-ci.net" | head -1 # For a specific change (using change number) $ ger extract-url "build-summary" 391831 # For a specific change (using Change-ID) $ ger extract-url "jenkins" If5a3ae8cb5a107e187447802358417f311d0c4b1 # Use regex for precise matching $ ger extract-url "job/MyProject/job/main/\\d+/" --regex # Search both messages and inline comments $ ger extract-url "github.com" --include-comments # JSON output for scripting $ ger extract-url "jenkins" --json | jq -r '.urls[-1]' # XML output $ ger extract-url "jenkins" --xml Note: - URLs are output in chronological order (oldest first) - Use tail -1 to get the latest URL, head -1 for the oldest - When no change-id is provided, it will be automatically extracted from the Change-ID footer in your HEAD commit`, ) .action(async (pattern, changeId, options) => { try { const effect = extractUrlCommand(pattern, changeId, options).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(ConfigServiceLive), ) await Effect.runPromise(effect) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) if (options.json) { console.log(JSON.stringify({ status: 'error', error: errorMessage }, null, 2)) } else if (options.xml) { console.log(`<?xml version="1.0" encoding="UTF-8"?>`) console.log(`<extract_url_result>`) console.log(` <status>error</status>`) console.log(` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>`) console.log(`</extract_url_result>`) } else { console.error('✗ Error:', errorMessage) } process.exit(1) } }) // install-hook command program .command('install-hook') .description('Install the Gerrit commit-msg hook for automatic Change-Id generation') .option('--force', 'Overwrite existing hook') .option('--xml', 'XML output for LLM consumption') .option('--json', 'JSON output for programmatic consumption') .addHelpText( 'after', ` Examples: # Install the commit-msg hook $ ger install-hook # Force reinstall (overwrite existing) $ ger install-hook --force Note: - Downloads hook from your configured Gerrit server - Installs to .git/hooks/commit-msg - Makes hook executable (chmod +x) - Required for commits to have Change-Id footers`, ) .action(async (options) => { await executeEffect( installHookCommand(options).pipe( Effect.provide(CommitHookServiceLive), Effect.provide(ConfigServiceLive), ), options, 'install_hook_result', ) }) // push command program .command('push') .description('Push commits to Gerrit for code review') .option('-b, --branch <branch>', 'Target branch (default: auto-detect)') .option('-t, --topic <topic>', 'Set change topic') .option('-r, --reviewer <email...>', 'Add reviewer(s)') .option('--cc <email...>', 'Add CC recipient(s)') .option('--wip', 'Mark as work-in-progress') .option('--ready', 'Mark as ready for review') .option('--hashtag <tag...>', 'Add hashtag(s)') .option('--private', 'Mark change as private') .option('--draft', 'Alias for --wip') .option('--dry-run', 'Show what would be pushed without pushing') .addHelpText('after', PUSH_HELP_TEXT) .action(async (options) => { try { const effect = pushCommand({ branch: options.branch, topic: options.topic, reviewer: options.reviewer, cc: options.cc, wip: options.wip, ready: options.ready, hashtag: options.hashtag, private: options.private, draft: options.draft, dryRun: options.dryRun, }).pipe(Effect.provide(CommitHookServiceLive), Effect.provide(ConfigServiceLive)) await Effect.runPromise(effect) } catch (error) { console.error('Error:', error instanceof Error ? error.message : String(error)) process.exit(1) } }) // files command program .command('files [change-id]') .description( 'List files changed in a Gerrit change (auto-detects from HEAD commit if not specified)', ) .option('--xml', 'XML output for LLM consumption') .option('--json', 'JSON output for programmatic consumption') .action(async (changeId, options) => { await executeEffect( filesCommand(changeId, options).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(ConfigServiceLive), ), options, 'files_result', ) }) // reviewers command program .command('reviewers [change-id]') .description( 'List reviewers on a Gerrit change (auto-detects from HEAD commit if not specified)', ) .option('--xml', 'XML output for LLM consumption') .option('--json', 'JSON output for programmatic consumption') .action(async (changeId, options) => { await executeEffect( reviewersCommand(changeId, options).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(ConfigServiceLive), ), options, 'reviewers_result', ) }) // checkout command program .command('checkout <change-id>') .description('Fetch and checkout a Gerrit change') .option('--detach', 'Checkout as detached HEAD without creating branch') .option('--remote <name>', 'Use specific git remote (default: auto-detect)') .addHelpText('after', CHECKOUT_HELP_TEXT) .action(async (changeId, options) => { try { const effect = checkoutCommand(changeId, { detach: options.detach, remote: options.remote, }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(ConfigServiceLive)) await Effect.runPromise(effect) } catch (error) { console.error('Error:', error instanceof Error ? error.message : String(error)) process.exit(1) } }) registerAnalyticsCommands(program) // cherry command program .command('cherry <change-id>') .description('Fetch and cherry-pick a Gerrit change onto the current branch') .option('-n, --no-commit', 'Stage changes without committing') .option('--no-verify', 'Bypass git commit hooks during cherry-pick') .option('--remote <name>', 'Use specific git remote (default: auto-detect)') .addHelpText('after', CHERRY_HELP_TEXT) .action(async (changeId, options) => { await executeEffect( cherryCommand(changeId, { noCommit: options.noCommit, noVerify: options.noVerify, remote: options.remote, }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(ConfigServiceLive)), options, 'cherry_result', ) }) }