@aaronshaf/ger 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/.ast-grep/rules/no-as-casting.yml +13 -0
  2. package/.eslintrc.js +12 -0
  3. package/.github/workflows/ci-simple.yml +53 -0
  4. package/.github/workflows/ci.yml +171 -0
  5. package/.github/workflows/claude-code-review.yml +78 -0
  6. package/.github/workflows/claude.yml +64 -0
  7. package/.github/workflows/dependency-update.yml +84 -0
  8. package/.github/workflows/release.yml +166 -0
  9. package/.github/workflows/security-scan.yml +113 -0
  10. package/.github/workflows/security.yml +96 -0
  11. package/.husky/pre-commit +16 -0
  12. package/.husky/pre-push +25 -0
  13. package/.lintstagedrc.json +6 -0
  14. package/.tool-versions +1 -0
  15. package/CLAUDE.md +103 -0
  16. package/DEVELOPMENT.md +361 -0
  17. package/LICENSE +21 -0
  18. package/README.md +325 -0
  19. package/bin/ger +3 -0
  20. package/biome.json +36 -0
  21. package/bun.lock +688 -0
  22. package/bunfig.toml +8 -0
  23. package/oxlint.json +24 -0
  24. package/package.json +55 -0
  25. package/scripts/check-coverage.ts +69 -0
  26. package/scripts/check-file-size.ts +38 -0
  27. package/scripts/fix-test-mocks.ts +55 -0
  28. package/src/api/gerrit.ts +466 -0
  29. package/src/cli/commands/abandon.ts +65 -0
  30. package/src/cli/commands/comment.ts +460 -0
  31. package/src/cli/commands/comments.ts +85 -0
  32. package/src/cli/commands/diff.ts +71 -0
  33. package/src/cli/commands/incoming.ts +226 -0
  34. package/src/cli/commands/init.ts +164 -0
  35. package/src/cli/commands/mine.ts +115 -0
  36. package/src/cli/commands/open.ts +57 -0
  37. package/src/cli/commands/review.ts +593 -0
  38. package/src/cli/commands/setup.ts +230 -0
  39. package/src/cli/commands/show.ts +303 -0
  40. package/src/cli/commands/status.ts +35 -0
  41. package/src/cli/commands/workspace.ts +200 -0
  42. package/src/cli/index.ts +420 -0
  43. package/src/prompts/default-review.md +80 -0
  44. package/src/prompts/system-inline-review.md +88 -0
  45. package/src/prompts/system-overall-review.md +152 -0
  46. package/src/schemas/config.test.ts +245 -0
  47. package/src/schemas/config.ts +75 -0
  48. package/src/schemas/gerrit.ts +455 -0
  49. package/src/services/ai-enhanced.ts +167 -0
  50. package/src/services/ai.ts +182 -0
  51. package/src/services/config.test.ts +414 -0
  52. package/src/services/config.ts +206 -0
  53. package/src/test-utils/mock-generator.ts +73 -0
  54. package/src/utils/comment-formatters.ts +153 -0
  55. package/src/utils/diff-context.ts +103 -0
  56. package/src/utils/diff-formatters.ts +141 -0
  57. package/src/utils/formatters.ts +85 -0
  58. package/src/utils/message-filters.ts +26 -0
  59. package/src/utils/shell-safety.ts +117 -0
  60. package/src/utils/status-indicators.ts +100 -0
  61. package/src/utils/url-parser.test.ts +123 -0
  62. package/src/utils/url-parser.ts +91 -0
  63. package/tests/abandon.test.ts +163 -0
  64. package/tests/ai-service.test.ts +489 -0
  65. package/tests/comment-batch-advanced.test.ts +431 -0
  66. package/tests/comment-gerrit-api-compliance.test.ts +414 -0
  67. package/tests/comment.test.ts +707 -0
  68. package/tests/comments.test.ts +323 -0
  69. package/tests/config-service-simple.test.ts +100 -0
  70. package/tests/diff.test.ts +419 -0
  71. package/tests/helpers/config-mock.ts +27 -0
  72. package/tests/incoming.test.ts +357 -0
  73. package/tests/interactive-incoming.test.ts +173 -0
  74. package/tests/mine.test.ts +318 -0
  75. package/tests/mocks/fetch-mock.ts +139 -0
  76. package/tests/mocks/msw-handlers.ts +80 -0
  77. package/tests/open.test.ts +233 -0
  78. package/tests/review.test.ts +669 -0
  79. package/tests/setup.ts +13 -0
  80. package/tests/show.test.ts +439 -0
  81. package/tests/unit/schemas/gerrit.test.ts +85 -0
  82. package/tests/unit/test-utils/mock-generator.test.ts +154 -0
  83. package/tests/unit/utils/comment-formatters.test.ts +415 -0
  84. package/tests/unit/utils/diff-context.test.ts +171 -0
  85. package/tests/unit/utils/diff-formatters.test.ts +165 -0
  86. package/tests/unit/utils/formatters.test.ts +411 -0
  87. package/tests/unit/utils/message-filters.test.ts +227 -0
  88. package/tests/unit/utils/prompt-helpers.test.ts +175 -0
  89. package/tests/unit/utils/shell-safety.test.ts +230 -0
  90. package/tests/unit/utils/status-indicators.test.ts +137 -0
  91. package/tsconfig.json +40 -0
