@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
text/typescript
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',
)
})
}