@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,206 @@
1
+ import * as fs from 'node:fs'
2
+ import * as os from 'node:os'
3
+ import * as path from 'node:path'
4
+ import { Schema } from '@effect/schema'
5
+ import { Context, Effect, Layer } from 'effect'
6
+ import { GerritCredentials } from '@/schemas/gerrit'
7
+ import { AiConfig, AppConfig, aiConfigFromFlat, migrateFromNestedConfig } from '@/schemas/config'
8
+
9
+ export interface ConfigServiceImpl {
10
+ readonly getCredentials: Effect.Effect<GerritCredentials, ConfigError>
11
+ readonly saveCredentials: (credentials: GerritCredentials) => Effect.Effect<void, ConfigError>
12
+ readonly deleteCredentials: Effect.Effect<void, ConfigError>
13
+ readonly getAiConfig: Effect.Effect<AiConfig, ConfigError>
14
+ readonly saveAiConfig: (config: AiConfig) => Effect.Effect<void, ConfigError>
15
+ readonly getFullConfig: Effect.Effect<AppConfig, ConfigError>
16
+ readonly saveFullConfig: (config: AppConfig) => Effect.Effect<void, ConfigError>
17
+ }
18
+
19
+ export class ConfigService extends Context.Tag('ConfigService')<
20
+ ConfigService,
21
+ ConfigServiceImpl
22
+ >() {}
23
+
24
+ export class ConfigError extends Schema.TaggedError<ConfigError>()('ConfigError', {
25
+ message: Schema.String,
26
+ } as const) {}
27
+
28
+ // File-based storage
29
+ const CONFIG_DIR = path.join(os.homedir(), '.ger')
30
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
31
+
32
+ const readFileConfig = (): unknown | null => {
33
+ try {
34
+ if (fs.existsSync(CONFIG_FILE)) {
35
+ const content = fs.readFileSync(CONFIG_FILE, 'utf8')
36
+ const parsed = JSON.parse(content)
37
+
38
+ // Check if this is the old nested format and migrate if needed
39
+ if (parsed && typeof parsed === 'object' && 'credentials' in parsed) {
40
+ // Migrate from nested format to flat format with validation
41
+ const migrated = migrateFromNestedConfig(parsed)
42
+
43
+ // Save the migrated config immediately
44
+ try {
45
+ writeFileConfig(migrated)
46
+ } catch (error) {
47
+ // Log migration write failure but continue to return migrated config
48
+ console.warn('Warning: Failed to save migrated config to disk:', error)
49
+ // Config migration succeeded in memory, user can still proceed
50
+ }
51
+
52
+ return migrated
53
+ }
54
+
55
+ return parsed
56
+ }
57
+ } catch {
58
+ // Ignore errors
59
+ }
60
+ return null
61
+ }
62
+
63
+ const writeFileConfig = (config: AppConfig): void => {
64
+ // Ensure config directory exists
65
+ if (!fs.existsSync(CONFIG_DIR)) {
66
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 })
67
+ }
68
+
69
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8')
70
+ // Set restrictive permissions
71
+ fs.chmodSync(CONFIG_FILE, 0o600)
72
+ }
73
+
74
+ const deleteFileConfig = (): void => {
75
+ try {
76
+ if (fs.existsSync(CONFIG_FILE)) {
77
+ fs.unlinkSync(CONFIG_FILE)
78
+ }
79
+ } catch {
80
+ // Ignore errors
81
+ }
82
+ }
83
+
84
+ export const ConfigServiceLive: Layer.Layer<ConfigService, never, never> = Layer.effect(
85
+ ConfigService,
86
+ Effect.sync(() => {
87
+ const getFullConfig = Effect.gen(function* () {
88
+ const fileContent = readFileConfig()
89
+ if (!fileContent) {
90
+ return yield* Effect.fail(
91
+ new ConfigError({
92
+ message: 'Configuration not found. Run "ger setup" to set up your credentials.',
93
+ }),
94
+ )
95
+ }
96
+
97
+ // Parse as flat config
98
+ const fullConfigResult = yield* Schema.decodeUnknown(AppConfig)(fileContent).pipe(
99
+ Effect.mapError(() => new ConfigError({ message: 'Invalid configuration format' })),
100
+ )
101
+
102
+ return fullConfigResult
103
+ })
104
+
105
+ const saveFullConfig = (config: AppConfig) =>
106
+ Effect.gen(function* () {
107
+ // Validate config using schema
108
+ const validatedConfig = yield* Schema.decodeUnknown(AppConfig)(config).pipe(
109
+ Effect.mapError(() => new ConfigError({ message: 'Invalid configuration format' })),
110
+ )
111
+
112
+ try {
113
+ writeFileConfig(validatedConfig)
114
+ } catch {
115
+ yield* Effect.fail(new ConfigError({ message: 'Failed to save configuration to file' }))
116
+ }
117
+ })
118
+
119
+ const getCredentials = Effect.gen(function* () {
120
+ const config = yield* getFullConfig
121
+ return {
122
+ host: config.host,
123
+ username: config.username,
124
+ password: config.password,
125
+ }
126
+ })
127
+
128
+ const saveCredentials = (credentials: GerritCredentials) =>
129
+ Effect.gen(function* () {
130
+ // Validate credentials using schema
131
+ const validatedCredentials = yield* Schema.decodeUnknown(GerritCredentials)(
132
+ credentials,
133
+ ).pipe(Effect.mapError(() => new ConfigError({ message: 'Invalid credentials format' })))
134
+
135
+ // Get existing config or create new one
136
+ const existingConfig = yield* getFullConfig.pipe(
137
+ Effect.orElseSucceed(() => {
138
+ // Create default config using Schema validation instead of type assertion
139
+ const defaultConfig = {
140
+ host: validatedCredentials.host,
141
+ username: validatedCredentials.username,
142
+ password: validatedCredentials.password,
143
+ aiAutoDetect: true,
144
+ }
145
+ // Validate the default config structure
146
+ return Schema.decodeUnknownSync(AppConfig)(defaultConfig)
147
+ }),
148
+ )
149
+
150
+ // Update credentials in flat config
151
+ const updatedConfig: AppConfig = {
152
+ ...existingConfig,
153
+ host: validatedCredentials.host,
154
+ username: validatedCredentials.username,
155
+ password: validatedCredentials.password,
156
+ }
157
+
158
+ yield* saveFullConfig(updatedConfig)
159
+ })
160
+
161
+ const deleteCredentials = Effect.gen(function* () {
162
+ try {
163
+ deleteFileConfig()
164
+ yield* Effect.void
165
+ } catch {
166
+ // Ignore errors
167
+ yield* Effect.void
168
+ }
169
+ })
170
+
171
+ const getAiConfig = Effect.gen(function* () {
172
+ const config = yield* getFullConfig
173
+ return aiConfigFromFlat(config)
174
+ })
175
+
176
+ const saveAiConfig = (aiConfig: AiConfig) =>
177
+ Effect.gen(function* () {
178
+ // Validate AI config using schema
179
+ const validatedAiConfig = yield* Schema.decodeUnknown(AiConfig)(aiConfig).pipe(
180
+ Effect.mapError(() => new ConfigError({ message: 'Invalid AI configuration format' })),
181
+ )
182
+
183
+ // Get existing config
184
+ const existingConfig = yield* getFullConfig
185
+
186
+ // Update AI config in flat structure
187
+ const updatedConfig: AppConfig = {
188
+ ...existingConfig,
189
+ aiTool: validatedAiConfig.tool,
190
+ aiAutoDetect: validatedAiConfig.autoDetect,
191
+ }
192
+
193
+ yield* saveFullConfig(updatedConfig)
194
+ })
195
+
196
+ return {
197
+ getCredentials,
198
+ saveCredentials,
199
+ deleteCredentials,
200
+ getAiConfig,
201
+ saveAiConfig,
202
+ getFullConfig,
203
+ saveFullConfig,
204
+ }
205
+ }),
206
+ )
@@ -0,0 +1,73 @@
1
+ import type { Schema } from '@effect/schema'
2
+ import type { ChangeInfo, FileDiffContent, FileInfo } from '@/schemas/gerrit'
3
+
4
+ export const generateMockChange = (
5
+ overrides?: Partial<Schema.Schema.Type<typeof ChangeInfo>>,
6
+ ): Schema.Schema.Type<typeof ChangeInfo> => {
7
+ const base: Schema.Schema.Type<typeof ChangeInfo> = {
8
+ id: 'myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940',
9
+ project: 'myProject',
10
+ branch: 'master',
11
+ change_id: 'I8473b95934b5732ac55d26311a706c9c2bde9940',
12
+ subject: 'Implementing new feature',
13
+ status: 'NEW' as const,
14
+ created: '2023-12-01 10:00:00.000000000',
15
+ updated: '2023-12-01 15:30:00.000000000',
16
+ insertions: 25,
17
+ deletions: 3,
18
+ _number: 12345,
19
+ owner: {
20
+ _account_id: 1000096,
21
+ name: 'John Developer',
22
+ email: 'john@example.com',
23
+ username: 'jdeveloper',
24
+ },
25
+ }
26
+
27
+ return { ...base, ...overrides }
28
+ }
29
+
30
+ export const generateMockFiles = (): Record<string, Schema.Schema.Type<typeof FileInfo>> => {
31
+ return {
32
+ 'src/main.ts': {
33
+ status: 'M' as const,
34
+ lines_inserted: 15,
35
+ lines_deleted: 3,
36
+ size_delta: 120,
37
+ size: 1200,
38
+ },
39
+ 'tests/main.test.ts': {
40
+ status: 'A' as const,
41
+ lines_inserted: 45,
42
+ lines_deleted: 0,
43
+ size_delta: 450,
44
+ size: 450,
45
+ },
46
+ }
47
+ }
48
+
49
+ export const generateMockFileDiff = (): Schema.Schema.Type<typeof FileDiffContent> => {
50
+ return {
51
+ content: [
52
+ {
53
+ ab: ['function main() {', ' console.log("Hello, world!")'],
54
+ },
55
+ {
56
+ a: [' return 0'],
57
+ b: [' return process.exit(0)'],
58
+ },
59
+ {
60
+ ab: ['}'],
61
+ },
62
+ ],
63
+ change_type: 'MODIFIED' as const,
64
+ diff_header: ['--- a/src/main.ts', '+++ b/src/main.ts'],
65
+ }
66
+ }
67
+
68
+ export const generateMockAccount = () => ({
69
+ _account_id: 1000096,
70
+ name: 'Test User',
71
+ email: 'test@example.com',
72
+ username: 'testuser',
73
+ })
@@ -0,0 +1,153 @@
1
+ import type { CommentInfo } from '@/schemas/gerrit'
2
+ import { colors, formatDate } from './formatters'
3
+
4
+ export interface CommentWithContext {
5
+ comment: CommentInfo
6
+ context?: {
7
+ before: string[]
8
+ line?: string
9
+ after: string[]
10
+ }
11
+ }
12
+
13
+ export const formatCommentsPretty = (comments: CommentWithContext[]): void => {
14
+ if (comments.length === 0) {
15
+ console.log('No comments found on this change')
16
+ return
17
+ }
18
+
19
+ console.log(`Found ${comments.length} comment${comments.length === 1 ? '' : 's'}:\n`)
20
+
21
+ let currentPath: string | undefined
22
+
23
+ for (const { comment, context } of comments) {
24
+ // Group by file
25
+ if (comment.path !== currentPath) {
26
+ currentPath = comment.path
27
+ console.log(`${colors.blue}═══ ${currentPath} ═══${colors.reset}`)
28
+ }
29
+
30
+ // Comment metadata
31
+ const author = comment.author?.name || 'Unknown'
32
+ const date = comment.updated ? formatDate(comment.updated) : ''
33
+ const status = comment.unresolved ? `${colors.yellow}[UNRESOLVED]${colors.reset} ` : ''
34
+
35
+ console.log(`\n${status}${colors.dim}${author} • ${date}${colors.reset}`)
36
+
37
+ if (comment.line) {
38
+ console.log(`${colors.dim}Line ${comment.line}:${colors.reset}`)
39
+
40
+ // Show context if available
41
+ if (context && (context.before.length > 0 || context.line || context.after.length > 0)) {
42
+ console.log(`${colors.dim}───────────────────${colors.reset}`)
43
+ for (const line of context.before) {
44
+ console.log(`${colors.dim} ${line}${colors.reset}`)
45
+ }
46
+ if (context.line) {
47
+ console.log(`${colors.green}> ${context.line}${colors.reset}`)
48
+ }
49
+ for (const line of context.after) {
50
+ console.log(`${colors.dim} ${line}${colors.reset}`)
51
+ }
52
+ console.log(`${colors.dim}───────────────────${colors.reset}`)
53
+ }
54
+ }
55
+
56
+ // Comment message (indent each line)
57
+ const messageLines = comment.message.split('\n')
58
+ for (const line of messageLines) {
59
+ console.log(` ${line}`)
60
+ }
61
+ }
62
+ }
63
+
64
+ // Escape special XML characters to prevent XSS
65
+ const escapeXml = (str: string): string => {
66
+ return str
67
+ .replace(/&/g, '&amp;')
68
+ .replace(/</g, '&lt;')
69
+ .replace(/>/g, '&gt;')
70
+ .replace(/"/g, '&quot;')
71
+ .replace(/'/g, '&apos;')
72
+ }
73
+
74
+ export const formatCommentsXml = (changeId: string, comments: CommentWithContext[]): void => {
75
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
76
+ console.log(`<comments_result>`)
77
+ console.log(` <change_id>${escapeXml(changeId)}</change_id>`)
78
+ console.log(` <comment_count>${comments.length}</comment_count>`)
79
+ console.log(` <comments>`)
80
+
81
+ for (const { comment, context } of comments) {
82
+ console.log(` <comment>`)
83
+ console.log(` <id>${escapeXml(comment.id)}</id>`)
84
+ if (comment.path) {
85
+ console.log(` <path><![CDATA[${comment.path}]]></path>`)
86
+ }
87
+ if (comment.line) {
88
+ console.log(` <line>${comment.line}</line>`)
89
+ }
90
+ if (comment.range) {
91
+ console.log(` <range>`)
92
+ console.log(` <start_line>${comment.range.start_line}</start_line>`)
93
+ console.log(` <end_line>${comment.range.end_line}</end_line>`)
94
+ if (comment.range.start_character !== undefined) {
95
+ console.log(` <start_character>${comment.range.start_character}</start_character>`)
96
+ }
97
+ if (comment.range.end_character !== undefined) {
98
+ console.log(` <end_character>${comment.range.end_character}</end_character>`)
99
+ }
100
+ console.log(` </range>`)
101
+ }
102
+ if (comment.author) {
103
+ console.log(` <author>`)
104
+ if (comment.author.name) {
105
+ console.log(` <name><![CDATA[${comment.author.name}]]></name>`)
106
+ }
107
+ if (comment.author.email) {
108
+ console.log(` <email>${escapeXml(comment.author.email)}</email>`)
109
+ }
110
+ if (comment.author._account_id !== undefined) {
111
+ console.log(` <account_id>${comment.author._account_id}</account_id>`)
112
+ }
113
+ console.log(` </author>`)
114
+ }
115
+ if (comment.updated) {
116
+ console.log(` <updated>${escapeXml(comment.updated)}</updated>`)
117
+ }
118
+ if (comment.unresolved !== undefined) {
119
+ console.log(` <unresolved>${comment.unresolved}</unresolved>`)
120
+ }
121
+ if (comment.in_reply_to) {
122
+ console.log(` <in_reply_to>${escapeXml(comment.in_reply_to)}</in_reply_to>`)
123
+ }
124
+ console.log(` <message><![CDATA[${comment.message}]]></message>`)
125
+
126
+ if (context && (context.before.length > 0 || context.line || context.after.length > 0)) {
127
+ console.log(` <diff_context>`)
128
+ if (context.before.length > 0) {
129
+ console.log(` <before>`)
130
+ for (const line of context.before) {
131
+ console.log(` <line><![CDATA[${line}]]></line>`)
132
+ }
133
+ console.log(` </before>`)
134
+ }
135
+ if (context.line) {
136
+ console.log(` <target_line><![CDATA[${context.line}]]></target_line>`)
137
+ }
138
+ if (context.after.length > 0) {
139
+ console.log(` <after>`)
140
+ for (const line of context.after) {
141
+ console.log(` <line><![CDATA[${line}]]></line>`)
142
+ }
143
+ console.log(` </after>`)
144
+ }
145
+ console.log(` </diff_context>`)
146
+ }
147
+
148
+ console.log(` </comment>`)
149
+ }
150
+
151
+ console.log(` </comments>`)
152
+ console.log(`</comments_result>`)
153
+ }
@@ -0,0 +1,103 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+ import type { FileDiffContent } from '@/schemas/gerrit'
4
+
5
+ export interface DiffContext {
6
+ before: string[]
7
+ line?: string
8
+ after: string[]
9
+ }
10
+
11
+ /**
12
+ * Extracts context around a specific line number from a diff.
13
+ * This is a more accurate implementation that properly tracks line numbers
14
+ * across different diff sections.
15
+ */
16
+ export const extractDiffContext = (
17
+ diff: FileDiffContent,
18
+ targetLine: number,
19
+ contextLines: number = 2,
20
+ ): DiffContext => {
21
+ const context: DiffContext = {
22
+ before: [],
23
+ after: [],
24
+ }
25
+
26
+ let currentNewLine = 1
27
+ const _foundTarget = false
28
+ const collectedLines: Array<{ line: string; lineNum: number; type: 'context' | 'added' }> = []
29
+
30
+ for (const section of diff.content) {
31
+ // Context lines (present in both old and new)
32
+ if (section.ab) {
33
+ for (const line of section.ab) {
34
+ collectedLines.push({ line, lineNum: currentNewLine, type: 'context' })
35
+ currentNewLine++
36
+ }
37
+ }
38
+
39
+ // Added lines (only in new file)
40
+ if (section.b) {
41
+ for (const line of section.b) {
42
+ collectedLines.push({ line, lineNum: currentNewLine, type: 'added' })
43
+ currentNewLine++
44
+ }
45
+ }
46
+
47
+ // Skip lines (large unchanged sections)
48
+ if (section.skip) {
49
+ // If target is in skipped section, we can't show context
50
+ if (currentNewLine <= targetLine && targetLine < currentNewLine + section.skip) {
51
+ return context // Return empty context
52
+ }
53
+ currentNewLine += section.skip
54
+ }
55
+
56
+ // Removed lines don't affect new file line numbers
57
+ // section.a is ignored for line counting
58
+ }
59
+
60
+ // Find the target line and extract context
61
+ const targetIndex = collectedLines.findIndex((item) => item.lineNum === targetLine)
62
+ if (targetIndex !== -1) {
63
+ // Get before context
64
+ for (let i = Math.max(0, targetIndex - contextLines); i < targetIndex; i++) {
65
+ context.before.push(collectedLines[i].line)
66
+ }
67
+
68
+ // Get target line
69
+ context.line = collectedLines[targetIndex].line
70
+
71
+ // Get after context
72
+ for (
73
+ let i = targetIndex + 1;
74
+ i < Math.min(collectedLines.length, targetIndex + contextLines + 1);
75
+ i++
76
+ ) {
77
+ context.after.push(collectedLines[i].line)
78
+ }
79
+ }
80
+
81
+ return context
82
+ }
83
+
84
+ export const getDiffContext = (
85
+ changeId: string,
86
+ path: string,
87
+ line?: number,
88
+ ): Effect.Effect<DiffContext, ApiError, GerritApiService> =>
89
+ Effect.gen(function* () {
90
+ if (!line || path === 'Commit Message' || path === '/COMMIT_MSG') {
91
+ return { before: [], after: [] }
92
+ }
93
+
94
+ const gerritApi = yield* GerritApiService
95
+
96
+ try {
97
+ const diff = yield* gerritApi.getFileDiff(changeId, path)
98
+ return extractDiffContext(diff, line)
99
+ } catch {
100
+ // Return empty context on error
101
+ return { before: [], after: [] }
102
+ }
103
+ })
@@ -0,0 +1,141 @@
1
+ import { colors } from './formatters'
2
+
3
+ interface DiffStats {
4
+ additions: number
5
+ deletions: number
6
+ files: number
7
+ }
8
+
9
+ /**
10
+ * Format a unified diff for pretty human-readable output
11
+ */
12
+ export const formatDiffPretty = (diffContent: string): string => {
13
+ if (!diffContent || typeof diffContent !== 'string') {
14
+ const emptyStats = { additions: 0, deletions: 0, files: 0 }
15
+ return formatDiffSummary(emptyStats) + '\n\n' + 'No diff content available'
16
+ }
17
+
18
+ const lines = diffContent.split('\n')
19
+ const formattedLines: string[] = []
20
+ let stats: DiffStats = { additions: 0, deletions: 0, files: 0 }
21
+
22
+ for (const line of lines) {
23
+ if (line.startsWith('diff --git')) {
24
+ stats.files++
25
+ // File header with colors
26
+ formattedLines.push(`${colors.bold}${colors.blue}${line}${colors.reset}`)
27
+ } else if (line.startsWith('index ')) {
28
+ // Index line
29
+ formattedLines.push(`${colors.dim}${line}${colors.reset}`)
30
+ } else if (line.startsWith('---')) {
31
+ // Old file marker
32
+ formattedLines.push(`${colors.red}${line}${colors.reset}`)
33
+ } else if (line.startsWith('+++')) {
34
+ // New file marker
35
+ formattedLines.push(`${colors.green}${line}${colors.reset}`)
36
+ } else if (line.startsWith('@@')) {
37
+ // Hunk header
38
+ formattedLines.push(`${colors.cyan}${line}${colors.reset}`)
39
+ } else if (line.startsWith('+') && !line.startsWith('+++')) {
40
+ // Added lines
41
+ stats.additions++
42
+ formattedLines.push(`${colors.green}${line}${colors.reset}`)
43
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
44
+ // Removed lines
45
+ stats.deletions++
46
+ formattedLines.push(`${colors.red}${line}${colors.reset}`)
47
+ } else if (line.startsWith(' ')) {
48
+ // Context lines
49
+ formattedLines.push(`${colors.dim}${line}${colors.reset}`)
50
+ } else {
51
+ // Other lines (usually empty or metadata)
52
+ formattedLines.push(line)
53
+ }
54
+ }
55
+
56
+ // Add summary at the top
57
+ const summary = formatDiffSummary(stats)
58
+
59
+ return summary + '\n\n' + formattedLines.join('\n')
60
+ }
61
+
62
+ /**
63
+ * Format diff summary statistics
64
+ */
65
+ export const formatDiffSummary = (stats: DiffStats): string => {
66
+ const { additions, deletions, files } = stats
67
+ const total = additions + deletions
68
+
69
+ let summary = `${colors.bold}Changes summary:${colors.reset} `
70
+
71
+ if (files > 0) {
72
+ summary += `${files} file${files !== 1 ? 's' : ''} changed`
73
+ }
74
+
75
+ if (additions > 0 || deletions > 0) {
76
+ if (files > 0) summary += ', '
77
+
78
+ if (additions > 0) {
79
+ summary += `${colors.green}+${additions} addition${additions !== 1 ? 's' : ''}${colors.reset}`
80
+ }
81
+
82
+ if (additions > 0 && deletions > 0) {
83
+ summary += ', '
84
+ }
85
+
86
+ if (deletions > 0) {
87
+ summary += `${colors.red}-${deletions} deletion${deletions !== 1 ? 's' : ''}${colors.reset}`
88
+ }
89
+ }
90
+
91
+ if (total === 0 && files === 0) {
92
+ summary += 'No changes detected'
93
+ }
94
+
95
+ return summary
96
+ }
97
+
98
+ /**
99
+ * Format a list of changed files for pretty output
100
+ */
101
+ export const formatFilesList = (files: string[]): string => {
102
+ if (!files || files.length === 0) {
103
+ return 'No files changed'
104
+ }
105
+
106
+ const header = `${colors.bold}Changed files (${files.length}):${colors.reset}\n`
107
+ const fileList = files
108
+ .map((file) => {
109
+ // Simple file status indicators - we could enhance this if we had status info
110
+ return ` ${colors.blue}•${colors.reset} ${file}`
111
+ })
112
+ .join('\n')
113
+
114
+ return header + fileList
115
+ }
116
+
117
+ /**
118
+ * Extract diff statistics from unified diff content
119
+ */
120
+ export const extractDiffStats = (diffContent: string): DiffStats => {
121
+ if (!diffContent || typeof diffContent !== 'string') {
122
+ return { additions: 0, deletions: 0, files: 0 }
123
+ }
124
+
125
+ const lines = diffContent.split('\n')
126
+ let additions = 0
127
+ let deletions = 0
128
+ let files = 0
129
+
130
+ for (const line of lines) {
131
+ if (line.startsWith('diff --git')) {
132
+ files++
133
+ } else if (line.startsWith('+') && !line.startsWith('+++')) {
134
+ additions++
135
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
136
+ deletions++
137
+ }
138
+ }
139
+
140
+ return { additions, deletions, files }
141
+ }