@@ -0,0 +1,226 @@
1
+ import { Effect } from 'effect'
2
+ import { select } from '@inquirer/prompts'
3
+ import { exec } from 'node:child_process'
4
+ import { promisify } from 'node:util'
5
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
6
+ import { type ConfigError, ConfigService } from '@/services/config'
7
+ import { colors } from '@/utils/formatters'
8
+ import { getStatusIndicators } from '@/utils/status-indicators'
9
+ import type { ChangeInfo } from '@/schemas/gerrit'
10
+ import { sanitizeUrlSync, getOpenCommand } from '@/utils/shell-safety'
11
+
12
+ const execAsync = promisify(exec)
13
+
14
+ interface IncomingOptions {
15
+ xml?: boolean
16
+ interactive?: boolean
17
+ }
18
+
19
+ // Group changes by project for better organization
20
+ const groupChangesByProject = (changes: readonly ChangeInfo[]) => {
21
+ const grouped = new Map<string, ChangeInfo[]>()
22
+
23
+ for (const change of changes) {
24
+ const project = change.project
25
+ if (!grouped.has(project)) {
26
+ grouped.set(project, [])
27
+ }
28
+ grouped.get(project)!.push(change)
29
+ }
30
+
31
+ // Sort projects alphabetically and changes by updated date
32
+ return Array.from(grouped.entries())
33
+ .sort(([a], [b]) => a.localeCompare(b))
34
+ .map(([project, projectChanges]) => ({
35
+ project,
36
+ changes: projectChanges.sort((a, b) => {
37
+ const dateA = a.updated ? new Date(a.updated).getTime() : 0
38
+ const dateB = b.updated ? new Date(b.updated).getTime() : 0
39
+ return dateB - dateA
40
+ }),
41
+ }))
42
+ }
43
+
44
+ // Format change for display in inquirer
45
+ const formatChangeChoice = (change: ChangeInfo) => {
46
+ const indicators = getStatusIndicators(change)
47
+ const statusPart = indicators ? `${indicators} ` : ''
48
+ const subject =
49
+ change.subject.length > 60 ? `${change.subject.substring(0, 57)}...` : change.subject
50
+
51
+ return {
52
+ name: `${statusPart}${subject} (${change._number})`,
53
+ value: change,
54
+ description: `By ${change.owner?.name || 'Unknown'} • ${change.status}`,
55
+ }
56
+ }
57
+
58
+ // Open change in browser
59
+ const openInBrowser = async (gerritHost: string, changeNumber: number) => {
60
+ const url = `${gerritHost}/c/${changeNumber}`
61
+ const sanitizedUrl = sanitizeUrlSync(url)
62
+
63
+ if (!sanitizedUrl) {
64
+ console.error(`${colors.red}✗ Invalid URL: ${url}${colors.reset}`)
65
+ return
66
+ }
67
+
68
+ const openCmd = getOpenCommand()
69
+ try {
70
+ await execAsync(`${openCmd} "${sanitizedUrl}"`)
71
+ console.log(`${colors.green}✓ Opened ${changeNumber} in browser${colors.reset}`)
72
+ } catch (error) {
73
+ console.error(`${colors.red}✗ Failed to open browser: ${error}${colors.reset}`)
74
+ }
75
+ }
76
+
77
+ export const incomingCommand = (
78
+ options: IncomingOptions,
79
+ ): Effect.Effect<void, ApiError | ConfigError, GerritApiService | ConfigService> =>
80
+ Effect.gen(function* () {
81
+ const gerritApi = yield* GerritApiService
82
+
83
+ // Query for changes where user is a reviewer but not the owner
84
+ const changes = yield* gerritApi.listChanges(
85
+ 'is:open -owner:self -is:wip -is:ignored reviewer:self',
86
+ )
87
+
88
+ if (options.interactive) {
89
+ if (changes.length === 0) {
90
+ console.log(`${colors.yellow}No incoming reviews found${colors.reset}`)
91
+ return
92
+ }
93
+
94
+ // Get Gerrit host for opening changes in browser
95
+ const configService = yield* ConfigService
96
+ const credentials = yield* configService.getCredentials
97
+
98
+ // Group changes by project
99
+ const groupedChanges = groupChangesByProject(changes)
100
+
101
+ // Create choices for inquirer with project sections
102
+ const choices: Array<{ name: string; value: ChangeInfo | string }> = []
103
+
104
+ for (const { project, changes: projectChanges } of groupedChanges) {
105
+ // Add project header as separator
106
+ choices.push({
107
+ name: `\n${colors.blue}━━━ ${project} ━━━${colors.reset}`,
108
+ value: 'separator',
109
+ })
110
+
111
+ // Add changes for this project
112
+ for (const change of projectChanges) {
113
+ const formatted = formatChangeChoice(change)
114
+ choices.push({
115
+ name: formatted.name,
116
+ value: change,
117
+ })
118
+ }
119
+ }
120
+
121
+ // Add exit option
122
+ choices.push({
123
+ name: `\n${colors.gray}Exit${colors.reset}`,
124
+ value: 'exit',
125
+ })
126
+
127
+ // Interactive selection loop
128
+ let continueSelecting = true
129
+ while (continueSelecting) {
130
+ const selected = yield* Effect.promise(async () => {
131
+ return await select({
132
+ message: 'Select a change to open in browser:',
133
+ choices: choices.filter((c) => c.value !== 'separator'),
134
+ pageSize: 15,
135
+ })
136
+ })
137
+
138
+ if (selected === 'exit' || !selected) {
139
+ continueSelecting = false
140
+ } else if (typeof selected !== 'string') {
141
+ // Open the selected change
142
+ yield* Effect.promise(() => openInBrowser(credentials.host, selected._number))
143
+
144
+ // Ask if user wants to continue
145
+ const continueChoice = yield* Effect.promise(async () => {
146
+ return await select({
147
+ message: 'Continue?',
148
+ choices: [
149
+ { name: 'Select another change', value: 'continue' },
150
+ { name: 'Exit', value: 'exit' },
151
+ ],
152
+ })
153
+ })
154
+
155
+ if (continueChoice === 'exit') {
156
+ continueSelecting = false
157
+ }
158
+ }
159
+ }
160
+
161
+ return
162
+ }
163
+
164
+ if (options.xml) {
165
+ // XML output
166
+ const xmlOutput = [
167
+ '<?xml version="1.0" encoding="UTF-8"?>',
168
+ '<incoming_reviews>',
169
+ ` <count>${changes.length}</count>`,
170
+ ]
171
+
172
+ if (changes.length > 0) {
173
+ xmlOutput.push(' <changes>')
174
+
175
+ // Group by project for XML output too
176
+ const groupedChanges = groupChangesByProject(changes)
177
+
178
+ for (const { project, changes: projectChanges } of groupedChanges) {
179
+ xmlOutput.push(` <project name="${project}">`)
180
+ for (const change of projectChanges) {
181
+ xmlOutput.push(' <change>')
182
+ xmlOutput.push(` <number>${change._number}</number>`)
183
+ xmlOutput.push(` <subject><![CDATA[${change.subject}]]></subject>`)
184
+ xmlOutput.push(` <status>${change.status}</status>`)
185
+ xmlOutput.push(` <owner>${change.owner?.name || 'Unknown'}</owner>`)
186
+ xmlOutput.push(` <updated>${change.updated}</updated>`)
187
+ xmlOutput.push(' </change>')
188
+ }
189
+ xmlOutput.push(' </project>')
190
+ }
191
+
192
+ xmlOutput.push(' </changes>')
193
+ }
194
+
195
+ xmlOutput.push('</incoming_reviews>')
196
+ console.log(xmlOutput.join('\n'))
197
+ } else {
198
+ // Pretty output (default)
199
+ if (changes.length === 0) {
200
+ console.log(`${colors.green}✓ No incoming reviews${colors.reset}`)
201
+ return
202
+ }
203
+
204
+ console.log(`${colors.blue}Incoming Reviews (${changes.length})${colors.reset}\n`)
205
+
206
+ // Group by project for display
207
+ const groupedChanges = groupChangesByProject(changes)
208
+
209
+ for (const { project, changes: projectChanges } of groupedChanges) {
210
+ console.log(`${colors.gray}${project}${colors.reset}`)
211
+
212
+ for (const change of projectChanges) {
213
+ const indicators = getStatusIndicators(change)
214
+ const statusPart = indicators ? `${indicators} ` : ''
215
+
216
+ console.log(
217
+ ` ${statusPart}${colors.yellow}#${change._number}${colors.reset} ${change.subject}`,
218
+ )
219
+ console.log(
220
+ ` ${colors.gray}by ${change.owner?.name || 'Unknown'} • ${change.status}${colors.reset}`,
221
+ )
222
+ }
223
+ console.log() // Empty line between projects
224
+ }
225
+ }
226
+ })
@@ -0,0 +1,164 @@
1
+ import * as fs from 'node:fs'
2
+ import * as os from 'node:os'
3
+ import * as path from 'node:path'
4
+ import * as readline from 'node:readline/promises'
5
+ import { Effect } from 'effect'
6
+ import type { GerritCredentials } from '@/schemas/gerrit'
7
+ import { ConfigService } from '@/services/config'
8
+
9
+ const CONFIG_FILE = path.join(os.homedir(), '.ger', 'auth.json')
10
+
11
+ const obscureToken = (token: string): string => {
12
+ if (token.length <= 8) return '****'
13
+ return `${token.substring(0, 4)}****${token.substring(token.length - 4)}`
14
+ }
15
+
16
+ // Hidden password input using manual stdin manipulation
17
+ const readPassword = async (prompt: string, fallbackPrompt?: string): Promise<string> => {
18
+ const stdin = process.stdin
19
+ const stdout = process.stdout
20
+
21
+ // Check if we can use raw mode (TTY environment)
22
+ if (!stdin.isTTY || !stdin.setRawMode) {
23
+ // Fallback to regular readline with warning
24
+ if (fallbackPrompt) {
25
+ console.log('⚠️ Note: Password will be visible while typing (non-TTY environment)')
26
+ }
27
+ const rl = readline.createInterface({
28
+ input: stdin,
29
+ output: stdout,
30
+ })
31
+ const answer = await rl.question(fallbackPrompt || prompt)
32
+ rl.close()
33
+ return answer
34
+ }
35
+
36
+ return new Promise((resolve) => {
37
+ stdout.write(prompt)
38
+
39
+ stdin.setRawMode(true)
40
+ stdin.resume()
41
+ stdin.setEncoding('utf8')
42
+
43
+ let password = ''
44
+
45
+ const onData = (char: string) => {
46
+ const code = char.charCodeAt(0)
47
+
48
+ if (code === 3) {
49
+ // Ctrl+C
50
+ stdout.write('\n')
51
+ process.exit(0)
52
+ } else if (code === 13 || code === 10) {
53
+ // Enter
54
+ stdin.setRawMode(false)
55
+ stdin.pause()
56
+ stdin.removeListener('data', onData)
57
+ stdout.write('\n')
58
+ resolve(password)
59
+ } else if (code === 127 || code === 8) {
60
+ // Backspace
61
+ if (password.length > 0) {
62
+ password = password.slice(0, -1)
63
+ stdout.write('\b \b') // Move back, write space, move back again
64
+ }
65
+ } else if (code >= 32 && code <= 126) {
66
+ // Printable characters
67
+ password += char
68
+ stdout.write('*')
69
+ }
70
+ }
71
+
72
+ stdin.on('data', onData)
73
+ })
74
+ }
75
+
76
+ const readExistingConfig = (): GerritCredentials | null => {
77
+ try {
78
+ if (fs.existsSync(CONFIG_FILE)) {
79
+ const content = fs.readFileSync(CONFIG_FILE, 'utf8')
80
+ return JSON.parse(content)
81
+ }
82
+ } catch {
83
+ // Ignore errors
84
+ }
85
+ return null
86
+ }
87
+
88
+ export const initCommand = (): Effect.Effect<void, Error, ConfigService> =>
89
+ Effect.gen(function* () {
90
+ const configService = yield* ConfigService
91
+
92
+ const rl = readline.createInterface({
93
+ input: process.stdin,
94
+ output: process.stdout,
95
+ })
96
+
97
+ console.log('Gerrit CLI Setup')
98
+ console.log('================')
99
+ console.log('')
100
+
101
+ // Load existing config if it exists
102
+ const existing = readExistingConfig()
103
+
104
+ if (existing) {
105
+ console.log('Found existing configuration:')
106
+ console.log(` Host: ${existing.host}`)
107
+ console.log(` Username: ${existing.username}`)
108
+ console.log(` Password: ${obscureToken(existing.password)}`)
109
+ console.log('')
110
+ console.log('Press Enter to keep existing values, or type new ones.')
111
+ console.log('')
112
+ }
113
+
114
+ // Prompt for host
115
+ const hostPrompt = existing?.host
116
+ ? `Gerrit host [${existing.host}]: `
117
+ : 'Gerrit host (e.g., https://gerrit.example.com): '
118
+ const host = yield* Effect.tryPromise(() => rl.question(hostPrompt)).pipe(
119
+ Effect.map((answer) => answer || existing?.host),
120
+ Effect.flatMap((value) =>
121
+ value ? Effect.succeed(value) : Effect.fail(new Error('Host is required')),
122
+ ),
123
+ )
124
+
125
+ // Prompt for username
126
+ const usernamePrompt = existing?.username ? `Username [${existing.username}]: ` : 'Username: '
127
+ const username = yield* Effect.tryPromise(() => rl.question(usernamePrompt)).pipe(
128
+ Effect.map((answer) => answer || existing?.username),
129
+ Effect.flatMap((value) =>
130
+ value ? Effect.succeed(value) : Effect.fail(new Error('Username is required')),
131
+ ),
132
+ )
133
+
134
+ // Close readline interface before password prompt (we'll use raw mode)
135
+ rl.close()
136
+
137
+ // Prompt for password (with hidden input)
138
+ const passwordPrompt = existing?.password
139
+ ? `HTTP Password [${obscureToken(existing.password)}]: `
140
+ : 'HTTP Password (from Gerrit Settings → HTTP Password): '
141
+
142
+ const password = yield* Effect.tryPromise(() => readPassword(passwordPrompt)).pipe(
143
+ Effect.map((answer) => answer || existing?.password),
144
+ Effect.flatMap((value) =>
145
+ value ? Effect.succeed(value) : Effect.fail(new Error('Password is required')),
146
+ ),
147
+ )
148
+
149
+ const credentials: GerritCredentials = {
150
+ host: host.replace(/\/$/, ''), // Remove trailing slash if present
151
+ username,
152
+ password,
153
+ }
154
+
155
+ yield* configService.saveCredentials(credentials)
156
+
157
+ console.log('')
158
+ console.log('✓ Credentials saved to ~/.ger/auth.json')
159
+ console.log('')
160
+ console.log('You can now use commands like:')
161
+ console.log(' ger status')
162
+ console.log(' ger mine --pretty')
163
+ console.log(' ger workspace <change-id>')
164
+ })
@@ -0,0 +1,115 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+ import type { ChangeInfo } from '@/schemas/gerrit'
4
+ import { colors } from '@/utils/formatters'
5
+
6
+ interface MineOptions {
7
+ xml?: boolean
8
+ }
9
+
10
+ // ANSI color codes
11
+
12
+ export const mineCommand = (
13
+ options: MineOptions,
14
+ ): Effect.Effect<void, ApiError, GerritApiService> =>
15
+ Effect.gen(function* () {
16
+ const gerritApi = yield* GerritApiService
17
+
18
+ const changes = yield* gerritApi.listChanges('owner:self status:open')
19
+
20
+ if (options.xml) {
21
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
22
+ console.log(`<changes count="${changes.length}">`)
23
+
24
+ for (const change of changes) {
25
+ console.log(` <change>`)
26
+ console.log(` <number>${change._number}</number>`)
27
+ console.log(` <subject><![CDATA[${change.subject}]]></subject>`)
28
+ console.log(` <project>${change.project}</project>`)
29
+ console.log(` <branch>${change.branch}</branch>`)
30
+ console.log(` <status>${change.status}</status>`)
31
+ console.log(` <change_id>${change.change_id}</change_id>`)
32
+ if (change.updated) {
33
+ console.log(` <updated>${change.updated}</updated>`)
34
+ }
35
+ if (change.owner?.name) {
36
+ console.log(` <owner>${change.owner.name}</owner>`)
37
+ }
38
+ console.log(` </change>`)
39
+ }
40
+
41
+ console.log(`</changes>`)
42
+ } else {
43
+ // Pretty output by default
44
+ if (changes.length === 0) {
45
+ return
46
+ }
47
+
48
+ // Group changes by project
49
+ const changesByProject = changes.reduce(
50
+ (acc, change) => {
51
+ if (!acc[change.project]) {
52
+ acc[change.project] = []
53
+ }
54
+ acc[change.project] = [...acc[change.project], change]
55
+ return acc
56
+ },
57
+ {} as Record<string, ChangeInfo[]>,
58
+ )
59
+
60
+ // Sort projects alphabetically
61
+ const sortedProjects = Object.keys(changesByProject).sort()
62
+
63
+ for (const [index, project] of sortedProjects.entries()) {
64
+ if (index > 0) {
65
+ console.log('') // Add blank line between projects
66
+ }
67
+ console.log(`${colors.blue}${project}${colors.reset}`)
68
+
69
+ const projectChanges = changesByProject[project]
70
+ for (const change of projectChanges) {
71
+ // Build status indicators
72
+ const indicators: string[] = []
73
+ const indicatorChars: string[] = [] // Track visual characters for padding
74
+
75
+ if (change.labels?.['Code-Review']) {
76
+ const cr = change.labels['Code-Review']
77
+ if (cr.approved || cr.value === 2) {
78
+ indicators.push(`${colors.green}✓${colors.reset}`)
79
+ indicatorChars.push('✓')
80
+ } else if (cr.rejected || cr.value === -2) {
81
+ indicators.push(`${colors.red}✗${colors.reset}`)
82
+ indicatorChars.push('✗')
83
+ } else if (cr.recommended || cr.value === 1) {
84
+ indicators.push(`${colors.cyan}↑${colors.reset}`)
85
+ indicatorChars.push('↑')
86
+ } else if (cr.disliked || cr.value === -1) {
87
+ indicators.push(`${colors.yellow}↓${colors.reset}`)
88
+ indicatorChars.push('↓')
89
+ }
90
+ }
91
+
92
+ // Check for Verified label as well
93
+ if (change.labels?.['Verified']) {
94
+ const v = change.labels.Verified
95
+ if (v.approved || v.value === 1) {
96
+ if (!indicatorChars.includes('✓')) {
97
+ indicators.push(`${colors.green}✓${colors.reset}`)
98
+ indicatorChars.push('✓')
99
+ }
100
+ } else if (v.rejected || v.value === -1) {
101
+ indicators.push(`${colors.red}✗${colors.reset}`)
102
+ indicatorChars.push('✗')
103
+ }
104
+ }
105
+
106
+ // Calculate padding based on visual characters, not color codes
107
+ const visualWidth = indicatorChars.join(' ').length
108
+ const padding = ' '.repeat(Math.max(0, 8 - visualWidth))
109
+ const statusStr = indicators.length > 0 ? indicators.join(' ') + padding : ' '
110
+
111
+ console.log(`${statusStr} ${change._number} ${change.subject}`)
112
+ }
113
+ }
114
+ }
115
+ })
@@ -0,0 +1,57 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+ import { type ConfigError, ConfigService } from '@/services/config'
4
+ import { extractChangeNumber, isValidChangeId } from '@/utils/url-parser'
5
+ import { sanitizeUrl, getOpenCommand } from '@/utils/shell-safety'
6
+ import { exec } from 'node:child_process'
7
+
8
+ interface OpenOptions {
9
+ // No options for now, but keeping the structure for future extensibility
10
+ }
11
+
12
+ export const openCommand = (
13
+ changeId: string,
14
+ _options: OpenOptions = {},
15
+ ): Effect.Effect<void, ApiError | ConfigError | Error, GerritApiService | ConfigService> =>
16
+ Effect.gen(function* () {
17
+ const gerritApi = yield* GerritApiService
18
+ const configService = yield* ConfigService
19
+
20
+ // Extract change number if a URL was provided
21
+ const cleanChangeId = extractChangeNumber(changeId)
22
+
23
+ // Validate the change ID
24
+ if (!isValidChangeId(cleanChangeId)) {
25
+ yield* Effect.fail(new Error(`Invalid change ID: ${cleanChangeId}`))
26
+ }
27
+
28
+ // Fetch change details to get the project name
29
+ const change = yield* gerritApi.getChange(cleanChangeId)
30
+
31
+ // Get the Gerrit host from config
32
+ const credentials = yield* configService.getCredentials
33
+ const gerritHost = credentials.host
34
+
35
+ const changeUrl = `${gerritHost}/c/${change.project}/+/${change._number}`
36
+
37
+ // Sanitize URL for shell safety
38
+ const safeUrl = yield* sanitizeUrl(changeUrl)
39
+
40
+ // Open the URL in the default browser
41
+ const openCmd = getOpenCommand()
42
+
43
+ yield* Effect.promise(
44
+ () =>
45
+ new Promise<void>((resolve, reject) => {
46
+ exec(`${openCmd} "${safeUrl}"`, (error) => {
47
+ if (error) {
48
+ reject(new Error(`Failed to open URL: ${error.message}`))
49
+ } else {
50
+ resolve()
51
+ }
52
+ })
53
+ }),
54
+ )
55
+
56
+ console.log(`Opened: ${changeUrl}`)
57
+ })